From 7d3007b6c433ec4f0ae554963e6022d370e4600b Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 4 Aug 2026 11:13:44 +0200 Subject: [PATCH] Fix: Remove hardcoded UUIDs and secrets from deploy scripts - deploy.py: UUIDs from env vars or Coolify API lookup by name - fast-deploy.sh: No hardcoded UUIDs, APP_DOMAIN from env - docker-compose.yml: All secrets from env vars, no hardcoded values - .env.example: All required vars documented - Deleted obsolete fast-frontend-deploy.sh with hardcoded container name --- .env.example | 153 +++++--- docker-compose.yml | 52 +-- scripts/deploy.py | 670 ++++++++++++++++++++------------ scripts/fast-deploy.sh | 89 ++++- scripts/fast-frontend-deploy.sh | 28 -- 5 files changed, 633 insertions(+), 359 deletions(-) delete mode 100755 scripts/fast-frontend-deploy.sh diff --git a/.env.example b/.env.example index 96f6e8b..5d65db9 100644 --- a/.env.example +++ b/.env.example @@ -1,48 +1,94 @@ -# LeoCRM v1.0 - Environment Variables Template +# LeoCRM - Environment Variables Template +# Copy to .env and fill in real values. -# === REQUIRED === -DATABASE_URL=postgresql+asyncpg://crm_api:your_password@localhost:5432/crm_db -AUTH_DATABASE_URL=postgresql+asyncpg://crm_auth:your_password@localhost:5432/crm_db -WORKER_DATABASE_URL=postgresql+asyncpg://crm_worker:your_password@localhost:5432/crm_db -MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_migration:your_password@localhost:5432/crm_db -REDIS_URL=redis://localhost:6379/0 +# === COOLIFY DEPLOYMENT (required for scripts/deploy.py) === +# Coolify API token (required for deploy) +COOLIFY_API_TOKEN= +# Coolify base URL +COOLIFY_BASE_URL=https://server.media-on.de +# Application UUID (optional — resolved via API lookup by APP_NAME if absent) +COOLIFY_APP_UUID= +# Worker Service UUID (optional — resolved via API lookup by WORKER_NAME if absent) +COOLIFY_WORKER_UUID= +# Application name for API lookup +APP_NAME=leocrm-api +# Worker name for API lookup +WORKER_NAME=leocrm-worker +# App domain (required for deploy, used for health check and FQDN) +APP_DOMAIN=https://crm.media-on.de -# === REQUIRED for Docker/Production === -# Redis password (required in Docker) -REDIS_PASSWORD=your_redis_password +# === COOLIFY INITIAL DEPLOY (only needed for --initial) === +# Coolify project UUID +COOLIFY_PROJECT_UUID= +# Coolify server UUID +COOLIFY_SERVER_UUID= +# Coolify private key UUID (for Git deploy key) +COOLIFY_PRIVATE_KEY_UUID= +# Coolify environment name +COOLIFY_ENVIRONMENT=production -# === OPTIONAL (with defaults) === +# === DATABASE (required) === +# Single password for all DB roles (crm_user, crm_api, crm_auth, crm_worker, crm_migration) +DB_PASSWORD= +# Database name +POSTGRES_DB=crm_db +# Database user (superuser/owner) +POSTGRES_USER=crm_user +# Database host (container name in Docker network) +DB_HOST=crm-postgres +# Full database URLs (constructed from DB_PASSWORD/DB_HOST if not set explicitly) +DATABASE_URL=postgresql+asyncpg://crm_api:${DB_PASSWORD}@crm-postgres:5432/${POSTGRES_DB} +AUTH_DATABASE_URL=postgresql+asyncpg://crm_auth:${DB_PASSWORD}@crm-postgres:5432/${POSTGRES_DB} +WORKER_DATABASE_URL=postgresql+asyncpg://crm_worker:${DB_PASSWORD}@crm-postgres:5432/${POSTGRES_DB} +MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_migration:${DB_PASSWORD}@crm-postgres:5432/${POSTGRES_DB} +# === REDIS (required) === +REDIS_PASSWORD= +REDIS_HOST=crm-redis +REDIS_URL=redis://:${REDIS_PASSWORD}@crm-redis:6379/0 + +# === SECURITY (required) === +# Secret key for signing, sessions (use a secure random string >= 32 chars) +SECRET_KEY= + +# === SSH VERIFICATION (optional, deploy.py verification only) === +SSH_KEY=/a0/usr/workdir/.ssh/coolify-01-root +SERVER_IP=46.225.91.159 +# Login test credentials (optional, for deploy verification) +LOGIN_EMAIL= +LOGIN_PASSWORD= + +# === APPLICATION === # Environment: development | production | testing -ENVIRONMENT=development - +ENVIRONMENT=production # Log level: DEBUG | INFO | WARNING | ERROR LOG_LEVEL=INFO - -# Database pool -DB_POOL_SIZE=10 -DB_MAX_OVERFLOW=20 -DB_ECHO=false - -# Session settings -SESSION_TTL_SECONDS=28800 -SESSION_COOKIE_NAME=leocrm_session -SESSION_COOKIE_SECURE=false -SESSION_COOKIE_SAMESITE=strict -SESSION_COOKIE_HTTPONLY=true - -# Password hashing -BCRYPT_ROUNDS=12 -PASSWORD_RESET_EXPIRY_HOURS=1 - # CORS allowed origins (comma-separated, NO wildcards) -CORS_ORIGINS=http://localhost:5173,http://localhost:3000 +CORS_ORIGINS=https://crm.media-on.de +# Frontend URL +FRONTEND_URL=https://crm.media-on.de +# Session cookie secure (true in production) +SESSION_COOKIE_SECURE=true -# Secret Key (for signing, sessions — use a secure random string ≥32 chars in prod) -SECRET_KEY=change-me-in-production-use-a-secure-random-string +# === DOCKER COMPOSE (optional overrides) === +# Container names +POSTGRES_CONTAINER_NAME=crm-postgres +REDIS_CONTAINER_NAME=crm-redis +APP_CONTAINER_NAME=crm-app +WORKER_CONTAINER_NAME=crm-worker +# Volume names +PGDATA_VOLUME_NAME=crm_pgdata +REDISDATA_VOLUME_NAME=crm_redisdata +STORAGE_VOLUME_NAME=crm_storage +# Network names +CRM_NETWORK_NAME=crm-net +COOLIFY_NETWORK_NAME=coolify +# Traefik host +APP_HOST=crm.media-on.de +APP_PORT=8000 -# Storage (file uploads, DMS) -STORAGE_PATH=/tmp +# === STORAGE === +STORAGE_PATH=/data/storage # Storage backend: local (default) or s3 STORAGE_BACKEND=local # S3-compatible storage (when STORAGE_BACKEND=s3) @@ -53,15 +99,15 @@ S3_SECRET_KEY= S3_REGION=us-east-1 S3_SECURE=true -# SMTP / Email +# === SMTP / EMAIL === SMTP_HOST=localhost SMTP_PORT=587 -SMTP_USERNAME= +SMTP_USER= SMTP_PASSWORD= -SMTP_FROM_EMAIL=noreply@leocrm.local -SMTP_USE_TLS=true +SMTP_FROM=no-reply@localhost +SMTP_TLS=true -# Rate limiting +# === RATE LIMITING === RATE_LIMIT_LOGIN_MAX=5 RATE_LIMIT_LOGIN_WINDOW=900 RATE_LIMIT_RESET_MAX=3 @@ -71,10 +117,29 @@ RATE_LIMIT_RESET_CONFIRM_WINDOW=3600 RATE_LIMIT_GENERAL_MAX=60 RATE_LIMIT_GENERAL_WINDOW=60 -# === AI / Search === -# Ollama Cloud API Key (für LiteLLM) +# === DATABASE POOL === +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=20 +DB_ECHO=false + +# === SESSION === +SESSION_TTL_SECONDS=28800 +SESSION_COOKIE_NAME=leocrm_session +SESSION_COOKIE_SAMESITE=strict +SESSION_COOKIE_HTTPONLY=true + +# === PASSWORD HASHING === +BCRYPT_ROUNDS=12 +PASSWORD_RESET_EXPIRY_HOURS=1 + +# === AI / SEARCH === +# Ollama Cloud API Key (for LiteLLM) API_KEY_OLLAMA_CLOUD= -# Embedding Modell (default: ollama/nomic-embed-text) +# Embedding model (default: ollama/nomic-embed-text) SEARCH_EMBEDDING_MODEL=ollama/nomic-embed-text -# LLM Modell für Query Understanding (default: ollama/deepseek-v4) +# LLM model for query understanding (default: ollama/deepseek-v4) SEARCH_LLM_MODEL=ollama/deepseek-v4 + +# === GIT (for initial deployment) === +API_GIT_REPO=https://forgejo.media-on.de/Leopoldadmin/leocrm.git +API_GIT_BRANCH=main diff --git a/docker-compose.yml b/docker-compose.yml index 2181ed1..f0a7b4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,12 @@ # - RLS policy enforcement # - Health & domain verification # +# No hardcoded secrets, UUIDs, or domains — everything from environment. +# # Usage (local testing): -# cp .env.docker.example .env.docker -# $EDITOR .env.docker # fill SECRET_KEY, POSTGRES_PASSWORD, ... -# docker compose --env-file .env.docker up --build +# cp .env.example .env +# $EDITOR .env # fill SECRET_KEY, DB_PASSWORD, REDIS_PASSWORD, ... +# docker compose up --build # curl http://localhost:8000/api/v1/health # ============================================================================= @@ -25,11 +27,11 @@ services: # ── PostgreSQL 16 with pgvector ───────────────────────────────────── postgres: image: pgvector/pgvector:pg16 - container_name: crm-postgres + container_name: ${POSTGRES_CONTAINER_NAME:-crm-postgres} restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-crm_user} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} POSTGRES_DB: ${POSTGRES_DB:-crm_db} PGDATA: /var/lib/postgresql/data/pgdata volumes: @@ -53,7 +55,7 @@ services: # ── Redis 7 (sessions, rate limiting, ARQ queue) ──────────────────── redis: image: redis:7-alpine - container_name: crm-redis + container_name: ${REDIS_CONTAINER_NAME:-crm-redis} restart: unless-stopped command: redis-server --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required} volumes: @@ -73,15 +75,15 @@ services: build: context: . dockerfile: Dockerfile - container_name: crm-app + container_name: ${APP_CONTAINER_NAME:-crm-app} restart: unless-stopped labels: - traefik.enable=true - - traefik.http.routers.crm.rule=Host(`crm.media-on.de`) + - traefik.http.routers.crm.rule=Host(`${APP_HOST:-crm.media-on.de}`) - traefik.http.routers.crm.entryPoints=http,https - traefik.http.routers.crm.tls=true - traefik.http.routers.crm.tls.certresolver=letsencrypt - - traefik.http.services.crm.loadbalancer.server.port=8000 + - traefik.http.services.crm.loadbalancer.server.port=${APP_PORT:-8000} networks: - crm-net - coolify @@ -92,15 +94,15 @@ services: condition: service_healthy environment: # API uses crm_api (NOSUPERUSER, NOBYPASSRLS) — RLS enforced - DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} + DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://crm_api:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} # Auth uses crm_auth (NOSUPERUSER, NOBYPASSRLS) — identity tables only - AUTH_DATABASE_URL: ${AUTH_DATABASE_URL:-postgresql+asyncpg://crm_auth:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} + AUTH_DATABASE_URL: ${AUTH_DATABASE_URL:-postgresql+asyncpg://crm_auth:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} # Migration/DDL uses crm_migration (owner, NOBYPASSRLS) - MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_migration:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} + MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_migration:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD}@redis:6379/0} SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)} - CORS_ORIGINS: ${CORS_ORIGINS:-https://crm.media-on.de} - FRONTEND_URL: ${FRONTEND_URL:-http://localhost:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-${APP_DOMAIN:-http://localhost:8000}} + FRONTEND_URL: ${FRONTEND_URL:-${APP_DOMAIN:-http://localhost:8000}} ENVIRONMENT: ${ENVIRONMENT:-production} LOG_LEVEL: ${LOG_LEVEL:-INFO} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} @@ -125,7 +127,7 @@ services: volumes: - storage:/data/storage healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/v1/health"] + test: ["CMD", "curl", "-fsS", "http://localhost:${APP_PORT:-8000}/api/v1/health"] interval: 30s timeout: 10s retries: 3 @@ -141,7 +143,7 @@ services: build: context: . dockerfile: Dockerfile - container_name: crm-worker + container_name: ${WORKER_CONTAINER_NAME:-crm-worker} restart: unless-stopped depends_on: postgres: @@ -162,12 +164,12 @@ services: cpus: "1.0" environment: # Worker uses crm_worker (NOSUPERUSER, NOBYPASSRLS) — RLS enforced - DATABASE_URL: ${WORKER_DATABASE_URL:-${DATABASE_URL:?DATABASE_URL is required}} - WORKER_DATABASE_URL: ${WORKER_DATABASE_URL:-postgresql+asyncpg://crm_worker:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} - MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_migration:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} + DATABASE_URL: ${WORKER_DATABASE_URL:-postgresql+asyncpg://crm_worker:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} + WORKER_DATABASE_URL: ${WORKER_DATABASE_URL:-postgresql+asyncpg://crm_worker:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} + MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_migration:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}} REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD}@redis:6379/0} SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)} - FRONTEND_URL: ${FRONTEND_URL:-http://localhost:8000} + FRONTEND_URL: ${FRONTEND_URL:-${APP_DOMAIN:-http://localhost:8000}} ENVIRONMENT: ${ENVIRONMENT:-production} LOG_LEVEL: ${LOG_LEVEL:-INFO} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} @@ -191,16 +193,16 @@ services: volumes: pgdata: - name: crm_pgdata + name: ${PGDATA_VOLUME_NAME:-crm_pgdata} redisdata: - name: crm_redisdata + name: ${REDISDATA_VOLUME_NAME:-crm_redisdata} storage: - name: crm_storage + name: ${STORAGE_VOLUME_NAME:-crm_storage} networks: crm-net: - name: crm-net + name: ${CRM_NETWORK_NAME:-crm-net} driver: bridge coolify: - name: coolify + name: ${COOLIFY_NETWORK_NAME:-coolify} external: true diff --git a/scripts/deploy.py b/scripts/deploy.py index 972f33c..02d15ba 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -6,20 +6,32 @@ docker commands, no SSH for container lifecycle. SSH is used *only* for post-deploy verification (Alembic version, RLS table count) because the Coolify API does not expose database internals. +No UUIDs, domains, or secrets are hardcoded. Everything comes from +environment variables or is resolved via the Coolify API. + Usage: python scripts/deploy.py # Full deploy (API + Worker) python scripts/deploy.py --skip-build # Skip build, just restart python scripts/deploy.py --worker-only # Only deploy worker service python scripts/deploy.py --verify-only # Only run verification + python scripts/deploy.py --initial # Create all resources from scratch Environment variables: - COOLIFY_API_TOKEN — Coolify API token (required) - COOLIFY_APP_UUID — Application UUID (default: stvabl4vaqru7jclx4ittzr3) - COOLIFY_WORKER_UUID — Worker Service UUID (default: asxqaq3566to108xordck0ff) - COOLIFY_BASE_URL — Coolify base URL (default: https://server.media-on.de) + COOLIFY_API_TOKEN — Coolify API token (required) + COOLIFY_BASE_URL — Coolify base URL (default: https://server.media-on.de) + COOLIFY_APP_UUID — Application UUID (optional, resolved via API if absent) + COOLIFY_WORKER_UUID — Worker Service UUID (optional, resolved via API if absent) + APP_NAME — Application name for API lookup (default: leocrm-api) + WORKER_NAME — Worker name for API lookup (default: leocrm-worker) + APP_DOMAIN — App domain for health/FQDN (required, e.g. https://crm.media-on.de) SSH_KEY — SSH key path for verification (default: /a0/usr/workdir/.ssh/coolify-01-root) SERVER_IP — Server IP for SSH verification (default: 46.225.91.159) + Secrets (required for --initial, used by regular deploy if setting ENVs): + DB_PASSWORD — PostgreSQL password for all roles + REDIS_PASSWORD — Redis password + SECRET_KEY — Application secret key + Exit codes: 0 — deployment successful 1 — deployment failed @@ -39,88 +51,43 @@ from typing import Any import httpx -# ─── Configuration ──────────────────────────────────────────────────── +# ─── Configuration (all from env, no hardcoded secrets/UUIDs) ───────── COOLIFY_BASE_URL = os.environ.get("COOLIFY_BASE_URL", "https://server.media-on.de") COOLIFY_TOKEN = os.environ.get("COOLIFY_API_TOKEN", "") -APP_UUID = os.environ.get("COOLIFY_APP_UUID", "stvabl4vaqru7jclx4ittzr3") -WORKER_UUID = os.environ.get("COOLIFY_WORKER_UUID", "asxqaq3566to108xordck0ff") +# UUIDs — from env or resolved via API lookup by name +APP_UUID = os.environ.get("COOLIFY_APP_UUID", "") +WORKER_UUID = os.environ.get("COOLIFY_WORKER_UUID", "") + +# Names for API lookup when UUIDs are not provided +APP_NAME = os.environ.get("APP_NAME", "leocrm-api") +WORKER_NAME = os.environ.get("WORKER_NAME", "leocrm-worker") + +# Domain (required) +APP_DOMAIN = os.environ.get("APP_DOMAIN", "") + +# SSH for verification only SSH_KEY = os.environ.get("SSH_KEY", "/a0/usr/workdir/.ssh/coolify-01-root") SERVER_IP = os.environ.get("SERVER_IP", "46.225.91.159") -# Domain for HTTP health / login checks -APP_DOMAIN = os.environ.get("APP_DOMAIN", "https://crm.media-on.de") - # Login test credentials (read-only verification) -LOGIN_EMAIL = os.environ.get("LOGIN_EMAIL", "admin@media-on.de") -LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "Admin123!") +LOGIN_EMAIL = os.environ.get("LOGIN_EMAIL", "") +LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "") -# Worker docker-compose definition using ${VARIABLE} syntax. -# Secrets are managed via Coolify's ENV API (POST/PATCH /services/{uuid}/envs). -# Coolify auto-generates the .env file from these variables during deploy. -WORKER_COMPOSE_YAML = """\ -services: - worker: - image: 'stvabl4vaqru7jclx4ittzr3:latest' - restart: unless-stopped - entrypoint: - - /app/worker.sh - environment: - DATABASE_URL: 'postgresql+asyncpg://crm_worker:${DB_PASSWORD}@crm-postgres:5432/crm_db' - WORKER_DATABASE_URL: 'postgresql+asyncpg://crm_worker:${DB_PASSWORD}@crm-postgres:5432/crm_db' - MIGRATION_DATABASE_URL: 'postgresql+asyncpg://crm_user:${DB_PASSWORD}@crm-postgres:5432/crm_db' - AUTH_DATABASE_URL: 'postgresql+asyncpg://crm_auth:${DB_PASSWORD}@crm-postgres:5432/crm_db' - REDIS_URL: 'redis://default:${REDIS_PASSWORD}@crm-redis:6379/0' - SECRET_KEY: ${SECRET_KEY} - ENVIRONMENT: ${ENVIRONMENT} - STORAGE_PATH: ${STORAGE_PATH} - COOLIFY_RESOURCE_UUID: asxqaq3566to108xordck0ff - COOLIFY_CONTAINER_NAME: worker-asxqaq3566to108xordck0ff - SERVICE_NAME_WORKER: worker - volumes: - - 'asxqaq3566to108xordck0ff_leocrm-worker-storage:/data/storage' - networks: - - coolify - - asxqaq3566to108xordck0ff - container_name: worker-asxqaq3566to108xordck0ff - labels: - - coolify.managed=true - - coolify.version=4.0.0-beta.470 - - coolify.serviceId=277 - - coolify.type=service - - coolify.name=worker-asxqaq3566to108xordck0ff - - coolify.resourceName=leocrm-worker - - coolify.projectName=crm - - coolify.serviceName=worker - - coolify.environmentName=production - - coolify.pullRequestId=0 - - coolify.service.subId=500 - - coolify.service.subType=application - - coolify.service.subName=worker -volumes: - leocrm-worker-storage: - name: leocrm-worker-storage - asxqaq3566to108xordck0ff_leocrm-worker-storage: - name: asxqaq3566to108xordck0ff_leocrm-worker-storage -networks: - coolify: - external: true - name: coolify - asxqaq3566to108xordck0ff: - name: asxqaq3566to108xordck0ff - external: true -""" +# Secrets from env (no hardcoded values) +DB_PASSWORD = os.environ.get("DB_PASSWORD", "") +REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") +SECRET_KEY = os.environ.get("SECRET_KEY", "") -# Worker ENV variables to set via Coolify API. -# For different deployments (dev/staging/prod), change these values. -WORKER_ENVS = [ - {"key": "DB_PASSWORD", "value": "4B6X2wlfbIx-PyaG8kGutsatdLbjdBUI"}, - {"key": "REDIS_PASSWORD", "value": "lAjCaTf3XFP5XSaPJ1HElgLAJhQQswLT"}, - {"key": "SECRET_KEY", "value": "vVdAnvyc-ob4myE5D1rAYn-SovzoBfQLP1z4wmWteTmFPV_lveCGIn2upNoiP590"}, - {"key": "ENVIRONMENT", "value": "production"}, - {"key": "STORAGE_PATH", "value": "/data/storage"}, -] +# Database/Redis host names (defaults match Coolify service names) +DB_HOST = os.environ.get("DB_HOST", "crm-postgres") +DB_NAME = os.environ.get("DB_NAME", "crm_db") +REDIS_HOST = os.environ.get("REDIS_HOST", "crm-redis") + +# Git repo for initial deployment +API_GIT_REPO = os.environ.get("API_GIT_REPO", "https://forgejo.media-on.de/Leopoldadmin/leocrm.git") +API_GIT_BRANCH = os.environ.get("API_GIT_BRANCH", "main") # ─── Data Structures ────────────────────────────────────────────────── @@ -149,6 +116,16 @@ class CoolifyClient: # ── Applications ── + def list_applications(self) -> list[dict[str, Any]]: + """List all applications.""" + resp = httpx.get( + f"{self.base_url}/api/v1/applications", + headers=self.headers, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + def deploy_application(self, app_uuid: str) -> dict[str, Any]: """Trigger a build & deploy for an application via Coolify API.""" resp = httpx.post( @@ -170,8 +147,29 @@ class CoolifyClient: resp.raise_for_status() return resp.json() + def update_application(self, app_uuid: str, **fields: Any) -> dict[str, Any]: + """Update application fields (e.g. domains/FQDN).""" + resp = httpx.patch( + f"{self.base_url}/api/v1/applications/{app_uuid}", + headers=self.headers, + json=fields, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + # ── Services (Worker) ── + def list_services(self) -> list[dict[str, Any]]: + """List all services.""" + resp = httpx.get( + f"{self.base_url}/api/v1/services", + headers=self.headers, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + def get_service(self, service_uuid: str) -> dict[str, Any]: """Get service details including status.""" resp = httpx.get( @@ -249,6 +247,133 @@ class CoolifyClient: return resp.json() +# ─── UUID Resolution ────────────────────────────────────────────────── + + +def resolve_app_uuid(client: CoolifyClient) -> str: + """Resolve app UUID from env var or API lookup by name.""" + if APP_UUID: + return APP_UUID + + print(f" COOLIFY_APP_UUID not set — looking up '{APP_NAME}' via Coolify API...") + apps = client.list_applications() + for app in apps: + name = app.get("name", "") + uuid = app.get("uuid", "") + if name == APP_NAME: + print(f" Found: {name} → {uuid}") + return uuid + raise ValueError(f"Application '{APP_NAME}' not found in Coolify") + + +def resolve_worker_uuid(client: CoolifyClient) -> str | None: + """Resolve worker UUID from env var or API lookup by name. + Returns None if worker is not configured (non-fatal).""" + if WORKER_UUID: + return WORKER_UUID + + print(f" COOLIFY_WORKER_UUID not set — looking up '{WORKER_NAME}' via Coolify API...") + try: + services = client.list_services() + for svc in services: + name = svc.get("name", "") + uuid = svc.get("uuid", "") + if name == WORKER_NAME: + print(f" Found: {name} → {uuid}") + return uuid + print(f" Worker '{WORKER_NAME}' not found — worker deploy will be skipped") + return None + except Exception as e: + print(f" Warning: could not list services: {e}") + return None + + +# ─── Worker Compose Generation ──────────────────────────────────────── + + +def generate_worker_compose(app_uuid: str, worker_uuid: str) -> str: + """Generate worker docker-compose YAML dynamically (no hardcoded UUIDs). + + Uses ${VARIABLE} syntax for secrets — Coolify substitutes from ENV. + """ + return ( + "services:\n" + " worker:\n" + f" image: '{app_uuid}:latest'\n" + " restart: unless-stopped\n" + " entrypoint:\n" + " - /app/worker.sh\n" + " environment:\n" + f" DATABASE_URL: 'postgresql+asyncpg://crm_worker:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n" + f" WORKER_DATABASE_URL: 'postgresql+asyncpg://crm_worker:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n" + f" MIGRATION_DATABASE_URL: 'postgresql+asyncpg://crm_user:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n" + f" AUTH_DATABASE_URL: 'postgresql+asyncpg://crm_auth:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n" + f" REDIS_URL: 'redis://default:${{REDIS_PASSWORD}}@{REDIS_HOST}:6379/0'\n" + " SECRET_KEY: ${SECRET_KEY}\n" + " ENVIRONMENT: ${ENVIRONMENT}\n" + " STORAGE_PATH: ${STORAGE_PATH}\n" + f" COOLIFY_RESOURCE_UUID: {worker_uuid}\n" + f" COOLIFY_CONTAINER_NAME: worker-{worker_uuid}\n" + " SERVICE_NAME_WORKER: worker\n" + " volumes:\n" + f" - '{worker_uuid}_leocrm-worker-storage:/data/storage'\n" + " networks:\n" + " - coolify\n" + f" - {worker_uuid}\n" + f" container_name: worker-{worker_uuid}\n" + " labels:\n" + " - coolify.managed=true\n" + " - coolify.version=4.0.0-beta.470\n" + " - coolify.type=service\n" + f" - coolify.name=worker-{worker_uuid}\n" + f" - coolify.resourceName={WORKER_NAME}\n" + " - coolify.serviceName=worker\n" + " - coolify.service.subType=application\n" + " - coolify.service.subName=worker\n" + "volumes:\n" + " leocrm-worker-storage:\n" + " name: leocrm-worker-storage\n" + f" {worker_uuid}_leocrm-worker-storage:\n" + f" name: {worker_uuid}_leocrm-worker-storage\n" + "networks:\n" + " coolify:\n" + " external: true\n" + " name: coolify\n" + f" {worker_uuid}:\n" + f" name: {worker_uuid}\n" + " external: true\n" + ) + + +def get_worker_envs() -> list[dict]: + """Worker ENV variables from environment (no hardcoded secrets).""" + return [ + {"key": "DB_PASSWORD", "value": DB_PASSWORD}, + {"key": "REDIS_PASSWORD", "value": REDIS_PASSWORD}, + {"key": "SECRET_KEY", "value": SECRET_KEY}, + {"key": "ENVIRONMENT", "value": os.environ.get("ENVIRONMENT", "production")}, + {"key": "STORAGE_PATH", "value": os.environ.get("STORAGE_PATH", "/data/storage")}, + ] + + +def get_api_envs() -> list[dict]: + """API ENV variables from environment (no hardcoded secrets).""" + return [ + {"key": "DATABASE_URL", "value": f"postgresql+asyncpg://crm_api:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"}, + {"key": "AUTH_DATABASE_URL", "value": f"postgresql+asyncpg://crm_auth:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"}, + {"key": "WORKER_DATABASE_URL", "value": f"postgresql+asyncpg://crm_worker:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"}, + {"key": "MIGRATION_DATABASE_URL", "value": f"postgresql+asyncpg://crm_user:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"}, + {"key": "REDIS_URL", "value": f"redis://default:{REDIS_PASSWORD}@{REDIS_HOST}:6379/0"}, + {"key": "SECRET_KEY", "value": SECRET_KEY}, + {"key": "ENVIRONMENT", "value": os.environ.get("ENVIRONMENT", "production")}, + {"key": "STORAGE_PATH", "value": os.environ.get("STORAGE_PATH", "/data/storage")}, + {"key": "FRONTEND_URL", "value": APP_DOMAIN}, + {"key": "CORS_ORIGINS", "value": APP_DOMAIN}, + {"key": "SESSION_COOKIE_SECURE", "value": "true"}, + {"key": "LOG_LEVEL", "value": os.environ.get("LOG_LEVEL", "INFO")}, + ] + + # ─── SSH Helper (verification only) ──────────────────────────────────── @@ -268,13 +393,20 @@ def ssh_run(cmd: str, timeout: int = 60) -> tuple[int, str]: # ─── Deploy Steps ────────────────────────────────────────────────────── -def deploy_api(client: CoolifyClient, skip_build: bool = False) -> StepResult: +def deploy_api(client: CoolifyClient, app_uuid: str, skip_build: bool = False) -> StepResult: """Deploy or restart the API application via Coolify API.""" + # Set FQDN via PATCH + if APP_DOMAIN: + print(f" Setting FQDN to {APP_DOMAIN}...") + try: + client.update_application(app_uuid, domains=APP_DOMAIN) + except Exception as e: + print(f" Warning: could not set FQDN: {e}") + if skip_build: print(" Skip-build mode: restarting application via Coolify API...") - # For skip-build we still trigger a deploy — Coolify will use cached image try: - result = client.deploy_application(APP_UUID) + result = client.deploy_application(app_uuid) deploy_uuid = _extract_deploy_uuid(result) if deploy_uuid: print(f" Deploy queued: {deploy_uuid[:12]}") @@ -285,7 +417,7 @@ def deploy_api(client: CoolifyClient, skip_build: bool = False) -> StepResult: print(" Triggering Coolify build & deploy for API...") try: - result = client.deploy_application(APP_UUID) + result = client.deploy_application(app_uuid) deploy_uuid = _extract_deploy_uuid(result) if not deploy_uuid: return StepResult(False, "No deployment UUID returned from Coolify") @@ -295,13 +427,13 @@ def deploy_api(client: CoolifyClient, skip_build: bool = False) -> StepResult: return StepResult(False, f"Deploy trigger failed: {e}") -def deploy_worker(client: CoolifyClient, skip_build: bool = False) -> StepResult: +def deploy_worker(client: CoolifyClient, app_uuid: str, worker_uuid: str, skip_build: bool = False) -> StepResult: """Deploy the worker service via Coolify API. Steps: - 1. Write .env file to server (secrets for ${VARIABLE} substitution) - 2. Update service compose (with ${VARIABLE} syntax, not hardcoded secrets) - 3. Set connect_to_docker_network=True (coolify network for Redis/Postgres) + 1. Update service compose (dynamically generated, ${VARIABLE} syntax) + 2. Set connect_to_docker_network=True (coolify network for Redis/Postgres) + 3. Set ENV variables via Coolify API (secrets from environment) 4. Tag latest API image as :latest (Coolify uses commit-hash tags) 5. Deploy via POST /deploy (creates new container) 6. Wait for healthy @@ -309,16 +441,16 @@ def deploy_worker(client: CoolifyClient, skip_build: bool = False) -> StepResult print(" Deploying worker service via Coolify API...") try: - # Step 1: Update service compose with ${VARIABLE} syntax + # Step 1: Update service compose with dynamic UUIDs and ${VARIABLE} syntax + compose_yaml = generate_worker_compose(app_uuid, worker_uuid) print(" Updating worker service compose...") - client.update_service(WORKER_UUID, WORKER_COMPOSE_YAML) + client.update_service(worker_uuid, compose_yaml) time.sleep(2) # Step 2: Set connect_to_docker_network=True print(" Ensuring coolify network connection...") - import httpx resp = httpx.patch( - f"{client.base_url}/api/v1/services/{WORKER_UUID}", + f"{client.base_url}/api/v1/services/{worker_uuid}", headers=client.headers, json={"connect_to_docker_network": True}, timeout=30, @@ -328,19 +460,16 @@ def deploy_worker(client: CoolifyClient, skip_build: bool = False) -> StepResult # Step 3: Set ENV variables via Coolify API (Coolify auto-generates .env) print(" Setting ENV variables via Coolify API...") - for env in WORKER_ENVS: - # POST creates the ENV variable; if it already exists (409), - # PATCH updates it + for env in get_worker_envs(): resp = httpx.post( - f"{client.base_url}/api/v1/services/{WORKER_UUID}/envs", + f"{client.base_url}/api/v1/services/{worker_uuid}/envs", headers=client.headers, json=env, timeout=30, ) if resp.status_code == 409: - # Already exists — update via PATCH resp = httpx.patch( - f"{client.base_url}/api/v1/services/{WORKER_UUID}/envs", + f"{client.base_url}/api/v1/services/{worker_uuid}/envs", headers=client.headers, json=env, timeout=30, @@ -351,16 +480,16 @@ def deploy_worker(client: CoolifyClient, skip_build: bool = False) -> StepResult # Step 4: Tag the latest API image as :latest print(" Tagging latest API image as :latest...") tag_code, tag_output = ssh_run( - 'docker images --format "{{.Repository}}:{{.Tag}}" | ' - 'grep "^stvabl4vaqru7jclx4ittzr3:" | grep -v latest | head -1 | ' - 'xargs -I{} docker tag {} stvabl4vaqru7jclx4ittzr3:latest' + f'docker images --format "{{{{.Repository}}}}:{{{{.Tag}}}}" | ' + f'grep "^{app_uuid}:" | grep -v latest | head -1 | ' + f'xargs -I{{}} docker tag {{}} {app_uuid}:latest' ) if tag_code != 0: print(f" Warning: could not tag :latest ({tag_output.strip()})") # Step 5: Deploy via POST /deploy print(" Deploying worker service...") - result = client.deploy_application(WORKER_UUID) + result = client.deploy_application(worker_uuid) deploy_uuid = _extract_deploy_uuid(result) if deploy_uuid: print(f" Worker deploy queued: {deploy_uuid[:12]}") @@ -371,7 +500,7 @@ def deploy_worker(client: CoolifyClient, skip_build: bool = False) -> StepResult print(" No deployment UUID returned, waiting for healthy...") # Step 6: Wait for healthy - return _wait_service_healthy(client, WORKER_UUID, timeout=120) + return _wait_service_healthy(client, worker_uuid, timeout=120) except Exception as e: return StepResult(False, f"Worker deploy failed: {e}") @@ -379,11 +508,9 @@ def deploy_worker(client: CoolifyClient, skip_build: bool = False) -> StepResult def _extract_deploy_uuid(result: dict[str, Any]) -> str | None: """Extract deployment UUID from Coolify deploy response.""" - # Coolify returns {"deployments": [{"deployment_uuid": "..."}]} deployments = result.get("deployments", []) if deployments and isinstance(deployments, list): return deployments[0].get("deployment_uuid") - # Some versions return {"deployment_uuid": "..."} directly return result.get("deployment_uuid") @@ -417,7 +544,6 @@ def _wait_service_healthy(client: CoolifyClient, service_uuid: str, timeout: int status = svc.get("status", "unknown") elapsed = int(time.time() - start) print(f" [{elapsed}s] Service status: {status}") - # Only accept exact 'running:healthy' or 'healthy' if status == "running:healthy" or status == "healthy": return StepResult(True, f"Service healthy: {status}", time.time() - start, svc) if "failed" in status.lower() or "error" in status.lower(): @@ -449,6 +575,8 @@ def verify_http_health() -> StepResult: def verify_login() -> StepResult: """Verify login works by sending a test login request.""" + if not LOGIN_EMAIL or not LOGIN_PASSWORD: + return StepResult(True, "Login test skipped (no credentials provided)") print(" Verifying login...") url = f"{APP_DOMAIN}/api/v1/auth/login" try: @@ -482,7 +610,6 @@ def verify_alembic() -> StepResult: version = output.strip() if not version: return StepResult(False, "Could not read Alembic version") - # Accept versions >= 0085 (migrations 0085-0090 handle RLS) version_ok = version >= "0085" return StepResult( version_ok, @@ -502,7 +629,6 @@ def verify_rls() -> StepResult: if not rls_count.isdigit(): return StepResult(False, f"Could not read RLS table count: {output}") count = int(rls_count) - # RLS should be active on all tenant tables (typically 90+) rls_ok = count >= 90 return StepResult( rls_ok, @@ -511,14 +637,13 @@ def verify_rls() -> StepResult: ) -def verify_worker_service(client: CoolifyClient) -> StepResult: +def verify_worker_service(client: CoolifyClient, worker_uuid: str) -> StepResult: """Verify worker service is running via Coolify API.""" print(" Verifying worker service via Coolify API...") try: - svc = client.get_service(WORKER_UUID) + svc = client.get_service(worker_uuid) status = svc.get("status", "unknown") status_lower = status.lower() - # Accept 'running:healthy', 'running', 'healthy', or 'up' if "running" in status_lower or "healthy" in status_lower or status_lower == "up": return StepResult(True, f"Worker service: {status}", details={"status": status}) return StepResult(False, f"Worker service not running: {status}", details={"status": status}) @@ -526,38 +651,33 @@ def verify_worker_service(client: CoolifyClient) -> StepResult: return StepResult(False, f"Worker service check failed: {e}") -def run_verification(client: CoolifyClient, check_worker: bool = True) -> list[tuple[str, StepResult]]: +def run_verification(client: CoolifyClient, worker_uuid: str | None = None) -> list[tuple[str, StepResult]]: """Run all verification checks and return results.""" results: list[tuple[str, StepResult]] = [] - # 1. HTTP health print("\n[Verify] HTTP health check...") r = verify_http_health() results.append(("HTTP health", r)) _print_result(r) - # 2. Login test print("\n[Verify] Login test...") r = verify_login() results.append(("Login test", r)) _print_result(r) - # 3. Alembic version print("\n[Verify] Alembic migration head...") r = verify_alembic() results.append(("Alembic version", r)) _print_result(r) - # 4. RLS tables print("\n[Verify] RLS tables...") r = verify_rls() results.append(("RLS tables", r)) _print_result(r) - # 5. Worker service (optional) - if check_worker: + if worker_uuid: print("\n[Verify] Worker service status...") - r = verify_worker_service(client) + r = verify_worker_service(client, worker_uuid) results.append(("Worker service", r)) _print_result(r) @@ -587,6 +707,19 @@ def print_summary(steps: list[tuple[str, StepResult]]) -> bool: return all_ok +# ─── Config Validation ──────────────────────────────────────────────── + + +def validate_config() -> list[str]: + """Validate required env vars. Returns list of error messages (empty = OK).""" + errors = [] + if not COOLIFY_TOKEN: + errors.append("COOLIFY_API_TOKEN is required") + if not APP_DOMAIN: + errors.append("APP_DOMAIN is required (e.g. https://crm.media-on.de)") + return errors + + # ─── Main Deploy Pipeline ────────────────────────────────────────────── @@ -596,16 +729,31 @@ def deploy_full(skip_build: bool = False) -> int: print(" LeoCRM Full Deploy") print(f"{'='*60}\n") - if not COOLIFY_TOKEN: - print("ERROR: COOLIFY_API_TOKEN not set") + errors = validate_config() + if errors: + for e in errors: + print(f"ERROR: {e}") return 2 client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN) steps: list[tuple[str, StepResult]] = [] + # Resolve UUIDs + print("\n[0/3] Resolving Coolify resources...") + try: + app_uuid = resolve_app_uuid(client) + print(f" App UUID: {app_uuid}") + except Exception as e: + print(f"ERROR: {e}") + return 2 + + worker_uuid = resolve_worker_uuid(client) + if worker_uuid: + print(f" Worker UUID: {worker_uuid}") + # Step 1: Deploy API print("\n[1/3] Deploying API via Coolify API...") - r = deploy_api(client, skip_build=skip_build) + r = deploy_api(client, app_uuid, skip_build=skip_build) steps.append(("API deploy", r)) _print_result(r) if not r.success: @@ -613,18 +761,20 @@ def deploy_full(skip_build: bool = False) -> int: return 1 # Step 2: Deploy Worker - print("\n[2/3] Deploying Worker via Coolify API...") - r = deploy_worker(client, skip_build=skip_build) - steps.append(("Worker deploy", r)) - _print_result(r) - # Worker failure is non-fatal but reported + if worker_uuid: + print("\n[2/3] Deploying Worker via Coolify API...") + r = deploy_worker(client, app_uuid, worker_uuid, skip_build=skip_build) + steps.append(("Worker deploy", r)) + _print_result(r) + else: + print("\n[2/3] Worker deploy skipped (no worker UUID resolved)") + steps.append(("Worker deploy", StepResult(True, "Skipped — no worker configured"))) # Step 3: Verification print("\n[3/3] Running verification...") - verify_results = run_verification(client, check_worker=True) + verify_results = run_verification(client, worker_uuid=worker_uuid) steps.extend(verify_results) - # Summary all_ok = print_summary(steps) return 0 if all_ok else 1 @@ -635,16 +785,31 @@ def deploy_worker_only(skip_build: bool = False) -> int: print(" LeoCRM Worker-Only Deploy") print(f"{'='*60}\n") - if not COOLIFY_TOKEN: - print("ERROR: COOLIFY_API_TOKEN not set") + errors = validate_config() + if errors: + for e in errors: + print(f"ERROR: {e}") return 2 client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN) steps: list[tuple[str, StepResult]] = [] + # Resolve UUIDs + print("\n[0/2] Resolving Coolify resources...") + try: + app_uuid = resolve_app_uuid(client) + except Exception as e: + print(f"ERROR: {e}") + return 2 + + worker_uuid = resolve_worker_uuid(client) + if not worker_uuid: + print("ERROR: No worker UUID resolved (COOLIFY_WORKER_UUID not set and API lookup failed)") + return 2 + # Step 1: Deploy Worker print("\n[1/2] Deploying Worker via Coolify API...") - r = deploy_worker(client, skip_build=skip_build) + r = deploy_worker(client, app_uuid, worker_uuid, skip_build=skip_build) steps.append(("Worker deploy", r)) _print_result(r) if not r.success: @@ -653,7 +818,7 @@ def deploy_worker_only(skip_build: bool = False) -> int: # Step 2: Verification print("\n[2/2] Running verification...") - verify_results = run_verification(client, check_worker=True) + verify_results = run_verification(client, worker_uuid=worker_uuid) steps.extend(verify_results) all_ok = print_summary(steps) @@ -666,100 +831,83 @@ def verify_only() -> int: print(" LeoCRM Verification Only") print(f"{'='*60}\n") - if not COOLIFY_TOKEN: - print("ERROR: COOLIFY_API_TOKEN not set") + errors = validate_config() + if errors: + for e in errors: + print(f"ERROR: {e}") return 2 client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN) - results = run_verification(client, check_worker=True) + worker_uuid = resolve_worker_uuid(client) + results = run_verification(client, worker_uuid=worker_uuid) all_ok = print_summary(results) return 0 if all_ok else 1 -# ─── CLI ─────────────────────────────────────────────────────────────── - - -# ─── Initial deployment: create all Coolify resources from scratch ────────── +# ─── Initial Deployment (create all Coolify resources from scratch) ────── # PostgreSQL Compose (pgvector for embeddings) -POSTGRES_COMPOSE = """\ -services: - postgres: - image: pgvector/pgvector:pg16 - restart: unless-stopped - environment: - POSTGRES_USER: ${DB_USER} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_NAME} - PGDATA: /var/lib/postgresql/data/pgdata - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${DB_USER} -d ${DB_NAME}'] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s -volumes: - pgdata: -""" +POSTGRES_COMPOSE = ( + "services:\n" + " postgres:\n" + " image: pgvector/pgvector:pg16\n" + " restart: unless-stopped\n" + " environment:\n" + " POSTGRES_USER: ${DB_USER}\n" + " POSTGRES_PASSWORD: ${DB_PASSWORD}\n" + " POSTGRES_DB: ${DB_NAME}\n" + " PGDATA: /var/lib/postgresql/data/pgdata\n" + " volumes:\n" + " - pgdata:/var/lib/postgresql/data\n" + " healthcheck:\n" + " test: ['CMD-SHELL', 'pg_isready -U ${DB_USER} -d ${DB_NAME}']\n" + " interval: 10s\n" + " timeout: 5s\n" + " retries: 5\n" + " start_period: 10s\n" + "volumes:\n" + " pgdata:\n" +) # Redis Compose -REDIS_COMPOSE = """\ -services: - redis: - image: redis:7-alpine - restart: unless-stopped - command: redis-server --requirepass ${REDIS_PASSWORD} - volumes: - - redisdata:/data - healthcheck: - test: ['CMD-SHELL', 'redis-cli ping || exit 1'] - interval: 10s - timeout: 5s - retries: 5 -volumes: - redisdata: -""" +REDIS_COMPOSE = ( + "services:\n" + " redis:\n" + " image: redis:7-alpine\n" + " restart: unless-stopped\n" + " command: redis-server --requirepass ${REDIS_PASSWORD}\n" + " volumes:\n" + " - redisdata:/data\n" + " healthcheck:\n" + " test: ['CMD-SHELL', 'redis-cli ping || exit 1']\n" + " interval: 10s\n" + " timeout: 5s\n" + " retries: 5\n" + "volumes:\n" + " redisdata:\n" +) -# ENV variables for PostgreSQL service -POSTGRES_ENVS = [ - {"key": "DB_USER", "value": "crm_user"}, - {"key": "DB_PASSWORD", "value": "4B6X2wlfbIx-PyaG8kGutsatdLbjdBUI"}, - {"key": "DB_NAME", "value": "crm_db"}, -] -# ENV variables for Redis service -REDIS_ENVS = [ - {"key": "REDIS_PASSWORD", "value": "lAjCaTf3XFP5XSaPJ1HElgLAJhQQswLT"}, -] +def get_postgres_envs() -> list[dict]: + """PostgreSQL ENV variables from environment.""" + return [ + {"key": "DB_USER", "value": os.environ.get("DB_USER", "crm_user")}, + {"key": "DB_PASSWORD", "value": DB_PASSWORD}, + {"key": "DB_NAME", "value": DB_NAME}, + ] -# API Application configuration -API_GIT_REPO = os.environ.get("API_GIT_REPO", "https://forgejo.media-on.de/Leopoldadmin/leocrm.git") -API_GIT_BRANCH = os.environ.get("API_GIT_BRANCH", "main") -# API ENV variables -API_ENVS = [ - {"key": "DATABASE_URL", "value": "postgresql+asyncpg://crm_api:4B6X2wlfbIx-PyaG8kGutsatdLbjdBUI@crm-postgres:5432/crm_db"}, - {"key": "AUTH_DATABASE_URL", "value": "postgresql+asyncpg://crm_auth:4B6X2wlfbIx-PyaG8kGutsatdLbjdBUI@crm-postgres:5432/crm_db"}, - {"key": "WORKER_DATABASE_URL", "value": "postgresql+asyncpg://crm_worker:4B6X2wlfbIx-PyaG8kGutsatdLbjdBUI@crm-postgres:5432/crm_db"}, - {"key": "MIGRATION_DATABASE_URL", "value": "postgresql+asyncpg://crm_user:4B6X2wlfbIx-PyaG8kGutsatdLbjdBUI@crm-postgres:5432/crm_db"}, - {"key": "REDIS_URL", "value": "redis://default:lAjCaTf3XFP5XSaPJ1HElgLAJhQQswLT@crm-redis:6379/0"}, - {"key": "SECRET_KEY", "value": "vVdAnvyc-ob4myE5D1rAYn-SovzoBfQLP1z4wmWteTmFPV_lveCGIn2upNoiP590"}, - {"key": "ENVIRONMENT", "value": "production"}, - {"key": "STORAGE_PATH", "value": "/data/storage"}, - {"key": "FRONTEND_URL", "value": "https://crm.media-on.de"}, - {"key": "CORS_ORIGINS", "value": "https://crm.media-on.de"}, - {"key": "SESSION_COOKIE_SECURE", "value": "true"}, - {"key": "LOG_LEVEL", "value": "INFO"}, -] +def get_redis_envs() -> list[dict]: + """Redis ENV variables from environment.""" + return [ + {"key": "REDIS_PASSWORD", "value": REDIS_PASSWORD}, + ] def create_service(client: CoolifyClient, project_uuid: str, environment_name: str, server_uuid: str, compose_raw: str, name: str) -> str | None: """Create a Coolify service from a docker-compose definition. Returns the service UUID or None on failure.""" - import base64 encoded = base64.b64encode(compose_raw.encode()).decode() try: resp = httpx.post( @@ -804,20 +952,36 @@ def set_service_envs(client: CoolifyClient, service_uuid: str, envs: list[dict]) print(f" Warning: could not set ENV {env['key']} ({resp.status_code})") +def set_application_envs(client: CoolifyClient, app_uuid: str, envs: list[dict]) -> None: + """Set ENV variables for an application via Coolify API.""" + for env in envs: + resp = httpx.post( + f"{client.base_url}/api/v1/applications/{app_uuid}/envs", + headers=client.headers, + json=env, + timeout=30, + ) + if resp.status_code == 409: + resp = httpx.patch( + f"{client.base_url}/api/v1/applications/{app_uuid}/envs", + headers=client.headers, + json=env, + timeout=30, + ) + if resp.status_code not in (200, 201): + print(f" Warning: could not set ENV {env['key']} ({resp.status_code})") + + def create_api_application(client: CoolifyClient, project_uuid: str, environment_name: str, server_uuid: str) -> str | None: """Create the API application from a Git repository via private deploy key. - Uses POST /applications/private-deploy-key with the Coolify host's private key. - This creates a Git-based application that can auto-update on git push. - Returns the application UUID or None on failure. """ - # The private key UUID for the Coolify host (localhost) - PRIVATE_KEY_UUID = os.environ.get( - "COOLIFY_PRIVATE_KEY_UUID", - "rgcsc0048c04csckk8kogk40", - ) + private_key_uuid = os.environ.get("COOLIFY_PRIVATE_KEY_UUID", "") + if not private_key_uuid: + print(" Error: COOLIFY_PRIVATE_KEY_UUID not set") + return None try: resp = httpx.post( @@ -827,11 +991,11 @@ def create_api_application(client: CoolifyClient, project_uuid: str, "project_uuid": project_uuid, "environment_name": environment_name, "server_uuid": server_uuid, - "private_key_uuid": PRIVATE_KEY_UUID, + "private_key_uuid": private_key_uuid, "git_repository": API_GIT_REPO, "git_branch": API_GIT_BRANCH, "build_pack": "dockerfile", - "name": "leocrm-api", + "name": APP_NAME, "ports_exposes": "8000", }, timeout=30, @@ -862,26 +1026,39 @@ def deploy_initial() -> int: print(" LeoCRM Initial Deployment") print(f"{'='*60}\n") - if not COOLIFY_TOKEN: - print("ERROR: COOLIFY_API_TOKEN not set") + errors = validate_config() + # For initial deploy, also require secrets and Coolify resource UUIDs + if not DB_PASSWORD: + errors.append("DB_PASSWORD is required for initial deployment") + if not REDIS_PASSWORD: + errors.append("REDIS_PASSWORD is required for initial deployment") + if not SECRET_KEY: + errors.append("SECRET_KEY is required for initial deployment") + if not os.environ.get("COOLIFY_PROJECT_UUID"): + errors.append("COOLIFY_PROJECT_UUID is required for initial deployment") + if not os.environ.get("COOLIFY_SERVER_UUID"): + errors.append("COOLIFY_SERVER_UUID is required for initial deployment") + if not os.environ.get("COOLIFY_PRIVATE_KEY_UUID"): + errors.append("COOLIFY_PRIVATE_KEY_UUID is required for initial deployment") + if errors: + for e in errors: + print(f"ERROR: {e}") return 2 client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN) steps: list[tuple[str, StepResult]] = [] - # Get project, environment, server - PROJECT_UUID = os.environ.get("COOLIFY_PROJECT_UUID", "damkcrjuy4cjofo954ahmooe") - ENVIRONMENT_NAME = os.environ.get("COOLIFY_ENVIRONMENT", "production") - SERVER_UUID = os.environ.get("COOLIFY_SERVER_UUID", "lw80w8scs444gwcw084s00s4") + project_uuid = os.environ.get("COOLIFY_PROJECT_UUID", "") + environment_name = os.environ.get("COOLIFY_ENVIRONMENT", "production") + server_uuid = os.environ.get("COOLIFY_SERVER_UUID", "") # Step 1: Create PostgreSQL service print("\n[1/7] Creating PostgreSQL service...") - pg_uuid = create_service(client, PROJECT_UUID, ENVIRONMENT_NAME, SERVER_UUID, + pg_uuid = create_service(client, project_uuid, environment_name, server_uuid, POSTGRES_COMPOSE, "crm-postgres") if pg_uuid: print(f" PostgreSQL service created: {pg_uuid[:12]}") - set_service_envs(client, pg_uuid, POSTGRES_ENVS) - # Deploy to start the container + set_service_envs(client, pg_uuid, get_postgres_envs()) client.deploy_application(pg_uuid) time.sleep(10) steps.append(("PostgreSQL service", StepResult(True, f"Created: {pg_uuid[:12]}"))) @@ -893,11 +1070,11 @@ def deploy_initial() -> int: # Step 2: Create Redis service print("\n[2/7] Creating Redis service...") - redis_uuid = create_service(client, PROJECT_UUID, ENVIRONMENT_NAME, SERVER_UUID, + redis_uuid = create_service(client, project_uuid, environment_name, server_uuid, REDIS_COMPOSE, "crm-redis") if redis_uuid: print(f" Redis service created: {redis_uuid[:12]}") - set_service_envs(client, redis_uuid, REDIS_ENVS) + set_service_envs(client, redis_uuid, get_redis_envs()) client.deploy_application(redis_uuid) time.sleep(10) steps.append(("Redis service", StepResult(True, f"Created: {redis_uuid[:12]}"))) @@ -909,24 +1086,13 @@ def deploy_initial() -> int: # Step 3: Create API application print("\n[3/7] Creating API application...") - api_uuid = create_api_application(client, PROJECT_UUID, ENVIRONMENT_NAME, SERVER_UUID) + api_uuid = create_api_application(client, project_uuid, environment_name, server_uuid) if api_uuid: print(f" API application created: {api_uuid[:12]}") - # Set API ENV variables - for env in API_ENVS: - resp = httpx.post( - f"{client.base_url}/api/v1/applications/{api_uuid}/envs", - headers=client.headers, - json=env, - timeout=30, - ) - if resp.status_code == 409: - resp = httpx.patch( - f"{client.base_url}/api/v1/applications/{api_uuid}/envs", - headers=client.headers, - json=env, - timeout=30, - ) + set_application_envs(client, api_uuid, get_api_envs()) + # Set FQDN + if APP_DOMAIN: + client.update_application(api_uuid, domains=APP_DOMAIN) steps.append(("API application", StepResult(True, f"Created: {api_uuid[:12]}"))) else: steps.append(("API application", StepResult(False, "Failed to create"))) @@ -936,15 +1102,16 @@ def deploy_initial() -> int: # Step 4: Create Worker service (with API image reference) print("\n[4/7] Creating Worker service...") - # Generate worker compose with the API application's UUID as image name - worker_compose = WORKER_COMPOSE_YAML.replace( - "stvabl4vaqru7jclx4ittzr3:latest", - f"{api_uuid}:latest", - ) - worker_uuid = create_service(client, PROJECT_UUID, ENVIRONMENT_NAME, SERVER_UUID, - worker_compose, "leocrm-worker") + worker_compose = generate_worker_compose(api_uuid, "PLACEHOLDER") + # For initial creation, use a temporary compose — Coolify will assign UUID + # We'll update it after creation with the real UUID + worker_uuid = create_service(client, project_uuid, environment_name, server_uuid, + worker_compose, WORKER_NAME) if worker_uuid: print(f" Worker service created: {worker_uuid[:12]}") + # Update compose with real worker UUID + real_compose = generate_worker_compose(api_uuid, worker_uuid) + client.update_service(worker_uuid, real_compose) # Set connect_to_docker_network httpx.patch( f"{client.base_url}/api/v1/services/{worker_uuid}", @@ -952,7 +1119,7 @@ def deploy_initial() -> int: json={"connect_to_docker_network": True}, timeout=30, ) - set_service_envs(client, worker_uuid, WORKER_ENVS) + set_service_envs(client, worker_uuid, get_worker_envs()) steps.append(("Worker service", StepResult(True, f"Created: {worker_uuid[:12]}"))) else: steps.append(("Worker service", StepResult(False, "Failed to create"))) @@ -962,7 +1129,6 @@ def deploy_initial() -> int: # Step 5: Deploy API (builds image + runs migrations via prestart.sh) print("\n[5/7] Deploying API (build + migrations)...") - # Use the newly created API application UUID for deployment try: result = client.deploy_application(api_uuid) deploy_uuid = _extract_deploy_uuid(result) @@ -981,13 +1147,11 @@ def deploy_initial() -> int: # Step 6: Deploy Worker print("\n[6/7] Deploying Worker...") - # Tag the latest API image as :latest (using the new API UUID) tag_code, tag_output = ssh_run( f'docker images --format "{{{{.Repository}}}}:{{{{.Tag}}}}" | ' f'grep "^{api_uuid}:" | grep -v latest | head -1 | ' f'xargs -I{{}} docker tag {{}} {api_uuid}:latest' ) - # Deploy worker result = client.deploy_application(worker_uuid) deploy_uuid = _extract_deploy_uuid(result) if deploy_uuid: @@ -1000,13 +1164,16 @@ def deploy_initial() -> int: # Step 7: Verification print("\n[7/7] Running verification...") - verify_results = run_verification(client, check_worker=True) + verify_results = run_verification(client, worker_uuid=worker_uuid) steps.extend(verify_results) all_ok = print_summary(steps) return 0 if all_ok else 1 +# ─── CLI ─────────────────────────────────────────────────────────────── + + def main() -> None: parser = argparse.ArgumentParser( description="LeoCRM automated deployment script (Coolify API only)", @@ -1017,6 +1184,7 @@ Examples: python scripts/deploy.py --skip-build # Skip build, just restart python scripts/deploy.py --worker-only # Only deploy worker python scripts/deploy.py --verify-only # Only run verification + python scripts/deploy.py --initial # Create all resources from scratch """, ) parser.add_argument( diff --git a/scripts/fast-deploy.sh b/scripts/fast-deploy.sh index fe42ce6..3212ef7 100644 --- a/scripts/fast-deploy.sh +++ b/scripts/fast-deploy.sh @@ -4,36 +4,103 @@ # bash scripts/fast-deploy.sh frontend # Frontend-only: build + copy (~20s) # bash scripts/fast-deploy.sh full # Full: Coolify rebuild (~2min) # Default: frontend +# +# Environment variables: +# COOLIFY_API_TOKEN — Coolify API token (required for full deploy) +# COOLIFY_BASE_URL — Coolify base URL (default: https://server.media-on.de) +# COOLIFY_APP_UUID — Application UUID (optional, resolved via API if absent) +# APP_NAME — Application name for API lookup (default: leocrm-api) +# APP_DOMAIN — App domain (required for full deploy, e.g. https://crm.media-on.de) +# SSH_KEY — SSH key path (default: /a0/usr/workdir/.ssh/coolify-01-root) +# SERVER_IP — Server IP (default: 46.225.91.159) set -euo pipefail -SERVER="root@46.225.91.159" SSH_KEY="${SSH_KEY:-/a0/usr/workdir/.ssh/coolify-01-root}" +SERVER_IP="${SERVER_IP:-46.225.91.159}" +SERVER="root@${SERVER_IP}" SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY" MODE="${1:-frontend}" -cd /a0/usr/workdir/leocrm-fix +# Resolve repo root from script location +cd "$(dirname "$0")/.." +REPO_ROOT="$(pwd)" +# ─── Helper: Resolve app UUID via Coolify API ──────────────────────── +resolve_app_uuid() { + local token="${COOLIFY_API_TOKEN:-}" + local base_url="${COOLIFY_BASE_URL:-https://server.media-on.de}" + local app_name="${APP_NAME:-leocrm-api}" + + if [ -z "$token" ]; then + echo "ERROR: COOLIFY_API_TOKEN not set" >&2 + return 1 + fi + + curl -sS -H "Authorization: Bearer $token" \ + "${base_url}/api/v1/applications" \ + | python3 -c " +import sys, json +apps = json.load(sys.stdin) +for app in apps: + if app.get('name') == '${app_name}': + print(app.get('uuid', '')) + break +" 2>/dev/null +} + +# ─── Full Deploy ───────────────────────────────────────────────────── if [ "$MODE" = "full" ]; then echo "[Full Deploy] Triggering Coolify rebuild..." - COOLIFY_API_TOKEN="${COOLIFY_API_TOKEN:-}" \ - COOLIFY_APP_UUID='stvabl4vaqru7jclx4ittzr3' \ - COOLIFY_BASE_URL='https://server.media-on.de' \ + + if [ -z "${COOLIFY_API_TOKEN:-}" ]; then + echo "ERROR: COOLIFY_API_TOKEN not set" >&2 + exit 1 + fi + if [ -z "${APP_DOMAIN:-}" ]; then + echo "ERROR: APP_DOMAIN not set (e.g. https://crm.media-on.de)" >&2 + exit 1 + fi + + COOLIFY_API_TOKEN="$COOLIFY_API_TOKEN" \ + COOLIFY_BASE_URL="${COOLIFY_BASE_URL:-https://server.media-on.de}" \ + COOLIFY_APP_UUID="${COOLIFY_APP_UUID:-}" \ + COOLIFY_WORKER_UUID="${COOLIFY_WORKER_UUID:-}" \ + APP_NAME="${APP_NAME:-leocrm-api}" \ + WORKER_NAME="${WORKER_NAME:-leocrm-worker}" \ + APP_DOMAIN="$APP_DOMAIN" \ SSH_KEY="$SSH_KEY" \ - SERVER_IP='46.225.91.159' \ - /opt/venv/bin/python scripts/deploy.py + SERVER_IP="$SERVER_IP" \ + /opt/venv/bin/python "$REPO_ROOT/scripts/deploy.py" exit 0 fi -# Frontend-only fast deploy +# ─── Frontend-only Fast Deploy ────────────────────────────────────── + +# Resolve app UUID +APP_UUID="${COOLIFY_APP_UUID:-}" +if [ -z "$APP_UUID" ]; then + if [ -z "${COOLIFY_API_TOKEN:-}" ]; then + echo "ERROR: COOLIFY_APP_UUID or COOLIFY_API_TOKEN must be set" >&2 + exit 1 + fi + echo "[1/6] Resolving app UUID via Coolify API..." + APP_UUID="$(resolve_app_uuid)" + if [ -z "$APP_UUID" ]; then + echo "ERROR: Could not resolve app UUID" >&2 + exit 1 + fi + echo " App UUID: $APP_UUID" +fi + echo "[1/5] Building frontend..." -cd frontend +cd "$REPO_ROOT/frontend" npm run build 2>&1 | tail -3 echo "[2/5] Finding container..." -CONTAINER=$(ssh $SSH_OPTS $SERVER 'docker ps --format "{{.Names}}" | grep "^stvabl4vaqru7jclx4ittzr3-" | head -1') +CONTAINER=$(ssh $SSH_OPTS $SERVER "docker ps --format '{{.Names}}' | grep '^${APP_UUID}-' | head -1") if [ -z "$CONTAINER" ]; then - echo "ERROR: Could not find app container" + echo "ERROR: Could not find app container with prefix ${APP_UUID}-" >&2 exit 1 fi echo " Container: $CONTAINER" diff --git a/scripts/fast-frontend-deploy.sh b/scripts/fast-frontend-deploy.sh deleted file mode 100755 index 6dc5cc9..0000000 --- a/scripts/fast-frontend-deploy.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Fast frontend-only deploy: build locally, copy to container, restart. -# Usage: bash scripts/fast-frontend-deploy.sh -# Skips Coolify rebuild entirely. ~20s total instead of ~2min. - -set -euo pipefail - -CONTAINER="stvabl4vaqru7jclx4ittzr3-153149570355" -SERVER="root@46.225.91.159" -SSH_KEY="${SSH_KEY:-/a0/usr/workdir/.ssh/coolify-01-root}" -SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY" - -echo "[1/4] Building frontend..." -cd /a0/usr/workdir/leocrm-fix/frontend -npm run build 2>&1 | tail -3 - -echo "[2/4] Packaging & uploading..." -tar czf /tmp/frontend-dist.tar.gz -C dist . -scp $SSH_OPTS /tmp/frontend-dist.tar.gz $SERVER:/tmp/frontend-dist.tar.gz - -echo "[3/4] Copying into container..." -ssh $SSH_OPTS $SERVER "docker cp /tmp/frontend-dist.tar.gz $CONTAINER:/tmp/ && docker exec $CONTAINER sh -c 'cd /app/frontend/dist && tar xzf /tmp/frontend-dist.tar.gz --overwrite && rm /tmp/frontend-dist.tar.gz' && rm /tmp/frontend-dist.tar.gz" - -echo "[4/4] Restarting container (clears service worker cache)..." -ssh $SSH_OPTS $SERVER "docker restart $CONTAINER" 2>/dev/null || true -sleep 3 - -echo "✅ Fast deploy done (~20s). Container restarted."