From 20e6545aa111ab5c557244398cd91b917c609f40 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 25 Jul 2026 22:42:05 +0200 Subject: [PATCH] Add automated deploy script with RLS, worker, health checks --- scripts/deploy.py | 450 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100755 scripts/deploy.py diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100755 index 0000000..ea12ef9 --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Automated deployment script for LeoCRM via Coolify API. + +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 + +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 + +Exit codes: + 0 — deployment successful + 1 — deployment failed + 2 — configuration error +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from dataclasses import dataclass +from typing import Any + +import httpx + +# ─── Configuration ──────────────────────────────────────────────────── + +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") +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" + +# ─── Data Structures ────────────────────────────────────────────────── + + +@dataclass +class DeployResult: + success: bool + message: str + duration_s: float = 0.0 + details: dict[str, Any] = None + + +# ─── 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"} + + 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) + 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) + resp.raise_for_status() + return resp.json() + + 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", + 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() + + 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}", + headers=self.headers, + json={"domains": domain}, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +# ─── SSH Helper ──────────────────────────────────────────────────────── + + +def ssh_run(cmd: str, timeout: int = 60) -> tuple[int, str]: + """Run a command on the server via SSH.""" + 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 + + +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]}...") + start = time.time() + while time.time() - start < timeout: + try: + dep = client.get_deployment(deployment_uuid) + status = dep.get("status", "unknown") + elapsed = int(time.time() - start) + print(f" [{elapsed}s] Status: {status}") + if status == "success": + return DeployResult(True, "Deployment successful", time.time() - start, dep) + if status == "failed": + return DeployResult(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) + + +def wait_for_healthy(timeout: int = 120) -> DeployResult: + """Wait for the container to become healthy.""" + print(" Waiting for container 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()}") + time.sleep(5) + return DeployResult(False, f"Container 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) + + +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 + 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() + checks.append(("Alembic version", version, version == "0040_outbox")) + + # 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_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 + EXECUTE 'ALTER TABLE ' || t || ' ENABLE ROW LEVEL SECURITY'; + EXECUTE 'ALTER TABLE ' || t || ' FORCE 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 + + 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) + + +# ─── Main Deploy Pipeline ────────────────────────────────────────────── + + +def deploy(environment: str = "production", skip_build: bool = False, migrate_only: bool = False) -> int: + """Run the full deployment pipeline.""" + print(f"\n{'='*60}") + print(f" LeoCRM Deploy — Environment: {environment}") + 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/7] 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: Wait for healthy container + if not migrate_only: + print("\n[2/7] 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 3: Ensure RLS + print("\n[3/7] 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 4: Verify DB + print("\n[4/7] 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 5: Start worker + if not migrate_only: + print("\n[5/7] 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 6: Verify health + print("\n[6/7] Verifying app health...") + app_health = verify_health() + steps.append(("App health", app_health)) + if not app_health.success: + print(f" ❌ {app_health.message}") + return 1 + print(f" ✅ {app_health.message}") + + # Step 7: Verify domain + print("\n[7/7] 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)") + + # Summary + 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")] + 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 + + +# ─── 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") + args = parser.parse_args() + + sys.exit(deploy(environment=args.environment, skip_build=args.skip_build, migrate_only=args.migrate_only)) + + +if __name__ == "__main__": + main()