From daeea1db0e88bacac72a52debe13627f36a9dd3e Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:06:02 +0200 Subject: [PATCH] Der Waechter hinterlaesst, was er getan hat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Waechter redete bisher nur ins Journal auf dem Wirt — die Konsole im Container sieht ihn also nicht. Er schreibt jetzt zusaetzlich storage/app/deploy/watchdog-last-run.json (atomar, .tmp + mv) mit Ausgang (idle/healed/stood_down) und den say()-Meldungen des Laufs. WatchdogLog::lastRun() liest das robust (fehlend/kaputt -> null, stale-Erkennung nach 5 Minuten). Die mitgelieferte Testvorlage hatte selbst einen Fehler: die docker-Attrappe setzte ihren mehrzeiligen Vorgabewert ungequotet in generierten Shell-Code ein, wodurch die "ps"-Antwort einen Dienst verschluckte und der idle-Test faelschlich "healed" sah. Behoben durch Anfuehrungszeichen um den eingesetzten Wert. Co-Authored-By: Claude Opus 5 --- app/Services/Deployment/WatchdogLog.php | 66 ++++++++++++ deploy/watchdog.sh | 50 +++++++++ tests/Feature/WatchdogVisibilityTest.php | 131 +++++++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 app/Services/Deployment/WatchdogLog.php create mode 100644 tests/Feature/WatchdogVisibilityTest.php diff --git a/app/Services/Deployment/WatchdogLog.php b/app/Services/Deployment/WatchdogLog.php new file mode 100644 index 0000000..c3b270b --- /dev/null +++ b/app/Services/Deployment/WatchdogLog.php @@ -0,0 +1,66 @@ +, stale: bool}|null + */ + public function lastRun(): ?array + { + try { + $path = storage_path('app/'.self::FILE); + + if (! File::exists($path)) { + return null; + } + + $data = json_decode((string) File::get($path), true); + + if (! is_array($data) || ! isset($data['at'])) { + return null; + } + + $at = Carbon::parse((string) $data['at']); + + return [ + 'at' => $at, + 'outcome' => (string) ($data['outcome'] ?? 'idle'), + 'actions' => array_values(array_filter( + is_array($data['actions'] ?? null) ? $data['actions'] : [], + 'is_string' + )), + 'stale' => $at->lt(Carbon::now()->subMinutes(self::STALE_AFTER_MINUTES)), + ]; + } catch (Throwable) { + // Dieselbe Haltung wie `UpdateChannel::readJson()`: die Konsole + // liest das bei jedem Seitenaufbau, und „ich weiß es nicht" ist + // ein brauchbarer Zustand — eine geworfene Ausnahme nicht. + return null; + } + } +} diff --git a/deploy/watchdog.sh b/deploy/watchdog.sh index 120a9d2..e974b13 100755 --- a/deploy/watchdog.sh +++ b/deploy/watchdog.sh @@ -37,6 +37,10 @@ say() { logger -t "$LOG_TAG" -- "$*" fi printf '%s\n' "$*" + # Jede Meldung ist zugleich ein Eintrag fuer die Konsole. Eine zweite + # Stelle, an der man daran denken muesste, waere eine Stelle, an der es + # irgendwann vergessen wird. + AKTIONEN+=("$*") } mkdir -p "$STATE_DIR" 2>/dev/null || true @@ -101,6 +105,10 @@ darf_eingreifen() { geheilt=false +# Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus +# einen Satz; das Journal hat weiterhin die Langfassung. +AKTIONEN=() + # ── 1. Fehlt ein Dienst? ───────────────────────────────────────────────────── # # `config --services` liest die Profile aus der .env mit, vpn-dns und @@ -206,3 +214,45 @@ fi if [[ "$geheilt" == true ]]; then say "Nachgesehen und eingegriffen." fi + +# ── Was die Konsole davon erfaehrt ─────────────────────────────────────────── +# +# Der Waechter redete bisher NUR ins Journal — und das liegt auf dem Wirt, +# waehrend die Konsole in einem Container laeuft. Sie sah ihn also gar nicht. +# Am 4. August 2026 hat genau das die Fehlersuche gekostet: der Waechter hielt +# die Sperre, der Agent kam nicht an die Arbeit, und die einzige Stelle, an der +# das gestanden haette, war von der Konsole aus unerreichbar. +# +# Drei Ausgaenge, weil sie drei verschiedene Dinge bedeuten: +# idle — nachgesehen, nichts zu tun. Der Normalfall. +# healed — eingegriffen. Was, steht in `actions`. +# stood_down — nicht drangekommen, weil ein Update die Sperre hielt. +# Betrieb, kein Fehler — aber es muss unterscheidbar sein. +# +# Atomar geschrieben: die Konsole liest diese Datei bei jedem Seitenaufbau, +# und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand +# nachsieht. +ausgang=idle +if [[ "$geheilt" == true ]]; then + ausgang=healed +elif [[ "$SPERRE" == verwehrt ]]; then + ausgang=stood_down +fi + +# Die Liste als JSON-Array. Anfuehrungszeichen, Backslashes und Umbrueche raus +# — der einzige freie Text sind die eigenen Meldungen oben, aber verlassen +# wird sich darauf nicht. +eintraege='' +for a in ${AKTIONEN+"${AKTIONEN[@]}"}; do + a="$(printf '%s' "$a" | tr -d '"\\' | tr '\n\r\t' ' ')" + eintraege+="\"$a\"," +done + +cat > "$STATE_DIR/watchdog-last-run.json.tmp" 2>/dev/null </dev/null || true +{ + "at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "outcome": "$ausgang", + "actions": [${eintraege%,}] +} +EOF diff --git a/tests/Feature/WatchdogVisibilityTest.php b/tests/Feature/WatchdogVisibilityTest.php new file mode 100644 index 0000000..718e833 --- /dev/null +++ b/tests/Feature/WatchdogVisibilityTest.php @@ -0,0 +1,131 @@ +timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'STUB_UP_CALLED' => $dir.'/.stub-up-called', + ])->run(<</dev/null 2>&1 || true + pkill -f 'sleep 20' 2>/dev/null || true + BASH); + + expect($result->successful())->toBeTrue($result->errorOutput()); + + return json_decode(File::get($dir.'/watchdog-last-run.json'), true); +} + +afterEach(function () { + File::deleteDirectory(storage_path('app/deploy')); +}); + +it('records a run where there was nothing to do', function () { + $run = runWatchdog(); + + expect($run['outcome'])->toBe('idle') + ->and($run['actions'])->toBe([]) + ->and($run['at'])->not->toBeEmpty(); +}); + +it('records what it healed', function () { + // `app` fehlt in der Liste der laufenden Dienste — der Waechter startet + // die Dienste und muss das hinterlassen. + $run = runWatchdog(running: 'redis'); + + expect($run['outcome'])->toBe('healed') + ->and($run['actions'])->not->toBeEmpty(); +}); + +it('records that it stood down because the lock was held', function () { + // DER Zustand, der bisher unsichtbar war. Ohne ihn sieht ein Waechter, + // der seit einer Stunde nicht eingreifen kann, genauso aus wie einer, + // der nichts zu tun hat. + $run = runWatchdog(running: 'redis', holdLock: true); + + expect($run['outcome'])->toBe('stood_down'); +}); + +it('reads nothing rather than falling over when the file is absent', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + + expect(app(WatchdogLog::class)->lastRun())->toBeNull(); +}); + +it('reads nothing rather than falling over when the file is rubbish', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/watchdog-last-run.json'), 'kein json {'); + + expect(app(WatchdogLog::class)->lastRun())->toBeNull(); +}); + +it('calls a run from long ago stale', function () { + // Ein toter Waechter muss als solcher lesbar sein. Bisher wuerde niemand + // es je erfahren. + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([ + 'at' => now()->subMinutes(30)->utc()->format('Y-m-d\TH:i:s\Z'), + 'outcome' => 'idle', + 'actions' => [], + ])); + + $run = app(WatchdogLog::class)->lastRun(); + + expect($run['stale'])->toBeTrue(); +}); + +it('does not call a fresh run stale', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([ + 'at' => now()->utc()->format('Y-m-d\TH:i:s\Z'), + 'outcome' => 'idle', + 'actions' => [], + ])); + + expect(app(WatchdogLog::class)->lastRun()['stale'])->toBeFalse(); +});