492 lines
20 KiB
Python
492 lines
20 KiB
Python
#!/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 in ("success", "finished"):
|
|
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_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
|
|
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/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}")
|
|
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)")
|
|
|
|
# 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", "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
|
|
|
|
|
|
# ─── 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()
|