295 lines
10 KiB
Markdown
295 lines
10 KiB
Markdown
# 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)
|
||
|
||
### J−1 (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)
|
||
```
|