Files

906 lines
36 KiB
Python
Raw Permalink Normal View History

#!/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-<uuid>) 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-<uuid> (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-<uuid> (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()