33 Commits

Author SHA1 Message Date
keyhan 54ab2f2f05 Fix Kaniko registry auth and push target for Harbor builds.
Mount docker config as config.json (Kaniko requirement), push via
harbor-registry internal URL, and wire harbor_registry_user credentials
in Helm/GitOps values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 12:29:23 +03:30
keyhan 214b617be0 Fix image pull/build failures on restricted egress clusters.
Use seeded abrban/ images instead of flaky proxy-gcr pulls, fix Kaniko dockerfile path for v1.27, correct docker auth host keys, route /v2/abrban/ through harbor-core, and prefer abrban/ for base images.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 20:08:16 +03:30
keyhan 2679c9d66e Fix Kaniko image refs to use seeded abrban/ images.
v1.27.6-debug does not exist on gcr.io; proxy-gcr pulls are flaky on kubelet. Seed and reference abrban/kaniko-executor:v1.23.2 instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:31:37 +03:30
keyhan 1ec4d07939 Add egress proxy to user-app Kaniko build jobs.
Inject registry-egress-proxy into Kaniko and network init containers so npm/apk/composer/pip/git clone work on restricted egress clusters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:07:48 +03:30
keyhan 3d773a4a62 feat(platform): wire OTP SMS env from platform Secret via Helm
Build and Deploy Platform / build-and-deploy (push) Successful in 32m19s
Enable backend.sms in the chart so MizbanSMS credentials from the platform
Secret are injected into the backend deployment for production OTP delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 20:05:08 +03:30
keyhan b2ecdad53b fix(logging): use ELASTIC_PASSWORD env in ES health probes
Build and Deploy Platform / build-and-deploy (push) Failing after 30m14s
Hardcoded Basic auth in probes broke after password rotation; exec probes
read the live secret so Elasticsearch stays healthy when credentials change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 19:41:19 +03:30
keyhan a2fe61b1f6 fix(platform): inject ELASTIC_PASSWORD from platform secrets in production
Build and Deploy Platform / build-and-deploy (push) Successful in 16m13s
Backend validate-production-config requires a non-default ELASTIC_PASSWORD;
read it from abrban-platform-secrets.elastic-password like other credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 19:24:34 +03:30
keyhan d3bbc0c0a0 fix(build): default Kaniko and init images to Harbor via Helm values
Build and Deploy Platform / build-and-deploy (push) Successful in 14m57s
User-app builds no longer pull gcr.io/docker.io directly when build.images
is configured in values.yaml, fixing ImagePullBackOff on clusters without
upstream registry access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 14:20:36 +03:30
keyhan 8163665c86 fix(platform): close remaining audit findings from security review
Harden preview/deploy flows, OTP generation, zip extraction, and multi-replica billing races; document full remediation status in AUDIT-STATUS.fa.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 12:30:22 +03:30
keyhan 6d9cd89cc5 docs: add portable from-zero deploy runbook and GitOps templates
Document server-side rollout (values, Sealed Secrets, logging, greenfield
reset) with environment variables so any cluster can follow the same steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 12:17:55 +03:30
keyhan 22359be40e fix(platform): apply production hardening from audit plan
Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:35:07 +03:30
keyhan 34c110be6a docs: document CI/CD bootstrap, seed images, and troubleshooting
Build and Deploy Platform / build-and-deploy (push) Successful in 8m9s
Add bootstrap checklist (seed-ci-images, Sealed Secrets, two-repo GitOps),
Kaniko BASE_IMAGE/insecure-pull details, platform secret requirements, and
updated gitops install order. Link RUNBOOK-CICD from README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:21:48 +03:30
keyhan 38b4a67db1 ci: add --insecure-pull for kaniko to pull base image from harbor-registry:5000
Build and Deploy Platform / build-and-deploy (push) Successful in 16m17s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:38:44 +03:30
keyhan 7695cb5420 ci: use internal harbor-registry URL for BASE_IMAGE pull (matches kaniko auth)
Build and Deploy Platform / build-and-deploy (push) Failing after 47s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:35:44 +03:30
keyhan 6ba77eebcf ci: pull node base image from Harbor instead of docker.io
Build and Deploy Platform / build-and-deploy (push) Failing after 47s
Kaniko builds failed with context deadline exceeded pulling node:24-alpine
from index.docker.io through the egress proxy. Seed node:24-alpine into
abrban/ and pass BASE_IMAGE build-arg so builds use the internal registry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:32:41 +03:30
keyhan f2e8195d1d ci: retry kaniko jobs (backoffLimit 2, push-retry) for flaky egress to docker.io
Build and Deploy Platform / build-and-deploy (push) Failing after 17m55s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:53:48 +03:30
keyhan ba82a5d785 ci: fail fast on kaniko job failure with logs, longer job TTL for post-mortem
Build and Deploy Platform / build-and-deploy (push) Failing after 15m40s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:35:07 +03:30
keyhan 1572b3ce66 fix(chart): support existingSecret and provide CLUSTER_KUBECONFIG_KEY to backend
Build and Deploy Platform / build-and-deploy (push) Failing after 50m25s
Backend now fails production validation without CLUSTER_KUBECONFIG_KEY.
Add cluster-kubeconfig-key to the chart secret and env, plus
secrets.existingSecret so GitOps deployments can use a pre-created
(sealed) Secret instead of the lookup/randAlphaNum template that churns
under Argo CD's helm template rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:39:06 +03:30
keyhan abfe858909 ci: use seeded abrban/alpine-git for clone init (proxy-dockerhub 404s for kubelet)
Build and Deploy Platform / build-and-deploy (push) Successful in 37m36s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 15:36:37 +03:30
keyhan e05e5e54ca ci: fix kaniko base-image pulls via egress proxy, use alpine/git for clone, longer build timeout
Build and Deploy Platform / build-and-deploy (push) Waiting to run
- Kaniko needs the egress proxy to pull node:24-alpine from docker.io
  (envFrom registry-egress-proxy; NO_PROXY keeps harbor push direct)
- Replace flaky apk-add-git init with alpine/git image
- Dump kaniko logs into workflow output on build timeout/failure

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 15:30:44 +03:30
keyhan 7e66d1edf3 ci: split GitOps state into cloud-host-gitops repo, add Sealed Secrets, fix pipeline auth
Build and Deploy Platform / build-and-deploy (push) Failing after 20m1s
- Workflow now pushes image tags to the separate cloud-host-gitops repo
  (no more CI loop risk) and authenticates via CI_TOKEN secret
- Fix undefined ${REGISTRY} in Kaniko jobs, add concurrency group,
  targeted tag update, and mounted kaniko-harbor-auth docker config
- Argo CD Application is now multi-source (chart from cloud-host,
  values from cloud-host-gitops)
- Remove plaintext runner token and proxy credentials from manifests;
  secrets are now SealedSecrets in the gitops repo

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 14:10:46 +03:30
keyhan d4559920d1 Fix CI: use git clone instead of actions/checkout, host mode without GitHub access.
Build and Deploy Platform / build-and-deploy (push) Failing after 20m1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 02:07:40 +03:30
keyhan c97152fa9e Fix CI runner: seed images to abrban/, kubectl via alpine init, Kaniko Jobs for builds.
Build and Deploy Platform / build-and-deploy (push) Failing after 15s
Harbor proxy-cache works from core API but containerd can't use it directly,
so all runner images are seeded to abrban/ project.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 02:04:49 +03:30
keyhan 5ed2ef0958 Add GitOps stack for abrban.com with Gitea Actions CI/CD.
Build and Deploy Platform / build-push-deploy (push) Has been cancelled
Harbor in-cluster builds via Kaniko, ArgoCD auto-sync, and production Helm values for abrban.com domains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 01:27:20 +03:30
keyhan ee5bd0a291 Add Harbor/Ceph runbooks and align single-node Ceph docs with abr deployment.
Document registry ingress split, image mirroring, loop OSD setup, and platform integration paths.
2026-07-01 00:22:06 +03:30
keyhan 8d1855b89c Detect and validate app runtime from uploaded archives.
Reject zip uploads when the selected runtime does not match archive contents, and re-validate before Kaniko builds to fail fast instead of producing the wrong Dockerfile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 00:27:03 +03:30
keyhan 837f0fa63f Harden platform security, reliability, and CI after full audit.
Close deployment IDOR and gate stub payment endpoints, add production
secret validation, health probes, Redis-backed build progress, GitHub
Actions CI, expanded tests, billing/k8s refactors, and ops runbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 20:59:49 +03:30
keyhan a87bc49393 docs: sync README + ARCHITECTURE to the Kaniko build pipeline
Update both docs to match the code after reverting the build-pipeline revamp:
Kaniko with per-runtime generated Dockerfiles, disk/PVC + git source, no
Nixpacks/MinIO/Trivy/registry-GC/build-queue. Also refreshes the stack
(Next.js 16, NestJS 11), mobile-OTP auth, the three Helm charts, project
structure, and local + cluster deploy steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:27:32 +03:30
keyhan 9c16b462f4 revert(build): remove app build pipeline revamp (Nixpacks/MinIO/Trivy/registry GC)
Reverts commits 3eff38f and c379a23 and restores the previous Kaniko-only
build pipeline (runtime detection + per-runtime Dockerfile generation,
disk-based source upload).

Removed: Nixpacks Dockerfile generation, MinIO source storage (common/storage),
Bull build queue + Redis build state (common/redis, deployment.processor),
Trivy image scan (scan.service, deployment.vulnerabilitySummary), and daily
registry garbage collection (registry-gc). Nothing outside the build/deploy
path depended on these. Backend tsc + 105/106 tests green (the pre-existing
helm.service chartPath failure is unrelated); frontend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:21:30 +03:30
keyhan bd14eb2daa feat(blog): real bilingual blog with covers, list & article pages
- Add blog content module (posts.ts): 4 platform-focused articles (deploy,
  managed databases, custom domain + SSL, replicas & cost) in fa/en with
  tag, date, reading time, excerpt and structured body.
- Self-contained gradient cover art (BlogCover) — no external images, themed
  per topic with the brand cloud motif.
- Landing blog section now shows the 3 latest posts with covers, linking to
  article pages; "view all" links to /blog.
- Dedicated /blog list page and /blog/[slug] article page over the shared
  cloud-sky shell (BlogShell), with a not-found state.
- Remove the now-redundant "Learn" header nav item (blog covers getting
  started) and drop its orphan section anchor.
- i18n: blog minRead/notFound/backToBlog strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:35:02 +03:30
keyhan f89c3de826 feat(landing): glass header nav with side drawer + blog section
- Header: full glassmorphism navigation (services, pricing, estimator, learn,
  blog). Desktop shows an inline glass pill; mobile opens a side drawer that
  slides in from the inline-start edge (right in RTL, left in LTR) over a
  blurred backdrop.
- Nav links smooth-scroll via the shared Lenis instance (exposed through
  scroll-store: setLenis/scrollToId), with an 80px offset under the header.
- "Learn" links to the existing how-it-works steps (id="learn"); add a
  lightweight Blog teaser section (id="blog", "coming soon" cards).
- i18n: fa/en strings for nav and blog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:36:27 +03:30
keyhan 985a23751e fix(landing): merge the final scene's two suns into one
The closing sky drew two suns: the 3D sun mesh (corona + GodRays) at a
fixed world point, and a disc/halo painted by the sky shader aimed at a
fixed direction. As the camera flew forward, parallax split them apart —
the mesh rode high while the painted halo stayed low.

Aim the sky shader's uSunDir at the real camera→sun direction each frame
so the halo locks onto the mesh (one sun), and lower SUN_POS.y 34→13 so
that single sun sits at the lower, near-horizon spot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:48:49 +03:30
keyhan f7974dd382 feat(landing,billing): public pricing, global discount, services bar & estimator
Backend
- Add platform-wide global discount (platform_settings: global_discount_percent),
  applied centrally in PricingCatalogService.computeTotalsFromDb so it reaches
  every real charge (previews, deploys, renewals, upgrades, invoices). Admin
  GET/PATCH /billing/settings/global-discount.
- Add unauthenticated PublicPricingController (catalog + calculate) for the
  public landing page, returning gross/net and the discount percentage.
- Bill application replicas by the user-selected footprint: app CPU/RAM/storage
  now all scale by replica count; the single-replica database stays unscaled.

Frontend
- Landing: Services bar (PaaS active, DBaaS, KaaS/LaaS "coming soon" with
  expandable runtime/database menus), transparent Pricing section (per-resource
  rate cards with cycle toggle + discount strikethrough), and a cost Estimator
  ("estimate your package").
- Optional services and the database are priced like runtimes: the estimator
  lets users pick their CPU/RAM/storage (and DB type) so the cost scales by need.
