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:
+190
-266
@@ -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:<TOKEN>@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
|
||||
`<resource-uuid>-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":"<STRONG_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:<PW>@<postgres-internal-host>: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="<sicheres-passwort>"
|
||||
export REDIS_PASSWORD="<sicheres-passwort>"
|
||||
export SECRET_KEY="<mindestens-32-zeichen>"
|
||||
export COOLIFY_PROJECT_UUID="<project-uuid>"
|
||||
export COOLIFY_SERVER_UUID="<server-uuid>"
|
||||
export COOLIFY_PRIVATE_KEY_UUID="<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:<PW>@<PG_HOST>:5432/crm_db"},
|
||||
> {"key":"AUTH_SECRET", "value":"<TOKEN_URLSAFE_48>"},
|
||||
> {"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 <access_token>` on protected routes
|
||||
docker exec <postgres-container> psql -U crm_user -d crm_db -c "SELECT version_num FROM alembic_version"
|
||||
docker exec <api-container> 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`
|
||||
|
||||
@@ -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 <container-name> --tail 50
|
||||
docker exec <container> 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 <postgres-container> 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-container> redis-cli -a "$REDIS_PASSWORD" SAVE
|
||||
docker cp <redis-container>:/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 <postgres-container> 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 <postgres-container> 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
|
||||
```
|
||||
|
||||
|
||||
+46
-14
@@ -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="<project-uuid>"
|
||||
export COOLIFY_SERVER_UUID="<server-uuid>"
|
||||
export COOLIFY_PRIVATE_KEY_UUID="<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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+54
-531
@@ -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)
|
||||
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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user