Un projet Compose par environnement sur la même machine : ports du proxy et origine publique en variables dans docker-compose.prod.yml, réglage mémoire des deux bases TimescaleDB et du webserver Airflow. Le workflow deploy.yml déploie dev en recette et main en production depuis un runner installé sur la VM, jamais sur pull_request. scripts/provision-host.sh prépare les deux dossiers, secrets et certificats compris, sans rien démarrer. L'image frontend quitte dhi.io/nginx, registre authentifié dont personne n'a l'accès, pour nginx:1.28-alpine : elle n'avait jamais été construite. ADR 0009, vues infra et CI/CD, README et .env.example mis à jour. Refs #21, #22
254 lines
14 KiB
Markdown
254 lines
14 KiB
Markdown
# Intégration et livraison continues
|
|
|
|
Ce document décrit la chaîne qui s'exécute entre un `git push` et un merge autorisé : ce qui est
|
|
vérifié, ce qui bloque, et ce qui ne l'est pas.
|
|
|
|
| Étage | Sert à | Statut |
|
|
|---|---|---|
|
|
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
|
|
| Livraison continue | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `Fait` |
|
|
|
|
Le **D** de CI/CD existe depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
|
|
production sur la VM de l'école, par un runner auto-hébergé (issue #21,
|
|
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Sa limite, nommée ici
|
|
plutôt que découverte en soutenance : les images sont construites sur la machine à chaque
|
|
déploiement, aucun artefact n'est publié puis promu d'un environnement à l'autre.
|
|
|
|
## Vue d'ensemble
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
push["push ou pull_request"]
|
|
|
|
subgraph back["Backend · .github/workflows/backend.yml"]
|
|
bv["verification<br/>ruff, mypy, pytest --cov-fail-under=85"]
|
|
bi["integration<br/>TimescaleDB réel + alembic upgrade head"]
|
|
bd["security-audit<br/>uv export | pip-audit"]
|
|
bs["sast<br/>bandit"]
|
|
end
|
|
|
|
subgraph front["Frontend · frontend.yml"]
|
|
fb["build<br/>npm ci, npm run build"]
|
|
ft["test<br/>couverture lcov"]
|
|
fd["security-audit<br/>npm audit --audit-level=high"]
|
|
end
|
|
|
|
subgraph mlw["ML · ml.yml"]
|
|
mv["verification<br/>ruff, mypy, pytest"]
|
|
ms["sast<br/>bandit"]
|
|
end
|
|
|
|
subgraph afw["Airflow · airflow.yml"]
|
|
av["verification<br/>ruff, intégrité des DAGs"]
|
|
ab["image<br/>construction de l'image"]
|
|
end
|
|
|
|
subgraph sq["SonarQube · sonarqube.yml"]
|
|
sb1["build-front / test-front"]
|
|
sb2["build-back / test-back"]
|
|
sb3["test-ml"]
|
|
sscan["sonarqube<br/>quality gate SonarCloud"]
|
|
end
|
|
|
|
push --> bv & bi & bd & bs
|
|
push --> fb --> ft
|
|
push --> fd
|
|
push --> mv & ms
|
|
push --> av & ab
|
|
push --> sb1 & sb2 --> sscan
|
|
|
|
subgraph cd["Déploiement · deploy.yml"]
|
|
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
|
|
end
|
|
|
|
push -->|"push sur dev ou main"| dep
|
|
```
|
|
|
|
## Déclenchement
|
|
|
|
Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** :
|
|
`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`,
|
|
`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun
|
|
incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline
|
|
déclenche le pipeline.
|
|
|
|
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
|
|
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
|
|
`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux
|
|
modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes`
|
|
y exécute les commandes de détection ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
|
|
Une modification de l'un ou l'autre peut donc casser la construction de cette image, et le filtre
|
|
le voit.
|
|
|
|
**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la
|
|
CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est
|
|
délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans
|
|
rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push.
|
|
|
|
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
|
|
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
|
|
|
|
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10
|
|
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image
|
|
Docker et son propre environnement.
|
|
|
|
## Déploiement
|
|
|
|
`deploy.yml` est le sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
|
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
|
|
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
|
|
entrant n'est ouvert.
|
|
|
|
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|
|
|---|---|---|---|
|
|
| `push` sur `dev` | `rec` | `/srv/enervision/rec` | aucune : la recette suit `dev` |
|
|
| `push` sur `main` | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée |
|
|
|
|
Le job aligne le clone sur la branche (`fetch`, `checkout`, `reset --hard`), lance
|
|
`make stack-up`, qui reconstruit les images et redémarre les conteneurs, puis attend jusqu'à trois
|
|
minutes que `/api/v1/health/ready` réponde derrière le proxy. Un groupe de concurrence par
|
|
branche, sans annulation, empêche deux déploiements simultanés du même environnement.
|
|
|
|
Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier
|
|
de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent
|
|
survivre d'un déploiement à l'autre.
|
|
|
|
**Piège à connaître.** Un runner auto-hébergé sur un dépôt public exécute ce qu'un workflow lui
|
|
envoie, et une PR de fork peut réécrire un workflow. Trois parades, et les trois sont
|
|
nécessaires : `deploy.yml` ne se déclenche jamais sur `pull_request` ; le runner tourne sous un
|
|
utilisateur dédié membre du groupe `docker`, jamais root ; le dépôt exige une approbation pour
|
|
les workflows des PR externes (Settings, Actions, « Require approval for all outside
|
|
collaborators »). Les workflows de CI restent sur `ubuntu-latest`.
|
|
|
|
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
|
|
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats
|
|
auto-signés, et ne démarre rien. Le détail des deux environnements, ports et noms d'hôte, est
|
|
dans [10-infra.md](10-infra.md).
|
|
|
|
## Ce qui bloque un merge
|
|
|
|
| Gate | Où | Seuil | Effet d'un échec |
|
|
|---|---|---|---|
|
|
| Formatage `ruff format --check` | backend, ml | zéro écart | Bloque |
|
|
| Analyse statique `ruff check` | backend, ml | zéro constat | Bloque |
|
|
| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque |
|
|
| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque |
|
|
| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque |
|
|
| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque |
|
|
| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque |
|
|
| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque |
|
|
| Quality gate SonarCloud | tout le dépôt | gate par défaut, couverture du **code neuf** | Bloque |
|
|
| Build `npm run build` | frontend | compilation | Bloque |
|
|
| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
|
|
| Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque |
|
|
|
|
Deux seuils portent une décision qu'il faut savoir défendre :
|
|
|
|
- **`npm audit --audit-level=high`** et non `moderate` : une vulnérabilité modérée dans une
|
|
dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les
|
|
`moderate` sont invisibles en CI, et qu'elles se regardent à la main.
|
|
- **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW
|
|
sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le
|
|
revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert**
|
|
même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au
|
|
21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes
|
|
analysées.
|
|
- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle,
|
|
une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et
|
|
le rejeu à l'identique promis plus bas n'existerait pas.
|
|
|
|
## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres`
|
|
|
|
`backend.yml` monte un service `timescale/timescaledb-ha:pg17`, **la même image que
|
|
`docker-compose.yml`**, et non une image `postgres` nue. La première migration s'arrête
|
|
volontairement si l'extension TimescaleDB manque : un écart d'image entre la CI et le poste
|
|
rendrait ce job vert sur une base qui n'est pas la nôtre.
|
|
|
|
Sur le poste, c'est `db/init/110-test-database.sql` qui pose l'extension. Ce fichier n'est pas
|
|
monté dans le service GitHub Actions, d'où l'étape `CREATE EXTENSION IF NOT EXISTS timescaledb`
|
|
avant `alembic upgrade head`.
|
|
|
|
La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
|
|
partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
|
|
|
|
## SonarCloud, et l'incident qui a immobilisé trois PR
|
|
|
|
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
|
|
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en
|
|
artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8`
|
|
avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
|
|
|
|
Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers
|
|
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
|
|
Sonar). `ml/` publie `ml/coverage.xml` (`pytest-cov`, même mécanisme que le backend, sans seuil
|
|
propre : la gate porte sur le code neuf). `etl/airflow` est exclu de la **couverture**
|
|
(`sonar.coverage.exclusions`) : ses tests ne font que charger les DAGs, ils ne mesurent rien.
|
|
Piège : tout nouveau dossier de tests doit être déclaré dans `sonar.tests`, faute de quoi il est
|
|
compté comme code de production non couvert (cf. l'incident ci-dessous).
|
|
|
|
**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont
|
|
restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que
|
|
la couverture globale du backend dépassait 87 %. Le diagnostic était **hors du code de ces PR** :
|
|
`sonar.test.inclusions` ne reconnaissait que les fichiers `test_*.py`, si bien que
|
|
`tests/api/acces.py`, `tests/factories.py` et les `__init__.py` du dossier de tests étaient
|
|
comptés comme **code de production non couvert**. Le motif `tests` sans joker ne désignait par
|
|
ailleurs que la racine.
|
|
|
|
Deux commits ont corrigé la configuration (`9e6a5c0` classe tout `apps/backend/tests` comme test,
|
|
`af2b8cb` déclenche l'analyse quand `sonar-project.properties` change). La gate est verte sur
|
|
toutes les PR depuis. Ce qui compte pour la suite : **la cause a été traitée en configuration, pas
|
|
contournée** en désactivant la gate ou en excluant les fichiers gênants.
|
|
|
|
## Dependabot
|
|
|
|
`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** :
|
|
`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les
|
|
deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc
|
|
elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse
|
|
les tests ne se merge pas.
|
|
|
|
## Stratégie de branche et conventions
|
|
|
|
| Règle | Détail |
|
|
|---|---|
|
|
| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` |
|
|
| Messages de commit | Conventional Commits |
|
|
| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public |
|
|
| Revue | Toute PR passe par une revue écrite avant merge |
|
|
| ADR | Toute décision structurante porte son ADR dans la même PR |
|
|
| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** |
|
|
|
|
## Secrets
|
|
|
|
Un seul secret est consommé côté GitHub : **`SONAR_TOKEN`**, porté par les secrets du dépôt.
|
|
Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le
|
|
workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le
|
|
run.
|
|
|
|
Le déploiement ne consomme **aucun secret GitHub** (issue #22). Les secrets de chaque
|
|
environnement, mots de passe PostgreSQL et Airflow, clés de signature, clé Fernet, vivent dans le
|
|
`.env` de son dossier sur la VM, en `600`, générés sur la machine par `scripts/provision-host.sh`.
|
|
Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce dossier, n'a rien
|
|
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
|
|
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
|
|
|
|
## Ce qui manque, et pourquoi
|
|
|
|
| Manque | Issue | Conséquence assumée |
|
|
|---|---|---|
|
|
| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit |
|
|
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
|
|
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
|
|
| Tests de charge | #47 | Aucun garde-fou de performance |
|
|
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
|
|
|
|
## Reproduire la CI en local
|
|
|
|
`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job
|
|
`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration
|
|
demandent une base : `make db-up` puis `uv run pytest -m integration`.
|
|
|
|
Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium
|
|
--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
|
|
`ml`.
|