- Admin billing: global-discount editor.
- i18n: fa/en strings for services, pricing, estimator and global discount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:44:30 +03:30
222 changed files with 18723 additions and 3910 deletions
+288
View File
@@ -0,0 +1,288 @@
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 directly to harbor-registry (internal, no TLS)
PUSH_REGISTRY: harbor-registry.cloudhost.svc.cluster.local:5000
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 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.23.2
# 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.23.2
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
+68
View File
@@ -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
View File
@@ -2,7 +2,14 @@
## 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
│ (Browser / CLI) │
│ USERS / ADMINS (Browser)
└──────────────────────────┬──────────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────────────┐
FRONTEND (Next.js 14)
┌──────────┐ ┌───────────────┐ ┌──────────┐ ┌───────────┐
│ │ Auth UI │ │ Deploy Wizard │ │ Dashboard│ │Admin Panel│ │
│ └──────────┘ └───────────────┘ └──────────┘ └───────────┘ │
│ FRONTEND Next.js 16 (App Router, bilingual)
Landing │ Auth (OTP) │ Deploy Wizard │ Dashboard │ Admin Panel
└──────────────────────────┬──────────────────────────────────────┘
│ REST API (JSON)
│ REST /api/v1 (JSON)
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND (NestJS 10)
┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐
│Auth │ │Applications │ │Deployments │ │Clusters │
│ │Module │ │Module │ │Module │ │Module │ │
│ └──────────┘ └──────────────┘ └────────────┘ └──────────┘
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐
│Billing │ │Lifecycle Snapshots │ │Tickets
│Module Module│Module │ │Module
└──────────┘ └──────────────┘ └────────────┘ └──────────┘
┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Kubernetes │ Helm │ Build │ │
│ Service │ Service │ Service │ │
└────────┬─────────┘ └──────┬───────┘ └──────┬───────────┘
└───────────┼───────────────────┼──────────────────┼───────────────┘
│ │ │
┌───────▼────────┐ ┌──────▼────────┐ ┌──────▼────────┐
│ Kubernetes │ │ Helm CLI │ │ Kaniko │
│ Cluster(s) │ │ (v3) │ │ (in-cluster) │
└───────────────┘ └───────────────┘ └───────────────┘
BACKEND NestJS 11
Auth · Users · Admin · Applications · Deployments · Clusters
Build · Billing · Lifecycle · Snapshots · Tickets · Access ·
Notifications · Kubernetes/Helm/Registry
└───┬───────────┬───────────────────┬───────────────┬─────────────┘
│ │ │
▼ ▼ ▼ ▼
──────── ┌────────┐ ┌──────────┐ ──────────────────┐
Postgres│ │ Redis │ Registry │ │ Kubernetes │
16 │ │(cache/ │ (:2) │ │ Cluster(s)
│ │ Bull) │ └──────────┘ │ ┌────────────┐
└────────┘ └────────┘ │ Build Jobs │
│ │ (Kaniko) │
└────────────┘
Helm releases
│ (user apps)
└──────────────────┘
```
---
@@ -54,110 +53,118 @@ CloudHost is a self-service PaaS platform that enables users to deploy **Node.js
| Module | Purpose |
|--------|---------|
| **Auth** | JWT access/refresh tokens, Passport strategies, role guards |
| **Users** | User CRUD, admin activate/deactivate, profile management |
| **Applications** | App CRUD, code upload (zip), metadata, runtime detection |
| **Build** | Kaniko-based image builds via BullMQ queue; auto-detects Node.js/Laravel/WordPress |
| **Kubernetes** | K8s API interactions — namespace, scale, delete, pod logs, build pods |
| **Helm** | Helm CLI wrapper — install/upgrade, rollback, uninstall, history |
| **Deployments** | Deployment lifecycle orchestration, history, stop/restart |
| **Clusters** | Multi-cluster management, kubeconfig storage, default cluster selection |
| **Billing** | Wallet system (deposit/deduct), transaction ledger, plan cost calculation |
| **Lifecycle** | Cron-based scanner: auto-suspend expired apps, auto-delete after grace period |
| **Snapshots** | Application snapshot/backup management |
| **Tickets** | Support ticket system for users |
| **Auth** | Mobile-number + OTP (SMS) and password login; JWT access/refresh; Passport strategies; role guards |
| **Users** | User CRUD, profile, phone verification |
| **Admin** | Super-admin user-detail dashboard and operations |
| **Applications** | App CRUD, code upload (→ disk), git config, runtime/version metadata |
| **Application-migrations** | Import / migrate existing applications (Bull queue) |
| **Build** | `build.service` — runtime detection + per-runtime Dockerfile generation, Kaniko image builds inside K8s |
| **Deployments** | Deploy orchestration (build → Helm), history, stop/restart |
| **Kubernetes** | K8s API wrapper, Helm CLI wrapper, registry service |
| **Clusters** | Multi-cluster management, kubeconfig storage, default cluster |
| **Billing** | Wallet (deposit/deduct), transaction ledger, invoices, pricing catalog, coupons/discounts |
| **Lifecycle** | Interval scanner: auto-suspend expired apps, auto-delete after grace period |
| **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
### Frontend: Next.js 14 (App Router) + Tailwind CSS
### Frontend: Next.js 16 (App Router) + Tailwind CSS v4
| Reason | Detail |
|--------|--------|
| **SSR & SEO** | Server-side rendering for fast initial loads |
| **App Router** | React Server Components, layouts, loading states |
| **Tailwind CSS** | Rapid UI development, consistent design system |
| **TypeScript** | End-to-end type safety with shared types |
| **React Query** | Server state management, caching, polling for live status |
| **Zustand** | Lightweight client state management (auth store) |
| **SSR & SEO** | Server-side rendering for fast initial loads and a public landing/blog |
| **App Router** | React Server Components, layouts, locale routing under `app/[lang]/` |
| **Bilingual** | `fa-IR` (default) + `en-US`; `middleware.ts` also splits landing vs authenticated panel |
| **React Query** | Server state, caching, polling for live build/deploy status |
| **Zustand** | Lightweight client auth store |
| **TypeScript** | End-to-end type safety |
### Backend: NestJS 10 (Node.js)
### Backend: NestJS 11 (Node.js 20)
| Reason | Detail |
|--------|--------|
| **Modular architecture** | Each domain is a self-contained module |
| **TypeScript native** | Full type safety, shared interfaces with frontend |
| **@kubernetes/client-node** | Official K8s client for direct API interaction |
| **Helm CLI** | Shell-out to helm for chart-based deployments |
| **Bull/BullMQ** | Redis-backed job queues for async build pipelines |
| **TypeORM** | PostgreSQL ORM with entity-based schema |
| **Modular** | Each domain is a self-contained module |
| **@kubernetes/client-node** | Direct K8s API interaction (Jobs, Deployments, logs, scale) |
| **Helm CLI** | Shell-out to helm for chart-based app deployments |
| **Bull (Redis)** | Async queues for service-access grants and application migrations |
| **TypeORM** | PostgreSQL ORM. `synchronize` is **development-only**; production schema changes ship as idempotent SQL migrations / `ALTER ... IF NOT EXISTS` |
### Build System: Kaniko (in-cluster)
| Reason | Detail |
|--------|--------|
| **No Docker daemon** | Builds inside K8s pods — no Docker-in-Docker |
| **Runtime detection** | Auto-detects Node.js, Laravel, WordPress from source files |
| **WordPress support** | Custom entrypoint script for wp-content merging |
| **Registry push** | Native push to insecure or authenticated registries |
| **No Docker daemon** | Builds run as unprivileged K8s Jobs in `cloudhost-builds` |
| **Runtime detection** | `detectRuntime()` infers Node.js / Laravel / WordPress from source files; the app may also pin a runtime explicitly |
| **Per-runtime Dockerfiles** | `generateDockerfile()` emits a tailored Dockerfile for Node.js, Laravel, WordPress, Go, PHP, Python, Django, or .NET |
| **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
| Reason | Detail |
|--------|--------|
| **Templated manifests** | Single chart handles Node.js, Laravel, WordPress |
| **Rollback support** | Built-in revision history and rollback |
| **Resource policies** | PVCs and secrets persist across helm uninstall |
| **Registry pull secrets** | Auto-created per namespace for insecure registries |
| **Templated manifests** | One `cloudhost-app` chart handles all runtimes + attached services |
| **Rollback** | Built-in revision history |
| **Persistence** | DB/app PVCs and secrets use keep policies so they survive helm uninstall |
| **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
- JWT Authentication (access + refresh tokens)
- Role-Based Access Control (User / Admin)
- K8s Namespace Isolation per user
- K8s RBAC — scoped ServiceAccounts
- Network Policies between namespaces
- Resource Quotas & Limit Ranges
- Secrets encryption (K8s Secrets)
- 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
```
- Mobile-OTP + password authentication; JWT access + refresh
- **Live** role/active-status enforcement — `JwtStrategy` reads the user from the DB each request
- Role-Based Access Control (`user` / `admin` / `technical` / `sales`)
- K8s namespace isolation per user; scoped ServiceAccounts
- Resource quotas & limit ranges; expandable per-app storage
- Secrets stored as K8s Secrets (env vars, DB creds)
- Input validation (class-validator), Helmet headers, Bcrypt password hashing
---
@@ -168,95 +175,100 @@ ACTIVE ──(expires)──► SUSPENDED ──(grace)──► PENDING_DELETIO
▲ │ │
└────── payment ────────┘ │
└────── payment (within grace) ──────────────────┘
DOCKED ── user removed the service; data retained until the plan expires
```
- **Billing Cycles**: HOURLY | MONTHLY | YEARLY
- **Hourly plans**: auto-renew from wallet each hour
- **Grace periods**: admin-configurable via PlatformSettings table
- **Lifecycle Scanner**: runs every 60s (configurable)
- **Billing cycles**: HOURLY | MONTHLY | YEARLY
- **Wallet**: deposits, deductions, refunds, gateway payments; invoices with coupons/discounts
- **Grace periods**: admin-configurable via the PlatformSettings entity (per cycle)
- **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 |
|-------|---------|
| `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:
Deploys the API, UI, PostgreSQL, and Redis into a namespace (default `cloudhost`).
| Template | Purpose |
|----------|---------|
| `deployment.yaml` | App pod with imagePullSecrets, probes, WordPress volumes |
| `service.yaml` | ClusterIP (port 80 → app port) |
| `ingress.yaml` | Nginx ingress with cert-manager TLS |
| `secret.yaml` | User env vars as K8s Secret |
| `db-deployment.yaml` | PostgreSQL or MySQL with health probes |
| `db-service.yaml` | Database ClusterIP service |
| `db-pvc.yaml` | Database storage (resource-policy: keep) |
| `db-secret.yaml` | Database credentials (resource-policy: keep) |
| `wp-pvc.yaml` | WordPress wp-content PVC (resource-policy: keep) |
| `registry-pull-secret.yaml` | imagePullSecret for insecure registry |
| `backend-deployment.yaml` / `backend-service.yaml` / `backend-pvc.yaml` | NestJS API + uploads PVC |
| `frontend-deployment.yaml` / `frontend-service.yaml` | Next.js UI |
| `postgres-*.yaml` / `redis-*.yaml` | Control-plane database & queue |
| `ingress.yaml` | Frontend / API / panel host rules (+ TLS) |
| `secret.yaml` | JWT, DB, registry, SMS and other platform secrets |
| `migrations-configmap.yaml` / `migrations-job.yaml` | Optional SQL migration Job (`migrations.enabled`) |
| `namespace.yaml` / `_helpers.tpl` / `NOTES.txt` | Namespace + chart helpers |
Key values: `ingress.enabled`, `ingress.tls.*`, `ingress.frontend.host` / `ingress.api.host`,
`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
```
host/
├── ARCHITECTURE.md
├── README.md
├── CHANGELOG.md
├── CONTRIBUTING.md
├── docker-compose.yml
├── backend/
cloud-host/
├── ARCHITECTURE.md README.md RUNBOOK.fa.md CHANGELOG.md CONTRIBUTING.md
├── UPGRADE.md UPGRADE.en.md docker-compose.yml
├── backend/ # NestJS 11 API
│ ├── Dockerfile
│ ├── package.json
│ ├── helm/cloudhost-platform/ # Helm chart for control plane
│ ├── helm/cloudhost-app/ # Helm chart for user apps
│ ├── 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
│ ├── helm/{cloudhost-platform, cloudhost-app, cloudhost-logging}/
│ ├── k8s/{logging, mail}/ # standalone manifests
│ ├── migrations/ # SQL migrations (one-off Jobs in prod)
│ └── src/
│ ├── app/dashboard/ # Apps, deploy, admin pages
│ ├── components/
│ ├── lib/ # API client, auth store
── types/ # Shared TS interfaces
└── uploads/ # User-uploaded code archives
│ ├── main.ts / app.module.ts
│ ├── auth/ users/ admin/ # OTP auth, users, super-admin dashboard
│ ├── applications/ application-migrations/
── deployments/ # orchestration (build → Helm)
│ ├── 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
1. Custom domains with auto TLS via cert-manager
2. Horizontal Pod Autoscaler based on CPU/memory
3. WebSocket/SSE for real-time build log streaming
4. GitOps integration (ArgoCD)
5. Additional runtimes (Python, Go, Rust)
6. App marketplace with pre-built templates
7. Per-app resource consumption dashboards
1. Per-app horizontal autoscaling (HPA) based on CPU/memory
2. WebSocket/SSE for real-time build log streaming (currently polled)
3. A Redis-backed build queue (so builds survive a replica restart and the control plane can scale out)
4. In-cluster image vulnerability scanning (report-only)
5. GitOps integration (e.g. ArgoCD) and git-push-to-deploy
6. Automated control-plane database backups (scheduled `pg_dump` + retention)
7. App marketplace with pre-built templates
Binary file not shown.
+172
View File
@@ -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)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 CloudHost
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+255 -227
View File
@@ -1,303 +1,331 @@
# ☁️ 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
```
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Next.js 16 │ REST │ NestJS API │ K8s │ Kubernetes │
│ Frontend │◄───────►│ Backend │◄──────►│ Cluster(s) │
└───────────── └────────┬──────── └─────────────┘
┌────────────────────
PostgreSQL Redis Container
(Bull) Registry
┌─────────────REST ┌─────────────────K8s API ┌──────────────┐
│ Next.js 16 /api/v1 │ NestJS 11 API + Helm │ Kubernetes │
│ Frontend │◄─────────►│ Backend │◄───────────►│ Cluster(s) │
└──────────────┘ └────────┬─────────┘ └─────────────┘
│ │ build Jobs
─────────────────┼─────────────────┐ ▼
▼ ┌──────────┐
PostgreSQL Redis Registry │ Kaniko │
16 (cache + Bull) (:2) └──────────┘
```
| Layer | Technology |
| ------------ | ------------------------------------------------------- |
| Frontend | Next.js 16, Tailwind CSS v4, React Query, Zustand |
| Backend API | NestJS 11, TypeORM, Passport JWT, Bull (Redis) |
| Build Engine | Kaniko (in-cluster, daemon-less Docker builds) |
| Deployment | Helm v3 charts, @kubernetes/client-node |
| Database | PostgreSQL 16 |
| Queue | Redis 7 + BullMQ |
| Layer | Technology |
| ---------------- | ----------------------------------------------------------------- |
| Frontend | Next.js 16 (App Router, SSR), React 19, Tailwind CSS v4, React Query, Zustand |
| Backend API | NestJS 11, TypeORM, Passport JWT, Bull (Redis) |
| Build engine | **Kaniko** (daemon-less in-cluster builds) with platform-generated per-runtime Dockerfiles |
| Source ingestion | Uploaded archive (zip/tar.gz) streamed into a build PVC, **or** git clone |
| Deployment | Helm v3 charts, `@kubernetes/client-node` |
| 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 [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 **[ARCHITECTURE.md](ARCHITECTURE.md)** for detailed system design.
> 🔼 See [UPGRADE.en.md](UPGRADE.en.md) ([فارسی](UPGRADE.md)) for dependency-upgrade notes.
> 📋 See [RUNBOOK.en.md](RUNBOOK.en.md) ([فارسی](RUNBOOK.fa.md)) for operations.
---
## Features
### For Developers
- 🚀 **One-click deploys** from uploaded code archive (zip)
- 🟢 **Node.js** — auto-detected via `package.json` (npm build & start)
- 🟣 **Laravel** — PHP 8.x + Nginx + Supervisor (auto-detected via `artisan`)
- 🔵 **WordPress** — official image + custom entrypoint for wp-content merging
- 🗄️ **Managed databases** — PostgreSQL or MySQL provisioned via Helm
- 💰 **Wallet system** — deposit funds, pay for plans (hourly/monthly/yearly)
- 📊 **Live logs** & deployment history with rollback
- 🔒 **Environment variables** managed as Kubernetes Secrets
- ⚙️ **Resource controls** — CPU, memory, replica count
- 📸 **Snapshots** — backup and restore application state
- 🎫 **Support tickets** — in-app support system
- 🚀 **Deploy from a code archive (zip/tar.gz) _or_ a git URL** (public or private via token)
- 🟢 **Multi-runtime** — Node.js, Laravel, Go, PHP, Python, Django, .NET, each built from a maintained Dockerfile template
- 🔵 **WordPress** — official image + custom entrypoint that merges your `wp-content`
- 🗄️ **Managed databases & services** — PostgreSQL, MySQL, MariaDB, MongoDB, Redis, RabbitMQ provisioned via Helm
- 💰 **Wallet system** — deposit funds, pay per plan (hourly / monthly / yearly), coupons & discounts
- 📊 **Live build & runtime logs** (Elasticsearch-backed) + deployment history with rollback
- 🔒 **Environment variables** stored as Kubernetes Secrets
- ⚙️ **Resource controls** — CPU, memory, replicas, expandable disk
- 🌐 **Custom domains** with automatic TLS
- 📸 **Snapshots** — backup & restore application state
- 🎫 **Support tickets** with technical/sales departments
### For Super Admins
- 🖥️ **Multi-cluster management** — register/remove Kubernetes clusters
- 👥 **User management** — activate, deactivate, change roles
- 📈 **Quotas** — per-cluster limits (CPU, memory, max apps)
- 💳 **Billing oversight** view all transactions, manage wallet deposits
- ⏱️ **Lifecycle settings** configure grace periods per billing cycle
- 🔐 **RBAC** — role-based guards on every endpoint
- 🖥️ **Multi-cluster management** — register/remove Kubernetes clusters (kubeconfig stored encrypted)
- 👥 **User management** — activate, deactivate, change roles, per-user detail dashboard
- 📈 **Quotas & pricing** — per-cluster limits and a configurable pricing catalog
- 💳 **Billing oversight** — transactions, invoices, wallet deposits, global discount
- ⏱️ **Lifecycle settings** — grace periods per billing cycle
- 🔐 **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
```
host/
├── ARCHITECTURE.md # Detailed architecture document
├── README.md # This file
├── CHANGELOG.md # Version history
├── CONTRIBUTING.md # Development workflow & conventions
├── docker-compose.yml # Local dev / production compose
cloud-host/
├── README.md # This file
├── ARCHITECTURE.md # Detailed system design
├── RUNBOOK.fa.md # Persian runbook: local dev + abrban/k3s production deploy
├── 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
│ ├── package.json
│ ├── helm/
│ │ ├── cloudhost-platform/ # Helm chart (control plane)
│ │ ── cloudhost-app/ # Helm chart (user apps)
│ │ ├── Chart.yaml
│ ├── values.yaml
│ └── templates/ # K8s manifest templates
│ ├── 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
│ │ ├── cloudhost-platform/ # Helm chart control plane (API, UI, Postgres, Redis)
│ │ ── cloudhost-app/ # Helm chart — a single user application + its services
│ │ └── cloudhost-logging/ # Helm chart — Elasticsearch / Kibana / Fluent-bit
├── k8s/ # Standalone manifests (logging, mail)
├── migrations/ # SQL migrations (applied via one-off Jobs in prod)
│ └── src/
│ ├── app/
│ ├── login/ & register/
│ └── dashboard/
│ ├── apps/ # App list + detail (lifecycle status)
│ ├── deploy/ # Multi-step deploy wizard
│ └── admin/ # Admin: users, clusters, billing, apps
│ ├── components/
│ ├── lib/ # API client, auth store
│ ├── hooks/
── types/ # TypeScript interfaces
│ ├── main.ts / app.module.ts
├── auth/ # Mobile-OTP + password login, JWT strategies, role guards
├── users/ # User CRUD, profile, phone verification
├── admin/ # Super-admin user-detail dashboard & ops
├── applications/ # App CRUD, code upload (→ disk), git config
├── application-migrations/ # Import/migrate existing apps (Bull queue)
│ ├── deployments/ # Deploy orchestration, history, stop/restart
│ ├── build/ # Kaniko build (build.service): per-runtime Dockerfile generation
│ ├── kubernetes/ # K8s client, Helm wrapper, registry service
── 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 |
| --------------- | ------- |
| Node.js | ≥ 20 |
| Docker & Compose| ≥ 24 |
| PostgreSQL | 16 |
| Redis | 7 |
| Helm | ≥ 3.12 |
> ️ 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
> (with the in-cluster registry). For pure UI/API development you don't need one.
### 1. Clone & Install
### 1. Clone & install
```bash
git clone <repo-url> host && cd host
git clone <repo-url> cloud-host && cd cloud-host
cd backend && npm install && cd ..
cd frontend && npm install && cd ..
```
### 2. Environment Variables
### 2. Environment variables
```bash
cp backend/.env.example backend/.env
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
docker compose up --build
docker compose up -d postgres redis
```
Backend at port 4000, Frontend at port 3000.
### 4. Deploy Platform on Kubernetes (Helm)
Prerequisites: NGINX Ingress, cert-manager (if TLS enabled), StorageClass for PVCs.
### 4. Run the apps
```bash
# Build images (set API URL to match ingress.api.host when TLS is on)
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
# Terminal 1 — Backend (http://localhost:4000, prefix /api/v1, Swagger at /docs)
cd backend && npm run start:dev
# Terminal 2 — Frontend
# Terminal 2 — Frontend (http://localhost:3000)
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
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 |
> To run the **whole** stack (API + UI + Postgres + Redis) in containers instead:
> `docker compose up --build` (backend on `:4000`, frontend on `:3000`).
---
## 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 |
|----------|-------------|---------|
| `PORT` | Backend port | `4000` |
| `DB_HOST` | PostgreSQL host | `localhost` |
| `DB_PORT` | PostgreSQL port | `5432` |
| `DB_USERNAME` | Database user | `cloudhost` |
| `DB_PASSWORD` | Database password | — |
| `DB_NAME` | Database name | `cloudhost` |
| `JWT_SECRET` | JWT signing secret | — |
| `JWT_EXPIRES_IN` | Access token TTL | `15m` |
| `REDIS_HOST` | Redis host | `localhost` |
| `REDIS_PORT` | Redis port | `6379` |
| `REGISTRY_URL` | Container registry URL | `localhost:30500` |
| `PLATFORM_DOMAIN` | Base domain for app subdomains | `apps.cloudhost.ir` |
| `LIFECYCLE_SCAN_INTERVAL_MS` | Lifecycle scanner interval | `60000` |
| `LIFECYCLE_HOURLY_DELETE_AFTER_MS` | Hourly plan grace period | `3600000` (1h) |
| `LIFECYCLE_MONTHLY_DELETE_AFTER_MS` | Monthly plan grace period | `259200000` (3d) |
| `LIFECYCLE_YEARLY_DELETE_AFTER_MS` | Yearly plan grace period | `604800000` (7d) |
| `DB_HOST` / `DB_PORT` / `DB_USERNAME` / `DB_PASSWORD` / `DB_DATABASE` | PostgreSQL connection | `localhost` / `5432` / `cloudhost` / — / `cloudhost` |
| `JWT_SECRET` / `JWT_EXPIRES_IN` | Access token secret + TTL | — / `1h` |
| `JWT_REFRESH_SECRET` / `JWT_REFRESH_EXPIRES_IN` | Refresh token secret + TTL | — / `7d` |
| `REDIS_HOST` / `REDIS_PORT` | Redis (cache + Bull queues) | `localhost` / `6379` |
| `SMS_PROVIDER` | OTP provider (`mizbansms` \| `kavenegar`) | `mizbansms` |
| `MIZBANSMS_USERNAME` / `MIZBANSMS_PASSWORD` / `MIZBANSMS_FROM` | OTP SMS credentials (required or OTP send 503s) | — |
| `REGISTRY_URL` / `REGISTRY_PULL_URL` | In-cluster registry (push / pull) | `registry.cloudhost-builds.svc.cluster.local:5000` |
| `BUILD_NAMESPACE` / `BUILD_SERVICE_ACCOUNT` | Build Jobs namespace + SA | `cloudhost-builds` / `kaniko-builder` |
| `KANIKO_IMAGE` | Kaniko executor image | `registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2` |
| `BUILD_ALPINE_IMAGE` | Alpine image for build init/helper pods | `registry.abrban.com/proxy-dockerhub/library/alpine:3.19` |
| `BUILD_ALPINE_GIT_IMAGE` | Git-clone init container image | `registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0` |
| `BASE_IMAGE_REGISTRY` | Harbor prefix for Docker Hub images in generated Dockerfiles | `registry.abrban.com/proxy-dockerhub/library` |
| `UPLOAD_DIR` | Disk path for uploaded source archives | `./uploads` |
| `INGRESS_CLASS` | Ingress controller for app Ingress objects | `traefik` |
| `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
- **JWT** access + refresh tokens with configurable expiry
- **Bcrypt** password hashing (12 rounds)
- **Helmet** HTTP security headers
- **RBAC** role-based route guards (`@Roles(UserRole.ADMIN)`)
- **Namespace isolation** — each user deploys to their own K8s namespace
- **Secrets** — env vars stored as K8s Secrets, never in plain manifests
- **Input validation** — `class-validator` on all DTOs
- **JWT** access + refresh tokens; live role/active-status enforcement from DB
- **Bcrypt** password hashing
- **Helmet** HTTP security headers, **class-validator** on all DTOs
- **RBAC** role-based route guards (`@Roles(...)`)
- **Namespace isolation** — each user deploys to their own Kubernetes namespace
- **Secrets** — env vars stored as K8s Secrets
---
+346
View File
@@ -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 تست شده
+321
View File
@@ -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` |
+426
View File
@@ -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
```
+195
View File
@@ -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 تست شده
+69
View File
@@ -0,0 +1,69 @@
# CloudHost — Operations Runbook (English)
Short operational guide. For architecture details see [ARCHITECTURE.md](ARCHITECTURE.md). For Persian production deploy steps see [RUNBOOK.fa.md](RUNBOOK.fa.md).
## Stack
- **Frontend:** Next.js 16 (`/fa-IR`, `/en-US` routes)
- **Backend:** NestJS 11 (`/api/v1`, Swagger at `/api/docs`)
- **Data:** PostgreSQL 16, Redis 7
- **Build:** Kaniko in-cluster (`cloudhost-builds` namespace)
- **Deploy:** Helm charts (`cloudhost-platform`, `cloudhost-app`, `cloudhost-logging`)
## Health checks
| Endpoint | Purpose |
|----------|---------|
| `GET /api/v1/health` | Liveness |
| `GET /api/v1/ready` | Readiness (DB ping) |
## Local development
```bash
docker compose up -d postgres redis
cd backend && cp .env.example .env && npm run start:dev
cd frontend && cp .env.local.example .env.local && npm run dev
```
## SQL migrations
1. Add file under `backend/migrations/`
2. Run `cd backend && npm run sync:migrations` (copies into Helm chart)
3. Helm post-install Job applies migrations on upgrade
## Build namespace bootstrap
```bash
kubectl apply -f backend/k8s/builds/cloudhost-builds-bootstrap.yaml
```
Configure `REGISTRY_URL`, `BUILD_NAMESPACE`, and `CLUSTER_KUBECONFIG_KEY` in backend env.
## Backups (optional Helm)
Enable in `values.yaml`:
```yaml
backups:
postgres:
enabled: true
schedule: "0 3 * * *"
storageSize: 10Gi
```
Restore: `gunzip -c backup.sql.gz | psql` against the control-plane database.
## Monitoring (optional)
Enable Prometheus ServiceMonitor:
```yaml
monitoring:
enabled: true
```
Requires `kube-prometheus-stack` or compatible Prometheus Operator in the cluster.
## CI
GitHub Actions runs backend/frontend tests, typecheck, build, and `helm lint` on push/PR.
+232
View File
@@ -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‌ها سالم.
+12
View File
@@ -20,6 +20,13 @@ JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=localhost
REDIS_PORT=6379
# Multi-cluster: AES-256-GCM key for encrypting kubeconfigs at rest (required in production).
# Generate with: openssl rand -hex 32
CLUSTER_KUBECONFIG_KEY=
# Stub payment gateway (dev/staging only — disabled in production unless explicitly enabled)
# PAYMENT_GATEWAY_STUB_ENABLED=true
# ─── OTP SMS ────────────────────────────────────────────────────────────────
# Pick the provider. Without valid credentials, OTP codes are logged to the API
# console in development only; in production a missing config makes OTP send fail
@@ -68,6 +75,11 @@ REGISTRY_PASSWORD=registry_secret
# Build
BUILD_NAMESPACE=cloudhost-builds
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
# Public URL(s) of the frontend — used for CORS and to derive the platform/preview
+3 -2
View File
@@ -1,5 +1,6 @@
# ---- Stage 1: Build ----
FROM node:24-alpine AS builder
ARG BASE_IMAGE=node:24-alpine
FROM ${BASE_IMAGE} AS builder
WORKDIR /app
@@ -10,7 +11,7 @@ COPY . .
RUN npm run build
# ---- Stage 2: Production ----
FROM node:24-alpine AS production
FROM ${BASE_IMAGE} AS production
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 \
+28
View File
@@ -0,0 +1,28 @@
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
/** @type {import('eslint').Linter.Config[]} */
export default [
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
{
files: ['src/**/*.ts', 'test/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
];
@@ -46,6 +46,16 @@ Database deployment name
{{- printf "%s-db" .Values.app.name }}
{{- 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
*/}}
@@ -53,13 +63,13 @@ Database image — auto-computed from type + version if not explicitly set
{{- if .Values.database.image }}
{{- .Values.database.image }}
{{- 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" }}
{{- 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" }}
{{- printf "mongo:%s" .Values.database.version }}
{{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mongo:%s" .Values.database.version)) }}
{{- else }}
{{- printf "mysql:%s" .Values.database.version }}
{{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mysql:%s" .Values.database.version)) }}
{{- end }}
{{- end }}
@@ -5,7 +5,7 @@
{{- define "cloudhost-app.logShipperContainers" -}}
{{- if .root.Values.elasticsearch.enabled }}
- name: log-shipper
image: fluent/fluent-bit:2.2
image: {{ include "cloudhost-app.baseImage" (dict "root" .root "image" "fluent/fluent-bit:2.2") }}
resources:
requests:
cpu: "10m"
@@ -16,6 +16,10 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }}
spec:
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:
matchLabels:
app: {{ $dbName }}
@@ -114,7 +118,7 @@ spec:
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
{{- else if eq .Values.database.type "mongodb" }}
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 }}
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
@@ -131,7 +135,7 @@ spec:
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
{{- else if eq .Values.database.type "mongodb" }}
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 }}
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
@@ -217,7 +217,7 @@ spec:
{{- end }}
{{- if .Values.elasticsearch.enabled }}
- name: fluent-bit
image: fluent/fluent-bit:2.2
image: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" "fluent/fluent-bit:2.2") }}
resources:
requests:
cpu: "10m"
@@ -1,4 +1,7 @@
{{- 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
kind: Secret
metadata:
@@ -8,7 +11,7 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }}
type: Opaque
stringData:
ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | default "CloudHost2024!Secure" | quote }}
FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | default "FluentBit2024!Writer" | quote }}
KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | default "Kibana2024!System" | quote }}
ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | quote }}
FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | quote }}
KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | quote }}
{{- end }}
@@ -2,11 +2,22 @@
{{- $name := include "cloudhost-app.name" . -}}
{{- $ns := include "cloudhost-app.namespace" . -}}
{{- $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
kind: Secret
metadata:
name: {{ $rabbitName }}-secret
name: {{ $rabbitSecretName }}
namespace: {{ $ns }}
labels:
app: {{ $rabbitName }}
@@ -16,7 +27,7 @@ metadata:
type: Opaque
data:
username: {{ "appuser" | b64enc | quote }}
password: {{ randAlphaNum 16 | b64enc | quote }}
password: {{ $rabbitPass | b64enc | quote }}
---
apiVersion: v1
kind: PersistentVolumeClaim
@@ -48,6 +59,9 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }}
spec:
replicas: 1
# RWO volume + single replica: recreate instead of rolling update.
strategy:
type: Recreate
selector:
matchLabels:
app: {{ $rabbitName }}
@@ -58,7 +72,7 @@ spec:
spec:
containers:
- 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:
- containerPort: 5672
name: amqp
@@ -68,12 +82,12 @@ spec:
- name: RABBITMQ_DEFAULT_USER
valueFrom:
secretKeyRef:
name: {{ $rabbitName }}-secret
name: {{ $rabbitSecretName }}
key: username
- name: RABBITMQ_DEFAULT_PASS
valueFrom:
secretKeyRef:
name: {{ $rabbitName }}-secret
name: {{ $rabbitSecretName }}
key: password
volumeMounts:
- name: rabbitmq-data
@@ -2,11 +2,21 @@
{{- $name := include "cloudhost-app.name" . -}}
{{- $ns := include "cloudhost-app.namespace" . -}}
{{- $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
kind: Secret
metadata:
name: {{ $redisName }}-secret
name: {{ $redisSecretName }}
namespace: {{ $ns }}
labels:
app: {{ $redisName }}
@@ -15,7 +25,7 @@ metadata:
"helm.sh/resource-policy": keep
type: Opaque
data:
password: {{ randAlphaNum 16 | b64enc | quote }}
password: {{ $redisPass | b64enc | quote }}
---
apiVersion: v1
kind: PersistentVolumeClaim
@@ -47,6 +57,10 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }}
spec:
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:
matchLabels:
app: {{ $redisName }}
@@ -57,7 +71,7 @@ spec:
spec:
containers:
- 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)"]
ports:
- containerPort: 6379
@@ -65,7 +79,14 @@ spec:
- name: REDIS_PASSWORD
valueFrom:
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
volumeMounts:
- name: redis-data
+6
View File
@@ -101,3 +101,9 @@ changeCause: ""
# ── Registry (for imagePullSecret) ──────────────────────
registry:
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: ""
+3
View File
@@ -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:
+13
View File
@@ -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
+140
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
# CloudHost Ceph extras (ObjectBucketClaim for zip uploads).
# Rook operator + CephCluster are installed via scripts/install.sh using official rook-release charts.
namespace: cloudhost-builds
objectStore:
# Must match rook-ceph-cluster cephObjectStores[].storageClass.name
bucketStorageClass: rook-ceph-bucket
# Claim name; Rook generates bucket + credentials secret
claimName: app-sources
# Prefix for generated bucket name (Rook appends random suffix)
generateBucketName: cloudhost-app-sources
platform:
# Copy S3 credentials into a stable secret name for backend/workers
createCredentialsSecret: true
credentialsSecretName: ceph-app-sources-credentials
# In-cluster RGW endpoint (adjust if ingress is enabled on object store)
endpoint: http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc.cluster.local:80
region: us-east-1
integration:
# Emit a ConfigMap with suggested backend env vars (non-secret)
createConfigMap: true
configMapName: cloudhost-ceph-integration
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
# Replaces registry.abrban.com docker distribution with Harbor.
# WARNING: This will delete the existing `Ingress/registry` routing. The old
# `Deployment/registry` and its PVC are left in place for rollback.
VALUES_FILE="${1:-/Users/keyhan/Documents/keyhan-project/cloud-host/backend/helm/cloudhost-harbor/values-registry.abrban.com.yaml}"
echo "==> Ensuring harbor repo"
helm repo add harbor https://helm.goharbor.io 2>/dev/null || true
helm repo update harbor
echo "==> [0/4] Preflight"
kubectl -n cloudhost get secret abrban-wildcard-tls >/dev/null
kubectl -n cloudhost get secret registry-egress-proxy >/dev/null
echo "==> [1/4] Disabling old registry ingress (host registry.abrban.com)"
kubectl -n cloudhost delete ingress registry --ignore-not-found
echo "==> [2/4] Scaling old registry deployment down (rollback-friendly)"
kubectl -n cloudhost scale deploy/registry --replicas=0 || true
echo "==> [3/4] Installing Harbor"
HTTP_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.HTTP_PROXY}' | base64 -d)"
HTTPS_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.HTTPS_PROXY}' | base64 -d)"
NO_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.NO_PROXY}' | base64 -d)"
TMP_PROXY_VALUES="$(mktemp)"
cat > "${TMP_PROXY_VALUES}" <<EOF
proxy:
httpProxy: "${HTTP_PROXY}"
httpsProxy: "${HTTPS_PROXY}"
noProxy: "${NO_PROXY}"
EOF
helm upgrade --install harbor harbor/harbor \
-n cloudhost \
-f "$VALUES_FILE" \
-f "${TMP_PROXY_VALUES}" \
--wait \
--timeout 20m
rm -f "${TMP_PROXY_VALUES}" || true
echo "==> [4/4] Done"
kubectl -n cloudhost get ingress | grep -n registry || true
@@ -0,0 +1,75 @@
## Harbor values to REPLACE registry.abrban.com
## Ingress controller on this cluster is Traefik (k3s).
##
## Install:
## helm upgrade --install harbor harbor/harbor -n cloudhost -f backend/helm/cloudhost-harbor/values-registry.abrban.com.yaml
##
externalURL: https://registry.abrban.com
proxy:
# Values are injected by install script from `cloudhost/registry-egress-proxy`.
httpProxy: ""
httpsProxy: ""
noProxy: ""
expose:
type: ingress
tls:
enabled: true
certSource: secret
secret:
secretName: abrban-wildcard-tls
ingress:
className: traefik
hosts:
core: registry.abrban.com
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
# Increase timeouts for large pushes (skopeo/registry blobs)
traefik.ingress.kubernetes.io/router.tls: "true"
# Disable components we don't need for now to reduce resources
trivy:
enabled: false
notary:
enabled: false
chartmuseum:
enabled: false
# Single-node staging: keep resource usage modest
core:
replicas: 1
jobservice:
replicas: 1
registry:
replicas: 1
persistence:
enabled: true
persistentVolumeClaim:
# Use existing default storage (local-path) until Ceph is ready.
# After Ceph, switch to rook-ceph-block for Harbor's PVCs.
registry:
storageClass: local-path
size: 50Gi
jobservice:
storageClass: local-path
size: 5Gi
database:
storageClass: local-path
size: 10Gi
redis:
storageClass: local-path
size: 5Gi
database:
type: internal
redis:
type: internal
portal:
replicas: 1
# We will create proxy-cache projects after install (todo: configure-proxy-cache)
@@ -1,4 +1,3 @@
{{- $auth := printf "elastic:%s" .Values.elasticPassword | b64enc }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
@@ -74,22 +73,20 @@ spec:
- name: es-data
mountPath: /usr/share/elasticsearch/data
readinessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
httpHeaders:
- name: Authorization
value: Basic {{ $auth }}
exec:
command:
- sh
- -c
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
httpHeaders:
- name: Authorization
value: Basic {{ $auth }}
exec:
command:
- sh
- -c
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,20 @@
-- Temporary external access grants (Redis, RabbitMQ, database)
CREATE TYPE service_access_target AS ENUM (
'database',
'redis',
'rabbitmq_amqp',
'rabbitmq_management'
);
DO $$ BEGIN
CREATE TYPE service_access_target AS ENUM (
'database',
'redis',
'rabbitmq_amqp',
'rabbitmq_management'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TYPE service_access_grant_status AS ENUM (
'active',
'expired',
'revoked'
);
DO $$ BEGIN
CREATE TYPE service_access_grant_status AS ENUM (
'active',
'expired',
'revoked'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS service_access_grants (
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';
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
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 }}
{{- define "cloudhost-platform.secretName" -}}
{{- if .Values.secrets.existingSecret }}
{{- .Values.secrets.existingSecret }}
{{- else }}
{{- printf "%s-secrets" (include "cloudhost-platform.fullname" .) }}
{{- end }}
{{- end }}
{{- define "cloudhost-platform.tlsSecretName" -}}
{{- 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" -}}
{{- printf "%s:%s" .Values.images.frontend.repository .Values.images.frontend.tag }}
{{- 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 }}
spec:
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:
type: Recreate
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: {{ include "cloudhost-platform.backend.fullname" . }}
@@ -19,6 +25,10 @@ spec:
labels:
app: {{ include "cloudhost-platform.backend.fullname" . }}
spec:
{{- with .Values.backend.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
{{- if .Values.postgres.enabled }}
- name: wait-postgres
@@ -68,6 +78,11 @@ spec:
value: {{ include "cloudhost-platform.redis.fullname" . }}
- name: REDIS_PORT
value: "6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: redis-password
- name: JWT_SECRET
valueFrom:
secretKeyRef:
@@ -78,8 +93,48 @@ spec:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
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
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 }}
- name: {{ $key }}
value: {{ $val | quote }}
@@ -88,19 +143,24 @@ spec:
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
{{- if .Values.backend.sourceStorage.enabled }}
envFrom:
- secretRef:
name: {{ .Values.backend.sourceStorage.existingSecret }}
{{- end }}
volumeMounts:
- name: uploads
mountPath: /app/uploads
livenessProbe:
httpGet:
path: /api/docs
path: /api/v1/health
port: 4000
initialDelaySeconds: 60
periodSeconds: 15
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /api/docs
path: /api/v1/ready
port: 4000
initialDelaySeconds: 20
periodSeconds: 10
@@ -17,6 +17,10 @@ spec:
labels:
app: {{ include "cloudhost-platform.frontend.fullname" . }}
spec:
{{- with .Values.frontend.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: frontend
image: {{ include "cloudhost-platform.frontendImage" . | quote }}
@@ -7,7 +7,7 @@ metadata:
labels:
{{- include "cloudhost-platform.labels" . | nindent 4 }}
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 }}
{{- end }}
{{- if and .Values.ingress.singleHost.enabled .Values.ingress.singleHost.apiPath }}
@@ -7,7 +7,9 @@ metadata:
labels:
{{- include "cloudhost-platform.labels" . | nindent 4 }}
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-delete-policy: before-hook-creation,hook-succeeded
spec:
@@ -47,9 +49,20 @@ spec:
- -c
- |
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
echo ">>> Applying $f"
psql -v ON_ERROR_STOP=1 -f "$f"
name=$(basename "$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
echo ">>> All migrations applied"
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:
app: {{ include "cloudhost-platform.postgres.fullname" . }}
spec:
{{- with .Values.postgres.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: postgres
image: {{ .Values.images.postgres | quote }}
@@ -19,9 +19,26 @@ spec:
labels:
app: {{ include "cloudhost-platform.redis.fullname" . }}
spec:
{{- with .Values.redis.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: redis
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:
- containerPort: 6379
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" .) }}
{{- $pgPass := .Values.postgres.password }}
{{- if not $pgPass }}
@@ -11,6 +18,14 @@
{{- if not $jwtRefresh }}
{{- if $existing }}{{- $jwtRefresh = index $existing.data "jwt-refresh-secret" | b64dec }}{{- else }}{{- $jwtRefresh = randAlphaNum 32 }}{{- 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
kind: Secret
metadata:
@@ -23,3 +38,6 @@ stringData:
postgres-password: {{ $pgPass | quote }}
jwt-secret: {{ $jwt | 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
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:
repository: registry.example.com/cloudhost-backend
tag: "1.0.0"
@@ -17,8 +22,25 @@ images:
tag: "1.0.0"
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:
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:
jwtSecret: "CHANGE_ME_LONG_JWT_SECRET"
@@ -36,10 +58,26 @@ ingress:
clusterIssuer: letsencrypt-prod
backend:
# Enable after copying ceph-app-sources-credentials secret into the cloudhost namespace
sourceStorage:
enabled: false
existingSecret: ceph-app-sources-credentials
env:
PLATFORM_DOMAIN: apps.example.com
REGISTRY_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:
enabled: true
backups:
postgres:
enabled: true
schedule: "0 3 * * *"
storageSize: 10Gi
retentionDays: 7
+89 -4
View File
@@ -12,6 +12,9 @@ createNamespace: true
global:
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:
postgres: postgres:16-alpine
redis: redis:7-alpine
@@ -25,6 +28,26 @@ images:
tag: "1.0.0"
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:
enabled: true
database: cloudhost
@@ -32,20 +55,57 @@ postgres:
# Leave empty to auto-generate on first install (stored in Secret)
password: ""
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:
enabled: true
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:
enabled: true
replicas: 1
imagePullSecrets:
- name: registry-pull-secret
uploads:
size: 20Gi
resources: {}
sourceStorage:
enabled: false
existingSecret: ceph-app-sources-credentials
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
extraEnv: {}
# OTP SMS — credentials live in the platform Secret (mizbansms-username/password).
sms:
enabled: false
provider: mizbansms
from: "5000467254"
api: "2016"
userType: "2"
env:
NODE_ENV: production
PORT: "4000"
@@ -66,12 +126,27 @@ backend:
frontend:
enabled: true
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)
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: ""
jwtRefreshSecret: ""
# AES key for encrypting stored kubeconfigs (64 hex chars or any passphrase)
clusterKubeconfigKey: ""
ingress:
enabled: true
@@ -97,3 +172,13 @@ ingress:
migrations:
enabled: true
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'
+20 -26
View File
@@ -9,18 +9,16 @@ metadata:
labels:
app.kubernetes.io/managed-by: cloudhost
---
# Elasticsearch credentials secret
apiVersion: v1
kind: Secret
metadata:
name: elasticsearch-credentials
namespace: logging
type: Opaque
stringData:
# Admin credentials - change in production!
ELASTIC_PASSWORD: "CloudHost2024!Secure"
# For Fluent Bit to send logs
FLUENTBIT_PASSWORD: "FluentBit2024!Writer"
# Elasticsearch credentials — managed OUT-OF-BAND, never committed to git.
# Create the Secret before applying this manifest (or use a SealedSecret in
# the GitOps repo):
#
# kubectl -n logging create secret generic elasticsearch-credentials \
# --from-literal=ELASTIC_PASSWORD="$(openssl rand -base64 24)" \
# --from-literal=FLUENTBIT_PASSWORD="$(openssl rand -base64 24)"
#
# The backend reads the same values from ELASTIC_PASSWORD / FLUENTBIT_PASSWORD
# env vars (see cloudhost-platform values: backend.extraEnv or an extra Secret).
---
# ConfigMap for Elasticsearch configuration
apiVersion: v1
@@ -183,24 +181,20 @@ spec:
- name: data
mountPath: /usr/share/elasticsearch/data
readinessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
scheme: HTTP
httpHeaders:
- name: Authorization
value: "Basic ZWxhc3RpYzpDbG91ZEhvc3QyMDI0IVNlY3VyZQ=="
exec:
command:
- sh
- -c
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
scheme: HTTP
httpHeaders:
- name: Authorization
value: "Basic ZWxhc3RpYzpDbG91ZEhvc3QyMDI0IVNlY3VyZQ=="
exec:
command:
- sh
- -c
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
+248
View File
@@ -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)
CREATE TYPE service_access_target AS ENUM (
'database',
'redis',
'rabbitmq_amqp',
'rabbitmq_management'
);
DO $$ BEGIN
CREATE TYPE service_access_target AS ENUM (
'database',
'redis',
'rabbitmq_amqp',
'rabbitmq_management'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TYPE service_access_grant_status AS ENUM (
'active',
'expired',
'revoked'
);
DO $$ BEGIN
CREATE TYPE service_access_grant_status AS ENUM (
'active',
'expired',
'revoked'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS service_access_grants (
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';
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
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
+485 -358
View File
File diff suppressed because it is too large Load Diff
+16 -6
View File
@@ -1,5 +1,5 @@
{
"name": "cloudhost-backend",
"name": "abrban-backend",
"version": "1.0.0",
"description": "CloudHost PaaS Backend API",
"private": true,
@@ -9,7 +9,9 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint": "eslint \"src/**/*.ts\" --fix",
"lint:check": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
@@ -18,9 +20,11 @@
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts",
"seed": "ts-node -r tsconfig-paths/register src/seed.ts"
"seed": "ts-node -r tsconfig-paths/register src/seed.ts",
"sync:migrations": "node scripts/sync-helm-migrations.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1077.0",
"@kubernetes/client-node": "^1.4.0",
"@nestjs/bull": "^11.0.4",
"@nestjs/common": "^11.1.24",
@@ -30,6 +34,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.26",
"@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"bcrypt": "^6.0.0",
"bull": "^4.12.0",
@@ -37,8 +42,8 @@
"class-validator": "^0.15.1",
"handlebars": "^4.7.8",
"helmet": "^8.2.0",
"ioredis": "^5.11.1",
"js-yaml": "^4.2.0",
"minio": "^8.0.7",
"multer": "^2.1.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -46,7 +51,8 @@
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.2",
"typeorm": "^1.0.0",
"uuid": "^14.0.0"
"uuid": "^14.0.0",
"yauzl": "^3.4.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.23",
@@ -59,6 +65,7 @@
"@types/multer": "^2.1.0",
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.0",
"@types/yauzl": "^3.4.0",
"@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^9.0.0",
@@ -84,6 +91,9 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"setupFilesAfterEnv": [
"<rootDir>/test-setup.ts"
]
}
}
+94
View File
@@ -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)`);
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
/**
* Copy SQL migrations from backend/migrations/ into the Helm chart ConfigMap source.
* Run after adding or editing migration files: npm run sync:migrations
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sourceDir = path.resolve(__dirname, '../migrations');
const targetDir = path.resolve(__dirname, '../helm/cloudhost-platform/migrations');
if (!fs.existsSync(sourceDir)) {
console.error(`Source not found: ${sourceDir}`);
process.exit(1);
}
fs.mkdirSync(targetDir, { recursive: true });
const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
fs.copyFileSync(path.join(sourceDir, file), path.join(targetDir, file));
}
// Remove stale SQL files no longer in source
for (const existing of fs.readdirSync(targetDir)) {
if (existing.endsWith('.sql') && !files.includes(existing)) {
fs.unlinkSync(path.join(targetDir, existing));
}
}
console.log(`Synced ${files.length} migration(s) to ${targetDir}`);
+20 -7
View File
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { ApplicationsModule } from './applications/applications.module';
@@ -15,8 +17,8 @@ import { SnapshotsModule } from './snapshots/snapshots.module';
import { LifecycleModule } from './lifecycle/lifecycle.module';
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
import { AdminModule } from './admin/admin.module';
import { RedisModule } from './common/redis/redis.module';
import { StorageModule } from './common/storage/storage.module';
import { HealthModule } from './health/health.module';
import { StorageModule } from './storage/storage.module';
import configuration from './config/configuration';
@Module({
@@ -51,18 +53,22 @@ import configuration from './config/configuration';
redis: {
host: configService.get('redis.host'),
port: configService.get('redis.port'),
password: configService.get('redis.password'),
},
}),
inject: [ConfigService],
}),
// Shared Redis client (build state across replicas)
RedisModule,
// Shared MinIO storage (application source archives)
StorageModule,
ThrottlerModule.forRoot([
{
name: 'default',
ttl: 60_000,
limit: 120,
},
]),
// Feature modules
StorageModule,
AuthModule,
UsersModule,
ApplicationsModule,
@@ -76,6 +82,13 @@ import configuration from './config/configuration';
LifecycleModule,
ApplicationMigrationsModule,
AdminModule,
HealthModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
@@ -18,7 +18,8 @@ import {
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
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 { DomainService } from './domain.service';
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto';
@@ -67,7 +68,20 @@ export class ApplicationsController {
}
@Post(':id/upload')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@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')
@UseInterceptors(FileInterceptor('file', {
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.');
}
// 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)
await this.kubernetesService.updateResources(app, dto, workload);
@@ -17,7 +17,13 @@ import {
} from '../common/enums';
import { ensureAppUrlEnv } from './app-url.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()
export class ApplicationsService {
@@ -28,7 +34,7 @@ export class ApplicationsService {
private appsRepository: Repository<Application>,
private clustersService: ClustersService,
private configService: ConfigService,
private storageService: StorageService,
private sourceStorage: SourceStorageService,
) {}
private toDnsLabel(value: string): string {
@@ -62,6 +68,18 @@ export class ApplicationsService {
dto = normalizeCreateApplicationDto(dto);
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.
const allocation = await this.clustersService.selectClusterForApplication(dto, userId);
const clusterId = allocation.cluster.id;
@@ -91,7 +109,7 @@ export class ApplicationsService {
const baseLabel = dto.name;
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);
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> {
const app = await this.findOne(id, userId);
// Delete the uploaded source archive from object storage.
// Delete uploaded source files
if (app.codePath) {
await this.storageService.removeSource(app.codePath);
}
// Remove any legacy on-disk dump/source dir (db dumps are still stored locally).
try {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
if (fs.existsSync(appDir)) {
fs.rmSync(appDir, { recursive: true, force: true });
this.logger.log(`Deleted upload directory: ${appDir}`);
try {
await this.sourceStorage.deleteSource(app.userId, app.id, app.codePath);
} catch (e: any) {
this.logger.warn(`Failed to delete source for ${app.name}: ${e.message}`);
}
} catch (e: any) {
this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`);
}
await this.appsRepository.remove(app);
@@ -265,15 +276,38 @@ export class ApplicationsService {
}
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
// pull it via a presigned URL — no local disk, no PVC, no kubectl cp).
const key = await this.storageService.putSource(app.userId, app.id, file.buffer);
app.codePath = key;
const saved = await this.appsRepository.save(app);
try {
const detected = await detectRuntimeFromArchive(tempPath);
assertRuntimeMatch(app.runtime, detected);
this.logger.log(`Uploaded code for ${app.name}${key} (${(file.size / 1024).toFixed(1)} KB)`);
return saved;
const storedPath = await this.sourceStorage.putSource(app.userId, app.id, file.buffer);
app.codePath = storedPath;
const saved = await this.appsRepository.save(app);
if (detected.confidence === 'low') {
Object.assign(saved, {
runtimeWarning:
'Could not determine the project type from the archive with high confidence. Build may fail if the selected runtime is wrong.',
});
}
this.logger.log(`Uploaded code for ${app.name}${storedPath} (${(file.size / 1024).toFixed(1)} KB)`);
return saved;
} catch (err) {
try {
await this.sourceStorage.deleteSource(app.userId, app.id);
} catch {
// ignore rollback errors
}
throw err;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
async uploadDbDump(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
@@ -16,6 +16,7 @@ import {
CustomDomainStatus,
ProductType,
} from '../../common/enums';
import { Exclude, Expose } from 'class-transformer';
import { User } from '../../users/entities/user.entity';
import { Deployment } from '../../deployments/entities/deployment.entity';
@@ -51,9 +52,16 @@ export class Application {
@Column({ nullable: true })
dbUsername: string;
/** Never expose raw DB password in API responses — use hasDbPassword for UI. */
@Exclude({ toPlainOnly: true })
@Column({ nullable: true })
dbPassword: string;
@Expose()
get hasDbPassword(): boolean {
return !!this.dbPassword;
}
@Column({ nullable: true, default: '1Gi' })
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
@@ -111,8 +119,19 @@ export class Application {
@Column({ nullable: true })
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 })
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 })
gitBranch: string; // Branch to clone (default: main)
+2
View File
@@ -1,5 +1,6 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@@ -7,6 +8,7 @@ import { OtpRequestDto, OtpVerifyDto } from './dto/otp.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Authentication')
@Throttle({ default: { limit: 20, ttl: 60_000 } })
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@@ -0,0 +1,149 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BillingService } from './billing.service';
import { BillingOpsService } from './billing-ops.service';
import { assertStubGatewayAllowed } from './payment-gateway.util';
import {
ChargeWalletDto,
InitiateInvoicePaymentDto,
VerifyInvoiceGatewayDto,
UpdateInvoiceStatusDto,
} from './dto/billing.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole, InvoiceStatus, PaymentMethod } from '../common/enums';
@ApiTags('Billing')
@ApiBearerAuth()
@Controller('billing')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class BillingInvoicesController {
constructor(
private readonly billingService: BillingService,
private readonly billingOpsService: BillingOpsService,
) {}
// ─── Invoices ─────────────────────────────────────────────────────
@Get('invoices')
@ApiOperation({ summary: 'List my invoices' })
async listMyInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('applicationId') applicationId?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
applicationId,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('invoices/:id')
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
async getInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Post('invoices/:id/pay/mixed')
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
async initiateInvoiceMixed(
@Request() req: any,
@Param('id') id: string,
@Body() dto: InitiateInvoicePaymentDto,
) {
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('invoices/:id/gateway/verify')
@ApiOperation({ summary: 'Verify invoice gateway payment' })
async verifyInvoiceGateway(
@Request() req: any,
@Param('id') id: string,
@Body() dto: VerifyInvoiceGatewayDto,
) {
assertStubGatewayAllowed();
const result = await this.billingService.verifyInvoiceGatewayPayment(
id,
req.user,
dto.trackingCode,
dto.amount,
);
const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
// ─── Invoice Admin ────────────────────────────────────────────────
@Get('admin/invoices')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all invoices (Admin)' })
async listAdminInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('userId') userId?: string,
@Query('applicationId') applicationId?: string,
@Query('paymentMethod') paymentMethod?: PaymentMethod,
@Query('search') search?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
userId,
applicationId,
paymentMethod,
search,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('admin/invoices/:id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get invoice details (Admin)' })
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Patch('admin/invoices/:id/status')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
async updateAdminInvoiceStatus(
@Param('id') id: string,
@Body() dto: UpdateInvoiceStatusDto,
) {
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
}
// ─── Wallet Admin ─────────────────────────────────────────────────
@Get('admin/wallets')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all wallets (Admin)' })
async getAllWallets() {
return this.billingService.getAllWallets();
}
@Post('admin/wallets/:userId/charge')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
async adminChargeWallet(
@Param('userId') userId: string,
@Body() dto: ChargeWalletDto,
) {
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
}
}
+258
View File
@@ -0,0 +1,258 @@
import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common';
import { BillingService } from './billing.service';
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { UpgradeResourcesDto } from './dto/billing.dto';
import {
BillingCycle,
InvoiceStatus,
ProductType,
DatabaseType,
UserRole,
} from '../common/enums';
import { Application } from '../applications/entities/application.entity';
@Injectable()
export class BillingOpsService {
constructor(
private readonly billingService: BillingService,
@Inject(forwardRef(() => AppLifecycleService))
private readonly lifecycleService: AppLifecycleService,
@Inject(forwardRef(() => ApplicationsService))
private readonly applicationsService: ApplicationsService,
@Inject(forwardRef(() => KubernetesService))
private readonly kubernetesService: KubernetesService,
) {}
async completePaidInvoiceEffect(invoice: any) {
if (invoice.status !== InvoiceStatus.PAID) return null;
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
const action = invoice.metadata?.action;
if (!action || !invoice.applicationId) return null;
if (action === 'renew' || action === 'activate') {
const cycle = invoice.metadata?.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) return null;
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
const result = {
action,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
billingCycle: activated.billingCycle,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
if (action === 'upgrade') {
const app = await this.applicationsService.findOne(invoice.applicationId);
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
const updatedApp = await this.applicationsService.update(
app.id,
app.userId,
this.buildUpgradeEntityPatch(app, resources),
);
try {
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
const result = {
action,
application: {
id: updatedApp.id,
name: updatedApp.name,
cpuRequest: updatedApp.cpuRequest,
cpuLimit: updatedApp.cpuLimit,
memoryRequest: updatedApp.memoryRequest,
memoryLimit: updatedApp.memoryLimit,
replicas: updatedApp.replicas,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
return null;
}
buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
const pt = app.productType ?? ProductType.APPLICATION;
if (dto.redisResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
redis: {
...app.optionalServiceResources?.redis,
...dto.redisResources,
storageGi:
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
},
},
};
}
if (dto.rabbitmqResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
rabbitmq: {
...app.optionalServiceResources?.rabbitmq,
...dto.rabbitmqResources,
storageGi:
dto.rabbitmqResources.storageGi ??
app.optionalServiceResources?.rabbitmq?.storageGi ??
2,
},
},
};
}
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
return {};
}
return {
cpuRequest: dto.cpuRequest || app.cpuRequest,
cpuLimit: dto.cpuLimit || app.cpuLimit,
memoryRequest: dto.memoryRequest || app.memoryRequest,
memoryLimit: dto.memoryLimit || app.memoryLimit,
replicas: dto.replicas ?? app.replicas,
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
appStorageSize: dto.appStorageSize || app.appStorageSize,
};
}
async applyUpgradeToKubernetes(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
): Promise<void> {
const pt = app.productType ?? ProductType.APPLICATION;
if (pt === ProductType.MANAGED_DATABASE) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
},
'database',
);
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
return;
}
if (pt === ProductType.MANAGED_REDIS) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
return;
}
if (pt === ProductType.MANAGED_RABBITMQ) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
return;
}
if (dto.redisResources && app.enableRedis) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
}
if (dto.rabbitmqResources && app.enableRabbitmq) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
}
const touchesAppWorkload =
dto.cpuRequest !== undefined ||
dto.cpuLimit !== undefined ||
dto.memoryRequest !== undefined ||
dto.memoryLimit !== undefined ||
dto.replicas !== undefined;
if (touchesAppWorkload) {
await this.kubernetesService.updateResources(app, {
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
});
}
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
}
if (
dto.dbStorageSize &&
dto.dbStorageSize !== previous.dbStorageSize &&
previous.databaseType &&
previous.databaseType !== DatabaseType.NONE
) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
async applyOptionalServiceUpgrade(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
service: 'redis' | 'rabbitmq',
): Promise<void> {
const res = app.optionalServiceResources?.[service];
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
if (res) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: res.cpuRequest,
cpuLimit: res.cpuLimit,
memoryRequest: res.memoryRequest,
memoryLimit: res.memoryLimit,
},
service,
);
}
const prevGi =
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
const nextGi = dtoRes?.storageGi;
if (nextGi != null && nextGi > prevGi) {
const resize =
service === 'redis'
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
async getAppWithAccess(user: any, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
if (isAdminOrSales) {
return this.applicationsService.findOne(applicationId);
}
return this.applicationsService.findOne(applicationId, user.id);
}
}
@@ -0,0 +1,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,
};
}
}
+33 -535
View File
@@ -3,35 +3,27 @@ import {
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
BadRequestException,
Inject,
forwardRef,
ForbiddenException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BillingService } from './billing.service';
import { BillingOpsService } from './billing-ops.service';
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import {
ChargeWalletDto,
CalculateCostDto,
CalculateDeployCostDto,
SetOptionalServicesPricingDto,
RenewApplicationDto,
UpgradeResourcesDto,
CalculateUpgradeCostDto,
InitiateInvoicePaymentDto,
VerifyInvoiceGatewayDto,
UpdateInvoiceStatusDto,
PayApplicationDto,
} from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { RolesGuard } from '../common/guards/roles.guard';
@@ -41,12 +33,7 @@ import {
BillingCycle,
AppLifecycleStatus,
InvoiceReason,
InvoiceStatus,
PaymentMethod,
ProductType,
DatabaseType,
} from '../common/enums';
import { Application } from '../applications/entities/application.entity';
@ApiTags('Billing')
@ApiBearerAuth()
@@ -55,12 +42,11 @@ import { Application } from '../applications/entities/application.entity';
export class BillingController {
constructor(
private readonly billingService: BillingService,
private readonly billingOpsService: BillingOpsService,
@Inject(forwardRef(() => AppLifecycleService))
private readonly lifecycleService: AppLifecycleService,
@Inject(forwardRef(() => ApplicationsService))
private readonly applicationsService: ApplicationsService,
@Inject(forwardRef(() => KubernetesService))
private readonly kubernetesService: KubernetesService,
) {}
// ─── Pricing catalog (Admin) ──────────────────────────────────────
@@ -98,6 +84,29 @@ export class BillingController {
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 ─────────────────────────────────────
@Get('settings/custom-domain-price')
@@ -129,274 +138,6 @@ export class BillingController {
return this.billingService.setOptionalServicesPricing(dto);
}
// ─── Wallet (User) ───────────────────────────────────────────────
@Get('wallet')
@ApiOperation({ summary: 'Get my wallet balance' })
async getBalance(@Request() req: any) {
return this.billingService.getBalance(req.user.id);
}
@Post('wallet/charge')
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
}
@Get('wallet/transactions')
@ApiOperation({ summary: 'Get my wallet transactions' })
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
}
@Get('resource-credits')
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
async getResourceCredits(@Request() req: any) {
const credits = await this.billingService.getActiveCredits(req.user.id);
return credits.map((c) => this.billingService.formatCreditForApi(c));
}
// ─── Invoices ─────────────────────────────────────────────────────
@Get('invoices')
@ApiOperation({ summary: 'List my invoices' })
async listMyInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('applicationId') applicationId?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
applicationId,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('invoices/:id')
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
async getInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Post('invoices/:id/pay/mixed')
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
async initiateInvoiceMixed(
@Request() req: any,
@Param('id') id: string,
@Body() dto: InitiateInvoicePaymentDto,
) {
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('invoices/:id/gateway/verify')
@ApiOperation({ summary: 'Verify invoice gateway payment' })
async verifyInvoiceGateway(
@Request() req: any,
@Param('id') id: string,
@Body() dto: VerifyInvoiceGatewayDto,
) {
const result = await this.billingService.verifyInvoiceGatewayPayment(
id,
req.user,
dto.trackingCode,
dto.amount,
);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('wallet/pay/:applicationId')
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
async payForApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: PayApplicationDto,
) {
const cycle = body.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) {
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
}
const app = await this.applicationsService.findOne(applicationId, req.user.id);
const payment = await this.billingService.resolveAppPayment(
req.user.id,
app,
cycle,
);
const coupon = await this.billingService.resolveCoupon(
req.user.id,
body.couponCode,
await this.billingService.getAppChargeBreakdown(app),
cycle,
payment.amountDue,
);
let invoice = null;
if (payment.amountDue > 0) {
invoice = await this.billingService.createInvoice({
userId: req.user.id,
applicationId: app.id,
reason: InvoiceReason.DEPLOY,
lines: [
{
label: `Application payment: ${app.name}`,
description: `Billing cycle: ${cycle}`,
amount: payment.amountDue,
metadata: {
cycle,
waivedAmount: payment.waivedAmount,
creditApplied: payment.creditId || null,
},
},
],
metadata: {
action: 'activate',
cycle,
},
discount: coupon ?? undefined,
});
}
let tx = null;
if (invoice) {
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
tx = paid.transaction;
invoice = paid.invoice;
}
const activated = await this.lifecycleService.activateApp(
applicationId,
cycle,
);
return {
transaction: tx,
invoice,
creditApplied: payment.creditId || null,
waivedAmount: payment.waivedAmount,
discountAmount: coupon?.amount ?? 0,
discountCode: coupon?.code ?? null,
paidAmount: invoice ? Number(invoice.total) : 0,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
},
message: payment.waivedAmount > 0
? payment.amountDue > 0
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
: payment.amountDue > 0
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
: `Application "${activated.name}" activated`,
};
}
// ─── Payment Gateway ─────────────────────────────────────────────
@Post('gateway/initiate')
@ApiOperation({ summary: 'Initiate a payment gateway transaction' })
async initiateGateway(
@Request() req: any,
@Body() body: { amount: number; description?: string; callbackUrl: string },
) {
// In production, integrate with Zarinpal/IDPay/etc.
// For now, simulate a gateway redirect URL.
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
return {
success: true,
trackingCode,
gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`,
message: 'Redirect user to gatewayUrl to complete payment',
};
}
@Post('gateway/verify')
@ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' })
async verifyGateway(
@Request() req: any,
@Body() body: { trackingCode: string; amount: number },
) {
// In production, verify with the gateway provider.
// For now, auto-approve and charge the wallet.
await this.billingService.chargeWallet(
req.user.id,
body.amount,
`Payment gateway: ${body.trackingCode}`,
);
return {
success: true,
message: 'Payment verified and wallet charged',
trackingCode: body.trackingCode,
};
}
// ─── Invoice Admin ────────────────────────────────────────────────
@Get('admin/invoices')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all invoices (Admin)' })
async listAdminInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('userId') userId?: string,
@Query('applicationId') applicationId?: string,
@Query('paymentMethod') paymentMethod?: PaymentMethod,
@Query('search') search?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
userId,
applicationId,
paymentMethod,
search,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('admin/invoices/:id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get invoice details (Admin)' })
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Patch('admin/invoices/:id/status')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
async updateAdminInvoiceStatus(
@Param('id') id: string,
@Body() dto: UpdateInvoiceStatusDto,
) {
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
}
// ─── Wallet Admin ─────────────────────────────────────────────────
@Get('admin/wallets')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all wallets (Admin)' })
async getAllWallets() {
return this.billingService.getAllWallets();
}
@Post('admin/wallets/:userId/charge')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
async adminChargeWallet(
@Param('userId') userId: string,
@Body() dto: ChargeWalletDto,
) {
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
}
// ─── Application Renewal ──────────────────────────────────────────
@Get('applications/:applicationId/renewal-cost')
@@ -405,8 +146,7 @@ export class BillingController {
@Request() req: any,
@Param('applicationId') applicationId: string,
) {
// User can only view their own app, admin/sales can view any
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
const costs = await this.billingService.calculateRenewalCost(app);
return {
applicationId: app.id,
@@ -425,7 +165,7 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: RenewApplicationDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
const costs = await this.billingService.calculateRenewalCost(app);
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
@@ -467,10 +207,8 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: RenewApplicationDto,
) {
// User can only renew their own app, admin/sales can renew any
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
// Calculate cost for the selected cycle
const costs = await this.billingService.calculateRenewalCost(app);
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
@@ -510,7 +248,6 @@ export class BillingController {
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
// Activate the application
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
return {
@@ -544,7 +281,6 @@ export class BillingController {
}
if (body.bypassPayment) {
// Direct activation without payment (for special cases, support, etc.)
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
return {
success: true,
@@ -560,7 +296,6 @@ export class BillingController {
};
}
// Normal renewal - deduct from app owner's wallet
const costs = await this.billingService.calculateRenewalCost(app);
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
: cycle === BillingCycle.MONTHLY ? costs.monthly
@@ -617,7 +352,7 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: CalculateUpgradeCostDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
const result = await this.billingService.calculateUpgradeCost(app, dto);
return {
@@ -652,7 +387,7 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: UpgradeResourcesDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
throw new BadRequestException(
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`,
@@ -704,20 +439,17 @@ export class BillingController {
@Param('applicationId') applicationId: string,
@Body() dto: UpgradeResourcesDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
// Application must be active to upgrade
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
throw new BadRequestException(
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`
);
}
// Calculate upgrade cost
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
let paidInvoice = null;
// If upgrading (positive difference), require payment
if (costResult.proratedAmount > 0) {
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
? app.userId
@@ -761,11 +493,11 @@ export class BillingController {
const updatedApp = await this.applicationsService.update(
app.id,
app.userId,
this.buildUpgradeEntityPatch(app, dto),
this.billingOpsService.buildUpgradeEntityPatch(app, dto),
);
try {
await this.applyUpgradeToKubernetes(updatedApp, dto, app);
await this.billingOpsService.applyUpgradeToKubernetes(updatedApp, dto, app);
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
@@ -790,238 +522,4 @@ export class BillingController {
: 'Resources updated (downgrade or no cost change).',
};
}
// ─── Helper Methods ───────────────────────────────────────────────
private async completePaidInvoiceEffect(invoice: any) {
if (invoice.status !== InvoiceStatus.PAID) return null;
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
const action = invoice.metadata?.action;
if (!action || !invoice.applicationId) return null;
if (action === 'renew' || action === 'activate') {
const cycle = invoice.metadata?.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) return null;
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
const result = {
action,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
billingCycle: activated.billingCycle,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
if (action === 'upgrade') {
const app = await this.applicationsService.findOne(invoice.applicationId);
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
const updatedApp = await this.applicationsService.update(
app.id,
app.userId,
this.buildUpgradeEntityPatch(app, resources),
);
try {
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
const result = {
action,
application: {
id: updatedApp.id,
name: updatedApp.name,
cpuRequest: updatedApp.cpuRequest,
cpuLimit: updatedApp.cpuLimit,
memoryRequest: updatedApp.memoryRequest,
memoryLimit: updatedApp.memoryLimit,
replicas: updatedApp.replicas,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
return null;
}
private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
const pt = app.productType ?? ProductType.APPLICATION;
if (dto.redisResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
redis: {
...app.optionalServiceResources?.redis,
...dto.redisResources,
storageGi:
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
},
},
};
}
if (dto.rabbitmqResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
rabbitmq: {
...app.optionalServiceResources?.rabbitmq,
...dto.rabbitmqResources,
storageGi:
dto.rabbitmqResources.storageGi ??
app.optionalServiceResources?.rabbitmq?.storageGi ??
2,
},
},
};
}
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
return {};
}
return {
cpuRequest: dto.cpuRequest || app.cpuRequest,
cpuLimit: dto.cpuLimit || app.cpuLimit,
memoryRequest: dto.memoryRequest || app.memoryRequest,
memoryLimit: dto.memoryLimit || app.memoryLimit,
replicas: dto.replicas ?? app.replicas,
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
appStorageSize: dto.appStorageSize || app.appStorageSize,
};
}
private async applyUpgradeToKubernetes(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
): Promise<void> {
const pt = app.productType ?? ProductType.APPLICATION;
if (pt === ProductType.MANAGED_DATABASE) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
},
'database',
);
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
return;
}
if (pt === ProductType.MANAGED_REDIS) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
return;
}
if (pt === ProductType.MANAGED_RABBITMQ) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
return;
}
if (dto.redisResources && app.enableRedis) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
}
if (dto.rabbitmqResources && app.enableRabbitmq) {
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
}
const touchesAppWorkload =
dto.cpuRequest !== undefined ||
dto.cpuLimit !== undefined ||
dto.memoryRequest !== undefined ||
dto.memoryLimit !== undefined ||
dto.replicas !== undefined;
if (touchesAppWorkload) {
await this.kubernetesService.updateResources(app, {
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
});
}
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
}
if (
dto.dbStorageSize &&
dto.dbStorageSize !== previous.dbStorageSize &&
previous.databaseType &&
previous.databaseType !== DatabaseType.NONE
) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
private async applyOptionalServiceUpgrade(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
service: 'redis' | 'rabbitmq',
): Promise<void> {
const res = app.optionalServiceResources?.[service];
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
if (res) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: res.cpuRequest,
cpuLimit: res.cpuLimit,
memoryRequest: res.memoryRequest,
memoryLimit: res.memoryLimit,
},
service,
);
}
const prevGi =
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
const nextGi = dtoRes?.storageGi;
if (nextGi != null && nextGi > prevGi) {
const resize =
service === 'redis'
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
private async getAppWithAccess(user: any, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
if (isAdminOrSales) {
return this.applicationsService.findOne(applicationId);
}
// Regular user - must own the app
return this.applicationsService.findOne(applicationId, user.id);
}
}
+15 -3
View File
@@ -1,7 +1,11 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingService } from './billing.service';
import { BillingOpsService } from './billing-ops.service';
import { BillingController } from './billing.controller';
import { BillingWalletController } from './billing-wallet.controller';
import { BillingInvoicesController } from './billing-invoices.controller';
import { PublicPricingController } from './public-pricing.controller';
import { DiscountController } from './discount.controller';
import { DiscountService } from './discount.service';
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 { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
@@ -34,13 +39,20 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
InvoiceLine,
Discount,
DiscountRedemption,
PlatformSetting,
]),
forwardRef(() => LifecycleModule),
forwardRef(() => ApplicationsModule),
forwardRef(() => KubernetesModule),
],
controllers: [BillingController, DiscountController],
providers: [BillingService, PricingCatalogService, DiscountService],
exports: [BillingService, PricingCatalogService, DiscountService],
controllers: [
BillingController,
BillingWalletController,
BillingInvoicesController,
PublicPricingController,
DiscountController,
],
providers: [BillingService, BillingOpsService, PricingCatalogService, DiscountService],
exports: [BillingService, BillingOpsService, PricingCatalogService, DiscountService],
})
export class BillingModule {}
+85 -32
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
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 { WalletTransaction } from './entities/wallet-transaction.entity';
import { Invoice } from './entities/invoice.entity';
@@ -48,6 +48,18 @@ export class BillingService {
return this.pricingCatalog.updateCatalog(dto);
}
// ─── Global (platform-wide) discount ──────────────────────────────
/** Current platform-wide discount percentage (0100). */
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 ─────────────────────────────────────────────
/**
@@ -160,6 +172,32 @@ export class BillingService {
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(
userId: string,
amount: number,
@@ -168,21 +206,23 @@ export class BillingService {
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId);
wallet.balance = Number(wallet.balance) + amount;
await this.walletRepo.save(wallet);
const saved = await this.walletRepo.manager.transaction(async (em) => {
const wallet = await this.lockWallet(em, userId);
wallet.balance = Number(wallet.balance) + amount;
await em.getRepository(Wallet).save(wallet);
const tx = this.txRepo.create({
walletId: wallet.id,
type: TransactionType.CHARGE,
amount,
balanceAfter: wallet.balance,
description: description || 'Wallet charge',
invoiceId,
const tx = em.getRepository(WalletTransaction).create({
walletId: wallet.id,
type: TransactionType.CHARGE,
amount,
balanceAfter: wallet.balance,
description: description || 'Wallet charge',
invoiceId,
});
return em.getRepository(WalletTransaction).save(tx);
});
const saved = await this.txRepo.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;
}
@@ -195,26 +235,28 @@ export class BillingService {
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId);
if (Number(wallet.balance) < amount) {
throw new BadRequestException('Insufficient wallet balance');
}
const saved = await this.walletRepo.manager.transaction(async (em) => {
const wallet = await this.lockWallet(em, userId);
if (Number(wallet.balance) < amount) {
throw new BadRequestException('Insufficient wallet balance');
}
wallet.balance = Number(wallet.balance) - amount;
await this.walletRepo.save(wallet);
wallet.balance = Number(wallet.balance) - amount;
await em.getRepository(Wallet).save(wallet);
const tx = this.txRepo.create({
walletId: wallet.id,
type: TransactionType.DEDUCTION,
amount,
balanceAfter: wallet.balance,
description: description || 'Service payment',
applicationId,
invoiceId,
const tx = em.getRepository(WalletTransaction).create({
walletId: wallet.id,
type: TransactionType.DEDUCTION,
amount,
balanceAfter: wallet.balance,
description: description || 'Service payment',
applicationId,
invoiceId,
});
return em.getRepository(WalletTransaction).save(tx);
});
const saved = await this.txRepo.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;
}
@@ -731,7 +773,9 @@ export class BillingService {
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 remainingHours = 0;
@@ -740,9 +784,18 @@ export class BillingService {
const expiresAt = new Date(app.planExpiresAt);
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)
if (difference.hourly > 0) {
proratedAmount = Math.ceil(difference.hourly * remainingHours);
if (cycleDifference > 0) {
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 { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import {
AppRuntime,
BillingCycle,
@@ -47,6 +48,13 @@ describe('PricingCatalogService', () => {
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 () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
@@ -56,6 +64,7 @@ describe('PricingCatalogService', () => {
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
{ provide: getRepositoryToken(PlatformSetting), useValue: settingsRepo },
],
}).compile();
+106 -8
View File
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import {
AppRuntime,
BillingCycle,
@@ -39,12 +40,22 @@ import {
} from './dto/pricing-catalog.dto';
import { OptionalServiceResourcesDto } from './dto/optional-service-resources.dto';
/** PlatformSetting key holding the platform-wide discount percentage (0100). */
export const GLOBAL_DISCOUNT_SETTING_KEY = 'global_discount_percent';
export interface CyclePrices {
hourly: number;
monthly: number;
yearly: number;
}
export interface CostTotals {
hourly: number;
monthly: number;
yearly: number;
breakdown: CostBreakdownLine[];
}
export interface PricingRateRow {
resourceType: PricingResourceType;
hourlyPrice: number;
@@ -129,10 +140,18 @@ export class PricingCatalogService implements OnModuleInit {
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
@InjectRepository(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() {
await this.ensureDefaults();
await this.getGlobalDiscountPercent(true);
}
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 rates = await this.getRatesForRuntime(runtime);
const optional = await this.getOptionalBillingContext();
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 (0100), 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(
dto: CalculateCostDto,
rates: PricingRate[],
@@ -739,13 +834,16 @@ export class PricingCatalogService implements OnModuleInit {
cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit);
memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit);
}
const storageQty =
(dto.dbStorageSize
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
: 0) +
(dto.appStorageSize
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
: 0);
// App resources (CPU/RAM/storage) bill per replica — each replica is a full
// 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
: 0;
const appStorage = dto.appStorageSize
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
: 0;
const storageQty = dbStorage + appStorage * replicas;
const map = new Map<PricingResourceType, number>();
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,
};
}
}
+105
View File
@@ -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();
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { Module, forwardRef } from '@nestjs/common';
import { BuildService } from './build.service';
import { ScanService } from './scan.service';
import { BuildProgressStore } from './build-progress.store';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
import { ClustersModule } from '../clusters/clusters.module';
@@ -9,7 +9,7 @@ import { ClustersModule } from '../clusters/clusters.module';
forwardRef(() => KubernetesModule),
ClustersModule,
],
providers: [BuildService, ScanService],
exports: [BuildService, ScanService],
providers: [BuildService, BuildProgressStore],
exports: [BuildService],
})
export class BuildModule {}
+152 -246
View File
@@ -1,259 +1,165 @@
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 { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
import { BuildProgressStore } from './build-progress.store';
import { SourceStorageService } from '../storage/source-storage.service';
/**
* Tests for build service:
* 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.
*/
describe('BuildService', () => {
let service: BuildService;
/**
* Tests for the WordPress build flow specifically:
* 1. Helper pod PVC race condition (must wait for termination)
* 2. WordPress Dockerfile generation correctness
* 3. Entrypoint should use ENTRYPOINT not CMD to avoid double docker-entrypoint.sh execution
*/
describe('WordPress Dockerfile generation', () => {
// Reproduce the wordpressDockerfile logic from build.service.ts
function wordpressDockerfile(app: {
runtimeVersion?: string;
phpVersion?: string;
codePath?: string;
port?: number;
}): string {
const wpVersion = app.runtimeVersion || '6.7';
const phpVersion = app.phpVersion || '8.3';
const hasUploadedCode = !!app.codePath;
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
RUN docker-php-ext-install opcache
RUN a2enmod rewrite
RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini
${hasUploadedCode ? `COPY . /tmp/user-content
RUN mkdir -p /usr/src/wordpress-user
ENTRYPOINT ["cloudhost-entrypoint.sh"]
CMD []` : `CMD ["apache2-foreground"]`}
EXPOSE 80
`;
}
it('should use ENTRYPOINT (not CMD) when user uploaded code', () => {
const df = wordpressDockerfile({ codePath: '/some/path/source.zip' });
expect(df).toContain('ENTRYPOINT ["cloudhost-entrypoint.sh"]');
expect(df).not.toContain('CMD ["cloudhost-entrypoint.sh"]');
});
it('should use CMD apache2-foreground for fresh install (no code)', () => {
const df = wordpressDockerfile({});
expect(df).toContain('CMD ["apache2-foreground"]');
expect(df).not.toContain('ENTRYPOINT');
});
it('should use correct WordPress and PHP versions', () => {
const df = wordpressDockerfile({ runtimeVersion: '6.4', phpVersion: '8.2' });
expect(df).toContain('FROM wordpress:6.4-php8.2-apache');
});
it('should default to WP 6.7 and PHP 8.3', () => {
const df = wordpressDockerfile({});
expect(df).toContain('FROM wordpress:6.7-php8.3-apache');
});
it('should COPY user content when codePath exists', () => {
const df = wordpressDockerfile({ codePath: '/tmp/source.zip' });
expect(df).toContain('COPY . /tmp/user-content');
});
it('should NOT copy user content for fresh install', () => {
const df = wordpressDockerfile({});
expect(df).not.toContain('COPY . /tmp/user-content');
});
});
describe('Helper pod PVC race condition', () => {
it('should wait for pod deletion (not just fire-and-forget)', () => {
// Simulate the fix: after deleteNamespacedPod, poll readNamespacedPod until 404
const deletionSteps = [
{ exists: true }, // pod still terminating
{ exists: true }, // still terminating
{ exists: false }, // gone (404)
];
let pollCount = 0;
let fullyTerminated = false;
for (const step of deletionSteps) {
pollCount++;
if (!step.exists) {
fullyTerminated = true;
break;
}
}
expect(fullyTerminated).toBe(true);
expect(pollCount).toBe(3);
});
it('should time out if pod never terminates', () => {
const maxPolls = 30; // e.g. 60s / 2s interval
let pollCount = 0;
let timedOut = false;
while (pollCount < maxPolls) {
pollCount++;
// Pod always exists (simulating stuck termination)
const exists = true;
if (!exists) break;
}
if (pollCount >= maxPolls) {
timedOut = true;
}
expect(timedOut).toBe(true);
});
});
describe('WordPress entrypoint script', () => {
const entrypointScript = `#!/bin/bash
set -e
# Merge user wp-content into PVC
if [ -d /usr/src/wordpress-user/wp-content ]; then
mkdir -p /var/www/html/wp-content
cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/
chown -R www-data:www-data /var/www/html/wp-content
fi
exec docker-entrypoint.sh apache2-foreground`;
it('should call docker-entrypoint.sh exactly once (via exec)', () => {
const matches = entrypointScript.match(/docker-entrypoint\.sh/g);
expect(matches).toHaveLength(1);
});
it('should use exec to replace process', () => {
expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground');
});
it('should merge wp-content on every start when staged content exists', () => {
expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content');
expect(entrypointScript).not.toContain('.user-content-merged');
});
it('should not copy user wp-config.php (credentials come from env vars)', () => {
expect(entrypointScript).not.toContain('wp-config.php');
});
it('should set proper ownership after merging wp-content', () => {
expect(entrypointScript).toContain('chown -R www-data:www-data /var/www/html/wp-content');
});
});
describe('WordPress zip structure handling', () => {
// The unzip init container handles single-subfolder flattening
it('should flatten single subfolder (public_html/) to root', () => {
// Simulate: zip contains only public_html/
const extractedItems = ['public_html'];
const count = extractedItems.length;
const firstItem = extractedItems[0];
let flattenedToRoot = false;
if (count === 1 && firstItem === 'public_html') {
// cp -a /tmp/extract/public_html/. /workspace-out/source/
flattenedToRoot = true;
}
expect(flattenedToRoot).toBe(true);
});
it('should copy as-is when multiple items exist', () => {
// Simulate: zip contains multiple items at root
const extractedItems = ['wp-admin', 'wp-content', 'wp-includes', 'index.php'];
const count = extractedItems.length;
let copiedAsIs = false;
if (count !== 1) {
copiedAsIs = true;
}
expect(copiedAsIs).toBe(true);
});
});
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`,
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BuildService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
const map: Record<string, string> = {
'build.namespace': 'cloudhost-builds',
'build.serviceAccount': 'kaniko-builder',
'registry.url': 'registry.local:5000',
};
return map[key];
}),
},
},
{ provide: ClustersService, useValue: {} },
{
provide: BuildProgressStore,
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
},
{ provide: RegistryService, useValue: {} },
{
provide: SourceStorageService,
useValue: {
isObjectStorage: () => false,
materializeToTempFile: jest.fn(),
getSize: jest.fn(),
},
},
],
volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }],
};
}
}).compile();
it('prefers a user-provided Dockerfile (BYO), falling back to Nixpacks', () => {
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');
service = module.get(BuildService);
});
it('uses the configured Nixpacks image (default when unset)', () => {
expect(nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }).image).toBe('ghcr.io/railwayapp/nixpacks:latest');
expect(
nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }, { nixpacksImage: 'registry.local/nixpacks:1.2.3' }).image,
).toBe('registry.local/nixpacks:1.2.3');
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 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"');
});
});
it('bakes build-time mirror env into the build via --env flags', () => {
const script = nixpacksPrepareInitContainer(
{ runtime: AppRuntime.NODEJS },
{ nixpacksBuildEnv: ['NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'] },
).command[2] as string;
expect(script).toContain(`--env 'NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'`);
});
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;
});
it('maps the selected Node version to NIXPACKS_NODE_VERSION', () => {
const c = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS, runtimeVersion: '20' });
expect(c.env).toContainEqual({ name: 'NIXPACKS_NODE_VERSION', value: '20' });
});
expect((service as any).egressProxyEnvFrom()).toEqual([
{ secretRef: { name: 'registry-egress-proxy' } },
]);
});
it('shellQuote escapes embedded single quotes safely', () => {
expect(shellQuote("a'b")).toBe("'a'\\''b'");
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' });
});
});
});
File diff suppressed because it is too large Load Diff
View File
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.
+141
View File
@@ -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