Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.
ROADMAP IM DEPLOYMENTCENTER (2.7.0)
Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.
Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.
- Migration 016: epic, roadmap_rank, target_build, defer_trigger,
doc_anchor, Status "deferred", Tabelle roadmap_epics.
KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
- Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
- Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
die Kennung nach einer Loeschung neu vergeben wurde.
- Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
Repository, damit sie auf allen drei Schreibwegen gleich greift.
- Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
dc_roadmap und dc_roadmap_set im MCP-Adapter.
- cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
--check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
- docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.
TESTINSTANZ UND TESTTOR
- dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
Basic-Auth davor, Rocket.Chat aus.
- 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
- scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
die Testinstanz und laesst dann den Testlauf laufen. Erst bei
Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
- tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
nicht versehentlich auf dc.mhdf.de landen.
- Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.
SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)
- Migration 013: projektuebergreifend melden (dev_agent, evidence,
triage_external, affects).
- Migration 014: supervisor_findings, Katalog, fuenf serverseitige
Detektoren im Tick.
- Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
- DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
- MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
von Fremddaten gegen Prompt-Injektion.
BEHOBEN
- observe und status im Supervisor legten den Zustand des Befundes als
"status" in die Antwort. Http::ok() merged ['status' => 'success']
darunter, also gewann der Befundzustand - eine angenommene Meldung kam
als status="open" an. Heisst jetzt finding_status. War Item #59.
Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
12 KiB
PHP
351 lines
12 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
|
|
/**
|
|
* Watchdog-Tick — Evaluationslauf von der Kommandozeile.
|
|
*
|
|
* Ersetzt den frueheren /watchdog/cli/tick.php. Macht dasselbe wie
|
|
* GET /api/watchdog/v1/evaluate, aber ohne HTTP-Umweg:
|
|
*
|
|
* - kein Shared Key im Crontab und damit nicht in der Prozessliste
|
|
* jedes Nutzers auf der Maschine (ps zeigt curl-Argumente),
|
|
* - keine Abhaengigkeit von Webserver, TLS, DNS oder Timeouts,
|
|
* - laeuft auch, wenn der Webserver gerade nicht erreichbar ist -
|
|
* also genau dann, wenn das Erkennen von Ausfaellen wichtig ist.
|
|
*
|
|
* Cron-Eintrag (minuetlich):
|
|
* * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
|
|
*
|
|
* Aufrufe:
|
|
* php cli/tick.php Lauf mit lesbarer Zusammenfassung
|
|
* php cli/tick.php --quiet nur bei Zustandswechseln und Fehlern Ausgabe
|
|
* php cli/tick.php --json maschinenlesbares Ergebnis
|
|
* php cli/tick.php --no-lock Sperre uebergehen (nur zur Fehlersuche)
|
|
* php cli/tick.php --help
|
|
*
|
|
* Rueckgabewerte:
|
|
* 0 Lauf erfolgreich
|
|
* 1 Fehler
|
|
* 2 uebersprungen, weil bereits ein Lauf aktiv ist
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Logger;
|
|
use Deploymentcenter\Modules\Watchdog\Evaluator;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Nur Kommandozeile.
|
|
//
|
|
// Erste und wichtigste Verteidigungslinie: sie wirkt unabhaengig davon, ob
|
|
// mod_rewrite, AllowOverride oder die .htaccess-Dateien greifen. Ohne sie
|
|
// koennte ein Aufruf ueber den Webserver einen Evaluationslauf ohne jede
|
|
// Authentifizierung ausloesen - der HTTP-Endpunkt verlangt dafuer aus gutem
|
|
// Grund den Shared Key oder eine angemeldete Sitzung.
|
|
// ---------------------------------------------------------------------------
|
|
if (PHP_SAPI !== 'cli') {
|
|
http_response_code(403);
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
exit("Dieses Skript laeuft ausschliesslich auf der Kommandozeile.\n");
|
|
}
|
|
|
|
require_once __DIR__ . '/../src/bootstrap.php';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fehlerbehandlung fuer die Kommandozeile.
|
|
//
|
|
// Der Handler aus bootstrap.php ruft Http::fail() auf: der schickt JSON samt
|
|
// Kopfzeilen und ist fuer eine HTTP-Antwort gedacht. Im Cron waere das eine
|
|
// unbrauchbare Mail und - schlimmer - der Rueckgabewert 0, sodass ein
|
|
// gescheiterter Lauf unbemerkt bliebe.
|
|
// ---------------------------------------------------------------------------
|
|
set_exception_handler(static function (\Throwable $e): void {
|
|
Logger::error('Watchdog-Tick abgebrochen', [
|
|
'error' => $e->getMessage(),
|
|
'file' => $e->getFile() . ':' . $e->getLine(),
|
|
]);
|
|
|
|
fwrite(STDERR, sprintf(
|
|
"[FEHLER] %s: %s\n %s:%d\n",
|
|
get_class($e),
|
|
$e->getMessage(),
|
|
$e->getFile(),
|
|
$e->getLine()
|
|
));
|
|
|
|
exit(1);
|
|
});
|
|
|
|
$options = parseArguments($argv);
|
|
|
|
if ($options['help']) {
|
|
printUsage();
|
|
exit(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ueberlappende Laeufe verhindern.
|
|
//
|
|
// Bei minuetlichem Cron und einem Lauf, der laenger als eine Minute braucht -
|
|
// etwa weil die Datenbank klemmt oder viele Webhooks zugestellt werden -
|
|
// starten sonst mehrere Evaluatoren gleichzeitig. Die wuerden dieselben
|
|
// Zustandswechsel doppelt protokollieren und doppelt alarmieren.
|
|
// ---------------------------------------------------------------------------
|
|
$lockHandle = null;
|
|
|
|
if (!$options['no-lock']) {
|
|
$lockFile = DC_VAR . '/watchdog-tick.lock';
|
|
|
|
if (!is_dir(DC_VAR)) {
|
|
@mkdir(DC_VAR, 0775, true);
|
|
}
|
|
|
|
$lockHandle = @fopen($lockFile, 'c');
|
|
|
|
if ($lockHandle === false) {
|
|
fwrite(STDERR, "[FEHLER] Sperrdatei nicht beschreibbar: {$lockFile}\n");
|
|
exit(1);
|
|
}
|
|
|
|
if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
|
|
// Kein Fehler, sondern der Normalfall bei einem langsamen Vorlauf.
|
|
if (!$options['quiet']) {
|
|
fwrite(STDERR, "[UEBERSPRUNGEN] Es laeuft bereits ein Tick.\n");
|
|
}
|
|
exit(2);
|
|
}
|
|
|
|
// Der Rueckgabewert von ftruncate ist hier ohne Belang: die Datei traegt
|
|
// nur die PID zur Fehlersuche, ihr Inhalt steuert nichts.
|
|
ftruncate($lockHandle, 0);
|
|
fwrite($lockHandle, (string)getmypid());
|
|
fflush($lockHandle);
|
|
}
|
|
|
|
// Kein try/finally um den Lauf: exit() fuehrt in PHP keine finally-Bloecke aus,
|
|
// die Freigabe waere dort also nur zum Schein notiert. Der Handler oben beendet
|
|
// den Prozess bei einer Exception - dabei gibt das Betriebssystem die Sperre
|
|
// ohnehin frei. Fuer den regulaeren Weg steht die Freigabe am Ende.
|
|
$startedAt = microtime(true);
|
|
$db = Db::init();
|
|
|
|
if ($options['report']) {
|
|
$reportRes = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($db, true);
|
|
if (!$options['quiet']) {
|
|
printf("Rocket.Chat Statusbericht: %s (%s)\n", $reportRes['sent'] ? 'ERFOLGREICH' : 'FEHLER', $reportRes['reason']);
|
|
}
|
|
}
|
|
|
|
// Zugangsschutz der Release-Verzeichnisse abgleichen.
|
|
//
|
|
// Abgelaufene Lizenzen loesen anders als ein Widerruf nichts aus - der
|
|
// Zeitpunkt vergeht einfach. Ohne diesen Abgleich behielten sie ihren Zugang.
|
|
// reconcile() prueft selbst, ob es etwas zu tun gibt, und macht im Normalfall
|
|
// nichts; ein minuetlicher Aufruf ist deshalb unbedenklich.
|
|
$guardResult = \Deploymentcenter\Modules\UpdateService\ReleaseGuard::reconcile($db);
|
|
|
|
if ($guardResult['ran'] && !$options['quiet']) {
|
|
printf(
|
|
"Release-Zugangsschutz erneuert (%s): %d Produkt(e), %d Zugang/Zugaenge%s\n",
|
|
$guardResult['reason'],
|
|
$guardResult['projects'],
|
|
$guardResult['entries'],
|
|
$guardResult['errors'] !== [] ? ', ' . count($guardResult['errors']) . ' Fehler' : ''
|
|
);
|
|
}
|
|
|
|
foreach ($guardResult['errors'] as $guardError) {
|
|
Logger::warning('Release-Zugangsschutz', ['error' => $guardError]);
|
|
}
|
|
|
|
// Nach einer Neuerzeugung gegenpruefen, ob der Schutz wirklich greift.
|
|
// Vorhandene Dateien beweisen nichts: Unter Nginx werden sie ignoriert, und
|
|
// dann waeren die Verzeichnisse offen, ohne dass es jemandem auffiele.
|
|
if ($guardResult['ran']) {
|
|
$guardTest = \Deploymentcenter\Modules\UpdateService\ReleaseGuard::selfTest($db);
|
|
|
|
foreach ($guardTest['failed'] as $failure) {
|
|
Logger::error('Release-Zugangsschutz greift nicht', [
|
|
'product' => $failure['slug'] ?? '?',
|
|
'status' => $failure['status'] ?? null,
|
|
'reason' => $failure['reason'] ?? '',
|
|
]);
|
|
}
|
|
|
|
if (!$options['quiet'] && $guardTest['failed'] !== []) {
|
|
printf(
|
|
"ACHTUNG: Zugangsschutz greift bei %d von %d Produkt(en) NICHT.\n",
|
|
count($guardTest['failed']),
|
|
$guardTest['checked']
|
|
);
|
|
}
|
|
}
|
|
|
|
$result = Evaluator::run($db);
|
|
|
|
// Supervisor-Detektoren.
|
|
//
|
|
// Laufen nur alle zehn Minuten - run() prueft das selbst, ein minuetlicher
|
|
// Aufruf ist deshalb unbedenklich. Bewusst NACH dem Evaluator: der setzt die
|
|
// Monitorzustaende, auf die silence.heartbeat schaut.
|
|
//
|
|
// Ein Fehlschlag darf den Tick nicht abbrechen. Die Detektoren sind Beiwerk;
|
|
// die Zustandsauswertung darueber ist die eigentliche Aufgabe dieses Laufs.
|
|
try {
|
|
$detectorResult = \Deploymentcenter\Modules\Supervisor\ServerDetectors::run($db);
|
|
} catch (\Throwable $e) {
|
|
$detectorResult = ['ran' => false, 'reason' => 'Fehler: ' . $e->getMessage()];
|
|
Logger::warning('Supervisor-Detektoren fehlgeschlagen', ['error' => $e->getMessage()]);
|
|
}
|
|
|
|
if (($detectorResult['ran'] ?? false) && !$options['quiet']) {
|
|
printf(
|
|
"Supervisor: %d Befund(e) in %d ms%s\n",
|
|
$detectorResult['findings'] ?? 0,
|
|
$detectorResult['duration_ms'] ?? 0,
|
|
($detectorResult['purged'] ?? 0) > 0 ? sprintf(', %d bereinigt', $detectorResult['purged']) : ''
|
|
);
|
|
}
|
|
|
|
// Einzelne Detektorfehler protokolliert run() bereits selbst - hier wuerde
|
|
// derselbe Eintrag ein zweites Mal entstehen.
|
|
if (!$options['quiet'] && ($detectorResult['errors'] ?? []) !== []) {
|
|
printf("Supervisor: %d Detektor(en) fehlgeschlagen.\n", count($detectorResult['errors']));
|
|
}
|
|
|
|
$wallMs = (int)round((microtime(true) - $startedAt) * 1000);
|
|
|
|
if ($options['json']) {
|
|
echo json_encode(
|
|
$result + ['wall_ms' => $wallMs],
|
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
|
), PHP_EOL;
|
|
} elseif (!($options['quiet'] && $result['changed'] === 0)) {
|
|
// Im Cron soll nur etwas ankommen, wenn es etwas zu berichten gibt -
|
|
// sonst erzeugt ein minuetlicher Job 1440 Mails am Tag.
|
|
printSummary($result, $wallMs);
|
|
}
|
|
|
|
releaseLock($lockHandle);
|
|
exit(0);
|
|
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* @param list<string> $argv
|
|
* @return array{help:bool,json:bool,quiet:bool,no-lock:bool}
|
|
*/
|
|
function parseArguments(array $argv): array
|
|
{
|
|
$options = ['help' => false, 'json' => false, 'quiet' => false, 'no-lock' => false, 'report' => false];
|
|
|
|
foreach (array_slice($argv, 1) as $arg) {
|
|
switch ($arg) {
|
|
case '-h':
|
|
case '--help':
|
|
$options['help'] = true;
|
|
break;
|
|
case '-j':
|
|
case '--json':
|
|
$options['json'] = true;
|
|
break;
|
|
case '-q':
|
|
case '--quiet':
|
|
$options['quiet'] = true;
|
|
break;
|
|
case '--no-lock':
|
|
$options['no-lock'] = true;
|
|
break;
|
|
case '-r':
|
|
case '--report':
|
|
case '--rocketchat-report':
|
|
$options['report'] = true;
|
|
break;
|
|
default:
|
|
fwrite(STDERR, "Unbekannte Option: {$arg}\n\n");
|
|
printUsage();
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* Gibt die Sperre frei. Ohne aktive Sperre ein No-Op.
|
|
*
|
|
* @param resource|null $handle
|
|
*/
|
|
function releaseLock($handle): void
|
|
{
|
|
if ($handle === null) {
|
|
return;
|
|
}
|
|
|
|
flock($handle, LOCK_UN);
|
|
fclose($handle);
|
|
}
|
|
|
|
/** @param array<string,mixed> $result */
|
|
function printSummary(array $result, int $wallMs): void
|
|
{
|
|
printf(
|
|
"Watchdog-Tick: %d Monitor(e) geprueft, %d Zustandswechsel in %d ms (gesamt %d ms).\n",
|
|
$result['checked'],
|
|
$result['changed'],
|
|
$result['duration_ms'],
|
|
$wallMs
|
|
);
|
|
|
|
foreach ($result['changes'] as $change) {
|
|
printf(
|
|
" %-28s %s -> %-8s %s%s\n",
|
|
$change['source'],
|
|
$change['from'],
|
|
$change['to'],
|
|
$change['reason'],
|
|
$change['suppressed'] !== null
|
|
? sprintf(' [Alarm unterdrueckt, %s ist selbst unten]', $change['suppressed'])
|
|
: ''
|
|
);
|
|
}
|
|
|
|
if (($result['released_leases'] ?? 0) > 0) {
|
|
printf(" %d abgelaufene Bugtracker-Lease(s) freigegeben.\n", $result['released_leases']);
|
|
}
|
|
|
|
if (($result['purged_metrics'] ?? 0) > 0) {
|
|
printf(" %d alte Metrikwerte abgeraeumt.\n", $result['purged_metrics']);
|
|
}
|
|
}
|
|
|
|
function printUsage(): void
|
|
{
|
|
echo <<<TXT
|
|
Watchdog-Tick - Evaluationslauf von der Kommandozeile.
|
|
|
|
Stuft Monitore anhand ihres erwarteten Intervalls auf warning bzw. down,
|
|
protokolliert Zustandswechsel, loest Webhooks aus, gibt abgelaufene
|
|
Bugtracker-Leases frei und raeumt stuendlich den Metrik-Verlauf ab.
|
|
|
|
Aufruf:
|
|
php cli/tick.php [Optionen]
|
|
|
|
Optionen:
|
|
-q, --quiet Ausgabe nur bei Zustandswechseln (fuer Cron)
|
|
-j, --json Ergebnis als JSON
|
|
--no-lock Sperre gegen ueberlappende Laeufe uebergehen
|
|
-h, --help Diese Hilfe
|
|
|
|
Rueckgabewerte:
|
|
0 erfolgreich
|
|
1 Fehler
|
|
2 uebersprungen, es laeuft bereits ein Tick
|
|
|
|
Cron (minuetlich):
|
|
* * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
|
|
|
|
TXT;
|
|
}
|