Compare commits
36 Commits
a58142cc4a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 58ab81469b | |||
| fec9ec386f | |||
| ec72ee4fca | |||
| 54ab2f2f05 | |||
| 214b617be0 | |||
| 2679c9d66e | |||
| 1ec4d07939 | |||
| 3d773a4a62 | |||
| b2ecdad53b | |||
| a2fe61b1f6 | |||
| d3bbc0c0a0 | |||
| 8163665c86 | |||
| 6d9cd89cc5 | |||
| 22359be40e | |||
| 34c110be6a | |||
| 38b4a67db1 | |||
| 7695cb5420 | |||
| 6ba77eebcf | |||
| f2e8195d1d | |||
| ba82a5d785 | |||
| 1572b3ce66 | |||
| abfe858909 | |||
| e05e5e54ca | |||
| 7e66d1edf3 | |||
| d4559920d1 | |||
| c97152fa9e | |||
| 5ed2ef0958 | |||
| ee5bd0a291 | |||
| 8d1855b89c | |||
| 837f0fa63f | |||
| a87bc49393 | |||
| 9c16b462f4 | |||
| bd14eb2daa | |||
| f89c3de826 | |||
| 985a23751e | |||
| f7974dd382 |
@@ -0,0 +1,291 @@
|
|||||||
|
name: Build and Deploy Platform
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Serialize builds so parallel pushes don't race on the GitOps values update.
|
||||||
|
concurrency:
|
||||||
|
group: build-deploy-platform
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
# PULL_REGISTRY: kubelet pulls via k3s mirror → harbor-core (matches registry-pull-secret)
|
||||||
|
PULL_REGISTRY: registry.abrban.com
|
||||||
|
# PUSH_REGISTRY: kaniko pushes via harbor-core (Harbor UI metadata + blob storage)
|
||||||
|
PUSH_REGISTRY: harbor-core.cloudhost.svc.cluster.local
|
||||||
|
PROJECT: abrban
|
||||||
|
BUILD_NS: cloudhost-builds
|
||||||
|
GITEA_HOST: gitea-http.gitea.svc.cluster.local:3000
|
||||||
|
# PAT of the "ci" user, stored as repo secret CI_TOKEN (names starting with GITEA_ are reserved)
|
||||||
|
GITEA_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
REPO_PATH: abrban/cloud-host.git
|
||||||
|
GITOPS_REPO_PATH: abrban/cloud-host-gitops.git
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: abrban-builder
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 --branch main "http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH}" workspace
|
||||||
|
cd workspace
|
||||||
|
echo "Checked out $(git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
- name: Set image tag
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
cd workspace
|
||||||
|
SHA="$(git rev-parse --short HEAD)"
|
||||||
|
TAG="$(date +%Y%m%d-%H%M)-${SHA}"
|
||||||
|
echo "IMAGE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
|
echo "Build tag: ${TAG}"
|
||||||
|
|
||||||
|
- name: Define job waiter
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
# wait_for_job <name>: exit 0 on Complete, exit 1 (with kaniko logs) on Failed/timeout
|
||||||
|
cat > wait_for_job.sh <<'ENDSCRIPT'
|
||||||
|
#!/bin/sh
|
||||||
|
JOB="$1"
|
||||||
|
DEADLINE=$(( $(date +%s) + 2400 ))
|
||||||
|
while :; do
|
||||||
|
CONDS="$(kubectl -n ${BUILD_NS} get job/${JOB} -o jsonpath='{range .status.conditions[*]}{.type}={.status} {end}' 2>/dev/null)"
|
||||||
|
case "$CONDS" in
|
||||||
|
*Complete=True*) echo "Job ${JOB} completed"; exit 0 ;;
|
||||||
|
*Failed=True*)
|
||||||
|
echo "Job ${JOB} FAILED — kaniko logs:"
|
||||||
|
kubectl -n ${BUILD_NS} logs job/${JOB} -c kaniko --tail=100 || true
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
|
||||||
|
echo "Timed out waiting for job ${JOB} — kaniko logs:"
|
||||||
|
kubectl -n ${BUILD_NS} logs job/${JOB} -c kaniko --tail=100 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
ENDSCRIPT
|
||||||
|
chmod +x wait_for_job.sh
|
||||||
|
|
||||||
|
- name: Run backend tests (Job)
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
JOB_NAME="test-be-$(echo $IMAGE_TAG | tr '.:' '-' | cut -c1-50)"
|
||||||
|
cat <<ENDJOB | kubectl apply -f -
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: ${JOB_NAME}
|
||||||
|
namespace: ${BUILD_NS}
|
||||||
|
spec:
|
||||||
|
ttlSecondsAfterFinished: 3600
|
||||||
|
backoffLimit: 0
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
|
containers:
|
||||||
|
- name: test
|
||||||
|
image: ${PULL_REGISTRY}/${PROJECT}/node:24-alpine
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: registry-egress-proxy
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
apk add --no-cache git &&
|
||||||
|
git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace &&
|
||||||
|
cd /workspace/backend &&
|
||||||
|
npm config set fetch-retries 5 &&
|
||||||
|
npm config set fetch-retry-mintimeout 20000 &&
|
||||||
|
npm config set fetch-retry-maxtimeout 120000 &&
|
||||||
|
(npm ci --legacy-peer-deps || (echo 'npm ci failed, retrying...' && sleep 5 && npm ci --legacy-peer-deps) || (echo 'npm ci failed again, retrying...' && sleep 10 && npm ci --legacy-peer-deps)) &&
|
||||||
|
npm run test -- --ci --runInBand
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 500m, memory: 1Gi }
|
||||||
|
limits: { cpu: "2", memory: 3Gi }
|
||||||
|
ENDJOB
|
||||||
|
echo "Waiting for backend test job: ${JOB_NAME}"
|
||||||
|
# Reuse the waiter but read logs from the "test" container on failure
|
||||||
|
DEADLINE=$(( $(date +%s) + 1800 ))
|
||||||
|
while :; do
|
||||||
|
CONDS="$(kubectl -n ${BUILD_NS} get job/${JOB_NAME} -o jsonpath='{range .status.conditions[*]}{.type}={.status} {end}' 2>/dev/null)"
|
||||||
|
case "$CONDS" in
|
||||||
|
*Complete=True*) echo "Tests passed"; break ;;
|
||||||
|
*Failed=True*)
|
||||||
|
echo "Tests FAILED — logs:"
|
||||||
|
kubectl -n ${BUILD_NS} logs job/${JOB_NAME} -c test --tail=200 || true
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
|
||||||
|
echo "Timed out waiting for tests — logs:"
|
||||||
|
kubectl -n ${BUILD_NS} logs job/${JOB_NAME} -c test --tail=200 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Build backend image (Kaniko Job)
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
JOB_NAME="build-be-$(echo $IMAGE_TAG | tr '.:' '-' | cut -c1-50)"
|
||||||
|
cat <<ENDJOB | kubectl apply -f -
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: ${JOB_NAME}
|
||||||
|
namespace: ${BUILD_NS}
|
||||||
|
spec:
|
||||||
|
ttlSecondsAfterFinished: 3600
|
||||||
|
backoffLimit: 2
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
|
initContainers:
|
||||||
|
- name: clone
|
||||||
|
# alpine/git ships git — no flaky apk install at build time
|
||||||
|
image: ${PULL_REGISTRY}/${PROJECT}/alpine-git:2.43.0
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace
|
||||||
|
volumeMounts:
|
||||||
|
- name: ws
|
||||||
|
mountPath: /workspace
|
||||||
|
containers:
|
||||||
|
- name: kaniko
|
||||||
|
image: ${PULL_REGISTRY}/${PROJECT}/kaniko-executor:v1.27.6-debug
|
||||||
|
# Base image (node:24-alpine) is seeded in Harbor abrban/ — avoids
|
||||||
|
# flaky direct pulls from docker.io through the egress proxy.
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: registry-egress-proxy
|
||||||
|
args:
|
||||||
|
- --dockerfile=/workspace/backend/Dockerfile
|
||||||
|
- --context=dir:///workspace/backend
|
||||||
|
- --build-arg=BASE_IMAGE=${PUSH_REGISTRY}/${PROJECT}/node:24-alpine
|
||||||
|
- --destination=${PUSH_REGISTRY}/${PROJECT}/cloudhost-backend:${IMAGE_TAG}
|
||||||
|
- --insecure
|
||||||
|
- --insecure-pull
|
||||||
|
- --insecure-registry=${PUSH_REGISTRY}
|
||||||
|
- --skip-tls-verify
|
||||||
|
- --push-retry=2
|
||||||
|
volumeMounts:
|
||||||
|
- name: ws
|
||||||
|
mountPath: /workspace
|
||||||
|
- name: docker-config
|
||||||
|
mountPath: /kaniko/.docker
|
||||||
|
volumes:
|
||||||
|
- name: ws
|
||||||
|
emptyDir: {}
|
||||||
|
- name: docker-config
|
||||||
|
secret:
|
||||||
|
secretName: kaniko-harbor-auth
|
||||||
|
items:
|
||||||
|
- key: .dockerconfigjson
|
||||||
|
path: config.json
|
||||||
|
ENDJOB
|
||||||
|
echo "Waiting for backend build job: ${JOB_NAME}"
|
||||||
|
./wait_for_job.sh "${JOB_NAME}"
|
||||||
|
echo "Backend build done"
|
||||||
|
|
||||||
|
- name: Build frontend image (Kaniko Job)
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
JOB_NAME="build-fe-$(echo $IMAGE_TAG | tr '.:' '-' | cut -c1-50)"
|
||||||
|
cat <<ENDJOB | kubectl apply -f -
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: ${JOB_NAME}
|
||||||
|
namespace: ${BUILD_NS}
|
||||||
|
spec:
|
||||||
|
ttlSecondsAfterFinished: 3600
|
||||||
|
backoffLimit: 2
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
|
initContainers:
|
||||||
|
- name: clone
|
||||||
|
image: ${PULL_REGISTRY}/${PROJECT}/alpine-git:2.43.0
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace
|
||||||
|
volumeMounts:
|
||||||
|
- name: ws
|
||||||
|
mountPath: /workspace
|
||||||
|
containers:
|
||||||
|
- name: kaniko
|
||||||
|
image: ${PULL_REGISTRY}/${PROJECT}/kaniko-executor:v1.27.6-debug
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: registry-egress-proxy
|
||||||
|
args:
|
||||||
|
- --dockerfile=/workspace/frontend/Dockerfile
|
||||||
|
- --context=dir:///workspace/frontend
|
||||||
|
- --build-arg=BASE_IMAGE=${PUSH_REGISTRY}/${PROJECT}/node:24-alpine
|
||||||
|
- --build-arg=NEXT_PUBLIC_API_URL=https://api.abrban.com
|
||||||
|
- --destination=${PUSH_REGISTRY}/${PROJECT}/cloudhost-frontend:${IMAGE_TAG}
|
||||||
|
- --insecure
|
||||||
|
- --insecure-pull
|
||||||
|
- --insecure-registry=${PUSH_REGISTRY}
|
||||||
|
- --skip-tls-verify
|
||||||
|
- --push-retry=2
|
||||||
|
volumeMounts:
|
||||||
|
- name: ws
|
||||||
|
mountPath: /workspace
|
||||||
|
- name: docker-config
|
||||||
|
mountPath: /kaniko/.docker
|
||||||
|
volumes:
|
||||||
|
- name: ws
|
||||||
|
emptyDir: {}
|
||||||
|
- name: docker-config
|
||||||
|
secret:
|
||||||
|
secretName: kaniko-harbor-auth
|
||||||
|
items:
|
||||||
|
- key: .dockerconfigjson
|
||||||
|
path: config.json
|
||||||
|
ENDJOB
|
||||||
|
echo "Waiting for frontend build job: ${JOB_NAME}"
|
||||||
|
./wait_for_job.sh "${JOB_NAME}"
|
||||||
|
echo "Frontend build done"
|
||||||
|
|
||||||
|
- name: Update GitOps repo and push
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 --branch main "http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${GITOPS_REPO_PATH}" gitops-repo
|
||||||
|
cd gitops-repo
|
||||||
|
VALUES=platform/values-abrban.yaml
|
||||||
|
if command -v yq >/dev/null 2>&1; then
|
||||||
|
IMAGE_TAG="${IMAGE_TAG}" yq -i '.images.backend.tag = strenv(IMAGE_TAG) | .images.frontend.tag = strenv(IMAGE_TAG)' "${VALUES}"
|
||||||
|
else
|
||||||
|
# Only touch the tag line directly below each cloudhost-* repository line.
|
||||||
|
sed -i "/repository: .*cloudhost-backend/{n;s|tag: \".*\"|tag: \"${IMAGE_TAG}\"|;}" "${VALUES}"
|
||||||
|
sed -i "/repository: .*cloudhost-frontend/{n;s|tag: \".*\"|tag: \"${IMAGE_TAG}\"|;}" "${VALUES}"
|
||||||
|
fi
|
||||||
|
git config user.email "ci@abrban.com"
|
||||||
|
git config user.name "Gitea Actions"
|
||||||
|
git add "${VALUES}"
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "ci: deploy platform ${IMAGE_TAG}"
|
||||||
|
# Retry with rebase — another pipeline may have pushed meanwhile.
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if git push origin HEAD:main; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Push rejected (attempt ${attempt}) — rebasing on latest main"
|
||||||
|
git pull --rebase origin main
|
||||||
|
[ "$attempt" = "3" ] && { echo "Giving up after 3 attempts"; exit 1; }
|
||||||
|
done
|
||||||
|
fi
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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
|
||||||
|
- name: Verify Helm migration ConfigMap is in sync
|
||||||
|
run: |
|
||||||
|
npm run sync:migrations
|
||||||
|
if ! git diff --quiet -- helm/cloudhost-platform/migrations; then
|
||||||
|
echo "::error::helm/cloudhost-platform/migrations is out of sync with backend/migrations. Run 'npm run sync:migrations' and commit."
|
||||||
|
git --no-pager diff --stat -- helm/cloudhost-platform/migrations
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend
|
||||||
|
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
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,172 @@
|
|||||||
|
# وضعیت رفع یافتههای گزارش ممیزی CloudHost
|
||||||
|
|
||||||
|
> مرجع: `AUDIT-REPORT.fa.pdf` / `scripts/audit-report.fa.html`
|
||||||
|
> آخرین بهروزرسانی: ۳ تیر ۱۴۰۴ (3 Jul 2026)
|
||||||
|
|
||||||
|
| نماد | معنی |
|
||||||
|
|------|------|
|
||||||
|
| ✅ | رفع شده |
|
||||||
|
| ⚠️ | جزئی / نیاز به پیکربندی محیط |
|
||||||
|
| 🔜 | عمداً به تعویق افتاده (اسکوپ بزرگ یا trade-off) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خلاصه
|
||||||
|
|
||||||
|
| دسته | تعداد | ✅ | ⚠️ | 🔜 |
|
||||||
|
|------|-------|----|----|-----|
|
||||||
|
| بلاکرهای پروداکشن | 8 | 8 | 0 | 0 |
|
||||||
|
| بیلد و Kaniko | 10 | 9 | 0 | 1 |
|
||||||
|
| دیپلوی و پیشنمایش | 6 | 6 | 0 | 0 |
|
||||||
|
| دیتابیس / سرویس اختیاری | 9 | 8 | 0 | 1 |
|
||||||
|
| Migration / اسکیما | 4 | 4 | 0 | 0 |
|
||||||
|
| بیلینگ و امنیت مالی | 5 | 5 | 0 | 0 |
|
||||||
|
| GitOps / CI-CD | 4 | 4 | 0 | 0 |
|
||||||
|
| امنیت اپ / auth | 6 | 3 | 0 | 3 |
|
||||||
|
| بهبود / زیرساخت | 5 | 2 | 1 | 2 |
|
||||||
|
|
||||||
|
**نتیجه:** همه بلاکرهای پروداکشن و تقریباً همه باگهای قطعی رفع شدهاند. موارد باقیمانده عمدتاً پیکربندی آینه رجیستری، JWT در localStorage، و پاکسازی PVC یتیم هستند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. بلاکرهای پروداکشن (اولویت ۱–۸)
|
||||||
|
|
||||||
|
| # | یافته | وضعیت | اقدام |
|
||||||
|
|---|--------|--------|-------|
|
||||||
|
| 1 | شارژ رایگان کیف پول (`POST /billing/wallet/charge`) | ✅ | HMAC + گارد production در `billing-wallet.controller.ts` |
|
||||||
|
| 2 | دیپلوی بدون پرداخت | ✅ | گارد بیلینگ در `triggerDeployment`, `startDeployment`, `PATCH resources` |
|
||||||
|
| 3 | namespace از ۸ کاراکتر UUID | ✅ | `userIdSlug` / `userNamespace` با UUID کامل |
|
||||||
|
| 4 | migration بدون ردیابی نسخه | ✅ | `schema_migrations` + `000_base_schema.sql` + pre-upgrade hook |
|
||||||
|
| 5 | `015` ستون `user_id` / `001` بدون گارد TYPE | ✅ | اصلاح نام ستون + `IF NOT EXISTS` |
|
||||||
|
| 6 | workflow Gitea بدون تست | ✅ | job تست + rebase در `.gitea/workflows/build-deploy.yaml` |
|
||||||
|
| 7 | رمز هاردکد Elasticsearch | ✅ | حذف از git + SealedSecret |
|
||||||
|
| 8 | COPY گو / `\|\| echo` Node | ✅ | اصلاح در `build.service.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. بیلد (Kaniko + Dockerfile)
|
||||||
|
|
||||||
|
| یافته | وضعیت | یادداشت |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| Go COPY نامعتبر | ✅ | |
|
||||||
|
| Node build failure نادیده | ✅ | `npm ci` + fail-on-build |
|
||||||
|
| Laravel extensions | ✅ | mbstring, xml, bcmath, zip, fileinfo, tokenizer |
|
||||||
|
| Python pyproject.toml | ✅ | تشخیص + نصب poetry/pdm |
|
||||||
|
| Kaniko 4Gi / PVC بدون SC | ✅ | limits قابل تنظیم + `BUILD_PVC_STORAGE_CLASS` |
|
||||||
|
| Git token در spec / branch injection / SSRF | ✅ | GIT_ASKPASS + Secret + validation |
|
||||||
|
| Zip slip در unzip | ✅ | اعتبارسنجی مسیر قبل و بعد از extract |
|
||||||
|
| state بیلد در حافظه | ✅ | Redis session + startup recovery |
|
||||||
|
| دیپلوی همزمان بدون قفل | ✅ | in-flight guard در `triggerDeployment` |
|
||||||
|
| Base image بدون آینه | ✅ | `build.images` در Helm values + پیشفرض Harbor در configuration |
|
||||||
|
| zip bomb (۱۰GiB) | 🔜 | سقف آپلود موجود؛ محدودیت تعداد entry در archive پیشنهاد میشود |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. دیپلوی و پیشنمایش
|
||||||
|
|
||||||
|
| یافته | وضعیت | یادداشت |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| حذف preview با custom domain pending | ✅ | `hasVerifiedCustomDomain()` در k8s + deployments |
|
||||||
|
| getPreviewInfo پچ NodePort | ✅ | فقط خواندن؛ ingressUrl اولویت دارد |
|
||||||
|
| fallback بینکلاستری → ImagePullBackOff | ✅ | `CLUSTER_DEPLOY_FALLBACK_ENABLED=true` برای fallback |
|
||||||
|
| NodePort host از API server | ⚠️ | `getClusterHostIp` همچنان fallback؛ ingressUrl مسیر اصلی |
|
||||||
|
| suspend NodePort revoke | ✅ | `deleteTemporaryAccessServicesForApp` در suspend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. دیتابیس و سرویسهای اختیاری
|
||||||
|
|
||||||
|
| یافته | وضعیت | یادداشت |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| Redis/RabbitMQ randAlphaNum | ✅ | الگوی `lookup` در helm templates |
|
||||||
|
| probe بدون auth | ✅ | redis-cli `-a` / mongo با credential |
|
||||||
|
| RWO بدون Recreate | ✅ | `strategy: Recreate` |
|
||||||
|
| dbPassword fallback هر deploy | ✅ | generate + persist در DB |
|
||||||
|
| Mongo snapshot/restore | ✅ | |
|
||||||
|
| WordPress MySQL اجباری | ✅ | |
|
||||||
|
| wp-content restore از PVC | ✅ | |
|
||||||
|
| PVC یتیم بعد از suspend/delete | 🔜 | نیاز به job پاکسازی دورهای |
|
||||||
|
| ایمیج DB از Docker Hub | ⚠️ | آینه در `values.yaml`؛ پیکربندی per-cluster |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. Migration / اسکیما
|
||||||
|
|
||||||
|
| یافته | وضعیت |
|
||||||
|
|--------|--------|
|
||||||
|
| Job دوباره همه SQL | ✅ |
|
||||||
|
| post-upgrade → pre-upgrade | ✅ |
|
||||||
|
| نبود base schema | ✅ |
|
||||||
|
| 015 user_id | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. بیلینگ
|
||||||
|
|
||||||
|
| یافته | وضعیت |
|
||||||
|
|--------|--------|
|
||||||
|
| wallet charge بدون درگاه | ✅ |
|
||||||
|
| proration اشتباه | ✅ |
|
||||||
|
| race در wallet | ✅ | pessimistic lock |
|
||||||
|
| auto-renew دو بار بین replicas | ✅ | lock روی Application در transaction |
|
||||||
|
| دیپلوی بدون پرداخت | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. GitOps / CI-CD
|
||||||
|
|
||||||
|
| یافته | وضعیت |
|
||||||
|
|--------|--------|
|
||||||
|
| workflow بدون تست | ✅ |
|
||||||
|
| elastic password در git | ✅ |
|
||||||
|
| platform Redis requirepass | ✅ |
|
||||||
|
| backend RollingUpdate + limits + postgres backup | ✅ |
|
||||||
|
| Swagger در production | ✅ |
|
||||||
|
| RUNBOOK-DEPLOY portable | ✅ | commit `6d9cd89` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸. امنیت اپلیکیشن
|
||||||
|
|
||||||
|
| یافته | وضعیت | یادداشت |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| gitToken / dbPassword در API | ✅ | `@Exclude` + `hasDbPassword` / `hasGitToken` |
|
||||||
|
| Elasticsearch log isolation | ✅ | namespace کامل |
|
||||||
|
| OTP Math.random | ✅ | `crypto.randomInt` |
|
||||||
|
| OTP consume race | ✅ | pessimistic lock در transaction |
|
||||||
|
| JWT در localStorage | 🔜 | نیاز به httpOnly cookie + CSRF — اسکوپ frontend بزرگ |
|
||||||
|
| refresh token rotation | 🔜 | |
|
||||||
|
| secret پیشفرض dev | ⚠️ | `validate-production-config` در production fail میکند |
|
||||||
|
| docker compose NODE_ENV=production | ✅ | `NODE_ENV: development` برای dev محلی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۹. بهبود / زیرساخت
|
||||||
|
|
||||||
|
| یافته | وضعیت |
|
||||||
|
|--------|--------|
|
||||||
|
| Backend Dockerfile helm/kubectl از اینترنت | 🔜 | mirror یا COPY از stage |
|
||||||
|
| orphan PVC cleanup | 🔜 |
|
||||||
|
| zip bomb hard limit | 🔜 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## متغیرهای محیطی جدید (مرجع deploy)
|
||||||
|
|
||||||
|
| متغیر | پیشفرض | توضیح |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| `CLUSTER_DEPLOY_FALLBACK_ENABLED` | `false` | fallback بین کلاستر |
|
||||||
|
| `CLUSTER_DEPLOY_FALLBACK_ATTEMPTS` | `3` | فقط وقتی fallback فعال |
|
||||||
|
| `BASE_IMAGE_REGISTRY` | `registry.abrban.com/proxy-dockerhub/library` | آینه base imageهای بیلد (یا از `build` در Helm values) |
|
||||||
|
| `KANIKO_IMAGE` | `registry.abrban.com/proxy-gcr/...` | Kaniko executor (یا `build.images.kaniko` در values) |
|
||||||
|
| `BUILD_PVC_STORAGE_CLASS` | — | StorageClass برای PVC بیلد |
|
||||||
|
| `BILLING_WALLET_HMAC_SECRET` | — | اجباری در production |
|
||||||
|
| `PAYMENT_GATEWAY_*` | — | اجباری برای charge واقعی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## commitهای مرتبط
|
||||||
|
|
||||||
|
- `22359be` — fix(platform): apply production hardening from audit plan
|
||||||
|
- `6d9cd89` — docs: add portable from-zero deploy runbook and GitOps templates
|
||||||
|
- *(uncommitted)* — رفع موارد باقیمانده این سند (preview، OTP، zip slip، auto-renew lock، docker-compose)
|
||||||
@@ -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,331 @@
|
|||||||
# ☁️ 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).
|
||||||
|
|
||||||
|
> 🔄 **CI/CD (Gitea Actions → Kaniko → Harbor → Argo CD):** see **[RUNBOOK-CICD.fa.md](RUNBOOK-CICD.fa.md)** (Persian) and **[gitops/README.md](gitops/README.md)** for bootstrap (`seed-ci-images`, Sealed Secrets, two-repo GitOps layout).
|
||||||
|
>
|
||||||
|
> 🚀 **Deploy from zero (any cluster):** **[RUNBOOK-DEPLOY.fa.md](RUNBOOK-DEPLOY.fa.md)** — server checklist, values, secrets, logging, greenfield reset.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
||||||
├── CHANGELOG.md # Version history
|
├── ARCHITECTURE.md # Detailed system design
|
||||||
├── CONTRIBUTING.md # Development workflow & conventions
|
├── RUNBOOK.fa.md # Persian runbook: local dev + abrban/k3s production deploy
|
||||||
├── docker-compose.yml # Local dev / production compose
|
├── RUNBOOK-DEPLOY.fa.md # Deploy platform from zero (any cluster): values, secrets, health checks
|
||||||
|
├── RUNBOOK-CICD.fa.md # CI/CD pipeline: Gitea Actions → Kaniko → Argo CD
|
||||||
|
├── CHANGELOG.md / CONTRIBUTING.md / UPGRADE.md / UPGRADE.en.md
|
||||||
|
├── docker-compose.yml # Local dev stack (Postgres + Redis + API + UI)
|
||||||
│
|
│
|
||||||
├── 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)
|
||||||
|
|
||||||
|
> **Production GitOps (from zero):** [`RUNBOOK-DEPLOY.fa.md`](RUNBOOK-DEPLOY.fa.md) — variable table, values, Sealed Secrets, logging, health checks.
|
||||||
|
>
|
||||||
|
> **Production abrban.com specifics:** [`RUNBOOK.fa.md`](RUNBOOK.fa.md) — Iran network, Ceph, Harbor details.
|
||||||
|
>
|
||||||
|
> **CI/CD pipeline:** [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md).
|
||||||
|
|
||||||
|
This section is the **generic Helm-only** path (Path B in RUNBOOK-DEPLOY) without Gitea/Argo.
|
||||||
|
|
||||||
|
**Prerequisites:** a Kubernetes cluster, an Ingress controller (Traefik on k3s by default,
|
||||||
|
or set `INGRESS_CLASS=nginx`), a default StorageClass for PVCs, and a container registry
|
||||||
|
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 | `registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2` |
|
||||||
| `REGISTRY_URL` | Container registry URL | `localhost:30500` |
|
| `BUILD_ALPINE_IMAGE` | Alpine image for build init/helper pods | `registry.abrban.com/proxy-dockerhub/library/alpine:3.19` |
|
||||||
| `PLATFORM_DOMAIN` | Base domain for app subdomains | `apps.cloudhost.ir` |
|
| `BUILD_ALPINE_GIT_IMAGE` | Git-clone init container image | `registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0` |
|
||||||
| `LIFECYCLE_SCAN_INTERVAL_MS` | Lifecycle scanner interval | `60000` |
|
| `BASE_IMAGE_REGISTRY` | Harbor prefix for Docker Hub images in generated Dockerfiles | `registry.abrban.com/proxy-dockerhub/library` |
|
||||||
| `LIFECYCLE_HOURLY_DELETE_AFTER_MS` | Hourly plan grace period | `3600000` (1h) |
|
| `UPLOAD_DIR` | Disk path for uploaded source archives | `./uploads` |
|
||||||
| `LIFECYCLE_MONTHLY_DELETE_AFTER_MS` | Monthly plan grace period | `259200000` (3d) |
|
| `INGRESS_CLASS` | Ingress controller for app Ingress objects | `traefik` |
|
||||||
| `LIFECYCLE_YEARLY_DELETE_AFTER_MS` | Yearly plan grace period | `604800000` (7d) |
|
| `PLATFORM_DOMAIN` / `PREVIEW_BASE_DOMAIN` | Base domain for app subdomains / previews | `apps.cloudhost.local` / — |
|
||||||
|
| `PLATFORM_STORAGE_CLASS` | StorageClass for new PVCs (needs volume expansion) | `cloudhost-expandable` |
|
||||||
|
| `ELASTICSEARCH_HOST` / `ELASTICSEARCH_PORT` | Log search backend | cluster DNS / `9200` |
|
||||||
|
| `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,321 @@
|
|||||||
|
# RUNBOOK — خط CI/CD (Gitea Actions → Kaniko → Harbor → Argo CD)
|
||||||
|
|
||||||
|
این مستند جریان کامل Build و Deploy پلتفرم را توضیح میدهد: از Push شدن کد روی `main` تا استقرار خودکار روی Kubernetes.
|
||||||
|
|
||||||
|
> **استقرار از صفر روی سرور جدید:** [`RUNBOOK-DEPLOY.fa.md`](RUNBOOK-DEPLOY.fa.md) — شامل جدول متغیرها، seal کردن Secretها، logging stack، greenfield reset، و چکلیست سلامت.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## معماری و جریان کلی
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Dev[Developer] -->|git push main| AppRepo["Gitea: abrban/cloud-host (کد + چارت)"]
|
||||||
|
AppRepo -->|trigger workflow| Runner["Act Runner (namespace: gitea)"]
|
||||||
|
Runner -->|"checkout با CI_TOKEN"| AppRepo
|
||||||
|
Runner -->|kubectl apply Job| Kaniko["Kaniko Job (namespace: cloudhost-builds)"]
|
||||||
|
Kaniko -->|"push با harbor_registry_user"| Harbor["Harbor (harbor-registry:5000)"]
|
||||||
|
Runner -->|"آپدیت image.tag + commit/push"| GitOpsRepo["Gitea: abrban/cloud-host-gitops (state)"]
|
||||||
|
GitOpsRepo -->|"poll (پیشفرض هر ۳ دقیقه)"| Argo["Argo CD (automated sync)"]
|
||||||
|
AppRepo -->|"Helm Chart (source دوم)"| Argo
|
||||||
|
Argo -->|"helm render + apply"| K8s["Kubernetes (namespace: cloudhost)"]
|
||||||
|
Harbor -->|"pull از طریق mirror در k3s"| K8s
|
||||||
|
Rollback["Rollback: git revert در cloud-host-gitops"] -.-> GitOpsRepo
|
||||||
|
```
|
||||||
|
|
||||||
|
مراحل به ترتیب:
|
||||||
|
|
||||||
|
1. Developer روی شاخهٔ `main` در ریپوی اپلیکیشن (`git.abrban.com/abrban/cloud-host`) push میکند.
|
||||||
|
2. Workflow در [`.gitea/workflows/build-deploy.yaml`](.gitea/workflows/build-deploy.yaml) روی Runner با لیبل `abrban-builder` اجرا میشود.
|
||||||
|
3. Runner کد را با توکن CI کلون میکند و تگ ایمیج (`YYYYMMDD-HHMM-<sha>`) را میسازد.
|
||||||
|
4. **Job تست بکاند** در namespace `cloudhost-builds` اجرا میشود (`npm ci` + `jest --ci`) — در صورت fail، بیلد ایمیج شروع نمیشود.
|
||||||
|
5. برای هر ایمیج (backend و frontend) یک Kaniko Job در namespace `cloudhost-builds` ساخته میشود که کد را کلون، ایمیج را build و به Harbor push میکند.
|
||||||
|
6. بعد از موفقیت هر دو Build، همان Runner ریپوی **`cloud-host-gitops`** را کلون میکند، مقدار `images.backend.tag` و `images.frontend.tag` را در `platform/values-abrban.yaml` عوض و commit/push میکند (با retry و `git pull --rebase` در صورت race).
|
||||||
|
7. Argo CD (Application به نام `abrban-platform` با sync خودکار) تغییر را تشخیص میدهد و نسخهٔ جدید را در namespace `cloudhost` مستقر میکند.
|
||||||
|
|
||||||
|
> **جلوگیری از حلقهٔ CI:** کامیتِ Pipeline به ریپوی جدا (`cloud-host-gitops`) میرود که هیچ Workflowای ندارد؛ بنابراین Build دوباره trigger نمیشود.
|
||||||
|
|
||||||
|
**زمان تقریبی یک Pipeline کامل:** ۱۵–۲۵ دقیقه (backend سنگینتر است؛ شامل دانلود npm، helm و kubectl داخل Dockerfile).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bootstrap — پیشنیازهای یکبار (کلاستر تازه)
|
||||||
|
|
||||||
|
قبل از اولین push به `main`، این موارد باید در کلاستر آماده باشند:
|
||||||
|
|
||||||
|
| # | کار | دستور / فایل |
|
||||||
|
|---|-----|--------------|
|
||||||
|
| 1 | Mirror k3s → Harbor | `./scripts/apply-k3s-registries.sh` |
|
||||||
|
| 2 | Secretهای TLS و registry در nsهای `gitea`, `cloudhost-builds`, `argocd` | [`gitops/README.md`](gitops/README.md) گام ۴ |
|
||||||
|
| 3 | پروکسی egress در `gitea` و `cloudhost-builds` | همان گام ۴ — برای npm/helm/kubectl داخل build و دانلود kubectl توسط Runner |
|
||||||
|
| 4 | **Seed ایمیجهای CI** در Harbor `abrban/` | [`gitops/jobs/seed-ci-images.yaml`](gitops/jobs/seed-ci-images.yaml) |
|
||||||
|
| 5 | Sealed Secrets controller | `helm upgrade --install sealed-secrets ... -f gitops/sealed-secrets/values.yaml` |
|
||||||
|
| 6 | SealedSecretها از ریپوی gitops | `kubectl apply -f` روی `cloud-host-gitops/sealed-secrets/` |
|
||||||
|
| 7 | Gitea Runner + Secret `CI_TOKEN` در ریپو | [`gitops/gitea/act-runner.yaml`](gitops/gitea/act-runner.yaml) |
|
||||||
|
| 8 | ریپوی `cloud-host-gitops` + Argo Application | [`gitops/argocd/application-platform.yaml`](gitops/argocd/application-platform.yaml) |
|
||||||
|
|
||||||
|
### Seed ایمیجهای CI (الزامی)
|
||||||
|
|
||||||
|
kubelet و Kaniko نمیتوانند reliably از proxy-cache هاربر برای همهٔ ایمیجها استفاده کنند. این ایمیجها باید **یکبار** با skopeo در پروژهٔ `abrban/` کپی شوند:
|
||||||
|
|
||||||
|
| ایمیج در Harbor | منبع upstream | مصرف |
|
||||||
|
|-----------------|---------------|------|
|
||||||
|
| `abrban/act-runner:0.2.11` | docker.io/gitea/act_runner | Gitea Actions runner |
|
||||||
|
| `abrban/alpine-git:2.43.0` | docker.io/alpine/git | initContainer کلون در Kaniko Job |
|
||||||
|
| `abrban/node:24-alpine` | docker.io/library/node | **BASE_IMAGE** در Dockerfile (هر stage) |
|
||||||
|
| `abrban/kaniko-executor:v1.27.6-debug` | gcr.io/kaniko-project/executor | Kaniko Job |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# پیشنیاز: secret registry-egress-proxy و registry-pull-secret در ns cloudhost
|
||||||
|
kubectl apply -f gitops/jobs/seed-ci-images.yaml
|
||||||
|
kubectl -n cloudhost wait --for=condition=complete job/seed-ci-images --timeout=15m
|
||||||
|
kubectl -n cloudhost logs job/seed-ci-images --tail=5
|
||||||
|
# انتظار: SEED_OK
|
||||||
|
```
|
||||||
|
|
||||||
|
بررسی:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost run tags --rm -i --restart=Never \
|
||||||
|
--image=registry.abrban.com/abrban/alpine:3 \
|
||||||
|
--overrides='{"spec":{"imagePullSecrets":[{"name":"registry-pull-secret"}]}}' \
|
||||||
|
-- sh -c 'H="harbor_registry_user:$(kubectl -n cloudhost get secret harbor-core -o jsonpath="{.data.REGISTRY_CREDENTIAL_PASSWORD}" | base64 -d)@harbor-registry.cloudhost.svc.cluster.local:5000"; for r in act-runner kaniko-executor alpine-git node; do wget -qO- "http://${H}/v2/abrban/${r}/tags/list"; echo; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
> بعد از bootstrap، Pipeline با push به `main` خودکار اجرا میشود؛ نیازی به `./scripts/trigger-platform-build.sh` برای جریان عادی نیست (فقط برای دیباگ دستی).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ساختار Repository (دو ریپو)
|
||||||
|
|
||||||
|
### `abrban/cloud-host` — Application Repo
|
||||||
|
|
||||||
|
| مسیر | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `backend/`, `frontend/` | کد اپلیکیشن + Dockerfile |
|
||||||
|
| `backend/helm/cloudhost-platform/` | Helm Chart پلتفرم |
|
||||||
|
| `.gitea/workflows/build-deploy.yaml` | Pipeline (Build + آپدیت GitOps) |
|
||||||
|
| `gitops/` | نصب زیرساخت (Argo CD، Gitea، Sealed Secrets، k3s و…) |
|
||||||
|
|
||||||
|
### `abrban/cloud-host-gitops` — GitOps Repo (منبع حقیقت Argo CD)
|
||||||
|
|
||||||
|
| مسیر | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `platform/values-abrban.yaml` | مقادیر Production — تنها فایلی که CI آپدیت میکند |
|
||||||
|
| `argocd/application-platform.yaml` | تعریف Application (نسخهٔ mirror آن در `gitops/argocd/` ریپوی اپ هم هست) |
|
||||||
|
| `sealed-secrets/*.yaml` | SealedSecretهای CI — رمزشده و قابل کامیت |
|
||||||
|
|
||||||
|
Application در Argo CD بهصورت **multi-source** تعریف شده: چارت از `cloud-host` و values از `cloud-host-gitops`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sources:
|
||||||
|
- repoURL: https://git.abrban.com/abrban/cloud-host.git
|
||||||
|
path: backend/helm/cloudhost-platform
|
||||||
|
helm:
|
||||||
|
valueFiles:
|
||||||
|
- $values/platform/values-abrban.yaml
|
||||||
|
- repoURL: https://git.abrban.com/abrban/cloud-host-gitops.git
|
||||||
|
ref: values
|
||||||
|
```
|
||||||
|
|
||||||
|
مزیت این جداسازی: history تمیز، دسترسی نوشتن CI محدود به ریپوی state، و امکان دیدن کل تاریخچهٔ Deployها با `git log` یک ریپوی کوچک.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## احراز هویتها (چه کسی با چه چیزی به کجا وصل میشود)
|
||||||
|
|
||||||
|
| مسیر | مکانیزم | محل نگهداری |
|
||||||
|
|------|---------|--------------|
|
||||||
|
| Runner → Gitea (ثبت) | Registration Token | SealedSecret `gitea-act-runner-token` (ns `gitea`) در ریپوی gitops |
|
||||||
|
| Workflow → Gitea (clone/push هر دو ریپو) | PAT کاربر `ci` | Secret ریپوی `cloud-host` در Gitea با نام **`CI_TOKEN`** (نامهای `GITEA_*` رزرو هستند) |
|
||||||
|
| Kaniko → Harbor (push) | `harbor_registry_user` | SealedSecret `kaniko-harbor-auth` (ns `cloudhost-builds`) در ریپوی gitops |
|
||||||
|
| kubelet → Harbor (pull) | user `cloudhost` | Secret `registry-pull-secret` + mirror در `gitops/k3s/registries.yaml` |
|
||||||
|
| Argo CD → `cloud-host` (read) | repo credential | Secret `gitea-repo-creds` (ns `argocd`) |
|
||||||
|
| Argo CD → `cloud-host-gitops` (read) | PAT کاربر `ci` | SealedSecret `gitea-gitops-repo-creds` (ns `argocd`) در ریپوی gitops |
|
||||||
|
|
||||||
|
### توکن CI برای Gitea (`CI_TOKEN`)
|
||||||
|
|
||||||
|
کاربر `ci` در Gitea ساخته شده و روی هر دو ریپو دسترسی write دارد. PAT آن با scope `read:repository, write:repository` بهعنوان Secret با نام `CI_TOKEN` در **Settings → Actions → Secrets** ریپوی `cloud-host` ثبت شده است.
|
||||||
|
|
||||||
|
برای rotate: در Gitea با کاربر `ci` توکن جدید بسازید (یا از API ادمین: `POST /api/v1/users/ci/tokens`)، مقدار Secret را در تنظیمات ریپو آپدیت کنید و SealedSecret `gitea-gitops-repo-creds` را هم دوباره seal کنید.
|
||||||
|
|
||||||
|
### احراز هویت Kaniko به Harbor
|
||||||
|
|
||||||
|
Kaniko به endpoint داخلی `harbor-registry.cloudhost.svc.cluster.local:5000` push میکند که **مستقیم به کامپوننت registry** میرود و harbor-core را دور میزند. نکتهٔ مهم:
|
||||||
|
|
||||||
|
- **Robot Accountهای Harbor اینجا کار نمیکنند** — توکن آنها را harbor-core صادر میکند و endpoint داخلی به سرویس توکن دسترسی ندارد.
|
||||||
|
- credential درست، کاربر داخلی `harbor_registry_user` است با پسورد `REGISTRY_CREDENTIAL_PASSWORD` از Secret `harbor-core`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REG_PASS="$(kubectl -n cloudhost get secret harbor-core \
|
||||||
|
-o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)"
|
||||||
|
kubectl -n cloudhost-builds create secret docker-registry kaniko-harbor-auth \
|
||||||
|
--docker-server=harbor-registry.cloudhost.svc.cluster.local:5000 \
|
||||||
|
--docker-username=harbor_registry_user \
|
||||||
|
--docker-password="${REG_PASS}"
|
||||||
|
```
|
||||||
|
|
||||||
|
نمونهٔ manifest: [`gitops/jobs/kaniko-harbor-auth.example.yaml`](gitops/jobs/kaniko-harbor-auth.example.yaml) — نسخهٔ واقعی بهصورت SealedSecret در ریپوی gitops است.
|
||||||
|
|
||||||
|
ورکفلو این Secret را در مسیر `/kaniko/.docker/config.json` هر دو Kaniko Job مانت میکند. چون push/pull داخلی و بدون TLS است، این فلگها لازماند:
|
||||||
|
|
||||||
|
- `--insecure` / `--skip-tls-verify` — push
|
||||||
|
- `--insecure-pull` / `--insecure-registry=${PUSH_REGISTRY}` — pull ایمیج پایه از `harbor-registry:5000`
|
||||||
|
|
||||||
|
ایمیج پایه (`node:24-alpine`) از endpoint داخلی کشیده میشود، نه از `registry.abrban.com`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# در .gitea/workflows/build-deploy.yaml
|
||||||
|
--build-arg=BASE_IMAGE=harbor-registry.cloudhost.svc.cluster.local:5000/abrban/node:24-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfileها از `ARG BASE_IMAGE=node:24-alpine` استفاده میکنند (build محلی بدون تغییر).
|
||||||
|
|
||||||
|
> **پروکسی egress** (`registry-egress-proxy` در ns `cloudhost-builds`) هنوز لازم است برای `npm ci` و دانلود helm/kubectl **داخل** مراحل RUN در Dockerfile — فقط pull ایمیج پایه از docker.io حذف شده است.
|
||||||
|
|
||||||
|
> **عارضهٔ جانبی push مستقیم به :5000** — Harbor DB از این ایمیجها بیخبر میماند؛ در UI هاربر دیده نمیشوند ولی pull بهدرستی کار میکند. برای دیدن تگها از registry API استفاده کنید (بخش عیبیابی).
|
||||||
|
|
||||||
|
### ارتباط Runner با Harbor
|
||||||
|
|
||||||
|
Runner خودش با Harbor حرف نمیزند؛ فقط Job میسازد. دو مسیر Harbor:
|
||||||
|
|
||||||
|
- **Push (داخلی):** `harbor-registry.cloudhost.svc.cluster.local:5000` — بدون عبور از Traefik.
|
||||||
|
- **Pull (kubelet):** `registry.abrban.com` — از طریق mirror در k3s (`scripts/apply-k3s-registries.sh`) به harbor-core route میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versioning ایمیجها
|
||||||
|
|
||||||
|
**استاندارد فعلی:** `YYYYMMDD-HHMM-<git-sha-short>` (مثلاً `20260702-1230-a1b2c3d`)
|
||||||
|
|
||||||
|
- **Immutable** است — هیچوقت یک تگ بازنویسی نمیشود (برخلاف `latest`).
|
||||||
|
- **قابل ردیابی** است — از روی تگ ایمیجِ در حال اجرا مستقیماً به کامیت میرسید.
|
||||||
|
- **مرتبشونده** است — بهترتیب زمانی دیده میشود.
|
||||||
|
|
||||||
|
از `latest` هرگز برای Deploy استفاده نکنید؛ هم قابلیت Rollback را از بین میبرد و هم Argo CD تغییری برای sync نمیبیند.
|
||||||
|
|
||||||
|
**SemVer برای Releaseها (اختیاری):** روی کامیت release یک Git Tag مثل `v1.4.0` بزنید و همان ایمیج را با `skopeo copy` تگ اضافه بزنید (rebuild لازم نیست). تگ SemVer برای انسانهاست؛ منبع حقیقتِ Deploy همان تگ SHA-دار در values است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## آپدیت خودکار Helm Values
|
||||||
|
|
||||||
|
مرحلهٔ آخر Workflow ریپوی `cloud-host-gitops` را کلون میکند و فقط دو مقدار را در `platform/values-abrban.yaml` عوض میکند:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
images:
|
||||||
|
backend:
|
||||||
|
repository: registry.abrban.com/abrban/cloudhost-backend
|
||||||
|
tag: "20260702-1230-a1b2c3d" # ← CI این را آپدیت میکند
|
||||||
|
frontend:
|
||||||
|
repository: registry.abrban.com/abrban/cloudhost-frontend
|
||||||
|
tag: "20260702-1230-a1b2c3d" # ← CI این را آپدیت میکند
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر `yq` روی Runner موجود باشد از آن استفاده میشود، وگرنه `sed` هدفمند (فقط خطِ `tag:` بلافاصله بعد از `repository: ...cloudhost-*`) اجرا میشود.
|
||||||
|
|
||||||
|
جایگزین بررسیشده و کنارگذاشتهشده: **Argo CD Image Updater** — با روش فعلی همپوشانی دارد و شفافیت کامیتِ صریح از CI را ندارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
چون Deploy فقط از Git انجام میشود، Rollback هم یک عملیات Git است — این بار در ریپوی `cloud-host-gitops`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.abrban.com/abrban/cloud-host-gitops.git && cd cloud-host-gitops
|
||||||
|
|
||||||
|
# 1. پیدا کردن کامیت deploy مشکلدار
|
||||||
|
git log --oneline -- platform/values-abrban.yaml
|
||||||
|
|
||||||
|
# 2. برگرداندن آن (تگ ایمیج به نسخهٔ قبلی برمیگردد)
|
||||||
|
git revert <commit-sha>
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# 3. Argo CD بهصورت خودکار به نسخهٔ قبلی sync میکند (ایمیج قبلی هنوز در Harbor هست)
|
||||||
|
```
|
||||||
|
|
||||||
|
نکتهها:
|
||||||
|
|
||||||
|
- `git revert` (نه `reset --force`) — history حفظ میشود و مشخص است چه چیزی چرا برگشت.
|
||||||
|
- **Rollback اضطراری** (وقتی Git در دسترس نیست): `argocd app rollback abrban-platform` یا Sync به revision قبلی در UI. **هشدار:** چون `selfHeal: true` فعال است، Argo در sync بعدی دوباره به HEAD گیت برمیگردد — rollback اضطراری موقتی است و باید بلافاصله با `git revert` دائمی شود.
|
||||||
|
- اگر Deployment جدید خراب باشد (CrashLoopBackOff)، بهخاطر `RollingUpdate` نسخهٔ قبلی تا آمادهشدن نسخهٔ جدید بالا میماند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مدیریت Secretها (Sealed Secrets)
|
||||||
|
|
||||||
|
کنترلر **Sealed Secrets** در `kube-system` نصب است (values در [`gitops/sealed-secrets/values.yaml`](gitops/sealed-secrets/values.yaml)؛ ایمیج آن از `ghcr.io/bitnami` به پروژهٔ `abrban/` هاربر seed شده). Secretهای CI بهصورت **SealedSecret** در ریپوی `cloud-host-gitops` (پوشهٔ `sealed-secrets/`) نگهداری میشوند — رمزشده با کلید عمومی کلاستر؛ فقط کنترلرِ داخل کلاستر میتواند رمزگشایی کند، پس کامیتکردنشان امن است.
|
||||||
|
|
||||||
|
| SealedSecret | Namespace | محتوا |
|
||||||
|
|--------------|-----------|-------|
|
||||||
|
| `gitea-act-runner-token` | `gitea` | توکن ثبت Runner |
|
||||||
|
| `kaniko-harbor-auth` | `cloudhost-builds` | dockerconfig کاربر `harbor_registry_user` |
|
||||||
|
| `gitea-gitops-repo-creds` | `argocd` | repo credential ریپوی gitops (کاربر `ci`) |
|
||||||
|
| `abrban-platform-secrets` | `cloudhost` | postgres-password، jwt-secret، jwt-refresh-secret، **cluster-kubeconfig-key**، **redis-password** |
|
||||||
|
| `elasticsearch-credentials` | `logging` | ELASTIC_PASSWORD، FLUENTBIT_PASSWORD (خارج از چارت پلتفرم — [`elasticsearch-credentials.example.yaml`](gitops/sealed-secrets/elasticsearch-credentials.example.yaml)) |
|
||||||
|
|
||||||
|
چارت Helm با `secrets.existingSecret: abrban-platform-secrets` در `platform/values-abrban.yaml` (ریپوی gitops) از Secret ازپیشساخته استفاده میکند — Argo CD با `helm template` نمیتواند Secret تصادفی بسازد (lookup خالی است و هر sync مقادیر JWT/Redis را عوض میکند).
|
||||||
|
|
||||||
|
نمونهٔ کامل values: [`gitops/platform/values-abrban.example.yaml`](gitops/platform/values-abrban.example.yaml) — شامل mirror ایمیج postgres/redis، `BASE_IMAGE_REGISTRY`، و envهای Elastic.
|
||||||
|
|
||||||
|
### Greenfield / ارتقا از نسخهٔ قدیم
|
||||||
|
|
||||||
|
اگر کلاستر قبلاً با schema یا namespace قدیمی بالا آمده، قبل از deploy جدید **reset دیتابیس** لازم است. مراحل کامل (با متغیرهای قابلتنظیم برای هر محیط) در **[`RUNBOOK-DEPLOY.fa.md` — فاز ۶](RUNBOOK-DEPLOY.fa.md#فاز-۶--greenfield--ارتقا-از-نسخهٔ-قدیم)**.
|
||||||
|
|
||||||
|
### ساخت/بهروزرسانی یک SealedSecret
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install kubeseal # فقط بار اول
|
||||||
|
|
||||||
|
kubectl -n <ns> create secret generic <name> --from-literal=key=value --dry-run=client -o json \
|
||||||
|
| kubeseal --controller-name=sealed-secrets-controller --controller-namespace=kube-system --format yaml \
|
||||||
|
> sealed-secrets/<name>.yaml
|
||||||
|
# سپس commit/push در ریپوی cloud-host-gitops و kubectl apply (یا sync توسط Argo در آینده)
|
||||||
|
```
|
||||||
|
|
||||||
|
> اگر Secret از قبل در کلاستر وجود دارد و میخواهید کنترلر آن را تصاحب کند، اول annotate کنید:
|
||||||
|
> `kubectl -n <ns> annotate secret <name> sealedsecrets.bitnami.com/managed="true"`
|
||||||
|
|
||||||
|
Secretهایی که هنوز دستیاند (خارج از چرخهٔ CI): `abrban-wildcard-tls`، `registry-pull-secret`، `registry-egress-proxy`، `harbor-core` (ساختهٔ Helm) — میتوانند بهتدریج seal شوند.
|
||||||
|
|
||||||
|
> **نکتهٔ امنیتی:** توکن ثبت Runner و پسورد پروکسی که قبلاً در history گیت افشا شده بودند rotate شدهاند (توکن Runner جدید صادر و Runner دوباره ثبت شد). پسورد کاربر پروکسی (`builder`) روی سرور پروکسی هنوز باید توسط ادمین عوض شود؛ بعد از تغییر، Secret `registry-egress-proxy` را در namespaceهای `cloudhost` و `gitea` آپدیت کنید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practiceهای GitOps در این استک (چکلیست)
|
||||||
|
|
||||||
|
- [x] **Git تنها منبع حقیقت** — Argo CD با `automated + prune + selfHeal`؛ تغییر دستی با `kubectl edit` برگردانده میشود.
|
||||||
|
- [x] **جداسازی App Repo از GitOps Repo** — history تمیز و دسترسی حداقلی CI.
|
||||||
|
- [x] **تگ Immutable بهجای `latest`** — هر Build تگ یکتا دارد.
|
||||||
|
- [x] **جلوگیری از CI Loop** — کامیت CI به ریپوی جدا میرود که Workflow ندارد.
|
||||||
|
- [x] **Build بدون Docker Daemon** — Kaniko داخل Job، بدون `docker.sock` و بدون privileged.
|
||||||
|
- [x] **جداسازی push/pull هاربر** — push داخلی بدون عبور از Ingress؛ pull از طریق mirror k3s.
|
||||||
|
- [x] **Concurrency در Workflow** — دو push پشتسرهم روی آپدیت values با هم race نمیکنند.
|
||||||
|
- [x] **Secretهای GitOps-شده** — Sealed Secrets نصب و secretهای CI رمزشده در Git.
|
||||||
|
- [ ] **محیط Staging** — با `platform/values-staging.yaml` و Application دوم قابل اضافهشدن است.
|
||||||
|
- [ ] **Notification** — Argo CD Notifications برای اطلاع از Sync موفق/ناموفق.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## عیبیابی سریع
|
||||||
|
|
||||||
|
| علامت | بررسی |
|
||||||
|
|-------|-------|
|
||||||
|
| Workflow اجرا نمیشود | `kubectl -n gitea logs deploy/gitea-act-runner` — ثبت Runner و لیبل `abrban-builder` |
|
||||||
|
| Build fail — clone | معتبربودن Secret `CI_TOKEN` در تنظیمات ریپوی `cloud-host` |
|
||||||
|
| Build fail — pull ایمیج پایه | `node:24-alpine` باید seed شده باشد؛ Workflow باید `BASE_IMAGE=harbor-registry.../abrban/node:24-alpine` + `--insecure-pull` داشته باشد؛ نه pull مستقیم از docker.io |
|
||||||
|
| Build fail — UNAUTHORIZED روی registry.abrban.com | BASE_IMAGE نباید `registry.abrban.com/...` باشد — credential کانیکو فقط برای `harbor-registry:5000` است |
|
||||||
|
| Build fail — HTTP response to HTTPS client | `--insecure-pull` و `--insecure-registry=harbor-registry.cloudhost.svc.cluster.local:5000` در Kaniko args |
|
||||||
|
| Build fail — timeout npm/helm/kubectl | `registry-egress-proxy` در ns `cloudhost-builds` و سلامت پروکسی egress |
|
||||||
|
| Build fail — push به Harbor | `kubectl -n cloudhost-builds get secret kaniko-harbor-auth`؛ پسورد باید با `REGISTRY_CREDENTIAL_PASSWORD` هاربر یکی باشد |
|
||||||
|
| کامیت values push نمیشود | دسترسی write کاربر `ci` روی `cloud-host-gitops` |
|
||||||
|
| Argo sync نمیکند | `kubectl -n argocd get app abrban-platform`؛ هر دو repo credential (`gitea-repo-creds` و `gitea-gitops-repo-creds`) |
|
||||||
|
| Pod ایمیج را pull نمیکند | `registry-pull-secret` در ns `cloudhost` و mirror k3s (`scripts/apply-k3s-registries.sh`) |
|
||||||
|
| دیدن تگهای موجود در registry | از داخل کلاستر: `wget -qO- "http://harbor_registry_user:<REG_PASS>@harbor-registry.cloudhost.svc.cluster.local:5000/v2/abrban/cloudhost-backend/tags/list"` |
|
||||||
|
| Backend CrashLoop — CLUSTER_KUBECONFIG_KEY | Secret `abrban-platform-secrets` باید کلید `cluster-kubeconfig-key` داشته باشد و در values: `secrets.existingSecret: abrban-platform-secrets` |
|
||||||
|
| Backend CrashLoop — DB auth | پسورد postgres در Secret با DB واقعی همخوان باشد (`ALTER USER ... WITH PASSWORD` در صورت rotate شدن Secret) |
|
||||||
|
| Backend CrashLoop — Redis auth | Secret `abrban-platform-secrets` باید کلید `redis-password` داشته باشد؛ backend و Redis پلتفرم هر دو از آن استفاده میکنند |
|
||||||
|
| Backend CrashLoop — ELASTIC_PASSWORD | در production مقدار پیشفرض رد میشود — env در values-abrban.yaml باید رمز rotateشده داشته باشد |
|
||||||
|
| Workflow fail — tests | Job `test-be-*` در ns `cloudhost-builds` — `kubectl logs job/... -c test` |
|
||||||
|
| SealedSecret باز نمیشود | `kubectl get sealedsecrets -A` (ستون SYNCED) و لاگ `kubectl -n kube-system logs deploy/sealed-secrets-controller` |
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
# RUNBOOK — استقرار پلتفرم CloudHost از صفر
|
||||||
|
|
||||||
|
این سند **کارهایی را که روی سرور/کلاستر باید انجام دهید** مرحلهبهمرحله توضیح میدهد — از bootstrap زیرساخت تا اولین deploy موفق پس از hardening.
|
||||||
|
|
||||||
|
> **برای چه کسی است:** هر کسی که میخواهد CloudHost را روی یک کلاستر Kubernetes تازه (یا کلاستر دیگری غیر از abrban) بالا بیاورد.
|
||||||
|
>
|
||||||
|
> **چه چیزی اینجا نیست:** جزئیات معماری اپ → [`RUNBOOK.fa.md`](RUNBOOK.fa.md)؛ جزئیات pipeline CI → [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قبل از شروع — جدول متغیرها
|
||||||
|
|
||||||
|
همهٔ دستورات زیر از این متغیرها استفاده میکنند. **یکبار** آنها را برای محیط خودتان پر کنید:
|
||||||
|
|
||||||
|
| متغیر | توضیح | مثال abrban | مثال محیط جدید |
|
||||||
|
|-------|--------|-------------|----------------|
|
||||||
|
| `PLATFORM_NS` | namespace پلتفرم | `cloudhost` | `cloudhost` |
|
||||||
|
| `BUILD_NS` | namespace بیلد Kaniko | `cloudhost-builds` | `cloudhost-builds` |
|
||||||
|
| `LOGGING_NS` | namespace Elasticsearch | `logging` | `logging` |
|
||||||
|
| `REGISTRY_HOST` | آدرس pull ایمیج (Ingress/registry عمومی) | `registry.abrban.com` | `registry.example.com` |
|
||||||
|
| `REGISTRY_PROJECT` | پروژه Harbor برای ایمیجهای platform | `abrban` | `cloudhost` |
|
||||||
|
| `REGISTRY_PUSH` | endpoint داخلی push (بدون TLS) | `harbor-registry.cloudhost.svc.cluster.local:5000` | `registry.registry.svc:5000` |
|
||||||
|
| `GIT_HOST` | URL گیت (Gitea/GitHub) | `git.abrban.com` | `git.example.com` |
|
||||||
|
| `APP_REPO` | ریپوی کد + چارت | `abrban/cloud-host` | `org/cloud-host` |
|
||||||
|
| `GITOPS_REPO` | ریپوی state (values + sealed secrets) | `abrban/cloud-host-gitops` | `org/cloud-host-gitops` |
|
||||||
|
| `VALUES_FILE` | فایل values در gitops | `platform/values-abrban.yaml` | `platform/values-production.yaml` |
|
||||||
|
| `PLATFORM_SECRET` | Secret پلتفرم (JWT, DB, Redis, …) | `abrban-platform-secrets` | `cloudhost-platform-secrets` |
|
||||||
|
| `ARGO_APP` | نام Application در Argo CD | `abrban-platform` | `cloudhost-platform` |
|
||||||
|
| `DOMAIN_LANDING` | لندینگ | `abrban.com` | `example.com` |
|
||||||
|
| `DOMAIN_PANEL` | پنل | `panel.abrban.com` | `panel.example.com` |
|
||||||
|
| `DOMAIN_API` | API | `api.abrban.com` | `api.example.com` |
|
||||||
|
| `DOMAIN_APPS` | دامنهٔ اپهای کاربر | `apps.abrban.com` | `apps.example.com` |
|
||||||
|
| `STORAGE_CLASS` | StorageClass PVCها | `local-path` | `standard` |
|
||||||
|
| `INGRESS_CLASS` | Ingress controller | `traefik` | `nginx` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# نمونه — قبل از اجرای دستورات export کنید:
|
||||||
|
export PLATFORM_NS=cloudhost
|
||||||
|
export BUILD_NS=cloudhost-builds
|
||||||
|
export LOGGING_NS=logging
|
||||||
|
export REGISTRY_HOST=registry.example.com
|
||||||
|
export REGISTRY_PROJECT=cloudhost
|
||||||
|
export REGISTRY_PUSH=harbor-registry.cloudhost.svc.cluster.local:5000
|
||||||
|
export GIT_HOST=git.example.com
|
||||||
|
export APP_REPO=org/cloud-host
|
||||||
|
export GITOPS_REPO=org/cloud-host-gitops
|
||||||
|
export VALUES_FILE=platform/values-production.yaml
|
||||||
|
export PLATFORM_SECRET=cloudhost-platform-secrets
|
||||||
|
export ARGO_APP=cloudhost-platform
|
||||||
|
export DOMAIN_LANDING=example.com
|
||||||
|
export DOMAIN_PANEL=panel.example.com
|
||||||
|
export DOMAIN_API=api.example.com
|
||||||
|
export DOMAIN_APPS=apps.example.com
|
||||||
|
export STORAGE_CLASS=standard
|
||||||
|
export INGRESS_CLASS=nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## دو مسیر استقرار
|
||||||
|
|
||||||
|
| | **مسیر A — GitOps (توصیه Production)** | **مسیر B — Helm مستقیم** |
|
||||||
|
|---|--------------------------------------|---------------------------|
|
||||||
|
| CI/CD | Gitea Actions → Kaniko → Argo CD | build/push دستی + `helm upgrade` |
|
||||||
|
| Values | ریپوی جدا `GITOPS_REPO` | فایل محلی `my-values.yaml` |
|
||||||
|
| Secretها | Sealed Secrets در gitops | inline در values یا Secret دستی |
|
||||||
|
| مستند | **همین سند +** [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md) | [`README.md`](README.md) بخش Deploy |
|
||||||
|
|
||||||
|
بقیهٔ این سند **مسیر A** را پوشش میدهد. برای مسیر B به انتهای سند بروید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مسیر A — GitOps: فاز ۰ تا ۷
|
||||||
|
|
||||||
|
### فاز ۰ — پیشنیازهای سختافزاری و شبکه
|
||||||
|
|
||||||
|
- [ ] کلاستر Kubernetes (k3s یا دیگر) با kubectl از ماشین admin
|
||||||
|
- [ ] DNS: رکوردهای A/CNAME برای `$DOMAIN_LANDING`, `$DOMAIN_PANEL`, `$DOMAIN_API`, `$REGISTRY_HOST`, `$GIT_HOST`, Argo CD
|
||||||
|
- [ ] گواهی TLS (wildcard یا cert-manager + `clusterIssuer`)
|
||||||
|
- [ ] دسترسی `kubectl` به کلاستر
|
||||||
|
- [ ] `helm`, `kubeseal` (برای Sealed Secrets) روی ماشین admin
|
||||||
|
- [ ] دو ریپوی Git: `$APP_REPO` (کد) و `$GITOPS_REPO` (خالی یا با skeleton)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فاز ۱ — Bootstrap زیرساخت (یکبار per cluster)
|
||||||
|
|
||||||
|
این مراحل در [`gitops/README.md`](gitops/README.md) هم هست؛ خلاصه:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloud-host # ریپوی اپلیکیشن
|
||||||
|
|
||||||
|
# 1) mirror رجیستری k3s → Harbor (یا registry خودتان)
|
||||||
|
./scripts/apply-k3s-registries.sh # در صورت k3s؛ برای کلاستر دیگر mirror معادل تنظیم کنید
|
||||||
|
|
||||||
|
# 2) Argo CD
|
||||||
|
helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
|
||||||
|
-f gitops/argocd/values-bootstrap.yaml --timeout 15m --wait
|
||||||
|
|
||||||
|
# 3) Gitea (یا GitHub/GitLab — workflow را متناسب تنظیم کنید)
|
||||||
|
helm upgrade --install gitea gitea-charts/gitea -n gitea --create-namespace \
|
||||||
|
-f gitops/gitea/values.yaml --timeout 15m --wait
|
||||||
|
|
||||||
|
# 4) Secretهای TLS + registry-pull + egress در nsهای لازم
|
||||||
|
# (wildcard TLS و registry-pull-secret را یکبار در $PLATFORM_NS بسازید، سپس کپی)
|
||||||
|
for ns in argocd gitea $BUILD_NS; do
|
||||||
|
kubectl -n $PLATFORM_NS get secret <wildcard-tls-secret> -o yaml \
|
||||||
|
| sed "s/namespace: ${PLATFORM_NS}/namespace: ${ns}/" | kubectl apply -f -
|
||||||
|
kubectl -n $PLATFORM_NS get secret registry-pull-secret -o yaml \
|
||||||
|
| sed "s/namespace: ${PLATFORM_NS}/namespace: ${ns}/" | kubectl apply -f -
|
||||||
|
done
|
||||||
|
|
||||||
|
# 5) Seed ایمیجهای CI (act-runner, alpine-git, node, kaniko) — فایل را برای REGISTRY_* خودتان ویرایش کنید
|
||||||
|
kubectl apply -f gitops/jobs/seed-ci-images.yaml
|
||||||
|
kubectl -n $PLATFORM_NS wait --for=condition=complete job/seed-ci-images --timeout=15m
|
||||||
|
|
||||||
|
# 6) Sealed Secrets controller
|
||||||
|
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
|
||||||
|
helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets \
|
||||||
|
-n kube-system -f gitops/sealed-secrets/values.yaml --timeout 10m --wait
|
||||||
|
|
||||||
|
# 7) Gitea Actions runner + Secret CI_TOKEN در ریپوی app
|
||||||
|
kubectl apply -f gitops/gitea/act-runner.yaml
|
||||||
|
# در Gitea: Settings → Actions → Secrets → CI_TOKEN = PAT کاربر ci
|
||||||
|
|
||||||
|
# 8) Argo CD Application (chart از app repo، values از gitops repo)
|
||||||
|
# قبل از apply: repoURLها در gitops/argocd/application-platform.yaml را با GIT_HOST/APP_REPO/GITOPS_REPO همخوان کنید
|
||||||
|
kubectl apply -f gitops/argocd/application-platform.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**بررسی فاز ۱:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get nodes
|
||||||
|
kubectl -n argocd get pods
|
||||||
|
kubectl -n gitea get pods
|
||||||
|
kubectl -n kube-system get pods -l app.kubernetes.io/name=sealed-secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فاز ۲ — آمادهسازی ریپوی GitOps (values)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# کلون ریپوی gitops (کنار ریپوی app یا هر مسیر دلخواه)
|
||||||
|
git clone "https://${GIT_HOST}/${GITOPS_REPO}.git" cloud-host-gitops
|
||||||
|
cd cloud-host-gitops
|
||||||
|
|
||||||
|
# کپی template values از ریپوی app
|
||||||
|
cp ../cloud-host/gitops/platform/values-abrban.example.yaml "${VALUES_FILE}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**فایل values را برای محیط خودتان ویرایش کنید** — حداقل این فیلدها:
|
||||||
|
|
||||||
|
| بخش | چه چیزی عوض شود |
|
||||||
|
|-----|------------------|
|
||||||
|
| `images.postgres/redis/busybox` | مسیر mirror در `$REGISTRY_HOST` (مثلاً `proxy-dockerhub/library/postgres:16-alpine`) |
|
||||||
|
| `images.backend/frontend.repository` | `$REGISTRY_HOST/$REGISTRY_PROJECT/cloudhost-backend` |
|
||||||
|
| `secrets.existingSecret` | `$PLATFORM_SECRET` |
|
||||||
|
| `ingress.*.host` | `$DOMAIN_LANDING`, `$DOMAIN_PANEL`, `$DOMAIN_API` |
|
||||||
|
| `ingress.className` | `$INGRESS_CLASS` |
|
||||||
|
| `global.storageClass` | `$STORAGE_CLASS` |
|
||||||
|
| `backend.env.PLATFORM_DOMAIN` | `$DOMAIN_APPS` |
|
||||||
|
| `backend.env.FRONTEND_URL` | `https://${DOMAIN_PANEL},https://${DOMAIN_LANDING}` |
|
||||||
|
| `backend.env.REGISTRY_URL` | push داخلی: `$REGISTRY_PUSH/$REGISTRY_PROJECT` |
|
||||||
|
| `backend.env.REGISTRY_PULL_URL` | `$REGISTRY_HOST/$REGISTRY_PROJECT` |
|
||||||
|
| `backend.env.BASE_IMAGE_REGISTRY` | prefix mirror برای Dockerfileهای کاربر |
|
||||||
|
| `backend.env.ELASTIC_*` | بعد از فاز ۴ پر میشود |
|
||||||
|
| `postgres/redis.imagePullSecrets` | `[{ name: registry-pull-secret }]` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add "${VALUES_FILE}"
|
||||||
|
git commit -m "chore: initial platform values for $(hostname -s 2>/dev/null || echo production)"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
> **نکته:** CI فقط `images.backend.tag` و `images.frontend.tag` را عوض میکند — بقیهٔ فایل دست شماست.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فاز ۳ — Secretهای پلتفرم (Sealed Secrets)
|
||||||
|
|
||||||
|
Secret پلتفرم **نباید** در values بهصورت plaintext commit شود. از SealedSecret استفاده کنید.
|
||||||
|
|
||||||
|
**کلیدهای الزامی** در `$PLATFORM_SECRET`:
|
||||||
|
|
||||||
|
| کلید | کاربرد |
|
||||||
|
|------|--------|
|
||||||
|
| `postgres-password` | Postgres پلتفرم + migration Job |
|
||||||
|
| `jwt-secret` | JWT access (حداقل ۳۲ کاراکتر تصادفی) |
|
||||||
|
| `jwt-refresh-secret` | JWT refresh |
|
||||||
|
| `cluster-kubeconfig-key` | رمزگذاری kubeconfig کلاسترها (۶۴ hex یا passphrase قوی) |
|
||||||
|
| `redis-password` | Redis پلتفرم + backend (Bull queues) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloud-host-gitops
|
||||||
|
|
||||||
|
# تولید رمزهای تصادفی (یا خودتان مقدار قوی بگذارید)
|
||||||
|
PG_PASS="$(openssl rand -base64 24)"
|
||||||
|
JWT="$(openssl rand -base64 32)"
|
||||||
|
JWT_REFRESH="$(openssl rand -base64 32)"
|
||||||
|
KUBE_KEY="$(openssl rand -hex 32)"
|
||||||
|
REDIS_PASS="$(openssl rand -base64 24)"
|
||||||
|
|
||||||
|
kubectl -n $PLATFORM_NS create secret generic "$PLATFORM_SECRET" \
|
||||||
|
--from-literal=postgres-password="$PG_PASS" \
|
||||||
|
--from-literal=jwt-secret="$JWT" \
|
||||||
|
--from-literal=jwt-refresh-secret="$JWT_REFRESH" \
|
||||||
|
--from-literal=cluster-kubeconfig-key="$KUBE_KEY" \
|
||||||
|
--from-literal=redis-password="$REDIS_PASS" \
|
||||||
|
--dry-run=client -o json \
|
||||||
|
| kubeseal \
|
||||||
|
--controller-name=sealed-secrets-controller \
|
||||||
|
--controller-namespace=kube-system \
|
||||||
|
--format yaml \
|
||||||
|
> "sealed-secrets/${PLATFORM_SECRET}.yaml"
|
||||||
|
|
||||||
|
kubectl apply -f "sealed-secrets/${PLATFORM_SECRET}.yaml"
|
||||||
|
git add "sealed-secrets/${PLATFORM_SECRET}.yaml"
|
||||||
|
git commit -m "chore: seal platform secrets"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
**بررسی:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n $PLATFORM_NS get secret "$PLATFORM_SECRET"
|
||||||
|
kubectl get sealedsecrets -A | grep "$PLATFORM_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
SealedSecretهای CI دیگر (kaniko، runner، repo creds) را طبق [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md) بسازید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فاز ۴ — Logging stack + Secret Elasticsearch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloud-host
|
||||||
|
|
||||||
|
# 1) namespace logging (اگر در manifest نیست)
|
||||||
|
kubectl create namespace $LOGGING_NS --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
# 2) Secret elasticsearch — خارج از git (plaintext commit ممنوع)
|
||||||
|
ELASTIC_PASS="$(openssl rand -base64 24)"
|
||||||
|
FLUENT_PASS="$(openssl rand -base64 24)"
|
||||||
|
|
||||||
|
kubectl -n $LOGGING_NS create secret generic elasticsearch-credentials \
|
||||||
|
--from-literal=ELASTIC_PASSWORD="$ELASTIC_PASS" \
|
||||||
|
--from-literal=FLUENTBIT_PASSWORD="$FLUENT_PASS"
|
||||||
|
|
||||||
|
# یا seal کنید:
|
||||||
|
kubectl -n $LOGGING_NS create secret generic elasticsearch-credentials \
|
||||||
|
--from-literal=ELASTIC_PASSWORD="$ELASTIC_PASS" \
|
||||||
|
--from-literal=FLUENTBIT_PASSWORD="$FLUENT_PASS" \
|
||||||
|
--dry-run=client -o json \
|
||||||
|
| kubeseal --controller-name=sealed-secrets-controller \
|
||||||
|
--controller-namespace=kube-system --format yaml \
|
||||||
|
> ../cloud-host-gitops/sealed-secrets/elasticsearch-credentials.yaml
|
||||||
|
|
||||||
|
# 3) deploy stack (بدون Secret inline — manifest فقط ConfigMap/Deployment دارد)
|
||||||
|
kubectl apply -f backend/k8s/logging/elasticsearch-stack.yaml
|
||||||
|
|
||||||
|
# 4) همان مقادیر را در values پلتفرم بگذارید (backend.env)
|
||||||
|
# ELASTIC_PASSWORD, FLUENTBIT_PASSWORD, KIBANA_SYSTEM_PASSWORD
|
||||||
|
# سپس commit/push در gitops repo
|
||||||
|
```
|
||||||
|
|
||||||
|
> backend در production بدون `ELASTIC_PASSWORD` معتبر **بالا نمیآید** (`validate-production-config`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فاز ۵ — اولین Deploy
|
||||||
|
|
||||||
|
**روش ۱ — CI (توصیه):** push به `main` در `$APP_REPO` → workflow تست + Kaniko + آپدیت tag در gitops → Argo sync.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloud-host
|
||||||
|
git push origin main # یا push به Gitea remote
|
||||||
|
# پیگیری: Gitea Actions UI یا kubectl -n $BUILD_NS get jobs -w
|
||||||
|
```
|
||||||
|
|
||||||
|
**روش ۲ — دستی (bootstrap / بدون CI):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build ایمیجها (روی ماشینی که به registry دسترسی دارد) یا trigger-platform-build.sh
|
||||||
|
TAG="$(date +%Y%m%d-%H%M)-manual"
|
||||||
|
VALUES="../cloud-host-gitops/${VALUES_FILE}"
|
||||||
|
./scripts/gitops-deploy.sh TAG="$TAG" VALUES="$VALUES"
|
||||||
|
```
|
||||||
|
|
||||||
|
**بررسی Argo:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n argocd get app "$ARGO_APP"
|
||||||
|
argocd app sync "$ARGO_APP" # در صورت sync خودکار غیرفعال
|
||||||
|
kubectl -n $PLATFORM_NS get pods
|
||||||
|
kubectl -n $PLATFORM_NS rollout status deploy/cloudhost-backend --timeout=300s
|
||||||
|
kubectl -n $PLATFORM_NS rollout status deploy/cloudhost-frontend --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فاز ۶ — Greenfield / ارتقا از نسخهٔ قدیم
|
||||||
|
|
||||||
|
اگر کلاستر **قبلاً** با نسخهٔ قدیمی CloudHost بالا آمده (namespace کوتاه UUID، migration بدون `schema_migrations`)، **قبل از deploy جدید** دیتابیس را reset کنید.
|
||||||
|
|
||||||
|
> ⚠️ **فقط greenfield / بدون دادهٔ واقعی.** در production با داده، اول backup بگیرید.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) backend را متوقف کنید
|
||||||
|
kubectl -n $PLATFORM_NS scale deploy/cloudhost-backend --replicas=0
|
||||||
|
|
||||||
|
# 2) schema را از نو بسازید
|
||||||
|
kubectl -n $PLATFORM_NS exec deploy/cloudhost-postgres -- \
|
||||||
|
psql -U cloudhost -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
||||||
|
|
||||||
|
# 3) Argo sync — migration Job (pre-upgrade hook) base schema + migrations را اجرا میکند
|
||||||
|
argocd app sync "$ARGO_APP"
|
||||||
|
|
||||||
|
# 4) backend را بالا بیاورید
|
||||||
|
kubectl -n $PLATFORM_NS scale deploy/cloudhost-backend --replicas=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**تغییرات breaking که reset میخواهند:**
|
||||||
|
|
||||||
|
| تغییر | اثر |
|
||||||
|
|-------|-----|
|
||||||
|
| namespace کاربر `user-<uuid-32>` بهجای `user-<8char>` | namespaceهای قدیمی دیگر استفاده نمیشوند — اپها redeploy |
|
||||||
|
| `000_base_schema.sql` + `schema_migrations` | DB باید از نو migrate شود |
|
||||||
|
| `redis-password` جدید | Secret + restart Redis و backend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فاز ۷ — چکلیست تأیید سلامت
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Podها
|
||||||
|
kubectl -n $PLATFORM_NS get deploy,pods
|
||||||
|
kubectl -n $LOGGING_NS get pods
|
||||||
|
|
||||||
|
# API
|
||||||
|
curl -sf "https://${DOMAIN_API}/api/v1/health" && echo OK
|
||||||
|
curl -sf "https://${DOMAIN_API}/api/v1/ready" && echo OK
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
curl -sf -o /dev/null -w '%{http_code}\n' "https://${DOMAIN_LANDING}"
|
||||||
|
curl -sf -o /dev/null -w '%{http_code}\n' "https://${DOMAIN_PANEL}"
|
||||||
|
|
||||||
|
# Migration
|
||||||
|
kubectl -n $PLATFORM_NS logs job/$(kubectl -n $PLATFORM_NS get jobs -o name | grep migration | tail -1 | cut -d/ -f2) 2>/dev/null || true
|
||||||
|
|
||||||
|
# Redis auth
|
||||||
|
kubectl -n $PLATFORM_NS exec deploy/cloudhost-redis -- redis-cli ping
|
||||||
|
|
||||||
|
# Backup CronJob (اگر enabled)
|
||||||
|
kubectl -n $PLATFORM_NS get cronjobs
|
||||||
|
```
|
||||||
|
|
||||||
|
| علامت | اقدام |
|
||||||
|
|-------|-------|
|
||||||
|
| Backend CrashLoop — JWT/DB/Redis | Secret `$PLATFORM_SECRET` و keys — [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md) عیبیابی |
|
||||||
|
| Backend CrashLoop — ELASTIC_PASSWORD | env در values + Secret logging |
|
||||||
|
| Migration fail | `kubectl logs` روی migration Job؛ `schema_migrations` و فایلهای `backend/migrations/` |
|
||||||
|
| Argo OutOfSync | `argocd app diff $ARGO_APP` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مسیر B — Helm مستقیم (بدون GitOps)
|
||||||
|
|
||||||
|
برای lab، staging، یا کلاستری **بدون** Gitea/Argo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp backend/helm/cloudhost-platform/values-production.example.yaml my-values.yaml
|
||||||
|
# ویرایش: hosts, registry, secrets (jwtSecret, postgres.password, redis.password), ingress
|
||||||
|
|
||||||
|
docker build -t $REG/cloudhost-backend:1.0.0 ./backend
|
||||||
|
docker build -t $REG/cloudhost-frontend:1.0.0 \
|
||||||
|
--build-arg NEXT_PUBLIC_API_URL=https://${DOMAIN_API} ./frontend
|
||||||
|
docker push $REG/cloudhost-backend:1.0.0
|
||||||
|
docker push $REG/cloudhost-frontend:1.0.0
|
||||||
|
|
||||||
|
helm upgrade --install cloudhost ./backend/helm/cloudhost-platform \
|
||||||
|
-n $PLATFORM_NS --create-namespace \
|
||||||
|
-f my-values.yaml \
|
||||||
|
--set images.backend.repository=$REG/cloudhost-backend \
|
||||||
|
--set images.frontend.repository=$REG/cloudhost-frontend \
|
||||||
|
--set images.backend.tag=1.0.0 \
|
||||||
|
--set images.frontend.tag=1.0.0 \
|
||||||
|
--set global.storageClass=$STORAGE_CLASS
|
||||||
|
```
|
||||||
|
|
||||||
|
> در این مسیر `secrets.existingSecret` خالی بماند تا Helm Secret بسازد — **برای production با Argo CD توصیه نمیشود** (lookup در `helm template` خالی است).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فایلهای مرجع در ریپو
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| [`gitops/platform/values-abrban.example.yaml`](gitops/platform/values-abrban.example.yaml) | Template values — کپی و rename برای محیط جدید |
|
||||||
|
| [`gitops/sealed-secrets/abrban-platform-secrets.example.yaml`](gitops/sealed-secrets/abrban-platform-secrets.example.yaml) | دستور seal Secret پلتفرم |
|
||||||
|
| [`gitops/sealed-secrets/elasticsearch-credentials.example.yaml`](gitops/sealed-secrets/elasticsearch-credentials.example.yaml) | دستور seal Secret logging |
|
||||||
|
| [`backend/helm/cloudhost-platform/values-production.example.yaml`](backend/helm/cloudhost-platform/values-production.example.yaml) | Template برای مسیر B |
|
||||||
|
| [`scripts/gitops-deploy.sh`](scripts/gitops-deploy.sh) | deploy دستی با Helm + values از gitops |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خلاصهٔ ترتیب (Quick reference)
|
||||||
|
|
||||||
|
```
|
||||||
|
فاز ۰ DNS + kubectl + helm + kubeseal + دو ریپو
|
||||||
|
↓
|
||||||
|
فاز ۱ Argo + Gitea + registry + sealed-secrets + runner + Application
|
||||||
|
↓
|
||||||
|
فاز ۲ کپی values template → ویرایش → push gitops
|
||||||
|
↓
|
||||||
|
فاز ۳ seal platform secrets → push gitops
|
||||||
|
↓
|
||||||
|
فاز ۴ elasticsearch stack + secret + env در values
|
||||||
|
↓
|
||||||
|
فاز ۵ push main (CI) یا gitops-deploy.sh (دستی)
|
||||||
|
↓
|
||||||
|
فاز ۶ (در صورت upgrade) reset DB
|
||||||
|
↓
|
||||||
|
فاز ۷ health check
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
> **استقرار از صفر روی سرور:** [`RUNBOOK-DEPLOY.fa.md`](RUNBOOK-DEPLOY.fa.md) — مراحل values، Secretها، deploy، greenfield reset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۱.۱ 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
|
||||||
@@ -68,6 +75,11 @@ REGISTRY_PASSWORD=registry_secret
|
|||||||
# Build
|
# Build
|
||||||
BUILD_NAMESPACE=cloudhost-builds
|
BUILD_NAMESPACE=cloudhost-builds
|
||||||
BUILD_SERVICE_ACCOUNT=kaniko-builder
|
BUILD_SERVICE_ACCOUNT=kaniko-builder
|
||||||
|
# Kaniko job images — defaults pull from Harbor proxy-cache when unset.
|
||||||
|
# KANIKO_IMAGE=registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2
|
||||||
|
# BUILD_ALPINE_IMAGE=registry.abrban.com/proxy-dockerhub/library/alpine:3.19
|
||||||
|
# BUILD_ALPINE_GIT_IMAGE=registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0
|
||||||
|
# BASE_IMAGE_REGISTRY=registry.abrban.com/proxy-dockerhub/library
|
||||||
|
|
||||||
# Platform
|
# Platform
|
||||||
# Public URL(s) of the frontend — used for CORS and to derive the platform/preview
|
# Public URL(s) of the frontend — used for CORS and to derive the platform/preview
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
# ---- Stage 1: Build ----
|
# ---- Stage 1: Build ----
|
||||||
FROM node:24-alpine AS builder
|
ARG BASE_IMAGE=node:24-alpine
|
||||||
|
FROM ${BASE_IMAGE} AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Stage 2: Production ----
|
# ---- Stage 2: Production ----
|
||||||
FROM node:24-alpine AS production
|
FROM ${BASE_IMAGE} AS production
|
||||||
|
|
||||||
RUN apk add --no-cache dumb-init curl bash \
|
RUN apk add --no-cache dumb-init curl bash \
|
||||||
&& curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz | tar xz -C /tmp \
|
&& curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz | tar xz -C /tmp \
|
||||||
|
|||||||
@@ -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: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -46,6 +46,16 @@ Database deployment name
|
|||||||
{{- printf "%s-db" .Values.app.name }}
|
{{- printf "%s-db" .Values.app.name }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Optional mirror registry prefix for Docker Hub images.
|
||||||
|
Usage: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" "redis:7.2-alpine") }}
|
||||||
|
*/}}
|
||||||
|
{{- define "cloudhost-app.baseImage" -}}
|
||||||
|
{{- $reg := "" -}}
|
||||||
|
{{- with .root.Values.images -}}{{- $reg = .baseRegistry | default "" -}}{{- end -}}
|
||||||
|
{{- if $reg -}}{{ printf "%s/%s" $reg .image }}{{- else -}}{{ .image }}{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Database image — auto-computed from type + version if not explicitly set
|
Database image — auto-computed from type + version if not explicitly set
|
||||||
*/}}
|
*/}}
|
||||||
@@ -53,13 +63,13 @@ Database image — auto-computed from type + version if not explicitly set
|
|||||||
{{- if .Values.database.image }}
|
{{- if .Values.database.image }}
|
||||||
{{- .Values.database.image }}
|
{{- .Values.database.image }}
|
||||||
{{- else if eq .Values.database.type "postgresql" }}
|
{{- else if eq .Values.database.type "postgresql" }}
|
||||||
{{- printf "postgres:%s-alpine" .Values.database.version }}
|
{{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "postgres:%s-alpine" .Values.database.version)) }}
|
||||||
{{- else if eq .Values.database.type "mariadb" }}
|
{{- else if eq .Values.database.type "mariadb" }}
|
||||||
{{- printf "mariadb:%s" .Values.database.version }}
|
{{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mariadb:%s" .Values.database.version)) }}
|
||||||
{{- else if eq .Values.database.type "mongodb" }}
|
{{- else if eq .Values.database.type "mongodb" }}
|
||||||
{{- printf "mongo:%s" .Values.database.version }}
|
{{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mongo:%s" .Values.database.version)) }}
|
||||||
{{- else }}
|
{{- else }}
|
||||||
{{- printf "mysql:%s" .Values.database.version }}
|
{{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mysql:%s" .Values.database.version)) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{- define "cloudhost-app.logShipperContainers" -}}
|
{{- define "cloudhost-app.logShipperContainers" -}}
|
||||||
{{- if .root.Values.elasticsearch.enabled }}
|
{{- if .root.Values.elasticsearch.enabled }}
|
||||||
- name: log-shipper
|
- name: log-shipper
|
||||||
image: fluent/fluent-bit:2.2
|
image: {{ include "cloudhost-app.baseImage" (dict "root" .root "image" "fluent/fluent-bit:2.2") }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: "10m"
|
cpu: "10m"
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ metadata:
|
|||||||
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
# RWO volume + single replica: recreate the old pod before starting the new
|
||||||
|
# one — a rolling update would deadlock on the attached PVC.
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: {{ $dbName }}
|
app: {{ $dbName }}
|
||||||
@@ -114,7 +118,7 @@ spec:
|
|||||||
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
|
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
{{- else if eq .Values.database.type "mongodb" }}
|
{{- else if eq .Values.database.type "mongodb" }}
|
||||||
exec:
|
exec:
|
||||||
command: ["mongosh", "--eval", "db.adminCommand('ping')"]
|
command: ["sh", "-c", "mongosh --quiet -u \"$MONGO_INITDB_ROOT_USERNAME\" -p \"$MONGO_INITDB_ROOT_PASSWORD\" --eval \"db.adminCommand('ping')\""]
|
||||||
{{- else }}
|
{{- else }}
|
||||||
exec:
|
exec:
|
||||||
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
|
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
|
||||||
@@ -131,7 +135,7 @@ spec:
|
|||||||
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
|
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
{{- else if eq .Values.database.type "mongodb" }}
|
{{- else if eq .Values.database.type "mongodb" }}
|
||||||
exec:
|
exec:
|
||||||
command: ["mongosh", "--eval", "db.adminCommand('ping')"]
|
command: ["sh", "-c", "mongosh --quiet -u \"$MONGO_INITDB_ROOT_USERNAME\" -p \"$MONGO_INITDB_ROOT_PASSWORD\" --eval \"db.adminCommand('ping')\""]
|
||||||
{{- else }}
|
{{- else }}
|
||||||
exec:
|
exec:
|
||||||
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
|
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.elasticsearch.enabled }}
|
{{- if .Values.elasticsearch.enabled }}
|
||||||
- name: fluent-bit
|
- name: fluent-bit
|
||||||
image: fluent/fluent-bit:2.2
|
image: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" "fluent/fluent-bit:2.2") }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: "10m"
|
cpu: "10m"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{{- if .Values.elasticsearch.enabled }}
|
{{- if .Values.elasticsearch.enabled }}
|
||||||
|
{{- if not .Values.elasticsearch.fluentbitPassword }}
|
||||||
|
{{- fail "elasticsearch.fluentbitPassword is required when elasticsearch.enabled=true — no hardcoded default is shipped" }}
|
||||||
|
{{- end }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
@@ -8,7 +11,7 @@ metadata:
|
|||||||
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | default "CloudHost2024!Secure" | quote }}
|
ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | quote }}
|
||||||
FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | default "FluentBit2024!Writer" | quote }}
|
FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | quote }}
|
||||||
KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | default "Kibana2024!System" | quote }}
|
KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -2,11 +2,22 @@
|
|||||||
{{- $name := include "cloudhost-app.name" . -}}
|
{{- $name := include "cloudhost-app.name" . -}}
|
||||||
{{- $ns := include "cloudhost-app.namespace" . -}}
|
{{- $ns := include "cloudhost-app.namespace" . -}}
|
||||||
{{- $rabbitName := printf "%s-rabbitmq" $name -}}
|
{{- $rabbitName := printf "%s-rabbitmq" $name -}}
|
||||||
|
{{- /* Preserve the existing password across upgrades — RabbitMQ only applies
|
||||||
|
RABBITMQ_DEFAULT_PASS on first boot, so a regenerated secret would
|
||||||
|
diverge from the credentials stored in the persisted volume. */ -}}
|
||||||
|
{{- $rabbitSecretName := printf "%s-secret" $rabbitName -}}
|
||||||
|
{{- $existingRabbit := lookup "v1" "Secret" $ns $rabbitSecretName -}}
|
||||||
|
{{- $rabbitPass := "" -}}
|
||||||
|
{{- if and $existingRabbit $existingRabbit.data (index $existingRabbit.data "password") -}}
|
||||||
|
{{- $rabbitPass = index $existingRabbit.data "password" | b64dec -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $rabbitPass = randAlphaNum 16 -}}
|
||||||
|
{{- end -}}
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ $rabbitName }}-secret
|
name: {{ $rabbitSecretName }}
|
||||||
namespace: {{ $ns }}
|
namespace: {{ $ns }}
|
||||||
labels:
|
labels:
|
||||||
app: {{ $rabbitName }}
|
app: {{ $rabbitName }}
|
||||||
@@ -16,7 +27,7 @@ metadata:
|
|||||||
type: Opaque
|
type: Opaque
|
||||||
data:
|
data:
|
||||||
username: {{ "appuser" | b64enc | quote }}
|
username: {{ "appuser" | b64enc | quote }}
|
||||||
password: {{ randAlphaNum 16 | b64enc | quote }}
|
password: {{ $rabbitPass | b64enc | quote }}
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
@@ -48,6 +59,9 @@ metadata:
|
|||||||
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
# RWO volume + single replica: recreate instead of rolling update.
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: {{ $rabbitName }}
|
app: {{ $rabbitName }}
|
||||||
@@ -58,7 +72,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: rabbitmq
|
- name: rabbitmq
|
||||||
image: {{ printf "rabbitmq:%s-management-alpine" (.Values.rabbitmq.version | default "3.13") }}
|
image: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "rabbitmq:%s-management-alpine" (.Values.rabbitmq.version | default "3.13"))) }}
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5672
|
- containerPort: 5672
|
||||||
name: amqp
|
name: amqp
|
||||||
@@ -68,12 +82,12 @@ spec:
|
|||||||
- name: RABBITMQ_DEFAULT_USER
|
- name: RABBITMQ_DEFAULT_USER
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ $rabbitName }}-secret
|
name: {{ $rabbitSecretName }}
|
||||||
key: username
|
key: username
|
||||||
- name: RABBITMQ_DEFAULT_PASS
|
- name: RABBITMQ_DEFAULT_PASS
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ $rabbitName }}-secret
|
name: {{ $rabbitSecretName }}
|
||||||
key: password
|
key: password
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: rabbitmq-data
|
- name: rabbitmq-data
|
||||||
|
|||||||
@@ -2,11 +2,21 @@
|
|||||||
{{- $name := include "cloudhost-app.name" . -}}
|
{{- $name := include "cloudhost-app.name" . -}}
|
||||||
{{- $ns := include "cloudhost-app.namespace" . -}}
|
{{- $ns := include "cloudhost-app.namespace" . -}}
|
||||||
{{- $redisName := printf "%s-redis" $name -}}
|
{{- $redisName := printf "%s-redis" $name -}}
|
||||||
|
{{- /* Preserve the existing password across upgrades — regenerating it every
|
||||||
|
upgrade would break app↔Redis auth against the persisted volume. */ -}}
|
||||||
|
{{- $redisSecretName := printf "%s-secret" $redisName -}}
|
||||||
|
{{- $existingRedis := lookup "v1" "Secret" $ns $redisSecretName -}}
|
||||||
|
{{- $redisPass := "" -}}
|
||||||
|
{{- if and $existingRedis $existingRedis.data (index $existingRedis.data "password") -}}
|
||||||
|
{{- $redisPass = index $existingRedis.data "password" | b64dec -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $redisPass = randAlphaNum 16 -}}
|
||||||
|
{{- end -}}
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ $redisName }}-secret
|
name: {{ $redisSecretName }}
|
||||||
namespace: {{ $ns }}
|
namespace: {{ $ns }}
|
||||||
labels:
|
labels:
|
||||||
app: {{ $redisName }}
|
app: {{ $redisName }}
|
||||||
@@ -15,7 +25,7 @@ metadata:
|
|||||||
"helm.sh/resource-policy": keep
|
"helm.sh/resource-policy": keep
|
||||||
type: Opaque
|
type: Opaque
|
||||||
data:
|
data:
|
||||||
password: {{ randAlphaNum 16 | b64enc | quote }}
|
password: {{ $redisPass | b64enc | quote }}
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
@@ -47,6 +57,10 @@ metadata:
|
|||||||
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
# RWO volume + single replica: recreate the old pod before starting the new
|
||||||
|
# one, otherwise a rolling update deadlocks on the attached PVC.
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: {{ $redisName }}
|
app: {{ $redisName }}
|
||||||
@@ -57,7 +71,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: redis
|
- name: redis
|
||||||
image: {{ printf "redis:%s-alpine" (.Values.redis.version | default "7.2") }}
|
image: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "redis:%s-alpine" (.Values.redis.version | default "7.2"))) }}
|
||||||
args: ["--requirepass", "$(REDIS_PASSWORD)"]
|
args: ["--requirepass", "$(REDIS_PASSWORD)"]
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 6379
|
- containerPort: 6379
|
||||||
@@ -65,7 +79,14 @@ spec:
|
|||||||
- name: REDIS_PASSWORD
|
- name: REDIS_PASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ $redisName }}-secret
|
name: {{ $redisSecretName }}
|
||||||
|
key: password
|
||||||
|
# redis-cli in the probes auto-authenticates from REDISCLI_AUTH,
|
||||||
|
# so `redis-cli ping` works even with --requirepass set.
|
||||||
|
- name: REDISCLI_AUTH
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ $redisSecretName }}
|
||||||
key: password
|
key: password
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: redis-data
|
- name: redis-data
|
||||||
|
|||||||
@@ -101,3 +101,9 @@ changeCause: ""
|
|||||||
# ── Registry (for imagePullSecret) ──────────────────────
|
# ── Registry (for imagePullSecret) ──────────────────────
|
||||||
registry:
|
registry:
|
||||||
url: "localhost:30500"
|
url: "localhost:30500"
|
||||||
|
|
||||||
|
# ── Base images ──────────────────────────────────────────
|
||||||
|
images:
|
||||||
|
# Optional mirror registry prefix for Docker Hub images (postgres, mysql,
|
||||||
|
# redis, rabbitmq, fluent-bit, …), e.g. "mirror.example.com".
|
||||||
|
baseRegistry: ""
|
||||||
|
|||||||
@@ -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,52 @@
|
|||||||
|
#!/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"
|
||||||
|
# IMPORTANT: do NOT set HTTP(S)_PROXY on harbor-core for in-cluster registry
|
||||||
|
# traffic. Egress proxy on core causes 502 on blob uploads via harbor-core
|
||||||
|
# (Kaniko/skopeo push fails; Harbor UI metadata never appears).
|
||||||
|
# Keep proxy empty here; Harbor proxy-cache projects can still use project-level
|
||||||
|
# proxy settings when needed. Expand noProxy for safety if proxy is re-enabled.
|
||||||
|
TMP_PROXY_VALUES="$(mktemp)"
|
||||||
|
cat > "${TMP_PROXY_VALUES}" <<EOF
|
||||||
|
proxy:
|
||||||
|
httpProxy: ""
|
||||||
|
httpsProxy: ""
|
||||||
|
noProxy: "harbor-core,harbor-jobservice,harbor-database,harbor-registry,harbor-portal,.svc,.cluster.local,10.43.0.0/16,127.0.0.1,localhost,registry.abrban.com"
|
||||||
|
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] Apply registry ingress path split (proxy-* → harbor-core)"
|
||||||
|
kubectl apply -f "$(dirname "$0")/../../../gitops/harbor/registry-ingress.yaml"
|
||||||
|
|
||||||
|
echo "==> 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: http://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)
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
{{- $auth := printf "elastic:%s" .Values.elasticPassword | b64enc }}
|
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: StatefulSet
|
kind: StatefulSet
|
||||||
metadata:
|
metadata:
|
||||||
@@ -74,22 +73,20 @@ spec:
|
|||||||
- name: es-data
|
- name: es-data
|
||||||
mountPath: /usr/share/elasticsearch/data
|
mountPath: /usr/share/elasticsearch/data
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
exec:
|
||||||
path: /_cluster/health?local=true
|
command:
|
||||||
port: 9200
|
- sh
|
||||||
httpHeaders:
|
- -c
|
||||||
- name: Authorization
|
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
|
||||||
value: Basic {{ $auth }}
|
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
exec:
|
||||||
path: /_cluster/health?local=true
|
command:
|
||||||
port: 9200
|
- sh
|
||||||
httpHeaders:
|
- -c
|
||||||
- name: Authorization
|
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
|
||||||
value: Basic {{ $auth }}
|
|
||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
timeoutSeconds: 10
|
timeoutSeconds: 10
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,20 @@
|
|||||||
-- Temporary external access grants (Redis, RabbitMQ, database)
|
-- Temporary external access grants (Redis, RabbitMQ, database)
|
||||||
CREATE TYPE service_access_target AS ENUM (
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE service_access_target AS ENUM (
|
||||||
'database',
|
'database',
|
||||||
'redis',
|
'redis',
|
||||||
'rabbitmq_amqp',
|
'rabbitmq_amqp',
|
||||||
'rabbitmq_management'
|
'rabbitmq_management'
|
||||||
);
|
);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
||||||
CREATE TYPE service_access_grant_status AS ENUM (
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE service_access_grant_status AS ENUM (
|
||||||
'active',
|
'active',
|
||||||
'expired',
|
'expired',
|
||||||
'revoked'
|
'revoked'
|
||||||
);
|
);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS service_access_grants (
|
CREATE TABLE IF NOT EXISTS service_access_grants (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ ALTER TABLE applications
|
|||||||
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
|
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_applications_user_product_type
|
CREATE INDEX IF NOT EXISTS idx_applications_user_product_type
|
||||||
ON applications (user_id, product_type);
|
ON applications ("userId", product_type);
|
||||||
|
|
||||||
ALTER TABLE resource_credits
|
ALTER TABLE resource_credits
|
||||||
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
|
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -46,8 +46,12 @@ app.kubernetes.io/instance: {{ .Release.Name }}
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- define "cloudhost-platform.secretName" -}}
|
{{- define "cloudhost-platform.secretName" -}}
|
||||||
|
{{- if .Values.secrets.existingSecret }}
|
||||||
|
{{- .Values.secrets.existingSecret }}
|
||||||
|
{{- else }}
|
||||||
{{- printf "%s-secrets" (include "cloudhost-platform.fullname" .) }}
|
{{- printf "%s-secrets" (include "cloudhost-platform.fullname" .) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{- define "cloudhost-platform.tlsSecretName" -}}
|
{{- define "cloudhost-platform.tlsSecretName" -}}
|
||||||
{{- if .Values.ingress.tls.secretName }}
|
{{- if .Values.ingress.tls.secretName }}
|
||||||
@@ -109,3 +113,18 @@ PLATFORM_DOMAIN / preview domain from the first entry only. The panel host
|
|||||||
{{- define "cloudhost-platform.frontendImage" -}}
|
{{- define "cloudhost-platform.frontendImage" -}}
|
||||||
{{- printf "%s:%s" .Values.images.frontend.repository .Values.images.frontend.tag }}
|
{{- printf "%s:%s" .Values.images.frontend.repository .Values.images.frontend.tag }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "cloudhost-platform.buildEnv" -}}
|
||||||
|
- name: KANIKO_IMAGE
|
||||||
|
value: {{ .Values.build.images.kaniko | quote }}
|
||||||
|
- name: BUILD_ALPINE_IMAGE
|
||||||
|
value: {{ .Values.build.images.alpine | quote }}
|
||||||
|
- name: BUILD_ALPINE_GIT_IMAGE
|
||||||
|
value: {{ .Values.build.images.alpineGit | quote }}
|
||||||
|
- name: BASE_IMAGE_REGISTRY
|
||||||
|
value: {{ .Values.build.baseImageRegistry | quote }}
|
||||||
|
{{- if .Values.build.egressProxySecret }}
|
||||||
|
- name: BUILD_EGRESS_PROXY_SECRET
|
||||||
|
value: {{ .Values.build.egressProxySecret | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ metadata:
|
|||||||
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.backend.replicas }}
|
replicas: {{ .Values.backend.replicas }}
|
||||||
|
# Zero-downtime rollouts: DB migrations run in a pre-upgrade hook Job, so the
|
||||||
|
# new pod only starts against a ready schema. The uploads PVC is RWO but
|
||||||
|
# local-path volumes pin pods to the same node, so surge pods can attach.
|
||||||
strategy:
|
strategy:
|
||||||
type: Recreate
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 1
|
||||||
|
maxUnavailable: 0
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: {{ include "cloudhost-platform.backend.fullname" . }}
|
app: {{ include "cloudhost-platform.backend.fullname" . }}
|
||||||
@@ -19,6 +25,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
|
||||||
@@ -68,6 +78,11 @@ spec:
|
|||||||
value: {{ include "cloudhost-platform.redis.fullname" . }}
|
value: {{ include "cloudhost-platform.redis.fullname" . }}
|
||||||
- name: REDIS_PORT
|
- name: REDIS_PORT
|
||||||
value: "6379"
|
value: "6379"
|
||||||
|
- name: REDIS_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
|
key: redis-password
|
||||||
- name: JWT_SECRET
|
- name: JWT_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -78,8 +93,48 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ include "cloudhost-platform.secretName" . }}
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
key: jwt-refresh-secret
|
key: jwt-refresh-secret
|
||||||
|
- name: CLUSTER_KUBECONFIG_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
|
key: cluster-kubeconfig-key
|
||||||
|
- name: ELASTIC_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
|
key: elastic-password
|
||||||
- name: FRONTEND_URL
|
- name: FRONTEND_URL
|
||||||
value: {{ include "cloudhost-platform.corsOrigins" . | quote }}
|
value: {{ include "cloudhost-platform.corsOrigins" . | quote }}
|
||||||
|
{{- if .Values.backend.sms.enabled }}
|
||||||
|
- name: SMS_PROVIDER
|
||||||
|
value: {{ .Values.backend.sms.provider | default "mizbansms" | quote }}
|
||||||
|
- name: MIZBANSMS_FROM
|
||||||
|
value: {{ .Values.backend.sms.from | default "5000467254" | quote }}
|
||||||
|
- name: MIZBANSMS_API
|
||||||
|
value: {{ .Values.backend.sms.api | default "2016" | quote }}
|
||||||
|
- name: MIZBANSMS_USERTYPE
|
||||||
|
value: {{ .Values.backend.sms.userType | default "2" | quote }}
|
||||||
|
- name: MIZBANSMS_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
|
key: mizbansms-username
|
||||||
|
- name: MIZBANSMS_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
|
key: mizbansms-password
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.registry.credentialsSecret }}
|
||||||
|
- name: REGISTRY_USERNAME
|
||||||
|
value: {{ .Values.registry.username | default "harbor_registry_user" | quote }}
|
||||||
|
- name: REGISTRY_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.registry.credentialsSecret | quote }}
|
||||||
|
key: {{ .Values.registry.credentialsPasswordKey | default "REGISTRY_CREDENTIAL_PASSWORD" | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- include "cloudhost-platform.buildEnv" . | nindent 12 }}
|
||||||
{{- range $key, $val := .Values.backend.env }}
|
{{- range $key, $val := .Values.backend.env }}
|
||||||
- name: {{ $key }}
|
- name: {{ $key }}
|
||||||
value: {{ $val | quote }}
|
value: {{ $val | quote }}
|
||||||
@@ -88,19 +143,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 }}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
||||||
annotations:
|
annotations:
|
||||||
helm.sh/hook: post-install,post-upgrade
|
# Run BEFORE the backend rolls out so schema-dependent code never starts
|
||||||
|
# against an unmigrated database.
|
||||||
|
helm.sh/hook: pre-install,pre-upgrade
|
||||||
helm.sh/hook-weight: "5"
|
helm.sh/hook-weight: "5"
|
||||||
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
|
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
|
||||||
spec:
|
spec:
|
||||||
@@ -47,9 +49,20 @@ spec:
|
|||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
set -e
|
set -e
|
||||||
|
# Track applied migrations so each file runs exactly once — the
|
||||||
|
# loop is idempotent across every helm upgrade.
|
||||||
|
psql -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW());"
|
||||||
for f in $(ls /migrations/*.sql | sort); do
|
for f in $(ls /migrations/*.sql | sort); do
|
||||||
echo ">>> Applying $f"
|
name=$(basename "$f")
|
||||||
psql -v ON_ERROR_STOP=1 -f "$f"
|
applied=$(psql -tA -c "SELECT 1 FROM schema_migrations WHERE filename = '$name';")
|
||||||
|
if [ "$applied" = "1" ]; then
|
||||||
|
echo ">>> Skipping $name (already applied)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo ">>> Applying $name"
|
||||||
|
psql -v ON_ERROR_STOP=1 --single-transaction \
|
||||||
|
-f "$f" \
|
||||||
|
-c "INSERT INTO schema_migrations (filename) VALUES ('$name');"
|
||||||
done
|
done
|
||||||
echo ">>> All migrations applied"
|
echo ">>> All migrations applied"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
|
|||||||
@@ -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,64 @@
|
|||||||
|
{{- 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"
|
||||||
|
# Retention: keep the last {{ .Values.backups.postgres.retentionDays | default 7 }} days
|
||||||
|
find /backup -name 'cloudhost-*.sql.gz' -mtime +{{ .Values.backups.postgres.retentionDays | default 7 }} -delete
|
||||||
|
volumeMounts:
|
||||||
|
- name: backup
|
||||||
|
mountPath: /backup
|
||||||
|
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 }}
|
||||||
@@ -19,6 +19,10 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: {{ include "cloudhost-platform.postgres.fullname" . }}
|
app: {{ include "cloudhost-platform.postgres.fullname" . }}
|
||||||
spec:
|
spec:
|
||||||
|
{{- with .Values.postgres.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
image: {{ .Values.images.postgres | quote }}
|
image: {{ .Values.images.postgres | quote }}
|
||||||
|
|||||||
@@ -19,9 +19,26 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: {{ include "cloudhost-platform.redis.fullname" . }}
|
app: {{ include "cloudhost-platform.redis.fullname" . }}
|
||||||
spec:
|
spec:
|
||||||
|
{{- with .Values.redis.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: redis
|
- name: redis
|
||||||
image: {{ .Values.images.redis | quote }}
|
image: {{ .Values.images.redis | quote }}
|
||||||
|
args: ["--requirepass", "$(REDIS_PASSWORD)"]
|
||||||
|
env:
|
||||||
|
- name: REDIS_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
|
key: redis-password
|
||||||
|
# redis-cli reads REDISCLI_AUTH so authenticated probes need no -a flag
|
||||||
|
- name: REDISCLI_AUTH
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
|
key: redis-password
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 6379
|
- containerPort: 6379
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
{{- if not .Values.secrets.existingSecret }}
|
||||||
|
{{/*
|
||||||
|
NOTE: lookup only works with `helm install/upgrade` (CLI). Argo CD renders with
|
||||||
|
`helm template` where lookup is always empty, so values would be regenerated on
|
||||||
|
every sync. For GitOps deployments set secrets.existingSecret and manage the
|
||||||
|
Secret out-of-band (e.g. SealedSecret in the gitops repo).
|
||||||
|
*/}}
|
||||||
{{- $existing := lookup "v1" "Secret" (include "cloudhost-platform.namespace" .) (include "cloudhost-platform.secretName" .) }}
|
{{- $existing := lookup "v1" "Secret" (include "cloudhost-platform.namespace" .) (include "cloudhost-platform.secretName" .) }}
|
||||||
{{- $pgPass := .Values.postgres.password }}
|
{{- $pgPass := .Values.postgres.password }}
|
||||||
{{- if not $pgPass }}
|
{{- if not $pgPass }}
|
||||||
@@ -11,6 +18,14 @@
|
|||||||
{{- if not $jwtRefresh }}
|
{{- if not $jwtRefresh }}
|
||||||
{{- if $existing }}{{- $jwtRefresh = index $existing.data "jwt-refresh-secret" | b64dec }}{{- else }}{{- $jwtRefresh = randAlphaNum 32 }}{{- end }}
|
{{- if $existing }}{{- $jwtRefresh = index $existing.data "jwt-refresh-secret" | b64dec }}{{- else }}{{- $jwtRefresh = randAlphaNum 32 }}{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- $kubeconfigKey := .Values.secrets.clusterKubeconfigKey }}
|
||||||
|
{{- if not $kubeconfigKey }}
|
||||||
|
{{- if and $existing (hasKey $existing.data "cluster-kubeconfig-key") }}{{- $kubeconfigKey = index $existing.data "cluster-kubeconfig-key" | b64dec }}{{- else }}{{- $kubeconfigKey = randAlphaNum 32 }}{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- $redisPass := .Values.redis.password }}
|
||||||
|
{{- if not $redisPass }}
|
||||||
|
{{- if and $existing (hasKey $existing.data "redis-password") }}{{- $redisPass = index $existing.data "redis-password" | b64dec }}{{- else }}{{- $redisPass = randAlphaNum 24 }}{{- end }}
|
||||||
|
{{- end }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
@@ -23,3 +38,6 @@ stringData:
|
|||||||
postgres-password: {{ $pgPass | quote }}
|
postgres-password: {{ $pgPass | quote }}
|
||||||
jwt-secret: {{ $jwt | quote }}
|
jwt-secret: {{ $jwt | quote }}
|
||||||
jwt-refresh-secret: {{ $jwtRefresh | quote }}
|
jwt-refresh-secret: {{ $jwtRefresh | quote }}
|
||||||
|
cluster-kubeconfig-key: {{ $kubeconfigKey | quote }}
|
||||||
|
redis-password: {{ $redisPass | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ global:
|
|||||||
storageClass: local-path # k3s example
|
storageClass: local-path # k3s example
|
||||||
|
|
||||||
images:
|
images:
|
||||||
|
# Mirror Docker Hub images through your private registry so cluster nodes
|
||||||
|
# never pull from docker.io directly (matches the kaniko/Harbor setup).
|
||||||
|
postgres: registry.example.com/mirror/postgres:16-alpine
|
||||||
|
redis: registry.example.com/mirror/redis:7-alpine
|
||||||
|
busybox: registry.example.com/mirror/busybox:1.36
|
||||||
backend:
|
backend:
|
||||||
repository: registry.example.com/cloudhost-backend
|
repository: registry.example.com/cloudhost-backend
|
||||||
tag: "1.0.0"
|
tag: "1.0.0"
|
||||||
@@ -17,8 +22,25 @@ images:
|
|||||||
tag: "1.0.0"
|
tag: "1.0.0"
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
|
|
||||||
|
# Build job images — override for clusters without Harbor proxy-cache.
|
||||||
|
build:
|
||||||
|
images:
|
||||||
|
kaniko: registry.example.com/proxy-gcr/kaniko-project/executor:v1.23.2
|
||||||
|
alpine: registry.example.com/proxy-dockerhub/library/alpine:3.19
|
||||||
|
alpineGit: registry.example.com/proxy-dockerhub/alpine/git:2.43.0
|
||||||
|
baseImageRegistry: registry.example.com/proxy-dockerhub/library
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
password: "CHANGE_ME_STRONG_POSTGRES_PASSWORD"
|
password: "CHANGE_ME_STRONG_POSTGRES_PASSWORD"
|
||||||
|
# Pull secret for the mirrored postgres image
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
|
|
||||||
|
redis:
|
||||||
|
# Auto-generated and persisted in the platform Secret when left empty.
|
||||||
|
password: ""
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
jwtSecret: "CHANGE_ME_LONG_JWT_SECRET"
|
jwtSecret: "CHANGE_ME_LONG_JWT_SECRET"
|
||||||
@@ -36,10 +58,26 @@ 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
|
||||||
REGISTRY_PULL_URL: registry.cloudhost-builds.svc.cluster.local:5000
|
REGISTRY_PULL_URL: registry.cloudhost-builds.svc.cluster.local:5000
|
||||||
|
# Elastic log-stack credentials (must match the logging namespace Secret)
|
||||||
|
ELASTIC_PASSWORD: "CHANGE_ME_ELASTIC_PASSWORD"
|
||||||
|
FLUENTBIT_PASSWORD: "CHANGE_ME_FLUENTBIT_PASSWORD"
|
||||||
|
KIBANA_SYSTEM_PASSWORD: "CHANGE_ME_KIBANA_PASSWORD"
|
||||||
|
# Swagger stays off in production; set SWAGGER_ENABLED: "true" to expose it
|
||||||
|
|
||||||
migrations:
|
migrations:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
backups:
|
||||||
|
postgres:
|
||||||
|
enabled: true
|
||||||
|
schedule: "0 3 * * *"
|
||||||
|
storageSize: 10Gi
|
||||||
|
retentionDays: 7
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ createNamespace: true
|
|||||||
global:
|
global:
|
||||||
storageClass: ""
|
storageClass: ""
|
||||||
|
|
||||||
|
# For clusters without direct docker.io access, point these at your mirror,
|
||||||
|
# e.g. registry.abrban.com/abrban/postgres:16-alpine, and set
|
||||||
|
# postgres.imagePullSecrets / redis.imagePullSecrets accordingly.
|
||||||
images:
|
images:
|
||||||
postgres: postgres:16-alpine
|
postgres: postgres:16-alpine
|
||||||
redis: redis:7-alpine
|
redis: redis:7-alpine
|
||||||
@@ -25,6 +28,26 @@ images:
|
|||||||
tag: "1.0.0"
|
tag: "1.0.0"
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Kaniko push credentials — harbor_registry_user for harbor-registry:5000 (Harbor production).
|
||||||
|
registry:
|
||||||
|
credentialsSecret: ""
|
||||||
|
credentialsPasswordKey: REGISTRY_CREDENTIAL_PASSWORD
|
||||||
|
username: harbor_registry_user
|
||||||
|
|
||||||
|
# Kaniko job images — defaults pull from Harbor proxy-cache.
|
||||||
|
# Override any line for a different registry/tag.
|
||||||
|
build:
|
||||||
|
images:
|
||||||
|
# Seeded into abrban/ via gitops/jobs/seed-ci-images.yaml — avoid flaky proxy-gcr pulls.
|
||||||
|
kaniko: registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug
|
||||||
|
alpine: registry.abrban.com/abrban/alpine:3.19
|
||||||
|
alpineGit: registry.abrban.com/abrban/alpine-git:2.43.0
|
||||||
|
# Seeded base images (gitops/jobs/seed-ci-images.yaml) — proxy-dockerhub cache can be corrupt on first pull.
|
||||||
|
baseImageRegistry: registry.abrban.com/abrban
|
||||||
|
# Secret with HTTP_PROXY/HTTPS_PROXY for Kaniko build jobs (npm, apk, git clone).
|
||||||
|
# Set to registry-egress-proxy in production; leave empty when nodes have direct egress.
|
||||||
|
egressProxySecret: ""
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
enabled: true
|
enabled: true
|
||||||
database: cloudhost
|
database: cloudhost
|
||||||
@@ -32,20 +55,57 @@ postgres:
|
|||||||
# Leave empty to auto-generate on first install (stored in Secret)
|
# Leave empty to auto-generate on first install (stored in Secret)
|
||||||
password: ""
|
password: ""
|
||||||
storage: 10Gi
|
storage: 10Gi
|
||||||
resources: {}
|
# Needed when images.postgres points at a private mirror
|
||||||
|
imagePullSecrets: []
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: "2"
|
||||||
|
memory: 2Gi
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
enabled: true
|
enabled: true
|
||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
resources: {}
|
# Leave empty to auto-generate on first install (stored in Secret as redis-password).
|
||||||
|
# With secrets.existingSecret, that Secret must also contain a redis-password key.
|
||||||
|
password: ""
|
||||||
|
# Needed when images.redis points at a private mirror
|
||||||
|
imagePullSecrets: []
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
enabled: true
|
enabled: true
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
uploads:
|
uploads:
|
||||||
size: 20Gi
|
size: 20Gi
|
||||||
resources: {}
|
sourceStorage:
|
||||||
|
enabled: false
|
||||||
|
existingSecret: ceph-app-sources-credentials
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: "2"
|
||||||
|
memory: 2Gi
|
||||||
extraEnv: {}
|
extraEnv: {}
|
||||||
|
# OTP SMS — credentials live in the platform Secret (mizbansms-username/password).
|
||||||
|
sms:
|
||||||
|
enabled: false
|
||||||
|
provider: mizbansms
|
||||||
|
from: "5000467254"
|
||||||
|
api: "2016"
|
||||||
|
userType: "2"
|
||||||
env:
|
env:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "4000"
|
PORT: "4000"
|
||||||
@@ -66,12 +126,27 @@ backend:
|
|||||||
frontend:
|
frontend:
|
||||||
enabled: true
|
enabled: true
|
||||||
replicas: 1
|
replicas: 1
|
||||||
resources: {}
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 1Gi
|
||||||
|
|
||||||
# JWT secrets — set in production (values-production.example.yaml)
|
# JWT secrets — set in production (values-production.example.yaml)
|
||||||
secrets:
|
secrets:
|
||||||
|
# Use a pre-created Secret instead of chart-managed one. Required for GitOps
|
||||||
|
# (Argo CD renders with `helm template`, so lookup/randAlphaNum regenerate on
|
||||||
|
# every sync). Secret must contain keys: postgres-password, jwt-secret,
|
||||||
|
# jwt-refresh-secret, cluster-kubeconfig-key, redis-password, elastic-password.
|
||||||
|
existingSecret: ""
|
||||||
jwtSecret: ""
|
jwtSecret: ""
|
||||||
jwtRefreshSecret: ""
|
jwtRefreshSecret: ""
|
||||||
|
# AES key for encrypting stored kubeconfigs (64 hex chars or any passphrase)
|
||||||
|
clusterKubeconfigKey: ""
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -97,3 +172,13 @@ ingress:
|
|||||||
migrations:
|
migrations:
|
||||||
enabled: true
|
enabled: true
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|
||||||
|
monitoring:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
backups:
|
||||||
|
postgres:
|
||||||
|
enabled: true
|
||||||
|
schedule: "0 3 * * *"
|
||||||
|
storageSize: 10Gi
|
||||||
|
retentionDays: 7
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -9,18 +9,16 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/managed-by: cloudhost
|
app.kubernetes.io/managed-by: cloudhost
|
||||||
---
|
---
|
||||||
# Elasticsearch credentials secret
|
# Elasticsearch credentials — managed OUT-OF-BAND, never committed to git.
|
||||||
apiVersion: v1
|
# Create the Secret before applying this manifest (or use a SealedSecret in
|
||||||
kind: Secret
|
# the GitOps repo):
|
||||||
metadata:
|
#
|
||||||
name: elasticsearch-credentials
|
# kubectl -n logging create secret generic elasticsearch-credentials \
|
||||||
namespace: logging
|
# --from-literal=ELASTIC_PASSWORD="$(openssl rand -base64 24)" \
|
||||||
type: Opaque
|
# --from-literal=FLUENTBIT_PASSWORD="$(openssl rand -base64 24)"
|
||||||
stringData:
|
#
|
||||||
# Admin credentials - change in production!
|
# The backend reads the same values from ELASTIC_PASSWORD / FLUENTBIT_PASSWORD
|
||||||
ELASTIC_PASSWORD: "CloudHost2024!Secure"
|
# env vars (see cloudhost-platform values: backend.extraEnv or an extra Secret).
|
||||||
# For Fluent Bit to send logs
|
|
||||||
FLUENTBIT_PASSWORD: "FluentBit2024!Writer"
|
|
||||||
---
|
---
|
||||||
# ConfigMap for Elasticsearch configuration
|
# ConfigMap for Elasticsearch configuration
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -183,24 +181,20 @@ spec:
|
|||||||
- name: data
|
- name: data
|
||||||
mountPath: /usr/share/elasticsearch/data
|
mountPath: /usr/share/elasticsearch/data
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
exec:
|
||||||
path: /_cluster/health?local=true
|
command:
|
||||||
port: 9200
|
- sh
|
||||||
scheme: HTTP
|
- -c
|
||||||
httpHeaders:
|
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
|
||||||
- name: Authorization
|
|
||||||
value: "Basic ZWxhc3RpYzpDbG91ZEhvc3QyMDI0IVNlY3VyZQ=="
|
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
exec:
|
||||||
path: /_cluster/health?local=true
|
command:
|
||||||
port: 9200
|
- sh
|
||||||
scheme: HTTP
|
- -c
|
||||||
httpHeaders:
|
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
|
||||||
- name: Authorization
|
|
||||||
value: "Basic ZWxhc3RpYzpDbG91ZEhvc3QyMDI0IVNlY3VyZQ=="
|
|
||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
timeoutSeconds: 10
|
timeoutSeconds: 10
|
||||||
|
|||||||
@@ -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).
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,20 @@
|
|||||||
-- Temporary external access grants (Redis, RabbitMQ, database)
|
-- Temporary external access grants (Redis, RabbitMQ, database)
|
||||||
CREATE TYPE service_access_target AS ENUM (
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE service_access_target AS ENUM (
|
||||||
'database',
|
'database',
|
||||||
'redis',
|
'redis',
|
||||||
'rabbitmq_amqp',
|
'rabbitmq_amqp',
|
||||||
'rabbitmq_management'
|
'rabbitmq_management'
|
||||||
);
|
);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
||||||
CREATE TYPE service_access_grant_status AS ENUM (
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE service_access_grant_status AS ENUM (
|
||||||
'active',
|
'active',
|
||||||
'expired',
|
'expired',
|
||||||
'revoked'
|
'revoked'
|
||||||
);
|
);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS service_access_grants (
|
CREATE TABLE IF NOT EXISTS service_access_grants (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ ALTER TABLE applications
|
|||||||
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
|
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_applications_user_product_type
|
CREATE INDEX IF NOT EXISTS idx_applications_user_product_type
|
||||||
ON applications (user_id, product_type);
|
ON applications ("userId", product_type);
|
||||||
|
|
||||||
ALTER TABLE resource_credits
|
ALTER TABLE resource_credits
|
||||||
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
|
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
|
||||||
|
|||||||
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,94 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generate the greenfield base schema (000_base_schema.sql) by letting TypeORM
|
||||||
|
* `synchronize` build every table from the entities against a throwaway
|
||||||
|
* Postgres, then dumping the schema. Run when entities change materially:
|
||||||
|
*
|
||||||
|
* docker run -d --name ch-schemagen -e POSTGRES_PASSWORD=pass \
|
||||||
|
* -e POSTGRES_USER=cloudhost -e POSTGRES_DB=cloudhost \
|
||||||
|
* -p 55432:5432 postgres:16-alpine
|
||||||
|
* node scripts/generate-base-schema.mjs
|
||||||
|
*
|
||||||
|
* The output is wrapped so it is safe to run on an already-populated database
|
||||||
|
* (every statement uses IF NOT EXISTS / duplicate_object guards where possible;
|
||||||
|
* the migration runner also records it in schema_migrations so it runs once).
|
||||||
|
*/
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const backendRoot = path.resolve(__dirname, '..');
|
||||||
|
const outPath = path.join(backendRoot, 'migrations', '000_base_schema.sql');
|
||||||
|
|
||||||
|
// Use the COMPILED entities (run `npm run build` first) — union-typed columns
|
||||||
|
// only carry correct decorator metadata through the project's tsc build.
|
||||||
|
const ds = new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
host: process.env.SCHEMA_DB_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.SCHEMA_DB_PORT || '55432', 10),
|
||||||
|
username: 'cloudhost',
|
||||||
|
password: 'pass',
|
||||||
|
database: 'cloudhost',
|
||||||
|
entities: [path.join(backendRoot, 'dist/**/*.entity.js')],
|
||||||
|
synchronize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ds.initialize();
|
||||||
|
await ds.destroy();
|
||||||
|
|
||||||
|
// Dump schema-only from the container, then strip owner/ACL noise.
|
||||||
|
const dumped = execFileSync('docker', [
|
||||||
|
'exec', 'ch-schemagen',
|
||||||
|
'pg_dump', '-U', 'cloudhost', '-d', 'cloudhost',
|
||||||
|
'--schema-only', '--no-owner', '--no-privileges',
|
||||||
|
], { maxBuffer: 32 * 1024 * 1024 }).toString();
|
||||||
|
|
||||||
|
// Strip:
|
||||||
|
// - psql client meta-commands that are version-specific (\restrict is
|
||||||
|
// pg_dump 16.13+ only) and would break on the migrations image's psql;
|
||||||
|
// - the `search_path = ''` reset, which otherwise persists into the trailing
|
||||||
|
// `INSERT INTO schema_migrations` the runner appends (unqualified) and the
|
||||||
|
// footer below, causing "no schema has been selected to create in".
|
||||||
|
const raw = dumped
|
||||||
|
.split('\n')
|
||||||
|
.filter(
|
||||||
|
(line) =>
|
||||||
|
!/^\\(restrict|unrestrict)\b/.test(line) &&
|
||||||
|
!/set_config\('search_path'/.test(line),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const header = `-- 000_base_schema.sql — greenfield base schema (generated from TypeORM entities).
|
||||||
|
-- Auto-generated by scripts/generate-base-schema.mjs. Do not edit by hand.
|
||||||
|
-- Incremental migrations (001+) run afterwards on top of this schema.
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// The legacy pricing-catalog migrations (004-009) target a superseded
|
||||||
|
// snake_case pricing schema that is incompatible with the current entities.
|
||||||
|
// On greenfield the base schema already creates the entity-shaped pricing
|
||||||
|
// tables and the app self-seeds their rows (PricingCatalogService.ensureDefaults
|
||||||
|
// on boot), so mark those migrations as already applied to skip them.
|
||||||
|
const supersededPricingMigrations = [
|
||||||
|
'004_pricing_catalog.sql',
|
||||||
|
'005_pricing_catalog_all_runtimes.sql',
|
||||||
|
'006_addon_rate_resources.sql',
|
||||||
|
'007_optional_service_pricing_matrix.sql',
|
||||||
|
'008_application_optional_service_resources.sql',
|
||||||
|
'009_optional_service_deploy_defaults.sql',
|
||||||
|
];
|
||||||
|
const footer = `
|
||||||
|
|
||||||
|
-- Mark superseded legacy pricing migrations as applied (see generator note).
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
|
||||||
|
INSERT INTO schema_migrations (filename) VALUES
|
||||||
|
${supersededPricingMigrations.map((m) => ` ('${m}')`).join(',\n')}
|
||||||
|
ON CONFLICT (filename) DO NOTHING;
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(outPath, header + raw + footer);
|
||||||
|
console.log(`Wrote ${outPath} (${raw.length} bytes)`);
|
||||||
@@ -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({
|
||||||
@@ -51,18 +53,22 @@ import configuration from './config/configuration';
|
|||||||
redis: {
|
redis: {
|
||||||
host: configService.get('redis.host'),
|
host: configService.get('redis.host'),
|
||||||
port: configService.get('redis.port'),
|
port: configService.get('redis.port'),
|
||||||
|
password: configService.get('redis.password'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
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 +82,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
|
||||||
@@ -373,6 +387,29 @@ export class ApplicationsController {
|
|||||||
throw new BadRequestException('Replicas can only be changed for the main application workload.');
|
throw new BadRequestException('Replicas can only be changed for the main application workload.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-staff users must go through the billed upgrade flow for any change
|
||||||
|
// that increases cost — direct PATCH must not bypass payment.
|
||||||
|
if (!isStaff) {
|
||||||
|
const upgradeDto =
|
||||||
|
workload === 'app'
|
||||||
|
? {
|
||||||
|
cpuLimit: dto.cpuLimit,
|
||||||
|
memoryLimit: dto.memoryLimit,
|
||||||
|
replicas: dto.replicas,
|
||||||
|
}
|
||||||
|
: workload === 'database'
|
||||||
|
? { databaseResources: { cpuLimit: dto.cpuLimit, memoryLimit: dto.memoryLimit } }
|
||||||
|
: workload === 'redis'
|
||||||
|
? { redisResources: { cpuLimit: dto.cpuLimit, memoryLimit: dto.memoryLimit } }
|
||||||
|
: { rabbitmqResources: { cpuLimit: dto.cpuLimit, memoryLimit: dto.memoryLimit } };
|
||||||
|
const cost = await this.billingService.calculateUpgradeCost(app, upgradeDto as any);
|
||||||
|
if (cost.proratedAmount > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This change increases the plan cost. Use the resource upgrade flow (with invoice payment) instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update in K8s (live)
|
// Update in K8s (live)
|
||||||
await this.kubernetesService.updateResources(app, dto, workload);
|
await this.kubernetesService.updateResources(app, dto, workload);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ 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 { userIdSlug } from '../kubernetes/k8s-workload.util';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApplicationsService {
|
export class ApplicationsService {
|
||||||
@@ -28,7 +34,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 {
|
||||||
@@ -62,6 +68,18 @@ export class ApplicationsService {
|
|||||||
dto = normalizeCreateApplicationDto(dto);
|
dto = normalizeCreateApplicationDto(dto);
|
||||||
const productType = dto.productType ?? ProductType.APPLICATION;
|
const productType = dto.productType ?? ProductType.APPLICATION;
|
||||||
|
|
||||||
|
// WordPress only runs on MySQL/MariaDB — reject PostgreSQL/Mongo/none up
|
||||||
|
// front instead of failing at runtime inside the WordPress container.
|
||||||
|
if (dto.runtime === AppRuntime.WORDPRESS) {
|
||||||
|
if (!dto.databaseType || dto.databaseType === DatabaseType.NONE) {
|
||||||
|
dto.databaseType = DatabaseType.MYSQL;
|
||||||
|
} else if (![DatabaseType.MYSQL, DatabaseType.MARIADB].includes(dto.databaseType)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`WordPress requires a MySQL or MariaDB database — "${dto.databaseType}" is not supported.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Placement is always decided automatically by the allocator.
|
// Placement is always decided automatically by the allocator.
|
||||||
const allocation = await this.clustersService.selectClusterForApplication(dto, userId);
|
const allocation = await this.clustersService.selectClusterForApplication(dto, userId);
|
||||||
const clusterId = allocation.cluster.id;
|
const clusterId = allocation.cluster.id;
|
||||||
@@ -91,7 +109,7 @@ export class ApplicationsService {
|
|||||||
|
|
||||||
const baseLabel = dto.name;
|
const baseLabel = dto.name;
|
||||||
const subdomain = customDomain
|
const subdomain = customDomain
|
||||||
? `${this.toDnsLabel(baseLabel)}-${this.toDnsLabel(userId.split('-')[0])}`
|
? `${this.toDnsLabel(baseLabel)}-${this.toDnsLabel(userIdSlug(userId).slice(0, 12))}`
|
||||||
: await this.generateRandomSubdomain(baseLabel);
|
: await this.generateRandomSubdomain(baseLabel);
|
||||||
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
|
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
|
||||||
|
|
||||||
@@ -206,20 +224,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);
|
|
||||||
}
|
|
||||||
// Remove any legacy on-disk dump/source dir (db dumps are still stored locally).
|
|
||||||
try {
|
try {
|
||||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
await this.sourceStorage.deleteSource(app.userId, app.id, app.codePath);
|
||||||
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) {
|
} catch (e: any) {
|
||||||
this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`);
|
this.logger.warn(`Failed to delete source for ${app.name}: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.appsRepository.remove(app);
|
await this.appsRepository.remove(app);
|
||||||
@@ -265,15 +276,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 storedPath = await this.sourceStorage.putSource(app.userId, app.id, file.buffer);
|
||||||
|
app.codePath = storedPath;
|
||||||
const saved = await this.appsRepository.save(app);
|
const saved = await this.appsRepository.save(app);
|
||||||
|
|
||||||
this.logger.log(`Uploaded code for ${app.name} → ${key} (${(file.size / 1024).toFixed(1)} KB)`);
|
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;
|
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> {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
CustomDomainStatus,
|
CustomDomainStatus,
|
||||||
ProductType,
|
ProductType,
|
||||||
} from '../../common/enums';
|
} from '../../common/enums';
|
||||||
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { Deployment } from '../../deployments/entities/deployment.entity';
|
import { Deployment } from '../../deployments/entities/deployment.entity';
|
||||||
|
|
||||||
@@ -51,9 +52,16 @@ export class Application {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
dbUsername: string;
|
dbUsername: string;
|
||||||
|
|
||||||
|
/** Never expose raw DB password in API responses — use hasDbPassword for UI. */
|
||||||
|
@Exclude({ toPlainOnly: true })
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
dbPassword: string;
|
dbPassword: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
get hasDbPassword(): boolean {
|
||||||
|
return !!this.dbPassword;
|
||||||
|
}
|
||||||
|
|
||||||
@Column({ nullable: true, default: '1Gi' })
|
@Column({ nullable: true, default: '1Gi' })
|
||||||
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
|
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
|
||||||
|
|
||||||
@@ -111,8 +119,19 @@ export class Application {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
gitUrl: string;
|
gitUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal access token for private repos. Never serialized into API
|
||||||
|
* responses (see hasGitToken) — it is a credential to an external system.
|
||||||
|
*/
|
||||||
|
@Exclude({ toPlainOnly: true })
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
gitToken: string; // Personal access token for private repos
|
gitToken: string;
|
||||||
|
|
||||||
|
/** Whether a git token is configured (safe indicator for the UI). */
|
||||||
|
@Expose()
|
||||||
|
get hasGitToken(): boolean {
|
||||||
|
return !!this.gitToken;
|
||||||
|
}
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
gitBranch: string; // Branch to clone (default: main)
|
gitBranch: string; // Branch to clone (default: main)
|
||||||
|
|||||||
@@ -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,199 @@
|
|||||||
|
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,
|
||||||
|
issueGatewayTrackingCode,
|
||||||
|
assertGatewayTrackingCodeValid,
|
||||||
|
} from './payment-gateway.util';
|
||||||
|
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||||
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
|
import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto';
|
||||||
|
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 — stub gateway, dev/staging only)' })
|
||||||
|
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
|
||||||
|
// Direct self-credit is only for environments with the stub gateway enabled.
|
||||||
|
// In production a real payment gateway must credit wallets.
|
||||||
|
assertStubGatewayAllowed();
|
||||||
|
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = issueGatewayTrackingCode(req.user.id, body.amount);
|
||||||
|
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();
|
||||||
|
// The tracking code binds user + amount at initiate time; reject tampered amounts.
|
||||||
|
assertGatewayTrackingCodeValid(body.trackingCode, req.user.id, body.amount);
|
||||||
|
await this.billingService.chargeWallet(
|
||||||
|
req.user.id,
|
||||||
|
body.amount,
|
||||||
|
`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 {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull, MoreThan, FindOptionsWhere } from 'typeorm';
|
import { Repository, IsNull, MoreThan, FindOptionsWhere, EntityManager } from 'typeorm';
|
||||||
import { Wallet } from './entities/wallet.entity';
|
import { Wallet } from './entities/wallet.entity';
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||||
import { Invoice } from './entities/invoice.entity';
|
import { Invoice } from './entities/invoice.entity';
|
||||||
@@ -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 ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,6 +172,32 @@ export class BillingService {
|
|||||||
return { balance: Number(wallet.balance) };
|
return { balance: Number(wallet.balance) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the user's wallet inside a transaction with a row-level lock
|
||||||
|
* (SELECT ... FOR UPDATE) so concurrent charge/deduct operations serialize
|
||||||
|
* instead of racing on read-modify-write.
|
||||||
|
*/
|
||||||
|
private async lockWallet(em: EntityManager, userId: string): Promise<Wallet> {
|
||||||
|
let wallet = await em.getRepository(Wallet).findOne({
|
||||||
|
where: { userId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!wallet) {
|
||||||
|
// First-time wallet creation may race; the unique userId column makes
|
||||||
|
// one insert win — re-read with the lock afterwards.
|
||||||
|
try {
|
||||||
|
await em.getRepository(Wallet).insert({ userId, balance: 0 });
|
||||||
|
} catch {
|
||||||
|
/* concurrent insert won — fall through to locked re-read */
|
||||||
|
}
|
||||||
|
wallet = await em.getRepository(Wallet).findOneOrFail({
|
||||||
|
where: { userId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
async chargeWallet(
|
async chargeWallet(
|
||||||
userId: string,
|
userId: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
@@ -168,11 +206,12 @@ export class BillingService {
|
|||||||
): Promise<WalletTransaction> {
|
): Promise<WalletTransaction> {
|
||||||
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||||
|
|
||||||
const wallet = await this.getOrCreateWallet(userId);
|
const saved = await this.walletRepo.manager.transaction(async (em) => {
|
||||||
|
const wallet = await this.lockWallet(em, userId);
|
||||||
wallet.balance = Number(wallet.balance) + amount;
|
wallet.balance = Number(wallet.balance) + amount;
|
||||||
await this.walletRepo.save(wallet);
|
await em.getRepository(Wallet).save(wallet);
|
||||||
|
|
||||||
const tx = this.txRepo.create({
|
const tx = em.getRepository(WalletTransaction).create({
|
||||||
walletId: wallet.id,
|
walletId: wallet.id,
|
||||||
type: TransactionType.CHARGE,
|
type: TransactionType.CHARGE,
|
||||||
amount,
|
amount,
|
||||||
@@ -180,9 +219,10 @@ export class BillingService {
|
|||||||
description: description || 'Wallet charge',
|
description: description || 'Wallet charge',
|
||||||
invoiceId,
|
invoiceId,
|
||||||
});
|
});
|
||||||
const saved = await this.txRepo.save(tx);
|
return em.getRepository(WalletTransaction).save(tx);
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${wallet.balance}`);
|
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${saved.balanceAfter}`);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,15 +235,16 @@ export class BillingService {
|
|||||||
): Promise<WalletTransaction> {
|
): Promise<WalletTransaction> {
|
||||||
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||||
|
|
||||||
const wallet = await this.getOrCreateWallet(userId);
|
const saved = await this.walletRepo.manager.transaction(async (em) => {
|
||||||
|
const wallet = await this.lockWallet(em, userId);
|
||||||
if (Number(wallet.balance) < amount) {
|
if (Number(wallet.balance) < amount) {
|
||||||
throw new BadRequestException('Insufficient wallet balance');
|
throw new BadRequestException('Insufficient wallet balance');
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet.balance = Number(wallet.balance) - amount;
|
wallet.balance = Number(wallet.balance) - amount;
|
||||||
await this.walletRepo.save(wallet);
|
await em.getRepository(Wallet).save(wallet);
|
||||||
|
|
||||||
const tx = this.txRepo.create({
|
const tx = em.getRepository(WalletTransaction).create({
|
||||||
walletId: wallet.id,
|
walletId: wallet.id,
|
||||||
type: TransactionType.DEDUCTION,
|
type: TransactionType.DEDUCTION,
|
||||||
amount,
|
amount,
|
||||||
@@ -212,9 +253,10 @@ export class BillingService {
|
|||||||
applicationId,
|
applicationId,
|
||||||
invoiceId,
|
invoiceId,
|
||||||
});
|
});
|
||||||
const saved = await this.txRepo.save(tx);
|
return em.getRepository(WalletTransaction).save(tx);
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${wallet.balance}`);
|
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${saved.balanceAfter}`);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,7 +773,9 @@ export class BillingService {
|
|||||||
yearly: newCost.yearly - currentCost.yearly,
|
yearly: newCost.yearly - currentCost.yearly,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate prorated amount based on remaining time in billing period
|
// Calculate prorated amount based on remaining time in billing period.
|
||||||
|
// Use the price difference of the app's own billing cycle scaled by the
|
||||||
|
// fraction of the cycle that remains — not the hourly rate for all cycles.
|
||||||
let proratedAmount = 0;
|
let proratedAmount = 0;
|
||||||
let remainingHours = 0;
|
let remainingHours = 0;
|
||||||
|
|
||||||
@@ -740,9 +784,18 @@ export class BillingService {
|
|||||||
const expiresAt = new Date(app.planExpiresAt);
|
const expiresAt = new Date(app.planExpiresAt);
|
||||||
remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60));
|
remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60));
|
||||||
|
|
||||||
|
const cycleDifference = this.amountForCycle(difference, app.billingCycle);
|
||||||
|
const cycleHours =
|
||||||
|
app.billingCycle === BillingCycle.HOURLY
|
||||||
|
? 1
|
||||||
|
: app.billingCycle === BillingCycle.MONTHLY
|
||||||
|
? 30 * 24
|
||||||
|
: 365 * 24;
|
||||||
|
|
||||||
// Only charge difference if upgrading (not downgrading)
|
// Only charge difference if upgrading (not downgrading)
|
||||||
if (difference.hourly > 0) {
|
if (cycleDifference > 0) {
|
||||||
proratedAmount = Math.ceil(difference.hourly * remainingHours);
|
const remainingFraction = Math.min(1, remainingHours / cycleHours);
|
||||||
|
proratedAmount = Math.ceil(cycleDifference * remainingFraction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatewaySigningSecret(): string {
|
||||||
|
return (
|
||||||
|
process.env.PAYMENT_GATEWAY_SIGNING_SECRET ||
|
||||||
|
process.env.JWT_SECRET ||
|
||||||
|
'default-jwt-secret'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmacSignature(payload: string): string {
|
||||||
|
return createHmac('sha256', gatewaySigningSecret()).update(payload).digest('hex').slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a tracking code that cryptographically binds the initiating user and
|
||||||
|
* amount, so `verify` cannot be replayed with a different (larger) amount.
|
||||||
|
* Format: PAY-<ts>-<rand>-<hmac(userId|amount|ts|rand)>
|
||||||
|
*/
|
||||||
|
export function issueGatewayTrackingCode(userId: string, amount: number): string {
|
||||||
|
const ts = Date.now().toString(36);
|
||||||
|
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
const sig = hmacSignature(`${userId}|${amount}|${ts}|${rand}`);
|
||||||
|
return `PAY-${ts}-${rand}-${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a tracking code issued by {@link issueGatewayTrackingCode} against
|
||||||
|
* the calling user and the amount being credited. Throws on any mismatch.
|
||||||
|
*/
|
||||||
|
export function assertGatewayTrackingCodeValid(
|
||||||
|
trackingCode: string,
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
): void {
|
||||||
|
const parts = String(trackingCode || '').split('-');
|
||||||
|
if (parts.length !== 4 || parts[0] !== 'PAY') {
|
||||||
|
throw new BadRequestException('Invalid gateway tracking code');
|
||||||
|
}
|
||||||
|
const [, ts, rand, sig] = parts;
|
||||||
|
const expected = hmacSignature(`${userId}|${amount}|${ts}|${rand}`);
|
||||||
|
const a = Buffer.from(sig);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
throw new BadRequestException('Gateway tracking code does not match the payment details');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// workload, so its storage is billed once regardless of app replicas.
|
||||||
|
const dbStorage = dto.dbStorageSize
|
||||||
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
||||||
: 0) +
|
: 0;
|
||||||
(dto.appStorageSize
|
const appStorage = dto.appStorageSize
|
||||||
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
||||||
: 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,105 @@
|
|||||||
|
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 SESSION_KEY_PREFIX = 'build:session:';
|
||||||
|
const TTL_SECONDS = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable subset of an active build session, persisted to Redis so that
|
||||||
|
* after a backend restart the orphaned cluster resources (job, PVC, secret,
|
||||||
|
* helper pod) of interrupted builds can still be located and cleaned up.
|
||||||
|
*/
|
||||||
|
export interface PersistedBuildSession {
|
||||||
|
deploymentId: string;
|
||||||
|
applicationId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
buildPodName?: string;
|
||||||
|
sourcePvcName?: string;
|
||||||
|
helperPodName?: string;
|
||||||
|
gitSecretName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BuildProgressStore implements OnModuleDestroy {
|
||||||
|
private readonly redis: Redis;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.redis = new Redis({
|
||||||
|
host: this.configService.get<string>('redis.host'),
|
||||||
|
port: this.configService.get<number>('redis.port'),
|
||||||
|
password: this.configService.get<string>('redis.password'),
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
});
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSession(session: PersistedBuildSession): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.set(
|
||||||
|
`${SESSION_KEY_PREFIX}${session.deploymentId}`,
|
||||||
|
JSON.stringify(session),
|
||||||
|
'EX',
|
||||||
|
TTL_SECONDS,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort — cleanup falls back to prefix-based resource scan.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(deploymentId: string): Promise<PersistedBuildSession | null> {
|
||||||
|
try {
|
||||||
|
const raw = await this.redis.get(`${SESSION_KEY_PREFIX}${deploymentId}`);
|
||||||
|
return raw ? (JSON.parse(raw) as PersistedBuildSession) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearSession(deploymentId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.del(`${SESSION_KEY_PREFIX}${deploymentId}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
this.redis.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,247 @@
|
|||||||
|
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;
|
|
||||||
}): string {
|
|
||||||
const wpVersion = app.runtimeVersion || '6.7';
|
|
||||||
const phpVersion = app.phpVersion || '8.3';
|
|
||||||
const hasUploadedCode = !!app.codePath;
|
|
||||||
|
|
||||||
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
|
|
||||||
RUN docker-php-ext-install opcache
|
|
||||||
RUN a2enmod rewrite
|
|
||||||
RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini
|
|
||||||
${hasUploadedCode ? `COPY . /tmp/user-content
|
|
||||||
RUN mkdir -p /usr/src/wordpress-user
|
|
||||||
ENTRYPOINT ["cloudhost-entrypoint.sh"]
|
|
||||||
CMD []` : `CMD ["apache2-foreground"]`}
|
|
||||||
EXPOSE 80
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should use ENTRYPOINT (not CMD) when user uploaded code', () => {
|
|
||||||
const df = wordpressDockerfile({ codePath: '/some/path/source.zip' });
|
|
||||||
expect(df).toContain('ENTRYPOINT ["cloudhost-entrypoint.sh"]');
|
|
||||||
expect(df).not.toContain('CMD ["cloudhost-entrypoint.sh"]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use CMD apache2-foreground for fresh install (no code)', () => {
|
|
||||||
const df = wordpressDockerfile({});
|
|
||||||
expect(df).toContain('CMD ["apache2-foreground"]');
|
|
||||||
expect(df).not.toContain('ENTRYPOINT');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use correct WordPress and PHP versions', () => {
|
|
||||||
const df = wordpressDockerfile({ runtimeVersion: '6.4', phpVersion: '8.2' });
|
|
||||||
expect(df).toContain('FROM wordpress:6.4-php8.2-apache');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default to WP 6.7 and PHP 8.3', () => {
|
|
||||||
const df = wordpressDockerfile({});
|
|
||||||
expect(df).toContain('FROM wordpress:6.7-php8.3-apache');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should COPY user content when codePath exists', () => {
|
|
||||||
const df = wordpressDockerfile({ codePath: '/tmp/source.zip' });
|
|
||||||
expect(df).toContain('COPY . /tmp/user-content');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT copy user content for fresh install', () => {
|
|
||||||
const df = wordpressDockerfile({});
|
|
||||||
expect(df).not.toContain('COPY . /tmp/user-content');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Helper pod PVC race condition', () => {
|
|
||||||
it('should wait for pod deletion (not just fire-and-forget)', () => {
|
|
||||||
// Simulate the fix: after deleteNamespacedPod, poll readNamespacedPod until 404
|
|
||||||
const deletionSteps = [
|
|
||||||
{ exists: true }, // pod still terminating
|
|
||||||
{ exists: true }, // still terminating
|
|
||||||
{ exists: false }, // gone (404)
|
|
||||||
];
|
|
||||||
|
|
||||||
let pollCount = 0;
|
|
||||||
let fullyTerminated = false;
|
|
||||||
|
|
||||||
for (const step of deletionSteps) {
|
|
||||||
pollCount++;
|
|
||||||
if (!step.exists) {
|
|
||||||
fullyTerminated = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(fullyTerminated).toBe(true);
|
|
||||||
expect(pollCount).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should time out if pod never terminates', () => {
|
|
||||||
const maxPolls = 30; // e.g. 60s / 2s interval
|
|
||||||
let pollCount = 0;
|
|
||||||
let timedOut = false;
|
|
||||||
|
|
||||||
while (pollCount < maxPolls) {
|
|
||||||
pollCount++;
|
|
||||||
// Pod always exists (simulating stuck termination)
|
|
||||||
const exists = true;
|
|
||||||
if (!exists) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pollCount >= maxPolls) {
|
|
||||||
timedOut = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(timedOut).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('WordPress entrypoint script', () => {
|
|
||||||
const entrypointScript = `#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Merge user wp-content into PVC
|
|
||||||
if [ -d /usr/src/wordpress-user/wp-content ]; then
|
|
||||||
mkdir -p /var/www/html/wp-content
|
|
||||||
cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/
|
|
||||||
chown -R www-data:www-data /var/www/html/wp-content
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec docker-entrypoint.sh apache2-foreground`;
|
|
||||||
|
|
||||||
it('should call docker-entrypoint.sh exactly once (via exec)', () => {
|
|
||||||
const matches = entrypointScript.match(/docker-entrypoint\.sh/g);
|
|
||||||
expect(matches).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use exec to replace process', () => {
|
|
||||||
expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge wp-content on every start when staged content exists', () => {
|
|
||||||
expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content');
|
|
||||||
expect(entrypointScript).not.toContain('.user-content-merged');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not copy user wp-config.php (credentials come from env vars)', () => {
|
|
||||||
expect(entrypointScript).not.toContain('wp-config.php');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set proper ownership after merging wp-content', () => {
|
|
||||||
expect(entrypointScript).toContain('chown -R www-data:www-data /var/www/html/wp-content');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('WordPress zip structure handling', () => {
|
|
||||||
// The unzip init container handles single-subfolder flattening
|
|
||||||
it('should flatten single subfolder (public_html/) to root', () => {
|
|
||||||
// Simulate: zip contains only public_html/
|
|
||||||
const extractedItems = ['public_html'];
|
|
||||||
const count = extractedItems.length;
|
|
||||||
const firstItem = extractedItems[0];
|
|
||||||
|
|
||||||
let flattenedToRoot = false;
|
|
||||||
if (count === 1 && firstItem === 'public_html') {
|
|
||||||
// cp -a /tmp/extract/public_html/. /workspace-out/source/
|
|
||||||
flattenedToRoot = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(flattenedToRoot).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should copy as-is when multiple items exist', () => {
|
|
||||||
// Simulate: zip contains multiple items at root
|
|
||||||
const extractedItems = ['wp-admin', 'wp-content', 'wp-includes', 'index.php'];
|
|
||||||
const count = extractedItems.length;
|
|
||||||
|
|
||||||
let copiedAsIs = false;
|
|
||||||
if (count !== 1) {
|
|
||||||
copiedAsIs = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(copiedAsIs).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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' }],
|
|
||||||
};
|
};
|
||||||
}
|
return map[key];
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: ClustersService, useValue: {} },
|
||||||
|
{
|
||||||
|
provide: BuildProgressStore,
|
||||||
|
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
|
||||||
|
},
|
||||||
|
{ provide: RegistryService, useValue: {} },
|
||||||
|
{
|
||||||
|
provide: SourceStorageService,
|
||||||
|
useValue: {
|
||||||
|
isObjectStorage: () => false,
|
||||||
|
materializeToTempFile: jest.fn(),
|
||||||
|
getSize: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).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('baseImage', () => {
|
||||||
expect(nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }).image).toBe('ghcr.io/railwayapp/nixpacks:latest');
|
it('prefixes Docker Hub library images (tag colon must not block mirroring)', () => {
|
||||||
expect(
|
const config = (service as any).configService as { get: jest.Mock };
|
||||||
nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }, { nixpacksImage: 'registry.local/nixpacks:1.2.3' }).image,
|
config.get.mockImplementation((key: string) => {
|
||||||
).toBe('registry.local/nixpacks:1.2.3');
|
if (key === 'build.baseImageRegistry') return 'registry.abrban.com/abrban';
|
||||||
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bakes build-time mirror env into the build via --env flags', () => {
|
expect((service as any).baseImage('node:20-alpine')).toBe(
|
||||||
const script = nixpacksPrepareInitContainer(
|
'registry.abrban.com/abrban/node:20-alpine',
|
||||||
{ runtime: AppRuntime.NODEJS },
|
);
|
||||||
{ nixpacksBuildEnv: ['NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'] },
|
expect((service as any).baseImage('alpine:3.19')).toBe(
|
||||||
).command[2] as string;
|
'registry.abrban.com/abrban/alpine:3.19',
|
||||||
expect(script).toContain(`--env 'NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'`);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps the selected Node version to NIXPACKS_NODE_VERSION', () => {
|
it('leaves images that already reference an external registry unchanged', () => {
|
||||||
const c = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS, runtimeVersion: '20' });
|
const config = (service as any).configService as { get: jest.Mock };
|
||||||
expect(c.env).toContainEqual({ name: 'NIXPACKS_NODE_VERSION', value: '20' });
|
config.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'build.baseImageRegistry') return 'registry.abrban.com/abrban';
|
||||||
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shellQuote escapes embedded single quotes safely', () => {
|
expect((service as any).baseImage('mcr.microsoft.com/dotnet/sdk:8.0')).toBe(
|
||||||
expect(shellQuote("a'b")).toBe("'a'\\''b'");
|
'mcr.microsoft.com/dotnet/sdk:8.0',
|
||||||
|
);
|
||||||
|
expect((service as any).baseImage('registry.abrban.com/abrban/node:20-alpine')).toBe(
|
||||||
|
'registry.abrban.com/abrban/node:20-alpine',
|
||||||
|
);
|
||||||
|
expect((service as any).baseImage('localhost:5000/myapp:latest')).toBe(
|
||||||
|
'localhost:5000/myapp:latest',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateDockerfile', () => {
|
||||||
|
it('generates Go Dockerfile with requested runtime version', () => {
|
||||||
|
const app = {
|
||||||
|
runtime: AppRuntime.GO,
|
||||||
|
runtimeVersion: '1.22',
|
||||||
|
port: 8080,
|
||||||
|
} as Application;
|
||||||
|
|
||||||
|
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('FROM golang:1.22-alpine');
|
||||||
|
expect(dockerfile).toContain('EXPOSE 8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates Go Dockerfile with cmd package when present in archive entries', () => {
|
||||||
|
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 mirrored base images when registry prefix is set', async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
BuildService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'build.namespace': 'cloudhost-builds',
|
||||||
|
'build.serviceAccount': 'kaniko-builder',
|
||||||
|
'build.baseImageRegistry': 'registry.abrban.com/abrban',
|
||||||
|
'registry.url': 'registry.local:5000',
|
||||||
|
};
|
||||||
|
return map[key];
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: ClustersService, useValue: {} },
|
||||||
|
{
|
||||||
|
provide: BuildProgressStore,
|
||||||
|
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
|
||||||
|
},
|
||||||
|
{ provide: RegistryService, useValue: {} },
|
||||||
|
{
|
||||||
|
provide: SourceStorageService,
|
||||||
|
useValue: {
|
||||||
|
isObjectStorage: () => false,
|
||||||
|
materializeToTempFile: jest.fn(),
|
||||||
|
getSize: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const mirrored = module.get(BuildService);
|
||||||
|
const app = {
|
||||||
|
runtime: AppRuntime.NODEJS,
|
||||||
|
runtimeVersion: '20',
|
||||||
|
} as Application;
|
||||||
|
|
||||||
|
const dockerfile = (mirrored as any).generateDockerfile(app) as string;
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('FROM registry.abrban.com/abrban/node:20-alpine');
|
||||||
|
expect(dockerfile).toContain('EXPOSE 3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('egressProxyEnvFrom', () => {
|
||||||
|
it('returns secretRef when BUILD_EGRESS_PROXY_SECRET is set', () => {
|
||||||
|
const config = (service as any).configService as { get: jest.Mock };
|
||||||
|
config.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'build.egressProxySecret') return 'registry-egress-proxy';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((service as any).egressProxyEnvFrom()).toEqual([
|
||||||
|
{ secretRef: { name: 'registry-egress-proxy' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when egress proxy is disabled', () => {
|
||||||
|
const config = (service as any).configService as { get: jest.Mock };
|
||||||
|
config.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'build.egressProxySecret') return '';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((service as any).egressProxyEnvFrom()).toBeUndefined();
|
||||||
|
expect((service as any).withEgressProxy({ name: 'kaniko' })).toEqual({ name: 'kaniko' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1155
-384
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user