From 5ed2ef0958a336deb22c0aa0e00ee84df8856483 Mon Sep 17 00:00:00 2001 From: keyhan Date: Thu, 2 Jul 2026 01:27:20 +0330 Subject: [PATCH] Add GitOps stack for abrban.com with Gitea Actions CI/CD. Harbor in-cluster builds via Kaniko, ArgoCD auto-sync, and production Helm values for abrban.com domains. Co-authored-by: Cursor --- .gitea/workflows/build-deploy.yaml | 61 +++ RUNBOOK-CEPH.fa.md | 19 +- .../templates/backend-deployment.yaml | 9 + .../templates/frontend-deployment.yaml | 4 + .../cloudhost-platform/templates/ingress.yaml | 2 +- .../values-production.example.yaml | 4 + backend/helm/cloudhost-platform/values.yaml | 7 + backend/k8s/mail/maddy.yaml | 248 ++++++++++ backend/package-lock.json | 453 ++++++++++++++++-- backend/package.json | 3 +- backend/src/app.module.ts | 2 + .../src/applications/applications.service.ts | 42 +- backend/src/build/build.service.spec.ts | 9 + backend/src/build/build.service.ts | 29 +- backend/src/config/configuration.ts | 8 + backend/src/snapshots/snapshots.service.ts | 33 +- .../storage/source-storage.service.spec.ts | 133 +++++ backend/src/storage/source-storage.service.ts | 162 +++++++ backend/src/storage/storage.module.ts | 9 + frontend/Dockerfile | 4 +- frontend/package-lock.json | 46 +- frontend/package.json | 2 +- .../app/[lang]/dashboard/apps/[id]/page.tsx | 4 +- gitops/README.md | 76 +++ gitops/argocd/application-platform.yaml | 25 + gitops/argocd/values-bootstrap.yaml | 32 ++ gitops/argocd/values.yaml | 33 ++ gitops/gitea/act-runner.yaml | 57 +++ gitops/gitea/values.yaml | 55 +++ gitops/jobs/build-platform-kaniko.yaml | 60 +++ gitops/jobs/image-import-pod.yaml | 37 ++ gitops/jobs/seed-ci-images.yaml | 33 ++ gitops/k3s/registries.yaml | 15 + gitops/platform/values-abrban.yaml | 56 +++ gitops/traefik/long-timeout-middleware.yaml | 12 + scripts/apply-k3s-registries.sh | 36 ++ scripts/gitops-deploy.sh | 36 ++ scripts/mirror-gitops-images.sh | 6 + scripts/trigger-platform-build.sh | 78 +++ 39 files changed, 1841 insertions(+), 99 deletions(-) create mode 100644 .gitea/workflows/build-deploy.yaml create mode 100644 backend/k8s/mail/maddy.yaml create mode 100644 backend/src/storage/source-storage.service.spec.ts create mode 100644 backend/src/storage/source-storage.service.ts create mode 100644 backend/src/storage/storage.module.ts create mode 100644 gitops/README.md create mode 100644 gitops/argocd/application-platform.yaml create mode 100644 gitops/argocd/values-bootstrap.yaml create mode 100644 gitops/argocd/values.yaml create mode 100644 gitops/gitea/act-runner.yaml create mode 100644 gitops/gitea/values.yaml create mode 100644 gitops/jobs/build-platform-kaniko.yaml create mode 100644 gitops/jobs/image-import-pod.yaml create mode 100644 gitops/jobs/seed-ci-images.yaml create mode 100644 gitops/k3s/registries.yaml create mode 100644 gitops/platform/values-abrban.yaml create mode 100644 gitops/traefik/long-timeout-middleware.yaml create mode 100755 scripts/apply-k3s-registries.sh create mode 100755 scripts/gitops-deploy.sh create mode 100755 scripts/mirror-gitops-images.sh create mode 100755 scripts/trigger-platform-build.sh diff --git a/.gitea/workflows/build-deploy.yaml b/.gitea/workflows/build-deploy.yaml new file mode 100644 index 0000000..2182866 --- /dev/null +++ b/.gitea/workflows/build-deploy.yaml @@ -0,0 +1,61 @@ +name: Build and Deploy Platform +on: + push: + branches: [main, master] + workflow_dispatch: + +env: + # Push via internal Harbor registry (no creds needed from runner pod) + REGISTRY_INTERNAL: harbor-registry.cloudhost.svc.cluster.local:5000 + REGISTRY: registry.abrban.com + BACKEND_IMAGE: abrban/cloudhost-backend + FRONTEND_IMAGE: abrban/cloudhost-frontend + +jobs: + build-push-deploy: + runs-on: abrban-kaniko + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set image tag + shell: bash + run: | + SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}" + echo "IMAGE_TAG=$(date +%Y%m%d-%H%M)-${SHA:0:8}" >> "$GITHUB_ENV" + + - name: Build backend (Kaniko) + run: | + /kaniko/executor \ + --context=./backend \ + --dockerfile=./backend/Dockerfile \ + --destination="${REGISTRY_INTERNAL}/${BACKEND_IMAGE}:${IMAGE_TAG}" \ + --insecure \ + --skip-tls-verify + + - name: Build frontend (Kaniko) + run: | + /kaniko/executor \ + --context=./frontend \ + --dockerfile=./frontend/Dockerfile \ + --build-arg=NEXT_PUBLIC_API_URL=https://api.abrban.com \ + --destination="${REGISTRY_INTERNAL}/${FRONTEND_IMAGE}:${IMAGE_TAG}" \ + --insecure \ + --skip-tls-verify + + - name: Update GitOps values + shell: bash + run: | + sed -i "s|tag: \"[^\"]*\"|tag: \"${IMAGE_TAG}\"|g" gitops/platform/values-abrban.yaml + git config user.email "ci@abrban.com" + git config user.name "Gitea Actions" + git add gitops/platform/values-abrban.yaml + git diff --cached --quiet || git commit -m "ci: deploy platform ${IMAGE_TAG}" + + - name: Push GitOps update + shell: bash + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git remote set-url origin "https://oauth2:${GITEA_TOKEN}@git.abrban.com/abrban/cloud-host.git" + git push origin HEAD:main diff --git a/RUNBOOK-CEPH.fa.md b/RUNBOOK-CEPH.fa.md index 37b4cd0..fe58f33 100644 --- a/RUNBOOK-CEPH.fa.md +++ b/RUNBOOK-CEPH.fa.md @@ -131,6 +131,22 @@ kubectl -n cloudhost-builds get cm app-sources -o yaml # BUCKET_NAME ### ۷. یکپارچه‌سازی 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 \ @@ -324,6 +340,7 @@ helm upgrade cloudhost-ceph . -n cloudhost-builds -f values.yaml - [ ] `rook-ceph-block` و `rook-ceph-bucket` در `kubectl get sc` - [ ] `ceph-app-sources-credentials` در `cloudhost-builds` و `cloudhost` - [ ] env بک‌اند: `PLATFORM_STORAGE_CLASS=rook-ceph-block` -- [ ] `SOURCE_STORAGE_*` در backend از secret خوانده می‌شود +- [x] `SOURCE_STORAGE_*` در backend از secret خوانده می‌شود (`backend.sourceStorage.enabled=true` در Helm) +- [ ] اپ تست: آپلود zip و deploy با bucket فعال - [ ] اپ تست با PVC جدید deploy شده - [ ] ایمیج‌های Rook در Harbor موجود و pull تست شده diff --git a/backend/helm/cloudhost-platform/templates/backend-deployment.yaml b/backend/helm/cloudhost-platform/templates/backend-deployment.yaml index 8acc54b..c0b2bd0 100644 --- a/backend/helm/cloudhost-platform/templates/backend-deployment.yaml +++ b/backend/helm/cloudhost-platform/templates/backend-deployment.yaml @@ -19,6 +19,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 @@ -88,6 +92,11 @@ 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 diff --git a/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml b/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml index a5a90b7..dc8a1f4 100644 --- a/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml +++ b/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml @@ -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 }} diff --git a/backend/helm/cloudhost-platform/templates/ingress.yaml b/backend/helm/cloudhost-platform/templates/ingress.yaml index 223271d..384840a 100644 --- a/backend/helm/cloudhost-platform/templates/ingress.yaml +++ b/backend/helm/cloudhost-platform/templates/ingress.yaml @@ -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 }} diff --git a/backend/helm/cloudhost-platform/values-production.example.yaml b/backend/helm/cloudhost-platform/values-production.example.yaml index ff96e1e..0abe039 100644 --- a/backend/helm/cloudhost-platform/values-production.example.yaml +++ b/backend/helm/cloudhost-platform/values-production.example.yaml @@ -36,6 +36,10 @@ 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 diff --git a/backend/helm/cloudhost-platform/values.yaml b/backend/helm/cloudhost-platform/values.yaml index 6635e7e..9c98cb1 100644 --- a/backend/helm/cloudhost-platform/values.yaml +++ b/backend/helm/cloudhost-platform/values.yaml @@ -42,8 +42,13 @@ redis: backend: enabled: true replicas: 1 + imagePullSecrets: + - name: registry-pull-secret uploads: size: 20Gi + sourceStorage: + enabled: false + existingSecret: ceph-app-sources-credentials resources: {} extraEnv: {} env: @@ -66,6 +71,8 @@ backend: frontend: enabled: true replicas: 1 + imagePullSecrets: + - name: registry-pull-secret resources: {} # JWT secrets — set in production (values-production.example.yaml) diff --git a/backend/k8s/mail/maddy.yaml b/backend/k8s/mail/maddy.yaml new file mode 100644 index 0000000..771f5ca --- /dev/null +++ b/backend/k8s/mail/maddy.yaml @@ -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 } diff --git a/backend/package-lock.json b/backend/package-lock.json index baf34f8..cc59d14 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,13 +1,14 @@ { - "name": "cloudhost-backend", + "name": "abrban-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cloudhost-backend", + "name": "abrban-backend", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-s3": "^3.1077.0", "@kubernetes/client-node": "^1.4.0", "@nestjs/bull": "^11.0.4", "@nestjs/common": "^11.1.24", @@ -180,6 +181,314 @@ "tslib": "^2.1.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.10.tgz", + "integrity": "sha512-OUNjNcyA8Ai2OdlRUxW5jHUf6XJmqqZk3UddL+mDiUCtXrVqdmIHHkdDFWBlBRjhore/3ZBMgRXgcS0ggtvD0Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1077.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1077.0.tgz", + "integrity": "sha512-yWK6jOMrMUgGarXlrQaAopWXva20NaOqTPApuE68SzsBFQ57fQ5E13BKUPLwtPgymk+8L6vG/O4h7Q30IBYr2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.10", + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/credential-provider-node": "^3.972.60", + "@aws-sdk/middleware-sdk-s3": "^3.972.56", + "@aws-sdk/signature-v4-multi-region": "^3.996.37", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/fetch-http-handler": "^5.6.1", + "@smithy/node-http-handler": "^4.9.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.25.tgz", + "integrity": "sha512-fJFkx6u6wCqGMV/v6EAxiwa2UzEukbvr1hNPv4MrD3yj4IFz011jZg42/eSTOP/u5kJ0tlILqEjCWtT8GiKZvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.14", + "@aws-sdk/xml-builder": "^3.972.32", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.28.0", + "@smithy/signature-v4": "^5.6.0", + "@smithy/types": "^4.15.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.51.tgz", + "integrity": "sha512-Xo+/zf5k5pZdo53X8aVXN4MJGfU/M1P7yMM/GbNY/x9fyRZGEzjhKqW38GA0FSQQ9TYKs+bfPyz5ja4bi6pjTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.53.tgz", + "integrity": "sha512-7E9oFUcf9YWe+ttGiWhe/cCSI+pswwelzgQMoKXgPJi1AIfS27TK6et5ZULqEqHu30zbN+jh1RqlwcXqY/aXyg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/fetch-http-handler": "^5.6.1", + "@smithy/node-http-handler": "^4.9.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.58.tgz", + "integrity": "sha512-MPr0hD8pyDGfF3dWXvFOILhcKTB9ptqJOJK9JEuDQzpc2HgKisY16eR7IrKUXxSbz8LZj+LHz/CS8Y5G1ai7yw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/credential-provider-env": "^3.972.51", + "@aws-sdk/credential-provider-http": "^3.972.53", + "@aws-sdk/credential-provider-login": "^3.972.57", + "@aws-sdk/credential-provider-process": "^3.972.51", + "@aws-sdk/credential-provider-sso": "^3.972.57", + "@aws-sdk/credential-provider-web-identity": "^3.972.57", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/credential-provider-imds": "^4.4.4", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.57.tgz", + "integrity": "sha512-kPWc/SCrl9agKeywxKwPEoQHanWag0LcNQrcZpEQpjNifkxq6tQENhgrrS9al317CF6yytyihlX+FhPHlk0QjA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.60.tgz", + "integrity": "sha512-hE2hIBJQjCDRx8TbSqpVQ+/o2mIrJZQZbQ3LlwE2bJf7z47x5GmhcvGwZPqJH7Oq//SzTXEBGSZ4qSpK3yPbhw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.51", + "@aws-sdk/credential-provider-http": "^3.972.53", + "@aws-sdk/credential-provider-ini": "^3.972.58", + "@aws-sdk/credential-provider-process": "^3.972.51", + "@aws-sdk/credential-provider-sso": "^3.972.57", + "@aws-sdk/credential-provider-web-identity": "^3.972.57", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/credential-provider-imds": "^4.4.4", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.51.tgz", + "integrity": "sha512-081dD2RlnmY+G05v6E73KfACvDjPjnttrLjGHE2SSglbID25UcuijbWpL4g+XR5T2Kl4oIJoVBXi64s+2f009Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.57.tgz", + "integrity": "sha512-dC7ZyX3EHKHLOeVUEDzzGvk0L1s6N06YDrau7P0rGXL/j1cO+DzN2w1x9vcEh7zljVCR3019f5mi1Th+GGTURw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/token-providers": "3.1077.0", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.57.tgz", + "integrity": "sha512-HtWM3FV2o7NJFJSUqFLBlxmV9RxQRHpzCvQaP1n1Qo4CxQSvwpJ8ERWHiLqXMFDgDXyELt+EZNFcpG6XQRcJbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.56.tgz", + "integrity": "sha512-VFLd7bT8ef48H2n2iDrY/w9EPpXLmC0v4NEriXy2vuTgEP/FrKqrcwi7eobhcOrdO37uLbMt5AWZlY55R2/xqg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/signature-v4-multi-region": "^3.996.37", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.25.tgz", + "integrity": "sha512-VpRQ3wR6l+fwRHV5veJL2ehtyQFrGyH/2CJG9DVtb8H3xyqqnZWSTSrq/CJJ7DvDlDgrPRiW2SkYA8pN6VWCFQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/signature-v4-multi-region": "^3.996.37", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/fetch-http-handler": "^5.6.1", + "@smithy/node-http-handler": "^4.9.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.37.tgz", + "integrity": "sha512-u8qd064XsHzM0Mk+yH4IPKn/ZC9rdniEKs+neBHNlsPZirw3rcLvmrH4ImoKC4yF7A0I/MbcC3dseARnJLiAhg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.14", + "@smithy/signature-v4": "^5.6.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1077.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1077.0.tgz", + "integrity": "sha512-sRUkfZ3fpOco95jZHsQUQiXvuIVLvCmWVclFg6dRFDyfsYs6Pdr/NuZ2+yJxeHN+6WAfDh2aZ8nlZntnvuhZUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.25", + "@aws-sdk/nested-clients": "^3.997.25", + "@aws-sdk/types": "^3.973.14", + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.14.tgz", + "integrity": "sha512-vH4pEu9YBEwr67yT+GVcmKX0GzfIrIYUn+MF5vXg9OspouVnAekuyVyawFvZHEK7WlcwVDwNrqI3ZBDUAiyu9A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.32.tgz", + "integrity": "sha512-2loKuOMRFDg1nwdni5AtJ9S5juVbRNPNsPC7tWTfkHyycPwACMhxepspUHi8GhvfNlL2cQo3sPMod1uib+KZ0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -211,7 +520,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -742,6 +1050,31 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -749,6 +1082,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2278,7 +2612,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.26.tgz", "integrity": "sha512-0VARQyzuGbprvjO+slWq9Jtj1P0jYCSKAUSv9LWFNWD39ZbDzXXM1pMs35kReVXwchra0urMfTQxw4uAOfdSzA==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -2325,7 +2658,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz", "integrity": "sha512-K45zUwYpowEsVqm8qNIzsMcl4LJev0MK9zVhDnmym7YRTJ2/caslqVeKYhPRd5+Fh81IkvWUVu6vEo46uZ5mgQ==", "license": "MIT", - "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -2408,7 +2740,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.26.tgz", "integrity": "sha512-MJ5Kwe52Ag4nlIuLK2ekB6TVYu1a22uvDzc0Aq0wIzcLySIz4YK0fMcrDOKGdbGQWpfZtNu1PM3jhlf4hvf6Og==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2660,6 +2991,87 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.28.0.tgz", + "integrity": "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.4.tgz", + "integrity": "sha512-jT0WrDaM88L5na9FX1xRNywCS3B1n75wPY5Ksasjo0PHUtuI7d8FclksN1BbOSYTiaiKxUDqU23nUymH/V+AaQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.1.tgz", + "integrity": "sha512-fW6l9rWoyk1iyzfuZaERnZLNjB6WIojgGm6Bo9Hpfpy3RUpltjLikNlxTsS/YtxVobcfbCGBuAncREYqT4hvqQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.1.tgz", + "integrity": "sha512-m/f15di58P6NtLQ7eVEb5N19NdJWn+4c7zfkFHMT/i3JH7U8UtknpPoy8o2tm2R3OdliYvsvQhZHIfACQDqT+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.0.tgz", + "integrity": "sha512-IkPHQdbyoebSwBCuMTzJ/2oIhKVqiZZAZxQYSlpDZqq/WhJUpmdgbHvP7ItddxsPzcDUJeI0V4PNMSNtlZ0aqA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.28.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -2955,7 +3367,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3143,7 +3554,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -3915,7 +4325,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3974,7 +4383,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4498,6 +4906,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -4529,7 +4943,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4609,7 +5022,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -4762,7 +5174,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4810,15 +5221,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5504,7 +5913,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6935,7 +7343,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -7649,7 +8056,6 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 10.16.0" } @@ -8595,7 +9001,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -8717,7 +9122,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -8965,7 +9369,6 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9321,7 +9724,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10327,7 +10729,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10473,7 +10874,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-1.0.0.tgz", "integrity": "sha512-2mSKNqucP8vo+xQLP59xlHUcqLvG6qajxA7q7tnhJgeZjTrA6lK/Ar7LRyiAxdXhyXmGbIPsArPmcUB9Xg+M7w==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -10687,7 +11087,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10950,7 +11349,6 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -11186,7 +11584,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/backend/package.json b/backend/package.json index 31846f1..cfe3bf9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { - "name": "cloudhost-backend", + "name": "abrban-backend", "version": "1.0.0", "description": "CloudHost PaaS Backend API", "private": true, @@ -24,6 +24,7 @@ "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", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7409a0f..9889698 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { LifecycleModule } from './lifecycle/lifecycle.module'; import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module'; import { AdminModule } from './admin/admin.module'; import { HealthModule } from './health/health.module'; +import { StorageModule } from './storage/storage.module'; import configuration from './config/configuration'; @Module({ @@ -66,6 +67,7 @@ import configuration from './config/configuration'; ]), // Feature modules + StorageModule, AuthModule, UsersModule, ApplicationsModule, diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index 5cd2381..da435ea 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -21,6 +21,8 @@ import { assertRuntimeMatch, detectRuntimeFromArchive, } from '../build/runtime-detector'; +import { SourceStorageService } from '../storage/source-storage.service'; +import * as os from 'os'; @Injectable() export class ApplicationsService { @@ -31,6 +33,7 @@ export class ApplicationsService { private appsRepository: Repository, private clustersService: ClustersService, private configService: ConfigService, + private sourceStorage: SourceStorageService, ) {} private toDnsLabel(value: string): string { @@ -208,17 +211,12 @@ export class ApplicationsService { async delete(id: string, userId: string): Promise { const app = await this.findOne(id, userId); - // Delete uploaded files + // Delete uploaded source files if (app.codePath) { try { - const uploadDir = this.configService.get('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}`); - } + await this.sourceStorage.deleteSource(app.userId, app.id, app.codePath); } catch (e: any) { - this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`); + this.logger.warn(`Failed to delete source for ${app.name}: ${e.message}`); } } @@ -265,21 +263,15 @@ export class ApplicationsService { } const app = await this.findOne(id, userId); - const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; - const appDir = path.join(uploadDir, app.userId, app.id); - - // Ensure directory exists - fs.mkdirSync(appDir, { recursive: true }); - - // Save the zip file - const zipPath = path.join(appDir, 'source.zip'); - fs.writeFileSync(zipPath, file.buffer); + const tempPath = path.join(os.tmpdir(), `upload-${app.id}-${Date.now()}.zip`); + fs.writeFileSync(tempPath, file.buffer); try { - const detected = await detectRuntimeFromArchive(zipPath); + const detected = await detectRuntimeFromArchive(tempPath); assertRuntimeMatch(app.runtime, detected); - app.codePath = zipPath; + 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') { @@ -289,13 +281,19 @@ export class ApplicationsService { }); } - this.logger.log(`Uploaded code for ${app.name} → ${zipPath} (${(file.size / 1024).toFixed(1)} KB)`); + this.logger.log(`Uploaded code for ${app.name} → ${storedPath} (${(file.size / 1024).toFixed(1)} KB)`); return saved; } catch (err) { - if (fs.existsSync(zipPath)) { - fs.unlinkSync(zipPath); + try { + await this.sourceStorage.deleteSource(app.userId, app.id); + } catch { + // ignore rollback errors } throw err; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } } } diff --git a/backend/src/build/build.service.spec.ts b/backend/src/build/build.service.spec.ts index 8701a66..08cff71 100644 --- a/backend/src/build/build.service.spec.ts +++ b/backend/src/build/build.service.spec.ts @@ -7,6 +7,7 @@ import { AppRuntime } from '../common/enums'; import { ClustersService } from '../clusters/clusters.service'; import { RegistryService } from '../kubernetes/registry.service'; import { BuildProgressStore } from './build-progress.store'; +import { SourceStorageService } from '../storage/source-storage.service'; describe('BuildService', () => { let service: BuildService; @@ -34,6 +35,14 @@ describe('BuildService', () => { useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() }, }, { provide: RegistryService, useValue: {} }, + { + provide: SourceStorageService, + useValue: { + isObjectStorage: () => false, + materializeToTempFile: jest.fn(), + getSize: jest.fn(), + }, + }, ], }).compile(); diff --git a/backend/src/build/build.service.ts b/backend/src/build/build.service.ts index f99e3bc..d2fbe17 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -11,6 +11,7 @@ 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'; import { detectDjangoSettingsModule, detectGoBuildTarget, @@ -64,6 +65,7 @@ export class BuildService { private clustersService: ClustersService, private registryService: RegistryService, private progressStore: BuildProgressStore, + private sourceStorage: SourceStorageService, ) {} private beginBuildSession(deploymentId: string): void { @@ -321,13 +323,23 @@ export class BuildService { this.beginBuildSession(deploymentId); } - const codePath = app.codePath ? path.resolve(app.codePath) : null; - const hasUploadedCode = codePath && fs.existsSync(codePath); + const hasUploadedCode = !!app.codePath; + let localZipPath: string | null = null; + let cleanupSource: (() => void) | null = null; + if (hasUploadedCode) { - await validateRuntimeFromArchive(app.runtime, codePath); + try { + const materialized = await this.sourceStorage.materializeToTempFile(app.codePath!); + localZipPath = materialized.path; + cleanupSource = materialized.cleanup; + await validateRuntimeFromArchive(app.runtime, localZipPath); + } catch (e) { + cleanupSource?.(); + throw e; + } } - const archiveEntries = hasUploadedCode ? await listArchiveEntries(codePath!) : []; + const archiveEntries = hasUploadedCode && localZipPath ? await listArchiveEntries(localZipPath) : []; // Determine Dockerfile based on runtime const dockerfileContent = this.generateDockerfile(app, archiveEntries); @@ -377,16 +389,18 @@ export class BuildService { // If we have uploaded code, create a PVC and upload via kubectl cp let sourcePvcName: string | undefined; - if (hasUploadedCode) { + if (hasUploadedCode && localZipPath) { sourcePvcName = `${buildPodName}-source`; if (deploymentId) { this.updateBuildSession(deploymentId, { sourcePvcName }); } - const zipSize = fs.statSync(codePath!).size; + const zipSize = await this.sourceStorage.getSize(app.codePath!); // Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024))); - await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId); + await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, localZipPath, pvcSizeGi, deploymentId); + cleanupSource?.(); + cleanupSource = null; } // Build the Kaniko Job spec @@ -653,6 +667,7 @@ export class BuildService { this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`); } this.endBuildSession(deploymentId); + cleanupSource?.(); } } diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 06e4c51..ad81759 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -159,6 +159,14 @@ export default () => ({ }, }, + sourceStorage: { + endpoint: process.env.SOURCE_STORAGE_ENDPOINT, + region: process.env.SOURCE_STORAGE_REGION || 'us-east-1', + bucket: process.env.SOURCE_STORAGE_BUCKET, + accessKey: process.env.SOURCE_STORAGE_ACCESS_KEY, + secretKey: process.env.SOURCE_STORAGE_SECRET_KEY, + }, + platform: { domain: resolvePlatformDomainFromEnv(), previewRootDomain: resolvePreviewRootDomainFromEnv(), diff --git a/backend/src/snapshots/snapshots.service.ts b/backend/src/snapshots/snapshots.service.ts index 2b1ccd6..8f2125e 100644 --- a/backend/src/snapshots/snapshots.service.ts +++ b/backend/src/snapshots/snapshots.service.ts @@ -15,6 +15,7 @@ import * as path from 'path'; import { AppSnapshot, SnapshotType, SnapshotStatus } from './entities/snapshot.entity'; import { ApplicationsService } from '../applications/applications.service'; import { KubernetesService } from '../kubernetes/kubernetes.service'; +import { SourceStorageService } from '../storage/source-storage.service'; import { AppRuntime, DatabaseType, ProductType } from '../common/enums'; const MAX_SNAPSHOTS = 10; @@ -30,6 +31,7 @@ export class SnapshotsService implements OnModuleInit { private applicationsService: ApplicationsService, private kubernetesService: KubernetesService, private configService: ConfigService, + private sourceStorage: SourceStorageService, ) {} async onModuleInit(): Promise { @@ -122,13 +124,18 @@ export class SnapshotsService implements OnModuleInit { if (!managedDbOnly) { // 1. Copy current source code zip - if (app.codePath && fs.existsSync(app.codePath)) { + if (app.codePath && (await this.sourceStorage.exists(app.codePath))) { await this.setSnapshotProgress(snapshotId, 12); - const destPath = path.join(snapshotDir, 'source.zip'); - fs.copyFileSync(app.codePath, destPath); - updates.appArchivePath = destPath; - updates.appArchiveSize = fs.statSync(destPath).size; - this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`); + const { path: tempPath, cleanup } = await this.sourceStorage.materializeToTempFile(app.codePath); + try { + const destPath = path.join(snapshotDir, 'source.zip'); + fs.copyFileSync(tempPath, destPath); + updates.appArchivePath = destPath; + updates.appArchiveSize = fs.statSync(destPath).size; + this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`); + } finally { + cleanup(); + } } // 2. Archive wp-content for WordPress apps @@ -315,8 +322,9 @@ export class SnapshotsService implements OnModuleInit { async downloadCurrentSource(applicationId: string, userId: string): Promise<{ filePath: string; fileName: string } | null> { const app = await this.applicationsService.findOne(applicationId, userId); - if (app.codePath && fs.existsSync(app.codePath)) { - return { filePath: app.codePath, fileName: `${app.name}-current-source.zip` }; + if (app.codePath && (await this.sourceStorage.exists(app.codePath))) { + const { path } = await this.sourceStorage.materializeToTempFile(app.codePath); + return { filePath: path, fileName: `${app.name}-current-source.zip` }; } return null; } @@ -382,12 +390,9 @@ export class SnapshotsService implements OnModuleInit { } else { // Revision not found — fall back to restoring source code and redeploying if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) { - const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; - const appDir = path.join(uploadDir, app.userId, app.id); - const destPath = path.join(appDir, 'source.zip'); - fs.mkdirSync(appDir, { recursive: true }); - fs.copyFileSync(snapshot.appArchivePath, destPath); - await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any); + const buffer = fs.readFileSync(snapshot.appArchivePath); + const storedPath = await this.sourceStorage.putSource(app.userId, app.id, buffer); + await this.applicationsService.update(app.id, app.userId, { codePath: storedPath } as any); details.push('✅ Source code restored (K8s revision expired — will need redeploy)'); this.logger.log(`Rollback ${snapshotId}: source code restored (revision not found)`); } else { diff --git a/backend/src/storage/source-storage.service.spec.ts b/backend/src/storage/source-storage.service.spec.ts new file mode 100644 index 0000000..f6b6079 --- /dev/null +++ b/backend/src/storage/source-storage.service.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { SourceStorageService } from './source-storage.service'; + +jest.mock('@aws-sdk/client-s3', () => { + const send = jest.fn(); + return { + S3Client: jest.fn().mockImplementation(() => ({ send })), + PutObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + GetObjectCommand: jest.fn(), + HeadObjectCommand: jest.fn(), + DeleteObjectCommand: jest.fn(), + __mockSend: send, + }; +}); + +const s3Module = jest.requireMock('@aws-sdk/client-s3'); +const mockSend = s3Module.__mockSend as jest.Mock; + +describe('SourceStorageService', () => { + let service: SourceStorageService; + let uploadDir: string; + + const createModule = (config: Record) => { + return Test.createTestingModule({ + providers: [ + SourceStorageService, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + const map: Record = { + 'platform.uploadDir': uploadDir, + 'sourceStorage.endpoint': config.endpoint, + 'sourceStorage.region': config.region, + 'sourceStorage.bucket': config.bucket, + 'sourceStorage.accessKey': config.accessKey, + 'sourceStorage.secretKey': config.secretKey, + }; + return map[key]; + }, + }, + }, + ], + }).compile(); + }; + + beforeEach(async () => { + uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudhost-upload-')); + mockSend.mockReset(); + jest.clearAllMocks(); + }); + + afterEach(() => { + fs.rmSync(uploadDir, { recursive: true, force: true }); + }); + + describe('local mode', () => { + beforeEach(async () => { + const module = await createModule({}); + service = module.get(SourceStorageService); + }); + + it('stores source zip on local disk', async () => { + const buffer = Buffer.from('zip-content'); + const storedPath = await service.putSource('user-1', 'app-1', buffer); + + expect(service.isObjectStorage()).toBe(false); + expect(storedPath).toBe(path.join(uploadDir, 'user-1', 'app-1', 'source.zip')); + expect(fs.readFileSync(storedPath, 'utf8')).toBe('zip-content'); + }); + + it('checks existence and size for local files', async () => { + const storedPath = await service.putSource('user-1', 'app-1', Buffer.from('abc')); + + expect(await service.exists(storedPath)).toBe(true); + expect(await service.getSize(storedPath)).toBe(3); + }); + + it('deletes local upload directory', async () => { + const storedPath = await service.putSource('user-1', 'app-1', Buffer.from('abc')); + expect(fs.existsSync(storedPath)).toBe(true); + + await service.deleteSource('user-1', 'app-1', storedPath); + expect(fs.existsSync(path.dirname(storedPath))).toBe(false); + }); + }); + + describe('object storage mode', () => { + beforeEach(async () => { + const module = await createModule({ + endpoint: 'http://rgw.local:80', + region: 'us-east-1', + bucket: 'app-sources', + accessKey: 'access', + secretKey: 'secret', + }); + service = module.get(SourceStorageService); + }); + + it('uploads source zip to S3', async () => { + mockSend.mockResolvedValue({}); + const buffer = Buffer.from('zip-content'); + + const key = await service.putSource('user-1', 'app-1', buffer); + + expect(service.isObjectStorage()).toBe(true); + expect(key).toBe('user-1/app-1/source.zip'); + expect(PutObjectCommand).toHaveBeenCalledWith({ + Bucket: 'app-sources', + Key: 'user-1/app-1/source.zip', + Body: buffer, + ContentType: 'application/zip', + }); + expect(mockSend).toHaveBeenCalled(); + expect(S3Client).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: 'http://rgw.local:80', + forcePathStyle: true, + }), + ); + }); + + it('treats object keys as non-local paths', () => { + expect(service.isLocalPath('user-1/app-1/source.zip')).toBe(false); + expect(service.isLocalPath(path.join(uploadDir, 'user-1/app-1/source.zip'))).toBe(true); + }); + }); +}); diff --git a/backend/src/storage/source-storage.service.ts b/backend/src/storage/source-storage.service.ts new file mode 100644 index 0000000..530c559 --- /dev/null +++ b/backend/src/storage/source-storage.service.ts @@ -0,0 +1,162 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + DeleteObjectCommand, + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pipeline } from 'stream/promises'; +import { Readable } from 'stream'; + +@Injectable() +export class SourceStorageService { + private readonly logger = new Logger(SourceStorageService.name); + private readonly s3Client: S3Client | null; + private readonly bucket: string | undefined; + private readonly uploadDir: string; + + constructor(private readonly configService: ConfigService) { + const endpoint = this.configService.get('sourceStorage.endpoint'); + const region = this.configService.get('sourceStorage.region') || 'us-east-1'; + const accessKey = this.configService.get('sourceStorage.accessKey'); + const secretKey = this.configService.get('sourceStorage.secretKey'); + this.bucket = this.configService.get('sourceStorage.bucket'); + this.uploadDir = this.configService.get('platform.uploadDir') || './uploads'; + + if (endpoint && this.bucket && accessKey && secretKey) { + this.s3Client = new S3Client({ + endpoint, + region, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + forcePathStyle: true, + }); + this.logger.log(`Object storage enabled (bucket=${this.bucket})`); + } else { + this.s3Client = null; + } + } + + isObjectStorage(): boolean { + return this.s3Client !== null; + } + + sourceKey(userId: string, appId: string, filename = 'source.zip'): string { + return `${userId}/${appId}/${filename}`; + } + + isLocalPath(codePath: string): boolean { + if (!codePath) return false; + if (path.isAbsolute(codePath)) return true; + if (codePath.startsWith('./') || codePath.startsWith('../')) return true; + const normalizedUploadDir = path.resolve(this.uploadDir); + return path.resolve(codePath).startsWith(normalizedUploadDir); + } + + localPath(userId: string, appId: string, filename = 'source.zip'): string { + return path.join(this.uploadDir, userId, appId, filename); + } + + async putSource(userId: string, appId: string, buffer: Buffer): Promise { + if (this.isObjectStorage()) { + const key = this.sourceKey(userId, appId); + await this.s3Client!.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: buffer, + ContentType: 'application/zip', + }), + ); + this.logger.log(`Uploaded source to S3 → s3://${this.bucket}/${key} (${(buffer.length / 1024).toFixed(1)} KB)`); + return key; + } + + const zipPath = this.localPath(userId, appId); + fs.mkdirSync(path.dirname(zipPath), { recursive: true }); + fs.writeFileSync(zipPath, buffer); + this.logger.log(`Uploaded source to local → ${zipPath} (${(buffer.length / 1024).toFixed(1)} KB)`); + return zipPath; + } + + async exists(codePath: string): Promise { + if (!codePath) return false; + if (this.isLocalPath(codePath)) { + return fs.existsSync(codePath); + } + if (!this.s3Client) return false; + try { + await this.s3Client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: codePath })); + return true; + } catch { + return false; + } + } + + async getSize(codePath: string): Promise { + if (this.isLocalPath(codePath)) { + return fs.statSync(codePath).size; + } + const head = await this.s3Client!.send(new HeadObjectCommand({ Bucket: this.bucket, Key: codePath })); + return head.ContentLength ?? 0; + } + + /** + * Returns a local filesystem path for reading the archive. + * For S3 keys, downloads to a temp file and returns { path, cleanup }. + */ + async materializeToTempFile(codePath: string): Promise<{ path: string; cleanup: () => void }> { + if (this.isLocalPath(codePath)) { + if (!fs.existsSync(codePath)) { + throw new Error(`Source file not found: ${codePath}`); + } + return { path: codePath, cleanup: () => {} }; + } + + const response = await this.s3Client!.send( + new GetObjectCommand({ Bucket: this.bucket, Key: codePath }), + ); + const tempPath = path.join( + os.tmpdir(), + `cloudhost-source-${Date.now()}-${path.basename(codePath)}`, + ); + const body = response.Body; + if (!body || typeof (body as Readable).pipe !== 'function') { + throw new Error(`Empty S3 response for key ${codePath}`); + } + await pipeline(body as Readable, fs.createWriteStream(tempPath)); + return { + path: tempPath, + cleanup: () => { + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch { + // ignore cleanup errors + } + }, + }; + } + + async deleteSource(userId: string, appId: string, codePath?: string | null): Promise { + if (this.isObjectStorage()) { + const key = codePath && !this.isLocalPath(codePath) ? codePath : this.sourceKey(userId, appId); + try { + await this.s3Client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); + this.logger.log(`Deleted S3 source → s3://${this.bucket}/${key}`); + } catch (e: any) { + this.logger.warn(`Failed to delete S3 source ${key}: ${e.message}`); + } + return; + } + + const appDir = path.join(this.uploadDir, userId, appId); + if (fs.existsSync(appDir)) { + fs.rmSync(appDir, { recursive: true, force: true }); + this.logger.log(`Deleted local upload directory: ${appDir}`); + } + } +} diff --git a/backend/src/storage/storage.module.ts b/backend/src/storage/storage.module.ts new file mode 100644 index 0000000..fd84106 --- /dev/null +++ b/backend/src/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { SourceStorageService } from './source-storage.service'; + +@Global() +@Module({ + providers: [SourceStorageService], + exports: [SourceStorageService], +}) +export class StorageModule {} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index fc80db4..43c0eb6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,7 +3,9 @@ FROM node:24-alpine AS deps WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm ci +# lockfile may reference npmmirror; use npmjs.org inside the image build +RUN sed -i 's|https://registry.npmmirror.com|https://registry.npmjs.org|g' package-lock.json \ + && npm ci # ---- Stage 2: Build ---- FROM node:24-alpine AS builder diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7ffd21d..ae4531a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "cloudhost-frontend", + "name": "abrban-frontend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cloudhost-frontend", + "name": "abrban-frontend", "version": "1.0.0", "dependencies": { "@react-three/drei": "^10.7.7", @@ -86,7 +86,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -311,6 +310,28 @@ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -1764,7 +1785,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2565,7 +2585,6 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2594,7 +2613,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2629,7 +2647,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -2697,7 +2714,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -3434,7 +3450,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3857,7 +3872,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4664,7 +4678,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4850,7 +4863,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7376,7 +7388,6 @@ "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.1.tgz", "integrity": "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng==", "license": "Zlib", - "peer": true, "peerDependencies": { "three": ">= 0.168.0 < 0.185.0" } @@ -7474,7 +7485,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7484,7 +7494,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8359,8 +8368,7 @@ "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -8449,7 +8457,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8709,7 +8716,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8997,7 +9003,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9259,7 +9264,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 61404c8..936e58a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "cloudhost-frontend", + "name": "abrban-frontend", "version": "1.0.0", "private": true, "scripts": { diff --git a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx index 88ebd0d..559367a 100644 --- a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx @@ -1062,7 +1062,7 @@ export default function AppDetailPage() { // The host the app is actually reachable on right now: the verified custom // domain when present, otherwise the platform-assigned subdomain. Single source // of truth so every place that shows "the app's domain" stays in sync. - const platformHost = `${app.subdomain}.${domainInfo?.platformDomain || 'apps.cloudhost.ir'}`; + const platformHost = `${app.subdomain}.${domainInfo?.platformDomain || 'apps.abrban.com'}`; const currentDomain = app.customDomain && app.customDomainStatus === 'verified' ? app.customDomain : platformHost; @@ -1735,7 +1735,7 @@ export default function AppDetailPage() {

