From bf9e827f85f0f465e2a3209637d95139ccac8642 Mon Sep 17 00:00:00 2001 From: keyhan Date: Sun, 24 May 2026 19:01:05 +0330 Subject: [PATCH] Add cloudhost-platform Helm chart and registry ingress manifests. Deploy backend, frontend, PostgreSQL, and Redis on Kubernetes with optional Ingress/TLS, SQL migration hooks, and public registry exposure at repo.3fase.ir. Co-authored-by: Cursor --- ARCHITECTURE.md | 15 +++ CHANGELOG.md | 6 + README.md | 27 +++- backend/helm/cloudhost-platform/.helmignore | 5 + backend/helm/cloudhost-platform/Chart.yaml | 6 + backend/helm/cloudhost-platform/README.md | 37 ++++++ .../migrations/001_service_access_grants.sql | 40 ++++++ .../002_application_docked_lifecycle.sql | 6 + .../migrations/003_resource_credits.sql | 24 ++++ .../migrations/004_pricing_catalog.sql | 117 ++++++++++++++++++ .../005_pricing_catalog_all_runtimes.sql | 24 ++++ .../migrations/006_addon_rate_resources.sql | 27 ++++ .../007_optional_service_pricing_matrix.sql | 98 +++++++++++++++ ...application_optional_service_resources.sql | 4 + .../009_optional_service_deploy_defaults.sql | 14 +++ .../migrations/010_invoices.sql | 52 ++++++++ .../011_deployment_cancelled_status.sql | 10 ++ .../012_cluster_pool_allocation.sql | 113 +++++++++++++++++ .../migrations/014_application_migrations.sql | 36 ++++++ .../015_application_product_type.sql | 9 ++ .../016_service_access_persistent.sql | 2 + .../migrations/017_snapshot_progress.sql | 2 + .../cloudhost-platform/templates/NOTES.txt | 37 ++++++ .../cloudhost-platform/templates/_helpers.tpl | 96 ++++++++++++++ .../templates/backend-deployment.yaml | 116 +++++++++++++++++ .../templates/backend-pvc.yaml | 19 +++ .../templates/backend-service.yaml | 18 +++ .../templates/frontend-deployment.yaml | 47 +++++++ .../templates/frontend-service.yaml | 18 +++ .../cloudhost-platform/templates/ingress.yaml | 73 +++++++++++ .../templates/migrations-configmap.yaml | 11 ++ .../templates/migrations-job.yaml | 62 ++++++++++ .../templates/namespace.yaml | 8 ++ .../templates/postgres-deployment.yaml | 66 ++++++++++ .../templates/postgres-pvc.yaml | 19 +++ .../templates/postgres-service.yaml | 18 +++ .../templates/redis-deployment.yaml | 52 ++++++++ .../templates/redis-pvc.yaml | 19 +++ .../templates/redis-service.yaml | 18 +++ .../cloudhost-platform/templates/secret.yaml | 25 ++++ .../cloudhost-platform/values-images.yaml | 17 +++ .../values-production.example.yaml | 44 +++++++ backend/helm/cloudhost-platform/values.yaml | 93 ++++++++++++++ frontend/Dockerfile | 2 + frontend/public/.gitkeep | 0 k8s/registry-ingress/cluster-issuer.yaml | 14 +++ k8s/registry-ingress/ingress.yaml | 26 ++++ k8s/registry-ingress/registry-proxy-env.yaml | 22 ++++ k8s/registry-ingress/traefik-middleware.yaml | 23 ++++ .../traefik-timeout-values.yaml | 14 +++ 50 files changed, 1649 insertions(+), 2 deletions(-) create mode 100644 backend/helm/cloudhost-platform/.helmignore create mode 100644 backend/helm/cloudhost-platform/Chart.yaml create mode 100644 backend/helm/cloudhost-platform/README.md create mode 100644 backend/helm/cloudhost-platform/migrations/001_service_access_grants.sql create mode 100644 backend/helm/cloudhost-platform/migrations/002_application_docked_lifecycle.sql create mode 100644 backend/helm/cloudhost-platform/migrations/003_resource_credits.sql create mode 100644 backend/helm/cloudhost-platform/migrations/004_pricing_catalog.sql create mode 100644 backend/helm/cloudhost-platform/migrations/005_pricing_catalog_all_runtimes.sql create mode 100644 backend/helm/cloudhost-platform/migrations/006_addon_rate_resources.sql create mode 100644 backend/helm/cloudhost-platform/migrations/007_optional_service_pricing_matrix.sql create mode 100644 backend/helm/cloudhost-platform/migrations/008_application_optional_service_resources.sql create mode 100644 backend/helm/cloudhost-platform/migrations/009_optional_service_deploy_defaults.sql create mode 100644 backend/helm/cloudhost-platform/migrations/010_invoices.sql create mode 100644 backend/helm/cloudhost-platform/migrations/011_deployment_cancelled_status.sql create mode 100644 backend/helm/cloudhost-platform/migrations/012_cluster_pool_allocation.sql create mode 100644 backend/helm/cloudhost-platform/migrations/014_application_migrations.sql create mode 100644 backend/helm/cloudhost-platform/migrations/015_application_product_type.sql create mode 100644 backend/helm/cloudhost-platform/migrations/016_service_access_persistent.sql create mode 100644 backend/helm/cloudhost-platform/migrations/017_snapshot_progress.sql create mode 100644 backend/helm/cloudhost-platform/templates/NOTES.txt create mode 100644 backend/helm/cloudhost-platform/templates/_helpers.tpl create mode 100644 backend/helm/cloudhost-platform/templates/backend-deployment.yaml create mode 100644 backend/helm/cloudhost-platform/templates/backend-pvc.yaml create mode 100644 backend/helm/cloudhost-platform/templates/backend-service.yaml create mode 100644 backend/helm/cloudhost-platform/templates/frontend-deployment.yaml create mode 100644 backend/helm/cloudhost-platform/templates/frontend-service.yaml create mode 100644 backend/helm/cloudhost-platform/templates/ingress.yaml create mode 100644 backend/helm/cloudhost-platform/templates/migrations-configmap.yaml create mode 100644 backend/helm/cloudhost-platform/templates/migrations-job.yaml create mode 100644 backend/helm/cloudhost-platform/templates/namespace.yaml create mode 100644 backend/helm/cloudhost-platform/templates/postgres-deployment.yaml create mode 100644 backend/helm/cloudhost-platform/templates/postgres-pvc.yaml create mode 100644 backend/helm/cloudhost-platform/templates/postgres-service.yaml create mode 100644 backend/helm/cloudhost-platform/templates/redis-deployment.yaml create mode 100644 backend/helm/cloudhost-platform/templates/redis-pvc.yaml create mode 100644 backend/helm/cloudhost-platform/templates/redis-service.yaml create mode 100644 backend/helm/cloudhost-platform/templates/secret.yaml create mode 100644 backend/helm/cloudhost-platform/values-images.yaml create mode 100644 backend/helm/cloudhost-platform/values-production.example.yaml create mode 100644 backend/helm/cloudhost-platform/values.yaml create mode 100644 frontend/public/.gitkeep create mode 100644 k8s/registry-ingress/cluster-issuer.yaml create mode 100644 k8s/registry-ingress/ingress.yaml create mode 100644 k8s/registry-ingress/registry-proxy-env.yaml create mode 100644 k8s/registry-ingress/traefik-middleware.yaml create mode 100644 k8s/registry-ingress/traefik-timeout-values.yaml diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2f86985..732917c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -177,6 +177,20 @@ ACTIVE ──(expires)──► SUSPENDED ──(grace)──► PENDING_DELETIO --- +## Helm Chart: cloudhost-platform + +Chart at `backend/helm/cloudhost-platform/` deploys the **control plane** (NestJS API, Next.js UI, PostgreSQL, Redis) into a dedicated namespace (default `cloudhost`). + +| Value | Purpose | +|-------|---------| +| `ingress.enabled` | Create Ingress (default `true`) | +| `ingress.tls.enabled` | cert-manager TLS via `clusterIssuer` | +| `ingress.frontend.host` / `ingress.api.host` | Public hostnames | +| `postgres.password` / `secrets.jwtSecret` | Credentials (auto-generated if empty on first install) | +| `migrations.enabled` | Post-install SQL migration Job | + +--- + ## 🚀 Helm Chart: cloudhost-app Single chart at `backend/helm/cloudhost-app/` handles all runtimes: @@ -208,6 +222,7 @@ host/ ├── backend/ │ ├── Dockerfile │ ├── package.json +│ ├── helm/cloudhost-platform/ # Helm chart for control plane │ ├── helm/cloudhost-app/ # Helm chart for user apps │ ├── src/ │ │ ├── main.ts / app.module.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 254f1b3..b076c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +#### Helm Chart (`backend/helm/cloudhost-platform/`) +- **Platform chart** — deploy control plane (backend, frontend, PostgreSQL, Redis) on Kubernetes +- Ingress with `ingress.enabled` / `ingress.tls.enabled` and cert-manager TLS +- Post-install migration Job for SQL files in `migrations/` +- `values-production.example.yaml` for production overrides + #### Helm Chart (`backend/helm/cloudhost-app/`) - **New Helm chart** replacing legacy Handlebars templates for all deployments - `deployment.yaml` — app deployment with `imagePullSecrets`, `imagePullPolicy: Always`, health probes diff --git a/README.md b/README.md index 74b6fd1..820d0b4 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ host/ │ ├── Dockerfile │ ├── package.json │ ├── helm/ -│ │ └── cloudhost-app/ # Helm chart (all runtimes) +│ │ ├── cloudhost-platform/ # Helm chart (control plane) +│ │ └── cloudhost-app/ # Helm chart (user apps) │ │ ├── Chart.yaml │ │ ├── values.yaml │ │ └── templates/ # K8s manifest templates @@ -147,7 +148,29 @@ docker compose up --build Backend at port 4000, Frontend at port 3000. -### 4. Run Locally (development) +### 4. Deploy Platform on Kubernetes (Helm) + +Prerequisites: NGINX Ingress, cert-manager (if TLS enabled), StorageClass for PVCs. + +```bash +# Build images (set API URL to match ingress.api.host when TLS is on) +export REG=your-registry.example.com +docker build -t $REG/cloudhost-backend:latest ./backend +docker build -t $REG/cloudhost-frontend:latest \ + --build-arg NEXT_PUBLIC_API_URL=https://api.platform.example.com ./frontend +docker push $REG/cloudhost-backend:latest $REG/cloudhost-frontend:latest + +# Install (copy and edit values-production.example.yaml first) +helm upgrade --install cloudhost ./backend/helm/cloudhost-platform \ + -n cloudhost --create-namespace \ + -f backend/helm/cloudhost-platform/values-production.example.yaml +``` + +Key values: `ingress.enabled`, `ingress.tls.enabled`, `ingress.frontend.host`, `ingress.api.host`, `postgres.password`, `secrets.jwtSecret`. + +See chart defaults in `backend/helm/cloudhost-platform/values.yaml` and post-install notes via `helm get notes cloudhost -n cloudhost`. + +### 5. Run Locally (development) ```bash # Terminal 1 — Backend diff --git a/backend/helm/cloudhost-platform/.helmignore b/backend/helm/cloudhost-platform/.helmignore new file mode 100644 index 0000000..77404f5 --- /dev/null +++ b/backend/helm/cloudhost-platform/.helmignore @@ -0,0 +1,5 @@ +# Patterns to ignore when packaging chart +.DS_Store +*.swp +*.bak +values-production.yaml diff --git a/backend/helm/cloudhost-platform/Chart.yaml b/backend/helm/cloudhost-platform/Chart.yaml new file mode 100644 index 0000000..8151e0e --- /dev/null +++ b/backend/helm/cloudhost-platform/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: cloudhost-platform +description: CloudHost control plane — backend, frontend, PostgreSQL, Redis +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/backend/helm/cloudhost-platform/README.md b/backend/helm/cloudhost-platform/README.md new file mode 100644 index 0000000..a8fcaba --- /dev/null +++ b/backend/helm/cloudhost-platform/README.md @@ -0,0 +1,37 @@ +# cloudhost-platform + +Helm chart for the CloudHost control plane: backend API, frontend UI, PostgreSQL, and Redis. + +## Quick install + +```bash +helm upgrade --install cloudhost . \ + -n cloudhost --create-namespace \ + -f values-production.example.yaml +``` + +## Ingress / TLS + +| Value | Description | +|-------|-------------| +| `ingress.enabled` | Create Ingress resources | +| `ingress.tls.enabled` | Enable cert-manager TLS | +| `ingress.tls.clusterIssuer` | ClusterIssuer name (e.g. `letsencrypt-prod`) | +| `ingress.frontend.host` | UI hostname | +| `ingress.api.host` | API hostname | + +Requires NGINX Ingress Controller and cert-manager when TLS is enabled. + +## Frontend image + +Build with the public API URL baked in: + +```bash +docker build -t $REG/cloudhost-frontend:tag \ + --build-arg NEXT_PUBLIC_API_URL=https://api.platform.example.com \ + ../../frontend +``` + +## Migrations + +SQL files in `migrations/` run via a post-install/post-upgrade Job when `migrations.enabled` is true. They assume incremental schema changes on top of an existing database; for a completely empty database you may need to bootstrap schema first (e.g. one-time `NODE_ENV=development` or manual setup). diff --git a/backend/helm/cloudhost-platform/migrations/001_service_access_grants.sql b/backend/helm/cloudhost-platform/migrations/001_service_access_grants.sql new file mode 100644 index 0000000..d2c6585 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/001_service_access_grants.sql @@ -0,0 +1,40 @@ +-- Temporary external access grants (Redis, RabbitMQ, database) +CREATE TYPE service_access_target AS ENUM ( + 'database', + 'redis', + 'rabbitmq_amqp', + 'rabbitmq_management' +); + +CREATE TYPE service_access_grant_status AS ENUM ( + 'active', + 'expired', + 'revoked' +); + +CREATE TABLE IF NOT EXISTS service_access_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "applicationId" UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + "userId" UUID NOT NULL, + "clusterId" UUID NOT NULL, + namespace VARCHAR(255) NOT NULL, + target service_access_target NOT NULL, + "nodePort" INTEGER NOT NULL, + "targetPort" INTEGER NOT NULL, + host VARCHAR(255) NOT NULL, + "k8sServiceName" VARCHAR(255) NOT NULL, + status service_access_grant_status NOT NULL DEFAULT 'active', + "expiresAt" TIMESTAMPTZ NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_service_access_grants_app_target_status + ON service_access_grants ("applicationId", target, status); + +INSERT INTO platform_settings (id, key, value, description, "createdAt", "updatedAt") +SELECT gen_random_uuid(), 'access_max_duration_minutes', '240', + 'Maximum duration (minutes) for temporary external service access', + NOW(), NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM platform_settings WHERE key = 'access_max_duration_minutes' +); diff --git a/backend/helm/cloudhost-platform/migrations/002_application_docked_lifecycle.sql b/backend/helm/cloudhost-platform/migrations/002_application_docked_lifecycle.sql new file mode 100644 index 0000000..6432b6a --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/002_application_docked_lifecycle.sql @@ -0,0 +1,6 @@ +-- Add DOCKED lifecycle status and dock metadata (run if TypeORM sync is disabled) +ALTER TYPE applications_lifecyclestatus_enum ADD VALUE IF NOT EXISTS 'docked'; + +ALTER TABLE applications + ADD COLUMN IF NOT EXISTS "dockedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "dockSnapshotId" VARCHAR; diff --git a/backend/helm/cloudhost-platform/migrations/003_resource_credits.sql b/backend/helm/cloudhost-platform/migrations/003_resource_credits.sql new file mode 100644 index 0000000..85f6b09 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/003_resource_credits.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS resource_credits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + "sourceAppName" VARCHAR, + runtime VARCHAR NOT NULL, + "databaseType" VARCHAR NOT NULL, + "cpuLimit" VARCHAR NOT NULL, + "memoryLimit" VARCHAR NOT NULL, + replicas INT NOT NULL DEFAULT 1, + "dbStorageSize" VARCHAR, + "appStorageSize" VARCHAR, + "enableRedis" BOOLEAN NOT NULL DEFAULT false, + "enableRabbitmq" BOOLEAN NOT NULL DEFAULT false, + "enableElasticsearch" BOOLEAN NOT NULL DEFAULT false, + "billingCycle" VARCHAR, + "expiresAt" TIMESTAMPTZ NOT NULL, + "consumedAt" TIMESTAMPTZ, + "appliedApplicationId" VARCHAR, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_resource_credits_user_active + ON resource_credits ("userId", "expiresAt") + WHERE "consumedAt" IS NULL; diff --git a/backend/helm/cloudhost-platform/migrations/004_pricing_catalog.sql b/backend/helm/cloudhost-platform/migrations/004_pricing_catalog.sql new file mode 100644 index 0000000..995d483 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/004_pricing_catalog.sql @@ -0,0 +1,117 @@ +-- Usage-based pricing catalog (replaces per-cycle service_plans + scattered platform_settings) + +CREATE TABLE IF NOT EXISTS pricing_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + runtime VARCHAR NOT NULL, + resource_type VARCHAR NOT NULL, + hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (runtime, resource_type) +); + +CREATE TABLE IF NOT EXISTS addon_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_type VARCHAR NOT NULL UNIQUE, + hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed addon rows +INSERT INTO addon_rates (resource_type, hourly_price, monthly_price, yearly_price) +VALUES + ('redis_addon', 0, 0, 0), + ('rabbitmq_addon', 0, 0, 0), + ('elasticsearch_addon', 0, 0, 0), + ('custom_domain_addon', 0, 0, 0) +ON CONFLICT (resource_type) DO NOTHING; + +-- Migrate runtime rates from service_plans + pricing_rules +INSERT INTO pricing_rates (runtime, resource_type, hourly_price, monthly_price, yearly_price, is_active) +SELECT + sp.runtime, + pr.resource_type, + COALESCE(MAX(CASE WHEN sp."billingCycle" = 'hourly' THEN pr.unit_price END), 0), + COALESCE(MAX(CASE WHEN sp."billingCycle" = 'monthly' THEN pr.unit_price END), 0), + COALESCE(MAX(CASE WHEN sp."billingCycle" = 'yearly' THEN pr.unit_price END), 0), + BOOL_OR(sp."isActive") +FROM pricing_rules pr +JOIN service_plans sp ON sp.id = pr."planId" +WHERE pr.resource_type IN ( + 'base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon' +) +GROUP BY sp.runtime, pr.resource_type +ON CONFLICT (runtime, resource_type) DO UPDATE SET + hourly_price = EXCLUDED.hourly_price, + monthly_price = EXCLUDED.monthly_price, + yearly_price = EXCLUDED.yearly_price, + is_active = EXCLUDED.is_active, + "updatedAt" = NOW(); + +-- Optional services from platform_settings JSON +UPDATE addon_rates ar SET + hourly_price = COALESCE((s.parsed->'redis'->>'hourly')::decimal, 0), + monthly_price = COALESCE((s.parsed->'redis'->>'monthly')::decimal, 0), + yearly_price = COALESCE((s.parsed->'redis'->>'yearly')::decimal, 0) +FROM ( + SELECT value::jsonb AS parsed + FROM platform_settings + WHERE key = 'optional_services_pricing_toman' + LIMIT 1 +) s +WHERE ar.resource_type = 'redis_addon' AND s.parsed IS NOT NULL; + +UPDATE addon_rates ar SET + hourly_price = COALESCE((s.parsed->'rabbitmq'->>'hourly')::decimal, 0), + monthly_price = COALESCE((s.parsed->'rabbitmq'->>'monthly')::decimal, 0), + yearly_price = COALESCE((s.parsed->'rabbitmq'->>'yearly')::decimal, 0) +FROM ( + SELECT value::jsonb AS parsed + FROM platform_settings + WHERE key = 'optional_services_pricing_toman' + LIMIT 1 +) s +WHERE ar.resource_type = 'rabbitmq_addon' AND s.parsed IS NOT NULL; + +UPDATE addon_rates ar SET + hourly_price = COALESCE((s.parsed->'elasticsearch'->>'hourly')::decimal, 0), + monthly_price = COALESCE((s.parsed->'elasticsearch'->>'monthly')::decimal, 0), + yearly_price = COALESCE((s.parsed->'elasticsearch'->>'yearly')::decimal, 0) +FROM ( + SELECT value::jsonb AS parsed + FROM platform_settings + WHERE key = 'optional_services_pricing_toman' + LIMIT 1 +) s +WHERE ar.resource_type = 'elasticsearch_addon' AND s.parsed IS NOT NULL; + +UPDATE addon_rates ar SET + monthly_price = COALESCE(s.price, 0) +FROM ( + SELECT value::decimal AS price + FROM platform_settings + WHERE key = 'custom_domain_monthly_price_toman' + LIMIT 1 +) s +WHERE ar.resource_type = 'custom_domain_addon' AND s.price IS NOT NULL; + +-- Default runtime rows for nodejs, laravel, wordpress +INSERT INTO pricing_rates (runtime, resource_type, hourly_price, monthly_price, yearly_price) +SELECT r.runtime, t.resource_type, 0, 0, 0 +FROM (VALUES ('nodejs'), ('laravel'), ('wordpress')) AS r(runtime) +CROSS JOIN ( + VALUES + ('base_fee'), + ('cpu_per_core'), + ('memory_per_gb'), + ('storage_per_gb'), + ('database_addon') +) AS t(resource_type) +ON CONFLICT (runtime, resource_type) DO NOTHING; diff --git a/backend/helm/cloudhost-platform/migrations/005_pricing_catalog_all_runtimes.sql b/backend/helm/cloudhost-platform/migrations/005_pricing_catalog_all_runtimes.sql new file mode 100644 index 0000000..1af68ab --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/005_pricing_catalog_all_runtimes.sql @@ -0,0 +1,24 @@ +-- Ensure pricing_rates rows exist for every AppRuntime enum value + +INSERT INTO pricing_rates (runtime, resource_type, hourly_price, monthly_price, yearly_price) +SELECT r.runtime, t.resource_type, 0, 0, 0 +FROM ( + VALUES + ('nodejs'), + ('laravel'), + ('wordpress'), + ('go'), + ('php'), + ('python'), + ('django'), + ('dotnet') +) AS r(runtime) +CROSS JOIN ( + VALUES + ('base_fee'), + ('cpu_per_core'), + ('memory_per_gb'), + ('storage_per_gb'), + ('database_addon') +) AS t(resource_type) +ON CONFLICT (runtime, resource_type) DO NOTHING; diff --git a/backend/helm/cloudhost-platform/migrations/006_addon_rate_resources.sql b/backend/helm/cloudhost-platform/migrations/006_addon_rate_resources.sql new file mode 100644 index 0000000..c86c060 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/006_addon_rate_resources.sql @@ -0,0 +1,27 @@ +-- Per-addon consumption footprint and optional resource unit prices (0 = use app runtime rates) + +ALTER TABLE addon_rates + ADD COLUMN IF NOT EXISTS cpu_limit VARCHAR, + ADD COLUMN IF NOT EXISTS memory_limit VARCHAR, + ADD COLUMN IF NOT EXISTS storage_gi DECIMAL(10, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS log_shipper_cpu_limit VARCHAR, + ADD COLUMN IF NOT EXISTS log_shipper_memory_limit VARCHAR, + ADD COLUMN IF NOT EXISTS cpu_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS cpu_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS cpu_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS memory_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS memory_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS memory_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS storage_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS storage_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS storage_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0; + +UPDATE addon_rates SET cpu_limit = '200m', memory_limit = '256Mi', storage_gi = 1 +WHERE resource_type = 'redis_addon' AND cpu_limit IS NULL; + +UPDATE addon_rates SET cpu_limit = '500m', memory_limit = '512Mi', storage_gi = 2 +WHERE resource_type = 'rabbitmq_addon' AND cpu_limit IS NULL; + +UPDATE addon_rates SET cpu_limit = '50m', memory_limit = '64Mi', storage_gi = 0, + log_shipper_cpu_limit = '50m', log_shipper_memory_limit = '64Mi' +WHERE resource_type = 'elasticsearch_addon' AND cpu_limit IS NULL; diff --git a/backend/helm/cloudhost-platform/migrations/007_optional_service_pricing_matrix.sql b/backend/helm/cloudhost-platform/migrations/007_optional_service_pricing_matrix.sql new file mode 100644 index 0000000..f9ddc33 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/007_optional_service_pricing_matrix.sql @@ -0,0 +1,98 @@ +-- Optional services: resource profile + pricing matrix (same model as application runtimes) + +CREATE TABLE IF NOT EXISTS optional_service_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service VARCHAR NOT NULL UNIQUE, + cpu_limit VARCHAR NOT NULL DEFAULT '200m', + memory_limit VARCHAR NOT NULL DEFAULT '256Mi', + storage_gi DECIMAL(10, 2) NOT NULL DEFAULT 0, + log_shipper_cpu_limit VARCHAR, + log_shipper_memory_limit VARCHAR, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS optional_service_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service VARCHAR NOT NULL, + resource_type VARCHAR NOT NULL, + hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (service, resource_type) +); + +INSERT INTO optional_service_profiles (service, cpu_limit, memory_limit, storage_gi, log_shipper_cpu_limit, log_shipper_memory_limit) +VALUES + ('redis', '200m', '256Mi', 1, NULL, NULL), + ('rabbitmq', '500m', '512Mi', 2, NULL, NULL), + ('elasticsearch', '50m', '64Mi', 0, '50m', '64Mi') +ON CONFLICT (service) DO NOTHING; + +UPDATE optional_service_profiles SET + log_shipper_cpu_limit = '50m', + log_shipper_memory_limit = '64Mi' +WHERE service = 'elasticsearch' AND log_shipper_cpu_limit IS NULL; + +INSERT INTO optional_service_rates (service, resource_type, hourly_price, monthly_price, yearly_price) +SELECT s.service, t.resource_type, 0, 0, 0 +FROM (VALUES ('redis'), ('rabbitmq'), ('elasticsearch')) AS s(service) +CROSS JOIN ( + VALUES ('base_fee'), ('cpu_per_core'), ('memory_per_gb'), ('storage_per_gb') +) AS t(resource_type) +ON CONFLICT (service, resource_type) DO NOTHING; + +-- Migrate legacy per-resource addon prices into optional_service_rates (if columns exist) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'addon_rates' AND column_name = 'cpu_hourly_price' + ) THEN + UPDATE optional_service_rates osr SET + hourly_price = COALESCE(ar.cpu_hourly_price, 0), + monthly_price = COALESCE(ar.cpu_monthly_price, 0), + yearly_price = COALESCE(ar.cpu_yearly_price, 0) + FROM addon_rates ar + WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'cpu_per_core' + AND (ar.cpu_hourly_price > 0 OR ar.cpu_monthly_price > 0); + + UPDATE optional_service_rates osr SET + hourly_price = COALESCE(ar.memory_hourly_price, 0), + monthly_price = COALESCE(ar.memory_monthly_price, 0), + yearly_price = COALESCE(ar.memory_yearly_price, 0) + FROM addon_rates ar + WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'memory_per_gb' + AND (ar.memory_hourly_price > 0 OR ar.memory_monthly_price > 0); + + UPDATE optional_service_rates osr SET + hourly_price = COALESCE(ar.storage_hourly_price, 0), + monthly_price = COALESCE(ar.storage_monthly_price, 0), + yearly_price = COALESCE(ar.storage_yearly_price, 0) + FROM addon_rates ar + WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'storage_per_gb' + AND (ar.storage_hourly_price > 0 OR ar.storage_monthly_price > 0); + + UPDATE optional_service_profiles osp SET + cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit), + memory_limit = COALESCE(ar.memory_limit, osp.memory_limit), + storage_gi = COALESCE(ar.storage_gi, osp.storage_gi) + FROM addon_rates ar WHERE ar.resource_type = 'redis_addon' AND osp.service = 'redis'; + + UPDATE optional_service_profiles osp SET + cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit), + memory_limit = COALESCE(ar.memory_limit, osp.memory_limit), + storage_gi = COALESCE(ar.storage_gi, osp.storage_gi) + FROM addon_rates ar WHERE ar.resource_type = 'rabbitmq_addon' AND osp.service = 'rabbitmq'; + + UPDATE optional_service_profiles osp SET + cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit), + memory_limit = COALESCE(ar.memory_limit, osp.memory_limit), + log_shipper_cpu_limit = COALESCE(ar.log_shipper_cpu_limit, osp.log_shipper_cpu_limit), + log_shipper_memory_limit = COALESCE(ar.log_shipper_memory_limit, osp.log_shipper_memory_limit) + FROM addon_rates ar WHERE ar.resource_type = 'elasticsearch_addon' AND osp.service = 'elasticsearch'; + END IF; +END $$; diff --git a/backend/helm/cloudhost-platform/migrations/008_application_optional_service_resources.sql b/backend/helm/cloudhost-platform/migrations/008_application_optional_service_resources.sql new file mode 100644 index 0000000..742a1ca --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/008_application_optional_service_resources.sql @@ -0,0 +1,4 @@ +-- Per-deploy optional service resource limits (user-chosen, like application CPU/RAM/storage) + +ALTER TABLE applications + ADD COLUMN IF NOT EXISTS optional_service_resources JSONB; diff --git a/backend/helm/cloudhost-platform/migrations/009_optional_service_deploy_defaults.sql b/backend/helm/cloudhost-platform/migrations/009_optional_service_deploy_defaults.sql new file mode 100644 index 0000000..7d652d1 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/009_optional_service_deploy_defaults.sql @@ -0,0 +1,14 @@ +-- Deploy-wizard default requests (TypeORM camelCase column names) + +ALTER TABLE optional_service_profiles + ADD COLUMN IF NOT EXISTS "cpuRequest" VARCHAR, + ADD COLUMN IF NOT EXISTS "memoryRequest" VARCHAR; + +UPDATE optional_service_profiles SET "cpuRequest" = '50m', "memoryRequest" = '64Mi' +WHERE service = 'redis' AND "cpuRequest" IS NULL; + +UPDATE optional_service_profiles SET "cpuRequest" = '100m', "memoryRequest" = '256Mi' +WHERE service = 'rabbitmq' AND "cpuRequest" IS NULL; + +UPDATE optional_service_profiles SET "cpuRequest" = '50m', "memoryRequest" = '64Mi' +WHERE service = 'elasticsearch' AND "cpuRequest" IS NULL; diff --git a/backend/helm/cloudhost-platform/migrations/010_invoices.sql b/backend/helm/cloudhost-platform/migrations/010_invoices.sql new file mode 100644 index 0000000..5c3ca6e --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/010_invoices.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "invoiceNumber" VARCHAR NOT NULL UNIQUE, + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + "applicationId" UUID REFERENCES applications(id) ON DELETE SET NULL, + reason VARCHAR NOT NULL DEFAULT 'manual', + status VARCHAR NOT NULL DEFAULT 'issued', + "paymentMethod" VARCHAR, + subtotal DECIMAL(14, 2) NOT NULL DEFAULT 0, + total DECIMAL(14, 2) NOT NULL DEFAULT 0, + "paidAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0, + "dueAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0, + "dueDate" TIMESTAMPTZ, + "paidAt" TIMESTAMPTZ, + "gatewayTrackingCode" VARCHAR, + "gatewayReference" VARCHAR, + "adminNote" VARCHAR, + "statusReason" VARCHAR, + metadata JSONB, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS invoice_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "invoiceId" UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + label VARCHAR NOT NULL, + description VARCHAR, + quantity INT NOT NULL DEFAULT 1, + "unitAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0, + amount DECIMAL(14, 2) NOT NULL DEFAULT 0, + metadata JSONB, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE wallet_transactions + ADD COLUMN IF NOT EXISTS "invoiceId" UUID REFERENCES invoices(id) ON DELETE SET NULL; + +ALTER TABLE wallet_transactions + ADD COLUMN IF NOT EXISTS "gatewayTrackingCode" VARCHAR; + +CREATE INDEX IF NOT EXISTS idx_invoices_user_status_created + ON invoices ("userId", status, "createdAt" DESC); + +CREATE INDEX IF NOT EXISTS idx_invoices_application + ON invoices ("applicationId"); + +CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice + ON invoice_lines ("invoiceId"); + +CREATE INDEX IF NOT EXISTS idx_wallet_transactions_invoice + ON wallet_transactions ("invoiceId"); diff --git a/backend/helm/cloudhost-platform/migrations/011_deployment_cancelled_status.sql b/backend/helm/cloudhost-platform/migrations/011_deployment_cancelled_status.sql new file mode 100644 index 0000000..182c49e --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/011_deployment_cancelled_status.sql @@ -0,0 +1,10 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_type + WHERE typname = 'deployments_status_enum' + ) THEN + ALTER TYPE deployments_status_enum ADD VALUE IF NOT EXISTS 'cancelled'; + END IF; +END $$; diff --git a/backend/helm/cloudhost-platform/migrations/012_cluster_pool_allocation.sql b/backend/helm/cloudhost-platform/migrations/012_cluster_pool_allocation.sql new file mode 100644 index 0000000..5781037 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/012_cluster_pool_allocation.sql @@ -0,0 +1,113 @@ +-- Cluster pool allocation, health snapshots, and audit logs. + +ALTER TABLE clusters + ADD COLUMN IF NOT EXISTS weight INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS tags JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS "healthStatus" VARCHAR NOT NULL DEFAULT 'unknown', + ADD COLUMN IF NOT EXISTS "lastHealthCheckedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "healthMessage" VARCHAR, + ADD COLUMN IF NOT EXISTS "availableResources" JSONB; + +ALTER TABLE cluster_pools + ADD COLUMN IF NOT EXISTS "isDefault" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 100; + +ALTER TABLE cluster_pools + ALTER COLUMN strategy SET DEFAULT 'weighted-resource'; + +CREATE INDEX IF NOT EXISTS idx_clusters_status_health + ON clusters(status, "healthStatus"); + +CREATE INDEX IF NOT EXISTS idx_clusters_tags + ON clusters USING GIN(tags); + +CREATE INDEX IF NOT EXISTS idx_cluster_pools_default_priority + ON cluster_pools("isDefault", priority) + WHERE "isActive" = TRUE; + +CREATE TABLE IF NOT EXISTS cluster_health ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "clusterId" UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE, + status VARCHAR NOT NULL DEFAULT 'unknown', + "readyNodes" INTEGER NOT NULL DEFAULT 0, + "nodeCount" INTEGER NOT NULL DEFAULT 0, + "cpuAllocatable" VARCHAR, + "memoryAllocatable" VARCHAR, + "podCount" INTEGER NOT NULL DEFAULT 0, + "appCount" INTEGER NOT NULL DEFAULT 0, + message VARCHAR, + resources JSONB, + "checkedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cluster_health_cluster_checked + ON cluster_health("clusterId", "checkedAt" DESC); + +CREATE TABLE IF NOT EXISTS cluster_allocation_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "applicationId" UUID REFERENCES applications(id) ON DELETE SET NULL, + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + "poolId" UUID REFERENCES cluster_pools(id) ON DELETE SET NULL, + "selectedClusterId" UUID REFERENCES clusters(id) ON DELETE SET NULL, + strategy VARCHAR NOT NULL DEFAULT 'weighted-resource', + "estimatedRequest" JSONB, + "candidateScores" JSONB, + "rejectionReasons" JSONB, + status VARCHAR NOT NULL DEFAULT 'success', + message VARCHAR, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_user_created + ON cluster_allocation_logs("userId", "createdAt" DESC); + +CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_app + ON cluster_allocation_logs("applicationId"); + +CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_cluster_created + ON cluster_allocation_logs("selectedClusterId", "createdAt" DESC); + +ALTER TABLE applications + ADD COLUMN IF NOT EXISTS "clusterId" UUID, + ADD COLUMN IF NOT EXISTS "poolId" UUID; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'applications' AND column_name = 'clusterId' AND data_type <> 'uuid' + ) THEN + ALTER TABLE applications + ALTER COLUMN "clusterId" TYPE UUID USING NULLIF("clusterId", '')::uuid; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'applications' AND column_name = 'poolId' AND data_type <> 'uuid' + ) THEN + ALTER TABLE applications + ALTER COLUMN "poolId" TYPE UUID USING NULLIF("poolId", '')::uuid; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_applications_cluster' + ) THEN + ALTER TABLE applications + ADD CONSTRAINT fk_applications_cluster FOREIGN KEY ("clusterId") + REFERENCES clusters(id) ON DELETE SET NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_applications_pool' + ) THEN + ALTER TABLE applications + ADD CONSTRAINT fk_applications_pool FOREIGN KEY ("poolId") + REFERENCES cluster_pools(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_applications_cluster_id ON applications("clusterId"); +CREATE INDEX IF NOT EXISTS idx_applications_pool_id ON applications("poolId"); diff --git a/backend/helm/cloudhost-platform/migrations/014_application_migrations.sql b/backend/helm/cloudhost-platform/migrations/014_application_migrations.sql new file mode 100644 index 0000000..9dce530 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/014_application_migrations.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS application_migration_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "applicationId" UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + "requestedBy" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + "sourceClusterId" UUID NOT NULL REFERENCES clusters(id) ON DELETE RESTRICT, + "targetClusterId" UUID NOT NULL REFERENCES clusters(id) ON DELETE RESTRICT, + status VARCHAR NOT NULL DEFAULT 'queued', + attempts INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 3, + "currentStep" VARCHAR, + "errorMessage" VARCHAR, + metadata JSONB, + "startedAt" TIMESTAMPTZ, + "completedAt" TIMESTAMPTZ, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_application_migration_jobs_app_created + ON application_migration_jobs("applicationId", "createdAt" DESC); + +CREATE INDEX IF NOT EXISTS idx_application_migration_jobs_status + ON application_migration_jobs(status); + +CREATE TABLE IF NOT EXISTS application_migration_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "migrationId" UUID NOT NULL REFERENCES application_migration_jobs(id) ON DELETE CASCADE, + step VARCHAR NOT NULL, + level VARCHAR NOT NULL DEFAULT 'info', + message VARCHAR NOT NULL, + metadata JSONB, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_application_migration_events_migration_created + ON application_migration_events("migrationId", "createdAt" ASC); diff --git a/backend/helm/cloudhost-platform/migrations/015_application_product_type.sql b/backend/helm/cloudhost-platform/migrations/015_application_product_type.sql new file mode 100644 index 0000000..a59885d --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/015_application_product_type.sql @@ -0,0 +1,9 @@ +-- Standalone managed services (database / redis / rabbitmq) vs full applications +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); + +ALTER TABLE resource_credits + ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application'; diff --git a/backend/helm/cloudhost-platform/migrations/016_service_access_persistent.sql b/backend/helm/cloudhost-platform/migrations/016_service_access_persistent.sql new file mode 100644 index 0000000..8847cb7 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/016_service_access_persistent.sql @@ -0,0 +1,2 @@ +ALTER TABLE service_access_grants + ADD COLUMN IF NOT EXISTS persistent BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/helm/cloudhost-platform/migrations/017_snapshot_progress.sql b/backend/helm/cloudhost-platform/migrations/017_snapshot_progress.sql new file mode 100644 index 0000000..1508197 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/017_snapshot_progress.sql @@ -0,0 +1,2 @@ +-- Snapshot creation progress (0–100) for UI feedback during DB dumps +ALTER TABLE snapshots ADD COLUMN IF NOT EXISTS progress INT NOT NULL DEFAULT 0; diff --git a/backend/helm/cloudhost-platform/templates/NOTES.txt b/backend/helm/cloudhost-platform/templates/NOTES.txt new file mode 100644 index 0000000..36bd46d --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/NOTES.txt @@ -0,0 +1,37 @@ +CloudHost platform has been deployed. + +Release: {{ .Release.Name }} +Namespace: {{ include "cloudhost-platform.namespace" . }} + +{{- if .Values.ingress.enabled }} + +Public URLs (configure DNS to point at your Ingress controller): + Frontend: {{ include "cloudhost-platform.frontendPublicUrl" . }} + API: {{ include "cloudhost-platform.apiPublicUrl" . }} + +Build the frontend image with: + docker build -t /cloudhost-frontend: \ + --build-arg NEXT_PUBLIC_API_URL={{ include "cloudhost-platform.apiPublicUrl" . }} \ + ./frontend + +{{- if .Values.ingress.tls.enabled }} +TLS: enabled (cert-manager issuer: {{ .Values.ingress.tls.clusterIssuer }}) +Ensure cert-manager and ClusterIssuer "{{ .Values.ingress.tls.clusterIssuer }}" exist. +{{- else }} +TLS: disabled (HTTP only) +{{- end }} + +{{- else }} + +Ingress is disabled. Port-forward to access services: + kubectl port-forward -n {{ include "cloudhost-platform.namespace" . }} svc/{{ include "cloudhost-platform.frontend.fullname" . }} 3000:3000 + kubectl port-forward -n {{ include "cloudhost-platform.namespace" . }} svc/{{ include "cloudhost-platform.backend.fullname" . }} 4000:4000 + +{{- end }} + +Backend CORS (FRONTEND_URL): {{ include "cloudhost-platform.frontendPublicUrl" . }} + +After install, register your Kubernetes cluster in the admin panel (Clusters) so deployments can run. + +To retrieve auto-generated secrets: + kubectl get secret -n {{ include "cloudhost-platform.namespace" . }} {{ include "cloudhost-platform.secretName" . }} -o jsonpath='{.data.postgres-password}' | base64 -d; echo diff --git a/backend/helm/cloudhost-platform/templates/_helpers.tpl b/backend/helm/cloudhost-platform/templates/_helpers.tpl new file mode 100644 index 0000000..fda6f09 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/_helpers.tpl @@ -0,0 +1,96 @@ +{{- define "cloudhost-platform.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cloudhost-platform.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{- define "cloudhost-platform.namespace" -}} +{{- .Values.namespace | default "cloudhost" }} +{{- end }} + +{{- define "cloudhost-platform.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end }} + +{{- define "cloudhost-platform.labels" -}} +helm.sh/chart: {{ include "cloudhost-platform.chart" . }} +{{ include "cloudhost-platform.selectorLabels" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "cloudhost-platform.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cloudhost-platform.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "cloudhost-platform.postgres.fullname" -}} +{{- printf "%s-postgres" (include "cloudhost-platform.fullname" .) }} +{{- end }} + +{{- define "cloudhost-platform.redis.fullname" -}} +{{- printf "%s-redis" (include "cloudhost-platform.fullname" .) }} +{{- end }} + +{{- define "cloudhost-platform.backend.fullname" -}} +{{- printf "%s-backend" (include "cloudhost-platform.fullname" .) }} +{{- end }} + +{{- define "cloudhost-platform.frontend.fullname" -}} +{{- printf "%s-frontend" (include "cloudhost-platform.fullname" .) }} +{{- end }} + +{{- define "cloudhost-platform.secretName" -}} +{{- printf "%s-secrets" (include "cloudhost-platform.fullname" .) }} +{{- end }} + +{{- define "cloudhost-platform.tlsSecretName" -}} +{{- if .Values.ingress.tls.secretName }} +{{- .Values.ingress.tls.secretName }} +{{- else }} +{{- printf "%s-platform-tls" (include "cloudhost-platform.fullname" .) }} +{{- end }} +{{- end }} + +{{- define "cloudhost-platform.urlScheme" -}} +{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled }}https{{- else }}http{{- end }} +{{- end }} + +{{- define "cloudhost-platform.frontendPublicUrl" -}} +{{- $scheme := include "cloudhost-platform.urlScheme" . -}} +{{- if .Values.ingress.enabled }} +{{- if .Values.ingress.singleHost.enabled }} +{{- printf "%s://%s" $scheme .Values.ingress.singleHost.host }} +{{- else }} +{{- printf "%s://%s" $scheme .Values.ingress.frontend.host }} +{{- end }} +{{- else }} +{{- printf "http://%s-frontend.%s.svc.cluster.local:3000" (include "cloudhost-platform.fullname" .) (include "cloudhost-platform.namespace" .) }} +{{- end }} +{{- end }} + +{{- define "cloudhost-platform.apiPublicUrl" -}} +{{- $scheme := include "cloudhost-platform.urlScheme" . -}} +{{- if .Values.ingress.enabled }} +{{- if .Values.ingress.singleHost.enabled }} +{{- printf "%s://%s%s" $scheme .Values.ingress.singleHost.host .Values.ingress.singleHost.apiPath }} +{{- else }} +{{- printf "%s://%s" $scheme .Values.ingress.api.host }} +{{- end }} +{{- else }} +{{- printf "http://%s-backend.%s.svc.cluster.local:4000" (include "cloudhost-platform.fullname" .) (include "cloudhost-platform.namespace" .) }} +{{- end }} +{{- end }} + +{{- define "cloudhost-platform.backendImage" -}} +{{- printf "%s:%s" .Values.images.backend.repository .Values.images.backend.tag }} +{{- end }} + +{{- define "cloudhost-platform.frontendImage" -}} +{{- printf "%s:%s" .Values.images.frontend.repository .Values.images.frontend.tag }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/backend-deployment.yaml b/backend/helm/cloudhost-platform/templates/backend-deployment.yaml new file mode 100644 index 0000000..82ab5c3 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/backend-deployment.yaml @@ -0,0 +1,116 @@ +{{- if .Values.backend.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cloudhost-platform.backend.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.backend.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.backend.replicas }} + strategy: + type: Recreate + selector: + matchLabels: + app: {{ include "cloudhost-platform.backend.fullname" . }} + template: + metadata: + labels: + app: {{ include "cloudhost-platform.backend.fullname" . }} + spec: + initContainers: + {{- if .Values.postgres.enabled }} + - name: wait-postgres + image: {{ .Values.images.busybox | quote }} + command: + - sh + - -c + - | + until nc -z {{ include "cloudhost-platform.postgres.fullname" . }} 5432; do + echo "waiting for postgres..." + sleep 2 + done + {{- end }} + {{- if .Values.redis.enabled }} + - name: wait-redis + image: {{ .Values.images.busybox | quote }} + command: + - sh + - -c + - | + until nc -z {{ include "cloudhost-platform.redis.fullname" . }} 6379; do + echo "waiting for redis..." + sleep 2 + done + {{- end }} + containers: + - name: backend + image: {{ include "cloudhost-platform.backendImage" . | quote }} + imagePullPolicy: {{ .Values.images.backend.pullPolicy }} + ports: + - containerPort: 4000 + env: + - name: DB_HOST + value: {{ include "cloudhost-platform.postgres.fullname" . }} + - name: DB_PORT + value: "5432" + - name: DB_USERNAME + value: {{ .Values.postgres.username | quote }} + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "cloudhost-platform.secretName" . }} + key: postgres-password + - name: DB_DATABASE + value: {{ .Values.postgres.database | quote }} + - name: REDIS_HOST + value: {{ include "cloudhost-platform.redis.fullname" . }} + - name: REDIS_PORT + value: "6379" + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "cloudhost-platform.secretName" . }} + key: jwt-secret + - name: JWT_REFRESH_SECRET + valueFrom: + secretKeyRef: + name: {{ include "cloudhost-platform.secretName" . }} + key: jwt-refresh-secret + - name: FRONTEND_URL + value: {{ include "cloudhost-platform.frontendPublicUrl" . | quote }} + {{- range $key, $val := .Values.backend.env }} + - name: {{ $key }} + value: {{ $val | quote }} + {{- end }} + {{- range $key, $val := .Values.backend.extraEnv }} + - name: {{ $key }} + value: {{ $val | quote }} + {{- end }} + volumeMounts: + - name: uploads + mountPath: /app/uploads + livenessProbe: + httpGet: + path: /api/docs + port: 4000 + initialDelaySeconds: 60 + periodSeconds: 15 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /api/docs + port: 4000 + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: uploads + persistentVolumeClaim: + claimName: {{ include "cloudhost-platform.backend.fullname" . }}-uploads +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/backend-pvc.yaml b/backend/helm/cloudhost-platform/templates/backend-pvc.yaml new file mode 100644 index 0000000..db5db97 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/backend-pvc.yaml @@ -0,0 +1,19 @@ +{{- if .Values.backend.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cloudhost-platform.backend.fullname" . }}-uploads + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.backend.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.global.storageClass }} + storageClassName: {{ .Values.global.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.backend.uploads.size | quote }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/backend-service.yaml b/backend/helm/cloudhost-platform/templates/backend-service.yaml new file mode 100644 index 0000000..1bbb939 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/backend-service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.backend.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cloudhost-platform.backend.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.backend.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app: {{ include "cloudhost-platform.backend.fullname" . }} + ports: + - name: http + port: 4000 + targetPort: 4000 +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml b/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml new file mode 100644 index 0000000..3169f89 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml @@ -0,0 +1,47 @@ +{{- if .Values.frontend.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cloudhost-platform.frontend.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.frontend.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.frontend.replicas }} + selector: + matchLabels: + app: {{ include "cloudhost-platform.frontend.fullname" . }} + template: + metadata: + labels: + app: {{ include "cloudhost-platform.frontend.fullname" . }} + spec: + containers: + - name: frontend + image: {{ include "cloudhost-platform.frontendImage" . | quote }} + imagePullPolicy: {{ .Values.images.frontend.pullPolicy }} + ports: + - containerPort: 3000 + env: + - name: PORT + value: "3000" + - name: HOSTNAME + value: "0.0.0.0" + livenessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 15 + readinessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + {{- with .Values.frontend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/frontend-service.yaml b/backend/helm/cloudhost-platform/templates/frontend-service.yaml new file mode 100644 index 0000000..facfd63 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/frontend-service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.frontend.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cloudhost-platform.frontend.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.frontend.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app: {{ include "cloudhost-platform.frontend.fullname" . }} + ports: + - name: http + port: 3000 + targetPort: 3000 +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/ingress.yaml b/backend/helm/cloudhost-platform/templates/ingress.yaml new file mode 100644 index 0000000..c4ded07 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/ingress.yaml @@ -0,0 +1,73 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "cloudhost-platform.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + {{- include "cloudhost-platform.labels" . | nindent 4 }} + annotations: + {{- if .Values.ingress.tls.enabled }} + cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.clusterIssuer | quote }} + {{- end }} + {{- if and .Values.ingress.singleHost.enabled .Values.ingress.singleHost.apiPath }} + nginx.ingress.kubernetes.io/use-regex: "true" + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + {{- if .Values.ingress.singleHost.enabled }} + - {{ .Values.ingress.singleHost.host | quote }} + {{- else }} + - {{ .Values.ingress.frontend.host | quote }} + - {{ .Values.ingress.api.host | quote }} + {{- end }} + secretName: {{ include "cloudhost-platform.tlsSecretName" . }} + {{- end }} + rules: + {{- if .Values.ingress.singleHost.enabled }} + - host: {{ .Values.ingress.singleHost.host | quote }} + http: + paths: + - path: {{ .Values.ingress.singleHost.apiPath }} + pathType: Prefix + backend: + service: + name: {{ include "cloudhost-platform.backend.fullname" . }} + port: + number: 4000 + - path: / + pathType: Prefix + backend: + service: + name: {{ include "cloudhost-platform.frontend.fullname" . }} + port: + number: 3000 + {{- else }} + - host: {{ .Values.ingress.frontend.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "cloudhost-platform.frontend.fullname" . }} + port: + number: 3000 + - host: {{ .Values.ingress.api.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "cloudhost-platform.backend.fullname" . }} + port: + number: 4000 + {{- end }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/migrations-configmap.yaml b/backend/helm/cloudhost-platform/templates/migrations-configmap.yaml new file mode 100644 index 0000000..7de0863 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/migrations-configmap.yaml @@ -0,0 +1,11 @@ +{{- if and .Values.migrations.enabled .Values.postgres.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cloudhost-platform.fullname" . }}-migrations + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + {{- include "cloudhost-platform.labels" . | nindent 4 }} +data: +{{- (.Files.Glob "migrations/*.sql").AsConfig | nindent 2 }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/migrations-job.yaml b/backend/helm/cloudhost-platform/templates/migrations-job.yaml new file mode 100644 index 0000000..0baddb0 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/migrations-job.yaml @@ -0,0 +1,62 @@ +{{- if and .Values.migrations.enabled .Values.postgres.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "cloudhost-platform.fullname" . }}-migrations + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + {{- include "cloudhost-platform.labels" . | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "5" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 3 + template: + spec: + restartPolicy: OnFailure + initContainers: + - name: wait-postgres + image: {{ .Values.images.busybox | quote }} + command: + - sh + - -c + - | + until nc -z {{ include "cloudhost-platform.postgres.fullname" . }} 5432; do + sleep 2 + done + containers: + - name: migrate + image: {{ .Values.migrations.image | quote }} + env: + - name: PGHOST + value: {{ include "cloudhost-platform.postgres.fullname" . }} + - name: PGPORT + value: "5432" + - name: PGDATABASE + value: {{ .Values.postgres.database | quote }} + - name: PGUSER + value: {{ .Values.postgres.username | quote }} + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ include "cloudhost-platform.secretName" . }} + key: postgres-password + command: + - sh + - -c + - | + set -e + for f in $(ls /migrations/*.sql | sort); do + echo ">>> Applying $f" + psql -v ON_ERROR_STOP=1 -f "$f" + done + echo ">>> All migrations applied" + volumeMounts: + - name: migrations + mountPath: /migrations + volumes: + - name: migrations + configMap: + name: {{ include "cloudhost-platform.fullname" . }}-migrations +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/namespace.yaml b/backend/helm/cloudhost-platform/templates/namespace.yaml new file mode 100644 index 0000000..bb7839e --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/namespace.yaml @@ -0,0 +1,8 @@ +{{- if .Values.createNamespace }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "cloudhost-platform.namespace" . }} + labels: + {{- include "cloudhost-platform.labels" . | nindent 4 }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/postgres-deployment.yaml b/backend/helm/cloudhost-platform/templates/postgres-deployment.yaml new file mode 100644 index 0000000..cf45a5e --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/postgres-deployment.yaml @@ -0,0 +1,66 @@ +{{- if .Values.postgres.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cloudhost-platform.postgres.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.postgres.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: {{ include "cloudhost-platform.postgres.fullname" . }} + template: + metadata: + labels: + app: {{ include "cloudhost-platform.postgres.fullname" . }} + spec: + containers: + - name: postgres + image: {{ .Values.images.postgres | quote }} + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: {{ .Values.postgres.database | quote }} + - name: POSTGRES_USER + value: {{ .Values.postgres.username | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "cloudhost-platform.secretName" . }} + key: postgres-password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + livenessProbe: + exec: + command: + - pg_isready + - -U + - {{ .Values.postgres.username }} + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + exec: + command: + - pg_isready + - -U + - {{ .Values.postgres.username }} + initialDelaySeconds: 5 + periodSeconds: 5 + {{- with .Values.postgres.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "cloudhost-platform.postgres.fullname" . }}-data +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/postgres-pvc.yaml b/backend/helm/cloudhost-platform/templates/postgres-pvc.yaml new file mode 100644 index 0000000..9782ebf --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/postgres-pvc.yaml @@ -0,0 +1,19 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cloudhost-platform.postgres.fullname" . }}-data + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.postgres.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.global.storageClass }} + storageClassName: {{ .Values.global.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgres.storage | quote }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/postgres-service.yaml b/backend/helm/cloudhost-platform/templates/postgres-service.yaml new file mode 100644 index 0000000..e8667ff --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/postgres-service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.postgres.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cloudhost-platform.postgres.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.postgres.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app: {{ include "cloudhost-platform.postgres.fullname" . }} + ports: + - name: postgres + port: 5432 + targetPort: 5432 +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/redis-deployment.yaml b/backend/helm/cloudhost-platform/templates/redis-deployment.yaml new file mode 100644 index 0000000..d8a9d17 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/redis-deployment.yaml @@ -0,0 +1,52 @@ +{{- if .Values.redis.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cloudhost-platform.redis.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.redis.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: {{ include "cloudhost-platform.redis.fullname" . }} + template: + metadata: + labels: + app: {{ include "cloudhost-platform.redis.fullname" . }} + spec: + containers: + - name: redis + image: {{ .Values.images.redis | quote }} + ports: + - containerPort: 6379 + volumeMounts: + - name: data + mountPath: /data + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 15 + periodSeconds: 10 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + {{- with .Values.redis.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "cloudhost-platform.redis.fullname" . }}-data +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/redis-pvc.yaml b/backend/helm/cloudhost-platform/templates/redis-pvc.yaml new file mode 100644 index 0000000..d29bf94 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/redis-pvc.yaml @@ -0,0 +1,19 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cloudhost-platform.redis.fullname" . }}-data + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.redis.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.global.storageClass }} + storageClassName: {{ .Values.global.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.redis.storage | quote }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/redis-service.yaml b/backend/helm/cloudhost-platform/templates/redis-service.yaml new file mode 100644 index 0000000..8e6d3f9 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/redis-service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cloudhost-platform.redis.fullname" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + app: {{ include "cloudhost-platform.redis.fullname" . }} + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app: {{ include "cloudhost-platform.redis.fullname" . }} + ports: + - name: redis + port: 6379 + targetPort: 6379 +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/secret.yaml b/backend/helm/cloudhost-platform/templates/secret.yaml new file mode 100644 index 0000000..91c7322 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/secret.yaml @@ -0,0 +1,25 @@ +{{- $existing := lookup "v1" "Secret" (include "cloudhost-platform.namespace" .) (include "cloudhost-platform.secretName" .) }} +{{- $pgPass := .Values.postgres.password }} +{{- if not $pgPass }} +{{- if $existing }}{{- $pgPass = index $existing.data "postgres-password" | b64dec }}{{- else }}{{- $pgPass = randAlphaNum 24 }}{{- end }} +{{- end }} +{{- $jwt := .Values.secrets.jwtSecret }} +{{- if not $jwt }} +{{- if $existing }}{{- $jwt = index $existing.data "jwt-secret" | b64dec }}{{- else }}{{- $jwt = randAlphaNum 32 }}{{- end }} +{{- end }} +{{- $jwtRefresh := .Values.secrets.jwtRefreshSecret }} +{{- if not $jwtRefresh }} +{{- if $existing }}{{- $jwtRefresh = index $existing.data "jwt-refresh-secret" | b64dec }}{{- else }}{{- $jwtRefresh = randAlphaNum 32 }}{{- end }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cloudhost-platform.secretName" . }} + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + {{- include "cloudhost-platform.labels" . | nindent 4 }} +type: Opaque +stringData: + postgres-password: {{ $pgPass | quote }} + jwt-secret: {{ $jwt | quote }} + jwt-refresh-secret: {{ $jwtRefresh | quote }} diff --git a/backend/helm/cloudhost-platform/values-images.yaml b/backend/helm/cloudhost-platform/values-images.yaml new file mode 100644 index 0000000..d7f66d2 --- /dev/null +++ b/backend/helm/cloudhost-platform/values-images.yaml @@ -0,0 +1,17 @@ +# Generated after local docker build — use with: +# helm upgrade --install cloudhost . -n cloudhost -f values.yaml -f values-images.yaml +# +# Built with: +# docker build -t cloudhost-backend:1.0.0 ./backend +# docker build -t cloudhost-frontend:1.0.0 \ +# --build-arg NEXT_PUBLIC_API_URL=https://api.cloudhost.local ./frontend + +images: + backend: + repository: cloudhost-backend + tag: "1.0.0" + pullPolicy: IfNotPresent + frontend: + repository: cloudhost-frontend + tag: "1.0.0" + pullPolicy: IfNotPresent diff --git a/backend/helm/cloudhost-platform/values-production.example.yaml b/backend/helm/cloudhost-platform/values-production.example.yaml new file mode 100644 index 0000000..8d33436 --- /dev/null +++ b/backend/helm/cloudhost-platform/values-production.example.yaml @@ -0,0 +1,44 @@ +# Example production overrides for cloudhost-platform +# cp values-production.example.yaml values-production.yaml && edit secrets/hosts + +namespace: cloudhost +createNamespace: true + +global: + storageClass: local-path # k3s example + +images: + backend: + repository: registry.example.com/cloudhost-backend + tag: "1.0.0" + pullPolicy: Always + frontend: + repository: registry.example.com/cloudhost-frontend + tag: "1.0.0" + pullPolicy: Always + +postgres: + password: "CHANGE_ME_STRONG_POSTGRES_PASSWORD" + +secrets: + jwtSecret: "CHANGE_ME_LONG_JWT_SECRET" + jwtRefreshSecret: "CHANGE_ME_LONG_REFRESH_SECRET" + +ingress: + enabled: true + className: nginx + frontend: + host: platform.example.com + api: + host: api.platform.example.com + tls: + enabled: true + clusterIssuer: letsencrypt-prod + +backend: + env: + PLATFORM_DOMAIN: apps.example.com + REGISTRY_PULL_URL: "10.0.0.50:30500" + +migrations: + enabled: true diff --git a/backend/helm/cloudhost-platform/values.yaml b/backend/helm/cloudhost-platform/values.yaml new file mode 100644 index 0000000..e989d4f --- /dev/null +++ b/backend/helm/cloudhost-platform/values.yaml @@ -0,0 +1,93 @@ +# ──────────────────────────────────────────────────────────── +# CloudHost Platform — Helm values +# Deploy: helm upgrade --install cloudhost ./backend/helm/cloudhost-platform -n cloudhost --create-namespace +# ──────────────────────────────────────────────────────────── + +nameOverride: "" +fullnameOverride: "" + +namespace: cloudhost +createNamespace: true + +global: + storageClass: "" + +images: + postgres: postgres:16-alpine + redis: redis:7-alpine + busybox: busybox:1.36 + backend: + repository: cloudhost-backend + tag: "1.0.0" + pullPolicy: IfNotPresent + frontend: + repository: cloudhost-frontend + tag: "1.0.0" + pullPolicy: IfNotPresent + +postgres: + enabled: true + database: cloudhost + username: cloudhost + # Leave empty to auto-generate on first install (stored in Secret) + password: "" + storage: 10Gi + resources: {} + +redis: + enabled: true + storage: 1Gi + resources: {} + +backend: + enabled: true + replicas: 1 + uploads: + size: 20Gi + resources: {} + extraEnv: {} + env: + NODE_ENV: production + PORT: "4000" + JWT_EXPIRES_IN: 15m + JWT_REFRESH_EXPIRES_IN: 7d + PLATFORM_DOMAIN: apps.cloudhost.local + REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000 + REGISTRY_PULL_URL: localhost:30500 + BUILD_NAMESPACE: cloudhost-builds + BUILD_SERVICE_ACCOUNT: kaniko-builder + UPLOAD_DIR: /app/uploads + PLATFORM_CREATE_STORAGE_CLASS: "true" + PLATFORM_STORAGE_CLASS: cloudhost-expandable + PLATFORM_STORAGE_PROVISIONER: rancher.io/local-path + +frontend: + enabled: true + replicas: 1 + resources: {} + +# JWT secrets — set in production (values-production.example.yaml) +secrets: + jwtSecret: "" + jwtRefreshSecret: "" + +ingress: + enabled: true + className: nginx + frontend: + host: platform.cloudhost.local + api: + host: api.cloudhost.local + singleHost: + enabled: false + host: platform.cloudhost.local + apiPath: /api + annotations: {} + tls: + enabled: true + clusterIssuer: letsencrypt-prod + secretName: "" + +migrations: + enabled: true + image: postgres:16-alpine diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a576e25..0baae27 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,6 +12,8 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG NEXT_PUBLIC_API_URL=http://localhost:4000 +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/k8s/registry-ingress/cluster-issuer.yaml b/k8s/registry-ingress/cluster-issuer.yaml new file mode 100644 index 0000000..6fe3f0f --- /dev/null +++ b/k8s/registry-ingress/cluster-issuer.yaml @@ -0,0 +1,14 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: admin@3fase.ir + privateKeySecretRef: + name: letsencrypt-prod-account-key + solvers: + - http01: + ingress: + class: traefik diff --git a/k8s/registry-ingress/ingress.yaml b/k8s/registry-ingress/ingress.yaml new file mode 100644 index 0000000..8aca824 --- /dev/null +++ b/k8s/registry-ingress/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: registry-public + namespace: cloudhost-builds + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/service.serverstransport: cloudhost-builds-registry-transport@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - repo.3fase.ir + secretName: repo-3fase-ir-tls + rules: + - host: repo.3fase.ir + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: registry + port: + number: 5000 diff --git a/k8s/registry-ingress/registry-proxy-env.yaml b/k8s/registry-ingress/registry-proxy-env.yaml new file mode 100644 index 0000000..45d5cf0 --- /dev/null +++ b/k8s/registry-ingress/registry-proxy-env.yaml @@ -0,0 +1,22 @@ +# Patch registry Deployment for reverse-proxy (Traefik + HTTPS) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry + namespace: cloudhost-builds +spec: + template: + spec: + containers: + - name: registry + env: + - name: REGISTRY_STORAGE_DELETE_ENABLED + value: "true" + - name: REGISTRY_HTTP_RELATIVEURLS + value: "true" + - name: REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin + value: '["*"]' + - name: REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods + value: '["HEAD","GET","OPTIONS","DELETE"]' + - name: REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers + value: '["Authorization","Accept","Cache-Control"]' diff --git a/k8s/registry-ingress/traefik-middleware.yaml b/k8s/registry-ingress/traefik-middleware.yaml new file mode 100644 index 0000000..1ef406e --- /dev/null +++ b/k8s/registry-ingress/traefik-middleware.yaml @@ -0,0 +1,23 @@ +apiVersion: traefik.io/v1alpha1 +kind: ServersTransport +metadata: + name: registry-transport + namespace: cloudhost-builds +spec: + forwardingTimeouts: + dialTimeout: 30s + responseHeaderTimeout: 600s + idleConnTimeout: 600s +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: registry-buffering + namespace: cloudhost-builds +spec: + buffering: + maxRequestBodyBytes: 0 + memRequestBodyBytes: 20971520 + maxResponseBodyBytes: 0 + memResponseBodyBytes: 20971520 + retryExpression: "IsNetworkError() && Attempts() < 2" diff --git a/k8s/registry-ingress/traefik-timeout-values.yaml b/k8s/registry-ingress/traefik-timeout-values.yaml new file mode 100644 index 0000000..8147c1a --- /dev/null +++ b/k8s/registry-ingress/traefik-timeout-values.yaml @@ -0,0 +1,14 @@ +# Merge with existing traefik helm values (helm upgrade traefik -f traefik-timeout-values.yaml) +ports: + web: + transport: + respondingTimeouts: + readTimeout: "0s" + writeTimeout: "0s" + idleTimeout: "1800s" + websecure: + transport: + respondingTimeouts: + readTimeout: "0s" + writeTimeout: "0s" + idleTimeout: "1800s"