CI/CD
Objectifs
- Estimer son travail
- Ajouter des tests unitaires en Python
- Créer une CI/CD pipeline sur GitLab
Rendu
- Rapport individuel en Markdown à rendre avant le prochain cours
- GitHub Classroom : https://classroom.github.com/a/aOak-l-G
- Nom du fichier :
report.mdà la racine du répertoire - Avec le lien vers la Merge Request GitLab
- Délai: 1 semaine
Tâches
Estimer son travail
- Estimer le temps nécessaire pour réaliser ce travail.
- Découper le travail en tâches pour faciliter l'estimation.
- Une fois terminé, comparer le temps estimé avec le temps réellement passé.
| Tâche | Temps estimé | Temps passé | Commentaire |
|---|---|---|---|
| Estimation | 10m | 15m | ... |
| ... | ... | ... | ... |
| Total | 2h | 1h30 | ... |
Git
- Reprendre son projet GitLab du laboratoire précédent (DOP Python).
- Travailler sur une nouvelle branche
feature/04-cicd.- Faire une merge request (MR) sur
mainune fois terminé et demander une revue. - Une fois qu'une MR est acceptée, la merge sur
main.
- Faire une merge request (MR) sur
- Séparer son travail en commits cohérents avec des messages de commit clairs et concis.
Tester le backend
- Ajouter les dépendances de développement
poetry add -G dev pytest pytest-cov httpx.- Une dépendance de développement est une dépendance qui n'est pas nécessaire en production, par exemple uniquement pour les tests.
pytestest le framework de test.pytest-covpermet de générer un rapport de couverture de code.httpxpermet de faire des requêtes HTTP dans les tests.
- Ajouter ou modifier les fichiers suivants (inspirés de cette documentation) :
- main.py
- test_main.py
from os import getenv
from sys import modules
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session
from . import models, schemas
from .database import SessionLocal, engine
if "pytest" not in modules:
models.Base.metadata.create_all(bind=engine)
app = FastAPI(root_path=getenv("ROOT_PATH"))
...
from random import choices, uniform
from string import ascii_letters
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from backend.database import Base
from backend.main import app, get_db
DATABASE_URL = "sqlite://"
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
def random_string(n=32):
return "".join(choices(ascii_letters, k=n))
def random_double():
return round(uniform(0.0, 100.0), 2)
product = {
"name": random_string(),
"description": random_string(512),
"price": random_double(),
}
def test_read_main():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"Hello": "World"}
def test_read_empty_products():
response = client.get("/products/")
assert response.status_code == 200
assert response.json() == []
def test_create_product():
response = client.post(
"/products/",
json=product,
)
assert response.status_code == 200
assert response.json() == {"id": 1, **product}
def test_read_product():
response = client.get("/products/1")
assert response.status_code == 200
assert response.json() == {"id": 1, **product}
def test_read_products():
response = client.get("/products/")
assert response.status_code == 200
assert response.json() == [{"id": 1, **product}]
def test_delete_product():
response = client.delete("/products/1")
assert response.status_code == 200
assert response.json() == {"id": 1, **product}
response = client.get("/products/1")
assert response.status_code == 404
def test_read_deleted_empty_products():
response = client.get("/products/")
assert response.status_code == 200
assert response.json() == []
- Pour lancer les tests :
poetry run pytest --cov
GitLab CI/CD
Créer une pipeline sur GitLab CI/CD qui :
- a les 3 stages :
- build : vérifie que le code compile.
- test (autant que possible parmi les éléments suivants) :
- vérifie que les tests (du backend) passent.
- Unit Test Reports
- Code Coverage
- Code Quality
- Dependency Scanning
- SAST
- Container Scanning
- deploy : met à jour les images Docker sur le registry.
- est déclenchée à chaque push sur n'importe quelle branche.
- le stage
deployn'est exécuté que surmain.
- le stage
- Le frontend et le backend doivent être dans des jobs séparés et en parallèle.
- Chacun est exécuté uniquement lorsqu'il y a des changements dans son dossier.
Proposition
Beaucoup de changements sur la pipeline vont être testés, une manière d'éviter d'avoir plein de commit est d'en avoir qu'un seul au final (à éviter sur main ou develop) : git commit --amend --all --no-edit && git push --force-with-lease
Commencer par le frontend (commencer le script par cd frontend/) :
- Le job
build-frontendutilise l'imagenode:lts, exécutenpm cietnpm run build.- Le résultat du build est gardé dans un artefact pour être utilisé par le job
deploy-frontend. - Ajouter le cache.
- Le résultat du build est gardé dans un artefact pour être utilisé par le job
- Le job
deploy-frontendutilise l'imagedockeravec le servicedocker:dind, exécutedocker build -t ${CI_REGISTRY_IMAGE}/frontend:latest .etdocker push ${CI_REGISTRY_IMAGE}/frontend:latest.- Docker in Docker
- Docker login
echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY --username $CI_REGISTRY_USER --password-stdin
- Docker Layer Caching
Solution .gitlab-ci.yml
build-frontend:
stage: build
image: node:lts
cache:
key:
files:
- frontend/package-lock.json
paths:
- frontend/.npm/
before_script:
- cd frontend/
script:
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts:
paths:
- frontend/dist/
deploy-frontend:
stage: deploy
image: docker
services:
- docker:dind
dependencies:
- build-frontend
variables:
REGISTRY_IMAGE: ${CI_REGISTRY_IMAGE}/frontend
before_script:
- cd frontend/
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY --username $CI_REGISTRY_USER --password-stdin
script:
- docker pull $REGISTRY_IMAGE:latest || true
- docker build --cache-from $REGISTRY_IMAGE:latest -t $REGISTRY_IMAGE:latest .
- docker push $REGISTRY_IMAGE:latest
Puis le backend (similairement au frontend) :
- Le job
build-backendutilise l'imagepython:3.11, installe Poetry et les dépendances en les cachant pour les prochains jobs.
build-backend:
stage: build
image: python:3.11
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip
- backend/.venv/
before_script:
- cd backend/
- pip install poetry
- poetry config virtualenvs.in-project true
script:
- poetry install
- Le job
test-backendreprend le cache du jobbuild-backendet exécutepoetry run pytest --cov.
test-backend:
stage: test
image: python:3.11
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip
- backend/.venv/
before_script:
- cd backend/
- pip install poetry
- poetry config virtualenvs.in-project true
script:
- poetry run pytest --cov
- Ajouter les éléments suivants :
- Le job
deploy-backendest très similaire au jobdeploy-frontend.
Solution .gitlab-ci.yml
build-backend:
stage: build
image: python:3.11
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip
- backend/.venv/
before_script:
- cd backend/
- pip install poetry
- poetry config virtualenvs.in-project true
script:
- poetry install
test-backend:
stage: test
image: python:3.11
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip
- backend/.venv/
before_script:
- cd backend/
- pip install poetry
- poetry config virtualenvs.in-project true
script:
- poetry run pytest --cov --junitxml="rspec.xml" --cov-report term --cov-report xml:coverage.xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
paths:
- backend/rspec.xml
reports:
junit: backend/rspec.xml
coverage_report:
coverage_format: cobertura
path: backend/coverage.xml
deploy-backend:
stage: deploy
image: docker
services:
- docker:dind
variables:
REGISTRY_IMAGE: ${CI_REGISTRY_IMAGE}/backend
before_script:
- cd backend/
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY --username $CI_REGISTRY_USER --password-stdin
script:
- docker pull $REGISTRY_IMAGE:latest || true
- docker build --cache-from $REGISTRY_IMAGE:latest -t $REGISTRY_IMAGE:latest .
- docker push $REGISTRY_IMAGE:latest
- Effectuer le stage
deployuniquement sur la branchemain. - Transformer votre pipeline en Directed Acyclic Graph Pipelines.
- Transformer votre pipeline en Parent-child pipelines.
- Optimisez vos fichiers YAML
- Ajouter et configurer (autant que possible) les éléments suivants :
Gestion des secrets
Le fichier .env est versionné avec un mot de passe non-secret destiné au développement local. En CI/CD, la variable GitLab masquée POSTGRES_PASSWORD est injectée dans l'environnement du runner et prend automatiquement le dessus sur la valeur du .env.
- Versionner le fichier
.envavec toutes les variables, incluant un mot de passe local non-secret :
POSTGRES_USER=postgres
POSTGRES_DB=postgres
POSTGRES_PASSWORD=postgres
- Créer une variable CI/CD masquée
POSTGRES_PASSWORDdans les paramètres du projet GitLab (Settings > CI/CD > Variables).- Activer l'option Masked pour que la valeur n'apparaisse jamais dans les logs.
- La variable d'environnement du runner override celle du
.env— aucune modification du fichier n'est nécessaire en pipeline.
- Dans le
compose.yml, référencer les variables depuis.envplutôt que de les coder en dur :
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
Développement local
Le .env versionné fonctionne tel quel pour le développement local et Docker Compose — aucune configuration supplémentaire n'est nécessaire.
- Pour Docker Compose, le
.envest chargé automatiquement. - Pour lancer le backend en local (sans Docker), utiliser
make dev-backend-dotenvqui charge explicitement le.envviapython-dotenv:
make dev-backend-dotenv
Annexe - Validation complète sans merge dans main
Pour exécuter le pipeline complet sans merge dans main, vous devez modifier votre .gitlab-ci.yml pour déclencher les jobs sur chaque push de branche.
Modifications à faire dans votre pipeline
- Activer la création de pipeline sur
push. - Mettre vos jobs
build-*,test-*etdeploy-*en exécution surpush. - Supprimer (ou adapter) les règles trop restrictives (par ex.
only: mainourulesMR-only).
Snippet 1 - workflow
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "push"
when: always
- if: $CI_PIPELINE_SOURCE == "web"
when: always
Snippet 2 - Règle type pour un job
Appliquez cette logique à tous les jobs du pipeline complet (build-*, test-*, deploy-*).
build-backend:
stage: build
# ...
rules:
- if: $CI_PIPELINE_SOURCE == "push"
when: on_success
- when: never
Snippet 3 - Exemple deploy
deploy-backend:
stage: deploy
# ...
rules:
- if: $CI_PIPELINE_SOURCE == "push"
when: on_success
- when: never
Vérification attendue
- Faire un commit puis
git pushsur une branchefeature/*. - Vérifier que les stages
build,test, puisdeploys'exécutent. - Vérifier la publication des images dans Deploy > Container Registry.