#!/usr/bin/env python3 """Automated deployment script for LeoCRM via Coolify API. All container management is done through the Coolify API — no manual 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_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 2 — configuration error """ from __future__ import annotations import argparse import base64 import os import subprocess import sys import time from dataclasses import dataclass, field from typing import Any import httpx # ─── 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", "") # 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 # Derive from APP_DOMAIN if not set (e.g. https://crm.media-on.de → crm) _domain_default = "" if os.environ.get("APP_DOMAIN"): try: _domain_default = os.environ["APP_DOMAIN"].split("//")[1].split(".")[0] except (IndexError, ValueError): pass APP_NAME = os.environ.get("APP_NAME", _domain_default or "app") WORKER_NAME = os.environ.get("WORKER_NAME", f"{APP_NAME}-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") # Login test credentials (read-only verification) LOGIN_EMAIL = os.environ.get("LOGIN_EMAIL", "") LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "") # 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", "") # 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 ────────────────────────────────────────────────── @dataclass class StepResult: success: bool message: str duration_s: float = 0.0 details: dict[str, Any] = field(default_factory=dict) # ─── Coolify API Client ──────────────────────────────────────────────── class CoolifyClient: """Client for Coolify API operations.""" def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip("/") self.headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } # ── 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( f"{self.base_url}/api/v1/deploy", headers=self.headers, json={"uuid": app_uuid}, timeout=30, ) resp.raise_for_status() return resp.json() def get_application(self, app_uuid: str) -> dict[str, Any]: """Get application details including status.""" resp = httpx.get( f"{self.base_url}/api/v1/applications/{app_uuid}", headers=self.headers, timeout=30, ) 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( f"{self.base_url}/api/v1/services/{service_uuid}", headers=self.headers, timeout=30, ) resp.raise_for_status() return resp.json() def update_service(self, service_uuid: str, docker_compose_raw: str) -> dict[str, Any]: """Update a service's docker_compose_raw (base64-encoded).""" encoded = base64.b64encode(docker_compose_raw.encode()).decode() resp = httpx.patch( f"{self.base_url}/api/v1/services/{service_uuid}", headers=self.headers, json={"docker_compose_raw": encoded}, timeout=30, ) resp.raise_for_status() return resp.json() def start_service(self, service_uuid: str) -> dict[str, Any]: """Start a service.""" resp = httpx.post( f"{self.base_url}/api/v1/services/{service_uuid}/start", headers=self.headers, timeout=30, ) resp.raise_for_status() return resp.json() def stop_service(self, service_uuid: str) -> dict[str, Any]: """Stop a service.""" resp = httpx.post( f"{self.base_url}/api/v1/services/{service_uuid}/stop", headers=self.headers, timeout=30, ) resp.raise_for_status() return resp.json() def restart_service(self, service_uuid: str) -> dict[str, Any]: """Restart a service.""" resp = httpx.post( f"{self.base_url}/api/v1/services/{service_uuid}/restart", headers=self.headers, timeout=30, ) resp.raise_for_status() return resp.json() # ── Deployments ── def get_deployment(self, deployment_uuid: str) -> dict[str, Any]: """Get deployment status.""" resp = httpx.get( f"{self.base_url}/api/v1/deployments/{deployment_uuid}", headers=self.headers, timeout=30, ) resp.raise_for_status() return resp.json() # ── Health ── def get_health(self) -> dict[str, Any]: """Check Coolify system health.""" resp = httpx.get( f"{self.base_url}/api/v1/health", headers=self.headers, timeout=15, ) resp.raise_for_status() 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) ──────────────────────────────────── def ssh_run(cmd: str, timeout: int = 60) -> tuple[int, str]: """Run a command on the server via SSH — used ONLY for verification.""" full_cmd = [ "ssh", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", f"root@{SERVER_IP}", cmd, ] result = subprocess.run(full_cmd, capture_output=True, text=True, timeout=timeout) return result.returncode, result.stdout + result.stderr # ─── Deploy Steps ────────────────────────────────────────────────────── def deploy_api(client: CoolifyClient, app_uuid: str, skip_build: bool = False) -> StepResult: """Deploy or restart the application via Coolify API. Uses /api/v1/deploy which works for both dockerfile and dockercompose build packs.""" # 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}") print(" Triggering Coolify deploy via /api/v1/deploy...") try: resp = httpx.post( f"{client.base_url}/api/v1/deploy", headers=client.headers, json={"uuid": app_uuid}, timeout=30, ) if resp.status_code == 200: deployments = resp.json().get("deployments", []) if deployments: deploy_uuid = deployments[0].get("deployment_uuid", "") if deploy_uuid: print(f" Deploy queued: {deploy_uuid[:12]}") return _wait_deployment(client, deploy_uuid, timeout=600) return StepResult(True, "Deploy triggered (no UUID returned)") else: return StepResult(False, f"Deploy failed: {resp.status_code} {resp.text[:200]}") except Exception as e: return StepResult(False, f"Deploy trigger failed: {e}") 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. 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 """ print(" Deploying worker service via Coolify API...") try: # 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, compose_yaml) time.sleep(2) # Step 2: Set connect_to_docker_network=True print(" Ensuring coolify network connection...") resp = httpx.patch( f"{client.base_url}/api/v1/services/{worker_uuid}", headers=client.headers, json={"connect_to_docker_network": True}, timeout=30, ) if resp.status_code != 200: print(f" Warning: could not set connect_to_docker_network ({resp.status_code})") # Step 3: Set ENV variables via Coolify API (Coolify auto-generates .env) print(" Setting ENV variables via Coolify API...") for env in get_worker_envs(): resp = httpx.post( f"{client.base_url}/api/v1/services/{worker_uuid}/envs", headers=client.headers, json=env, timeout=30, ) if resp.status_code == 409: resp = httpx.patch( f"{client.base_url}/api/v1/services/{worker_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})") # Step 4: Tag the latest API image as :latest print(" Tagging latest API image as :latest...") tag_code, tag_output = ssh_run( 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) deploy_uuid = _extract_deploy_uuid(result) if deploy_uuid: print(f" Worker deploy queued: {deploy_uuid[:12]}") dep_result = _wait_deployment(client, deploy_uuid, timeout=120) if not dep_result.success: return dep_result else: print(" No deployment UUID returned, waiting for healthy...") # Step 6: Wait for healthy return _wait_service_healthy(client, worker_uuid, timeout=120) except Exception as e: return StepResult(False, f"Worker deploy failed: {e}") def _extract_deploy_uuid(result: dict[str, Any]) -> str | None: """Extract deployment UUID from Coolify deploy response.""" deployments = result.get("deployments", []) if deployments and isinstance(deployments, list): return deployments[0].get("deployment_uuid") return result.get("deployment_uuid") def _wait_deployment(client: CoolifyClient, deploy_uuid: str, timeout: int = 300) -> StepResult: """Wait for a Coolify deployment to reach success/failed status.""" print(f" Waiting for deployment {deploy_uuid[:12]}...") start = time.time() while time.time() - start < timeout: try: dep = client.get_deployment(deploy_uuid) status = dep.get("status", "unknown") elapsed = int(time.time() - start) print(f" [{elapsed}s] Deployment status: {status}") if status in ("success", "finished"): return StepResult(True, "Deployment successful", time.time() - start, dep) if status == "failed": return StepResult(False, f"Deployment failed: {dep.get('message', 'unknown')}", time.time() - start, dep) except Exception as e: print(f" Warning: API error: {e}") time.sleep(10) return StepResult(False, f"Deployment timed out after {timeout}s", time.time() - start) def _wait_service_healthy(client: CoolifyClient, service_uuid: str, timeout: int = 120) -> StepResult: """Wait for a Coolify service to reach running:healthy status.""" print(f" Waiting for service {service_uuid[:12]} to become healthy...") start = time.time() while time.time() - start < timeout: try: svc = client.get_service(service_uuid) status = svc.get("status", "unknown") elapsed = int(time.time() - start) print(f" [{elapsed}s] Service status: {status}") 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(): return StepResult(False, f"Service failed: {status}", time.time() - start, svc) except Exception as e: print(f" Warning: API error: {e}") time.sleep(5) return StepResult(False, f"Service did not become healthy in {timeout}s", time.time() - start) # ─── Verification ────────────────────────────────────────────────────── def verify_http_health() -> StepResult: """Verify the API is healthy via HTTP endpoint.""" print(" Verifying API health via HTTP...") url = f"{APP_DOMAIN}/api/v1/health" try: resp = httpx.get(url, timeout=30, follow_redirects=True) if resp.status_code == 200: body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text if isinstance(body, dict) and body.get("status", "").lower() in ("healthy", "ok"): return StepResult(True, f"Health check passed: {body}") return StepResult(True, f"Health check HTTP 200: {body}") return StepResult(False, f"Health check failed: HTTP {resp.status_code}") except Exception as e: return StepResult(False, f"Health check error: {e}") 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: resp = httpx.post( url, json={"email": LOGIN_EMAIL, "password": LOGIN_PASSWORD}, headers={"Origin": APP_DOMAIN}, timeout=30, follow_redirects=True, ) if resp.status_code == 200: body = resp.json() token = body.get("access_token") or body.get("token") if token: return StepResult(True, "Login successful — token received") return StepResult(True, f"Login HTTP 200: {list(body.keys())}") if resp.status_code == 422: return StepResult(False, f"Login validation error (422): {resp.text[:200]}") return StepResult(False, f"Login failed: HTTP {resp.status_code}") except Exception as e: return StepResult(False, f"Login error: {e}") def verify_alembic() -> StepResult: """Verify Alembic migration head via SSH (Coolify API doesn't expose DB internals).""" print(" Verifying Alembic migration head...") code, output = ssh_run( 'docker exec crm-postgres psql -U crm_user -d crm_db -t -c ' '"SELECT version_num FROM alembic_version" 2>/dev/null' ) version = output.strip() if not version: return StepResult(False, "Could not read Alembic version") version_ok = version >= "0085" return StepResult( version_ok, f"Alembic version: {version} ({'OK' if version_ok else 'BEHIND — expected >= 0085'})", details={"alembic_version": version}, ) def verify_rls() -> StepResult: """Verify RLS is active on tenant tables via SSH (read-only check).""" print(" Verifying RLS tables...") code, output = ssh_run( 'docker exec crm-postgres psql -U crm_user -d crm_db -t -c ' '"SELECT count(*) FROM pg_class WHERE relrowsecurity=true AND relforcerowsecurity=true" 2>/dev/null' ) rls_count = output.strip() if not rls_count.isdigit(): return StepResult(False, f"Could not read RLS table count: {output}") count = int(rls_count) rls_ok = count >= 90 return StepResult( rls_ok, f"RLS tables: {count} ({'OK' if rls_ok else 'LOW — expected >= 90'})", details={"rls_tables": count}, ) 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) status = svc.get("status", "unknown") status_lower = status.lower() 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}) except Exception as e: return StepResult(False, f"Worker service check failed: {e}") 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]] = [] print("\n[Verify] HTTP health check...") r = verify_http_health() results.append(("HTTP health", r)) _print_result(r) print("\n[Verify] Login test...") r = verify_login() results.append(("Login test", r)) _print_result(r) print("\n[Verify] Alembic migration head...") r = verify_alembic() results.append(("Alembic version", r)) _print_result(r) print("\n[Verify] RLS tables...") r = verify_rls() results.append(("RLS tables", r)) _print_result(r) if worker_uuid: print("\n[Verify] Worker service status...") r = verify_worker_service(client, worker_uuid) results.append(("Worker service", r)) _print_result(r) return results def _print_result(r: StepResult) -> None: if r.success: print(f" ✅ {r.message}") else: print(f" ❌ {r.message}") # ─── Summary ─────────────────────────────────────────────────────────── def print_summary(steps: list[tuple[str, StepResult]]) -> bool: """Print deployment summary and return overall success.""" print(f"\n{'='*60}") print(" Deploy Summary") print(f"{'='*60}") for name, result in steps: status = "✅" if result.success else "❌" print(f" {status} {name}: {result.message}") all_ok = all(r.success for _, r in steps) print(f"\n Overall: {'✅ SUCCESS' if all_ok else '❌ FAILED'}\n") 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 ────────────────────────────────────────────── def seed_admin_user(app_uuid: str) -> StepResult: """Seed admin user after fresh deployment via SSH into the app container.""" admin_email = os.environ.get("ADMIN_EMAIL", "admin@media-on.de") admin_password = os.environ.get("ADMIN_PASSWORD", "Admin123!") if not admin_password: return StepResult(True, "Skipped — ADMIN_PASSWORD not set") print(f"\n[Seed] Seeding admin user ({admin_email})...") cmd = f"docker ps --filter name={app_uuid} --format '{{{{.Names}}}}' | grep crm-app | head -1" rc, container = ssh_run(cmd, timeout=30) if rc != 0 or not container.strip(): return StepResult(False, f"Could not find crm-app container for {app_uuid}") container = container.strip() seed_cmd = f"docker exec {container} python3 /app/scripts/seed_admin.py" rc, out = ssh_run(seed_cmd, timeout=60) if rc != 0: return StepResult(False, f"Admin seed failed: {out.strip()}") return StepResult(True, f"Admin user seeded: {admin_email}") def deploy_full(skip_build: bool = False) -> int: """Full deploy: API + Worker + Verification.""" print(f"\n{'='*60}") print(" LeoCRM Full Deploy") print(f"{'='*60}\n") 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, app_uuid, skip_build=skip_build) steps.append(("API deploy", r)) _print_result(r) if not r.success: print_summary(steps) return 1 # Step 2: Deploy Worker 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: Seed admin user (if ADMIN_PASSWORD set) print("\n[3/4] Seeding admin user...") r = seed_admin_user(app_uuid) steps.append(("Admin seed", r)) _print_result(r) # Step 4: Verification print("\n[4/4] Running verification...") 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 def deploy_worker_only(skip_build: bool = False) -> int: """Deploy only the worker service + verification.""" print(f"\n{'='*60}") print(" LeoCRM Worker-Only Deploy") print(f"{'='*60}\n") 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, app_uuid, worker_uuid, skip_build=skip_build) steps.append(("Worker deploy", r)) _print_result(r) if not r.success: print_summary(steps) return 1 # Step 2: Verification print("\n[2/2] Running verification...") 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 def verify_only() -> int: """Run only verification checks.""" print(f"\n{'='*60}") print(" LeoCRM Verification Only") print(f"{'='*60}\n") errors = validate_config() if errors: for e in errors: print(f"ERROR: {e}") return 2 client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN) 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 # ─── Initial Deployment (create all Coolify resources from scratch) ────── # PostgreSQL Compose (pgvector for embeddings) POSTGRES_COMPOSE = ( "services:\n" " postgres:\n" " image: pgvector/pgvector:pg16\n" " container_name: crm-postgres\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:\n" " redis:\n" " image: redis:7-alpine\n" " container_name: crm-redis\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" ) 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}, ] 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.""" encoded = base64.b64encode(compose_raw.encode()).decode() try: resp = httpx.post( f"{client.base_url}/api/v1/services", headers=client.headers, json={ "project_uuid": project_uuid, "environment_name": environment_name, "server_uuid": server_uuid, "docker_compose_raw": encoded, "name": name, }, timeout=30, ) if resp.status_code in (200, 201): data = resp.json() return data.get("uuid") print(f" Error creating service {name}: {resp.status_code} {resp.text[:200]}") return None except Exception as e: print(f" Error creating service {name}: {e}") return None def set_service_envs(client: CoolifyClient, service_uuid: str, envs: list[dict]) -> None: """Set ENV variables for a service via Coolify API.""" for env in envs: resp = httpx.post( f"{client.base_url}/api/v1/services/{service_uuid}/envs", headers=client.headers, json=env, timeout=30, ) if resp.status_code == 409: resp = httpx.patch( f"{client.base_url}/api/v1/services/{service_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 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. Returns the application UUID or None on failure. """ 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( f"{client.base_url}/api/v1/applications/private-deploy-key", headers=client.headers, json={ "project_uuid": project_uuid, "environment_name": environment_name, "server_uuid": server_uuid, "private_key_uuid": private_key_uuid, "git_repository": API_GIT_REPO, "git_branch": API_GIT_BRANCH, "build_pack": "dockerfile", "name": APP_NAME, "ports_exposes": "8000", }, timeout=30, ) if resp.status_code in (200, 201): data = resp.json() return data.get("uuid") print(f" Error creating API application: {resp.status_code} {resp.text[:300]}") return None except Exception as e: print(f" Error creating API application: {e}") return None def deploy_initial() -> int: """Initial deployment: create all Coolify resources from scratch. Creates: 1. PostgreSQL service (pgvector/pgvector:pg16) 2. Redis service (redis:7-alpine) 3. API application (from Git repo) 4. Worker service (same image as API) 5. Sets all ENV variables 6. Deploys API (builds image + runs migrations) 7. Deploys Worker """ print(f"\n{'='*60}") print(" LeoCRM Initial Deployment") print(f"{'='*60}\n") 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]] = [] project_uuid = os.environ.get("COOLIFY_PROJECT_UUID", "") environment_name = os.environ.get("COOLIFY_ENVIRONMENT", "production") server_uuid = os.environ.get("COOLIFY_SERVER_UUID", "") # ─── Single docker-compose application (like the original working app) ── # All 4 containers (postgres, redis, crm-app, crm-worker) in one stack. # Coolify reads docker-compose.yaml from the Git repo, builds images, all # containers share one network. Service names work as DNS names. No manual # network configuration needed. No cryptic container names. # Step 1: Create application via private-deploy-key (with Git repo) print("\n[1/4] Creating docker-compose application...") try: resp = httpx.post( f"{client.base_url}/api/v1/applications/private-deploy-key", headers=client.headers, json={ "project_uuid": project_uuid, "environment_name": environment_name, "server_uuid": server_uuid, "name": APP_NAME, "git_repository": API_GIT_REPO, "git_branch": API_GIT_BRANCH, "private_key_uuid": os.environ.get("COOLIFY_PRIVATE_KEY_UUID", ""), "build_pack": "dockerfile", "ports_exposes": "8000", }, timeout=30, ) if resp.status_code == 201: app_uuid = resp.json().get("uuid") print(f" Application created: {app_uuid[:12]}") steps.append(("Create application", StepResult(True, f"Created: {app_uuid[:12]}"))) else: steps.append(("Create application", StepResult(False, f"{resp.status_code}: {resp.text[:200]}"))) print_summary(steps) return 1 except Exception as e: steps.append(("Create application", StepResult(False, str(e)))) print_summary(steps) return 1 _print_result(steps[-1][1]) # Step 2: PATCH to dockercompose build_pack (Coolify reads docker-compose.yaml from Git) print("\n[2/4] Configuring docker-compose build pack...") try: resp = httpx.patch( f"{client.base_url}/api/v1/applications/{app_uuid}", headers=client.headers, json={"build_pack": "dockercompose"}, timeout=30, ) if resp.status_code == 200: print(" Build pack set to dockercompose") steps.append(("Configure compose", StepResult(True, "dockercompose build pack set"))) else: steps.append(("Configure compose", StepResult(False, f"{resp.status_code}: {resp.text[:200]}"))) print_summary(steps) return 1 except Exception as e: steps.append(("Configure compose", StepResult(False, str(e)))) print_summary(steps) return 1 _print_result(steps[-1][1]) # Step 3: Set environment variables (domain, admin credentials, secrets) print("\n[3/4] Setting environment variables...") envs = [ {"key": "POSTGRES_USER", "value": os.environ.get("DB_USER", "crm_user")}, {"key": "POSTGRES_DB", "value": DB_NAME}, {"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": "LOG_LEVEL", "value": os.environ.get("LOG_LEVEL", "INFO")}, {"key": "SESSION_COOKIE_SECURE", "value": "true"}, {"key": "STORAGE_PATH", "value": os.environ.get("STORAGE_PATH", "/data/storage")}, {"key": "CORS_ORIGINS", "value": APP_DOMAIN}, {"key": "FRONTEND_URL", "value": APP_DOMAIN}, {"key": "APP_DOMAIN", "value": APP_DOMAIN}, {"key": "ADMIN_EMAIL", "value": os.environ.get("ADMIN_EMAIL", "admin@media-on.de")}, {"key": "ADMIN_PASSWORD", "value": os.environ.get("ADMIN_PASSWORD", "Admin123!")}, ] try: resp = httpx.patch( f"{client.base_url}/api/v1/applications/{app_uuid}/envs/bulk", headers=client.headers, json={"data": envs}, timeout=30, ) if resp.status_code in (200, 201): print(f" {len(envs)} environment variables set") steps.append(("Set envs", StepResult(True, f"{len(envs)} envs set"))) else: steps.append(("Set envs", StepResult(False, f"{resp.status_code}: {resp.text[:200]}"))) except Exception as e: steps.append(("Set envs", StepResult(False, str(e)))) _print_result(steps[-1][1]) # Step 4: Deploy via /api/v1/deploy (Magic ENV SERVICE_FQDN_CRM_APP_8000 handles domain) print("\n[4/4] Deploying docker-compose stack...") try: resp = httpx.post( f"{client.base_url}/api/v1/deploy", headers=client.headers, json={"uuid": app_uuid}, timeout=30, ) if resp.status_code == 200: deploy_data = resp.json() deployments = deploy_data.get("deployments", []) if deployments: deploy_uuid = deployments[0].get("deployment_uuid", "") print(f" Deploy queued: {deploy_uuid[:12]}") r = _wait_deployment(client, deploy_uuid, timeout=600) else: r = StepResult(True, "Deploy triggered (no UUID)") else: r = StepResult(False, f"{resp.status_code}: {resp.text[:200]}") except Exception as e: r = StepResult(False, f"Deploy failed: {e}") steps.append(("Deploy", r)) _print_result(r) if not r.success: print_summary(steps) return 1 # Verification print("\n[5/5] Running verification...") verify_results = run_verification(client, worker_uuid=None) 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)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: 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 python scripts/deploy.py --verify-only # Only run verification python scripts/deploy.py --initial # Create all resources from scratch """, ) parser.add_argument( "--skip-build", action="store_true", help="Skip build, just restart services via Coolify API", ) parser.add_argument( "--worker-only", action="store_true", help="Only deploy the worker service", ) parser.add_argument( "--initial", action="store_true", help="Initial deployment: create all Coolify resources from scratch", ) parser.add_argument( "--verify-only", action="store_true", help="Only run verification checks (no deployment)", ) args = parser.parse_args() if args.initial: sys.exit(deploy_initial()) elif args.verify_only: sys.exit(verify_only()) elif args.worker_only: sys.exit(deploy_worker_only(skip_build=args.skip_build)) else: sys.exit(deploy_full(skip_build=args.skip_build)) if __name__ == "__main__": main()