Introduction
Quand on développe une application, on finit toujours par se poser la même question : comment s'assurer que chaque changement de code est testé, sécurisé et déployé automatiquement ?
La réponse : un pipeline CI/CD.
Dans cet article, je vous montre comment j'ai construit un pipeline complet pour UrbanHub Park, une API Python Flask de gestion de stationnement intelligent. On part de zéro, et on couvre tout : build Docker, tests automatisés, scan de sécurité, analyse de qualité avec SonarQube, et déploiement automatique.
Stack utilisée :
- Python 3.12 + Flask
- Docker
- GitLab CI
- pytest + pytest-cov
- Bandit (SAST)
- pip-audit (SCA)
- SonarQube
L'application — Une API Flask minimaliste
Avant de parler pipeline, voici l'app. Deux endpoints, rien de plus :
# app/main.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/health')
def health():
return jsonify({"status": "ok"}), 200
@app.route('/api/parking/spots')
def get_spots():
spots = [
{"id": 1, "zone": "A", "available": True},
{"id": 2, "zone": "B", "available": False},
{"id": 3, "zone": "A", "available": True},
]
return jsonify(spots), 200
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
Le /health est crucial — c'est lui qui permet au pipeline de vérifier que l'app est bien démarrée après déploiement.
Les tests — pytest en action
6 tests qui couvrent les deux niveaux : unitaire et fonctionnel.
# tests/test_app.py
import pytest
from app.main import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
# Tests unitaires
def test_health_status_code(client):
"""Vérifie que /health renvoie un code 200."""
response = client.get('/health')
assert response.status_code == 200
def test_health_body(client):
"""Vérifie que /health renvoie {"status": "ok"}."""
response = client.get('/health')
assert response.json['status'] == 'ok'
# Tests API (fonctionnels)
def test_spots_returns_json(client):
"""Vérifie que l'API renvoie du JSON."""
response = client.get('/api/parking/spots')
assert response.content_type == 'application/json'
def test_spots_count(client):
"""Vérifie qu'on reçoit 3 places de parking."""
response = client.get('/api/parking/spots')
assert len(response.json) == 3
def test_spot_structure(client):
"""Vérifie la structure d'une place."""
response = client.get('/api/parking/spots')
spot = response.json[0]
assert 'id' in spot
assert 'zone' in spot
assert 'available' in spot
def test_invalid_route(client):
"""Vérifie qu'une route inexistante renvoie 404."""
response = client.get('/api/inexistant')
assert response.status_code == 404
Pour lancer en local :
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install pytest pytest-cov
pytest tests/ -v --cov=app --cov-report=html:htmlcov
Astuce : Sur Debian/Ubuntu récent, le
pip installdirect est bloqué (PEP 668). Il faut obligatoirement passer par un venv.
Le Dockerfile — Sécurité dès la construction
FROM python:3.12-slim
WORKDIR /app
# Sécurité : utilisateur non-root
RUN adduser --disabled-password --gecos '' appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:5000/health || exit 1
CMD ["python", "app/main.py"]
3 bonnes pratiques à retenir ici :
USER appuser— Si le conteneur est compromis, l'attaquant n'a pas les droits root. C'est le principe du moindre privilège.COPY requirements.txtavantCOPY .— Docker met en cache chaque layer. Si tu changes ton code mais pas tes dépendances, Docker ne réinstalle pas les packages. Ça accélère le build.HEALTHCHECK— Docker vérifie automatiquement que l'app répond. Si/healthne répond plus, Docker marque le conteneur commeunhealthy.
Le Pipeline — 5 stages, du build au deploy
Voici le .gitlab-ci.yml complet :
stages:
- build
- test
- security
- quality
- deploy
variables:
IMAGE_NAME: "urbanhub-park"
IMAGE_TAG: "${CI_COMMIT_SHORT_SHA}"
Stage 1 : Build
build_image:
stage: build
script:
- docker build --network=host
-t ${IMAGE_NAME}:${IMAGE_TAG}
-t ${IMAGE_NAME}:latest .
On crée deux tags : un avec le hash du commit (pour le versioning) et un latest (pour le déploiement).
Stage 2 : Tests
run_tests:
stage: test
image: python:3.12-slim
before_script:
- pip install -r requirements.txt
- pip install pytest pytest-cov
script:
- pytest tests/ -v
--junitxml=report.xml
--cov=app
--cov-report=xml:coverage.xml
--cov-report=html:htmlcov
artifacts:
when: always
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- htmlcov/
- coverage.xml
expire_in: 7 days
Le format JUnit XML permet à GitLab d'afficher les résultats des tests directement dans l'interface. Le Cobertura est envoyé à SonarQube pour la couverture.
Stage 3 : Sécurité (DevSecOps)
Deux jobs en parallèle :
# SAST — Analyse statique du code
bandit_sast:
stage: security
image: python:3.12-slim
before_script:
- pip install bandit
script:
- bandit -r app/ -f json -o bandit-report.json || true
- bandit -r app/ -f html -o bandit-report.html || true
artifacts:
paths:
- bandit-report.json
- bandit-report.html
expire_in: 7 days
allow_failure: true
# SCA — Scan des dépendances
dependency_scan:
stage: security
image: python:3.12-slim
before_script:
- pip install pip-audit
script:
- pip install -r requirements.txt
- pip-audit -r requirements.txt
-f json -o pip-audit-report.json || true
artifacts:
paths:
- pip-audit-report.json
expire_in: 7 days
allow_failure: true
Pourquoi allow_failure: true ? Les scans de sécurité trouvent souvent des warnings qui ne sont pas critiques. On ne veut pas bloquer tout le pipeline pour un warning de faible sévérité. Par contre, les rapports sont conservés en artifacts pour analyse.
Pourquoi || true ? Sans ça, si Bandit ou pip-audit trouve la moindre vulnérabilité, le job échoue (exit code ≠ 0) et le rapport n'est pas généré. Le || true force le job à continuer pour produire le rapport complet.
Stage 4 : Qualité (SonarQube)
sonarqube_analysis:
stage: quality
image:
name: sonarsource/sonar-scanner-cli:latest
entrypoint: [""]
variables:
SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
GIT_DEPTH: "0"
cache:
key: "${CI_JOB_NAME}"
paths:
- .sonar/cache
script:
- sonar-scanner
allow_failure: true
needs: ["run_tests"]
only:
- main
- develop
Le needs: ["run_tests"] est important : il attend que les tests soient finis pour récupérer le coverage.xml via les artifacts.
Le fichier sonar-project.properties à la racine :
sonar.projectKey=urbanhub-park
sonar.projectName=UrbanHub Park
sonar.projectVersion=1.0
sonar.sources=app
sonar.tests=tests
sonar.language=py
sonar.sourceEncoding=UTF-8
sonar.python.coverage.reportPaths=coverage.xml
Liaison SonarQube ↔ GitLab en 4 étapes :
- SonarQube tourne sur la VM (
docker run -d --name sonarqube -p 9000:9000 sonarqube:lts-community) - Créer le projet dans SonarQube (Project key :
urbanhub-park) - Générer un Project Analysis Token (My Account → Security)
- Ajouter dans GitLab → Settings → CI/CD → Variables :
SONAR_TOKEN(Masked) etSONAR_HOST_URL
Stage 5 : Deploy
deploy_app:
stage: deploy
script:
- docker stop ${IMAGE_NAME} || true
- docker rm ${IMAGE_NAME} || true
- docker run -d --network host
--name ${IMAGE_NAME} ${IMAGE_NAME}:latest
- sleep 5
- curl -f http://localhost:5000/health || exit 1
needs: ["run_tests"]
environment:
name: staging
url: http://localhost:5000
only:
- main
Le curl -f http://localhost:5000/health || exit 1 à la fin est un smoke test : si l'app ne répond pas, le job échoue et on est prévenu.
Le || true après docker stop et docker rm évite une erreur au premier déploiement (quand il n'y a pas encore de conteneur à arrêter).
La structure complète du projet
urbanhub-park/
├── .gitlab-ci.yml # Pipeline 5 stages
├── .gitignore
├── .dockerignore
├── Dockerfile # Image sécurisée (non-root, slim)
├── sonar-project.properties # Config SonarQube
├── requirements.txt # flask==3.1.0
├── app/
│ ├── __init__.py
│ └── main.py # API Flask
├── tests/
│ ├── __init__.py
│ └── test_app.py # 6 tests
└── docs/
└── documentation-technique.md
Les commandes à connaître
# === Local ===
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python app/main.py
pytest tests/ -v --cov=app
# === Docker ===
docker build -t urbanhub-park .
docker run -d --network host --name urbanhub-park urbanhub-park:latest
docker logs urbanhub-park
curl http://localhost:5000/health
# === Sécurité ===
pip install bandit pip-audit
bandit -r app/
pip-audit -r requirements.txt
# === Git ===
git add .
git commit -m "feat: add CI/CD pipeline"
git push origin main
Erreurs courantes et solutions
| Problème | Cause | Solution |
|---|---|---|
externally-managed-environment |
pip bloqué sur Debian récent | Utiliser un venv : python3 -m venv venv
|
ensurepip is not available |
Package manquant | sudo apt install python3.11-venv -y |
Push rejeté protected branch
|
Branche main protégée | Déprotéger ou pusher sur develop
|
venv/ poussé sur git |
Oubli du .gitignore | git rm -r --cached venv/ && git commit |
SonarQube not reachable
|
Variables CI/CD manquantes | Vérifier SONAR_TOKEN et SONAR_HOST_URL dans GitLab |
Ce qui manque (et comment améliorer)
Aucun pipeline n'est parfait. Voici les limites et les pistes d'amélioration :
| Limite | Amélioration |
|---|---|
| Pas de tests de charge | Intégrer Locust ou k6 |
| Pas de DAST (test dynamique) | Ajouter OWASP ZAP |
| Un seul environnement | Séparer dev / staging / prod |
| Pas de scan d'images Docker | Ajouter Trivy |
| Pas de rollback automatique | Implémenter un health check + rollback |
| Serveur unique | Migrer vers Kubernetes |
Conclusion
On est passé d'un simple python app/main.py à un pipeline CI/CD complet qui, à chaque git push :
- Build l'image Docker
- Teste le code (unitaire + API + couverture)
- Scanne la sécurité (code + dépendances)
- Analyse la qualité (SonarQube)
- Déploie automatiquement
Le tout en un seul fichier .gitlab-ci.yml.
L'approche DevSecOps (intégrer la sécurité dans le pipeline plutôt qu'après coup) n'est pas juste un buzzword — c'est ce qui fait la différence entre un pipeline amateur et un pipeline professionnel.
Le code complet est disponible sur GitLab. N'hésitez pas à le forker et l'adapter à vos projets.
Si cet article vous a été utile, un like ou un commentaire fait toujours plaisir. Et si vous avez des suggestions d'amélioration du pipeline, je suis preneur !
Top comments (0)