#!/usr/bin/env python3 """ Empfaengt den Codeberg-Webhook fuer fds2610/monitor und stoesst bei einem Push auf den konfigurierten Branch (Standard: main, d.h. nach jedem manuell gemergten PR) ein Update per update.sh an. Laeuft direkt auf dem Host (nicht im Docker-Compose-Stack), weil update.sh selbst "docker compose" auf dem Host aufruft. Konfiguration ueber Umgebungsvariablen: MONITOR_DEPLOY_WEBHOOK_SECRET - Pflicht. HMAC-Secret, dasselbe wie beim Einrichten des Webhooks im Codeberg-Repo (Einstellungen -> Webhooks -> Forgejo). Ohne gesetztes Secret weist der Endpunkt jede Anfrage mit 401 ab. MONITOR_DEPLOY_WEBHOOK_PORT - Port, auf dem gelauscht wird (Default: 9099). MONITOR_DEPLOY_WEBHOOK_BRANCH - Branch, dessen Push ein Update ausloest (Default: main). MONITOR_DEPLOY_UPDATE_SCRIPT - Pfad zu update.sh (Default: update.sh neben diesem Skript). MONITOR_DEPLOY_LOG_FILE - Pfad, in den die update.sh-Ausgabe geschrieben wird (Default: deploy-webhook.log neben diesem Skript). MONITOR_DEPLOY_TRIGGER_FILE - Signaldatei des sync-Containers (Default: api/data/deploy-requested). Taucht sie auf (Zahl offener PRs ist gesunken), wird geprueft, ob origin/ vor dem lokalen Stand liegt, und nur dann ein Update gestartet - Fallback fuer einen verpassten Push-Webhook. Siehe README_UPDATE.md fuer die Einrichtung auf einem Produktionsserver. """ from __future__ import annotations import hashlib import hmac import json import os import subprocess import sys import threading import time from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path REPO_DIR = Path(__file__).resolve().parent SIGNATURE_HEADERS = ("X-Forgejo-Signature", "X-Gitea-Signature") # Verhindert ueberlappende update.sh-Laeufe, falls mehrere Ausloeser kurz hintereinander # eintreffen (Webhook-Retry, Webhook plus Trigger-Datei). request_update() versucht den Lock # nicht-blockierend zu erwerben, run_update() gibt ihn im finally-Block wieder frei. update_lock = threading.Lock() # Ein Ausloeser, der waehrend eines laufenden Updates eintrifft, wird nicht verworfen, sondern # merkt einen Folgelauf vor (der Merge kann nach dem fetch von update.sh dazugekommen sein). # state_lock macht "Pending pruefen + Lock freigeben" bzw. "Lock versuchen + Pending setzen" # atomar, damit kein Ausloeser zwischen beiden Schritten verloren geht. state_lock = threading.Lock() update_pending = threading.Event() def verify_signature(secret: str, body: bytes, headers) -> bool: expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() for name in SIGNATURE_HEADERS: provided = headers.get(name) if provided and hmac.compare_digest(expected, provided.strip().lower()): return True return False def update_paths() -> tuple[Path, Path]: update_script = Path( os.environ.get("MONITOR_DEPLOY_UPDATE_SCRIPT", str(REPO_DIR / "update.sh")) ) log_file = Path( os.environ.get("MONITOR_DEPLOY_LOG_FILE", str(REPO_DIR / "deploy-webhook.log")) ) return update_script, log_file def request_update() -> bool: """Startet update.sh oder merkt einen Folgelauf vor. True = gestartet, False = laeuft bereits.""" update_script, log_file = update_paths() with state_lock: if not update_lock.acquire(blocking=False): update_pending.set() return False threading.Thread(target=run_update, args=(update_script, log_file), daemon=True).start() return True def run_update(update_script: Path, log_file: Path) -> None: try: while True: update_pending.clear() with open(log_file, "ab") as log: log.write( f"\n--- update.sh gestartet ({datetime.now().isoformat()}) ---\n".encode("utf-8") ) log.flush() try: subprocess.run( [str(update_script)], cwd=str(update_script.parent), stdout=log, stderr=subprocess.STDOUT, check=False, ) except OSError as exc: log.write(f"--- update.sh konnte nicht gestartet werden: {exc} ---\n".encode("utf-8")) with state_lock: if not update_pending.is_set(): return finally: update_lock.release() def remote_is_ahead(branch: str) -> bool: """True, wenn origin/ vom lokalen HEAD abweicht. Bei Git-Fehlern ebenfalls True: dann entscheidet update.sh (und sein Log) statt eines stillen Ausbleibens.""" try: subprocess.run( ["git", "fetch", "--quiet", "origin", branch], cwd=str(REPO_DIR), check=True, timeout=120, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) local = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=str(REPO_DIR), check=True, capture_output=True, text=True, ).stdout.strip() remote = subprocess.run( ["git", "rev-parse", "FETCH_HEAD"], cwd=str(REPO_DIR), check=True, capture_output=True, text=True, ).stdout.strip() except (OSError, subprocess.SubprocessError): return True return local != remote def watch_trigger_file(trigger: Path, branch: str) -> None: """Fallback fuer einen verpassten Push-Webhook: der sync-Container legt die Signaldatei an, wenn die Zahl offener PRs sinkt. Der Check ist ein stat() im Zwei-Sekunden-Takt.""" while True: if trigger.exists(): try: trigger.unlink() except OSError: pass if remote_is_ahead(branch): print("[deploy-webhook] Trigger-Datei: neuer Stand auf origin, starte Update", file=sys.stderr) request_update() time.sleep(2) class Handler(BaseHTTPRequestHandler): server_version = "monitor-deploy-webhook/1" def log_message(self, fmt, *args): sys.stderr.write("[deploy-webhook] %s - %s\n" % (self.address_string(), fmt % args)) def do_POST(self): secret = os.environ.get("MONITOR_DEPLOY_WEBHOOK_SECRET", "") length = int(self.headers.get("Content-Length", "0") or "0") body = self.rfile.read(length) if length else b"" if not secret: self._respond(401, {"error": "kein MONITOR_DEPLOY_WEBHOOK_SECRET konfiguriert"}) return if not verify_signature(secret, body, self.headers): self._respond(401, {"error": "Signatur fehlt oder ist ungueltig"}) return event = self.headers.get("X-Forgejo-Event") or self.headers.get("X-Gitea-Event") or "" if event == "ping": self._respond(200, {"status": "pong"}) return try: payload = json.loads(body or b"{}") except json.JSONDecodeError: payload = {} branch = os.environ.get("MONITOR_DEPLOY_WEBHOOK_BRANCH", "main") ref = payload.get("ref", "") if event != "push" or ref != f"refs/heads/{branch}": self._respond(200, {"status": "ignoriert"}) return if not request_update(): self._respond(202, {"status": "update laeuft bereits, Folgelauf vorgemerkt"}) return self._respond(202, {"status": "update gestartet"}) def _respond(self, status: int, payload: dict) -> None: body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def main() -> None: if not os.environ.get("MONITOR_DEPLOY_WEBHOOK_SECRET"): print( "[deploy-webhook] WARNUNG: MONITOR_DEPLOY_WEBHOOK_SECRET ist nicht gesetzt - " "der Endpunkt weist jede Anfrage mit 401 ab.", file=sys.stderr, ) trigger = Path( os.environ.get("MONITOR_DEPLOY_TRIGGER_FILE", str(REPO_DIR / "api" / "data" / "deploy-requested")) ) branch = os.environ.get("MONITOR_DEPLOY_WEBHOOK_BRANCH", "main") threading.Thread(target=watch_trigger_file, args=(trigger, branch), daemon=True).start() port = int(os.environ.get("MONITOR_DEPLOY_WEBHOOK_PORT", "9099")) server = ThreadingHTTPServer(("0.0.0.0", port), Handler) print(f"[deploy-webhook] lausche auf Port {port}", file=sys.stderr) server.serve_forever() if __name__ == "__main__": main()