1. Inleiding: Tijd om met de code aan de slag te gaan

Hallo! In deel 1 hebben we het gehad over de redenen om zelf een GitHub Webhook automatische deploysysteem op te zetten en wat we nodig hebben. In deel 2 hebben we de volledige architectuur en de kernrollen van de FastAPI-webhookdienst uitvoerig besproken. Nu is het tijd om het beeld dat we in ons hoofd hebben in daadwerkelijke code om te zetten.

Voor degenen die deel 1 en 2 nog niet hebben gezien, kunt u deze hier vinden.

Een eigen automatische deploysysteem met GitHub Webhook opzetten

1 - Waarom zelf implementeren? 2 - Volledige architectuur en procesontwerp

In deel 3 ronden we de basisconfiguratie van de stagingserver af en bouwen we samen de basisstructuur van de FastAPI-webhookserver die GitHub Webhook-verzoeken veilig kan ontvangen en verifiëren. Het automatische deploysysteem, dat tot nu toe slechts een abstract idee leek, komt nu een stap dichter bij de realiteit!

2. Basisconfiguratie van de stagingserver

Het eerste wat we moeten doen, is de omgeving van de stagingserver voor de deploy gereedmaken zodat we de code kunnen uitvoeren. Maak verbinding met de server via SSH en volg de onderstaande stappen.

2.1. Voorbereiding van de Python-ontwikkelomgeving

Het instellen van een Python-virtuele omgeving op de server is een goede gewoonte om afhankelijkheidsconflicten tussen projecten te vermijden en een schone ontwikkelomgeving te behouden. Installeer de benodigde pakketten met pip in de venv-omgeving.

# Installeer FastAPI, Uvicorn en python-dotenv om .env-bestanden te kunnen lezen
pip install fastapi uvicorn python-dotenv

2.2. Controleer of Git, Docker en Docker Compose zijn geïnstalleerd

Zoals eerder vermeld, zijn Git, Docker en Docker Compose essentiële tools voor automatische deploy. We veronderstellen dat deze tools al op de server zijn geïnstalleerd, aangezien u dit artikel leest.

Als ze nog niet zijn geïnstalleerd, controleer dan eerst of ze aanwezig zijn met de volgende commando's.

git --version
docker --version
docker compose version # of docker-compose --version (afhankelijk van de installatiemethode)

Als ze niet zijn geïnstalleerd, raadpleeg dan de officiële documentatie van elk gereedschap voor installatie-instructies. Als u gedetailleerde begeleiding nodig heeft over de installatie van Docker, kunt u het volgende artikel bekijken: Docker Installatiegids

Opmerking: Nadat u Docker hebt geïnstalleerd, moet u de huidige gebruiker rechten voor de Docker-groep toekennen en vervolgens uitloggen en opnieuw inloggen om docker commando's zonder sudo te kunnen uitvoeren.

sudo usermod -aG docker $USER

Na het toekennen van Docker-rechten is het absoluut noodzakelijk om uit te loggen en weer in te loggen op de server om de wijzigingen toe te passen. Controleer of het docker run hello-world commando normaal werkt zonder sudo.

Overzicht van het opzetten van een FastAPI-webhookserver

3. Basisstructuur van de FastAPI-webhookserver opzetten

Laten we nu de basisstructuur opzetten voor de FastAPI-applicatie die de webhook verzoeken zal ontvangen. Maak een main.py bestand aan in de ~/projects/webhook_server map en schrijf de volgende code.

3.1. Maak het main.py bestand en initialiseer de FastAPI-app

Voor de uitleg heb ik alle logica in één keer in main.py geschreven, maar u kunt de code in uw eigen stijl opdelen en importeren in afzonderlijke bestanden voor beheersbaarheid. Hieronder volgt een voorbeeld van het schrijven.

# ~/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

# Laad het .env bestand (voor gebruik van omgevingsvariabelen)
load_dotenv()

# Logging configuratie
logging.basicConfig(
    level=logging.INFO, # Log berichten van niveau INFO en hoger
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler("webhook_server.log"), # Opslaan van logs in een bestand
        logging.StreamHandler() # Log naar de console
    ]
)
logger = logging.getLogger(__name__)

app = FastAPI()

# Laad de GitHub Webhook Secret vanuit omgevingsvariabelen
GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET")

# Test root endpoint: Het is raadzaam deze te verwijderen voor productie om veiligheidsredenen.
@app.get("/")
async def read_root():
    return {"message": "Webhook server is running!"}

