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>
This commit is contained in:
fds2610-bot 2026-10-01 08:25:21 +02:00
parent ed0652ddd5
commit 9c87840253
8 changed files with 174 additions and 29 deletions

View file

@ -55,3 +55,6 @@ MONITOR_DEPLOY_WEBHOOK_SECRET=
MONITOR_DEPLOY_WEBHOOK_PORT=9099 MONITOR_DEPLOY_WEBHOOK_PORT=9099
# Branch, dessen Push ein Update ausloest. # Branch, dessen Push ein Update ausloest.
MONITOR_DEPLOY_WEBHOOK_BRANCH=main 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=

View file

@ -64,6 +64,12 @@ daher hier nur als Checkliste:
`.env` (`MONITOR_DEPLOY_WEBHOOK_SECRET`), Ereignis **nur** "Push". Ein Merge erzeugt immer `.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 einen Push auf den Ziel-Branch, ein eigenes "Pull Request merged"-Event ist daher nicht
noetig. 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/<Branch>` 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 ## Testen

View file

@ -23,6 +23,7 @@ $service = new CodebergSyncService(
new TicketRepository($db), new TicketRepository($db),
new RepoConfigRepository($db), new RepoConfigRepository($db),
new KpiRepository($db), new KpiRepository($db),
new SyncTrigger($config->deployTriggerPath()),
); );
// Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README). // Ohne --watch bleibt das Skript ein einmaliger Lauf (manueller Aufruf, siehe README).

View file

@ -77,7 +77,7 @@ final class Application
// unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php) // unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php)
// - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden. // - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden.
$codebergClient = new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()); $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); $ticketController = new TicketController($tickets, $syncService);
$eventController = new EventController($events); $eventController = new EventController($events);
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath()); $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());

View file

@ -78,6 +78,16 @@ final class Config
return $this->get('MONITOR_SYNC_TRIGGER_FILE', dirname(__DIR__) . '/data/sync-requested'); 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). * Abstand zwischen zwei periodischen Codeberg-Syncs (bin/sync-codeberg.php --watch).
* Mit funktionierendem Webhook ist das nur noch das Sicherheitsnetz fuer verpasste * Mit funktionierendem Webhook ist das nur noch das Sicherheitsnetz fuer verpasste

View file

@ -35,6 +35,7 @@ final class CodebergSyncService
private readonly TicketRepository $tickets, private readonly TicketRepository $tickets,
private readonly RepoConfigRepository $repoConfigs, private readonly RepoConfigRepository $repoConfigs,
private readonly KpiRepository $kpis, private readonly KpiRepository $kpis,
private readonly ?SyncTrigger $deployTrigger = null,
) { ) {
} }
@ -99,7 +100,16 @@ final class CodebergSyncService
$repoConfigs $repoConfigs
)); ));
$previous = $this->kpis->find('open_prs')['current_value'] ?? null;
$this->kpis->report('open_prs', (float) $openPrCount, 'Offene PRs', null, 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. */ /** @return string[] external_refs, die bei diesem Lauf tatsaechlich bei Codeberg gesehen wurden. */

View file

@ -10,6 +10,7 @@ use Monitor\Api\Repository\RepoConfigRepository;
use Monitor\Api\Repository\TicketRepository; use Monitor\Api\Repository\TicketRepository;
use Monitor\Api\Sync\CodebergClient; use Monitor\Api\Sync\CodebergClient;
use Monitor\Api\Sync\CodebergSyncService; use Monitor\Api\Sync\CodebergSyncService;
use Monitor\Api\Sync\SyncTrigger;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class FakeCodebergClient implements CodebergClient final class FakeCodebergClient implements CodebergClient
@ -337,4 +338,47 @@ final class CodebergSyncServiceTest extends TestCase
self::assertSame(4.0, $kpis->find('open_prs')['current_value']); 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);
}
} }

View file

@ -15,6 +15,10 @@ Konfiguration ueber Umgebungsvariablen:
MONITOR_DEPLOY_UPDATE_SCRIPT - Pfad zu update.sh (Default: update.sh neben diesem Skript). 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 MONITOR_DEPLOY_LOG_FILE - Pfad, in den die update.sh-Ausgabe geschrieben wird
(Default: deploy-webhook.log neben diesem Skript). (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. Siehe README_UPDATE.md fuer die Einrichtung auf einem Produktionsserver.
""" """
@ -27,6 +31,7 @@ import os
import subprocess import subprocess
import sys import sys
import threading import threading
import time
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@ -34,10 +39,16 @@ from pathlib import Path
REPO_DIR = Path(__file__).resolve().parent REPO_DIR = Path(__file__).resolve().parent
SIGNATURE_HEADERS = ("X-Forgejo-Signature", "X-Gitea-Signature") SIGNATURE_HEADERS = ("X-Forgejo-Signature", "X-Gitea-Signature")
# Verhindert ueberlappende update.sh-Laeufe, falls mehrere Webhook-Zustellungen kurz # Verhindert ueberlappende update.sh-Laeufe, falls mehrere Ausloeser kurz hintereinander
# hintereinander eintreffen (z.B. Codeberg-Retry). do_POST versucht den Lock nicht-blockierend # eintreffen (Webhook-Retry, Webhook plus Trigger-Datei). request_update() versucht den Lock
# zu erwerben, run_update() gibt ihn im finally-Block wieder frei. # nicht-blockierend zu erwerben, run_update() gibt ihn im finally-Block wieder frei.
update_lock = threading.Lock() 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: def verify_signature(secret: str, body: bytes, headers) -> bool:
@ -49,8 +60,31 @@ def verify_signature(secret: str, body: bytes, headers) -> bool:
return False 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: def run_update(update_script: Path, log_file: Path) -> None:
try: try:
while True:
update_pending.clear()
with open(log_file, "ab") as log: with open(log_file, "ab") as log:
log.write( log.write(
f"\n--- update.sh gestartet ({datetime.now().isoformat()}) ---\n".encode("utf-8") f"\n--- update.sh gestartet ({datetime.now().isoformat()}) ---\n".encode("utf-8")
@ -66,10 +100,50 @@ def run_update(update_script: Path, log_file: Path) -> None:
) )
except OSError as exc: except OSError as exc:
log.write(f"--- update.sh konnte nicht gestartet werden: {exc} ---\n".encode("utf-8")) 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: finally:
update_lock.release() 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): class Handler(BaseHTTPRequestHandler):
server_version = "monitor-deploy-webhook/1" server_version = "monitor-deploy-webhook/1"
@ -104,18 +178,9 @@ class Handler(BaseHTTPRequestHandler):
self._respond(200, {"status": "ignoriert"}) self._respond(200, {"status": "ignoriert"})
return return
update_script = Path( if not request_update():
os.environ.get("MONITOR_DEPLOY_UPDATE_SCRIPT", str(REPO_DIR / "update.sh")) self._respond(202, {"status": "update laeuft bereits, Folgelauf vorgemerkt"})
)
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 return
threading.Thread(target=run_update, args=(update_script, log_file), daemon=True).start()
self._respond(202, {"status": "update gestartet"}) self._respond(202, {"status": "update gestartet"})
def _respond(self, status: int, payload: dict) -> None: def _respond(self, status: int, payload: dict) -> None:
@ -135,6 +200,12 @@ def main() -> None:
file=sys.stderr, 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")) port = int(os.environ.get("MONITOR_DEPLOY_WEBHOOK_PORT", "9099"))
server = ThreadingHTTPServer(("0.0.0.0", port), Handler) server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
print(f"[deploy-webhook] lausche auf Port {port}", file=sys.stderr) print(f"[deploy-webhook] lausche auf Port {port}", file=sys.stderr)