monitor/deploy-webhook.py
fds2610-bot 9c87840253 Update-Fallback: Deploy-Check, wenn die Zahl offener PRs sinkt
Der sync-Container legt api/data/deploy-requested an, sobald die Zahl offener
PRs sinkt. deploy-webhook.py prueft dann per git fetch, ob origin/main vor dem
lokalen Stand liegt, und startet nur dann update.sh. Fallback fuer einen
verpassten Push-Webhook ohne zusaetzliche Codeberg-Verbindung.

Ausloeser waehrend eines laufenden Updates werden nicht mehr verworfen,
sondern loesen einen Folgelauf aus.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 08:27:39 +02:00

216 lines
8.7 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).
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/<Branch> 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/<branch> 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()