diff --git a/.gitea/workflows/build-deploy.yaml b/.gitea/workflows/build-deploy.yaml index d2441ef..08f85a1 100644 --- a/.gitea/workflows/build-deploy.yaml +++ b/.gitea/workflows/build-deploy.yaml @@ -71,6 +71,63 @@ jobs: 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 <>> 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: diff --git a/backend/helm/cloudhost-platform/templates/postgres-backup.yaml b/backend/helm/cloudhost-platform/templates/postgres-backup.yaml index be16986..e1151c9 100644 --- a/backend/helm/cloudhost-platform/templates/postgres-backup.yaml +++ b/backend/helm/cloudhost-platform/templates/postgres-backup.yaml @@ -41,6 +41,8 @@ spec: 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 diff --git a/backend/helm/cloudhost-platform/templates/postgres-deployment.yaml b/backend/helm/cloudhost-platform/templates/postgres-deployment.yaml index cf45a5e..b4baba8 100644 --- a/backend/helm/cloudhost-platform/templates/postgres-deployment.yaml +++ b/backend/helm/cloudhost-platform/templates/postgres-deployment.yaml @@ -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 }} diff --git a/backend/helm/cloudhost-platform/templates/redis-deployment.yaml b/backend/helm/cloudhost-platform/templates/redis-deployment.yaml index d8a9d17..5e13613 100644 --- a/backend/helm/cloudhost-platform/templates/redis-deployment.yaml +++ b/backend/helm/cloudhost-platform/templates/redis-deployment.yaml @@ -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: diff --git a/backend/helm/cloudhost-platform/templates/secret.yaml b/backend/helm/cloudhost-platform/templates/secret.yaml index c760c6d..5d0f8a2 100644 --- a/backend/helm/cloudhost-platform/templates/secret.yaml +++ b/backend/helm/cloudhost-platform/templates/secret.yaml @@ -22,6 +22,10 @@ Secret out-of-band (e.g. SealedSecret in the gitops repo). {{- 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: @@ -35,4 +39,5 @@ stringData: jwt-secret: {{ $jwt | quote }} jwt-refresh-secret: {{ $jwtRefresh | quote }} cluster-kubeconfig-key: {{ $kubeconfigKey | quote }} + redis-password: {{ $redisPass | quote }} {{- end }} diff --git a/backend/helm/cloudhost-platform/values-production.example.yaml b/backend/helm/cloudhost-platform/values-production.example.yaml index 0abe039..764641c 100644 --- a/backend/helm/cloudhost-platform/values-production.example.yaml +++ b/backend/helm/cloudhost-platform/values-production.example.yaml @@ -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" @@ -19,6 +24,15 @@ images: 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" @@ -44,6 +58,20 @@ backend: PLATFORM_DOMAIN: apps.example.com REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000 REGISTRY_PULL_URL: registry.cloudhost-builds.svc.cluster.local:5000 + # Mirror prefix for base images in generated Dockerfiles + managed services + BASE_IMAGE_REGISTRY: registry.example.com/mirror + # 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 diff --git a/backend/helm/cloudhost-platform/values.yaml b/backend/helm/cloudhost-platform/values.yaml index 2862bc2..7ae047a 100644 --- a/backend/helm/cloudhost-platform/values.yaml +++ b/backend/helm/cloudhost-platform/values.yaml @@ -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 @@ -32,12 +35,31 @@ 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 @@ -49,7 +71,13 @@ backend: sourceStorage: enabled: false existingSecret: ceph-app-sources-credentials - resources: {} + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi extraEnv: {} env: NODE_ENV: production @@ -73,7 +101,13 @@ frontend: replicas: 1 imagePullSecrets: - name: registry-pull-secret - resources: {} + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi # JWT secrets — set in production (values-production.example.yaml) secrets: @@ -117,6 +151,7 @@ monitoring: backups: postgres: - enabled: false + enabled: true schedule: "0 3 * * *" storageSize: 10Gi + retentionDays: 7 diff --git a/backend/k8s/logging/elasticsearch-stack.yaml b/backend/k8s/logging/elasticsearch-stack.yaml index a25c18d..cb8c3d8 100644 --- a/backend/k8s/logging/elasticsearch-stack.yaml +++ b/backend/k8s/logging/elasticsearch-stack.yaml @@ -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 diff --git a/backend/migrations/000_base_schema.sql b/backend/migrations/000_base_schema.sql new file mode 100644 index 0000000..90ce330 --- /dev/null +++ b/backend/migrations/000_base_schema.sql @@ -0,0 +1,1638 @@ +-- 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. + +-- +-- PostgreSQL database dump +-- + + +-- Dumped from database version 16.13 +-- Dumped by pg_dump version 16.13 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: public; Type: SCHEMA; Schema: -; Owner: - +-- + +-- *not* creating schema, since initdb creates it + + +-- +-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON SCHEMA public IS ''; + + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + + +-- +-- Name: addon_rates_resourcetype_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.addon_rates_resourcetype_enum AS ENUM ( + 'base_fee', + 'cpu_per_core', + 'memory_per_gb', + 'storage_per_gb', + 'database_addon', + 'redis_addon', + 'rabbitmq_addon', + 'elasticsearch_addon', + 'custom_domain_addon' +); + + +-- +-- Name: applications_billingcycle_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.applications_billingcycle_enum AS ENUM ( + 'hourly', + 'monthly', + 'yearly' +); + + +-- +-- Name: applications_customdomainstatus_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.applications_customdomainstatus_enum AS ENUM ( + 'none', + 'pending_dns', + 'verified' +); + + +-- +-- Name: applications_databasetype_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.applications_databasetype_enum AS ENUM ( + 'mysql', + 'postgresql', + 'mongodb', + 'mariadb', + 'none' +); + + +-- +-- Name: applications_lifecyclestatus_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.applications_lifecyclestatus_enum AS ENUM ( + 'active', + 'suspended', + 'pending_deletion', + 'docked', + 'deleted' +); + + +-- +-- Name: applications_runtime_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.applications_runtime_enum AS ENUM ( + 'nodejs', + 'laravel', + 'wordpress', + 'go', + 'php', + 'python', + 'django', + 'dotnet' +); + + +-- +-- Name: clusters_status_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.clusters_status_enum AS ENUM ( + 'active', + 'inactive', + 'maintenance' +); + + +-- +-- Name: deployments_status_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.deployments_status_enum AS ENUM ( + 'pending', + 'building', + 'build_failed', + 'deploying', + 'running', + 'failed', + 'cancelled', + 'stopped', + 'deleting' +); + + +-- +-- Name: invoices_paymentmethod_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.invoices_paymentmethod_enum AS ENUM ( + 'wallet', + 'gateway', + 'mixed' +); + + +-- +-- Name: invoices_reason_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.invoices_reason_enum AS ENUM ( + 'deploy', + 'renewal', + 'upgrade', + 'wallet_topup', + 'manual' +); + + +-- +-- Name: invoices_status_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.invoices_status_enum AS ENUM ( + 'draft', + 'issued', + 'partially_paid', + 'paid', + 'void', + 'failed' +); + + +-- +-- Name: optional_service_profiles_service_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.optional_service_profiles_service_enum AS ENUM ( + 'redis', + 'rabbitmq', + 'elasticsearch' +); + + +-- +-- Name: optional_service_rates_resourcetype_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.optional_service_rates_resourcetype_enum AS ENUM ( + 'base_fee', + 'cpu_per_core', + 'memory_per_gb', + 'storage_per_gb', + 'database_addon', + 'redis_addon', + 'rabbitmq_addon', + 'elasticsearch_addon', + 'custom_domain_addon' +); + + +-- +-- Name: optional_service_rates_service_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.optional_service_rates_service_enum AS ENUM ( + 'redis', + 'rabbitmq', + 'elasticsearch' +); + + +-- +-- Name: pricing_rates_resourcetype_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.pricing_rates_resourcetype_enum AS ENUM ( + 'base_fee', + 'cpu_per_core', + 'memory_per_gb', + 'storage_per_gb', + 'database_addon', + 'redis_addon', + 'rabbitmq_addon', + 'elasticsearch_addon', + 'custom_domain_addon' +); + + +-- +-- Name: pricing_rates_runtime_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.pricing_rates_runtime_enum AS ENUM ( + 'nodejs', + 'laravel', + 'wordpress', + 'go', + 'php', + 'python', + 'django', + 'dotnet' +); + + +-- +-- Name: pricing_rules_resourcetype_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.pricing_rules_resourcetype_enum AS ENUM ( + 'base_fee', + 'cpu_per_core', + 'memory_per_gb', + 'storage_per_gb', + 'database_addon', + 'redis_addon', + 'rabbitmq_addon', + 'elasticsearch_addon', + 'custom_domain_addon' +); + + +-- +-- Name: resource_credits_billingcycle_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.resource_credits_billingcycle_enum AS ENUM ( + 'hourly', + 'monthly', + 'yearly' +); + + +-- +-- Name: resource_credits_databasetype_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.resource_credits_databasetype_enum AS ENUM ( + 'mysql', + 'postgresql', + 'mongodb', + 'mariadb', + 'none' +); + + +-- +-- Name: resource_credits_runtime_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.resource_credits_runtime_enum AS ENUM ( + 'nodejs', + 'laravel', + 'wordpress', + 'go', + 'php', + 'python', + 'django', + 'dotnet' +); + + +-- +-- Name: service_access_grants_status_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.service_access_grants_status_enum AS ENUM ( + 'active', + 'expired', + 'revoked' +); + + +-- +-- Name: service_access_grants_target_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.service_access_grants_target_enum AS ENUM ( + 'database', + 'redis', + 'rabbitmq_amqp', + 'rabbitmq_management' +); + + +-- +-- Name: service_plans_billingcycle_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.service_plans_billingcycle_enum AS ENUM ( + 'hourly', + 'monthly', + 'yearly' +); + + +-- +-- Name: service_plans_runtime_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.service_plans_runtime_enum AS ENUM ( + 'nodejs', + 'laravel', + 'wordpress', + 'go', + 'php', + 'python', + 'django', + 'dotnet' +); + + +-- +-- Name: snapshots_status_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.snapshots_status_enum AS ENUM ( + 'in_progress', + 'completed', + 'failed' +); + + +-- +-- Name: snapshots_type_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.snapshots_type_enum AS ENUM ( + 'pre_deploy', + 'manual' +); + + +-- +-- Name: ticket_messages_senderrole_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.ticket_messages_senderrole_enum AS ENUM ( + 'user', + 'admin', + 'technical', + 'sales' +); + + +-- +-- Name: tickets_department_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.tickets_department_enum AS ENUM ( + 'technical', + 'sales' +); + + +-- +-- Name: tickets_priority_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.tickets_priority_enum AS ENUM ( + 'low', + 'medium', + 'high' +); + + +-- +-- Name: tickets_status_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.tickets_status_enum AS ENUM ( + 'open', + 'answered', + 'waiting', + 'closed' +); + + +-- +-- Name: users_role_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.users_role_enum AS ENUM ( + 'user', + 'admin', + 'technical', + 'sales' +); + + +-- +-- Name: verification_codes_purpose_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.verification_codes_purpose_enum AS ENUM ( + 'login', + 'change_phone' +); + + +-- +-- Name: wallet_transactions_type_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.wallet_transactions_type_enum AS ENUM ( + 'charge', + 'deduction', + 'refund', + 'gateway_payment' +); + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: addon_rates; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.addon_rates ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "resourceType" public.addon_rates_resourcetype_enum NOT NULL, + "hourlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "monthlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "yearlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "isActive" boolean DEFAULT true NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: application_migration_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.application_migration_events ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "migrationId" uuid NOT NULL, + step character varying NOT NULL, + level character varying DEFAULT 'info'::character varying NOT NULL, + message character varying NOT NULL, + metadata jsonb, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: application_migration_jobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.application_migration_jobs ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "applicationId" uuid NOT NULL, + "requestedBy" character varying NOT NULL, + "sourceClusterId" uuid NOT NULL, + "targetClusterId" uuid NOT NULL, + status character varying DEFAULT 'queued'::character varying NOT NULL, + attempts integer DEFAULT 0 NOT NULL, + "maxAttempts" integer DEFAULT 3 NOT NULL, + "currentStep" character varying, + "errorMessage" character varying, + metadata jsonb, + "startedAt" timestamp with time zone, + "completedAt" timestamp with time zone, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: applications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.applications ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + name character varying NOT NULL, + "productType" character varying DEFAULT 'application'::character varying NOT NULL, + description character varying, + runtime public.applications_runtime_enum NOT NULL, + "databaseType" public.applications_databasetype_enum DEFAULT 'none'::public.applications_databasetype_enum NOT NULL, + "runtimeVersion" character varying, + "phpVersion" character varying, + "dbVersion" character varying, + "dbUsername" character varying, + "dbPassword" character varying, + "dbStorageSize" character varying DEFAULT '1Gi'::character varying, + "appStorageSize" character varying DEFAULT '2Gi'::character varying, + "enableRedis" boolean DEFAULT false NOT NULL, + "redisVersion" character varying DEFAULT '7.2'::character varying, + "enableRabbitmq" boolean DEFAULT false NOT NULL, + "rabbitmqVersion" character varying DEFAULT '3.13'::character varying, + "enableElasticsearch" boolean DEFAULT false NOT NULL, + "elasticsearchVersion" character varying DEFAULT '8.12'::character varying, + "logPaths" jsonb, + "optionalServiceResources" jsonb, + "gitUrl" character varying, + "gitToken" character varying, + "gitBranch" character varying, + "codePath" character varying, + "dbDumpPath" character varying, + "envVars" jsonb, + "cpuRequest" character varying DEFAULT '100m'::character varying NOT NULL, + "cpuLimit" character varying DEFAULT '500m'::character varying NOT NULL, + "memoryRequest" character varying DEFAULT '128Mi'::character varying NOT NULL, + "memoryLimit" character varying DEFAULT '512Mi'::character varying NOT NULL, + replicas integer DEFAULT 1 NOT NULL, + port integer DEFAULT 3000 NOT NULL, + "userId" uuid NOT NULL, + "clusterId" uuid, + "poolId" uuid, + "latestImageTag" character varying, + subdomain character varying, + "customDomain" character varying, + "customDomainStatus" public.applications_customdomainstatus_enum DEFAULT 'none'::public.applications_customdomainstatus_enum NOT NULL, + "customDomainVerifiedAt" timestamp with time zone, + "planId" character varying, + "billingCycle" public.applications_billingcycle_enum, + "lifecycleStatus" public.applications_lifecyclestatus_enum DEFAULT 'active'::public.applications_lifecyclestatus_enum NOT NULL, + "planExpiresAt" timestamp with time zone, + "suspendedAt" timestamp with time zone, + "suspendedReplicas" jsonb, + "scheduledDeletionAt" timestamp with time zone, + "dockedAt" timestamp with time zone, + "dockSnapshotId" character varying, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: audit_logs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_logs ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + action character varying NOT NULL, + "actorUserId" uuid NOT NULL, + "targetUserId" uuid, + metadata jsonb, + ip character varying, + "userAgent" character varying, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: cluster_allocation_logs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.cluster_allocation_logs ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "applicationId" uuid, + "userId" character varying NOT NULL, + "poolId" uuid, + "selectedClusterId" uuid, + strategy character varying DEFAULT 'weighted-resource'::character varying NOT NULL, + "estimatedRequest" jsonb, + "candidateScores" jsonb, + "rejectionReasons" jsonb, + status character varying DEFAULT 'success'::character varying NOT NULL, + message character varying, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: cluster_health; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.cluster_health ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "clusterId" uuid NOT NULL, + status character varying DEFAULT 'unknown'::character varying NOT NULL, + "readyNodes" integer DEFAULT 0 NOT NULL, + "nodeCount" integer DEFAULT 0 NOT NULL, + "cpuAllocatable" character varying, + "memoryAllocatable" character varying, + "podCount" integer DEFAULT 0 NOT NULL, + "appCount" integer DEFAULT 0 NOT NULL, + message character varying, + resources jsonb, + "checkedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: cluster_pools; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.cluster_pools ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + name character varying NOT NULL, + description character varying, + strategy character varying DEFAULT 'weighted-resource'::character varying NOT NULL, + "clusterIds" jsonb DEFAULT '[]'::jsonb NOT NULL, + "isActive" boolean DEFAULT true NOT NULL, + "isDefault" boolean DEFAULT false NOT NULL, + priority integer DEFAULT 100 NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: clusters; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.clusters ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + name character varying NOT NULL, + description character varying, + status public.clusters_status_enum DEFAULT 'active'::public.clusters_status_enum NOT NULL, + kubeconfig text NOT NULL, + "apiServer" character varying NOT NULL, + region character varying, + weight integer DEFAULT 1 NOT NULL, + tags jsonb DEFAULT '[]'::jsonb NOT NULL, + "healthStatus" character varying DEFAULT 'unknown'::character varying NOT NULL, + "lastHealthCheckedAt" timestamp with time zone, + "healthMessage" character varying, + "availableResources" jsonb, + provider character varying, + "isDefault" boolean DEFAULT false NOT NULL, + "defaultCpuLimit" character varying DEFAULT '4'::character varying NOT NULL, + "defaultMemoryLimit" character varying DEFAULT '8Gi'::character varying NOT NULL, + "maxAppsPerUser" integer DEFAULT 10 NOT NULL, + metadata jsonb, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: deployments; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.deployments ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + status public.deployments_status_enum DEFAULT 'pending'::public.deployments_status_enum NOT NULL, + "imageTag" character varying NOT NULL, + version character varying, + "k8sResources" jsonb, + "buildLog" text, + "deployLog" text, + "previewSubdomain" character varying(63), + "errorMessage" character varying, + "applicationId" uuid NOT NULL, + "triggeredBy" character varying NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL, + "finishedAt" timestamp without time zone +); + + +-- +-- Name: discount_redemptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.discount_redemptions ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "discountId" uuid NOT NULL, + "userId" character varying NOT NULL, + "invoiceId" character varying, + amount numeric(14,2) DEFAULT '0'::numeric NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: discounts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.discounts ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + code character varying NOT NULL, + name character varying NOT NULL, + description character varying, + "percentOff" integer NOT NULL, + services jsonb DEFAULT '[]'::jsonb NOT NULL, + "isPublic" boolean DEFAULT true NOT NULL, + "allowedUserIds" jsonb DEFAULT '[]'::jsonb NOT NULL, + "maxUses" integer, + "maxUsesPerUser" integer, + "usedCount" integer DEFAULT 0 NOT NULL, + "startsAt" timestamp with time zone, + "endsAt" timestamp with time zone, + "isActive" boolean DEFAULT true NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: invoice_lines; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoice_lines ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "invoiceId" uuid NOT NULL, + label character varying NOT NULL, + description character varying, + quantity integer DEFAULT 1 NOT NULL, + "unitAmount" numeric(14,2) DEFAULT '0'::numeric NOT NULL, + amount numeric(14,2) DEFAULT '0'::numeric NOT NULL, + metadata jsonb, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: invoices; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoices ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "invoiceNumber" character varying NOT NULL, + "userId" uuid NOT NULL, + "applicationId" uuid, + reason public.invoices_reason_enum DEFAULT 'manual'::public.invoices_reason_enum NOT NULL, + status public.invoices_status_enum DEFAULT 'issued'::public.invoices_status_enum NOT NULL, + "paymentMethod" public.invoices_paymentmethod_enum, + subtotal numeric(14,2) DEFAULT '0'::numeric NOT NULL, + "discountAmount" numeric(14,2) DEFAULT '0'::numeric NOT NULL, + "discountCode" character varying, + total numeric(14,2) DEFAULT '0'::numeric NOT NULL, + "paidAmount" numeric(14,2) DEFAULT '0'::numeric NOT NULL, + "dueAmount" numeric(14,2) DEFAULT '0'::numeric NOT NULL, + "dueDate" timestamp with time zone, + "paidAt" timestamp with time zone, + "gatewayTrackingCode" character varying, + "gatewayReference" character varying, + "adminNote" character varying, + "statusReason" character varying, + metadata jsonb, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: optional_service_profiles; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.optional_service_profiles ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + service public.optional_service_profiles_service_enum NOT NULL, + "cpuRequest" character varying, + "memoryRequest" character varying, + "cpuLimit" character varying DEFAULT '200m'::character varying NOT NULL, + "memoryLimit" character varying DEFAULT '256Mi'::character varying NOT NULL, + "storageGi" numeric(10,2) DEFAULT '1'::numeric NOT NULL, + "logShipperCpuLimit" character varying, + "logShipperMemoryLimit" character varying, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: optional_service_rates; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.optional_service_rates ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + service public.optional_service_rates_service_enum NOT NULL, + "resourceType" public.optional_service_rates_resourcetype_enum NOT NULL, + "hourlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "monthlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "yearlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "isActive" boolean DEFAULT true NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: platform_settings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.platform_settings ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + key character varying NOT NULL, + value text NOT NULL, + description character varying, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: pricing_rates; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pricing_rates ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + runtime public.pricing_rates_runtime_enum NOT NULL, + "resourceType" public.pricing_rates_resourcetype_enum NOT NULL, + "hourlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "monthlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "yearlyPrice" numeric(12,2) DEFAULT '0'::numeric NOT NULL, + "isActive" boolean DEFAULT true NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: pricing_rules; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pricing_rules ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "resourceType" public.pricing_rules_resourcetype_enum NOT NULL, + "unitPrice" numeric(12,2) NOT NULL, + description character varying, + "planId" uuid NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: resource_credits; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.resource_credits ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "userId" uuid NOT NULL, + "sourceAppName" character varying, + "productType" character varying DEFAULT 'application'::character varying NOT NULL, + runtime public.resource_credits_runtime_enum NOT NULL, + "databaseType" public.resource_credits_databasetype_enum NOT NULL, + "cpuLimit" character varying NOT NULL, + "memoryLimit" character varying NOT NULL, + replicas integer DEFAULT 1 NOT NULL, + "dbStorageSize" character varying, + "appStorageSize" character varying, + "enableRedis" boolean DEFAULT false NOT NULL, + "enableRabbitmq" boolean DEFAULT false NOT NULL, + "enableElasticsearch" boolean DEFAULT false NOT NULL, + "billingCycle" public.resource_credits_billingcycle_enum, + "expiresAt" timestamp with time zone NOT NULL, + "consumedAt" timestamp with time zone, + "appliedApplicationId" character varying, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: service_access_grants; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.service_access_grants ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "applicationId" uuid NOT NULL, + "userId" character varying NOT NULL, + "clusterId" character varying NOT NULL, + namespace character varying NOT NULL, + target public.service_access_grants_target_enum NOT NULL, + "nodePort" integer NOT NULL, + "targetPort" integer NOT NULL, + host character varying NOT NULL, + "k8sServiceName" character varying NOT NULL, + status public.service_access_grants_status_enum DEFAULT 'active'::public.service_access_grants_status_enum NOT NULL, + persistent boolean DEFAULT false NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: service_plans; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.service_plans ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + name character varying NOT NULL, + runtime public.service_plans_runtime_enum NOT NULL, + description character varying, + "billingCycle" public.service_plans_billingcycle_enum NOT NULL, + "isActive" boolean DEFAULT true NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: snapshots; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.snapshots ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + type public.snapshots_type_enum DEFAULT 'manual'::public.snapshots_type_enum NOT NULL, + status public.snapshots_status_enum DEFAULT 'in_progress'::public.snapshots_status_enum NOT NULL, + label character varying, + "appArchivePath" character varying, + "wpContentArchivePath" character varying, + "imageTag" character varying, + "dbDumpPath" character varying, + "hasDatabase" boolean DEFAULT false NOT NULL, + "appArchiveSize" bigint, + "dbDumpSize" bigint, + "wpContentSize" bigint, + "errorMessage" character varying, + progress integer DEFAULT 0 NOT NULL, + "applicationId" uuid NOT NULL, + "createdBy" character varying NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: ticket_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.ticket_messages ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + message text NOT NULL, + "ticketId" uuid NOT NULL, + "senderId" uuid NOT NULL, + "senderRole" public.ticket_messages_senderrole_enum NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: tickets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.tickets ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + subject character varying NOT NULL, + department public.tickets_department_enum NOT NULL, + status public.tickets_status_enum DEFAULT 'open'::public.tickets_status_enum NOT NULL, + priority public.tickets_priority_enum DEFAULT 'medium'::public.tickets_priority_enum NOT NULL, + "userId" uuid NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL, + "closedAt" timestamp without time zone +); + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + phone character varying, + email character varying, + "phoneVerified" boolean DEFAULT false NOT NULL, + password character varying NOT NULL, + "firstName" character varying NOT NULL, + "lastName" character varying NOT NULL, + role public.users_role_enum DEFAULT 'user'::public.users_role_enum NOT NULL, + "isActive" boolean DEFAULT true NOT NULL, + namespace character varying, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: verification_codes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.verification_codes ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + "userId" uuid NOT NULL, + purpose public.verification_codes_purpose_enum NOT NULL, + destination character varying NOT NULL, + "codeHash" character varying NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + attempts integer DEFAULT 0 NOT NULL, + "consumedAt" timestamp with time zone, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: wallet_transactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.wallet_transactions ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + type public.wallet_transactions_type_enum NOT NULL, + amount numeric(14,2) NOT NULL, + "balanceAfter" numeric(14,2) NOT NULL, + description character varying, + "applicationId" character varying, + "invoiceId" uuid, + "walletId" uuid NOT NULL, + "gatewayTrackingCode" character varying, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: wallets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.wallets ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + balance numeric(14,2) DEFAULT '0'::numeric NOT NULL, + "userId" uuid NOT NULL, + "createdAt" timestamp without time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: application_migration_jobs PK_130c380307e6aec30012c92e932; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.application_migration_jobs + ADD CONSTRAINT "PK_130c380307e6aec30012c92e932" PRIMARY KEY (id); + + +-- +-- Name: verification_codes PK_18741b6b8bf1680dbf5057421d7; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.verification_codes + ADD CONSTRAINT "PK_18741b6b8bf1680dbf5057421d7" PRIMARY KEY (id); + + +-- +-- Name: audit_logs PK_1bb179d048bbc581caa3b013439; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_logs + ADD CONSTRAINT "PK_1bb179d048bbc581caa3b013439" PRIMARY KEY (id); + + +-- +-- Name: deployments PK_1e5627acb3c950deb83fe98fc48; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deployments + ADD CONSTRAINT "PK_1e5627acb3c950deb83fe98fc48" PRIMARY KEY (id); + + +-- +-- Name: optional_service_profiles PK_2625f60eb9be17174299de23f47; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.optional_service_profiles + ADD CONSTRAINT "PK_2625f60eb9be17174299de23f47" PRIMARY KEY (id); + + +-- +-- Name: platform_settings PK_2934aeb70ec285196dcab4a2e96; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.platform_settings + ADD CONSTRAINT "PK_2934aeb70ec285196dcab4a2e96" PRIMARY KEY (id); + + +-- +-- Name: discount_redemptions PK_2efe685154f7c7deacaf6f0ef63; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.discount_redemptions + ADD CONSTRAINT "PK_2efe685154f7c7deacaf6f0ef63" PRIMARY KEY (id); + + +-- +-- Name: tickets PK_343bc942ae261cf7a1377f48fd0; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tickets + ADD CONSTRAINT "PK_343bc942ae261cf7a1377f48fd0" PRIMARY KEY (id); + + +-- +-- Name: cluster_allocation_logs PK_34d1148bb1df92decfcdc7cab87; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cluster_allocation_logs + ADD CONSTRAINT "PK_34d1148bb1df92decfcdc7cab87" PRIMARY KEY (id); + + +-- +-- Name: ticket_messages PK_37beb692dedf7eccb4e519ccec1; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ticket_messages + ADD CONSTRAINT "PK_37beb692dedf7eccb4e519ccec1" PRIMARY KEY (id); + + +-- +-- Name: invoice_lines PK_3d18eb48142b916f581f0c21a65; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_lines + ADD CONSTRAINT "PK_3d18eb48142b916f581f0c21a65" PRIMARY KEY (id); + + +-- +-- Name: wallet_transactions PK_5120f131bde2cda940ec1a621db; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT "PK_5120f131bde2cda940ec1a621db" PRIMARY KEY (id); + + +-- +-- Name: clusters PK_56c8e201f375e1e961dcdd6831c; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.clusters + ADD CONSTRAINT "PK_56c8e201f375e1e961dcdd6831c" PRIMARY KEY (id); + + +-- +-- Name: invoices PK_668cef7c22a427fd822cc1be3ce; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT "PK_668cef7c22a427fd822cc1be3ce" PRIMARY KEY (id); + + +-- +-- Name: discounts PK_66c522004212dc814d6e2f14ecc; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.discounts + ADD CONSTRAINT "PK_66c522004212dc814d6e2f14ecc" PRIMARY KEY (id); + + +-- +-- Name: service_plans PK_679a9e435f1af95a94d9749a087; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.service_plans + ADD CONSTRAINT "PK_679a9e435f1af95a94d9749a087" PRIMARY KEY (id); + + +-- +-- Name: resource_credits PK_6a1f7bee5bd45667c08a63d9e3c; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.resource_credits + ADD CONSTRAINT "PK_6a1f7bee5bd45667c08a63d9e3c" PRIMARY KEY (id); + + +-- +-- Name: pricing_rates PK_6b3ac518f3abafc7d0e1b6bb449; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_rates + ADD CONSTRAINT "PK_6b3ac518f3abafc7d0e1b6bb449" PRIMARY KEY (id); + + +-- +-- Name: addon_rates PK_817008cdfd7d2040209665de3cf; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.addon_rates + ADD CONSTRAINT "PK_817008cdfd7d2040209665de3cf" PRIMARY KEY (id); + + +-- +-- Name: wallets PK_8402e5df5a30a229380e83e4f7e; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT "PK_8402e5df5a30a229380e83e4f7e" PRIMARY KEY (id); + + +-- +-- Name: applications PK_938c0a27255637bde919591888f; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applications + ADD CONSTRAINT "PK_938c0a27255637bde919591888f" PRIMARY KEY (id); + + +-- +-- Name: users PK_a3ffb1c0c8416b9fc6f907b7433; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY (id); + + +-- +-- Name: cluster_health PK_a619d68317e11de0cdc7769936c; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cluster_health + ADD CONSTRAINT "PK_a619d68317e11de0cdc7769936c" PRIMARY KEY (id); + + +-- +-- Name: service_access_grants PK_ba880e7036fb12629d2bb20813b; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.service_access_grants + ADD CONSTRAINT "PK_ba880e7036fb12629d2bb20813b" PRIMARY KEY (id); + + +-- +-- Name: application_migration_events PK_cbbcd35db024c6ce5282dc4b826; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.application_migration_events + ADD CONSTRAINT "PK_cbbcd35db024c6ce5282dc4b826" PRIMARY KEY (id); + + +-- +-- Name: cluster_pools PK_cd7c2f36783be8d628956f41734; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cluster_pools + ADD CONSTRAINT "PK_cd7c2f36783be8d628956f41734" PRIMARY KEY (id); + + +-- +-- Name: optional_service_rates PK_dde26d2c02f198afe455543e16c; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.optional_service_rates + ADD CONSTRAINT "PK_dde26d2c02f198afe455543e16c" PRIMARY KEY (id); + + +-- +-- Name: snapshots PK_f5661b5fd4224d23e26a631986b; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.snapshots + ADD CONSTRAINT "PK_f5661b5fd4224d23e26a631986b" PRIMARY KEY (id); + + +-- +-- Name: pricing_rules PK_fda27bb8db4630894decda61ff6; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_rules + ADD CONSTRAINT "PK_fda27bb8db4630894decda61ff6" PRIMARY KEY (id); + + +-- +-- Name: wallets UQ_2ecdb33f23e9a6fc392025c0b97; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT "UQ_2ecdb33f23e9a6fc392025c0b97" UNIQUE ("userId"); + + +-- +-- Name: platform_settings UQ_5d9031e30fac3ec3ec8b9602e17; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.platform_settings + ADD CONSTRAINT "UQ_5d9031e30fac3ec3ec8b9602e17" UNIQUE (key); + + +-- +-- Name: optional_service_profiles UQ_63c79ca1dc0b57933b800469504; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.optional_service_profiles + ADD CONSTRAINT "UQ_63c79ca1dc0b57933b800469504" UNIQUE (service); + + +-- +-- Name: pricing_rates UQ_8323897b5a47d20ef92896dc5a8; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_rates + ADD CONSTRAINT "UQ_8323897b5a47d20ef92896dc5a8" UNIQUE (runtime, "resourceType"); + + +-- +-- Name: discounts UQ_8c7cc2340e9ea0fc5a246e63749; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.discounts + ADD CONSTRAINT "UQ_8c7cc2340e9ea0fc5a246e63749" UNIQUE (code); + + +-- +-- Name: users UQ_97672ac88f789774dd47f7c8be3; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE (email); + + +-- +-- Name: users UQ_a000cca60bcf04454e727699490; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT "UQ_a000cca60bcf04454e727699490" UNIQUE (phone); + + +-- +-- Name: invoices UQ_bf8e0f9dd4558ef209ec111782d; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT "UQ_bf8e0f9dd4558ef209ec111782d" UNIQUE ("invoiceNumber"); + + +-- +-- Name: optional_service_rates UQ_da71e6eb43a191e589c4e1d32f6; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.optional_service_rates + ADD CONSTRAINT "UQ_da71e6eb43a191e589c4e1d32f6" UNIQUE (service, "resourceType"); + + +-- +-- Name: addon_rates UQ_ef90e5f423c8ff2fd3b321763e8; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.addon_rates + ADD CONSTRAINT "UQ_ef90e5f423c8ff2fd3b321763e8" UNIQUE ("resourceType"); + + +-- +-- Name: IDX_43facb440ecfb2acadcf2922b5; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "IDX_43facb440ecfb2acadcf2922b5" ON public.service_access_grants USING btree ("applicationId", target, status); + + +-- +-- Name: IDX_5b7fbc8045a0654e5f8db27dc5; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "IDX_5b7fbc8045a0654e5f8db27dc5" ON public.audit_logs USING btree ("targetUserId"); + + +-- +-- Name: IDX_b455a8c210f79a873760a92819; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "IDX_b455a8c210f79a873760a92819" ON public.verification_codes USING btree ("userId", purpose); + + +-- +-- Name: IDX_c1e88bb93860db181f5b21a306; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "IDX_c1e88bb93860db181f5b21a306" ON public.discount_redemptions USING btree ("discountId", "userId"); + + +-- +-- Name: IDX_e36d23e1e7cf81ea77758bef79; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "IDX_e36d23e1e7cf81ea77758bef79" ON public.audit_logs USING btree ("actorUserId"); + + +-- +-- Name: application_migration_jobs FK_12568b7d01ef97d05da755833f3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.application_migration_jobs + ADD CONSTRAINT "FK_12568b7d01ef97d05da755833f3" FOREIGN KEY ("applicationId") REFERENCES public.applications(id) ON DELETE CASCADE; + + +-- +-- Name: wallets FK_2ecdb33f23e9a6fc392025c0b97; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT "FK_2ecdb33f23e9a6fc392025c0b97" FOREIGN KEY ("userId") REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: cluster_allocation_logs FK_3b1bba8052f0504c2ff400f70a4; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cluster_allocation_logs + ADD CONSTRAINT "FK_3b1bba8052f0504c2ff400f70a4" FOREIGN KEY ("selectedClusterId") REFERENCES public.clusters(id) ON DELETE SET NULL; + + +-- +-- Name: cluster_allocation_logs FK_3ef9df9c9317fab60fdcb5f581b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cluster_allocation_logs + ADD CONSTRAINT "FK_3ef9df9c9317fab60fdcb5f581b" FOREIGN KEY ("applicationId") REFERENCES public.applications(id) ON DELETE SET NULL; + + +-- +-- Name: cluster_allocation_logs FK_44764afc850c4b3a8c62547c2a9; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cluster_allocation_logs + ADD CONSTRAINT "FK_44764afc850c4b3a8c62547c2a9" FOREIGN KEY ("poolId") REFERENCES public.cluster_pools(id) ON DELETE SET NULL; + + +-- +-- Name: service_access_grants FK_48b102a7593a5e76d9fda39dfb0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.service_access_grants + ADD CONSTRAINT "FK_48b102a7593a5e76d9fda39dfb0" FOREIGN KEY ("applicationId") REFERENCES public.applications(id) ON DELETE CASCADE; + + +-- +-- Name: tickets FK_4bb45e096f521845765f657f5c8; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tickets + ADD CONSTRAINT "FK_4bb45e096f521845765f657f5c8" FOREIGN KEY ("userId") REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: snapshots FK_5652b27d83628dc5612245d62ce; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.snapshots + ADD CONSTRAINT "FK_5652b27d83628dc5612245d62ce" FOREIGN KEY ("applicationId") REFERENCES public.applications(id) ON DELETE CASCADE; + + +-- +-- Name: wallet_transactions FK_581a824e911b9baa6be19cd92fc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT "FK_581a824e911b9baa6be19cd92fc" FOREIGN KEY ("invoiceId") REFERENCES public.invoices(id) ON DELETE SET NULL; + + +-- +-- Name: invoices FK_875034bdd7f0ac4a726ceffe71e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT "FK_875034bdd7f0ac4a726ceffe71e" FOREIGN KEY ("applicationId") REFERENCES public.applications(id) ON DELETE SET NULL; + + +-- +-- Name: wallet_transactions FK_8a94d9d61a2b05123710b325fbf; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT "FK_8a94d9d61a2b05123710b325fbf" FOREIGN KEY ("walletId") REFERENCES public.wallets(id) ON DELETE CASCADE; + + +-- +-- Name: discount_redemptions FK_8b48303d6ae52e9aa69e2df2259; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.discount_redemptions + ADD CONSTRAINT "FK_8b48303d6ae52e9aa69e2df2259" FOREIGN KEY ("discountId") REFERENCES public.discounts(id) ON DELETE CASCADE; + + +-- +-- Name: applications FK_90ad8bec24861de0180f638b9cc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applications + ADD CONSTRAINT "FK_90ad8bec24861de0180f638b9cc" FOREIGN KEY ("userId") REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: application_migration_jobs FK_932030f502543c1116155e7ef14; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.application_migration_jobs + ADD CONSTRAINT "FK_932030f502543c1116155e7ef14" FOREIGN KEY ("targetClusterId") REFERENCES public.clusters(id) ON DELETE RESTRICT; + + +-- +-- Name: application_migration_events FK_93b1269e814ba5dfb106d408440; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.application_migration_events + ADD CONSTRAINT "FK_93b1269e814ba5dfb106d408440" FOREIGN KEY ("migrationId") REFERENCES public.application_migration_jobs(id) ON DELETE CASCADE; + + +-- +-- Name: verification_codes FK_9a854eeb4598a22d554ecfe6e81; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.verification_codes + ADD CONSTRAINT "FK_9a854eeb4598a22d554ecfe6e81" FOREIGN KEY ("userId") REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: invoice_lines FK_9f57f31e620fe759b452feb776e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_lines + ADD CONSTRAINT "FK_9f57f31e620fe759b452feb776e" FOREIGN KEY ("invoiceId") REFERENCES public.invoices(id) ON DELETE CASCADE; + + +-- +-- Name: ticket_messages FK_b01e2a35417efbe04c10828266f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ticket_messages + ADD CONSTRAINT "FK_b01e2a35417efbe04c10828266f" FOREIGN KEY ("ticketId") REFERENCES public.tickets(id) ON DELETE CASCADE; + + +-- +-- Name: resource_credits FK_c546fd3ecce0091b717b32b0c5b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.resource_credits + ADD CONSTRAINT "FK_c546fd3ecce0091b717b32b0c5b" FOREIGN KEY ("userId") REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: cluster_health FK_d38286a989998e278202b43f6c9; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cluster_health + ADD CONSTRAINT "FK_d38286a989998e278202b43f6c9" FOREIGN KEY ("clusterId") REFERENCES public.clusters(id) ON DELETE CASCADE; + + +-- +-- Name: deployments FK_dca3b4d49c7df1e3a6a93b74fd7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.deployments + ADD CONSTRAINT "FK_dca3b4d49c7df1e3a6a93b74fd7" FOREIGN KEY ("applicationId") REFERENCES public.applications(id) ON DELETE CASCADE; + + +-- +-- Name: ticket_messages FK_ddea80824c24d270ef2cb4cb0ba; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ticket_messages + ADD CONSTRAINT "FK_ddea80824c24d270ef2cb4cb0ba" FOREIGN KEY ("senderId") REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: pricing_rules FK_f44e102039feebaf8436a606652; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_rules + ADD CONSTRAINT "FK_f44e102039feebaf8436a606652" FOREIGN KEY ("planId") REFERENCES public.service_plans(id) ON DELETE CASCADE; + + +-- +-- Name: application_migration_jobs FK_f68e403cd35eb2b95b1f77355e5; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.application_migration_jobs + ADD CONSTRAINT "FK_f68e403cd35eb2b95b1f77355e5" FOREIGN KEY ("sourceClusterId") REFERENCES public.clusters(id) ON DELETE RESTRICT; + + +-- +-- Name: invoices FK_fcbe490dc37a1abf68f19c5ccb9; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT "FK_fcbe490dc37a1abf68f19c5ccb9" FOREIGN KEY ("userId") REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + + + + +-- 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 + ('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') +ON CONFLICT (filename) DO NOTHING; diff --git a/backend/migrations/001_service_access_grants.sql b/backend/migrations/001_service_access_grants.sql index d2c6585..1e7c207 100644 --- a/backend/migrations/001_service_access_grants.sql +++ b/backend/migrations/001_service_access_grants.sql @@ -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(), diff --git a/backend/migrations/015_application_product_type.sql b/backend/migrations/015_application_product_type.sql index a59885d..a3eaba0 100644 --- a/backend/migrations/015_application_product_type.sql +++ b/backend/migrations/015_application_product_type.sql @@ -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'; diff --git a/backend/scripts/generate-base-schema.mjs b/backend/scripts/generate-base-schema.mjs new file mode 100644 index 0000000..ea0fb48 --- /dev/null +++ b/backend/scripts/generate-base-schema.mjs @@ -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)`); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9889698..7190c1b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -53,6 +53,7 @@ import configuration from './config/configuration'; redis: { host: configService.get('redis.host'), port: configService.get('redis.port'), + password: configService.get('redis.password'), }, }), inject: [ConfigService], diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 67c35bc..3ada03e 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -387,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); diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index da435ea..3c5f689 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -22,6 +22,7 @@ import { 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() @@ -67,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; @@ -96,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'; diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index 0a23825..d37e4c8 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -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'; @@ -111,8 +112,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) diff --git a/backend/src/billing/billing-wallet.controller.ts b/backend/src/billing/billing-wallet.controller.ts index 791b7a8..d18cdd0 100644 --- a/backend/src/billing/billing-wallet.controller.ts +++ b/backend/src/billing/billing-wallet.controller.ts @@ -14,7 +14,11 @@ import { import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { BillingService } from './billing.service'; -import { assertStubGatewayAllowed } from './payment-gateway.util'; +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'; @@ -43,8 +47,11 @@ export class BillingWalletController { } @Post('wallet/charge') - @ApiOperation({ summary: 'Charge my wallet (self top-up)' }) + @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'); } @@ -160,7 +167,7 @@ export class BillingWalletController { @Body() body: { amount: number; description?: string; callbackUrl: string }, ) { assertStubGatewayAllowed(); - const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; + const trackingCode = issueGatewayTrackingCode(req.user.id, body.amount); return { success: true, trackingCode, @@ -176,6 +183,8 @@ export class BillingWalletController { @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, diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index c8fb59c..8d61622 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -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'; @@ -172,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 { + 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, @@ -180,21 +206,23 @@ export class BillingService { ): Promise { 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; } @@ -207,26 +235,28 @@ export class BillingService { ): Promise { 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; } @@ -743,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; @@ -752,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); } } diff --git a/backend/src/billing/payment-gateway.util.ts b/backend/src/billing/payment-gateway.util.ts index ca552dc..3fb321b 100644 --- a/backend/src/billing/payment-gateway.util.ts +++ b/backend/src/billing/payment-gateway.util.ts @@ -1,4 +1,5 @@ -import { ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { createHmac, timingSafeEqual } from 'node:crypto'; /** * Stub gateway endpoints auto-approve payments without a real provider. @@ -12,3 +13,49 @@ export function assertStubGatewayAllowed(): void { 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--- + */ +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'); + } +} diff --git a/backend/src/build/build-progress.store.ts b/backend/src/build/build-progress.store.ts index 5df193b..6be3367 100644 --- a/backend/src/build/build-progress.store.ts +++ b/backend/src/build/build-progress.store.ts @@ -4,8 +4,24 @@ 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; @@ -14,6 +30,7 @@ export class BuildProgressStore implements OnModuleDestroy { this.redis = new Redis({ host: this.configService.get('redis.host'), port: this.configService.get('redis.port'), + password: this.configService.get('redis.password'), lazyConnect: true, maxRetriesPerRequest: 1, }); @@ -52,6 +69,36 @@ export class BuildProgressStore implements OnModuleDestroy { } } + async setSession(session: PersistedBuildSession): Promise { + 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 { + 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 { + try { + await this.redis.del(`${SESSION_KEY_PREFIX}${deploymentId}`); + } catch { + // ignore + } + } + onModuleDestroy(): void { this.redis.disconnect(); } diff --git a/backend/src/build/build.service.ts b/backend/src/build/build.service.ts index d2fbe17..04d5fde 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -31,12 +31,14 @@ export class BuildCancelledError extends Error { interface ActiveBuildSession { cancelled: boolean; + applicationId?: string; coreApi?: k8s.CoreV1Api; batchApi?: k8s.BatchV1Api; namespace?: string; buildPodName?: string; sourcePvcName?: string; helperPodName?: string; + gitSecretName?: string; processes: ChildProcess[]; socket?: net.Socket; } @@ -68,8 +70,71 @@ export class BuildService { private sourceStorage: SourceStorageService, ) {} - private beginBuildSession(deploymentId: string): void { - this.activeBuilds.set(deploymentId, { cancelled: false, processes: [] }); + /** + * Prefix Docker Hub base images with the configured mirror registry + * (BASE_IMAGE_REGISTRY), so generated Dockerfiles work on clusters that + * cannot reach docker.io. Images already pinned to another registry + * (gcr.io, mcr.microsoft.com, …) are returned unchanged. + */ + private baseImage(image: string): string { + const prefix = this.configService.get('build.baseImageRegistry'); + if (!prefix) return image; + const firstSegment = image.split('/')[0]; + const hasRegistry = firstSegment.includes('.') || firstSegment.includes(':'); + if (hasRegistry) return image; + return `${prefix}/${image}`; + } + + /** + * Git branch names come from users and end up in a shell command — accept + * only conservative ref characters and reject anything option-like. + */ + private assertSafeGitBranch(branch: string): string { + const b = (branch || '').trim(); + if (!b || b.length > 255 || b.startsWith('-') || b.includes('..') || !/^[A-Za-z0-9._/-]+$/.test(b)) { + throw new Error(`Invalid git branch name: "${branch}"`); + } + return b; + } + + /** + * SSRF guard for user-supplied repo URLs: only http(s), no embedded + * credentials, and no loopback/link-local/private or cluster-internal hosts. + */ + private assertSafeGitUrl(gitUrl: string): void { + let url: URL; + try { + url = new URL(gitUrl); + } catch { + throw new Error(`Invalid git URL: "${gitUrl}"`); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error(`Unsupported git URL protocol: "${url.protocol}" — only http(s) is allowed`); + } + if (url.username || url.password) { + throw new Error('Git URL must not contain embedded credentials — use the git token field instead'); + } + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + const blockedHosts = ['localhost', 'metadata.google.internal']; + const blockedSuffixes = ['.local', '.localhost', '.internal', '.svc', '.svc.cluster.local', '.cluster.local']; + const isPrivateIPv4 = + /^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(host) || + /^172\.(1[6-9]|2\d|3[01])\./.test(host); + const isIPv6Internal = host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'); + if ( + blockedHosts.includes(host) || + blockedSuffixes.some((s) => host.endsWith(s)) || + isPrivateIPv4 || + isIPv6Internal || + !host.includes('.') + ) { + throw new Error(`Git URL host "${url.hostname}" is not allowed`); + } + } + + private beginBuildSession(deploymentId: string, applicationId?: string): void { + this.activeBuilds.set(deploymentId, { cancelled: false, processes: [], applicationId }); + this.persistSession(deploymentId); } private getSession(deploymentId?: string): ActiveBuildSession | undefined { @@ -80,6 +145,26 @@ export class BuildService { private updateBuildSession(deploymentId: string, update: Partial): void { const session = this.activeBuilds.get(deploymentId); if (session) Object.assign(session, update); + this.persistSession(deploymentId); + } + + /** + * Mirror the serializable part of the session to Redis, so interrupted + * builds can be detected and their cluster resources cleaned up after a + * backend restart (the in-memory map does not survive restarts). + */ + private persistSession(deploymentId: string): void { + const session = this.activeBuilds.get(deploymentId); + if (!session) return; + void this.progressStore.setSession({ + deploymentId, + applicationId: session.applicationId, + namespace: session.namespace, + buildPodName: session.buildPodName, + sourcePvcName: session.sourcePvcName, + helperPodName: session.helperPodName, + gitSecretName: session.gitSecretName, + }); } private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void { @@ -122,7 +207,10 @@ export class BuildService { } private endBuildSession(deploymentId?: string): void { - if (deploymentId) this.activeBuilds.delete(deploymentId); + if (deploymentId) { + this.activeBuilds.delete(deploymentId); + void this.progressStore.clearSession(deploymentId); + } } async cancelBuild(deploymentId: string): Promise { @@ -154,7 +242,7 @@ export class BuildService { } } - const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session; + const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName, gitSecretName } = session; if (coreApi && namespace) { const cleanup: Promise[] = []; if (helperPodName) { @@ -200,6 +288,11 @@ export class BuildService { .catch(() => undefined), ); } + if (gitSecretName) { + cleanup.push( + coreApi.deleteNamespacedSecret({ name: gitSecretName, namespace }).catch(() => undefined), + ); + } await Promise.all(cleanup); this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`); } @@ -209,7 +302,7 @@ export class BuildService { percent: 0, message: 'Cancelled by user', }); - this.activeBuilds.delete(deploymentId); + this.endBuildSession(deploymentId); } /** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */ @@ -226,13 +319,14 @@ export class BuildService { const cleanup: Promise[] = []; - const [pods, pvcs, jobs, configMaps] = await Promise.all([ + const [pods, pvcs, jobs, configMaps, secrets] = await Promise.all([ coreApi.listNamespacedPod({ namespace: buildNamespace }), coreApi.listNamespacedPersistentVolumeClaim({ namespace: buildNamespace, }), batchApi.listNamespacedJob({ namespace: buildNamespace }), coreApi.listNamespacedConfigMap({ namespace: buildNamespace }), + coreApi.listNamespacedSecret({ namespace: buildNamespace }), ]); for (const pod of pods.items) { @@ -283,6 +377,12 @@ export class BuildService { cleanup.push(coreApi.deleteNamespacedConfigMap({ name, namespace: buildNamespace }).catch(() => undefined)); } } + for (const secret of secrets.items) { + const name = secret.metadata?.name || ''; + if (name.startsWith(prefix)) { + cleanup.push(coreApi.deleteNamespacedSecret({ name, namespace: buildNamespace }).catch(() => undefined)); + } + } await Promise.all(cleanup); this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`); @@ -320,7 +420,7 @@ export class BuildService { this.logger.log(`Starting image build for ${app.name} → ${imageUri}`); if (deploymentId) { - this.beginBuildSession(deploymentId); + this.beginBuildSession(deploymentId, app.id); } const hasUploadedCode = !!app.codePath; @@ -389,6 +489,8 @@ export class BuildService { // If we have uploaded code, create a PVC and upload via kubectl cp let sourcePvcName: string | undefined; + // Secret holding the git token for private-repo clones (created lazily) + let gitSecretName: string | undefined; if (hasUploadedCode && localZipPath) { sourcePvcName = `${buildPodName}-source`; if (deploymentId) { @@ -446,7 +548,7 @@ export class BuildService { // Add init container that unzips the source code from PVC initContainers.push({ name: 'unzip-source', - image: 'alpine:3.19', + image: this.baseImage('alpine:3.19'), imagePullPolicy: 'IfNotPresent', command: [ 'sh', @@ -493,36 +595,60 @@ export class BuildService { ], }); } else if (hasGitUrl) { - // Build the git clone URL — inject token for private repos - let cloneUrl = app.gitUrl!; + // Validate user-controlled values before they get anywhere near a shell. + this.assertSafeGitUrl(app.gitUrl!); + const branch = this.assertSafeGitBranch(app.gitBranch || 'main'); + + // The token never appears in the command line or the clone URL — it is + // delivered via a Secret env var and handed to git through GIT_ASKPASS, + // so it can't leak through pod specs, `ps`, or job logs. if (app.gitToken) { - // Convert https://github.com/user/repo.git → https://@github.com/user/repo.git - // Also works for GitLab, Bitbucket, etc. - try { - const url = new URL(cloneUrl); - url.username = app.gitToken; - url.password = ''; // Some providers use token as username, others as password - cloneUrl = url.toString(); - } catch { - // If URL parsing fails, try simple injection after protocol - cloneUrl = cloneUrl.replace('https://', `https://${app.gitToken}@`); - } + gitSecretName = `${buildPodName}-git`; + if (deploymentId) this.updateBuildSession(deploymentId, { gitSecretName }); + await coreApi.createNamespacedSecret({ + namespace: buildNamespace!, + body: { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: gitSecretName, namespace: buildNamespace }, + type: 'Opaque', + stringData: { GIT_TOKEN: app.gitToken }, + }, + }); } - const branch = app.gitBranch || 'main'; // Clone git repo into /workspace/source, then copy our generated Dockerfile initContainers.push({ name: 'git-clone', - image: 'alpine/git:2.43.0', + image: this.baseImage('alpine/git:2.43.0'), imagePullPolicy: 'IfNotPresent', + env: [ + { name: 'GIT_URL', value: app.gitUrl! }, + { name: 'GIT_BRANCH', value: branch }, + ...(gitSecretName + ? [ + { + name: 'GIT_TOKEN', + valueFrom: { secretKeyRef: { name: gitSecretName, key: 'GIT_TOKEN' } }, + }, + ] + : []), + ], command: [ 'sh', '-c', ` - echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" && - git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source && - cp /dockerfile/Dockerfile /workspace-out/Dockerfile && - echo ">>> Workspace contents:" && + set -e + if [ -n "\${GIT_TOKEN:-}" ]; then + printf '#!/bin/sh\\necho "$GIT_TOKEN"\\n' > /tmp/git-askpass.sh + chmod +x /tmp/git-askpass.sh + export GIT_ASKPASS=/tmp/git-askpass.sh + export GIT_TERMINAL_PROMPT=0 + fi + echo ">>> Cloning branch '$GIT_BRANCH' from $GIT_URL" + git clone --depth 1 --branch "$GIT_BRANCH" "$GIT_URL" /workspace-out/source + cp /dockerfile/Dockerfile /workspace-out/Dockerfile + echo ">>> Workspace contents:" ls -la /workspace-out/source/ `, ], @@ -545,7 +671,7 @@ export class BuildService { // add an init container that creates empty source dir + copies Dockerfile initContainers.push({ name: 'prepare-workspace', - image: 'alpine:3.19', + image: this.baseImage('alpine:3.19'), imagePullPolicy: 'IfNotPresent', command: [ 'sh', @@ -586,8 +712,14 @@ export class BuildService { args: kanikoArgs, volumeMounts: kanikoVolumeMounts, resources: { - requests: { cpu: '500m', memory: '1Gi' }, - limits: { cpu: '2', memory: '4Gi' }, + requests: { + cpu: this.configService.get('build.kaniko.cpuRequest') || '500m', + memory: this.configService.get('build.kaniko.memoryRequest') || '1Gi', + }, + limits: { + cpu: this.configService.get('build.kaniko.cpuLimit') || '2', + memory: this.configService.get('build.kaniko.memoryLimit') || '4Gi', + }, }, }, ], @@ -666,6 +798,17 @@ export class BuildService { } catch (e: any) { this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`); } + // Clean up git-token Secret + if (gitSecretName) { + try { + await coreApi.deleteNamespacedSecret({ + name: gitSecretName, + namespace: buildNamespace!, + }); + } catch (e: any) { + this.logger.warn(`Failed to clean up git Secret: ${e.message}`); + } + } this.endBuildSession(deploymentId); cleanupSource?.(); } @@ -780,6 +923,8 @@ export class BuildService { metadata: { name: pvcName, namespace }, spec: { accessModes: ['ReadWriteOnce'], + // Explicit StorageClass — don't rely on a cluster default existing + storageClassName: this.configService.get('platform.storageClass') || undefined, resources: { requests: { storage: `${sizeGi}Gi` } }, }, }, @@ -797,7 +942,7 @@ export class BuildService { containers: [ { name: 'helper', - image: 'alpine:3.19', + image: this.baseImage('alpine:3.19'), imagePullPolicy: 'IfNotPresent', command: ['sh', '-c', 'sleep 3600'], volumeMounts: [{ name: 'source', mountPath: '/data' }], @@ -1012,10 +1157,12 @@ export class BuildService { const port = app.port || 3000; const nodeVersion = app.runtimeVersion || '20'; return `# --- Build stage --- -FROM node:${nodeVersion}-alpine AS builder +FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS builder WORKDIR /app COPY package*.json ./ -RUN npm install --legacy-peer-deps && npm cache clean --force +# Reproducible install from the lockfile when present +RUN if [ -f package-lock.json ]; then npm ci --legacy-peer-deps; else npm install --legacy-peer-deps; fi \\ + && npm cache clean --force COPY . . # Auto-detect Next.js and enable standalone output @@ -1031,13 +1178,19 @@ RUN for cfg in next.config.js next.config.mjs next.config.ts; do \\ break; \\ done -RUN npm run build || echo ">>> Build script failed or not found — continuing" +# Run the build script when one exists — and FAIL the image build if it fails, +# instead of silently shipping a broken image. +RUN if node -e "const s=(require('./package.json').scripts||{});process.exit(s.build?0:1)"; then \\ + echo ">>> Running build script" && npm run build; \\ + else \\ + echo ">>> No build script defined — skipping"; \\ + fi # Clean up dev dependencies and caches to reduce image size RUN rm -rf node_modules/.cache .next/cache /tmp/* /root/.npm 2>/dev/null; true # --- Production stage --- -FROM node:${nodeVersion}-alpine AS runner +FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS runner WORKDIR /app RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 @@ -1070,9 +1223,9 @@ CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f serv const phpVersion = app.phpVersion || '8.3'; const port = app.port || 80; return `# --- Build stage (match production PHP version for Composer) --- -FROM php:${phpVersion}-cli-alpine AS composer +FROM ${this.baseImage(`php:${phpVersion}-cli-alpine`)} AS composer RUN apk add --no-cache git unzip -COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +COPY --from=${this.baseImage('composer:2')} /usr/bin/composer /usr/bin/composer WORKDIR /app COPY composer.json composer.lock* ./ RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs @@ -1080,11 +1233,15 @@ COPY . . RUN composer dump-autoload --optimize --no-dev --no-scripts # --- Production stage --- -FROM php:${phpVersion}-fpm-alpine +FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)} -RUN apk add --no-cache nginx supervisor curl openssl \\ - && docker-php-ext-install pdo pdo_mysql opcache \\ - && docker-php-ext-install pdo_pgsql 2>/dev/null || true +# Laravel needs bcmath/gd/intl/zip beyond the built-in set; pdo_pgsql is built +# properly against libpq instead of being silently skipped. +RUN apk add --no-cache nginx supervisor curl openssl icu-libs libzip libpng libjpeg-turbo freetype postgresql-libs \\ + && apk add --no-cache --virtual .build-deps icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev postgresql-dev \\ + && docker-php-ext-configure gd --with-jpeg --with-freetype \\ + && docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache bcmath zip gd intl exif pcntl \\ + && apk del .build-deps WORKDIR /var/www/html COPY --from=composer /app . @@ -1163,7 +1320,7 @@ CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"] const phpVersion = app.phpVersion || '8.3'; const hasUploadedCode = !!app.codePath; - return `FROM wordpress:${wpVersion}-php${phpVersion}-apache + return `FROM ${this.baseImage(`wordpress:${wpVersion}-php${phpVersion}-apache`)} # Install additional PHP extensions commonly needed by WordPress RUN docker-php-ext-install opcache @@ -1281,7 +1438,7 @@ CMD []` const port = app.port || 8080; const buildTarget = detectGoBuildTarget(archiveEntries); return `# --- Build stage --- -FROM golang:${goVersion}-alpine AS builder +FROM ${this.baseImage(`golang:${goVersion}-alpine`)} AS builder WORKDIR /app # Install git for fetching dependencies @@ -1297,8 +1454,13 @@ COPY . . # Build the application RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main ${buildTarget} +# Collect optional runtime asset dirs — COPY has no shell so "|| true" is not +# valid there; stage them in the builder instead. +RUN mkdir -p /assets \\ + && for d in static templates public; do [ -d "$d" ] && cp -r "$d" /assets/ || true; done + # --- Production stage --- -FROM alpine:3.19 +FROM ${this.baseImage('alpine:3.19')} WORKDIR /app # Add CA certificates for HTTPS requests @@ -1307,11 +1469,9 @@ RUN apk --no-cache add ca-certificates tzdata # Create non-root user RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup -# Copy the binary from builder +# Copy the binary and any staged asset dirs from the builder COPY --from=builder /app/main . -COPY --from=builder /app/static ./static 2>/dev/null || true -COPY --from=builder /app/templates ./templates 2>/dev/null || true -COPY --from=builder /app/public ./public 2>/dev/null || true +COPY --from=builder /assets/ ./ # Create data directory for persistent storage RUN mkdir -p /app/data && chown -R appuser:appgroup /app @@ -1331,16 +1491,14 @@ CMD ["./main"] private phpDockerfile(app: Application): string { const phpVersion = app.phpVersion || '8.3'; const port = app.port || 80; - return `FROM php:${phpVersion}-fpm-alpine + return `FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)} -RUN apk add --no-cache nginx supervisor curl \\ - && docker-php-ext-install pdo pdo_mysql opcache \\ - && docker-php-ext-install pdo_pgsql 2>/dev/null || true - -# Install common PHP extensions -RUN apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev \\ +# Install common PHP extensions (pdo_pgsql built properly against libpq) +RUN apk add --no-cache nginx supervisor curl postgresql-libs libpng libjpeg-turbo freetype \\ + && apk add --no-cache --virtual .build-deps postgresql-dev libpng-dev libjpeg-turbo-dev freetype-dev \\ && docker-php-ext-configure gd --with-freetype --with-jpeg \\ - && docker-php-ext-install gd + && docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache gd \\ + && apk del .build-deps WORKDIR /var/www/html COPY . . @@ -1401,7 +1559,7 @@ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] const pythonVersion = app.runtimeVersion || '3.12'; const port = app.port || 8000; return `# --- Build stage --- -FROM python:${pythonVersion}-slim AS builder +FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder WORKDIR /app # Install build dependencies @@ -1409,13 +1567,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\ build-essential libpq-dev \\ && rm -rf /var/lib/apt/lists/* -# Copy requirements and install dependencies -COPY requirements.txt* ./ -RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\ - pip install --no-cache-dir --user flask gunicorn +# Install dependencies from requirements.txt or pyproject.toml. A failing +# install FAILS the build — no silent fallback that hides missing deps. +COPY . . +RUN if [ -f requirements.txt ]; then \\ + echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\ + elif [ -f pyproject.toml ]; then \\ + echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\ + else \\ + echo ">>> No requirements.txt or pyproject.toml — installing default flask+gunicorn" \\ + && pip install --no-cache-dir --user flask gunicorn; \\ + fi # --- Production stage --- -FROM python:${pythonVersion}-slim +FROM ${this.baseImage(`python:${pythonVersion}-slim`)} WORKDIR /app # Install runtime dependencies @@ -1455,21 +1620,28 @@ CMD sh -c "if [ -f main.py ]; then if grep -qi fastapi main.py; then exec uvicor const port = app.port || 8000; const settingsModule = detectDjangoSettingsModule(archiveEntries); return `# --- Build stage --- -FROM python:${pythonVersion}-slim AS builder +FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder WORKDIR /app # Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \\ - build-essential libpq-dev \\ + build-essential libpq-dev default-libmysqlclient-dev pkg-config \\ && rm -rf /var/lib/apt/lists/* -# Copy requirements and install dependencies -COPY requirements.txt* ./ -RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\ - pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient +# Install dependencies from requirements.txt or pyproject.toml. A failing +# install FAILS the build — no silent fallback that hides missing deps. +COPY . . +RUN if [ -f requirements.txt ]; then \\ + echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\ + elif [ -f pyproject.toml ]; then \\ + echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\ + else \\ + echo ">>> No requirements.txt or pyproject.toml — installing Django defaults" \\ + && pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient; \\ + fi # --- Production stage --- -FROM python:${pythonVersion}-slim +FROM ${this.baseImage(`python:${pythonVersion}-slim`)} WORKDIR /app # Install runtime dependencies diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index ad81759..27f9ed2 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -82,6 +82,7 @@ export default () => ({ redis: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD || undefined, }, cluster: { @@ -133,6 +134,20 @@ export default () => ({ build: { namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds', serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder', + /** + * Optional registry prefix for Docker Hub base images used in generated + * Dockerfiles and managed-service charts (e.g. "mirror.example.com" makes + * `node:20-alpine` → `mirror.example.com/node:20-alpine`). Useful when + * cluster nodes cannot reach docker.io directly. + */ + baseImageRegistry: (process.env.BASE_IMAGE_REGISTRY || '').trim().replace(/\/+$/, ''), + /** Kaniko build container resources — tune for large images. */ + kaniko: { + cpuRequest: process.env.KANIKO_CPU_REQUEST || '500m', + cpuLimit: process.env.KANIKO_CPU_LIMIT || '2', + memoryRequest: process.env.KANIKO_MEMORY_REQUEST || '1Gi', + memoryLimit: process.env.KANIKO_MEMORY_LIMIT || '4Gi', + }, }, elasticsearch: { diff --git a/backend/src/config/validate-production-config.spec.ts b/backend/src/config/validate-production-config.spec.ts index e374979..908ee48 100644 --- a/backend/src/config/validate-production-config.spec.ts +++ b/backend/src/config/validate-production-config.spec.ts @@ -28,12 +28,24 @@ describe('validateProductionConfig', () => { expect(() => validateProductionConfig()).toThrow(/CLUSTER_KUBECONFIG_KEY/); }); + it('throws in production when elastic credentials keep the well-known defaults', () => { + process.env.NODE_ENV = 'production'; + process.env.JWT_SECRET = 'a-very-long-random-production-secret'; + process.env.JWT_REFRESH_SECRET = 'another-very-long-random-refresh-secret'; + process.env.DB_PASSWORD = 'strong-db-password-here'; + process.env.CLUSTER_KUBECONFIG_KEY = '0123456789abcdef0123456789abcdef'; + process.env.ELASTIC_PASSWORD = 'CloudHost2024!Secure'; + + expect(() => validateProductionConfig()).toThrow(/ELASTIC_PASSWORD/); + }); + it('passes in production with strong secrets', () => { process.env.NODE_ENV = 'production'; process.env.JWT_SECRET = 'a-very-long-random-production-secret'; process.env.JWT_REFRESH_SECRET = 'another-very-long-random-refresh-secret'; process.env.DB_PASSWORD = 'strong-db-password-here'; process.env.CLUSTER_KUBECONFIG_KEY = '0123456789abcdef0123456789abcdef'; + process.env.ELASTIC_PASSWORD = 'a-strong-rotated-elastic-password'; expect(() => validateProductionConfig()).not.toThrow(); }); diff --git a/backend/src/config/validate-production-config.ts b/backend/src/config/validate-production-config.ts index 63bae96..94f1097 100644 --- a/backend/src/config/validate-production-config.ts +++ b/backend/src/config/validate-production-config.ts @@ -25,6 +25,14 @@ export function validateProductionConfig(): void { if (!process.env.CLUSTER_KUBECONFIG_KEY?.trim()) { errors.push('CLUSTER_KUBECONFIG_KEY must be set in production to encrypt stored kubeconfigs'); } + // Elastic log-stack credentials must not fall back to the well-known dev defaults. + const elasticDefaults = ['CloudHost2024!Secure', 'FluentBit2024!Writer', 'Kibana2024!System']; + if (!process.env.ELASTIC_PASSWORD || elasticDefaults.includes(process.env.ELASTIC_PASSWORD)) { + errors.push('ELASTIC_PASSWORD must be set to a strong random value in production'); + } + if (process.env.FLUENTBIT_PASSWORD && elasticDefaults.includes(process.env.FLUENTBIT_PASSWORD)) { + errors.push('FLUENTBIT_PASSWORD must be changed from the default in production'); + } if (errors.length > 0) { throw new Error( diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index 7b2aac4..c81d52f 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -1,6 +1,6 @@ -import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef, OnModuleInit } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import * as fs from 'fs'; import { Deployment } from './entities/deployment.entity'; import { ApplicationsService } from '../applications/applications.service'; @@ -16,7 +16,7 @@ import { import { ClustersService } from '../clusters/clusters.service'; @Injectable() -export class DeploymentsService { +export class DeploymentsService implements OnModuleInit { private readonly logger = new Logger(DeploymentsService.name); constructor( @@ -29,6 +29,46 @@ export class DeploymentsService { private clustersService: ClustersService, ) {} + /** + * Recover from a backend restart: any deployment still marked as in-flight + * belonged to a pipeline running in the old process and will never finish. + * Mark it failed and best-effort clean up its orphaned build resources + * (Kaniko job, source PVC, helper pod, git secret) in the cluster. + */ + onModuleInit(): void { + void this.failInterruptedDeployments().catch((err) => + this.logger.error('Failed to recover interrupted deployments on startup', err), + ); + } + + private async failInterruptedDeployments(): Promise { + const stuck = await this.deploymentsRepository.find({ + where: { + status: In([DeploymentStatus.PENDING, DeploymentStatus.BUILDING, DeploymentStatus.DEPLOYING]), + }, + }); + if (stuck.length === 0) return; + + this.logger.warn(`Found ${stuck.length} deployment(s) interrupted by a restart — marking as failed`); + for (const deployment of stuck) { + await this.deploymentsRepository.update(deployment.id, { + status: DeploymentStatus.FAILED, + errorMessage: 'Build interrupted by a platform restart — please redeploy', + }); + this.buildService.setProgress(deployment.id, { + phase: 'failed', + percent: 0, + message: 'Build interrupted by a platform restart', + }); + try { + const app = await this.applicationsService.findOne(deployment.applicationId); + if (app) await this.buildService.cleanupBuildResourcesForApp(app); + } catch (err: any) { + this.logger.warn(`Cleanup of interrupted deployment ${deployment.id} failed: ${err.message}`); + } + } + } + /** * Random 7-digit suffix for the preview host: -<7-digit>.. * Generated once per application (see resolvePreviewNumber) and persisted. @@ -58,6 +98,8 @@ export class DeploymentsService { async triggerDeployment(applicationId: string, userId: string): Promise { const app = await this.applicationsService.findOne(applicationId, userId); + this.ensureAppPaidAndActive(app, 'deploying'); + // Create deployment record const deployment = this.deploymentsRepository.create({ applicationId: app.id, @@ -451,15 +493,20 @@ export class DeploymentsService { return app.latestImageTag === MANAGED_DEPLOY_MARKER; } - private ensureRedeployAllowed(app: any): void { - if (!app.billingCycle) return; - + /** + * All deploy/start/redeploy operations require the app to be paid for: + * activated (billingCycle set via wallet/pay), lifecycle ACTIVE, and paid + * time remaining. Prevents deploying/resuming without payment. + */ + private ensureAppPaidAndActive(app: any, action = 'deploying'): void { const isActive = app.lifecycleStatus === AppLifecycleStatus.ACTIVE; const expiresAt = app.planExpiresAt ? new Date(app.planExpiresAt) : null; const hasPaidTimeRemaining = !!expiresAt && expiresAt > new Date(); - if (!isActive || !hasPaidTimeRemaining) { - throw new BadRequestException('Payment must be completed successfully before redeploying this application.'); + if (!app.billingCycle || !isActive || !hasPaidTimeRemaining) { + throw new BadRequestException( + `Payment must be completed successfully before ${action} this application.`, + ); } } @@ -636,6 +683,9 @@ export class DeploymentsService { async startDeployment(applicationId: string, userId: string): Promise { const app = await this.applicationsService.findOne(applicationId, userId); + // Prevent resuming a billing-suspended/expired app without payment — + // otherwise `start` bypasses the lifecycle suspension entirely. + this.ensureAppPaidAndActive(app, 'starting'); await this.kubernetesService.resumeApplication(app); await this.applicationsService.clearSuspendedReplicas(app.id); @@ -671,7 +721,7 @@ export class DeploymentsService { throw new NotFoundException('No source code available. Upload code or set a git URL first.'); } - this.ensureRedeployAllowed(app); + this.ensureAppPaidAndActive(app, 'redeploying'); // Create new deployment record const deployment = this.deploymentsRepository.create({ diff --git a/backend/src/kubernetes/elasticsearch.service.ts b/backend/src/kubernetes/elasticsearch.service.ts index eef149c..f411b5d 100644 --- a/backend/src/kubernetes/elasticsearch.service.ts +++ b/backend/src/kubernetes/elasticsearch.service.ts @@ -5,6 +5,7 @@ import * as crypto from 'crypto'; import { ChildProcess, spawn } from 'child_process'; import { ClustersService } from '../clusters/clusters.service'; import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service'; +import { userNamespace } from './k8s-workload.util'; interface ElasticsearchCredentials { username: string; @@ -97,9 +98,9 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { private configService: ConfigService, private helmService: HelmService, ) { - this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure'; - this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer'; - this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System'; + this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || ''; + this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || ''; + this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || ''; } async onModuleInit(): Promise { @@ -649,7 +650,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { generateUserCredentials(userId: string): ElasticsearchCredentials { const hash = crypto.createHash('sha256').update(`${userId}-${this.ELASTIC_PASSWORD}`).digest('hex'); return { - username: `user-${userId.split('-')[0]}`, + username: userNamespace(userId), password: hash.substring(0, 24), }; } @@ -666,16 +667,16 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { * Get index pattern for a user's applications */ getIndexPattern(userId: string): string { - const userPrefix = userId.split('-')[0]; - return `logs-user-${userPrefix}-*`; + return `logs-${userNamespace(userId)}-*`; } /** * Build must clauses for user log isolation (new + legacy fields). */ buildUserLogMustClauses(userId: string, filters: LogSearchFilters = {}): any[] { - const userPrefix = userId.split('-')[0]; - const namespace = `user-${userPrefix}`; + // Full-UUID namespace — a truncated prefix would match other tenants' + // namespaces and leak their logs. + const namespace = userNamespace(userId); const must: any[] = [ { @@ -758,7 +759,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { } getUserIndexPattern(userId: string): string { - return `logs-user-${userId.split('-')[0]}-*`; + return `logs-${userNamespace(userId)}-*`; } private elasticsearchFetch(url: string, auth: string, body: unknown): Promise { diff --git a/backend/src/kubernetes/k8s-client-v1-migration.spec.ts b/backend/src/kubernetes/k8s-client-v1-migration.spec.ts index 854a501..fefaed9 100644 --- a/backend/src/kubernetes/k8s-client-v1-migration.spec.ts +++ b/backend/src/kubernetes/k8s-client-v1-migration.spec.ts @@ -125,11 +125,11 @@ describe('KubernetesService — k8s v1 client shape', () => { expect(logs).toBe('hello logs'); const listArg = coreApi.listNamespacedPod.mock.calls[0][0]; - expect(listArg).toMatchObject({ namespace: 'user-abc123' }); + expect(listArg).toMatchObject({ namespace: 'user-abc123def456' }); expect(typeof listArg.labelSelector).toBe('string'); const logArg = coreApi.readNamespacedPodLog.mock.calls[0][0]; - expect(logArg).toMatchObject({ name: 'pod-1', namespace: 'user-abc123', tailLines: 200 }); + expect(logArg).toMatchObject({ name: 'pod-1', namespace: 'user-abc123def456', tailLines: 200 }); }); it('getDatabasePvcSize reads the PVC with v1 object args and unwrapped spec', async () => { @@ -144,7 +144,7 @@ describe('KubernetesService — k8s v1 client shape', () => { expect(size).toBe('5Gi'); const arg = coreApi.readNamespacedPersistentVolumeClaim.mock.calls[0][0]; - expect(arg).toMatchObject({ name: 'my-app-db', namespace: 'user-abc123' }); + expect(arg).toMatchObject({ name: 'my-app-db', namespace: 'user-abc123def456' }); }); it('scaleDeployment patches with the v1 object body and a header-options 2nd arg', async () => { @@ -157,7 +157,7 @@ describe('KubernetesService — k8s v1 client shape', () => { const [param, options] = appsApi.patchNamespacedDeployment.mock.calls[0]; expect(param).toMatchObject({ name: 'my-app', - namespace: 'user-abc123', + namespace: 'user-abc123def456', body: { spec: { replicas: 3 } }, }); // v1 takes the merge-patch content-type via the 2nd ConfigurationOptions arg diff --git a/backend/src/kubernetes/k8s-workload.util.ts b/backend/src/kubernetes/k8s-workload.util.ts index 3bd139d..c7740db 100644 --- a/backend/src/kubernetes/k8s-workload.util.ts +++ b/backend/src/kubernetes/k8s-workload.util.ts @@ -1,9 +1,18 @@ import { Application } from '../applications/entities/application.entity'; import { DatabaseType, isManagedProductType } from '../common/enums'; -/** Kubernetes namespace for a user's applications. */ +/** + * Collision-free slug for a user id: the full UUID with dashes stripped + * (32 hex chars). Never truncate the UUID — truncated prefixes collide + * between users and break tenant isolation (shared namespaces/logs). + */ +export function userIdSlug(userId: string): string { + return userId.replace(/-/g, ''); +} + +/** Kubernetes namespace for a user's applications ("user-" + 32 chars ≤ 63). */ export function userNamespace(userId: string): string { - return `user-${userId.split('-')[0]}`; + return `user-${userIdSlug(userId)}`; } /** Primary pod label selector target for an application workload. */ diff --git a/backend/src/kubernetes/kubernetes.service.spec.ts b/backend/src/kubernetes/kubernetes.service.spec.ts index 086ac22..6bfab6d 100644 --- a/backend/src/kubernetes/kubernetes.service.spec.ts +++ b/backend/src/kubernetes/kubernetes.service.spec.ts @@ -15,7 +15,7 @@ describe('buildHelmValues logic', () => { return { app: { name: app.name, - namespace: `user-${app.userId.split('-')[0]}`, + namespace: `user-${app.userId.replace(/-/g, '')}`, runtime: app.runtime, image: imageUri, port: app.port, @@ -72,7 +72,7 @@ describe('buildHelmValues logic', () => { it('should set correct namespace from userId', () => { const values = buildHelmValues(baseApp, 'registry/my-app:123'); - expect(values.app.namespace).toBe('user-abc123'); + expect(values.app.namespace).toBe('user-abc123def456'); }); it('should disable database when type is NONE', () => { diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 24b1dd4..244534b 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as k8s from '@kubernetes/client-node'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; @@ -17,6 +18,7 @@ import { HelmService } from './helm.service'; import { RegistryService } from './registry.service'; import { K8sClientService } from './k8s-client.service'; import { K8sLifecycleService } from './k8s-lifecycle.service'; +import { userNamespace, userIdSlug } from './k8s-workload.util'; import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util'; const execFileAsync = promisify(execFile); @@ -209,8 +211,27 @@ export class KubernetesService implements OnModuleInit { } /** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */ + /** + * Return the app's database password, generating and PERSISTING one if it is + * missing. Without persistence a fresh password would be generated on every + * helm upgrade, breaking auth against the database's persisted volume. + */ + private ensureDbPassword(app: Application): string { + if (!app.dbPassword) { + app.dbPassword = this.generatePassword(); + this.deploymentsRepository.manager + .getRepository(Application) + .update(app.id, { dbPassword: app.dbPassword }) + .catch((e: any) => + this.logger.warn(`Failed to persist generated dbPassword for ${app.name}: ${e.message}`), + ); + this.logger.warn(`App ${app.name} had no dbPassword — generated and persisted one`); + } + return app.dbPassword; + } + private buildManagedHelmValues(app: Application): Record { - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const pullRegistryUrl = this.registryService.getRegistryUrl(); const isPostgres = app.databaseType === DatabaseType.POSTGRESQL; const productType = app.productType; @@ -247,7 +268,10 @@ export class KubernetesService implements OnModuleInit { type: app.databaseType, version: app.dbVersion || (isPostgres ? '16' : '8.0'), username: app.dbUsername || 'appuser', - password: app.dbPassword || this.generatePassword(), + password: + app.databaseType && app.databaseType !== DatabaseType.NONE + ? this.ensureDbPassword(app) + : '', storageSize: app.dbStorageSize || '1Gi', resources: this.resolveDatabaseResources(app), }, @@ -260,6 +284,7 @@ export class KubernetesService implements OnModuleInit { ownerId: app.userId, applicationId: app.id, }, + images: { baseRegistry: this.configService.get('build.baseImageRegistry') || '' }, changeCause: `Helm provision ${app.name} (${productType}) at ${new Date().toISOString()}`, }; @@ -287,7 +312,7 @@ export class KubernetesService implements OnModuleInit { private buildHelmValues(app: Application, imageUri: string, previewNumber?: string | null): Record { const domain = this.configService.get('platform.domain'); const previewRootDomain = this.configService.get('platform.previewRootDomain') || domain; - const namespacePrefix = app.userId.split('-')[0]; + const namespacePrefix = userIdSlug(app.userId); const previewHost = previewNumber && !app.customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : ''; const pullRegistryUrl = this.registryService.getRegistryUrl(); const isWordPress = app.runtime === AppRuntime.WORDPRESS; @@ -299,7 +324,7 @@ export class KubernetesService implements OnModuleInit { app: { enabled: true, name: app.name, - namespace: `user-${app.userId.split('-')[0]}`, + namespace: this.getUserNamespace(app.userId), runtime: app.runtime, image: imageUri, port: app.port, @@ -330,7 +355,7 @@ export class KubernetesService implements OnModuleInit { type: app.databaseType, version: app.dbVersion || (isPostgres ? '16' : '8.0'), username: app.dbUsername || 'appuser', - password: app.dbPassword || this.generatePassword(), + password: hasDb ? this.ensureDbPassword(app) : '', storageSize: app.dbStorageSize || '1Gi', resources: this.resolveDatabaseResources(app), }, @@ -344,10 +369,11 @@ export class KubernetesService implements OnModuleInit { logPaths: app.logPaths || [], ownerId: app.userId, applicationId: app.id, - elasticPassword: this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure', - fluentbitPassword: this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer', - kibanaPassword: this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System', + elasticPassword: this.configService.get('elasticsearch.password'), + fluentbitPassword: this.configService.get('elasticsearch.fluentbitPassword'), + kibanaPassword: this.configService.get('elasticsearch.kibanaPassword'), }, + images: { baseRegistry: this.configService.get('build.baseImageRegistry') || '' }, changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`, }; @@ -389,7 +415,7 @@ export class KubernetesService implements OnModuleInit { async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise): Promise { const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const managed = isManagedProductType(app.productType); const workloads = [ ...(!managed ? [{ name: app.name, replicas: app.replicas || 1 }] : []), @@ -429,7 +455,7 @@ export class KubernetesService implements OnModuleInit { async updateIngress(app: Application): Promise { const domain = this.configService.get('platform.domain'); const subdomain = app.subdomain || app.name; - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const customDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : undefined; // When there's no verified custom domain, restore the stable preview host so @@ -528,7 +554,7 @@ export class KubernetesService implements OnModuleInit { const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); await this.ensurePlatformStorageClass(kubeconfig); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const context: ManifestContext = { appName: app.name, namespace, @@ -545,7 +571,10 @@ export class KubernetesService implements OnModuleInit { domain: this.configService.get('platform.domain') || 'apps.cloudhost.ir', subdomain: app.subdomain || app.name, dbUsername: app.dbUsername || 'appuser', - dbPassword: app.dbPassword || this.generatePassword(), + dbPassword: + app.databaseType && app.databaseType !== DatabaseType.NONE + ? this.ensureDbPassword(app) + : '', dbVersion: app.dbVersion || '', dbStorageSize: app.dbStorageSize || '1Gi', dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest, @@ -602,7 +631,7 @@ export class KubernetesService implements OnModuleInit { const context: ManifestContext = { appName: app.name, - namespace: `user-${app.userId.split('-')[0]}`, + namespace: this.getUserNamespace(app.userId), image: imageUri, port: app.port, replicas: app.replicas, @@ -616,7 +645,10 @@ export class KubernetesService implements OnModuleInit { domain: domain, subdomain: app.subdomain || app.name, dbUsername: app.dbUsername || 'appuser', - dbPassword: app.dbPassword || this.generatePassword(), + dbPassword: + app.databaseType && app.databaseType !== DatabaseType.NONE + ? this.ensureDbPassword(app) + : '', dbVersion: app.dbVersion || '', dbStorageSize: app.dbStorageSize || '1Gi', dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest, @@ -1172,10 +1204,10 @@ export class KubernetesService implements OnModuleInit { /** Replicate logging credentials into the app namespace for Fluent Bit sidecars. */ private async ensureElasticsearchCredentialsSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise { const name = 'elasticsearch-credentials'; - const stringData = { - ELASTIC_PASSWORD: this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure', - FLUENTBIT_PASSWORD: this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer', - KIBANA_SYSTEM_PASSWORD: this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System', + const stringData: { [key: string]: string } = { + ELASTIC_PASSWORD: this.configService.get('elasticsearch.password') || '', + FLUENTBIT_PASSWORD: this.configService.get('elasticsearch.fluentbitPassword') || '', + KIBANA_SYSTEM_PASSWORD: this.configService.get('elasticsearch.kibanaPassword') || '', }; try { @@ -1512,7 +1544,7 @@ export class KubernetesService implements OnModuleInit { } const previewRootDomain = this.configService.get('platform.previewRootDomain') || ctx.domain; - const namespacePrefix = ctx.ownerId.split('-')[0]; + const namespacePrefix = userIdSlug(ctx.ownerId); const previewHost = previewNumber && !customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : ''; if (previewHost) { rules.push({ @@ -2228,7 +2260,7 @@ export class KubernetesService implements OnModuleInit { async scaleDeployment(app: Application, replicas: number): Promise { const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json')); } @@ -2268,7 +2300,7 @@ export class KubernetesService implements OnModuleInit { async captureWorkloadReplicaSnapshot(app: Application): Promise> { const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const snapshot: Record = {}; for (const workload of this.getApplicationWorkloadDeployments(app)) { @@ -2297,7 +2329,7 @@ export class KubernetesService implements OnModuleInit { */ async suspendApplication(app: Application): Promise> { const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`); @@ -2323,7 +2355,7 @@ export class KubernetesService implements OnModuleInit { */ async resumeApplication(app: Application): Promise { const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`); @@ -2355,7 +2387,7 @@ export class KubernetesService implements OnModuleInit { async restartDeployment(app: Application): Promise { const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name; await appsApi.patchNamespacedDeployment( @@ -2550,7 +2582,7 @@ export class KubernetesService implements OnModuleInit { */ async getResourceUsage(app: Application): Promise { const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const workloads: any[] = []; @@ -2640,7 +2672,7 @@ export class KubernetesService implements OnModuleInit { workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app', ): Promise { const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const target = this.workloadDeploymentTarget(app, workload); if (!target) { @@ -2685,7 +2717,7 @@ export class KubernetesService implements OnModuleInit { } getUserNamespace(userId: string): string { - return `user-${userId.split('-')[0]}`; + return userNamespace(userId); } private getClusterHostIp(kc: k8s.KubeConfig): string { @@ -2979,7 +3011,7 @@ export class KubernetesService implements OnModuleInit { const subdomain = app.subdomain || app.name; const verifiedCustomDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : null; const previewRootDomain = this.configService.get('platform.previewRootDomain') || domain; - const namespacePrefix = app.userId.split('-')[0]; + const namespacePrefix = userIdSlug(app.userId); let ingressUrl = `https://${subdomain}.${domain}`; if (verifiedCustomDomain) { @@ -3362,7 +3394,7 @@ export class KubernetesService implements OnModuleInit { */ async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise { const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const dbLabel = `${app.name}-db`; const start = Date.now(); @@ -3469,14 +3501,12 @@ export class KubernetesService implements OnModuleInit { async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> { const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const dbName = `${app.name}-db`; const ts = Date.now(); const pvcName = `${app.name}-db-dump-${ts}`; const helperPodName = `${pvcName}-helper`; const jobName = `${app.name}-db-restore-${ts}`; - const isPostgres = app.databaseType === DatabaseType.POSTGRESQL; - const dbDatabase = app.name.replace(/-/g, '_'); const dumpSize = fs.statSync(dumpFilePath).size; const pvcSizeGi = Math.max(1, Math.ceil((dumpSize * 2) / (1024 * 1024 * 1024))); @@ -3562,14 +3592,8 @@ export class KubernetesService implements OnModuleInit { } catch {} } - // ── 4. Build restore command ── - const command = isPostgres - ? ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" psql -h ${dbName} -U "$DB_USER" -d ${dbDatabase} -f /dump/dump.sql 2>&1`] - : ['sh', '-c', `mysql -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`]; - - const defaultDbVer = isPostgres ? '16' : '8.0'; - const restoreDbVer = app.dbVersion || defaultDbVer; - const image = isPostgres ? `postgres:${restoreDbVer}-alpine` : `mysql:${restoreDbVer}`; + // ── 4. Build restore command (per database type) ── + const { image, restoreCommand: command } = this.databaseDumpSpec(app, dbName); // ── 5. Create the restore Job ── const job: k8s.V1Job = { @@ -3777,7 +3801,7 @@ export class KubernetesService implements OnModuleInit { private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> { const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const oldPvcName = `${app.name}-db`; const newPvcName = `${app.name}-db-resizable`; const deploymentName = `${app.name}-db`; @@ -3952,7 +3976,7 @@ export class KubernetesService implements OnModuleInit { */ async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const pvcName = `${app.name}-db`; try { @@ -4025,7 +4049,7 @@ export class KubernetesService implements OnModuleInit { async getDatabasePvcSize(app: Application): Promise { try { const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const pvcName = `${app.name}-db`; const pvc = await coreApi.readNamespacedPersistentVolumeClaim({ @@ -4051,7 +4075,7 @@ export class KubernetesService implements OnModuleInit { totalUsedGb: number; }> { const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const result = { database: null as StorageUsageSlice | null, @@ -4260,7 +4284,7 @@ export class KubernetesService implements OnModuleInit { */ async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> { const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); try { const pvc = await coreApi.readNamespacedPersistentVolumeClaim({ @@ -4317,7 +4341,7 @@ export class KubernetesService implements OnModuleInit { */ async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); // Try new unified name first, then legacy wp-content name let pvcName = `${app.name}-storage`; @@ -4374,6 +4398,60 @@ export class KubernetesService implements OnModuleInit { // ─── Snapshot helpers ─────────────────────────────── + /** + * Per-database tooling for dump/restore jobs. `dumpCommand` writes to + * `outputPath`; `restoreCommand` reads from `/dump/dump.sql` (the copied + * dump file keeps that name regardless of format — mongodump archives are + * binary but mongorestore does not care about the extension). + */ + private databaseDumpSpec(app: Application, dbHost: string): { + image: string; + outputPath: string; + dumpCommand: string[]; + restoreCommand: string[]; + } { + const dbDatabase = app.name.replace(/-/g, '_'); + switch (app.databaseType) { + case DatabaseType.POSTGRESQL: { + const image = `postgres:${app.dbVersion || '16'}-alpine`; + return { + image, + outputPath: '/dump/output.sql', + dumpCommand: ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" pg_dump -h ${dbHost} -U "$DB_USER" -d ${dbDatabase} --no-owner --no-acl > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`], + restoreCommand: ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" psql -h ${dbHost} -U "$DB_USER" -d ${dbDatabase} -f /dump/dump.sql 2>&1`], + }; + } + case DatabaseType.MONGODB: { + const image = `mongo:${app.dbVersion || '7.0'}`; + const auth = `-u "$DB_USER" -p "$DB_PASSWORD" --authenticationDatabase admin`; + return { + image, + outputPath: '/dump/output.archive', + dumpCommand: ['sh', '-c', `mongodump --host ${dbHost} ${auth} --db ${dbDatabase} --archive=/dump/output.archive --gzip 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`], + restoreCommand: ['sh', '-c', `mongorestore --host ${dbHost} ${auth} --nsInclude '${dbDatabase}.*' --archive=/dump/dump.sql --gzip --drop 2>&1`], + }; + } + case DatabaseType.MARIADB: { + const image = `mariadb:${app.dbVersion || '11'}`; + return { + image, + outputPath: '/dump/output.sql', + dumpCommand: ['sh', '-c', `mariadb-dump -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`], + restoreCommand: ['sh', '-c', `mariadb -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`], + }; + } + default: { + const image = `mysql:${app.dbVersion || '8.0'}`; + return { + image, + outputPath: '/dump/output.sql', + dumpCommand: ['sh', '-c', `mysqldump -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`], + restoreCommand: ['sh', '-c', `mysql -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`], + }; + } + } + } + /** * Export (dump) the application database to a local file via a K8s Job. * Returns the dump as a Buffer, or null on failure. @@ -4383,20 +4461,12 @@ export class KubernetesService implements OnModuleInit { async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> { const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const dbName = `${app.name}-db`; const jobName = `${app.name}-db-dump-${Date.now()}`; - const isPostgres = app.databaseType === DatabaseType.POSTGRESQL; - const dbDatabase = app.name.replace(/-/g, '_'); - const defaultDbVer = isPostgres ? '16' : '8.0'; - const dbVer = app.dbVersion || defaultDbVer; - const image = isPostgres ? `postgres:${dbVer}-alpine` : `mysql:${dbVer}`; - - // Dump command writes to /dump/output.sql, then sleeps to allow exec retrieval - const command = isPostgres - ? ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" pg_dump -h ${dbName} -U "$DB_USER" -d ${dbDatabase} --no-owner --no-acl > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`] - : ['sh', '-c', `mysqldump -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`]; + // Dump command writes to spec.outputPath, then sleeps to allow exec retrieval + const { image, outputPath, dumpCommand: command } = this.databaseDumpSpec(app, dbName); const job: k8s.V1Job = { apiVersion: 'batch/v1', @@ -4519,7 +4589,7 @@ export class KubernetesService implements OnModuleInit { }); await new Promise((resolve, reject) => { - exec.exec(namespace, podName!, 'dump', ['cat', '/dump/output.sql'], stdoutStream, stderrStream, null, false, (status: k8s.V1Status) => { + exec.exec(namespace, podName!, 'dump', ['cat', outputPath], stdoutStream, stderrStream, null, false, (status: k8s.V1Status) => { if (status.status === 'Success') resolve(); else reject(new Error(status.message || 'exec failed')); }); @@ -4564,7 +4634,7 @@ export class KubernetesService implements OnModuleInit { async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> { const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const pvcName = `${app.name}-storage`; const jobName = `${app.name}-wp-archive-${Date.now()}`; @@ -4712,127 +4782,92 @@ export class KubernetesService implements OnModuleInit { /** * Restore wp-content from a tar.gz archive into the WordPress PVC. + * + * The archive is streamed into a helper pod with `kubectl cp` (a Secret + * would be capped at ~1MiB — far too small for real wp-content) and + * extracted in place onto the mounted PVC. */ async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> { const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); - const batchApi = kc.makeApiClient(k8s.BatchV1Api); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const pvcName = `${app.name}-storage`; - const jobName = `${app.name}-wp-restore-${Date.now()}`; - const secretName = `${jobName}-archive`; + const ts = Date.now(); + const helperPodName = `${app.name}-wp-restore-${ts}`; - // Store archive in a secret - const archiveSecret = { + const helperPod: k8s.V1Pod = { apiVersion: 'v1', - kind: 'Secret', - metadata: { name: secretName, namespace }, - data: { 'wp-content.tar.gz': archiveBuffer.toString('base64') }, - }; - - try { - await coreApi.createNamespacedSecret({ namespace, body: archiveSecret }); - } catch (e: any) { - return { - success: false, - logs: `Failed to create archive secret: ${e.message}`, - }; - } - - const job: k8s.V1Job = { - apiVersion: 'batch/v1', - kind: 'Job', - metadata: { name: jobName, namespace }, + kind: 'Pod', + metadata: { name: helperPodName, namespace }, spec: { - ttlSecondsAfterFinished: 120, - backoffLimit: 0, - template: { - spec: { - restartPolicy: 'Never', - containers: [ - { - name: 'restore', - image: 'alpine:3.19', - command: ['sh', '-c', 'apk add --no-cache tar gzip > /dev/null 2>&1 && rm -rf /wp-content/* && cd /wp-content && tar xzf /archive/wp-content.tar.gz && echo "RESTORE_DONE"'], - volumeMounts: [ - { name: 'wp-content', mountPath: '/wp-content' }, - { name: 'archive', mountPath: '/archive', readOnly: true }, - ], - resources: { - requests: { cpu: '100m', memory: '64Mi' }, - limits: { cpu: '500m', memory: '256Mi' }, - }, - }, - ], - volumes: [ - { - name: 'wp-content', - persistentVolumeClaim: { claimName: pvcName }, - }, - { name: 'archive', secret: { secretName } }, - ], + containers: [ + { + name: 'restore', + image: 'alpine:3.19', + command: ['sh', '-c', 'sleep 3600'], + volumeMounts: [{ name: 'wp-content', mountPath: '/wp-content' }], + resources: { + requests: { cpu: '100m', memory: '128Mi' }, + limits: { cpu: '500m', memory: '512Mi' }, + }, }, - }, + ], + volumes: [{ name: 'wp-content', persistentVolumeClaim: { claimName: pvcName } }], + restartPolicy: 'Never', }, }; - try { - await batchApi.createNamespacedJob({ namespace, body: job }); - } catch (e: any) { - try { - await coreApi.deleteNamespacedSecret({ name: secretName, namespace }); - } catch {} - return { - success: false, - logs: `Failed to create restore job: ${e.message}`, - }; - } + const tmpArchive = path.join(os.tmpdir(), `wp-content-restore-${ts}.tar.gz`); + const tmpKubeconfig = path.join(os.tmpdir(), `kubeconfig-wprestore-${ts}.yaml`); - // Wait - const timeout = 300_000; - const start = Date.now(); - let succeeded = false; - let failed = false; - while (Date.now() - start < timeout) { - await new Promise((r) => setTimeout(r, 3000)); - try { - const st = await batchApi.readNamespacedJob({ - name: jobName, - namespace, - }); - if (st.status?.succeeded && st.status.succeeded > 0) { - succeeded = true; - break; - } - if (st.status?.failed && st.status.failed > 0) { - failed = true; - break; - } - } catch {} - } - - let logs = ''; try { - const pods = await coreApi.listNamespacedPod({ - namespace, - labelSelector: `job-name=${jobName}`, - }); - if (pods.items.length > 0 && pods.items[0].metadata?.name) { - const logRes = await coreApi.readNamespacedPodLog({ - name: pods.items[0].metadata.name, - namespace, - }); - logs = logRes || ''; + fs.writeFileSync(tmpArchive, archiveBuffer); + fs.writeFileSync(tmpKubeconfig, kc.exportConfig()); + + await coreApi.createNamespacedPod({ namespace, body: helperPod }); + + // Wait for helper pod Running + const podTimeout = 120_000; + const podStart = Date.now(); + while (Date.now() - podStart < podTimeout) { + const pod = await coreApi.readNamespacedPod({ name: helperPodName, namespace }); + if (pod.status?.phase === 'Running') break; + if (pod.status?.phase === 'Failed') throw new Error('wp-content restore helper pod failed to start'); + await new Promise((r) => setTimeout(r, 2000)); } - } catch {} - try { - await coreApi.deleteNamespacedSecret({ name: secretName, namespace }); - } catch {} + await execFileAsync( + 'kubectl', + ['--kubeconfig', tmpKubeconfig, 'cp', tmpArchive, `${namespace}/${helperPodName}:/tmp/wp-content.tar.gz`, '--retries', '3'], + { maxBuffer: 50 * 1024 * 1024, timeout: 600_000 }, + ); - return { - success: succeeded && !failed, - logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out'), - }; + const { stdout, stderr } = await execFileAsync( + 'kubectl', + [ + '--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '--', + 'sh', '-c', + 'rm -rf /wp-content/* /wp-content/.[!.]* 2>/dev/null; tar xzf /tmp/wp-content.tar.gz -C /wp-content && echo RESTORE_DONE', + ], + { maxBuffer: 10 * 1024 * 1024, timeout: 600_000 }, + ); + + const logs = `${stdout || ''}${stderr || ''}`; + const success = logs.includes('RESTORE_DONE'); + return { success, logs: logs || (success ? 'Restore completed' : 'Restore failed') }; + } catch (e: any) { + this.logger.error(`wp-content restore failed for ${app.name}: ${e.message}`); + return { success: false, logs: e.message || 'wp-content restore failed' }; + } finally { + try { + fs.unlinkSync(tmpArchive); + } catch {} + try { + fs.unlinkSync(tmpKubeconfig); + } catch {} + try { + await coreApi.deleteNamespacedPod({ name: helperPodName, namespace }); + } catch {} + } } // ─── K8s Revision-based Rollback ───────────────────── @@ -4852,7 +4887,7 @@ export class KubernetesService implements OnModuleInit { }>; currentRevision: number; }> { - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const releaseName = app.name; try { @@ -4890,7 +4925,7 @@ export class KubernetesService implements OnModuleInit { * Rollback a Helm release to a specific revision. */ async rollbackDeploymentRevision(app: Application, targetRevision: number): Promise<{ success: boolean; message: string }> { - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const releaseName = app.name; try { diff --git a/backend/src/kubernetes/logs.controller.ts b/backend/src/kubernetes/logs.controller.ts index 2b0a3ea..9adb610 100644 --- a/backend/src/kubernetes/logs.controller.ts +++ b/backend/src/kubernetes/logs.controller.ts @@ -18,6 +18,7 @@ import { AuthGuard } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ElasticsearchService } from './elasticsearch.service'; +import { userNamespace } from './k8s-workload.util'; import { Application } from '../applications/entities/application.entity'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; @@ -225,7 +226,7 @@ export class LogsController { if (appFilters.applicationName) { filterParts.push(`applicationName:${appFilters.applicationName}`); } - filterParts.push(`namespace:user-${userId.split('-')[0]}`); + filterParts.push(`namespace:${userNamespace(userId)}`); const kibanaHost = connInfo.host.replace('elasticsearch', 'kibana'); const query = filterParts.length > 0 ? filterParts.join(' AND ') : '*'; diff --git a/backend/src/main.ts b/backend/src/main.ts index e871a6a..14243ce 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,5 +1,5 @@ -import { NestFactory } from '@nestjs/core'; -import { Logger, ValidationPipe } from '@nestjs/common'; +import { NestFactory, Reflector } from '@nestjs/core'; +import { ClassSerializerInterceptor, Logger, ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; import { AppModule } from './app.module'; @@ -47,22 +47,31 @@ async function bootstrap() { }), ); + // Strip @Exclude()-marked fields (e.g. gitToken) from serialized responses. + app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + // API prefix app.setGlobalPrefix('api/v1'); - // Swagger - const config = new DocumentBuilder() - .setTitle('CloudHost PaaS API') - .setDescription('Self-service PaaS platform API') - .setVersion('1.0') - .addBearerAuth() - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, document); + // Swagger — disabled in production unless explicitly opted in (SWAGGER_ENABLED=true) + const swaggerEnabled = + process.env.NODE_ENV !== 'production' || process.env.SWAGGER_ENABLED === 'true'; + if (swaggerEnabled) { + const config = new DocumentBuilder() + .setTitle('CloudHost PaaS API') + .setDescription('Self-service PaaS platform API') + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + } const port = process.env.PORT || 4000; await app.listen(port); console.log(`🚀 CloudHost API running on http://localhost:${port}`); - console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`); + if (swaggerEnabled) { + console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`); + } } bootstrap(); diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 53cd306..9ac024d 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, OneToMany, } from 'typeorm'; +import { Exclude } from 'class-transformer'; import { UserRole } from '../../common/enums'; import { Application } from '../../applications/entities/application.entity'; @@ -30,6 +31,8 @@ export class User { @Column({ default: false }) phoneVerified: boolean; + /** Bcrypt hash — never serialized into API responses. */ + @Exclude({ toPlainOnly: true }) @Column() password: string; diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 343ea6c..094816e 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -12,6 +12,7 @@ import * as bcrypt from 'bcrypt'; import { User } from './entities/user.entity'; import { UserRole } from '../common/enums'; import { normalizeIranMobile } from '../common/phone.util'; +import { userNamespace } from '../kubernetes/k8s-workload.util'; @Injectable() export class UsersService { @@ -22,9 +23,10 @@ export class UsersService { async create(data: Partial): Promise { const user = this.usersRepository.create(data); - // Assign a unique namespace based on user ID + // Assign a unique namespace based on the FULL user UUID (truncated + // prefixes collide between users and break tenant isolation). const saved = await this.usersRepository.save(user); - saved.namespace = `user-${saved.id.split('-')[0]}`; + saved.namespace = userNamespace(saved.id); return this.usersRepository.save(saved); } diff --git a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx index 559367a..d8810f0 100644 --- a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx @@ -1566,7 +1566,7 @@ export default function AppDetailPage() { {app.gitBranch} )} - {app.gitToken && ( + {(app.hasGitToken ?? app.gitToken) && ( {ad.private} )} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 365a887..c6c7b08 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -33,6 +33,8 @@ export interface Application { appStorageSize?: string; gitUrl?: string; gitToken?: string; + /** Server-provided indicator; raw gitToken is no longer returned by the API. */ + hasGitToken?: boolean; gitBranch?: string; codePath?: string; envVars?: Record; diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/scripts/audit-report.fa.html b/scripts/audit-report.fa.html new file mode 100644 index 0000000..7748442 --- /dev/null +++ b/scripts/audit-report.fa.html @@ -0,0 +1,317 @@ + + + + +گزارش بررسی فنی CloudHost + + + + +
+

گزارش بررسی فنی پلتفرم CloudHost

+

باگ‌ها، ریسک‌های پروداکشن و موارد بهبود — بیلد، دیتابیس‌ها، GitOps/CI-CD و امنیت اپلیکیشن

+

تاریخ: ۲ تیر ۱۴۰۴ (2 Jul 2026) · محدوده: کل مخزن cloud-host

+
+ +
+ BUG — قطعاً می‌شکند + SECURITY — حفره امنیتی + RISK — احتمال شکست در پروداکشن + IMPROVEMENT — بهبود +
+ +
+

خلاصه مدیریتی

+

پروژه معماری خوبی دارد اما در وضعیت فعلی آماده پروداکشن نیست. چند دسته مشکل بحرانی وجود دارد که یا هم‌اکنون باگ هستند یا حتماً در پروداکشن (به‌ویژه در شبکه ایران) می‌شکنند:

+
    +
  1. باگ‌های قطعی بیلد — برخی Dockerfileها اصلاً build نمی‌شوند (مثلاً Go).
  2. +
  3. باگ چرخه دوم آپگرید — سیستم migration در دومین helm upgrade قطعاً می‌شکند.
  4. +
  5. حفره‌های امنیتی مالی — کاربر می‌تواند کیف پول خود را رایگان شارژ کند و بدون پرداخت دیپلوی کند.
  6. +
  7. وابستگی به Docker Hub بدون آینه (mirror) برای ایمیج دیتابیس‌ها و base imageها.
  8. +
  9. چرخش رمز سرویس‌ها — رمز Redis/RabbitMQ در هر آپگرید عوض می‌شود و اتصال اپ قطع می‌شود.
  10. +
+
+ +
+

۱. فرایند بیلد اپلیکیشن‌ها (Kaniko + Dockerfile هر رانتایم)

+ +

باگ‌های قطعی

+ +
+
BUGGo — سینتکس نامعتبر COPY؛ هر بیلد Go خراب می‌شود
+

backend/src/build/build.service.ts:1311-1314 — دستور COPY ... 2>/dev/null || true از ریدایرکت شل پشتیبانی نمی‌کند؛ Kaniko این خطوط را رد می‌کند و بیلد هر اپ Go شکست می‌خورد.

+
+ +
+
BUGNode.js — شکست بیلد نادیده گرفته می‌شود
+

backend/src/build/build.service.ts:1034RUN npm run build || echo "..."؛ اگر بیلد خطا بدهد باز هم ایمیج ساخته می‌شود و اپ خراب دیپلوی می‌شود. کاربر «بیلد موفق» می‌بیند ولی اپ کار نمی‌کند.

+
+ +

ریسک‌های جدی

+ +
+
RISKBase imageها بدون آینه، از Docker Hub / GCR / MCR
+

همه رانتایم‌ها (node:, php:, python:, golang:, wordpress:) و ایمیج Kaniko و init pods (alpine:3.19, alpine/git) مستقیم از رجیستری‌های عمومی pull می‌شوند. آینه فقط برای استک لاگینگ تعریف شده (configuration.ts:151). در ایران بیشترین منبع شکست بیلد است.

+
+ +
+
RISKLaravel — نبود اکستنشن‌های ضروری PHP
+

backend/src/build/build.service.ts:1085 — فقط pdo, pdo_mysql, opcache نصب می‌شود؛ mbstring, xml, bcmath, zip, fileinfo, tokenizer که Laravel استاندارد لازم دارد نصب نمی‌شود.

+
+ +
+
RISKPython — پروژه‌های pyproject.toml پشتیبانی نمی‌شوند
+

backend/src/build/build.service.ts:1413 — تشخیص‌دهنده pyproject.toml را Python می‌شناسد ولی Dockerfile فقط requirements.txt نصب می‌کند؛ پروژه‌های Poetry/PDM فقط Flask+gunicorn پیش‌فرض می‌گیرند. اگر install خطا بدهد، fallback خاموش (2>/dev/null ||) اپ اشتباه بالا می‌آورد.

+
+ +
+
RISKحافظه Kaniko فقط ۴Gi و PVC بیلد بدون StorageClass
+

build.service.ts:588 بیلد Next.js/.NET/Composer اغلب بیشتر می‌خواهد → OOMKilled. build.service.ts:775 PVC بیلد storageClassName ندارد → در کلاستر بدون SC پیش‌فرض برای همیشه Pending می‌ماند. همچنین npm install --legacy-peer-deps به‌جای npm ci (خط ۱۰۱۸).

+
+ +

امنیت بیلد

+ +
+
SECURITYتوکن Git داخل spec پاد و تزریق دستور از branch
+

build.service.ts:498-523cloneUrl با توکن embed‌شده در command کانتینر → قابل دیدن در kubectl get pod -o yaml، etcd و audit log. همچنین ${branch} بدون کوت داخل شل → نامی مثل main; curl evil کد اجرا می‌کند. بدون اعتبارسنجی URL گیت (SSRF به IPهای داخلی کلاستر). خطر Zip slip / zip bomb در استخراج با unzip (خط ۴۶۲) با سقف آپلود ۱۰GiB.

+
+ +

پایداری فرایند

+ +
+
BUGری‌استارت backend وسط بیلد → deployment گیر می‌کند
+

build.service.ts:56 — state بیلد در Map حافظه است؛ بعد از ری‌استارت، Job روی کلاستر ادامه می‌دهد ولی deployment در وضعیت BUILDING گیر می‌کند و reconcile نمی‌شود. همچنین دیپلوی هم‌زمان برای یک اپ قفل ندارد و روی همان Helm release رقابت می‌کنند.

+
+
+ +
+

۲. پیش‌نمایش و دیپلوی

+

پیش‌نمایش با ساخت یک عدد ۷ رقمی پایدار برای هر اپ و host به‌شکل {userPrefix}-{previewNumber}.{previewRootDomain} کار می‌کند.

+ +
+
RISKبا ست‌شدن دامنه اختصاصی، پیش‌نمایش بلافاصله حذف می‌شود
+

kubernetes.service.ts:291 — حتی قبل از تأیید DNS؛ کاربر تا وریفای شدن دامنه هیچ آدرس قابل‌دسترسی ندارد. پیش‌نمایش نیازمند DNS wildcard فعال + cert-manager و مقدار PREVIEW_BASE_DOMAIN است.

+
+ +
+
RISKgetPreviewInfo روی هر فراخوانی Service را به NodePort پچ می‌کند
+

kubernetes.service.ts:2955 — عارضه جانبی که ممکن است اپ را ناخواسته روی IP نود باز کند.

+
+ +
+
BUGرجیستری per-cluster + fallback بین‌کلاستری → ImagePullBackOff
+

deployments.service.ts:360 — ایمیج روی رجیستری کلاستر A ساخته و push می‌شود، ولی deployWithClusterFallback می‌تواند روی کلاستر B دیپلوی کند که آن ایمیج را ندارد.

+
+
+ +
+

۳. دیتابیس‌ها و سرویس‌های اختیاری

+ +

باگ‌ها

+ +
+
BUGرمز Redis و RabbitMQ در هر helm upgrade عوض می‌شود
+

redis-deployment.yaml:18، rabbitmq-deployment.yaml:19randAlphaNum 16 بدون lookup هر بار مقدار جدید تولید می‌کند؛ resource-policy: keep فقط جلوی حذف را می‌گیرد نه تغییر. بعد از هر redeploy رمز عوض می‌شود ولی داده PVC رمز قدیمی دارد → قطع اتصال. الگوی درست در چارت پلتفرم (cloudhost-platform/templates/secret.yaml) با lookup موجود است.

+
+ +
+
BUGHealth probe رِدیس/مونگو بدون احراز هویت
+

redis-deployment.yaml:80redis-cli ping بدون -a؛ با --requirepass جواب NOAUTH → probe رد → CrashLoopBackOff. همین برای probe مونگو بدون credential.

+
+ +
+
BUGMongoDB در snapshot و wp-content restore پشتیبانی نمی‌شوند
+

kubernetes.service.ts:4389 — export/restore فقط Postgres و MySQL دارد؛ اپ Mongo dump خراب می‌گیرد. kubernetes.service.ts:4724 — restore محتوای wp-content از طریق Secret ذخیره می‌شود که محدودیت ~۱MiB دارد؛ هر wp-content واقعی بزرگ‌تر است → شکست.

+
+ +
+
BUGWordPress + PostgreSQL و WordPress بدون دیتابیس مجاز است
+

ایمیج رسمی وردپرس فقط MySQL/MariaDB را می‌شناسد ولی پلتفرم databaseType: postgresql یا حتی none را می‌پذیرد → سایت بالا نمی‌آید. باید هنگام رانتایم WordPress دیتابیس اجباراً MySQL شود.

+
+ +

ریسک‌ها

+
    +
  • RISK ایمیج همه سرویس‌ها از Docker Hub بدون مکانیزم آینه در چارت اپ (postgres:16-alpine, mysql:8.0, ...)؛ override database.image هست ولی backend هرگز آن را ست نمی‌کند.
  • +
  • RISK Deployment + PVC نوع RWO بدون strategy: Recreate برای دیتابیس/Redis/RabbitMQ → در آپگرید ایمیج پاد جدید منتظر ولوم می‌ماند.
  • +
  • RISK fallback تولید رمز DB (kubernetes.service.ts:333): اگر dbPassword خالی باشد هر دیپلوی رمز جدید می‌سازد و با داده قدیمی PVC ناسازگار می‌شود.
  • +
  • RISK خاموش‌کردن سرویس PVC یتیم جا می‌گذارد — کاربر آن‌ها را نمی‌بیند ولی هزینه استوریج ادامه دارد.
  • +
  • RISK دسترسی خارجی NodePort — host اشتباه (kubernetes.service.ts:2691): IP از API server گرفته می‌شود نه worker node؛ رشته اتصال بلااستفاده است. suspend هم گرنت‌های NodePort را باطل نمی‌کند.
  • +
+
+ +
+

۴. کنترل‌پلین، GitOps و CI/CD

+ +

باگ‌ها (باید قبل از دیپلوی بعدی رفع شوند)

+ +
+
BUGسیستم migration در آپگرید دوم می‌شکند
+

migrations-job.yaml:50-53 — Job همه فایل‌های SQL را در هر اجرا دوباره اجرا می‌کند بدون جدول ردیابی نسخه. 001_service_access_grants.sql:2,9 از CREATE TYPE بدون گارد استفاده می‌کند → آپگرید دوم: ERROR: type already exists → sync fail.

+
+ +
+
BUGmigration هوک بعد از دیپلوی backend اجرا می‌شود
+

migrations-job.yaml:10-11post-upgrade؛ backend جدید ممکن است قبل از آماده شدن اسکیما بالا بیاید → CrashLoop. باید pre-upgrade باشد.

+
+ +
+
BUGنبود base schema و نام ستون اشتباه در migration 015
+

هیچ SQL جدول‌های users/applications را نمی‌سازد؛ روی دیتابیس خالی اولین migration شکست می‌خورد. 015_application_product_type.sql:5-6 ستون user_id می‌سازد ولی entity آن را userId تعریف کرده (application.entity.ts:150) → ساخت ایندکس fail.

+
+ +

رمزهای هاردکد شده در گیت

+
+
SECURITYرمزهای الستیک‌سرچ در فایل commit‌شده
+

backend/k8s/logging/elasticsearch-stack.yaml:21-23ELASTIC_PASSWORD: "CloudHost2024!Secure" و FLUENTBIT_PASSWORD. باید rotate و از گیت خارج شوند. همین‌ها به‌عنوان default در configuration.ts:148-150 هستند و در validate-production بررسی نمی‌شوند.

+
+ +

ریسک‌های CI/CD و کنترل‌پلین

+
    +
  • RISK workflow کامیت‌شده auth کانیکو به Harbor و توکن clone ندارد (.gitea/workflows/build-deploy.yaml:74) → push/clone شکست می‌خورد؛ اصلاحات در تغییرات uncommit هستند.
  • +
  • RISK تست‌ها در مسیر Gitea اجرا نمی‌شوند (فقط GitHub Actions) → کد خراب می‌تواند به پروداکشن برسد.
  • +
  • RISK ایمیج backend حین بیلد Helm و kubectl را از اینترنت دانلود می‌کند (backend/Dockerfile:16-20) بدون پروکسی.
  • +
  • RISK git push بدون pull --rebase (workflow:177) → احتمال half-done deploy.
  • +
  • RISK postgres/redis پلتفرم در values-abrban.yaml آینه نشده و imagePullSecret ندارند.
  • +
  • RISK strategy: Recreate روی backend (backend-deployment.yaml:12) → داون‌تایم کامل API در هر دیپلوی.
  • +
  • RISK بدون resource limits در values پروداکشن → ریسک OOM روی k3s تک‌نود؛ Redis پلتفرم بدون requirepass؛ backup پستگرس خاموش.
  • +
  • RISK docker compose up --build کامل کار نمی‌کند — backend با NODE_ENV=productionsynchronize:false و بدون migration → جدول‌ها موجود نیست.
  • +
+
+ +
+

۵. امنیت و کیفیت کد اپلیکیشن

+ +

حفره‌های امنیتی بحرانی (P0)

+ +
+
SECURITYهر کاربر لاگین‌شده می‌تواند کیف پول خود را رایگان شارژ کند
+

billing-wallet.controller.ts:45-49POST /billing/wallet/charge بدون درگاه پرداخت مستقیم chargeWallet را صدا می‌زند → پول رایگان در پروداکشن. همچنین gateway/verify با PAYMENT_GATEWAY_STUB_ENABLED=true مبلغ دلخواه را می‌پذیرد.

+
+ +
+
SECURITYدور زدن بیلینگ در deploy / start / resources
+

deployments.service.ts:637 startDeployment اپ suspend‌شده را بدون بررسی وضعیت/کیف پول resume می‌کند. triggerDeployment (دیپلوی اول) گارد بیلینگ ندارد. applications.controller.ts:375 PATCH resources ارتقا را بدون مسیر پرداخت انجام می‌دهد.

+
+ +
+
SECURITYتداخل namespace بین کاربران (۸ کاراکتر اول UUID)
+

kubernetes.service.ts:2687-2689user-${userId.split('-')[0]}؛ دو کاربر با ۸ کاراکتر اول یکسان namespace مشترک و دسترسی به workload/secret همدیگر می‌گیرند. همین مشکل در ایزوله‌سازی لاگ الستیک (elasticsearch.service.ts:676).

+
+ +

امنیتی (P1)

+
    +
  • SECURITY gitToken و dbPassword در پاسخ API برمی‌گردند (application.entity.ts:54,114) — نیاز به @Exclude.
  • +
  • RISK عملیات کیف پول بدون transaction/lock (billing.service.ts:210) — کسر هم‌زمان می‌تواند overdraw کند.
  • +
  • RISK اسکنر auto-renew idempotent نیست بین رپلیکاها (app-lifecycle.service.ts:39) — دو پاد یک اپ را دوبار شارژ می‌کنند.
  • +
  • BUG proration ارتقا همیشه نرخ ساعتی را استفاده می‌کند (billing.service.ts:755) → ارتقای ماهانه/سالانه undercharge یا رایگان.
  • +
  • SECURITY توکن‌ها در localStorage (frontend/src/lib/store.ts:43) → در معرض XSS.
  • +
  • SECURITY refresh token بدون rotation/ابطال و context جعل هویت روی refresh دوباره اعتبارسنجی نمی‌شود (auth.service.ts:165).
  • +
+ +

ریسک‌های متوسط

+
    +
  • RISK OTP با Math.random() به‌جای CSPRNG (verification.service.ts:188) و race در مصرف OTP (خط ۲۲۵).
  • +
  • RISK Swagger بی‌قید در پروداکشن باز است (main.ts:53).
  • +
  • RISK secretهای پیش‌فرض ضعیف خارج از پروداکشن (configuration.ts:75default-jwt-secret).
  • +
+
+ +
+

اولویت‌بندی برای پروداکشن

+ +
+

باید قبل از هر دیپلوی پروداکشن رفع شود (بلاکر)

+
    +
  1. حذف/گیت کردن POST /billing/wallet/charge پشت درگاه پرداخت واقعی.
  2. +
  3. گارد بیلینگ روی triggerDeployment، startDeployment و PATCH resources.
  4. +
  5. ساخت namespace از کل UUID، نه ۸ کاراکتر اول (تداخل بین‌مستأجری).
  6. +
  7. سیستم migration: جدول ردیابی نسخه یا SQL کاملاً idempotent + هوک pre-upgrade + base schema برای نصب تازه.
  8. +
  9. اصلاح 015 (user_iduserId) و گارد duplicate_object برای CREATE TYPE در 001.
  10. +
  11. commit و deploy اصلاحات uncommit شده workflow (توکن Gitea + auth Harbor کانیکو).
  12. +
  13. rotate کردن رمزهای هاردکد الستیک‌سرچ.
  14. +
  15. رفع سینتکس COPY در Dockerfile گو و حذف || echo از بیلد Node.
  16. +
+
+ + + + + + + + + + + + + + + + + +
اولویتاقدام
۹الگوی lookup برای رمز Redis/RabbitMQ (توقف چرخش رمز).
۱۰probe رِدیس/مونگو با احراز هویت.
۱۱آینه‌کردن base imageهای بیلد + ایمیج دیتابیس‌ها برای شبکه ایران.
۱۲transaction/lock روی عملیات کیف پول.
۱۳strategy: Recreate روی سرویس‌های stateful و RollingUpdate روی backend.
۱۴رفع ImagePullBackOff در fallback بین‌کلاستری.
۱۵حذف gitToken/dbPassword از پاسخ‌ها با @Exclude.
۱۶اعتبارسنجی و کوت gitBranch، انتقال توکن گیت به Secret.
۱۷پشتیبانی MongoDB در snapshot، restore وردپرس از PVC به‌جای Secret.
۱۸اجبار MySQL برای رانتایم WordPress.
۱۹اجرای تست در مسیر Gitea قبل از دیپلوی.
۲۰resource limits و backup پستگرس روی کنترل‌پلین.
+
+ + + + + diff --git a/scripts/generate-audit-pdf.mjs b/scripts/generate-audit-pdf.mjs new file mode 100644 index 0000000..fd1092f --- /dev/null +++ b/scripts/generate-audit-pdf.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import puppeteer from 'puppeteer-core'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +const htmlPath = path.join(__dirname, 'audit-report.fa.html'); +const pdfPath = path.join(root, 'AUDIT-REPORT.fa.pdf'); + +const chromePaths = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', + '/usr/bin/chromium', +]; + +const executablePath = process.env.CHROME_PATH || chromePaths.find((p) => fs.existsSync(p)); + +if (!executablePath) { + console.error('Chrome/Chromium not found. Install Google Chrome or set CHROME_PATH.'); + process.exit(1); +} + +const browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], +}); +const page = await browser.newPage(); +await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' }); +await page.pdf({ + path: pdfPath, + format: 'A4', + printBackground: true, + margin: { top: '14mm', right: '13mm', bottom: '14mm', left: '13mm' }, +}); +await browser.close(); +console.log(`Created: ${pdfPath}`); diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..31d161e --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "cloudhost-pdf-scripts", + "private": true, + "type": "module", + "dependencies": { + "puppeteer-core": "^24.0.0" + } +}