#!/usr/bin/env python3 """ Custom r10k Webhook Server for Puppet Control Repo """ from datetime import datetime import os import subprocess import logging import hmac import hashlib from fastapi import FastAPI, Request, HTTPException, BackgroundTasks from fastapi.responses import JSONResponse import uvicorn from pydantic import BaseModel # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('/var/log/r10k-webhook.log'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) app = FastAPI(title="r10k Webhook Server") class WebhookPayload(BaseModel): """Data model for webhook payload""" ref: str project: dict commits: list def run_r10k_deploy() -> bool: """Run r10k deploy command""" try: cmd = [ '/usr/bin/r10k', 'deploy', '-v', '-c', '/etc/puppetlabs/r10k/r10k.conf' ] logger.info("Starting r10k deploy...") result = subprocess.run( cmd, capture_output=True, text=True, check=True ) logger.info("r10k deploy successful!") logger.debug("r10k stdout: %s", result.stdout) if result.stderr: logger.warning("r10k stderr: %s", result.stderr) return True except subprocess.CalledProcessError as e: logger.error("r10k deploy failed: %s", e) logger.error("stdout: %s", e.stdout) logger.error("stderr: %s", e.stderr) return False except FileNotFoundError: logger.error("r10k binary not found") return False except PermissionError: logger.error("Permission denied running r10k") return False def validate_signature(payload: bytes, signature: str, secret: str) -> bool: """Validate webhook signature""" if not secret: return True expected = hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest() if signature.startswith('sha256='): return hmac.compare_digest(signature, f'sha256={expected}') return hmac.compare_digest(signature, expected) @app.post("/webhook") async def webhook_handler( request: Request, background_tasks: BackgroundTasks ): """Handle incoming webhook requests""" body = await request.body() headers = dict(request.headers) event_type = headers.get( 'x-gitlab-event', headers.get('x-github-event', 'unknown') ) signature = headers.get( 'x-gitlab-token', headers.get('x-hub-signature-256', '') ) print( f"DEBUG: Received webhook: event_type={event_type}, " f"headers={headers}" ) logger.info( "Received webhook: event_type=%s, headers=%s", event_type, headers ) webhook_secret = os.getenv('R10K_WEBHOOK_SECRET', '') is_valid = validate_signature(body, signature, webhook_secret) if webhook_secret and not is_valid: logger.warning("Invalid webhook signature") raise HTTPException(status_code=403, detail="Invalid signature") try: payload = await request.json() ref = payload.get('ref', '') branch = ref.split('/')[-1] if '/' in ref else ref print( f"DEBUG: Parsed payload: ref={ref}, " f"branch={branch}" ) logger.info("Parsed payload: ref=%s, branch=%s", ref, branch) if branch not in ['main', 'master']: logger.info("Ignoring non-main branch: %s", branch) return JSONResponse({ "status": "ignored", "branch": branch }) # Match GitLab event types explicitly valid_events = [ 'push hook', 'merge request hook', 'push', 'Push', 'Push Hook' ] normalized_event = event_type.lower().strip() print(f"DEBUG: Normalized event: {normalized_event}") logger.info("Normalized event: %s", normalized_event) if normalized_event in valid_events: logger.info("Triggering r10k for %s on %s", event_type, branch) background_tasks.add_task(run_r10k_deploy) return JSONResponse({ "status": "accepted", "message": "r10k deploy triggered", "timestamp": datetime.utcnow().isoformat(), "branch": branch }) logger.info("Ignoring event type: %s", event_type) return JSONResponse({ "status": "ignored", "event": event_type }) except ValueError as e: logger.error("Webhook processing error: %s", e) raise HTTPException( status_code=400, detail="Invalid payload" ) from e @app.get("/health") async def health_check(): """Health check endpoint""" result = subprocess.run( ['r10k', '--version'], capture_output=True, text=True, check=True ) return { "status": "healthy", "r10k_version": result.stdout.strip() } if __name__ == "__main__": uvicorn.run( "webhook_server:app", host="0.0.0.0", port=8080, log_level="info" )