refactor(deploy): remove old multi-resource code, document single docker-compose workflow

- Remove POSTGRES_COMPOSE, REDIS_COMPOSE templates (unused)
- Remove create_service(), create_api_application(), generate_worker_compose()
- Remove deploy_worker(), deploy_worker_only(), verify_worker_service()
- Remove resolve_worker_uuid() and all worker_uuid references
- Remove get_worker_envs(), get_api_envs(), get_postgres_envs(), get_redis_envs()
- Remove set_service_envs(), set_application_envs() (dead code)
- Remove _extract_deploy_uuid(), _wait_service_healthy() (only used by deploy_worker)
- Remove seed_admin_user() (only used by old deploy_full)
- Remove DB_HOST, REDIS_HOST, WORKER_UUID, WORKER_NAME config vars
- Remove --worker-only CLI arg
- Replace old deploy_full() with simple redeploy via /api/v1/deploy
- Update run_verification() to remove worker_uuid param
- Add KI workflow comment at top of deploy.py
- Update DEPLOY.md: single docker-compose stack workflow
- Update COOLIFY_SETUP.md: single docker-compose stack, remove 3-resource setup
- Update docs/INSTALL.md: automated --initial workflow

deploy.py: 1370 → 893 lines (-477 lines, -35%)
This commit is contained in:
Agent Zero
2026-08-06 01:22:09 +02:00
parent 85fcb90b32
commit 5b7d93cd0e
4 changed files with 400 additions and 877 deletions
+56 -533
View File
@@ -1,37 +1,54 @@
#!/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.
═══════════════════════════════════════════════════════════════════════
CURRENT WORKFLOW — Single docker-compose Stack
═══════════════════════════════════════════════════════════════════════
No UUIDs, domains, or secrets are hardcoded. Everything comes from
environment variables or is resolved via the Coolify API.
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 # Full deploy (API + Worker)
python scripts/deploy.py # Redeploy via Coolify API
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
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)
COOLIFY_WORKER_UUID — Worker Service UUID (optional, resolved via API if absent)
APP_NAME — Application name for API lookup (default: leocrm-api)
WORKER_NAME — Worker name for API lookup (default: leocrm-worker)
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)
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, used by regular deploy if setting ENVs):
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
@@ -56,12 +73,10 @@ import httpx
COOLIFY_BASE_URL = os.environ.get("COOLIFY_BASE_URL", "https://server.media-on.de")
COOLIFY_TOKEN = os.environ.get("COOLIFY_API_TOKEN", "")
# UUIDs — from env or resolved via API lookup by name
# UUID — from env or resolved via API lookup by name
APP_UUID = os.environ.get("COOLIFY_APP_UUID", "")
WORKER_UUID = os.environ.get("COOLIFY_WORKER_UUID", "")
# Names for API lookup when UUIDs are not provided
# Derive from APP_DOMAIN if not set (e.g. https://crm.media-on.de → crm)
# 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:
@@ -69,7 +84,6 @@ if os.environ.get("APP_DOMAIN"):
except (IndexError, ValueError):
pass
APP_NAME = os.environ.get("APP_NAME", _domain_default or "app")
WORKER_NAME = os.environ.get("WORKER_NAME", f"{APP_NAME}-worker")
# Domain (required)
APP_DOMAIN = os.environ.get("APP_DOMAIN", "")
@@ -87,10 +101,8 @@ DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
SECRET_KEY = os.environ.get("SECRET_KEY", "")
# Database/Redis host names (defaults match Coolify service names)
DB_HOST = os.environ.get("DB_HOST", "crm-postgres")
# Database name (used by deploy_initial for POSTGRES_DB env)
DB_NAME = os.environ.get("DB_NAME", "crm_db")
REDIS_HOST = os.environ.get("REDIS_HOST", "crm-redis")
# Git repo for initial deployment
API_GIT_REPO = os.environ.get("API_GIT_REPO", "https://forgejo.media-on.de/Leopoldadmin/leocrm.git")
@@ -165,7 +177,7 @@ class CoolifyClient:
resp.raise_for_status()
return resp.json()
# ── Services (Worker) ──
# ── Services ──
def list_services(self) -> list[dict[str, Any]]:
"""List all services."""
@@ -273,114 +285,6 @@ def resolve_app_uuid(client: CoolifyClient) -> str:
raise ValueError(f"Application '{APP_NAME}' not found in Coolify")
def resolve_worker_uuid(client: CoolifyClient) -> str | None:
"""Resolve worker UUID from env var or API lookup by name.
Returns None if worker is not configured (non-fatal)."""
if WORKER_UUID:
return WORKER_UUID
print(f" COOLIFY_WORKER_UUID not set — looking up '{WORKER_NAME}' via Coolify API...")
try:
services = client.list_services()
for svc in services:
name = svc.get("name", "")
uuid = svc.get("uuid", "")
if name == WORKER_NAME:
print(f" Found: {name}{uuid}")
return uuid
print(f" Worker '{WORKER_NAME}' not found — worker deploy will be skipped")
return None
except Exception as e:
print(f" Warning: could not list services: {e}")
return None
# ─── Worker Compose Generation ────────────────────────────────────────
def generate_worker_compose(app_uuid: str, worker_uuid: str) -> str:
"""Generate worker docker-compose YAML dynamically (no hardcoded UUIDs).
Uses ${VARIABLE} syntax for secrets — Coolify substitutes from ENV.
"""
return (
"services:\n"
" worker:\n"
f" image: '{app_uuid}:latest'\n"
" restart: unless-stopped\n"
" entrypoint:\n"
" - /app/worker.sh\n"
" environment:\n"
f" DATABASE_URL: 'postgresql+asyncpg://crm_worker:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n"
f" WORKER_DATABASE_URL: 'postgresql+asyncpg://crm_worker:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n"
f" MIGRATION_DATABASE_URL: 'postgresql+asyncpg://crm_user:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n"
f" AUTH_DATABASE_URL: 'postgresql+asyncpg://crm_auth:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n"
f" REDIS_URL: 'redis://default:${{REDIS_PASSWORD}}@{REDIS_HOST}:6379/0'\n"
" SECRET_KEY: ${SECRET_KEY}\n"
" ENVIRONMENT: ${ENVIRONMENT}\n"
" STORAGE_PATH: ${STORAGE_PATH}\n"
f" COOLIFY_RESOURCE_UUID: {worker_uuid}\n"
f" COOLIFY_CONTAINER_NAME: worker-{worker_uuid}\n"
" SERVICE_NAME_WORKER: worker\n"
" volumes:\n"
f" - '{worker_uuid}_leocrm-worker-storage:/data/storage'\n"
" networks:\n"
" - coolify\n"
f" - {worker_uuid}\n"
f" container_name: worker-{worker_uuid}\n"
" labels:\n"
" - coolify.managed=true\n"
" - coolify.version=4.0.0-beta.470\n"
" - coolify.type=service\n"
f" - coolify.name=worker-{worker_uuid}\n"
f" - coolify.resourceName={WORKER_NAME}\n"
" - coolify.serviceName=worker\n"
" - coolify.service.subType=application\n"
" - coolify.service.subName=worker\n"
"volumes:\n"
" leocrm-worker-storage:\n"
" name: leocrm-worker-storage\n"
f" {worker_uuid}_leocrm-worker-storage:\n"
f" name: {worker_uuid}_leocrm-worker-storage\n"
"networks:\n"
" coolify:\n"
" external: true\n"
" name: coolify\n"
f" {worker_uuid}:\n"
f" name: {worker_uuid}\n"
" external: true\n"
)
def get_worker_envs() -> list[dict]:
"""Worker ENV variables from environment (no hardcoded secrets)."""
return [
{"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": "STORAGE_PATH", "value": os.environ.get("STORAGE_PATH", "/data/storage")},
]
def get_api_envs() -> list[dict]:
"""API ENV variables from environment (no hardcoded secrets)."""
return [
{"key": "DATABASE_URL", "value": f"postgresql+asyncpg://crm_api:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"},
{"key": "AUTH_DATABASE_URL", "value": f"postgresql+asyncpg://crm_auth:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"},
{"key": "WORKER_DATABASE_URL", "value": f"postgresql+asyncpg://crm_worker:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"},
{"key": "MIGRATION_DATABASE_URL", "value": f"postgresql+asyncpg://crm_user:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"},
{"key": "REDIS_URL", "value": f"redis://default:{REDIS_PASSWORD}@{REDIS_HOST}:6379/0"},
{"key": "SECRET_KEY", "value": SECRET_KEY},
{"key": "ENVIRONMENT", "value": os.environ.get("ENVIRONMENT", "production")},
{"key": "STORAGE_PATH", "value": os.environ.get("STORAGE_PATH", "/data/storage")},
{"key": "FRONTEND_URL", "value": APP_DOMAIN},
{"key": "CORS_ORIGINS", "value": APP_DOMAIN},
{"key": "SESSION_COOKIE_SECURE", "value": "true"},
{"key": "LOG_LEVEL", "value": os.environ.get("LOG_LEVEL", "INFO")},
]
# ─── SSH Helper (verification only) ────────────────────────────────────
@@ -433,93 +337,6 @@ def deploy_api(client: CoolifyClient, app_uuid: str, skip_build: bool = False) -
return StepResult(False, f"Deploy trigger failed: {e}")
def deploy_worker(client: CoolifyClient, app_uuid: str, worker_uuid: str, skip_build: bool = False) -> StepResult:
"""Deploy the worker service via Coolify API.
Steps:
1. Update service compose (dynamically generated, ${VARIABLE} syntax)
2. Set connect_to_docker_network=True (coolify network for Redis/Postgres)
3. Set ENV variables via Coolify API (secrets from environment)
4. Tag latest API image as :latest (Coolify uses commit-hash tags)
5. Deploy via POST /deploy (creates new container)
6. Wait for healthy
"""
print(" Deploying worker service via Coolify API...")
try:
# Step 1: Update service compose with dynamic UUIDs and ${VARIABLE} syntax
compose_yaml = generate_worker_compose(app_uuid, worker_uuid)
print(" Updating worker service compose...")
client.update_service(worker_uuid, compose_yaml)
time.sleep(2)
# Step 2: Set connect_to_docker_network=True
print(" Ensuring coolify network connection...")
resp = httpx.patch(
f"{client.base_url}/api/v1/services/{worker_uuid}",
headers=client.headers,
json={"connect_to_docker_network": True},
timeout=30,
)
if resp.status_code != 200:
print(f" Warning: could not set connect_to_docker_network ({resp.status_code})")
# Step 3: Set ENV variables via Coolify API (Coolify auto-generates .env)
print(" Setting ENV variables via Coolify API...")
for env in get_worker_envs():
resp = httpx.post(
f"{client.base_url}/api/v1/services/{worker_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code == 409:
resp = httpx.patch(
f"{client.base_url}/api/v1/services/{worker_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f" Warning: could not set ENV {env['key']} ({resp.status_code})")
# Step 4: Tag the latest API image as :latest
print(" Tagging latest API image as :latest...")
tag_code, tag_output = ssh_run(
f'docker images --format "{{{{.Repository}}}}:{{{{.Tag}}}}" | '
f'grep "^{app_uuid}:" | grep -v latest | head -1 | '
f'xargs -I{{}} docker tag {{}} {app_uuid}:latest'
)
if tag_code != 0:
print(f" Warning: could not tag :latest ({tag_output.strip()})")
# Step 5: Deploy via POST /deploy
print(" Deploying worker service...")
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=120)
if not dep_result.success:
return dep_result
else:
print(" No deployment UUID returned, waiting for healthy...")
# Step 6: Wait for 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."""
deployments = result.get("deployments", [])
if deployments and isinstance(deployments, list):
return deployments[0].get("deployment_uuid")
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]}...")
@@ -540,26 +357,6 @@ def _wait_deployment(client: CoolifyClient, deploy_uuid: str, timeout: int = 300
return StepResult(False, f"Deployment timed out after {timeout}s", time.time() - start)
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:
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 status == "running:healthy" or status == "healthy":
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 StepResult(False, f"Service did not become healthy in {timeout}s", time.time() - start)
# ─── Verification ──────────────────────────────────────────────────────
@@ -665,21 +462,7 @@ def verify_rls() -> StepResult:
)
def verify_worker_service(client: CoolifyClient, worker_uuid: str) -> 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")
status_lower = status.lower()
if "running" in status_lower or "healthy" in status_lower or status_lower == "up":
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, worker_uuid: str | None = None) -> list[tuple[str, StepResult]]:
def run_verification(client: CoolifyClient) -> list[tuple[str, StepResult]]:
"""Run all verification checks and return results."""
results: list[tuple[str, StepResult]] = []
@@ -703,12 +486,6 @@ def run_verification(client: CoolifyClient, worker_uuid: str | None = None) -> l
results.append(("RLS tables", r))
_print_result(r)
if worker_uuid:
print("\n[Verify] Worker service status...")
r = verify_worker_service(client, worker_uuid)
results.append(("Worker service", r))
_print_result(r)
return results
@@ -751,29 +528,14 @@ def validate_config() -> list[str]:
# ─── Main Deploy Pipeline ──────────────────────────────────────────────
def seed_admin_user(app_uuid: str) -> StepResult:
"""Seed admin user after fresh deployment via SSH into the app container."""
admin_email = os.environ.get("ADMIN_EMAIL", "admin@media-on.de")
admin_password = os.environ.get("ADMIN_PASSWORD", "Admin123!")
if not admin_password:
return StepResult(True, "Skipped — ADMIN_PASSWORD not set")
print(f"\n[Seed] Seeding admin user ({admin_email})...")
cmd = f"docker ps --filter name={app_uuid} --format '{{{{.Names}}}}' | grep crm-app | head -1"
rc, container = ssh_run(cmd, timeout=30)
if rc != 0 or not container.strip():
return StepResult(False, f"Could not find crm-app container for {app_uuid}")
container = container.strip()
seed_cmd = f"docker exec {container} python3 /app/scripts/seed_admin.py"
rc, out = ssh_run(seed_cmd, timeout=60)
if rc != 0:
return StepResult(False, f"Admin seed failed: {out.strip()}")
return StepResult(True, f"Admin user seeded: {admin_email}")
def deploy_full(skip_build: bool = False) -> int:
"""Full deploy: API + Worker + Verification."""
"""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 Full Deploy")
print(" LeoCRM Deploy")
print(f"{'='*60}\n")
errors = validate_config()
@@ -785,8 +547,8 @@ def deploy_full(skip_build: bool = False) -> int:
client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN)
steps: list[tuple[str, StepResult]] = []
# Resolve UUIDs
print("\n[0/3] Resolving Coolify resources...")
# Step 1: Resolve UUID
print("\n[1/2] Resolving Coolify application...")
try:
app_uuid = resolve_app_uuid(client)
print(f" App UUID: {app_uuid}")
@@ -794,12 +556,8 @@ def deploy_full(skip_build: bool = False) -> int:
print(f"ERROR: {e}")
return 2
worker_uuid = resolve_worker_uuid(client)
if worker_uuid:
print(f" Worker UUID: {worker_uuid}")
# Step 1: Deploy API
print("\n[1/3] Deploying API via Coolify API...")
# 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)
@@ -807,71 +565,9 @@ def deploy_full(skip_build: bool = False) -> int:
print_summary(steps)
return 1
# Step 2: Deploy Worker
if worker_uuid:
print("\n[2/3] Deploying Worker via Coolify API...")
r = deploy_worker(client, app_uuid, worker_uuid, skip_build=skip_build)
steps.append(("Worker deploy", r))
_print_result(r)
else:
print("\n[2/3] Worker deploy skipped (no worker UUID resolved)")
steps.append(("Worker deploy", StepResult(True, "Skipped — no worker configured")))
# Step 3: Seed admin user (if ADMIN_PASSWORD set)
print("\n[3/4] Seeding admin user...")
r = seed_admin_user(app_uuid)
steps.append(("Admin seed", r))
_print_result(r)
# Step 4: Verification
print("\n[4/4] Running verification...")
verify_results = run_verification(client, worker_uuid=worker_uuid)
steps.extend(verify_results)
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(" LeoCRM Worker-Only 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]] = []
# Resolve UUIDs
print("\n[0/2] Resolving Coolify resources...")
try:
app_uuid = resolve_app_uuid(client)
except Exception as e:
print(f"ERROR: {e}")
return 2
worker_uuid = resolve_worker_uuid(client)
if not worker_uuid:
print("ERROR: No worker UUID resolved (COOLIFY_WORKER_UUID not set and API lookup failed)")
return 2
# Step 1: Deploy Worker
print("\n[1/2] Deploying Worker via Coolify API...")
r = deploy_worker(client, app_uuid, worker_uuid, 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, worker_uuid=worker_uuid)
# Step 3: Verification
print("\n[3/3] Running verification...")
verify_results = run_verification(client)
steps.extend(verify_results)
all_ok = print_summary(steps)
@@ -891,182 +587,16 @@ def verify_only() -> int:
return 2
client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN)
worker_uuid = resolve_worker_uuid(client)
results = run_verification(client, worker_uuid=worker_uuid)
results = run_verification(client)
all_ok = print_summary(results)
return 0 if all_ok else 1
# ─── Initial Deployment (create all Coolify resources from scratch) ──────
# PostgreSQL Compose (pgvector for embeddings)
POSTGRES_COMPOSE = (
"services:\n"
" postgres:\n"
" image: pgvector/pgvector:pg16\n"
" container_name: crm-postgres\n"
" restart: unless-stopped\n"
" environment:\n"
" POSTGRES_USER: ${DB_USER}\n"
" POSTGRES_PASSWORD: ${DB_PASSWORD}\n"
" POSTGRES_DB: ${DB_NAME}\n"
" PGDATA: /var/lib/postgresql/data/pgdata\n"
" volumes:\n"
" - pgdata:/var/lib/postgresql/data\n"
" healthcheck:\n"
" test: ['CMD-SHELL', 'pg_isready -U ${DB_USER} -d ${DB_NAME}']\n"
" interval: 10s\n"
" timeout: 5s\n"
" retries: 5\n"
" start_period: 10s\n"
"volumes:\n"
" pgdata:\n"
)
# Redis Compose
REDIS_COMPOSE = (
"services:\n"
" redis:\n"
" image: redis:7-alpine\n"
" container_name: crm-redis\n"
" restart: unless-stopped\n"
" command: redis-server --requirepass ${REDIS_PASSWORD}\n"
" volumes:\n"
" - redisdata:/data\n"
" healthcheck:\n"
" test: ['CMD-SHELL', 'redis-cli ping || exit 1']\n"
" interval: 10s\n"
" timeout: 5s\n"
" retries: 5\n"
"volumes:\n"
" redisdata:\n"
)
def get_postgres_envs() -> list[dict]:
"""PostgreSQL ENV variables from environment."""
return [
{"key": "DB_USER", "value": os.environ.get("DB_USER", "crm_user")},
{"key": "DB_PASSWORD", "value": DB_PASSWORD},
{"key": "DB_NAME", "value": DB_NAME},
]
def get_redis_envs() -> list[dict]:
"""Redis ENV variables from environment."""
return [
{"key": "REDIS_PASSWORD", "value": REDIS_PASSWORD},
]
def create_service(client: CoolifyClient, project_uuid: str, environment_name: str,
server_uuid: str, compose_raw: str, name: str) -> str | None:
"""Create a Coolify service from a docker-compose definition.
Returns the service UUID or None on failure."""
encoded = base64.b64encode(compose_raw.encode()).decode()
try:
resp = httpx.post(
f"{client.base_url}/api/v1/services",
headers=client.headers,
json={
"project_uuid": project_uuid,
"environment_name": environment_name,
"server_uuid": server_uuid,
"docker_compose_raw": encoded,
"name": name,
},
timeout=30,
)
if resp.status_code in (200, 201):
data = resp.json()
return data.get("uuid")
print(f" Error creating service {name}: {resp.status_code} {resp.text[:200]}")
return None
except Exception as e:
print(f" Error creating service {name}: {e}")
return None
def set_service_envs(client: CoolifyClient, service_uuid: str, envs: list[dict]) -> None:
"""Set ENV variables for a service via Coolify API."""
for env in envs:
resp = httpx.post(
f"{client.base_url}/api/v1/services/{service_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code == 409:
resp = httpx.patch(
f"{client.base_url}/api/v1/services/{service_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f" Warning: could not set ENV {env['key']} ({resp.status_code})")
def set_application_envs(client: CoolifyClient, app_uuid: str, envs: list[dict]) -> None:
"""Set ENV variables for an application via Coolify API."""
for env in envs:
resp = httpx.post(
f"{client.base_url}/api/v1/applications/{app_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code == 409:
resp = httpx.patch(
f"{client.base_url}/api/v1/applications/{app_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f" Warning: could not set ENV {env['key']} ({resp.status_code})")
def create_api_application(client: CoolifyClient, project_uuid: str,
environment_name: str, server_uuid: str) -> str | None:
"""Create the API application from a Git repository via private deploy key.
Returns the application UUID or None on failure.
"""
private_key_uuid = os.environ.get("COOLIFY_PRIVATE_KEY_UUID", "")
if not private_key_uuid:
print(" Error: COOLIFY_PRIVATE_KEY_UUID not set")
return None
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,
"private_key_uuid": private_key_uuid,
"git_repository": API_GIT_REPO,
"git_branch": API_GIT_BRANCH,
"build_pack": "dockerfile",
"name": APP_NAME,
"ports_exposes": "8000",
},
timeout=30,
)
if resp.status_code in (200, 201):
data = resp.json()
return data.get("uuid")
print(f" Error creating API application: {resp.status_code} {resp.text[:300]}")
return None
except Exception as e:
print(f" Error creating API application: {e}")
return None
# ─── Initial Deployment (create Coolify application from scratch) ──────
def deploy_initial() -> int:
"""Initial deployment: create all Coolify resources from scratch.
"""Initial deployment: create the Coolify application from scratch.
═══════════════════════════════════════════════════════════════════════
⚠️ KI / AGENT HINWEISE — BITTE VOR ÄNDERUNGEN LESEN ⚠️
@@ -1149,7 +679,7 @@ def deploy_initial() -> int:
environment_name = os.environ.get("COOLIFY_ENVIRONMENT", "production")
server_uuid = os.environ.get("COOLIFY_SERVER_UUID", "")
# ─── Single docker-compose application (like the original working app) ──
# ─── 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
@@ -1315,7 +845,7 @@ def deploy_initial() -> int:
# Verification
print("\n[5/5] Running verification...")
verify_results = run_verification(client, worker_uuid=None)
verify_results = run_verification(client)
steps.extend(verify_results)
all_ok = print_summary(steps)
@@ -1331,9 +861,8 @@ def main() -> None:
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/deploy.py # Full deploy (API + Worker)
python scripts/deploy.py # Redeploy via Coolify API
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
python scripts/deploy.py --initial # Create all resources from scratch
""",
@@ -1342,10 +871,6 @@ Examples:
"--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(
"--initial", action="store_true",
help="Initial deployment: create all Coolify resources from scratch",
@@ -1360,8 +885,6 @@ Examples:
sys.exit(deploy_initial())
elif 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))