# Endpoint voor het ontvangen van GitHub Webhook
@app.post("/webhook")
async def github_webhook(request: Request, background_tasks: BackgroundTasks):
    logger.info("Webhook request received.")

    # 1. Verificatie van de GitHub Webhook Secret (de eerste beveiligingsstap)
    if GITHUB_WEBHOOK_SECRET:
        signature = request.headers.get("X-Hub-Signature-256")
        if not signature:
            logger.warning("Signature header missing. Aborting.")
            raise HTTPException(status_code=401, detail="X-Hub-Signature-256 header missing")

        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("Invalid signature. Aborting.")
            raise HTTPException(status_code=401, detail="Invalid signature")
        else:
            logger.info("Signature verified successfully.")
    else:
        logger.warning("GITHUB_WEBHOOK_SECRET is not set. Skipping signature verification (NOT RECOMMENDED FOR PRODUCTION).")


    # 2. Parse HTTP Body (Payload)
    try:
        payload = await request.json()
        event_type = request.headers.get("X-GitHub-Event")
        # Haal de naam van de repository uit de GitHub Webhook payload.
        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"Event: {event_type}, Repo: {repo_name}, Ref: {ref}, Pusher: {pusher_name}")

        # Voer de echte deploy logica uit in de achtergrond
        background_tasks.add_task(handle_deploy, repo_name)

    except Exception as e:
        logger.error(f"Error parsing webhook payload or processing request: {e}")
        raise HTTPException(status_code=400, detail=f"Error processing request: {e}")

    # Geef onmiddellijk een antwoord terug aan GitHub
    return {"message": "Webhook received and processing started in background!"}

# Functie die de echte deploy logica verwerkt
def handle_deploy(repo_name: str):
    if repo_name == "deployer":
        logger.info("⚙️ Deployer self-update skipped.")
        return

    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"),
    }

    p_name = {
        "sample_project1": "p_name_1",
        "sample_project2": "p_name_2",
        "sample_project3": "p_name_3",
    }

    repo_path: Optional[str] = repo_paths.get(repo_name)

    if not repo_path:
        logger.warning(f"🚫 Unknown repository: {repo_name}")
        return

    repo_path = Path(repo_path)

    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"
    deploy_color = env_vars.get("COLOR", "red")
    project_name = str(deploy_color) + p_name.get(repo_name, repo_name)

    docker_compose_file = "docker-compose.dev.yml" if is_debug else "docker-compose.prod.yml"

    compose_file = repo_path / docker_compose_file

    logger.info(f"Pulling latest code for {repo_name} in {repo_path}...")
    try:
        subprocess.run(["git", "-C", repo_path, "pull"], check=True, capture_output=True, text=True)
        logger.info(f"Git pull successful for {repo_name}.")
    except subprocess.CalledProcessError as e:
        logger.error(f"Git pull failed for {repo_name}: {e.stderr}")
        return

    logger.info(f"Checking for changes in {repo_name} to decide rebuild...")
    changed = should_rebuild(repo_path)

    try:
        if changed:
            logger.info(f"🚀 Changes detected. Building and deploying {repo_name} with {docker_compose_file}...")
            subprocess.run(["docker", "compose", "-p", project_name, "-f", compose_file, "up", "-d", "--build"], check=True, capture_output=True, text=True)
            logger.info(f"Docker compose build and up successful for {repo_name}.")
        else:
            logger.info(f"✨ No significant changes. Deploying {repo_name} without rebuilding images using {docker_compose_file}...")
            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 successful for {repo_name}.")
    except subprocess.CalledProcessError as e:
        logger.error(f"Docker compose failed for {repo_name}: {e.stderr}")
        return

    logger.info(f"✅ Deployment task for {repo_name} finished.")

# Functie om te bepalen of de Docker-afbeelding opnieuw moet worden opgebouwd
def should_rebuild(repo_path: Path) -> bool:
    trigger_files = [
        "requirements.txt", "Dockerfile", "Dockerfile.dev", "Dockerfile.prod",
        ".dockerignore", ".env"
    ]

    logger.info(f"Checking git diff for {repo_path}...")
    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"Changed files in git: {changed_files_in_git}")
    except subprocess.CalledProcessError as e:
        logger.error(f"Git diff failed for {repo_path}: {e.stderr}")
        return True

    if any(f in changed_files_in_git for f in trigger_files):
        logger.info("Detected trigger file changes via git diff. Rebuild required.")
        return True

    rebuild_trigger_file = repo_path / "REBUILD_TRIGGER"
    if rebuild_trigger_file.exists():
        try:
            rebuild_trigger_file.unlink()
            logger.info("Detected REBUILD_TRIGGER file. Rebuild required and trigger file removed.")
            return True
        except Exception as e:
            logger.error(f"Error removing REBUILD_TRIGGER file: {e}")
            return True

    logger.info("No rebuild trigger files found. No rebuild required.")
    return False

3.2. Definieer de /webhook endpoint

In de bovenstaande code is de asynchrone functie github_webhook gedefinieerd onder de app.post("/webhook") decorator, die het endpoint is dat GitHub Webhook verzoeken ontvangt. Deze functie verwerkt inkomende POST verzoeken en kan via het Request object toegang krijgen tot de headers en de body (Payload). BackgroundTasks is een krachtige functie van FastAPI die helpt om tijdrovende taken zoals deploy in de achtergrond uit te voeren, terwijl er onmiddellijk een antwoord naar GitHub wordt gestuurd.

