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 <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-24 19:01:05 +03:30
parent abbe821d91
commit bf9e827f85
50 changed files with 1649 additions and 2 deletions
+15
View File
@@ -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 ## 🚀 Helm Chart: cloudhost-app
Single chart at `backend/helm/cloudhost-app/` handles all runtimes: Single chart at `backend/helm/cloudhost-app/` handles all runtimes:
@@ -208,6 +222,7 @@ host/
├── backend/ ├── backend/
│ ├── Dockerfile │ ├── Dockerfile
│ ├── package.json │ ├── package.json
│ ├── helm/cloudhost-platform/ # Helm chart for control plane
│ ├── helm/cloudhost-app/ # Helm chart for user apps │ ├── helm/cloudhost-app/ # Helm chart for user apps
│ ├── src/ │ ├── src/
│ │ ├── main.ts / app.module.ts │ │ ├── main.ts / app.module.ts
+6
View File
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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/`) #### Helm Chart (`backend/helm/cloudhost-app/`)
- **New Helm chart** replacing legacy Handlebars templates for all deployments - **New Helm chart** replacing legacy Handlebars templates for all deployments
- `deployment.yaml` — app deployment with `imagePullSecrets`, `imagePullPolicy: Always`, health probes - `deployment.yaml` — app deployment with `imagePullSecrets`, `imagePullPolicy: Always`, health probes
+25 -2
View File
@@ -70,7 +70,8 @@ host/
│ ├── Dockerfile │ ├── Dockerfile
│ ├── package.json │ ├── package.json
│ ├── helm/ │ ├── helm/
│ │ ── cloudhost-app/ # Helm chart (all runtimes) │ │ ── cloudhost-platform/ # Helm chart (control plane)
│ │ └── cloudhost-app/ # Helm chart (user apps)
│ │ ├── Chart.yaml │ │ ├── Chart.yaml
│ │ ├── values.yaml │ │ ├── values.yaml
│ │ └── templates/ # K8s manifest templates │ │ └── templates/ # K8s manifest templates
@@ -147,7 +148,29 @@ docker compose up --build
Backend at port 4000, Frontend at port 3000. 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 ```bash
# Terminal 1 — Backend # Terminal 1 — Backend
@@ -0,0 +1,5 @@
# Patterns to ignore when packaging chart
.DS_Store
*.swp
*.bak
values-production.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"
+37
View File
@@ -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).
@@ -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'
);
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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 $$;
@@ -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;
@@ -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;
@@ -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");
@@ -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 $$;
@@ -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");
@@ -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);
@@ -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';
@@ -0,0 +1,2 @@
ALTER TABLE service_access_grants
ADD COLUMN IF NOT EXISTS persistent BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- Snapshot creation progress (0100) for UI feedback during DB dumps
ALTER TABLE snapshots ADD COLUMN IF NOT EXISTS progress INT NOT NULL DEFAULT 0;
@@ -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 <registry>/cloudhost-frontend:<tag> \
--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
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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
@@ -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
@@ -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
+2
View File
@@ -12,6 +12,8 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build
View File
+14
View File
@@ -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
+26
View File
@@ -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
@@ -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"]'
@@ -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"
@@ -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"