DEV Community

Hamza Rif
Hamza Rif

Posted on

Pipeline CI/CD complet pour une API Python Flask — De zéro au déploiement automatisé avec GitLab CI

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Astuce : Sur Debian/Ubuntu récent, le pip install direct 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"]
Enter fullscreen mode Exit fullscreen mode

3 bonnes pratiques à retenir ici :

  1. USER appuser — Si le conteneur est compromis, l'attaquant n'a pas les droits root. C'est le principe du moindre privilège.

  2. COPY requirements.txt avant COPY . — 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.

  3. HEALTHCHECK — Docker vérifie automatiquement que l'app répond. Si /health ne répond plus, Docker marque le conteneur comme unhealthy.


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}"
Enter fullscreen mode Exit fullscreen mode

Stage 1 : Build

build_image:
  stage: build
  script:
    - docker build --network=host
        -t ${IMAGE_NAME}:${IMAGE_TAG}
        -t ${IMAGE_NAME}:latest .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Liaison SonarQube ↔ GitLab en 4 étapes :

  1. SonarQube tourne sur la VM (docker run -d --name sonarqube -p 9000:9000 sonarqube:lts-community)
  2. Créer le projet dans SonarQube (Project key : urbanhub-park)
  3. Générer un Project Analysis Token (My Account → Security)
  4. Ajouter dans GitLab → Settings → CI/CD → Variables : SONAR_TOKEN (Masked) et SONAR_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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 :

  1. Build l'image Docker
  2. Teste le code (unitaire + API + couverture)
  3. Scanne la sécurité (code + dépendances)
  4. Analyse la qualité (SonarQube)
  5. 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)