4. Implementatie van de verificatielogica voor GitHub Webhook Secret

Het belangrijkste beveiligingselement in de bovenstaande code is de verificatielogica met behulp van GITHUB_WEBHOOK_SECRET.

4.1. Het belang van de Secret waarde

Bij het instellen van een GitHub webhook kunt u een Secret waarde opgeven. Deze waarde wordt gebruikt door GitHub om, bij het versturen van de webhook verzoek, een hash-waarde (Hash) te genereren door de request body en de Secret waarde te combineren, en deze vervolgens in de X-Hub-Signature-256 header te plaatsen. Onze webhookserver genereert een hash op dezelfde manier en vergelijkt deze met de hash die door GitHub is verzonden om te bevestigen dat het verzoek afkomstig is van GitHub en dat de gegevens niet zijn gewijzigd tijdens de verzending.

4.2. Verificatie met behulp van de X-Hub-Signature-256 header

Zoals u in de code kunt zien, wordt de verificatie uitgevoerd met de modules hmac en hashlib.

  1. Secret instellen: Maak eerst een .env bestand aan in de ~/projects/webhook_server directory en noteer de Secret waarde die u gebruikt bij het instellen van de GitHub Webhook. Dit bestand moet aan .gitignore worden toegevoegd zodat het niet naar de Git-repository wordt geüpload. Deze Secret waarde wordt later gebruikt in de Webhook-instellingen van de GitHub-repository.
# ~/projects/webhook_server/.env
GITHUB_WEBHOOK_SECRET="Voer hier de Secret waarde in die u gebruikt bij het instellen van de GitHub Webhook"
  1. FastAPI code: In de main.py laad je dit .env bestand met behulp van de python-dotenv bibliotheek en haal je de GITHUB_WEBHOOK_SECRET waarde op met os.getenv(). Elke keer als een webhookverzoek binnenkomt, haal je de handtekening op via Request.headers.get("X-Hub-Signature-256") en bereken je de verwachte handtekening met de functie hmac.new() en vergelijk je deze.

Deze verificatielogica is een essentiële bescherming tegen externe aanvallen op de webhook endpoint, dus sla deze niet over!

5. Instellen van een loggingsysteem

In de code zie je dat we de module logging hebben gebruikt om een basis loggingconfiguratie in te stellen.

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler("webhook_server.log"), # Opslaan in het webhook_server.log bestand
        logging.StreamHandler() # Ook log naar de console
    ]
)
logger = logging.getLogger(__name__)

Met deze instelling worden informatieberichten elke keer dat een webhookverzoek binnenkomt in het webhook_server.log bestand geregistreerd en ook naar de terminal weergegeven. Als de deploylogica later complexer wordt, kan dit logging helpen om te achterhalen waar een probleem is ontstaan.

6. De webhookserver uitvoeren met Uvicorn en lokaal testen

Laten we nu snel testen of de FastAPI-webhook server goed werkt. In de ~/projects/webhook_server map van de stagingserver, activeer je de virtuele omgeving en voer je het volgende commando uit.

# Controleer of de virtuele omgeving is geactiveerd (kijk of er (venv) voor staat)
# source venv/bin/activate # Als het niet geactiveerd is, voer dit commando opnieuw uit

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

Met de `--reload` optie wordt de server automatisch opnieuw opgestart bij wijzigingen in de code tijdens de ontwikkeling.

Toegang tot 127.0.0.1:8000/docs in uw browser om te controleren of de API-documentatie goed is aangemaakt. Als de documentatie correct is, is de applicatie operationeel. Als u het IP-adres van de server kent (bijvoorbeeld 192.168.1.100), kunt u een testverzoek verzenden met het volgende curl commando vanaf uw lokale machine.

 

# Vervang 'YOUR_SERVER_IP' door het IP-adres van de server.
# 'YOUR_WEBHOOK_SECRET' moet de werkelijke waarde uit het .env bestand zijn.

# Bevat eenvoudige dummy JSON-gegevens.
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"}}'

 

Controleer of het bericht Webhook received and processing started in background! verschijnt, samen met logging-output in de terminal, en dat het ook in het server logbestand (webhook_server.log) wordt geregistreerd. Als er fouten optreden, kunt u de foutmeldingen in de terminal gebruiken om te debuggen.

7. Tot slot: Vorige aflevering in de praktijk

In dit deel hebben we de omgeving van de staging server opgezet en de basisstructuur van de FastAPI-webhookserver die GitHub Webhook verzoeken ontvangt en de Secret verifieert, afgerond. De webhookserver is nu klaar om veilige externe verzoeken te ontvangen.

In deel 4 wordt ingegaan op de daadwerkelijke deploylogica (Git Pull, beslissen over de herbouw van Docker-afbeeldingen, uitvoeren van Docker Compose) met behulp van de subprocess module, en hoe deze FastAPI-webhookserver automatisch als Systemd service te registreren bij het opnieuw opstarten van de server. Houd het in de gaten!