diff --git a/.env.example b/.env.example index 1755fb4..4467e46 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,6 @@ MONITOR_DEPLOY_WEBHOOK_SECRET= MONITOR_DEPLOY_WEBHOOK_PORT=9099 # Branch, dessen Push ein Update ausloest. MONITOR_DEPLOY_WEBHOOK_BRANCH=main +# Optional: Signaldatei des sync-Containers (Fallback bei verpasstem Push-Webhook, siehe +# README_UPDATE.md). Default: api/data/deploy-requested neben deploy-webhook.py. +# MONITOR_DEPLOY_TRIGGER_FILE= diff --git a/README_UPDATE.md b/README_UPDATE.md index 44e193f..71342de 100644 --- a/README_UPDATE.md +++ b/README_UPDATE.md @@ -64,6 +64,12 @@ daher hier nur als Checkliste: `.env` (`MONITOR_DEPLOY_WEBHOOK_SECRET`), Ereignis **nur** "Push". Ein Merge erzeugt immer einen Push auf den Ziel-Branch, ein eigenes "Pull Request merged"-Event ist daher nicht noetig. +8. **Fallback bei verpasstem Webhook (automatisch, keine Einrichtung):** Sinkt im + `sync`-Container die Zahl offener PRs (Merge oder Schliessen), legt er `api/data/deploy-requested` + an. `deploy-webhook.py` bemerkt die Datei, holt `origin/` und startet `update.sh` + nur, wenn der Stand abweicht. Pfad aenderbar ueber `MONITOR_DEPLOY_TRIGGER_FILE`. Greift + spaetestens beim naechsten Sync (`MONITOR_SYNC_INTERVAL`), keine zusaetzliche Codeberg-Verbindung. + Trifft ein Ausloeser waehrend eines laufenden Updates ein, laeuft danach ein Folgelauf. ## Testen diff --git a/api/bin/sync-codeberg.php b/api/bin/sync-codeberg.php index 4dcabc0..a260a5e 100755 --- a/api/bin/sync-codeberg.php +++ b/api/bin/sync-codeberg.php @@ -23,6 +23,7 @@ $service = new CodebergSyncService( new TicketRepository($db), new RepoConfigRepository($db), new KpiRepository($db), + new SyncTrigger($config->deployTriggerPath()), ); // Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README). diff --git a/api/src/Application.php b/api/src/Application.php index cd137a0..b612a04 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -77,7 +77,7 @@ final class Application // unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php) // - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden. $codebergClient = new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()); - $syncService = new CodebergSyncService($codebergClient, $tickets, $repoConfigs, $kpis); + $syncService = new CodebergSyncService($codebergClient, $tickets, $repoConfigs, $kpis, new SyncTrigger($config->deployTriggerPath())); $ticketController = new TicketController($tickets, $syncService); $eventController = new EventController($events); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); diff --git a/api/src/Config.php b/api/src/Config.php index 6054486..dfb748d 100644 --- a/api/src/Config.php +++ b/api/src/Config.php @@ -78,6 +78,16 @@ final class Config return $this->get('MONITOR_SYNC_TRIGGER_FILE', dirname(__DIR__) . '/data/sync-requested'); } + /** + * Signaldatei vom sync-Container an deploy-webhook.py auf dem Host: wird geschrieben, wenn + * die Zahl offener PRs sinkt (Fallback fuer einen verpassten Push-Webhook). Liegt im selben + * ./api/data-Verzeichnis, das der Host direkt sieht. + */ + public function deployTriggerPath(): string + { + return $this->get('MONITOR_DEPLOY_TRIGGER_FILE', dirname(__DIR__) . '/data/deploy-requested'); + } + /** * Abstand zwischen zwei periodischen Codeberg-Syncs (bin/sync-codeberg.php --watch). * Mit funktionierendem Webhook ist das nur noch das Sicherheitsnetz fuer verpasste diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php index 0488d5b..0950004 100644 --- a/api/src/Sync/CodebergSyncService.php +++ b/api/src/Sync/CodebergSyncService.php @@ -35,6 +35,7 @@ final class CodebergSyncService private readonly TicketRepository $tickets, private readonly RepoConfigRepository $repoConfigs, private readonly KpiRepository $kpis, + private readonly ?SyncTrigger $deployTrigger = null, ) { } @@ -99,7 +100,16 @@ final class CodebergSyncService $repoConfigs )); + $previous = $this->kpis->find('open_prs')['current_value'] ?? null; + $this->kpis->report('open_prs', (float) $openPrCount, 'Offene PRs', null, null); + + // Fallback fuer einen verpassten Push-Webhook an deploy-webhook.py: Sinkt die Zahl, + // wurde ein PR gemerged oder geschlossen - der Host prueft dann selbst, ob origin/main + // vor dem lokalen Stand liegt (ein blosses Schliessen loest dort kein Update aus). + if ($this->deployTrigger !== null && $previous !== null && (float) $openPrCount < (float) $previous) { + $this->deployTrigger->request(); + } } /** @return string[] external_refs, die bei diesem Lauf tatsaechlich bei Codeberg gesehen wurden. */ diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php index e50437c..960d1d9 100644 --- a/api/tests/Unit/Sync/CodebergSyncServiceTest.php +++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php @@ -10,6 +10,7 @@ use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Sync\CodebergClient; use Monitor\Api\Sync\CodebergSyncService; +use Monitor\Api\Sync\SyncTrigger; use PHPUnit\Framework\TestCase; final class FakeCodebergClient implements CodebergClient @@ -337,4 +338,47 @@ final class CodebergSyncServiceTest extends TestCase self::assertSame(4.0, $kpis->find('open_prs')['current_value']); } + + /** @return array{string, SyncTrigger} */ + private static function deployTrigger(): array + { + $path = sys_get_temp_dir() . '/deploy-trigger-' . bin2hex(random_bytes(4)); + + return [$path, new SyncTrigger($path)]; + } + + private static function syncWithOpenPrs(int $openPrs, KpiRepository $kpis, SyncTrigger $trigger): void + { + $client = new FakeCodebergClient( + issuesByRepoAndState: [self::MONITOR_SLUG => ['open' => [], 'closed' => []]], + openPullRequestsByRepo: [self::MONITOR_SLUG => $openPrs], + ); + + (new CodebergSyncService($client, new TicketRepository(Database::inMemory()), self::repoConfigsFor([self::MONITOR_REPO]), $kpis, $trigger))->sync(); + } + + public function testRequestsDeployCheckWhenOpenPullRequestCountDrops(): void + { + [$path, $trigger] = self::deployTrigger(); + $kpis = self::kpis(); + + self::syncWithOpenPrs(1, $kpis, $trigger); + self::assertFileDoesNotExist($path, 'Erster Lauf ohne Vorwert darf nichts ausloesen.'); + + self::syncWithOpenPrs(0, $kpis, $trigger); + self::assertFileExists($path); + @unlink($path); + } + + public function testDoesNotRequestDeployCheckWhenOpenPullRequestCountStaysOrRises(): void + { + [$path, $trigger] = self::deployTrigger(); + $kpis = self::kpis(); + + self::syncWithOpenPrs(1, $kpis, $trigger); + self::syncWithOpenPrs(1, $kpis, $trigger); + self::syncWithOpenPrs(2, $kpis, $trigger); + + self::assertFileDoesNotExist($path); + } } diff --git a/deploy-webhook.py b/deploy-webhook.py index c470073..fcd24a0 100755 --- a/deploy-webhook.py +++ b/deploy-webhook.py @@ -15,6 +15,10 @@ Konfiguration ueber Umgebungsvariablen: 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. """ @@ -27,6 +31,7 @@ import os import subprocess import sys import threading +import time from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -34,10 +39,16 @@ 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. +# 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: @@ -49,27 +60,90 @@ def verify_signature(secret: str, body: bytes, headers) -> bool: 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: - 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, + 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") ) - except OSError as exc: - log.write(f"--- update.sh konnte nicht gestartet werden: {exc} ---\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" @@ -104,18 +178,9 @@ class Handler(BaseHTTPRequestHandler): 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"}) + if not request_update(): + self._respond(202, {"status": "update laeuft bereits, Folgelauf vorgemerkt"}) 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: @@ -135,6 +200,12 @@ def main() -> None: 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)