{ad.nameHost}@ {ad.orText} www

{ad.typeColon}CNAME

-

{ad.valueColon}{domainInfo?.fullPlatformUrl || `${app.subdomain}.apps.cloudhost.ir`}

+

{ad.valueColon}{domainInfo?.fullPlatformUrl || `${app.subdomain}.apps.abrban.com`}

{ad.dnsStep4a}www{ad.dnsStep4b}

{ad.dnsStep5}

diff --git a/gitops/README.md b/gitops/README.md new file mode 100644 index 0000000..5b015c0 --- /dev/null +++ b/gitops/README.md @@ -0,0 +1,76 @@ +# GitOps stack for abrban.com + +## DNS (A record → cluster IP `78.157.39.52`) + +| Host | Purpose | +|------|---------| +| `abrban.com` | Landing / frontend | +| `panel.abrban.com` | Authenticated panel | +| `api.abrban.com` | Backend API | +| `registry.abrban.com` | Harbor | +| `git.abrban.com` | Gitea | +| `argocd.abrban.com` | Argo CD | + +## Harbor proxy-cache (بدون mirror دستی) + +Harbor ایمیج‌های upstream را on-demand می‌کشد و cache می‌کند: + +| پروژه Harbor | upstream | مثال | +|--------------|----------|------| +| `proxy-dockerhub` | docker.io | `registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0` | +| `proxy-quay` | quay.io | `registry.abrban.com/proxy-quay/argoproj/argocd:v3.4.4` | +| `proxy-k8s` | registry.k8s.io | CSI sidecarها | +| `proxy-gitea` | docker.gitea.com | `registry.abrban.com/proxy-gitea/gitea:1.26.1-rootless` | +| `proxy-gcr` | gcr.io | `registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2` | +| `abrban` | — | ایمیج‌های ساخته‌شده (backend/frontend) | + +اولین pull هر ایمیج کمی طول می‌کشد (Harbor از upstream می‌کشد). **نیازی به Job skopeo جداگانه نیست.** + +k3s باید `registry.abrban.com` را به Harbor داخلی route کند: + +```bash +./scripts/apply-k3s-registries.sh +``` + +## Install order + +```bash +# 1. k3s → Harbor داخلی (بدون Traefik timeout) +./scripts/apply-k3s-registries.sh + +# 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 (ایمیج‌ها از Harbor proxy-gitea) +helm upgrade --install gitea gitea-charts/gitea -n gitea --create-namespace \ + -f gitops/gitea/values.yaml --timeout 15m --wait + +# 4. TLS + registry secrets در namespaceهای argocd/gitea/cloudhost-builds +for ns in argocd gitea cloudhost-builds; do + kubectl -n cloudhost get secret abrban-wildcard-tls -o yaml | sed "s/namespace: cloudhost/namespace: ${ns}/" | kubectl apply -f - + kubectl -n cloudhost get secret registry-pull-secret -o yaml | sed "s/namespace: cloudhost/namespace: ${ns}/" | kubectl apply -f - +done + +# 5. git push سورس به Gitea (نه docker push) +# git remote add gitea https://git.abrban.com/abrban/cloud-host.git +# git push gitea main + +# 6. اولین build در کلاستر (Kaniko → abrban/) +./scripts/trigger-platform-build.sh + +# 7. Deploy +./scripts/gitops-deploy.sh + +# 8. Gitea Actions runner +kubectl apply -f gitops/gitea/act-runner.yaml + +# 9. Argo CD Application +kubectl apply -f gitops/argocd/application-platform.yaml +``` + +## CI/CD + +Gitea Actions: [.gitea/workflows/build-deploy.yaml](../.gitea/workflows/build-deploy.yaml) + +Push به `main` → Kaniko (از Harbor proxy) → push به `abrban/` → ArgoCD sync. diff --git a/gitops/argocd/application-platform.yaml b/gitops/argocd/application-platform.yaml new file mode 100644 index 0000000..7ee049d --- /dev/null +++ b/gitops/argocd/application-platform.yaml @@ -0,0 +1,25 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: abrban-platform + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://git.abrban.com/abrban/cloud-host.git + targetRevision: main + path: backend/helm/cloudhost-platform + helm: + valueFiles: + - ../../../gitops/platform/values-abrban.yaml + destination: + server: https://kubernetes.default.svc + namespace: cloudhost + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=false diff --git a/gitops/argocd/values-bootstrap.yaml b/gitops/argocd/values-bootstrap.yaml new file mode 100644 index 0000000..95d6801 --- /dev/null +++ b/gitops/argocd/values-bootstrap.yaml @@ -0,0 +1,32 @@ +# Bootstrap install — uses images already cached on node (no Harbor push required) +configs: + params: + server.insecure: true + +global: + image: + repository: quay.io/argoproj/argocd + tag: v3.4.4 + +redis: + image: + repository: ecr-public.aws.com/docker/library/redis + tag: 8.2.3-alpine + +dex: + image: + repository: ghcr.io/dexidp/dex + tag: v2.45.1 + +server: + ingress: + enabled: true + ingressClassName: traefik + hostname: argocd.abrban.com + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + tls: true + extraTls: + - secretName: abrban-wildcard-tls + hosts: + - argocd.abrban.com diff --git a/gitops/argocd/values.yaml b/gitops/argocd/values.yaml new file mode 100644 index 0000000..f78f449 --- /dev/null +++ b/gitops/argocd/values.yaml @@ -0,0 +1,33 @@ +global: + image: + repository: registry.abrban.com/proxy-quay/argoproj/argocd + tag: v3.4.4 + imagePullSecrets: + - name: registry-pull-secret + +redis: + image: + repository: registry.abrban.com/proxy-dockerhub/library/redis + tag: 8.2.3-alpine + +dex: + image: + repository: registry.abrban.com/proxy-dockerhub/dexidp/dex + tag: v2.45.1 + +configs: + params: + server.insecure: true + +server: + ingress: + enabled: true + ingressClassName: traefik + hostname: argocd.abrban.com + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + tls: true + extraTls: + - secretName: abrban-wildcard-tls + hosts: + - argocd.abrban.com diff --git a/gitops/gitea/act-runner.yaml b/gitops/gitea/act-runner.yaml new file mode 100644 index 0000000..a41c8b0 --- /dev/null +++ b/gitops/gitea/act-runner.yaml @@ -0,0 +1,57 @@ +# Gitea Actions runner — host mode + Kaniko (no docker.sock; k3s uses containerd) +apiVersion: v1 +kind: Secret +metadata: + name: gitea-act-runner-token + namespace: gitea +type: Opaque +stringData: + token: "nL63VkZEyqpCNFdF3AMM9wzQLdLlATUvXSe5Tj0R" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gitea-act-runner + namespace: gitea +spec: + replicas: 1 + selector: + matchLabels: + app: gitea-act-runner + template: + metadata: + labels: + app: gitea-act-runner + spec: + imagePullSecrets: + - name: registry-pull-secret + initContainers: + - name: kaniko-bin + image: registry.abrban.com/abrban/kaniko-executor:v1.23.2 + imagePullPolicy: IfNotPresent + command: ["sh", "-c", "cp /kaniko/executor /kaniko-bin/executor && chmod +x /kaniko-bin/executor"] + volumeMounts: + - name: kaniko-bin + mountPath: /kaniko-bin + containers: + - name: runner + image: registry.abrban.com/abrban/act-runner:0.2.11 + imagePullPolicy: IfNotPresent + env: + - name: GITEA_INSTANCE_URL + value: https://git.abrban.com + - name: GITEA_RUNNER_REGISTRATION_TOKEN + valueFrom: + secretKeyRef: + name: gitea-act-runner-token + key: token + - name: GITEA_RUNNER_NAME + value: k8s-abr-runner + - name: GITEA_RUNNER_LABELS + value: abrban-kaniko:host + volumeMounts: + - name: kaniko-bin + mountPath: /kaniko + volumes: + - name: kaniko-bin + emptyDir: {} diff --git a/gitops/gitea/values.yaml b/gitops/gitea/values.yaml new file mode 100644 index 0000000..3c884f7 --- /dev/null +++ b/gitops/gitea/values.yaml @@ -0,0 +1,55 @@ +global: + imagePullSecrets: + - registry-pull-secret + storageClass: local-path + +# Local copy in abrban/ (seeded by copy-gitea-image job) — kubelet pulls without proxy-cache +image: + registry: registry.abrban.com + repository: abrban/gitea + tag: 1.26.1 + rootless: true + +gitea: + config: + database: + DB_TYPE: sqlite3 + server: + DOMAIN: git.abrban.com + ROOT_URL: https://git.abrban.com/ + SSH_DOMAIN: git.abrban.com + actions: + ENABLED: true + +ingress: + enabled: true + className: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + hosts: + - host: git.abrban.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: abrban-wildcard-tls + hosts: + - git.abrban.com + +persistence: + enabled: true + storageClass: local-path + size: 10Gi + +# SQLite — no extra DB images to pull +postgresql: + enabled: false + +valkey-cluster: + enabled: false + +valkey: + enabled: false + +postgresql-ha: + enabled: false diff --git a/gitops/jobs/build-platform-kaniko.yaml b/gitops/jobs/build-platform-kaniko.yaml new file mode 100644 index 0000000..eb58ffd --- /dev/null +++ b/gitops/jobs/build-platform-kaniko.yaml @@ -0,0 +1,60 @@ +# In-cluster platform build (Kaniko → Harbor abrban/). Apply via scripts/trigger-platform-build.sh +# Base images pulled via Harbor proxy-cache — no separate mirror job. +apiVersion: batch/v1 +kind: Job +metadata: + name: build-platform-images + namespace: cloudhost-builds +spec: + ttlSecondsAfterFinished: 3600 + backoffLimit: 1 + template: + spec: + restartPolicy: Never + imagePullSecrets: + - name: registry-pull-secret + initContainers: + - name: git-clone + image: registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0 + env: + - name: GIT_REPO + value: http://gitea-http.gitea.svc.cluster.local:3000/abrban/cloud-host.git + - name: GIT_REF + value: main + command: + - sh + - -ec + - | + git clone --depth=1 --branch "${GIT_REF}" "${GIT_REPO}" /workspace + volumeMounts: + - name: workspace + mountPath: /workspace + containers: + - name: kaniko + image: registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2 + env: + - name: IMAGE_TAG + value: bootstrap + command: + - sh + - -ec + - | + set -eux + REG="harbor-registry.cloudhost.svc.cluster.local:5000/abrban" + /kaniko/executor \ + --dockerfile=/workspace/backend/Dockerfile \ + --context=dir:///workspace/backend \ + --destination="${REG}/cloudhost-backend:${IMAGE_TAG}" \ + --insecure --skip-tls-verify + /kaniko/executor \ + --dockerfile=/workspace/frontend/Dockerfile \ + --context=dir:///workspace/frontend \ + --build-arg=NEXT_PUBLIC_API_URL=https://api.abrban.com \ + --destination="${REG}/cloudhost-frontend:${IMAGE_TAG}" \ + --insecure --skip-tls-verify + volumeMounts: + - name: workspace + mountPath: /workspace + volumes: + - name: workspace + emptyDir: {} diff --git a/gitops/jobs/image-import-pod.yaml b/gitops/jobs/image-import-pod.yaml new file mode 100644 index 0000000..5bc8a9c --- /dev/null +++ b/gitops/jobs/image-import-pod.yaml @@ -0,0 +1,37 @@ +apiVersion: v1 +kind: Pod +metadata: + name: image-import + namespace: cloudhost +spec: + nodeName: abr + restartPolicy: Never + hostNetwork: true + containers: + - name: import + image: quay.io/skopeo/stable:latest + command: + - sh + - -ec + - | + sleep 3600 + securityContext: + privileged: true + volumeMounts: + - name: containerd-sock + mountPath: /run/containerd/containerd.sock + - name: containerd-sock-k3s + mountPath: /run/k3s/containerd/containerd.sock + - name: import-dir + mountPath: /import + volumes: + - name: containerd-sock + hostPath: + path: /run/k3s/containerd/containerd.sock + type: Socket + - name: containerd-sock-k3s + hostPath: + path: /run/k3s/containerd/containerd.sock + type: Socket + - name: import-dir + emptyDir: {} diff --git a/gitops/jobs/seed-ci-images.yaml b/gitops/jobs/seed-ci-images.yaml new file mode 100644 index 0000000..341b7a4 --- /dev/null +++ b/gitops/jobs/seed-ci-images.yaml @@ -0,0 +1,33 @@ +# Bootstrap: copy act_runner + kaniko into abrban/ (kubelet cannot use proxy-cache reliably) +apiVersion: batch/v1 +kind: Job +metadata: + name: seed-ci-images + namespace: cloudhost +spec: + ttlSecondsAfterFinished: 3600 + backoffLimit: 2 + template: + spec: + restartPolicy: Never + imagePullSecrets: + - name: registry-pull-secret + containers: + - name: skopeo + image: registry.abrban.com/proxy-quay/skopeo/stable:latest + envFrom: + - secretRef: + name: registry-egress-proxy + command: + - sh + - -ec + - | + set -eux + DEST="docker://harbor-registry.cloudhost.svc.cluster.local:5000/abrban" + skopeo copy --dest-tls-verify=false \ + docker://docker.gitea.com/gitea/act_runner:0.2.11 \ + "${DEST}/act-runner:0.2.11" + skopeo copy --dest-tls-verify=false \ + docker://gcr.io/kaniko-project/executor:v1.23.2 \ + "${DEST}/kaniko-executor:v1.23.2" + echo SEED_OK diff --git a/gitops/k3s/registries.yaml b/gitops/k3s/registries.yaml new file mode 100644 index 0000000..46ba8d8 --- /dev/null +++ b/gitops/k3s/registries.yaml @@ -0,0 +1,15 @@ +# k3s containerd registry config — apply on each node at /etc/rancher/k3s/registries.yaml +# Proxy-cache only works through harbor-core (not harbor-registry or Traefik /v2/ alone). +# +# Apply: ./scripts/apply-k3s-registries.sh + +mirrors: + registry.abrban.com: + endpoint: + - http://harbor-core.cloudhost.svc.cluster.local # use ClusterIP on single-node (see script) + +configs: + registry.abrban.com: + auth: + username: harbor_registry_user + password: REPLACE_WITH_REGISTRY_CREDENTIAL_PASSWORD diff --git a/gitops/platform/values-abrban.yaml b/gitops/platform/values-abrban.yaml new file mode 100644 index 0000000..fe06d01 --- /dev/null +++ b/gitops/platform/values-abrban.yaml @@ -0,0 +1,56 @@ +# Production values for abrban.com — used by ArgoCD / Gitea Actions GitOps +namespace: cloudhost +createNamespace: false + +global: + storageClass: local-path + +images: + backend: + repository: registry.abrban.com/abrban/cloudhost-backend + tag: "1.0.0" + pullPolicy: Always + frontend: + repository: registry.abrban.com/abrban/cloudhost-frontend + tag: "1.0.0" + pullPolicy: Always + +backend: + imagePullSecrets: + - name: registry-pull-secret + sourceStorage: + enabled: true + existingSecret: ceph-app-sources-credentials + env: + NODE_ENV: production + PORT: "4000" + PLATFORM_DOMAIN: apps.abrban.com + REGISTRY_URL: registry.abrban.com + REGISTRY_PULL_URL: registry.abrban.com + BUILD_NAMESPACE: cloudhost-builds + BUILD_SERVICE_ACCOUNT: kaniko-builder + UPLOAD_DIR: /app/uploads + PLATFORM_CREATE_STORAGE_CLASS: "false" + PLATFORM_STORAGE_CLASS: rook-ceph-block + PLATFORM_STORAGE_PROVISIONER: rook-ceph.rbd.csi.ceph.com + ELASTICSEARCH_HOST: elasticsearch.logging.svc.cluster.local + ELASTICSEARCH_AUTO_PORT_FORWARD: "false" + +frontend: + imagePullSecrets: + - name: registry-pull-secret + +ingress: + enabled: true + className: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + frontend: + host: abrban.com + panel: + host: panel.abrban.com + api: + host: api.abrban.com + tls: + enabled: true + secretName: abrban-wildcard-tls diff --git a/gitops/traefik/long-timeout-middleware.yaml b/gitops/traefik/long-timeout-middleware.yaml new file mode 100644 index 0000000..9acb270 --- /dev/null +++ b/gitops/traefik/long-timeout-middleware.yaml @@ -0,0 +1,12 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: long-timeout + namespace: cloudhost +spec: + buffering: + maxRequestBodyBytes: 0 + memRequestBodyBytes: 0 + maxResponseBodyBytes: 0 + memResponseBodyBytes: 0 + retryExpression: "IsNetworkError() && Attempts() < 3" diff --git a/scripts/apply-k3s-registries.sh b/scripts/apply-k3s-registries.sh new file mode 100755 index 0000000..4050ed8 --- /dev/null +++ b/scripts/apply-k3s-registries.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Route registry.abrban.com pulls to harbor-core (HTTP) for proxy-cache support. +set -euo pipefail + +NS="${NS:-kube-system}" +NODE="${NODE:-abr}" +HARBOR_CORE_IP="${HARBOR_CORE_IP:-$(kubectl -n cloudhost get svc harbor-core -o jsonpath='{.spec.clusterIP}')}" +REG_USER="${REG_USER:-harbor_registry_user}" +REG_PASS="${REG_PASS:-$(kubectl -n cloudhost get secret harbor-core -o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)}" + +kubectl -n "${NS}" delete pod k3s-registries-setup --ignore-not-found + +kubectl -n "${NS}" run k3s-registries-setup \ + --image=rancher/mirrored-library-busybox:1.36.1 \ + --restart=Never \ + --overrides="$(cat < /host/etc/rancher/k3s/registries.yaml <<'REGEOF'\nmirrors:\n registry.abrban.com:\n endpoint:\n - http://${HARBOR_CORE_IP}\n \\\"registry.cloudhost-builds.svc.cluster.local:5000\\\":\n endpoint:\n - \\\"http://127.0.0.1:30500\\\"\nconfigs:\n registry.abrban.com:\n auth:\n username: ${REG_USER}\n password: ${REG_PASS}\n \\\"${HARBOR_CORE_IP}\\\":\n auth:\n username: ${REG_USER}\n password: ${REG_PASS}\n \\\"registry.cloudhost-builds.svc.cluster.local:5000\\\":\n auth:\n username: admin\n password: \\\"\\\"\n \\\"127.0.0.1:30500\\\":\n auth:\n username: admin\n password: \\\"\\\"\nREGEOF\nnsenter -t 1 -m -u -n -i -- systemctl restart k3s 2>/dev/null || true\necho k3s-restarted\nsleep 30"], + "volumeMounts": [{"name": "host", "mountPath": "/host"}] + }], + "volumes": [{"name": "host", "hostPath": {"path": "/"}}] + } +} +EOF +)" + +kubectl -n "${NS}" wait --for=condition=Ready pod/k3s-registries-setup --timeout=180s || true +kubectl -n "${NS}" logs k3s-registries-setup diff --git a/scripts/gitops-deploy.sh b/scripts/gitops-deploy.sh new file mode 100755 index 0000000..fed08a9 --- /dev/null +++ b/scripts/gitops-deploy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Deploy platform via Helm only — images must already be in Harbor (built by Gitea Actions / Kaniko). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +NAMESPACE="${NAMESPACE:-cloudhost}" +RELEASE="${RELEASE:-cloudhost}" +VALUES="${VALUES:-${ROOT}/gitops/platform/values-abrban.yaml}" +TAG="${TAG:-}" + +if [[ -z "${TAG}" ]]; then + TAG="$(grep -E '^\s+tag:' "${VALUES}" | head -1 | sed 's/.*tag: *"\?\([^"]*\)"\?.*/\1/')" +fi + +if [[ -z "${TAG}" || "${TAG}" == "1.0.0" ]]; then + echo "ERROR: No image tag set. Build in-cluster first:" >&2 + echo " ./scripts/trigger-platform-build.sh" >&2 + echo "Or set TAG=... after CI has pushed images." >&2 + exit 1 +fi + +echo "==> Helm upgrade ${RELEASE} (tag=${TAG})" +helm upgrade --install "${RELEASE}" "${ROOT}/backend/helm/cloudhost-platform" \ + -n "${NAMESPACE}" \ + -f "${VALUES}" \ + --set createNamespace=false \ + --set global.storageClass=local-path \ + --set images.backend.tag="${TAG}" \ + --set images.frontend.tag="${TAG}" \ + --timeout 15m \ + --wait + +kubectl -n "${NAMESPACE}" rollout status deploy/cloudhost-backend --timeout=300s +kubectl -n "${NAMESPACE}" rollout status deploy/cloudhost-frontend --timeout=300s + +echo "==> Done (tag=${TAG})" diff --git a/scripts/mirror-gitops-images.sh b/scripts/mirror-gitops-images.sh new file mode 100755 index 0000000..60013c9 --- /dev/null +++ b/scripts/mirror-gitops-images.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# DEPRECATED — use Harbor proxy-cache instead (see gitops/README.md). +# Harbor projects: proxy-dockerhub, proxy-quay, proxy-k8s, proxy-gitea, proxy-gcr +echo "ERROR: Do not use this script. Harbor proxy-cache pulls upstream images on demand." >&2 +echo "See gitops/README.md and RUNBOOK-HARBOR.fa.md" >&2 +exit 1 diff --git a/scripts/trigger-platform-build.sh b/scripts/trigger-platform-build.sh new file mode 100755 index 0000000..fb25e57 --- /dev/null +++ b/scripts/trigger-platform-build.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Trigger in-cluster Kaniko build → Harbor. No local docker build/push. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +NAMESPACE="${BUILD_NAMESPACE:-cloudhost-builds}" +JOB_NAME="${JOB_NAME:-build-platform-images}" +GIT_REPO="${GIT_REPO:-http://gitea-http.gitea.svc.cluster.local:3000/abrban/cloud-host.git}" +GIT_REF="${GIT_REF:-main}" +IMAGE_TAG="${IMAGE_TAG:-$(date +%Y%m%d-%H%M)}" + +echo "==> Applying Kaniko build job (tag=${IMAGE_TAG}, ref=${GIT_REF})" +kubectl -n "${NAMESPACE}" delete job "${JOB_NAME}" --ignore-not-found + +kubectl apply -f - < Waiting for build job..." +kubectl -n "${NAMESPACE}" wait --for=condition=complete "job/${JOB_NAME}" --timeout=45m + +echo "==> Updating values-abrban.yaml tag to ${IMAGE_TAG}" +sed -i.bak "s|tag: \".*\"|tag: \"${IMAGE_TAG}\"|g" "${ROOT}/gitops/platform/values-abrban.yaml" +rm -f "${ROOT}/gitops/platform/values-abrban.yaml.bak" + +echo "==> Build complete. Deploy with:" +echo " TAG=${IMAGE_TAG} ./scripts/gitops-deploy.sh"