From b3f40bacd2be7bf3f9524582b275ab2c92465117 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 1 Aug 2026 21:22:55 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20deploy.py=20rewrite=20=E2=80=94=20everyt?= =?UTF-8?q?hing=20via=20Coolify=20API,=20no=20manual=20docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy.py | 872 ++++++++++++++++++++++++++-------------------- 1 file changed, 498 insertions(+), 374 deletions(-) diff --git a/scripts/deploy.py b/scripts/deploy.py index 87adf65..bf4f713 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -1,35 +1,24 @@ #!/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. + Usage: - python scripts/deploy.py # Deploy to production - python scripts/deploy.py --environment test # Deploy to test environment - python scripts/deploy.py --skip-build # Skip build, just restart - python scripts/deploy.py --migrate-only # Only run migrations + 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 -Features: - - Triggers Coolify build & deploy via API - - Waits for healthy status - - Starts worker container automatically - - Verifies RLS, migrations, and health - - Supports test and production environments - - No manual steps required - -Environment variables (set in .env or shell): - COOLIFY_API_TOKEN — Coolify API token - COOLIFY_APP_UUID — Application UUID for the app - COOLIFY_BASE_URL — Coolify base URL (default: https://server.media-on.de) - SSH_KEY — SSH key path for server access - SERVER_IP — Server IP for SSH access - - # Worker (optional — if not set, worker is skipped) - WORKER_APP_UUID — Separate Coolify app UUID for worker (optional) - - # S3 Storage (optional — if not set, local storage is used) - S3_ENDPOINT — S3-compatible endpoint - S3_BUCKET — Bucket name - S3_ACCESS_KEY — Access key - S3_SECRET_KEY — Secret key +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) + 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) Exit codes: 0 — deployment successful @@ -40,12 +29,12 @@ Exit codes: from __future__ import annotations import argparse -import json +import base64 import os import subprocess import sys import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any import httpx @@ -55,22 +44,57 @@ import httpx 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") + 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") -# Worker configuration -WORKER_APP_UUID = os.environ.get("WORKER_APP_UUID", "") # Empty = use SSH-based worker -WORKER_CONTAINER_NAME = "leocrm-worker" +# 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!") + +# Worker docker-compose definition (base64 will be computed at runtime) +WORKER_COMPOSE_YAML = """\ +services: + worker: + image: 'stvabl4vaqru7jclx4ittzr3:latest' + restart: unless-stopped + entrypoint: + - /app/worker.sh + environment: + DATABASE_URL: 'postgresql+asyncpg://crm_worker:PW@crm-postgres:5432/crm_db' + WORKER_DATABASE_URL: 'postgresql+asyncpg://crm_worker:PW@crm-postgres:5432/crm_db' + MIGRATION_DATABASE_URL: 'postgresql+asyncpg://crm_user:PW@crm-postgres:5432/crm_db' + REDIS_URL: 'redis://default:PW@crm-redis:6379/0' + SECRET_KEY: 'PW' + ENVIRONMENT: production + STORAGE_PATH: /data/storage + volumes: + - 'leocrm-worker-storage:/data/storage' + networks: + - coolify +networks: + coolify: + external: true + name: coolify +volumes: + leocrm-worker-storage: + name: leocrm-worker-storage +""" + # ─── Data Structures ────────────────────────────────────────────────── @dataclass -class DeployResult: +class StepResult: success: bool message: str duration_s: float = 0.0 - details: dict[str, Any] = None + details: dict[str, Any] = field(default_factory=dict) # ─── Coolify API Client ──────────────────────────────────────────────── @@ -81,60 +105,118 @@ class CoolifyClient: def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip("/") - self.headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + self.headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } - def deploy(self, app_uuid: str) -> dict[str, Any]: - """Trigger a deployment for an application.""" - resp = httpx.post(f"{self.base_url}/api/v1/deploy", headers=self.headers, json={"uuid": app_uuid}, timeout=30) + # ── Applications ── + + 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_app(self, app_uuid: str) -> dict[str, Any]: - """Get application details.""" - resp = httpx.get(f"{self.base_url}/api/v1/applications/{app_uuid}", headers=self.headers, timeout=30) + 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() + # ── Services (Worker) ── + + 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() - - def update_env(self, app_uuid: str, envs: list[dict[str, str]]) -> dict[str, Any]: - """Bulk update environment variables.""" - resp = httpx.patch( - f"{self.base_url}/api/v1/applications/{app_uuid}/envs/bulk", + resp = httpx.get( + f"{self.base_url}/api/v1/deployments/{deployment_uuid}", headers=self.headers, - json={"data": envs}, timeout=30, ) resp.raise_for_status() return resp.json() - def get_envs(self, app_uuid: str) -> list[dict[str, Any]]: - """Get all environment variables.""" - resp = httpx.get(f"{self.base_url}/api/v1/applications/{app_uuid}/envs", headers=self.headers, timeout=30) - resp.raise_for_status() - return resp.json() + # ── Health ── - def set_domain(self, app_uuid: str, domain: str) -> dict[str, Any]: - """Set the domain for an application.""" - resp = httpx.patch( - f"{self.base_url}/api/v1/applications/{app_uuid}", + def get_health(self) -> dict[str, Any]: + """Check Coolify system health.""" + resp = httpx.get( + f"{self.base_url}/api/v1/health", headers=self.headers, - json={"domains": domain}, - timeout=30, + timeout=15, ) resp.raise_for_status() return resp.json() -# ─── SSH Helper ──────────────────────────────────────────────────────── +# ─── SSH Helper (verification only) ──────────────────────────────────── def ssh_run(cmd: str, timeout: int = 60) -> tuple[int, str]: - """Run a command on the server via SSH.""" + """Run a command on the server via SSH — used ONLY for verification.""" full_cmd = [ "ssh", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no", @@ -146,360 +228,402 @@ def ssh_run(cmd: str, timeout: int = 60) -> tuple[int, str]: return result.returncode, result.stdout + result.stderr -def scp_upload(local_path: str, remote_path: str) -> bool: - """Upload a file to the server via SCP.""" - cmd = [ - "scp", "-i", SSH_KEY, - "-o", "StrictHostKeyChecking=no", - local_path, f"root@{SERVER_IP}:{remote_path}", - ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - return result.returncode == 0 - - # ─── Deploy Steps ────────────────────────────────────────────────────── -def wait_for_deployment(client: CoolifyClient, deployment_uuid: str, timeout: int = 300) -> DeployResult: - """Wait for a deployment to complete.""" - print(f" Waiting for deployment {deployment_uuid[:12]}...") +def deploy_api(client: CoolifyClient, skip_build: bool = False) -> StepResult: + """Deploy or restart the API application via Coolify API.""" + 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) + deploy_uuid = _extract_deploy_uuid(result) + if deploy_uuid: + print(f" Deploy queued: {deploy_uuid[:12]}") + return _wait_deployment(client, deploy_uuid, timeout=300) + return StepResult(True, "Deploy triggered (no UUID returned)") + except Exception as e: + return StepResult(False, f"Deploy trigger failed: {e}") + + print(" Triggering Coolify build & deploy for API...") + try: + 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") + print(f" Deploy queued: {deploy_uuid[:12]}") + return _wait_deployment(client, deploy_uuid, timeout=300) + except Exception as e: + return StepResult(False, f"Deploy trigger failed: {e}") + + +def deploy_worker(client: CoolifyClient, skip_build: bool = False) -> StepResult: + """Deploy or restart the worker service via Coolify API.""" + print(" Deploying worker service via Coolify API...") + + try: + if skip_build: + # Just restart the existing service + print(" Skip-build mode: restarting worker service...") + client.restart_service(WORKER_UUID) + time.sleep(3) + return _wait_service_healthy(client, WORKER_UUID, timeout=120) + + # Update the service with the latest docker_compose_raw + print(" Updating worker service compose definition...") + client.update_service(WORKER_UUID, WORKER_COMPOSE_YAML) + + # Trigger deploy via the deploy endpoint + print(" Triggering worker deployment...") + 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=300) + if not dep_result.success: + return dep_result + else: + print(" No deployment UUID returned, checking service status directly...") + + # Wait for service to be 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.""" + # 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") + + +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(deployment_uuid) + dep = client.get_deployment(deploy_uuid) status = dep.get("status", "unknown") elapsed = int(time.time() - start) - print(f" [{elapsed}s] Status: {status}") + print(f" [{elapsed}s] Deployment status: {status}") if status in ("success", "finished"): - return DeployResult(True, "Deployment successful", time.time() - start, dep) + return StepResult(True, "Deployment successful", time.time() - start, dep) if status == "failed": - return DeployResult(False, f"Deployment failed: {dep.get('message', 'unknown')}", time.time() - start, dep) + 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 DeployResult(False, f"Deployment timed out after {timeout}s", time.time() - start) + return StepResult(False, f"Deployment timed out after {timeout}s", time.time() - start) -def wait_for_healthy(timeout: int = 120) -> DeployResult: - """Wait for the container to become healthy.""" - print(" Waiting for container to become healthy...") +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: - code, output = ssh_run('docker ps --format "{{.Names}} {{.Status}}" | grep stvabl4 | head -1') - if "healthy" in output: - return DeployResult(True, "Container is healthy", time.time() - start) - elapsed = int(time.time() - start) - print(f" [{elapsed}s] {output.strip()}") + 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 "healthy" in status.lower(): + 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 DeployResult(False, f"Container did not become healthy in {timeout}s", time.time() - start) + return StepResult(False, f"Service did not become healthy in {timeout}s", time.time() - start) -def start_worker(image_tag: str | None = None) -> DeployResult: - """Start or restart the worker container via SSH.""" - print(" Starting worker container...") - - # Get the latest image tag if not provided - if image_tag is None: - code, output = ssh_run('docker images --format "{{.Repository}}:{{.Tag}}" | grep stvabl4 | head -1') - if code != 0 or not output.strip(): - return DeployResult(False, "Could not find app image") - image_tag = output.strip() - - # Get env vars from the app container - code, env_output = ssh_run('docker exec $(docker ps --format "{{.Names}}" | grep stvabl4 | head -1) env 2>/dev/null | grep -E "^DATABASE_URL=|^REDIS_URL=|^SECRET_KEY=|^ENVIRONMENT=|^SESSION_COOKIE_SECURE=|^STORAGE_PATH=|^CORS_ORIGINS=|^S3_" | sort') - if code != 0: - return DeployResult(False, "Could not get env vars from app container") - - # Build env flags - env_flags = [] - for line in env_output.strip().split("\n"): - if "=" in line: - key, _, value = line.partition("=") - env_flags.append(f'-e {key}="{value}"') - env_str = " ".join(env_flags) - - # Stop and remove old worker - ssh_run(f"docker stop {WORKER_CONTAINER_NAME} 2>/dev/null; docker rm {WORKER_CONTAINER_NAME} 2>/dev/null") - - # Start new worker - cmd = f'''docker run -d --name {WORKER_CONTAINER_NAME} --network coolify --restart unless-stopped {env_str} --entrypoint /app/worker.sh {image_tag}''' - code, output = ssh_run(cmd) - if code != 0: - return DeployResult(False, f"Failed to start worker: {output}") - - # Verify worker is running - time.sleep(3) - code, output = ssh_run(f'docker ps --format "{{{{.Names}}}} {{{{.Status}}}}" | grep {WORKER_CONTAINER_NAME}') - if code != 0: - return DeployResult(False, "Worker container not running") - - return DeployResult(True, f"Worker started: {output.strip()}", 3) +# ─── Verification ────────────────────────────────────────────────────── -def verify_health() -> DeployResult: - """Verify the app is healthy via HTTP.""" - print(" Verifying app health...") - code, output = ssh_run('docker exec $(docker ps --format "{{.Names}}" | grep stvabl4 | head -1) curl -s http://localhost:8000/api/v1/health 2>/dev/null') - if code == 0 and "healthy" in output: - return DeployResult(True, f"Health check passed: {output.strip()}") - return DeployResult(False, f"Health check failed: {output}") - - -def verify_domain(domain: str) -> DeployResult: - """Verify the domain is accessible.""" - print(f" Verifying domain {domain}...") - code, output = ssh_run(f'curl -s -o /dev/null -w "%{{http_code}}" {domain}/api/v1/health 2>/dev/null') - status = output.strip() - if status == "200": - return DeployResult(True, f"Domain accessible: {status}") - return DeployResult(False, f"Domain returned: {status}") - - -def verify_db() -> DeployResult: - """Verify database migrations and RLS.""" - print(" Verifying database...") - checks = [] - - # Check alembic version — must be at least 0045 (latest migration) - 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() - # Accept any version >= 0045 (covers 0045, 0044_repair_contact_migration, etc.) - version_ok = version >= "0045" or version.startswith("0045") - checks.append(("Alembic version", version, version_ok)) - - # Check RLS - 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() - checks.append(("RLS tables", rls_count, int(rls_count) > 90 if rls_count.isdigit() else False)) - - # Check event_outbox - code, output = ssh_run('docker exec crm-postgres psql -U crm_user -d crm_db -t -c "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name=\'event_outbox\')" 2>/dev/null') - outbox = output.strip() - checks.append(("Event outbox", outbox, "t" in outbox)) - - all_pass = all(c[2] for c in checks) - details = {c[0]: c[1] for c in checks} - return DeployResult(all_pass, "DB verification " + ("passed" if all_pass else "failed"), 0, details) - - -def ensure_volume() -> DeployResult: - """Ensure persistent volume is configured in Coolify DB. - - Creates a persistent volume entry in Coolify's local_persistent_volumes table - if it doesn't exist. Coolify will then automatically mount the volume in - every generated docker-compose.yaml — no post-deploy patching needed. - - This works on any Coolify instance and with multiple apps per server. - """ - print(" Ensuring persistent volume in Coolify DB...") - - # Get app ID from Coolify DB - code, output = ssh_run(f"docker exec coolify-db psql -U coolify -d coolify -t -c \"SELECT id FROM applications WHERE uuid = '{APP_UUID}'\" 2>/dev/null") - if code != 0 or not output.strip(): - return DeployResult(False, "Could not find app in Coolify DB") - app_id = output.strip() - - # Check if volume entry already exists - vol_name = f"{APP_UUID}_storage" - code, output = ssh_run(f"docker exec coolify-db psql -U coolify -d coolify -t -c \"SELECT id FROM local_persistent_volumes WHERE resource_id = {app_id} AND name = '{vol_name}'\" 2>/dev/null") - if code == 0 and output.strip(): - return DeployResult(True, "Volume already configured in Coolify DB") - - # Create volume entry in Coolify DB - code, output = ssh_run(f"docker exec coolify-db psql -U coolify -d coolify -c \"INSERT INTO local_persistent_volumes (name, mount_path, resource_type, resource_id, created_at, updated_at, is_preview_suffix_enabled, uuid) VALUES ('{vol_name}', '/data/storage', 'App\\\\\\\\Models\\\\\\\\Application', {app_id}, NOW(), NOW(), false, gen_random_uuid())\" 2>&1") - if code != 0: - return DeployResult(False, f"Failed to create volume entry: {output}") - - return DeployResult(True, "Volume entry created in Coolify DB") - - -def ensure_rls() -> DeployResult: - """Ensure RLS is active on all tenant tables (idempotent).""" - print(" Ensuring RLS on all tenant tables...") - - # Upload RLS fix script - rls_script = """DO $$ -DECLARE t TEXT; -BEGIN - FOR t IN SELECT table_name FROM information_schema.columns WHERE column_name = 'tenant_id' AND table_schema = 'public' LOOP - -- Skip system/auth/config tables that need access without tenant context - IF t IN ('users', 'tenants', 'user_tenants', 'sessions', - 'audit_log', 'password_reset_tokens', 'api_tokens', - 'groups', 'roles', 'permissions', 'user_groups', - 'system_settings', 'currencies', 'tax_rates', 'sequences', - 'plugins', 'tenant_plugin_activation', - 'workspaces', 'workspace_modules', 'workspace_users', 'workspace_widgets', - 'automation_cron_jobs', 'automation_definitions', 'automation_runs', - 'automation_versions', 'automation_agent_definitions', - 'automation_agent_runs', 'automation_agent_versions', - 'user_preferences', 'saved_filters', 'saved_views', 'webhooks', - 'notification_preferences', 'notification_types') THEN - CONTINUE; - END IF; - EXECUTE 'ALTER TABLE ' || t || ' ENABLE ROW LEVEL SECURITY'; - EXECUTE 'DROP POLICY IF EXISTS tenant_isolation ON ' || t; - EXECUTE 'CREATE POLICY tenant_isolation ON ' || t || ' USING (tenant_id = current_setting(''app.current_tenant_id'')::uuid) WITH CHECK (tenant_id = current_setting(''app.current_tenant_id'')::uuid)'; - END LOOP; -END$$; -""" - - # Write script locally, upload, execute - import tempfile - with tempfile.NamedTemporaryFile(mode="w", suffix=".sql", delete=False) as f: - f.write(rls_script) - local_path = f.name - +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: - if not scp_upload(local_path, "/tmp/rls_fix.sql"): - return DeployResult(False, "Failed to upload RLS script") - - code, output = ssh_run('docker exec -i crm-postgres psql -U crm_user -d crm_db < /tmp/rls_fix.sql 2>&1') - if code != 0 and "ERROR" in output: - return DeployResult(False, f"RLS script failed: {output}") - - return DeployResult(True, "RLS ensured on all tenant tables") - finally: - os.unlink(local_path) + 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.""" + print(" Verifying login...") + url = f"{APP_DOMAIN}/api/v1/auth/login" + try: + resp = httpx.post( + url, + json={"email": LOGIN_EMAIL, "password": LOGIN_PASSWORD}, + 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") + # Accept versions >= 0085 (migrations 0085-0090 handle RLS) + 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 should be active on all tenant tables (typically 90+) + 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) -> 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") + if "running" in status.lower(): + 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, check_worker: bool = True) -> 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: + print("\n[Verify] Worker service status...") + r = verify_worker_service(client) + 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 # ─── Main Deploy Pipeline ────────────────────────────────────────────── -def deploy(environment: str = "production", skip_build: bool = False, migrate_only: bool = False) -> int: - """Run the full deployment pipeline.""" +def deploy_full(skip_build: bool = False) -> int: + """Full deploy: API + Worker + Verification.""" print(f"\n{'='*60}") - print(f" LeoCRM Deploy — Environment: {environment}") + print(" LeoCRM Full Deploy") print(f"{'='*60}\n") - + if not COOLIFY_TOKEN: print("ERROR: COOLIFY_API_TOKEN not set") return 2 - + client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN) - domain = "https://crm.media-on.de" if environment == "production" else "https://crm-test.media-on.de" - - steps = [] - - # Step 1: Trigger Coolify deploy - if not skip_build and not migrate_only: - print("\n[1/8] Triggering Coolify build & deploy...") - try: - result = client.deploy(APP_UUID) - deploy_info = result["deployments"][0] - deploy_uuid = deploy_info["deployment_uuid"] - print(f" Deploy queued: {deploy_uuid[:12]}") - - # Wait for deployment - dep_result = wait_for_deployment(client, deploy_uuid, timeout=300) - steps.append(("Coolify deploy", dep_result)) - if not dep_result.success: - print(f"\n ❌ {dep_result.message}") - return 1 - print(f" ✅ {dep_result.message}") - except Exception as e: - print(f"\n ❌ Deploy trigger failed: {e}") - return 1 - else: - print("\n[1/7] Skipping build (skip-build flag)") - steps.append(("Coolify deploy", DeployResult(True, "Skipped"))) - - # Step 2: Ensure persistent volume - if not migrate_only: - print("\n[2/8] Ensuring persistent volume...") - vol_result = ensure_volume() - steps.append(("Persistent volume", vol_result)) - if not vol_result.success: - print(f" ⚠️ {vol_result.message} (non-fatal)") - else: - print(f" ✅ {vol_result.message}") - - # Step 3: Wait for healthy container - if not migrate_only: - print("\n[3/8] Waiting for container health...") - health_result = wait_for_healthy(timeout=120) - steps.append(("Container health", health_result)) - if not health_result.success: - print(f" ❌ {health_result.message}") - return 1 - print(f" ✅ {health_result.message}") - - # Step 4: Ensure RLS - print("\n[4/8] Ensuring RLS policies...") - rls_result = ensure_rls() - steps.append(("RLS policies", rls_result)) - if not rls_result.success: - print(f" ⚠️ {rls_result.message} (non-fatal)") - else: - print(f" ✅ {rls_result.message}") - - # Step 5: Verify DB - print("\n[5/8] Verifying database...") - db_result = verify_db() - steps.append(("DB verification", db_result)) - if db_result.success: - print(f" ✅ {db_result.message}") - for k, v in (db_result.details or {}).items(): - print(f" {k}: {v}") - else: - print(f" ⚠️ {db_result.message}") - - # Step 6: Start worker - if not migrate_only: - print("\n[6/8] Starting worker container...") - worker_result = start_worker() - steps.append(("Worker", worker_result)) - if not worker_result.success: - print(f" ⚠️ {worker_result.message} (non-fatal)") - else: - print(f" ✅ {worker_result.message}") - - # Step 7: Verify health - print("\n[7/8] Verifying app health...") - app_health = verify_health() - steps.append(("App health", app_health)) - if not app_health.success: - print(f" ❌ {app_health.message}") + steps: list[tuple[str, StepResult]] = [] + + # Step 1: Deploy API + print("\n[1/3] Deploying API via Coolify API...") + r = deploy_api(client, skip_build=skip_build) + steps.append(("API deploy", r)) + _print_result(r) + if not r.success: + print_summary(steps) return 1 - print(f" ✅ {app_health.message}") - - # Step 8: Verify domain - print("\n[8/8] Verifying domain...") - domain_result = verify_domain(domain) - steps.append(("Domain", domain_result)) - if domain_result.success: - print(f" ✅ {domain_result.message}") - else: - print(f" ⚠️ {domain_result.message} (may need DNS/certificate time)") - + + # 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 + + # Step 3: Verification + print("\n[3/3] Running verification...") + verify_results = run_verification(client, check_worker=True) + steps.extend(verify_results) + # Summary + 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(" Deploy Summary") - print(f"{'='*60}") - for name, result in steps: - status = "✅" if result.success else "⚠️ " if "non-fatal" in result.message else "❌" - print(f" {status} {name}: {result.message}") - - # Return success if all critical steps passed - critical = [s for s in steps if s[0] in ("Coolify deploy", "Container health", "App health", "Persistent volume")] - all_critical = all(s[1].success for s in critical) - print(f"\n Overall: {'✅ SUCCESS' if all_critical else '❌ FAILED'}\n") - return 0 if all_critical else 1 + print(" LeoCRM Worker-Only Deploy") + print(f"{'='*60}\n") + + if not COOLIFY_TOKEN: + print("ERROR: COOLIFY_API_TOKEN not set") + return 2 + + client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN) + steps: list[tuple[str, StepResult]] = [] + + # Step 1: Deploy Worker + print("\n[1/2] Deploying Worker via Coolify API...") + r = deploy_worker(client, 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, check_worker=True) + 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") + + if not COOLIFY_TOKEN: + print("ERROR: COOLIFY_API_TOKEN not set") + return 2 + + client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN) + results = run_verification(client, check_worker=True) + all_ok = print_summary(results) + return 0 if all_ok else 1 # ─── CLI ─────────────────────────────────────────────────────────────── -def main(): - parser = argparse.ArgumentParser(description="LeoCRM automated deployment script") - parser.add_argument("--environment", "-e", default="production", choices=["production", "test"], - help="Target environment (default: production)") - parser.add_argument("--skip-build", action="store_true", - help="Skip Coolify build, just restart containers") - parser.add_argument("--migrate-only", action="store_true", - help="Only run migrations and RLS, no container changes") +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 + """, + ) + 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( + "--verify-only", action="store_true", + help="Only run verification checks (no deployment)", + ) args = parser.parse_args() - - sys.exit(deploy(environment=args.environment, skip_build=args.skip_build, migrate_only=args.migrate_only)) + + if 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__":