Le seul Terraform du dépôt installait un cluster k3s que rien ne consomme, et sa racine ne passait même pas `terraform init` : depuis Terraform 1.x, un provisioner `destroy` impose que tout le bloc `connection` ne lise que `self`, et celui du module k3s lisait `var.*`. Il est corrigé en batissant la connexion sur `triggers`, où ne figurent que l'adresse, le port, l'utilisateur et le chemin de la clé : jamais la clé ni un mot de passe. `environments/vm-eni` provisionne la machine qui porte la recette et la production : Docker et le plugin Compose, `scripts/provision-host.sh`, puis l'enregistrement du runner. Rien n'y construit d'image ni ne lance de conteneur, et un `apply` n'interrompt pas la stack qui tourne. Le déploiement continu ne bouge pas : `deploy.yml` reste le seul chemin de livraison. `environments/dev` devient `k3s-cible`, parce qu'il provisionnait un cluster et pas un environnement applicatif, et que `rec` et `prod` vivent sur la même machine. `environments/prod`, dossier vide, disparaît. `infra.yml` formate et valide chaque racine à chaque push : c'est l'absence d'un tel job qui a laissé ce Terraform cassé sans que rien ne le dise. La boucle parcourt `environments/*`, une racine ajoutée est couverte sans y toucher. ADR 0010 pour la frontière entre provisionnement et livraison, et la doc d'architecture alignée dessus.
15 KiB
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 | En cours |
Le D de CI/CD est écrit 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). Il n'a encore rien
déployé : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Statut à
basculer sur Fait au premier déploiement vert. 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.
Ce que ce workflow ne fait pas, et ne fera pas : préparer la machine. Installation de Docker,
clones, .env, certificats et enregistrement du runner sont provisionnés par
infra/terraform/environments/vm-eni
(ADR 0010). Terraform provisionne,
GitHub Actions déploie ; aucun des deux ne fait le travail de l'autre.
Vue d'ensemble
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 infw["Infra · infra.yml"]
it["terraform<br/>fmt -check, init et validate par racine"]
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 --> it
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 six workflows hébergés par GitHub 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/**, infra.yml sur infra/terraform/**, 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).
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 : c'est l'interpréteur
de l'image apache/airflow:3.3.2-python3.12 retenue, et les tests d'intégrité doivent tourner sur
le même. 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 septiè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, redémarre les conteneurs puis applique les
migrations Alembic dans le conteneur backend, et attend jusqu'à trois minutes que
/api/v1/health/ready réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la
base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base
sans schéma, et c'est pourquoi make stack-up la porte. 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 doit exiger une approbation
pour les workflows des PR externes (Settings, Actions, « Require approval for all outside
collaborators »), ce qui reste à activer. Les workflows de CI restent sur ubuntu-latest.
Cet utilisateur dédié doit posséder /srv/enervision : sinon git refuse les deux clones pour
propriété douteuse et le .env en 600 lui échappe. PROPRIETAIRE=<utilisateur du runner>
passé à scripts/provision-host.sh fixe ce propriétaire.
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.
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 |
| Formatage et validité Terraform | infra | fmt -check -recursive, puis init et validate par racine |
Bloque |
Deux seuils portent une décision qu'il faut savoir défendre :
npm audit --audit-level=highet nonmoderate: une vulnérabilité modérée dans une dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que lesmoderatesont 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.