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>
216 lines
8.7 KiB
Python
Executable file
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()
|