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.

Image de l'aperçu du serveur webhook FastAPI

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.

  1. 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"
  1. Code FastAPI : Dans main.py, nous utilisons la bibliothèque python-dotenv pour charger ce fichier .env et obtenir la valeur GITHUB_WEBHOOK_SECRET avec os.getenv(). Lorsqu'une requête webhook sera reçue, nous récupérerons la valeur de signature avec Request.headers.get("X-Hub-Signature-256") et utiliserons la fonction hmac.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 !