#!/usr/bin/env python3 """Automated deployment script for LeoCRM via Coolify API. ═══════════════════════════════════════════════════════════════════════ CURRENT WORKFLOW — Single docker-compose Stack ═══════════════════════════════════════════════════════════════════════ All 4 containers (postgres, redis, crm_app, crm_worker) run in a single docker-compose stack managed by one Coolify Application. deploy.py --initial → Create the docker-compose application from scratch (private-deploy-key + PATCH to dockercompose). Two-phase: first deploy without domain, then set docker_compose_domains and redeploy. deploy.py → Redeploy existing application via /api/v1/deploy. deploy.py --verify-only → Run verification checks only. No separate Coolify services for worker/db/redis. Everything is in one stack so containers share a network and can reach each other by DNS. ═══════════════════════════════════════════════════════════════════════ Usage: python scripts/deploy.py # Redeploy via Coolify API python scripts/deploy.py --skip-build # Skip build, just restart python scripts/deploy.py --initial # Create all resources from scratch python scripts/deploy.py --verify-only # Only run verification 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) APP_NAME — Application name for API lookup (default: derived from APP_DOMAIN) 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) LOGIN_EMAIL — Login test email (optional, for verification) LOGIN_PASSWORD — Login test password (optional, for verification) Secrets (required for --initial): DB_PASSWORD — PostgreSQL password for all roles REDIS_PASSWORD — Redis password SECRET_KEY — Application secret key Initial deploy only: COOLIFY_PROJECT_UUID — Coolify project UUID COOLIFY_SERVER_UUID — Coolify server UUID COOLIFY_PRIVATE_KEY_UUID — Coolify private deploy key UUID COOLIFY_ENVIRONMENT — Coolify environment name (default: production) 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", "") # UUID — from env or resolved via API lookup by name APP_UUID = os.environ.get("COOLIFY_APP_UUID", "") # Derive APP_NAME 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") # 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 name (used by deploy_initial for POSTGRES_DB env) DB_NAME = os.environ.get("DB_NAME", "crm_db") # 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 ── 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") # ─── 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 — only for non-dockercompose apps. # dockercompose apps use docker_compose_domains, not domains. # PATCHing domains on a dockercompose app returns 422. if APP_DOMAIN: try: app_info = client.get_application(app_uuid) build_pack = app_info.get("build_pack", "") except Exception as e: print(f" Warning: could not fetch app info to check build_pack: {e}") build_pack = "" if build_pack == "dockercompose": print(f" Skipping FQDN PATCH (dockercompose app — domain set via docker_compose_domains)") else: 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 _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) # ─── Verification ────────────────────────────────────────────────────── def verify_http_health() -> StepResult: """Verify the API is healthy via HTTP endpoint. Uses verify=False to handle self-signed certs during Let's Encrypt provisioning.""" print(" Verifying API health via HTTP...") url = f"{APP_DOMAIN}/api/v1/health" try: resp = httpx.get(url, timeout=30, follow_redirects=True, verify=False) 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. Uses verify=False to handle self-signed certs during Let's Encrypt provisioning.""" 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, verify=False, ) 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 _find_pg_container() -> str | None: """Dynamically find the PostgreSQL container name via SSH. Works for both docker-compose stacks (postgres-) and standalone services (crm-postgres).""" code, output = ssh_run( 'docker ps --format "{{.Names}}" 2>/dev/null | grep -iE "postgres" | grep -v "coolify-db" | head -1' ) container = output.strip() if container: return container # Fallback: try the old hardcoded name return "crm-postgres" def verify_alembic() -> StepResult: """Verify Alembic migration head via SSH (Coolify API doesn't expose DB internals). Dynamically finds the PostgreSQL container name — works for docker-compose stacks where the container name is postgres- (not hardcoded crm-postgres).""" print(" Verifying Alembic migration head...") pg_container = _find_pg_container() code, output = ssh_run( f'docker exec {pg_container} 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, f"Could not read Alembic version (container: {pg_container})") 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). Dynamically finds the PostgreSQL container name — works for docker-compose stacks where the container name is postgres- (not hardcoded crm-postgres).""" print(" Verifying RLS tables...") pg_container = _find_pg_container() code, output = ssh_run( f'docker exec {pg_container} 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 (container: {pg_container}): {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 run_verification(client: CoolifyClient) -> 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) 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 deploy_full(skip_build: bool = False) -> int: """Redeploy the application via Coolify API (/api/v1/deploy). This is the standard redeploy workflow for an existing docker-compose stack. For initial creation, use deploy_initial() via --initial. """ print(f"\n{'='*60}") print(" LeoCRM 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]] = [] # Step 1: Resolve UUID print("\n[1/2] Resolving Coolify application...") try: app_uuid = resolve_app_uuid(client) print(f" App UUID: {app_uuid}") except Exception as e: print(f"ERROR: {e}") return 2 # Step 2: Deploy via Coolify API print("\n[2/2] Deploying 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 3: Verification print("\n[3/3] Running verification...") verify_results = run_verification(client) 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) results = run_verification(client) all_ok = print_summary(results) return 0 if all_ok else 1 # ─── Initial Deployment (create Coolify application from scratch) ────── def deploy_initial() -> int: """Initial deployment: create the Coolify application from scratch. ═══════════════════════════════════════════════════════════════════════ ⚠️ KI / AGENT HINWEISE — BITTE VOR ÄNDERUNGEN LESEN ⚠️ ═══════════════════════════════════════════════════════════════════════ ARCHITEKTUR: Einzelner docker-compose Stack (NICHT 4 separate Ressourcen!) ───────────────────────────────────────────────────────────────────── Dieses Script erstellt EINE Coolify Application mit build_pack=dockercompose. Coolify liest docker-compose.yaml aus dem Git-Repo und baut alle 4 Container (postgres, redis, crm_app, crm_worker) in einem gemeinsamen Stack. ❌ NICHT tun: - 4 separate Coolify Ressourcen erstellen (Services für PG/Redis, Application für API, Service für Worker). Das funktioniert NICHT weil Coolify jedem Service ein eigenes Docker-Netzwerk gibt. Die Container können sich nicht per DNS erreichen → socket.gaierror: Temporary failure in name resolution. - connect_to_docker_network=True setzen. Coolify kümmert sich selbst um das Netzwerk innerhalb eines docker-compose Stacks. - :443 an die Domain anhängen (z.B. https://crm.media-on.de:443). Das führt zu leeren Host() Traefik-Labels → 503 Fehler. Siehe unten. - docker-compose.yml (mit .yml) verwenden. Coolify sucht docker-compose.yaml (mit .yaml). Falsche Dateiendung → "Docker Compose file not found" Fehler. - Service-Namen mit Bindestrich verwenden (crm-app, crm-worker). Coolify konvertiert Bindestriche zu Unterstrichen in docker_compose_domains. Die DB zeigt dann crm_app aber die docker-compose.yaml hat crm-app → kein Match → keine Traefik-Labels → 503. Verwende Unterstriche: crm_app, crm_worker. ✅ RICHTIG: - Service-Namen mit Unterstrichen: crm_app, crm_worker, postgres, redis - docker-compose.yaml (mit .yaml, nicht .yml) - 2-Phase Deploy: Erster Deploy OHNE Domain, dann docker_compose_domains setzen, dann Redeploy. Grund: Coolify muss zuerst die docker-compose.yaml aus dem Git-Repo lesen um die Service-Namen zu kennen. Erst dann kann docker_compose_domains korrekt zugeordnet werden. - docker_compose_domains Format: [{"name": "crm_app", "domain": "https://crm.media-on.de"}] WICHTIG: KEIN :443 am Ende! Das :443 führt zu leeren Host() Labels. - SERVICE_FQDN_CRM_APP_8000 in docker-compose.yaml environment block für automatische Domain-Konfiguration durch Coolify Magic ENV. FUNKTIONIERENDE BEISPIEL-APP: hms-licht-ton (UUID wvus7va5u0f9dmg27ggca7rl) - docker_compose_domains: {"frontend":{"domain":"https://hms.media-on.de"}} - Container hat Traefik-Labels: Host(`hms.media-on.de`) - Kein :443, Service-Name ohne Bindestrich MEHRERE INSTANZEN: - APP_NAME und APP_DOMAIN als ENV-Variablen setzen - Alles andere wird automatisch konfiguriert - Beispiel: APP_NAME=leocrm-test APP_DOMAIN=https://crm-test.media-on.de ═══════════════════════════════════════════════════════════════════════ """ 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 ────────────────────────────── # 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: First Deploy (without domain — Coolify needs to read docker-compose.yaml first) print("\n[4/5] Deploying docker-compose stack (first 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: 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 # Step 5: Set domain via docker_compose_domains (after first deploy, then redeploy) print("\n[5/5] Setting domain and redeploying...") if APP_DOMAIN: try: resp = httpx.patch( f"{client.base_url}/api/v1/applications/{app_uuid}", headers=client.headers, json={ "docker_compose_domains": [ {"name": "crm_app", "domain": APP_DOMAIN} ] }, timeout=30, ) print(f" Domain set: {APP_DOMAIN} ({resp.status_code})") # Redeploy with domain labels time.sleep(5) resp2 = httpx.post( f"{client.base_url}/api/v1/deploy", headers=client.headers, json={"uuid": app_uuid}, timeout=30, ) if resp2.status_code == 200: deploy_data = resp2.json() deployments = deploy_data.get("deployments", []) if deployments: deploy_uuid = deployments[0].get("deployment_uuid", "") print(f" Redeploy queued: {deploy_uuid[:12]}") r = _wait_deployment(client, deploy_uuid, timeout=600) else: r = StepResult(True, "Redeploy triggered (no UUID)") else: r = StepResult(False, f"Redeploy failed: {resp2.status_code}") except Exception as e: r = StepResult(False, f"Domain/Redeploy failed: {e}") else: r = StepResult(True, "No domain set (APP_DOMAIN not configured)") steps.append(("Set domain + redeploy", r)) _print_result(r) # Verification print("\n[5/5] Running verification...") verify_results = run_verification(client) 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 # Redeploy via Coolify API python scripts/deploy.py --skip-build # Skip build, just restart 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( "--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()) else: sys.exit(deploy_full(skip_build=args.skip_build)) if __name__ == "__main__": main()