eptm_dashboard/DEPLOY_PROD.md

295 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Déploiement en production — EPTM Dashboard
Procédure de bascule de l'app **Streamlit legacy** (`absences.service`,
`dashboard.eptm-automation.ch`) vers l'app **Reflex** (en prod sur le
même sous-domaine). Document à retravailler avant exécution.
---
## État actuel (2026-05-11)
```
Internet :80/:443
┌────────────────────────────────────┐
│ NPM (nginx-proxy-manager) │
│ container "npm" sur proxy_net │
└────────────────────────────────────┘
├── dashboard.eptm-automation.ch
│ → 172.17.0.1:8501
│ → Streamlit legacy (systemd "absences.service")
│ /opt/absences/.venv/bin/streamlit run src/app.py
└── dev.dashboard.eptm-automation.ch
→ eptm-dashboard-app-1:3001 (Reflex dev)
+ /_event → :8001
(network proxy_net)
```
- Streamlit tourne en systemd `absences.service` (enabled, depuis le 2026-05-09).
- L'app Reflex dev est containerisée, déjà sur `proxy_net`, accessible via NPM.
- Pas encore de Dockerfile/compose **prod** : il faut les créer.
## État cible
```
Internet :80/:443 → NPM
├── dashboard.eptm-automation.ch
│ → eptm-dashboard-prod-app-1:3002 (Reflex prod)
│ + /_event → :8002
└── dev.dashboard.eptm-automation.ch
→ eptm-dashboard-app-1:3001 (Reflex dev, inchangé)
+ /_event → :8001
```
- Streamlit `absences.service` arrêté + disabled (binaires gardés en backup
quelques semaines, suppression plus tard).
- Deux stacks compose côte à côte sur `proxy_net`, ports internes
distincts (3001/8001 dev, 3002/8002 prod), aucun port host exposé.
- NPM continue de gérer les certs Let's Encrypt (déjà émis pour ce
sous-domaine, juste à conserver — pas de renouvellement à forcer).
---
## ⚠️ À clarifier avant exécution
| # | Question | Hypothèse par défaut |
|---|---|---|
| 1 | **Données** : prod partage `./data` avec dev ou stack séparée `./data-prod` ? | **Séparer** (`./data-prod`) — sinon un test en dev peut planter la prod (WAL OK mais migrations destructives non) |
| 2 | **Premier remplissage de la DB prod** : repartir d'un import frais (run_imports + sync_escada) ou copier `data/eptm.db` actuel ? | **Copier** la DB de dev au moment du cutover (snapshot cohérent) |
| 3 | **Streamlit après cutover** : on garde le service en `disabled` qq semaines, ou on purge `/opt/absences` ? | **Garder disabled** ~1 mois (rollback plan B), purge en tâche TODO |
| 4 | **Build** : local sur le serveur ou registry (GHCR / Docker Hub) ? | **Local** sur le serveur — pas de CI pour démarrer, on n'est qu'une instance |
| 5 | **Downtime acceptable** au cutover ? | ~30-60s pendant que NPM bascule de proxy host. Pas de zero-downtime |
| 6 | **`.env.prod`** : tester avec env identique à dev ? | À auditer ensemble (peut contenir `escada_username/password`, SMTP, etc.) |
---
## Fichiers à créer
### 1. `Dockerfile.prod` (multi-stage)
```dockerfile
# Stage 1 : builder — installe deps + export frontend
FROM python:3.13-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs npm curl unzip && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN reflex export --frontend-only --no-zip
# Stage 2 : runtime — backend granian uniquement
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app /app
RUN pip install --no-cache-dir -r requirements.txt
ENV FRONTEND_PORT=3002 BACKEND_PORT=8002
EXPOSE 3002 8002
CMD ["reflex", "run", "--env", "prod"]
```
> NB : à vérifier — Reflex 0.9.x peut exiger `reflex run --backend-only` côté
> runtime si le frontend export est servi par le même process. **TODO :
> tester avec un build pilote avant cutover.**
### 2. `docker-compose.prod.yml`
```yaml
services:
app:
build:
context: .
dockerfile: Dockerfile.prod
image: eptm-dashboard-prod
container_name: eptm-dashboard-prod-app-1
init: true
restart: unless-stopped
volumes:
- ./data-prod:/app/data # ← séparé de dev (cf. Q1)
- ./logs-prod:/logs
env_file:
- .env.prod
environment:
- FRONTEND_PORT=3002
- BACKEND_PORT=8002
- API_URL=https://dashboard.eptm-automation.ch
- REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data
- TZ=Europe/Zurich
networks:
- default
- proxy_net
networks:
proxy_net:
external: true
```
### 3. `.env.prod`
À auditer — copier les valeurs sensibles depuis le `settings.json` plutôt
que de tout mettre en env (séparation cleane). Variables minimales :
- `DATA_DIR=/app/data` (déjà override probable)
- `(à voir avec l'user — TZ, secrets, etc.)`
---
## Procédure de cutover (jour J)
### J1 (préparation)
1. **Backup DB Streamlit** (si pertinent — vérifier si Streamlit a sa propre DB) :
```bash
sudo cp /opt/absences/data /opt/backups/absences-$(date +%F).tgz # à adapter
```
2. **Backup DB Reflex dev** (qui deviendra la prod) :
```bash
cp /opt/eptm-dashboard/data/eptm.db /opt/backups/eptm-pre-cutover-$(date +%F).db
```
3. Créer `Dockerfile.prod`, `docker-compose.prod.yml`, `.env.prod` (cf. ci-dessus).
4. Préparer `./data-prod/` (copie de `./data/` au moment opportun).
### J0 (cutover, ~10 min de fenêtre)
```bash
# 1. Build l'image prod (peut être fait avant la fenêtre — pas de downtime)
cd /opt/eptm-dashboard
docker compose -f docker-compose.prod.yml build app
# 2. Snapshot DB de dev → data-prod
cp -r data/ data-prod/
# (optionnel : purger data-prod/browser_profile et data-prod/pdfs si volumineux
# et resync-able depuis Escada)
# 3. Démarrer le container prod (encore inaccessible — NPM pointe encore sur Streamlit)
docker compose -f docker-compose.prod.yml up -d app
docker logs -f eptm-dashboard-prod-app-1 # vérifier "App running" puis Ctrl-C
# 4. Stopper Streamlit
sudo systemctl stop absences
sudo systemctl disable absences
# 5. Reconfigurer NPM proxy host #2 (dashboard.eptm-automation.ch)
# via UI https://npm.eptm-automation.ch :
# - Forward Hostname / IP : eptm-dashboard-prod-app-1
# - Forward Port : 3002
# - WebSocket support : ON
# - Custom location :
# Location : /_event
# Forward : eptm-dashboard-prod-app-1:8002
# Advanced : proxy_read_timeout 86400;
# - SSL : conserver le certificat Let's Encrypt déjà émis pour
# ce domaine, Force SSL, HSTS, HTTP/2
# 6. Recharger NPM si l'UI ne le fait pas auto :
docker exec npm nginx -s reload
# 7. Vérification
curl -I https://dashboard.eptm-automation.ch
# → doit retourner 200 + headers Reflex (Server: granian)
```
### J+1 → J+30 (stabilisation)
- Garder `absences.service` en `disabled` (rollback rapide possible).
- Surveiller `docker logs -f eptm-dashboard-prod-app-1` + `data-prod/logs/`.
- Si stable après ~1 mois : purger `/opt/absences/`, retirer le user systemd file.
---
## Rollback (si quelque chose plante après le cutover)
```bash
# 1. Rebasculer NPM sur Streamlit
# UI NPM → proxy host #2 → Forward : 172.17.0.1:8501
# 2. Relancer Streamlit
sudo systemctl start absences
# 3. Stopper le container prod Reflex
docker compose -f docker-compose.prod.yml down
# 4. Investiguer les logs Reflex tranquillement
docker logs eptm-dashboard-prod-app-1 > /tmp/cutover-fail.log
```
---
## Workflow déploiements suivants
Une fois la prod en place :
```bash
# 1. Dev : commit & push
git add -A && git commit -m "feat: xxx" && git push
# 2. Build nouvelle image prod
cd /opt/eptm-dashboard
docker compose -f docker-compose.prod.yml build app
# 3. Redémarrer le container (downtime ~10s)
docker compose -f docker-compose.prod.yml up -d app
# 4. Vérifier
docker logs -f eptm-dashboard-prod-app-1
curl -I https://dashboard.eptm-automation.ch
```
Optionnel : tagger les versions prod (`git tag -a prod-2026-05-15 && git push --tags`).
---
## Caveats / pièges à surveiller
- **WebSocket NPM** : déjà OK en dev (cf. memory NPM). Reproduire **exactement**
la même config sur proxy host #2 : WS support cocheé + custom location
`/_event` avec `proxy_read_timeout 86400`. Sans ça, le state Reflex ne
fonctionne pas.
- **`API_URL`** : doit matcher l'URL publique exacte (`https://dashboard.eptm-automation.ch`),
sinon le frontend ne joint pas le backend.
- **Ports internes prod** : `3002`/`8002` (pas `3001`/`8001` qui sont pris par dev).
- **`reflex export`** prend 1-5 min (npm install + bundle). À faire **avant**
la fenêtre de cutover.
- **`data-prod/browser_profile/`** : éviter de copier — le profil Chrome
contient une session SSO valide pour le compte de dev. La prod doit re-login
au premier sync Escada (ouvrir un Chromium visible avec `headless=False`
une fois, ou pré-importer le profile sur un compte service dédié).
- **localStorage des users connectés** : le LocalStorage du browser des
utilisateurs survit au cutover (clés `username`, `theme`, etc.) — ils ne
seront pas déconnectés.
- **DNS / certs** : `dashboard.eptm-automation.ch` pointe déjà sur l'IP
publique du serveur, le cert NPM est déjà émis et auto-renouvelé. Pas
d'action DNS/cert nécessaire.
- **Backups DB** : ajouter un cron quotidien après cutover :
```cron
0 3 * * * cp /opt/eptm-dashboard/data-prod/eptm.db /opt/backups/eptm-$(date +\%F).db
```
---
## Annexe : commandes de check post-déploiement
```bash
# Container prod up ?
docker ps --filter name=eptm-dashboard-prod-app-1
# Logs récents
docker logs --tail 50 eptm-dashboard-prod-app-1
# Proxy NPM répond ?
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" https://dashboard.eptm-automation.ch/login
# WS handshake OK ?
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" \
-H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://dashboard.eptm-automation.ch/_event
# → attend HTTP 101 (Switching Protocols)
```