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
|
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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue