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:
parent
ed0652ddd5
commit
9c87840253
8 changed files with 174 additions and 29 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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/<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<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.
|
||||
"""
|
||||
|
|
@ -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/<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"
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue