From 5b7d93cd0eeab30f513bc29e0e89f1b9564a7d81 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 6 Aug 2026 01:22:09 +0200 Subject: [PATCH] refactor(deploy): remove old multi-resource code, document single docker-compose workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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%) --- COOLIFY_SETUP.md | 456 +++++++++++++++-------------------- DEPLOY.md | 172 +++++++++----- docs/INSTALL.md | 60 +++-- scripts/deploy.py | 589 +++++----------------------------------------- 4 files changed, 400 insertions(+), 877 deletions(-) diff --git a/COOLIFY_SETUP.md b/COOLIFY_SETUP.md index d3048c3..e0e963c 100644 --- a/COOLIFY_SETUP.md +++ b/COOLIFY_SETUP.md @@ -1,177 +1,195 @@ -# Coolify Setup — CRM System v1.0 +# Coolify Setup — LeoCRM -Production deployment guide for the **CRM System** to the Coolify PaaS instance -at `server.media-on.de` (server UUID `lw80w8scs4044gwcw084s00s4`). +Production deployment guide for LeoCRM to the Coolify PaaS instance +at `server.media-on.de`. -The deploy consists of **three Coolify resources** in the same project/environment: +## Architektur -1. A **PostgreSQL 16** database resource (one-click or Docker image). -2. The **crm-app** Application (Dockerfile build from a Git repository). -3. The **crm-worker** Application (same Dockerfile build, different entrypoint). +LeoCRM läuft als **einzelner docker-compose Stack** in einer Coolify Application. +Coolify liest die `docker-compose.yaml` aus dem Git-Repo und startet alle 4 +Container (postgres, redis, crm_app, crm_worker) in einem gemeinsamen Stack. -The resources talk to each other over the internal Docker network. The app -is exposed publicly on `https://crm.media-on.de:443` (Let's Encrypt via Coolify). -The worker is not exposed publicly — it only needs Redis and PostgreSQL access. +``` +Coolify Application (build_pack=dockercompose) +├── postgres (pgvector/pgvector:pg16) +├── redis (redis:7-alpine) +├── crm_app (FastAPI API Server, Port 8000) +└── crm_worker (ARQ Background Worker) +``` + +Alle Container teilen sich ein Docker-Netzwerk. Service-Namen funktionieren +als DNS-Namen (z.B. `postgres`, `redis`, `crm_app`, `crm_worker`). + +**Keine separaten Coolify Services** für DB/Redis/Worker. Das funktioniert nicht, +weil Coolify jedem Service ein eigenes Netzwerk gibt und die Container sich +nicht per DNS erreichen können. --- -## 0. ⚠️ Critical domain-format gotcha - -Coolify's per-application **Domain field must contain an explicit port** in the -URL. If you enter the domain without `:443`, Let's Encrypt certificate issuance -will silently fail and Traefik will not route traffic correctly. - -``` -✅ https://crm.media-on.de:443 -❌ https://crm.media-on.de -❌ crm.media-on.de -``` - -> The same rule applies in the Coolify API: when calling -> `PATCH /api/v1/applications/{uuid}` you must set -> `{"domains": "https://crm.media-on.de:443"}` (note the `:443` suffix). -> This is a known bug-fix from earlier deployments — never drop the port. - ---- - -## 1. Prerequisites +## 1. Voraussetzungen - Coolify server reachable at `https://server.media-on.de`, API token created in *Keys & Tokens → API tokens* (Bearer token, scope: `*`). -- The DNS **A record** for `crm.media-on.de` points to the public IP of the - Coolify server (Traefik will answer on `:443` and route by `Host` header). -- The CRM source code lives in a **Forgejo repository** that Coolify can - clone. Suggested location: - `https://forge.media-on.de/leopoldadmin/crm-system` (branch `master`). - > If the repo does not exist yet, create it and push the project: - > ```bash - > # One-time: create the repo via Forgejo API or UI - > git remote add origin https://leopoldadmin:@forge.media-on.de/leopoldadmin/crm-system.git - > git push -u origin master - > ``` -- You have the **internal host:port** of the Postgres resource that will be - provisioned in step 2 (Coolify will print it, e.g. `abc123-postgres:5432`). +- DNS **A record** for die App-Domain (z.B. `crm.media-on.de`) zeigt auf die + öffentliche IP des Coolify-Servers. +- LeoCRM source code in Forgejo repository: + `https://forgejo.media-on.de/Leopoldadmin/leocrm.git` (branch `main`). +- Ein **Private Deploy Key** in Coolify hinterlegt (für Git-Zugriff). +- Python 3.12+ mit `httpx` für das deploy script. --- -## 2. Resource A — PostgreSQL 16 database +## 2. Initial Deployment (automatisiert) -In the Coolify UI: - -1. Go to **Databases → + Add**. -2. Choose **PostgreSQL 16** (Alpine). -3. Configuration: - - **Name**: `crm-postgres` - - **Database name**: `crm_db` - - **User**: `crm_user` - - **Password**: *(generate a strong one — see Secret generation below)* - - **Public accessibility**: **disabled** (only the crm-app talks to it) -4. Click **Deploy** and wait for status `running:healthy`. -5. Note the **internal host:port** Coolify exposes (typically - `-postgres:5432`). You will need it in step 3. - -> **Alternative (API):** -> ```bash -> curl -X POST http://server.media-on.de/api/v1/databases \ -> -H "Authorization: Bearer $COOLIFY_TOKEN" \ -> -H "Content-Type: application/json" \ -> -d '{"type":"postgresql","project_uuid":"...","environment_name":"production", -> "server_uuid":"lw80w8scs4044gwcw084s00s4", -> "name":"crm-postgres","postgres_user":"crm_user", -> "postgres_password":"", -> "postgres_db":"crm_db","is_public":false}' -> ``` - ---- - -## 3. Resource B — crm-app (Dockerfile build) - -In the Coolify UI: - -1. **Projects → + Add Project** if you don't have one yet (e.g. `CRM`). -2. **Environment → + Add Environment** → name: `production`. -3. Inside that environment, **+ Add → Application → Public/Private Repository**. -4. Fill in: - - **Git repository**: `https://forge.media-on.de/leopoldadmin/crm-system` - - **Branch**: `master` - - **Build pack**: `Dockerfile` - - **Dockerfile location**: `Dockerfile` (default, repo root) - - **Port**: `8000` -5. Click **Deploy** once to let Coolify create the resource (it will fail to - start without environment variables — that's expected). -6. Note the **Application UUID** (visible in the URL or via - `GET /api/v1/applications`). - ---- - -## 4. Environment variables (on the crm-app resource) - -In **crm-app → Environment Variables**, set: - -| Key | Value | Notes | -|-----|-------|-------| -| `DATABASE_URL` | `postgresql+asyncpg://crm_user:@:5432/crm_db` | Use the internal host from step 2 (e.g. `crm-postgres-xyz:5432`), **not** `localhost` and **not** the public DNS. | -| `AUTH_SECRET` | *see secret generation* | **MUST be ≥ 32 chars.** | -| `CORS_ORIGINS` | `https://crm.media-on.de:443` | Comma-separated, no wildcards, must match the domain where the browser actually loads the SPA. | -| `ENVIRONMENT` | `production` | | -| `LOG_LEVEL` | `INFO` | `DEBUG` only temporarily. | -| `BCRYPT_ROUNDS` | `12` | Aligned with `.env.example`. | - - -### Secret generation (run once, locally) +### 2.1 Umgebungsvariablen setzen ```bash -# AUTH_SECRET (min 32 chars, recommended 48+) -python -c "import secrets; print(secrets.token_urlsafe(48))" - -# POSTGRES_PASSWORD (min 16 chars, recommended 24+) -python -c "import secrets; print(secrets.token_urlsafe(24))" +export COOLIFY_API_TOKEN="dein-token" +export APP_DOMAIN="https://crm.media-on.de" +export APP_NAME="leocrm" +export DB_PASSWORD="" +export REDIS_PASSWORD="" +export SECRET_KEY="" +export COOLIFY_PROJECT_UUID="" +export COOLIFY_SERVER_UUID="" +export COOLIFY_PRIVATE_KEY_UUID="" +export COOLIFY_ENVIRONMENT="production" # optional +export ADMIN_EMAIL="admin@media-on.de" # optional +export ADMIN_PASSWORD="Admin123!" # optional ``` -**Never commit these values.** Coolify stores them encrypted at rest, but they -are still rendered in the UI to anyone with read access to the environment. +### 2.2 Deploy starten -> **Alternative (API — bulk update):** -> ```bash -> curl -X PATCH http://server.media-on.de/api/v1/applications/$APP_UUID/envs/bulk \ -> -H "Authorization: Bearer $COOLIFY_TOKEN" \ -> -H "Content-Type: application/json" \ -> -d '{ -> "data": [ -> {"key":"DATABASE_URL", "value":"postgresql+asyncpg://crm_user:@:5432/crm_db"}, -> {"key":"AUTH_SECRET", "value":""}, -> {"key":"CORS_ORIGINS", "value":"https://crm.media-on.de:443"}, -> {"key":"ENVIRONMENT", "value":"production"}, -> {"key":"LOG_LEVEL", "value":"INFO"}, -> {"key":"BCRYPT_ROUNDS", "value":"12"} -> ] -> }' -> ``` +```bash +python scripts/deploy.py --initial +``` + +Das Script führt einen **2-Phase Deploy** durch: + +1. **Phase 1**: Application via `private-deploy-key` erstellen, build_pack auf + `dockercompose` setzen, ENV-Variablen setzen, erster Deploy **ohne Domain**. + Coolify liest `docker-compose.yaml` aus dem Git-Repo und baut alle Container. + +2. **Phase 2**: `docker_compose_domains` setzen (für Traefik-Labels), dann + Redeploy. Jetzt ist die App unter der Domain erreichbar. + +### 2.3 Warum 2-Phase Deploy? + +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. Ein Deploy ohne vorherigen Read der Compose-Datei führt zu +fehlenden Traefik-Labels → 503 Fehler. --- -## 5. Configure the public domain (with port!) +## 3. Redeploy (bestehende Anwendung) -In **crm-app → Domains → + Add Domain**: +```bash +export COOLIFY_API_TOKEN="dein-token" +export APP_DOMAIN="https://crm.media-on.de" +export COOLIFY_APP_UUID="dx4pqdziu4uj6x9fxs1u5z0x" # optional -- **Domain**: `https://crm.media-on.de:443` - - ⚠️ **Port `:443` is mandatory.** See section 0. -- **Let's Encrypt**: **enabled** (default). -- Click **Save**. Coolify will issue the certificate and reload Traefik. +python scripts/deploy.py +``` -> **Alternative (API):** -> ```bash -> curl -X PATCH http://server.media-on.de/api/v1/applications/$APP_UUID \ -> -H "Authorization: Bearer $COOLIFY_TOKEN" \ -> -H "Content-Type: application/json" \ -> -d '{"domains": "https://crm.media-on.de:443"}' -> ``` +Triggert `/api/v1/deploy` für die bestehende Coolify Application und wartet auf +Erfolg. Danach läuft automatisch die Verifikation (HTTP, Login, Alembic, RLS). --- -## 6. Healthcheck (Coolify side) +## 4. Verifikation -In **crm-app → Advanced → Healthcheck**: +```bash +python scripts/deploy.py --verify-only +``` + +Prüft: +- HTTP Health (`/api/v1/health`) +- Login (optional, wenn LOGIN_EMAIL/LOGIN_PASSWORD gesetzt) +- Alembic Migration Head (via SSH in den postgres Container) +- RLS-Tabellen-Anzahl (via SSH) + +--- + +## 5. Wichtige Hinweise + +### 5.1 Service-Namen mit Unterstrichen + +In `docker-compose.yaml` müssen Service-Namen **Unterstriche** verwenden: +`crm_app`, `crm_worker` — nicht `crm-app`, `crm-worker`. + +Coolify konvertiert Bindestriche zu Unterstrichen in `docker_compose_domains`. +Bei Bindestrichen in der Compose-Datei gibt es keinen Match → keine +Traefik-Labels → 503 Fehler. + +### 5.2 docker-compose.yaml (nicht .yml) + +Coolify sucht nach `docker-compose.yaml` (mit `.yaml`). Eine Datei namens +`docker-compose.yml` wird nicht gefunden. + +### 5.3 Domain ohne :443 + +In `docker_compose_domains` darf die Domain **kein** `:443` am Ende haben: +``` +✅ https://crm.media-on.de +❌ https://crm.media-on.de:443 +``` +Das `:443` führt zu leeren `Host()` Traefik-Labels. + +### 5.4 Kein connect_to_docker_network + +Innerhalb eines docker-compose Stacks kümmert sich Coolify selbst um das +Netzwerk. `connect_to_docker_network=True` ist nicht nötig und sollte nicht +gesetzt werden. + +--- + +## 6. Mehrere Instanzen + +```bash +# Test-Instanz +APP_NAME=leocrm-test APP_DOMAIN=https://crm-test.media-on.de \ + python scripts/deploy.py --initial + +# Produktions-Instanz +APP_NAME=leocrm APP_DOMAIN=https://crm.media-on.de \ + python scripts/deploy.py --initial +``` + +Jede Instanz hat eigene DB, Redis, Container und Domain. Alle Parameter werden +aus `APP_NAME` und `APP_DOMAIN` abgeleitet. + +--- + +## 7. Environment-Variablen in Coolify + +Das `--initial` Script setzt automatisch folgende ENV-Variablen in Coolify: + +| Key | Wert | Quelle | +|-----|------|--------| +| `POSTGRES_USER` | `crm_user` | Default | +| `POSTGRES_DB` | `crm_db` | Default | +| `DB_PASSWORD` | * | ENV | +| `REDIS_PASSWORD` | * | ENV | +| `SECRET_KEY` | * | ENV | +| `ENVIRONMENT` | `production` | Default | +| `LOG_LEVEL` | `INFO` | Default | +| `SESSION_COOKIE_SECURE` | `true` | Default | +| `STORAGE_PATH` | `/data/storage` | Default | +| `CORS_ORIGINS` | APP_DOMAIN | ENV | +| `FRONTEND_URL` | APP_DOMAIN | ENV | +| `APP_DOMAIN` | APP_DOMAIN | ENV | +| `ADMIN_EMAIL` | `admin@media-on.de` | ENV (optional) | +| `ADMIN_PASSWORD` | `Admin123!` | ENV (optional) | + +Die `docker-compose.yaml` verwendet `${VARIABLE}` Syntax — Coolify substituiert +aus diesen ENV-Variablen. + +--- + +## 8. Healthcheck + +In **Coolify → Application → Advanced → Healthcheck**: - **Healthcheck path**: `/api/v1/health` - **Healthcheck method**: `GET` @@ -180,138 +198,44 @@ In **crm-app → Advanced → Healthcheck**: - **Healthcheck retries**: `3` - **Healthcheck start period**: `15s` -> The Dockerfile's in-container `HEALTHCHECK` is the source of truth for -> Docker-level health. The Coolify/Traefik healthcheck is what drives -> automatic rollbacks and load-balancer routing. Set both, identically. +--- + +## 9. Going forward — Redeploys + +- **Code change** → push to `main` auf Forgejo → `python scripts/deploy.py` + (oder Coolify UI → Deployments → Deploy). +- **Environment variable change** → Coolify UI (oder API `PATCH .../envs/bulk`) + → Deploy (Coolify startet nicht automatisch bei ENV-Änderung neu). +- **Domain change** → API (`PATCH /api/v1/applications/{uuid}` mit + `docker_compose_domains`) — reproduzierbar, UI als Fallback. --- -## 7. Build & deploy +## 10. Troubleshooting -In the Coolify UI: **crm-app → Deployments → Deploy**. +**503 Fehler (Traefik):** +- Domain ohne `:443` in `docker_compose_domains` +- Service-Namen mit Unterstrichen in docker-compose.yaml +- `docker-compose.yaml` (nicht `.yml`) -Watch the build log. The first deploy will: +**Container können sich nicht erreichen (DNS):** +- Alles in einem docker-compose Stack (nicht separate Coolify Services) +- Kein `connect_to_docker_network` setzen -1. Clone the repo (branch `master`). -2. Build the multi-stage Dockerfile (≈ 1–2 min, depending on cache). -3. Start the container. `prestart.sh` runs `alembic upgrade head` against the - Postgres database. -4. Uvicorn binds to `0.0.0.0:8000` and starts serving. - -A healthy deploy ends with the container status `running:healthy`. - -> **Alternative (API):** -> ```bash -> curl -X POST http://server.media-on.de/api/v1/deploy \ -> -H "Authorization: Bearer $COOLIFY_TOKEN" \ -> -H "Content-Type: application/json" \ -> -d "{\"uuid\":\"$APP_UUID\"}" -> ``` - ---- - -## 8. Verification - -From anywhere with internet access: +**"Docker Compose file not found":** +- Datei heißt `docker-compose.yaml` (nicht `.yml`) +**Migration fehlgeschlagen:** ```bash -# 1. Root health (used by Docker HEALTHCHECK & Coolify healthcheck) -curl -fsSL -o /dev/null -w "%{http_code}\n" https://crm.media-on.de:443/health -# → 200 - -# 2. API v1 health (mounted under the versioned router) -curl -fsSL -o /dev/null -w "%{http_code}\n" https://crm.media-on.de:443/api/v1/health -# → 200 - -# 3. Frontend SPA (served by the static-files mount) -curl -fsSL -o /dev/null -w "%{http_code} %{content_type}\n" \ - https://crm.media-on.de:443/index.html -# → 200 text/html - -# 4. Interactive API docs -# Open in a browser: https://crm.media-on.de:443/docs -# Register a user via POST /api/v1/auth/register -# Login via POST /api/v1/auth/login → access_token -# Use the token as `Authorization: Bearer ` on protected routes +docker exec psql -U crm_user -d crm_db -c "SELECT version_num FROM alembic_version" +docker exec alembic upgrade head ``` -If any of these return `502` / `503` / `504`: - -- Check **crm-app → Logs** in Coolify (the UI is the only place with full - stdout/stderr, the API does not expose logs). -- Confirm the container is `running:healthy` (not `running:unhealthy`, - `exited`, or `starting`). -- Confirm the Postgres resource is `running:healthy` and the - `DATABASE_URL` host matches its internal DNS name. - -For full incident response, see [`/a0/.a0/runbook-restore.md`](../../a0/runbook-restore.md). - --- -## 9. Going forward — redeploys - -- **Code change** → push to `master` on Forgejo → **Deployments → Deploy** in - Coolify. The Dockerfile layer-cache will reuse `pip install -r - requirements.txt` if `requirements.txt` is unchanged. -- **Environment variable change** → edit in Coolify UI (or `PATCH .../envs/bulk` - via API) → **Deploy** (Coolify does *not* auto-restart on ENV change alone). -- **Domain change** → use the API (`PATCH /api/v1/applications/{uuid}`) so it - is reproducible; the UI is a fallback only. - ---- - -## 10. References +## 11. Referenzen - Coolify v4 API — `/a0/usr/plugins/coolify_control/help/coolify-control/help.md` -- App architecture (Section 13 lockdown) — `/a0/.a0/02-architecture.md` -- Task graph (Phase 4d) — `/a0/.a0/03-task-graph.json` -- Restore runbook — `/a0/.a0/runbook-restore.md` - ---- - -## 11. Resource C — crm-worker (Background Worker) - -The crm-worker runs the ARQ background worker and scheduler in a separate -container, using the same Docker image as crm-app but with a different -entrypoint (`/app/worker.sh` instead of `/app/prestart.sh`). - -### Setup in Coolify UI - -1. In the same project/environment as crm-app, **+ Add → Application → - Public/Private Repository**. -2. Fill in: - - **Git repository**: same as crm-app (`https://forgejo.media-on.de/Leopoldadmin/leocrm.git`) - - **Branch**: `main` - - **Build pack**: `Dockerfile` - - **Dockerfile location**: `Dockerfile` (same image) - - **Port**: `8000` (not used, but Coolify requires a port) - - **Custom Entrypoint**: `/app/worker.sh` -3. Click **Deploy** once to create the resource. -4. Note the **Application UUID**. - -### Environment variables (on the crm-worker resource) - -Set the same variables as crm-app, except: - -| Key | Value | Notes | -|-----|-------|-------| -| `DATABASE_URL` | same as crm-app | | -| `REDIS_URL` | same as crm-app | | -| `SECRET_KEY` | same as crm-app | | -| `ENVIRONMENT` | `production` | | -| `LOG_LEVEL` | `INFO` | | -| `STORAGE_PATH` | `/data/storage` | | - -No domain is needed — the worker is not publicly accessible. - -### Healthcheck (Coolify side) - -- **Healthcheck path**: `/api/v1/health` (not used by worker, but Coolify requires one) -- Alternatively, use a custom healthcheck command: - `pgrep -f "arq app.core.worker.WorkerSettings" || exit 1` - -### Scaling - -To scale the worker horizontally, deploy multiple crm-worker instances. -Cron jobs use a Redis-based distributed lock (`SET NX` with TTL) so only -one replica executes each scheduled job. +- App architecture — `architecture.md` +- Deploy script — `scripts/deploy.py` +- Install guide — `docs/INSTALL.md` diff --git a/DEPLOY.md b/DEPLOY.md index be0c59a..d03a936 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,39 +1,86 @@ # LeoCRM Deployment +## Architektur + +LeoCRM läuft als **einzelner docker-compose Stack** in Coolify. Alle 4 Container +(PostgreSQL, Redis, API, Worker) werden aus der `docker-compose.yaml` im Git-Repo +gestartet und teilen sich ein Docker-Netzwerk. + +``` +┌─────────────────────────────────────────────┐ +│ Coolify Application (docker-compose) │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ postgres │ │ redis │ │ +│ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ +│ ┌────┴─────┐ ┌────┴─────┐ │ +│ │ crm_app │ │crm_worker│ │ +│ │ (API) │ │ (ARQ) │ │ +│ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────┘ +``` + +Keine separaten Coolify Services für DB/Redis/Worker. Alles in einem Stack. + ## Quick Start -### Option A: Coolify (empfohlen für Produktion) +### Redeploy (bestehende Anwendung) ```bash -# Einmalig: Umgebungsvariablen setzen +# Umgebungsvariablen setzen export COOLIFY_API_TOKEN="dein-token" -export COOLIFY_APP_UUID="dx4pqdziu4uj6x9fxs1u5z0x" # oder via APP_DOMAIN auto-resolved +export APP_DOMAIN="https://crm.media-on.de" +export COOLIFY_APP_UUID="dx4pqdziu4uj6x9fxs1u5z0x" # optional, wird via APP_NAME gesucht -# Deploy +# Redeploy via Coolify API python scripts/deploy.py -# Redeploy (ohne Neubuild) -python scripts/deploy.py --skip-build +# Verifikation nur +python scripts/deploy.py --verify-only ``` Das Script macht automatisch: -1. Coolify Build & Deploy triggern -2. Persistent Volume in Coolify DB konfigurieren (automatisch, portabel) -3. Auf healthy Container warten -4. RLS auf allen Tenant-Tabellen sicherstellen -5. DB-Migrationen verifizieren -6. Worker-Container starten -7. App-Health verifizieren -8. Domain-Erreichbarkeit prüfen +1. Coolify Application auflösen (via UUID oder Name) +2. Deploy via `/api/v1/deploy` triggern +3. Auf Deployment-Erfolg warten +4. HTTP Health-Check verifizieren +5. Login-Test (optional, wenn LOGIN_EMAIL/LOGIN_PASSWORD gesetzt) +6. Alembic-Migration-Head prüfen (via SSH) +7. RLS-Tabellen zählen (via SSH) -**Funktioniert auf jeder Coolify-Instanz. Bei mehreren Apps. Bei Erst-Deploy und Redeploy.** +### Initial Deployment (neue Anwendung) + +```bash +# Alle Umgebungsvariablen setzen +export COOLIFY_API_TOKEN="dein-token" +export APP_DOMAIN="https://crm.media-on.de" +export APP_NAME="leocrm" # Coolify Application Name +export DB_PASSWORD="..." +export REDIS_PASSWORD="..." +export SECRET_KEY="..." +export COOLIFY_PROJECT_UUID="..." +export COOLIFY_SERVER_UUID="..." +export COOLIFY_PRIVATE_KEY_UUID="..." +export COOLIFY_ENVIRONMENT="production" # optional, default: production + +# Initial deployment +python scripts/deploy.py --initial +``` + +Das Script macht automatisch: +1. Coolify Application via `private-deploy-key` erstellen +2. Build Pack auf `dockercompose` setzen (liest docker-compose.yaml aus Git) +3. Environment-Variablen setzen (Secrets, Domain, Admin-Credentials) +4. Erster Deploy (ohne Domain — Coolify muss docker-compose.yaml lesen) +5. `docker_compose_domains` setzen + Redeploy (mit Traefik-Labels) +6. Verifikation (HTTP, Login, Alembic, RLS) ### Option B: Docker Compose (lokal / ohne Coolify) ```bash # .env.docker erstellen cp .env.docker.example .env.docker -$EDITOR .env.docker # SECRET_KEY, POSTGRES_PASSWORD, etc. ausfüllen +$EDITOR .env.docker # SECRET_KEY, DB_PASSWORD, REDIS_PASSWORD etc. ausfüllen # Starten (alle 4 Container: Postgres, Redis, App, Worker) docker compose --env-file .env.docker up --build -d @@ -46,19 +93,20 @@ docker compose down ``` **Container:** -- `crm-postgres` — PostgreSQL 16 mit pgvector -- `crm-redis` — Redis 7 -- `crm-app` — FastAPI API Server -- `crm-worker` — ARQ Background Worker +- `postgres` — PostgreSQL 16 mit pgvector +- `redis` — Redis 7 +- `crm_app` — FastAPI API Server +- `crm_worker` — ARQ Background Worker Alle mit persistenten Volumes. Kein Datenverlust bei Redeploy. ## Voraussetzungen - Python 3.12+ +- `httpx` Python package - Docker & Docker Compose (für Option B) - Coolify v4+ (für Option A) -- SSH-Zugang zum Server (für Option A) +- SSH-Zugang zum Server (für Verifikation, Option A) ## Umgebungsvariablen @@ -66,13 +114,26 @@ Siehe `.env.example` für alle Variablen. Wichtigste: | Variable | Pflicht | Default | Beschreibung | |---|---|---|---| -| `DATABASE_URL` | Ja | — | PostgreSQL Connection String | -| `REDIS_URL` | Ja | — | Redis Connection String | -| `SECRET_KEY` | Ja | — | Mindestens 32 Zeichen | -| `ENVIRONMENT` | Nein | `development` | `production` oder `development` | -| `SESSION_COOKIE_SECURE` | Nein | `true` | In Production muss `true` | -| `STORAGE_PATH` | Nein | `/data/storage` | Datei-Upload-Pfad | -| `STORAGE_BACKEND` | Nein | `local` | `local` oder `s3` | +| `COOLIFY_API_TOKEN` | Ja | — | Coolify API Token | +| `APP_DOMAIN` | Ja | — | App-Domain (z.B. https://crm.media-on.de) | +| `COOLIFY_APP_UUID` | Nein | — | Application UUID (auto-resolved via APP_NAME) | +| `APP_NAME` | Nein | abgeleitet aus APP_DOMAIN | Coolify Application Name | +| `SSH_KEY` | Nein | `/a0/usr/workdir/.ssh/coolify-01-root` | SSH Key für Verifikation | +| `SERVER_IP` | Nein | `46.225.91.159` | Server IP für SSH | +| `LOGIN_EMAIL` | Nein | — | Login-Test Email (optional) | +| `LOGIN_PASSWORD` | Nein | — | Login-Test Passwort (optional) | + +### Nur für `--initial`: + +| Variable | Pflicht | Beschreibung | +|---|---|---| +| `DB_PASSWORD` | Ja | PostgreSQL Passwort | +| `REDIS_PASSWORD` | Ja | Redis Passwort | +| `SECRET_KEY` | Ja | Application Secret Key (min. 32 Zeichen) | +| `COOLIFY_PROJECT_UUID` | Ja | Coolify Project UUID | +| `COOLIFY_SERVER_UUID` | Ja | Coolify Server UUID | +| `COOLIFY_PRIVATE_KEY_UUID` | Ja | Coolify Private Deploy Key UUID | +| `COOLIFY_ENVIRONMENT` | Nein | Coolify Environment (default: production) | ## S3 Storage (optional) @@ -85,19 +146,19 @@ S3_ACCESS_KEY=... S3_SECRET_KEY=... ``` -## Test- vs. Produktionsumgebung +## Mehrere Instanzen -**Test:** +Mehrere LeoCRM-Instanzen auf demselben Coolify-Server: ```bash -python scripts/deploy.py --environment test -``` -Eigene Coolify-App, eigene DB, eigene Domain (`crm-test.media-on.de`). +# Test-Instanz +APP_NAME=leocrm-test APP_DOMAIN=https://crm-test.media-on.de python scripts/deploy.py --initial -**Produktion:** -```bash -python scripts/deploy.py --environment production +# Produktions-Instanz +APP_NAME=leocrm APP_DOMAIN=https://crm.media-on.de python scripts/deploy.py --initial ``` +Jede Instanz hat eigene DB, Redis, Container und Domain. + ## Troubleshooting **Container nicht healthy:** @@ -110,15 +171,10 @@ docker logs --tail 50 docker exec alembic upgrade head ``` -**RLS nicht aktiv:** -```bash -python scripts/deploy.py --migrate-only -``` - -**Worker nicht gestartet:** -```bash -python scripts/deploy.py --skip-build # startet Worker automatisch -``` +**503 Fehler (Traefik):** +- Domain ohne `:443` in `docker_compose_domains` setzen +- Service-Namen mit Unterstrichen in docker-compose.yaml (crm_app, nicht crm-app) +- `docker-compose.yaml` (nicht `.yml`) als Dateiname ## Backup & Restore @@ -126,46 +182,34 @@ python scripts/deploy.py --skip-build # startet Worker automatisch ```bash # Full DB backup (run on the host or via docker exec) -docker exec crm-postgres pg_dump -U crm_user -Fc crm_db > backup_$(date +%Y%m%d_%H%M%S).dump - -# Backup mit Custom-Format (komprimiert, parallel restore-fähig) -docker exec crm-postgres pg_dump -U crm_user -Fc -Z 9 crm_db > backup_$(date +%Y%m%d).dump +docker exec pg_dump -U crm_user -Fc crm_db > backup_$(date +%Y%m%d_%H%M%S).dump ``` ### Backup (Redis — Sessions/Queues) ```bash -# Redis RDB Snapshot -docker exec crm-redis redis-cli -a "$REDIS_PASSWORD" SAVE -docker cp crm-redis:/data/dump.rdb redis_backup_$(date +%Y%m%d).rdb -``` - -### Backup (File Storage) - -```bash -# Local storage volume -docker run --rm -v leocrm-fix_storage:/data -v $(pwd):/backup alpine \ - tar czf /backup/storage_$(date +%Y%m%d).tar.gz /data +docker exec redis-cli -a "$REDIS_PASSWORD" SAVE +docker cp :/data/dump.rdb redis_backup_$(date +%Y%m%d).rdb ``` ### Restore (PostgreSQL) ```bash # Stop app containers -docker compose stop crm-app crm-worker +docker compose stop crm_app crm_worker # Restore DB -docker exec -i crm-postgres pg_restore -U crm_user -d crm_db --clean < backup_20260726.dump +docker exec -i pg_restore -U crm_user -d crm_db --clean < backup_20260726.dump # Restart app -docker compose start crm-app crm-worker +docker compose start crm_app crm_worker ``` ### Automatisierte Backups (Cron) ```bash # /etc/cron.d/leocrm-backup -0 2 * * * root docker exec crm-postgres pg_dump -U crm_user -Fc crm_db > /backups/leocrm_$(date +\%Y\%m\%d).dump +0 2 * * * root docker exec pg_dump -U crm_user -Fc crm_db > /backups/leocrm_$(date +\%Y\%m\%d).dump 0 3 * * * root find /backups -name 'leocrm_*.dump' -mtime +30 -delete ``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9a7078c..4661936 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -241,28 +241,60 @@ SECRET_KEY=your-secret-key-with-at-least-32-characters!! --- ## 6. Coolify-Setup +### 6.1 Neue Anwendung erstellen (automatisiert) -### 6.1 Neue Anwendung erstellen +LeoCRM wird als einzelner docker-compose Stack in Coolify deployt. Alle 4 Container +(postgres, redis, crm_app, crm_worker) laufen in einer Coolify Application mit +`build_pack=dockercompose`. -1. In Coolify: **+ New Resource** → **Docker Compose** -2. Name: `leocrm` -3. Compose-Datei einfügen (siehe oben) -4. Domain zuweisen: `crm.example.com` +```bash +# Umgebungsvariablen setzen +export COOLIFY_API_TOKEN="dein-token" +export APP_DOMAIN="https://crm.example.com" +export APP_NAME="leocrm" +export DB_PASSWORD="YourSecurePassword2026" +export REDIS_PASSWORD="YourRedisPassword2026" +export SECRET_KEY="your-secret-key-with-at-least-32-characters!!" +export COOLIFY_PROJECT_UUID="" +export COOLIFY_SERVER_UUID="" +export COOLIFY_PRIVATE_KEY_UUID="" -### 6.2 Environment-Variablen in Coolify +# Initial deployment (2-Phase: ohne Domain, dann mit Domain + Redeploy) +python scripts/deploy.py --initial +``` -Alle Variablen aus der `.env`-Datei in Coolify als Environment-Variablen setzen. +Das Script macht automatisch: +1. Coolify Application via `private-deploy-key` erstellen +2. Build Pack auf `dockercompose` setzen (liest docker-compose.yaml aus Git) +3. Environment-Variablen setzen (Secrets, Domain, Admin-Credentials) +4. Erster Deploy (ohne Domain — Coolify muss docker-compose.yaml lesen) +5. `docker_compose_domains` setzen + Redeploy (mit Traefik-Labels) +6. Verifikation (HTTP, Login, Alembic, RLS) -### 6.3 Deploy +### 6.2 Redeploy (bestehende Anwendung) -1. **Deploy** klicken -2. Warten bis API-Container healthy wird (start_period: 180s) -3. Worker-Container wird automatisch healthy +```bash +export COOLIFY_API_TOKEN="dein-token" +export APP_DOMAIN="https://crm.example.com" +python scripts/deploy.py +``` -### 6.4 WICHTIG: DB-Image +### 6.3 Wichtige Hinweise + +- **docker-compose.yaml** (nicht `.yml`) — Coolify sucht nach `.yaml` +- **Service-Namen mit Unterstrichen**: `crm_app`, `crm_worker` (nicht mit Bindestrich) +- **Domain ohne `:443`** in `docker_compose_domains` — `:443` führt zu leeren Traefik-Labels +- **Kein `connect_to_docker_network`** — Coolify kümmert sich selbst um das Netzwerk im Stack +- **DB-Image**: MUSS `pgvector/pgvector:pg16` sein (nicht `postgres:16-alpine`) + +### 6.4 Environment-Variablen in Coolify + +Das `--initial` Script setzt automatisch alle benötigten ENV-Variablen in Coolify. +Die `docker-compose.yaml` verwendet `${VARIABLE}` Syntax — Coolify substituiert +aus diesen ENV-Variablen. + +Siehe auch: `DEPLOY.md` und `COOLIFY_SETUP.md` für Details. -Das DB-Image MUSS `pgvector/pgvector:pg16` sein, nicht `postgres:16-alpine`. -LeoCRM benötigt die `vector`-Extension für die unified_search-Plugin-Migration. --- diff --git a/scripts/deploy.py b/scripts/deploy.py index f613667..595b48e 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -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))