1. Introduction : Il est temps de toucher au code
Bonjour ! Dans le premier épisode, nous avons discuté des raisons de construire un système de déploiement automatique avec GitHub Webhook et des éléments nécessaires, dans le deuxième épisode, nous avons examiné en détail l'architecture globale et le rôle clé du service webhook FastAPI. Il est maintenant temps de transposer le schéma que vous avez en tête en code réel.
Pour ceux qui n'ont pas encore vu les épisodes précédents, vous pouvez les consulter via les liens ci-dessous.
Construire votre propre système de déploiement automatique avec GitHub Webhook
1 - Pourquoi implémenter soi-même ? 2 - Conception de l'architecture et du processus global
Dans cet épisode 3, nous allons terminer la configuration de base de l'environnement du serveur de staging et créer la structure de base du serveur webhook FastAPI capable de recevoir et de vérifier en toute sécurité les requêtes GitHub Webhook. C'est le moment où le système de déploiement automatique, qui semblait vague, se rapproche un peu plus de la réalité !
2. Configuration initiale de l'environnement du serveur de staging
La première chose à faire est de préparer l'environnement du serveur de staging où le déploiement aura lieu, afin que nous puissions exécuter notre code. Connectez-vous au serveur via SSH et suivez les étapes suivantes.
2.1. Préparation de l'environnement de développement Python
Configurer un environnement virtuel Python sur le serveur est une bonne habitude car cela empêche les conflits de dépendance entre projets et maintient un environnement de développement propre. Installez les paquets nécessaires avec pip dans l'environnement venv.
# Installation de FastAPI, Uvicorn et python-dotenv pour lire le fichier .env
pip install fastapi uvicorn python-dotenv
2.2. Vérification de l'installation de Git, Docker et Docker Compose
Comme mentionné dans l'épisode précédent, Git, Docker, et Docker Compose sont des outils clés pour le déploiement automatique. Nous supposons que les lecteurs de cet article ont déjà ces outils installés sur leur serveur.
Si ce n'est pas le cas, veuillez vérifier leur installation à l'aide des commandes suivantes.
git --version
docker --version
docker compose version # ou docker-compose --version (selon la méthode d'installation)
Si ces outils ne sont pas installés, veuillez suivre les documentations officielles de chacun d'eux pour procéder à l'installation. Si vous avez besoin d'un guide détaillé pour l'installation de Docker, vous pouvez consulter l'article suivant : Guide d'installation de Docker
Remarque : Après avoir installé Docker, vous devez ajouter le groupe Docker à l'utilisateur actuel et vous déconnecter puis vous reconnecter pour pouvoir utiliser la commande docker
sans sudo
.
sudo usermod -aG docker $USER
Après avoir accordé les permissions Docker, vous devez vous déconnecter et vous reconnecter sur le serveur pour que les modifications prennent effet. Veuillez exécuter la commande docker run hello-world
sans sudo
pour vérifier son bon fonctionnement.
3. Créer la structure de base du serveur webhook FastAPI
Nous allons maintenant créer la structure de base de l'application FastAPI qui va accepter les requêtes webhook. Créez un fichier main.py
dans le répertoire ~/projects/webhook_server
et écrivez le code suivant.
3.1. Création du fichier main.py
et initialisation de l'application FastAPI
Pour des raisons d'explication, j'ai écrit toute la logique dans le main.py
en une seule fois, mais vous devriez organiser votre code de manière agréable, par exemple en créant des fichiers séparés pour chaque fonction utilitaire afin de mieux gérer votre projet. Voici un exemple d'écriture.
# ~/projects/webhook_server/main.py
import hmac
import hashlib
import os
import logging
import subprocess
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from dotenv import load_dotenv, dotenv_values
# Chargement du fichier .env (pour utiliser les variables d'environnement)
load_dotenv()
# Configuration de la journalisation
logging.basicConfig(
level=logging.INFO, # Enregistrer les logs de niveau INFO et au-dessus
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("webhook_server.log"), # Enregistrer les logs dans un fichier
logging.StreamHandler() # Afficher les logs dans la console
]
)
logger = logging.getLogger(__name__)
app = FastAPI()
# Chargement de la clé secrète du Webhook GitHub depuis les variables d'environnement
GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET")
# Endpoint racine de test : il est préférable de le supprimer en production pour des raisons de sécurité.
@app.get("/")
async def read_root():
return {"message": "Le serveur webhook fonctionne !"}
# Endpoint pour recevoir le Webhook GitHub
@app.post("/webhook")
async def github_webhook(request: Request, background_tasks: BackgroundTasks):
logger.info("Requête webhook reçue.")
# 1. Vérification de la clé secrète du Webhook GitHub
if GITHUB_WEBHOOK_SECRET:
signature = request.headers.get("X-Hub-Signature-256")
if not signature:
logger.warning("L'en-tête de signature est manquant. Abandon.")
raise HTTPException(status_code=401, detail="En-tête X-Hub-Signature-256 manquant")
body = await request.body()
expected_signature = "sha256=" + hmac.new(
GITHUB_WEBHOOK_SECRET.encode('utf-8'),
body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_signature, signature):
logger.warning("Signature invalide. Abandon.")
raise HTTPException(status_code=401, detail="Signature invalide")
else:
logger.info("Signature vérifiée avec succès.")
else:
logger.warning("GITHUB_WEBHOOK_SECRET n'est pas défini. Ignorer la vérification de la signature (NON RECOMMANDÉ EN PRODUCTION).")
# 2. Analyse du corps HTTP (Payload)
try:
payload = await request.json()
event_type = request.headers.get("X-GitHub-Event")
# Extraire le nom du dépôt à partir du payload du Webhook GitHub.
repo_name = payload.get("repository", {}).get("name", "unknown_repository")
ref = payload.get("ref", "unknown_ref")
pusher_name = payload.get("pusher", {}).get("name", "unknown_pusher")
logger.info(f"Événement : {event_type}, Dépôt : {repo_name}, Référence : {ref}, Pousseur : {pusher_name}")
# Exécuter la logique de déploiement en arrière-plan
background_tasks.add_task(handle_deploy, repo_name)
except Exception as e:
logger.error(f"Erreur lors de l'analyse du payload webhook ou du traitement de la requête : {e}")
raise HTTPException(status_code=400, detail=f"Erreur lors du traitement de la requête : {e}")
# Retourner immédiatement une réponse à GitHub
return {"message": "Webhook reçu et traitement démarré en arrière-plan !"}
# Fonction qui traite la logique de déploiement
def handle_deploy(repo_name: str):
# Le dépôt appelé 'deployer' est celui qui met à jour le serveur webhook lui-même, donc on l'ignore
if repo_name == "deployer":
logger.info("⚙️ Mise à jour du déployeur ignorée.")
return
# Lire les chemins locaux de chaque dépôt depuis les variables d'environnement.
# Ce dictionnaire doit mapper vos noms de projet réels aux chemins sur le serveur.
repo_paths = {
"sample_project1": os.getenv("SAMPLE_PROJECT_1_PATH"),
"sample_project2": os.getenv("SAMPLE_PROJECT_2_PATH"),
"sample_project3": os.getenv("SAMPLE_PROJECT_3_PATH"),
}
# Si le nom du dépôt et le nom du projet Docker Compose sont différents, envisagez de les mapper également.
# Cela vous permettra d'utiliser l'option -p (nom du projet) dans Docker Compose.
p_name = {
"sample_project1": "p_name_1",
"sample_project2": "p_name_2",
"sample_project3": "p_name_3",
}
# Trouver le chemin local du dépôt à partir de la requête webhook actuelle.
repo_path: Optional[str] = repo_paths.get(repo_name)
# Si le dépôt n'est pas défini, enregistrer un avertissement et sortir.
if not repo_path:
logger.warning(f"🚫 Dépôt inconnu : {repo_name}")
return
# Convertir en objet Path pour mieux gérer le chemin de fichier.
repo_path = Path(repo_path)
# Charger les valeurs DEBUG et COLOR depuis le fichier .env du dépôt.
# Ceci peut être utilisé pour distinguer les environnements de développement/production, ou pour définir dynamiquement le nom du projet Docker Compose.
env_path = repo_path / ".env"
env_vars = dotenv_values(env_path) if env_path.exists() else {}
is_debug = env_vars.get("DEBUG", "false").lower() == "true" # Convertir la chaîne "true" en booléen
deploy_color = env_vars.get("COLOR", "red") # valeur par défaut est "red"
project_name = str(deploy_color) + p_name.get(repo_name, repo_name) # nom du projet Docker Compose
# Vous pouvez également décider quel fichier Docker Compose utiliser en fonction de la valeur DEBUG.
docker_compose_file = "docker-compose.dev.yml" if is_debug else "docker-compose.prod.yml"
# Définir le chemin du fichier Docker Compose.
compose_file = repo_path / docker_compose_file
logger.info(f"Récupération du dernier code pour {repo_name} dans {repo_path}...")
# Exécuter Git Pull : récupérer le dernier code à partir du chemin du dépôt.
try:
subprocess.run(["git", "-C", repo_path, "pull"], check=True, capture_output=True, text=True)
logger.info(f"Git pull réussi pour {repo_name}.")
except subprocess.CalledProcessError as e:
logger.error(f"Git pull a échoué pour {repo_name} : {e.stderr}")
return # arrêter ici en cas d'échec de déploiement
logger.info(f"Vérification des changements dans {repo_name} pour décider de reconstruire...")
# Comparer les changements : déterminer si une reconstruction de l'image Docker est nécessaire.
changed = should_rebuild(repo_path)
try:
if changed:
logger.info(f"🚀 Changements détectés. Construction et déploiement de {repo_name} avec {docker_compose_file}...")
# Si des changements ont eu lieu, exécuter Docker Compose avec l'option --build pour reconstruire l'image.
subprocess.run(["docker", "compose", "-p", project_name, "-f", compose_file, "up", "-d", "--build"], check=True, capture_output=True, text=True)
logger.info(f"Construction et mise à jour de Docker compose réussies pour {repo_name}.")
else:
logger.info(f"✨ Pas de changements significatifs. Déploiement de {repo_name} sans reconstruire les images avec {docker_compose_file}...")
# S'il n'y a pas de changements, exécuter Docker Compose sans l'option --build.
subprocess.run(["docker", "compose", "-p", project_name, "-f", compose_file, "up", "-d"], check=True, capture_output=True, text=True)
logger.info(f"Docker compose up réussi pour {repo_name}.")
except subprocess.CalledProcessError as e:
logger.error(f"Docker compose a échoué pour {repo_name} : {e.stderr}")
return # arrêter ici en cas d'échec de déploiement
logger.info(f"✅ La tâche de déploiement pour {repo_name} est terminée.")
# Fonction pour déterminer si une reconstruction de l'image Docker est nécessaire
def should_rebuild(repo_path: Path) -> bool:
# Ces fichiers, s'ils changent, déclenchent une reconstruction de l'image Docker.
trigger_files = [
"requirements.txt", "Dockerfile", "Dockerfile.dev", "Dockerfile.prod",
".dockerignore", ".env" # Les changements du fichier .env déclenchent également une reconstruction
]
logger.info(f"Vérification des modifications git pour {repo_path}...")
# Utiliser git diff pour obtenir la liste des fichiers modifiés entre HEAD~1 (le commit précédent) et HEAD actuel.
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD~1"],
cwd=repo_path,
capture_output=True, text=True, check=True
)
changed_files_in_git = result.stdout.strip().splitlines()
logger.info(f"Fichiers modifiés dans git : {changed_files_in_git}")
except subprocess.CalledProcessError as e:
logger.error(f"Échec de git diff pour {repo_path} : {e.stderr}")
return True
# Vérifier si parmi les fichiers modifiés, il y a des fichiers déclencheurs.
if any(f in changed_files_in_git for f in trigger_files):
logger.info("Modifications de fichiers déclencheurs détectées via git diff. Reconstruction nécessaire.")
return True
# Vérifier si le fichier de déclenchement de reconstruction (REBUILD_TRIGGER) existe.
if rebuild_trigger_file.exists():
try:
rebuild_trigger_file.unlink() # supprimer le fichier
logger.info("Fichier REBUILD_TRIGGER détecté. Reconstruction nécessaire et fichier déclencheur supprimé.")
return True
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier REBUILD_TRIGGER : {e}")
return True
logger.info("Aucun fichier déclencheur de reconstruction trouvé. Pas de reconstruction nécessaire.")
return False
3.2. Définition de l'endpoint /webhook
Le code ci-dessus définit la fonction asynchrone github_webhook
sous le décorateur app.post("/webhook")
, qui est l'endpoint pour recevoir les requêtes Webhook de GitHub. Cette fonction traite les requêtes POST
entrantes et peut accéder aux en-têtes et au corps (Payload) via l'objet Request
. BackgroundTasks
est une fonctionnalité puissante de FastAPI qui permet d'exécuter des tâches de longue durée, comme le déploiement, en arrière-plan tout en envoyant immédiatement une réponse à GitHub.
4. Implémentation de la logique de vérification de la clé secrète du Webhook GitHub
Le plus important dans le code ci-dessus est la logique de vérification utilisant la GITHUB_WEBHOOK_SECRET
.
4.1. Importance de la valeur Secret
Lors de la configuration du Webhook GitHub, vous pouvez définir une valeur Secret
. Cette valeur est utilisée par GitHub pour générer une valeur de hachage (Hash) en combinant le corps de la requête et la clé secrète lors de l'envoi de requêtes. Cette valeur de hachage est ensuite envoyée dans l'en-tête X-Hub-Signature-256
. Notre serveur webhook générera une valeur de hachage de la même manière, puis comparera cette valeur avec celle envoyée par GitHub pour vérifier si la requête provient réellement de GitHub et si les données n'ont pas été altérées en transit.
4.2. Vérification à l'aide de l'en-tête X-Hub-Signature-256
Comme vous l'avez vu dans le code, nous utilisons les modules hmac
et hashlib
pour effectuer la vérification de la clé secrète.
- Configuration de la clé secrète : Tout d'abord, créez un fichier
.env
dans le répertoire~/projects/webhook_server
et enregistrez-y la valeur de clé secrète que vous utiliserez lors de la configuration du Webhook GitHub. Ce fichier doit être ajouté à.gitignore
pour éviter qu'il ne soit ajouté à votre référentiel Git. Cette valeur de Secret sera utilisée plus tard dans la configuration du Webhook du dépôt GitHub.
# ~/projects/webhook_server/.env
GITHUB_WEBHOOK_SECRET="Entrez_la_valeur_de_votre_cle_secrete_ici"
- Code FastAPI : Dans
main.py
, nous utilisons la bibliothèquepython-dotenv
pour charger ce fichier.env
et obtenir la valeurGITHUB_WEBHOOK_SECRET
avecos.getenv()
. Lorsqu'une requête webhook sera reçue, nous récupérerons la valeur de signature avecRequest.headers.get("X-Hub-Signature-256")
et utiliserons la fonctionhmac.new()
pour calculer la valeur de signature attendue et la comparer.
Cette logique de vérification est un bouclier essentiel pour protéger votre endpoint webhook contre les attaques externes, donc ne l'ignorez jamais !
5. Configuration du système de journalisation
Comme vous le voyez dans le code, j'ai déjà configuré une journalisation de base en utilisant le module logging
.
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("webhook_server.log"), # Enregistrer dans le fichier webhook_server.log
logging.StreamHandler() # Également afficher dans la console
]
)
logger = logging.getLogger(__name__)
Avec cette configuration, chaque fois qu'une requête webhook sera reçue, l'information sera enregistrée dans le fichier webhook_server.log
et affichée dans le terminal. Plus tard, lorsque la logique de déploiement sera plus complexe, cette capacité de journalisation vous aidera à identifier facilement à quel moment un problème survient.
6. Exécution du serveur webhook avec Uvicorn et test local
Il est maintenant temps de tester rapidement si le serveur webhook FastAPI que nous avons créé fonctionne correctement en local. Dans le répertoire ~/projects/webhook_server
du serveur de staging, assurez-vous que l'environnement virtuel est activé et exécutez la commande suivante.
# Vérifiez si l'environnement virtuel est activé (assurez-vous que (venv) est présent)
# source venv/bin/activate # si ce n'est pas le cas, exécutez à nouveau cette commande
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
Le paramètre `--reload` redémarre automatiquement le serveur lors de modifications apportées au code pendant le développement, ce qui est pratique.
Accédez à 127.0.0.1:8000/docs dans votre navigateur pour vérifier si la documentation API a été correctement générée. Si la documentation est présente, l'application est en cours d'exécution correctement. Si vous connaissez l'adresse IP du serveur (par exemple : 192.168.1.100
), vous pouvez envoyer une requête de test avec la commande curl
depuis votre machine locale.
# Remplacez l'adresse IP du serveur par 'YOUR_SERVER_IP'.
# Remplacez 'YOUR_WEBHOOK_SECRET' par la valeur réelle du fichier .env.
# Inclut des données JSON factices.
curl -X POST http://YOUR_SERVER_IP:8000/webhook \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: sha256=$(echo -n '{"repository":{"name":"your-repo"},"ref":"refs/heads/main","pusher":{"name":"test-user"}}' | openssl dgst -sha256 -hmac 'YOUR_WEBHOOK_SECRET' | awk '{print $2}')" \
-d '{"repository":{"name":"your-repo"},"ref":"refs/heads/main","pusher":{"name":"test-user"}}'
Vérifiez si le message Webhook reçu et traitement démarré en arrière-plan !
est affiché dans le terminal ainsi que dans le fichier de logs du serveur (webhook_server.log
). Si des erreurs se produisent, vous pouvez les déboguer grâce aux messages d'erreur affichés dans le terminal.
7. En conclusion : Aperçu du prochain épisode
Dans cet épisode, nous avons mis en place l'environnement du serveur de staging et complété la structure de base du serveur webhook FastAPI capable d'accueillir les requêtes en toute sécurité tout en vérifiant la clé secrète.
Dans le prochain épisode 4, nous implémenterons la logique réelle de déploiement (Git Pull, décision de reconstruire l'image Docker, exécution de Docker Compose) en utilisant le module subprocess
et nous verrons également comment inscrire ce serveur webhook FastAPI en tant que service Systemd pour qu'il redémarre automatiquement lors du redémarrage du serveur. Restez à l'écoute !
Aucun commentaire.