monitor/deploy-webhook.py
fds2610-bot 6bad9b470c Auto-Update per Push-Webhook fuer Produktionsserver einrichten
deploy-webhook.py laeuft auf dem Host, prueft eingehende Codeberg-Webhooks per
HMAC-Signatur (analog zu POST /api/v1/webhooks/codeberg) und stoesst bei einem
Push auf main - also nach jedem manuell gemergten PR - das bereits vorhandene
update.sh an. README_UPDATE.md dokumentiert die dafuer noetigen manuellen
Vorarbeiten auf dem Server (Checkout, Docker, systemd-Unit, Webhook-Einrichtung).

Schliesst #36
2026-08-04 12:20:41 +02:00

145 lines
5.5 KiB
Python
Executable file

#!/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).
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
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 Webhook-Zustellungen kurz
# hintereinander eintreffen (z.B. Codeberg-Retry). do_POST versucht den Lock nicht-blockierend
# zu erwerben, run_update() gibt ihn im finally-Block wieder frei.
update_lock = threading.Lock()
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 run_update(update_script: Path, log_file: Path) -> None:
try:
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"))
finally:
update_lock.release()
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
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"))
)
if not update_lock.acquire(blocking=False):
self._respond(202, {"status": "update laeuft bereits"})
return
threading.Thread(target=run_update, args=(update_script, log_file), daemon=True).start()
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,
)
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()