From 6bdab32b94ab8eec7a7cd3c8951e7a6ea696c584 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 16:54:23 +0200 Subject: [PATCH 1/8] Umsetzungsplan: drei Wirt-Vorgaenge in die Konsole Waechter sichtbar machen, Wirt-Helfer ehrlich melden, Tunnel-Rettung als Anfrageart. Alle drei folgen dem Muster, das Agent und Update-Kanal schon benutzen: der Wirt schreibt Zustand als JSON in den Bind-Mount, die Konsole liest ihn, und neue Handlungen gehen durch den bestehenden Postkasten. Beim Wirt-Helfer wird bewusst NICHT automatisiert: sudoers gewaehrt dem Dienstbenutzer genau drei benannte Befehle, und etwas Root-Eigenes, das ungeprueft aus dem beschreibbaren Checkout ausfuehrt, gaebe jedem, der je an diesen Benutzer kommt, Root auf dem Wirt. Gemeldet wird dafuer vollstaendig und BEVOR jemand einen Knopf drueckt, der daran scheitert. Nebenbei aufgeloest: die gebrauchte Vertragsversion stand zweimal im Repo. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-04-wirt-in-die-konsole.md | 825 ++++++++++++++++++ 1 file changed, 825 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-wirt-in-die-konsole.md diff --git a/docs/superpowers/plans/2026-08-04-wirt-in-die-konsole.md b/docs/superpowers/plans/2026-08-04-wirt-in-die-konsole.md new file mode 100644 index 0000000..e035150 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-wirt-in-die-konsole.md @@ -0,0 +1,825 @@ +# Wirt-Vorgänge in die Konsole — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Drei Vorgänge, für die heute die Kommandozeile auf dem Wirt nötig ist, werden aus der Konsole heraus sichtbar bzw. bedienbar — der Wächter, der Zustand des Wirt-Helfers, und die Tunnel-Rettung. + +**Architecture:** Alle drei folgen dem Muster, das Agent und Update-Kanal bereits benutzen: der Wirt schreibt Zustand als JSON nach `storage/app/deploy/` (Bind-Mount), die Konsole liest ihn. Neue Handlungen gehen durch den bestehenden Postkasten (`update-request.json` + `KIND_*`), nie über einen zweiten Weg. + +**Tech Stack:** Bash (`deploy/*.sh`), Laravel 13.8, Livewire 3, Pest, Tailwind v4. + +## Global Constraints + +- **Jede Datei, die der Wirt schreibt und die Konsole liest, wird ATOMAR geschrieben** (`.tmp` + `mv`/`rename`), und **beim Lesen fällt nichts um**, wenn sie fehlt, leer ist oder Unsinn enthält. In dieser Codebasis waren beide Punkte schon je ein Important-Befund. Vorbilder: `write_alive()` in `deploy/update-agent.sh`, `readJson()` in `UpdateChannel`. +- **Kein zweiter Weg in eine Auslieferung oder einen Wirt-Vorgang.** Neue Handlungen gehen durch `UpdateChannel::submit()` mit eigener `KIND_*`, wie `KIND_CHECK`/`KIND_RESTART`/`KIND_ARCHIVE_KEY`. +- **`deploy/update-agent.sh` und `deploy/watchdog.sh` laufen unter `set -e`-Regimen.** Eine Zuweisung aus einer Kommandoersetzung reicht deren Status weiter und beendet das Skript, BEVOR es Zustand schreibt — genau diese Ausfallart hat die Konsole schon zweimal eine nie endende Prüfung zeigen lassen. Jeder neue Ausdruck muss das überleben. `watchdog.sh` läuft unter `set -uo pipefail` (**ohne** `-e`), `update-agent.sh` unter `set -Eeuo pipefail`. +- **Kein Aufruf nach draußen ohne Frist.** `timeout -k` wie im übrigen Wächter. +- **Die Sicherheitsgrenze des Wirt-Helfers bleibt.** `sudoers` gewährt genau drei benannte Befehle. Es wird **nichts** gebaut, das root ungeprüft aus dem beschreibbaren Checkout ausführen lässt. Aufgabe ist zu **melden**, nicht zu automatisieren. +- **R21** Operator-Guard, **R23** Bestätigung im Modal (kein `wire:confirm`, kein `confirm(`), **R18/R24** wie in CLAUDE.md. +- **Sprachdateien:** jede Zeichenkette in `lang/de/` **und** `lang/en/`. +- **Testlauf:** `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=` — der `docker compose`-Aufruf **muss** aus `/home/nexxo/clupilot` kommen, nie aus dem Worktree (sonst „service app is not running"). `cd` hält zwischen Aufrufen nicht. +- **Nie `git add -A`.** Eigene Pfade einzeln nennen, `git status` vorher prüfen — es laufen mehrere Sitzungen in diesem Arbeitsbaum. + +--- + +## File Structure + +| Datei | Verantwortung | +|---|---| +| `deploy/watchdog.sh` | Schreibt am Ende jedes Laufs seinen Ausgang — auch den Rücktritt. | +| `app/Services/Deployment/WatchdogLog.php` | **Neu.** Liest den Ausgang, beurteilt Frische. Eigene Klasse, weil `UpdateChannel` bereits über 900 Zeilen hat und der Wächter eine andere Sache ist als der Update-Kanal. | +| `deploy/lib/release.sh` | Hält die gebrauchte Vertragsversion des Wirt-Helfers an **einer** Stelle. | +| `deploy/update-agent.sh` | Meldet Vertragsversion (vorhanden/gebraucht); führt die Tunnel-Rettung aus. | +| `app/Services/Deployment/UpdateChannel.php` | Neue Anfrageart Tunnel-Rettung, Helfer-Vertrag in `state()`. | +| `app/Livewire/Admin/Settings.php` + Blade | Anzeige Wächter, Anzeige Helfer-Vertrag, Knopf Tunnel-Rettung. | + +--- + +### Task 1: Der Wächter hinterlässt, was er getan hat + +**Files:** +- Modify: `deploy/watchdog.sh` +- Create: `app/Services/Deployment/WatchdogLog.php` +- Test: `tests/Feature/WatchdogVisibilityTest.php` (neu) + +**Interfaces:** +- Produces: Datei `storage/app/deploy/watchdog-last-run.json` mit + `{"at":"","outcome":"idle"|"healed"|"stood_down","actions":["…"]}` +- Produces: `WatchdogLog::lastRun(): ?array` mit den Schlüsseln `at` (Carbon), `outcome` (string), `actions` (string[]), `stale` (bool — älter als 5 Minuten, also mehr als vier ausgefallene Takte) + +- [ ] **Step 1: Write the failing test** + +Neue Datei `tests/Feature/WatchdogVisibilityTest.php`: + +```php +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(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=WatchdogVisibility` +Expected: FAIL — weder die Datei noch die Klasse gibt es. + +- [ ] **Step 3: Implement — der Wächter schreibt seinen Ausgang** + +In `deploy/watchdog.sh`: eine Liste mitführen und am Ende schreiben. + +Direkt hinter `geheilt=false` einfügen: + +```bash +# Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus +# einen Satz; das Journal hat weiterhin die Langfassung. +AKTIONEN=() +``` + +Die Funktion `say()` erweitern, damit jede Meldung zugleich in die Liste geht — +**nicht** eine zweite Stelle, an der man daran denken muss: + +```bash +say() { + if command -v logger >/dev/null 2>&1; then + 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+=("$*") +} +``` + +Am **Ende** der Datei, nach dem bestehenden `if [[ "$geheilt" == true ]]`-Block: + +```bash +# ── 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 +``` + +**Achtung `set -u`:** `${AKTIONEN+"${AKTIONEN[@]}"}` statt `"${AKTIONEN[@]}"` — ein leeres Array gilt unter `set -u` in älteren bash als ungesetzt und bricht den Lauf. + +- [ ] **Step 4: Implement — die Konsole liest ihn** + +Neue Datei `app/Services/Deployment/WatchdogLog.php`: + +```php +, 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; + } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter="WatchdogVisibility|WatchdogLockContention"` +Expected: PASS — beide, denn `WatchdogLockContention` fährt denselben Wächter. + +- [ ] **Step 6: Commit** + +```bash +git add deploy/watchdog.sh app/Services/Deployment/WatchdogLog.php tests/Feature/WatchdogVisibilityTest.php +git commit -m "Der Waechter hinterlaesst, was er getan hat" +``` + +--- + +### Task 2: Die gebrauchte Vertragsversion an einer Stelle, und ehrlich gemeldet + +**Files:** +- Modify: `deploy/lib/release.sh` (neue Konstante/Funktion) +- Modify: `deploy/update.sh:75` (die doppelte Stelle auflösen) +- Modify: `deploy/update-agent.sh` (Vertrag melden; hartkodierte `3` auflösen) +- Modify: `app/Services/Deployment/UpdateChannel.php` (`state()` durchreichen) +- Test: `tests/Feature/HostStepContractTest.php` (neu) + +**Interfaces:** +- Produces: `release_host_step_needs` in `deploy/lib/release.sh` → gibt die gebrauchte Vertragsversion aus (heute `3`) +- Produces: `update-status.json` bekommt `host_step_contract` (int, `0` wenn der Helfer fehlt oder nicht antwortet) und `host_step_needs` (int) +- Produces: `state()` bekommt `host_step_ok` (bool) und `host_step_have`/`host_step_needs` (int) + +- [ ] **Step 1: Write the failing test** + +Neue Datei `tests/Feature/HostStepContractTest.php`: + +```php +toContain('release_host_step_needs') + ->and($update)->toContain('release_host_step_needs') + ->and($agent)->toContain('release_host_step_needs'); + + // Und keine nackte Zahl mehr an den beiden alten Stellen. + expect($update)->not->toContain('HOST_STEP_NEEDS=3') + ->and($agent)->not->toContain('(( have < 3 ))'); +}); + +it('answers the needed contract version from the shell', function () { + $result = Process::path(base_path())->timeout(30)->run( + 'bash -c '.escapeshellarg('set -Eeuo pipefail; . deploy/lib/release.sh; release_host_step_needs') + ); + + expect($result->exitCode())->toBe(0) + ->and(trim($result->output()))->toMatch('/^[0-9]+$/'); +}); + +it('reports the helper as not ok when the host has an older one', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'host_step_contract' => 2, + 'host_step_needs' => 3, + ])); + + $state = app(UpdateChannel::class)->state(); + + expect($state['host_step_ok'])->toBeFalse() + ->and($state['host_step_have'])->toBe(2) + ->and($state['host_step_needs'])->toBe(3); +}); + +it('reports the helper as ok when it is current', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'host_step_contract' => 3, + 'host_step_needs' => 3, + ])); + + expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue(); +}); + +it('does not cry wolf when the agent has not reported yet', function () { + // Ein Wirt, dessen Agent die Zahlen noch nie gemeldet hat (alte Fassung, + // erster Lauf), darf nicht als kaputt dastehen. „Ich weiß es nicht" ist + // nicht dasselbe wie „zu alt". + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode(['state' => 'idle'])); + + expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=HostStepContract` +Expected: FAIL + +- [ ] **Step 3: Implement — eine Stelle für die Zahl** + +Ans Ende von `deploy/lib/release.sh`: + +```bash +# release_host_step_needs — welche Vertragsversion des root-eigenen Helfers +# diese Fassung braucht. +# +# Sie stand zweimal im Repo: als `HOST_STEP_NEEDS=3` in update.sh und als +# hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen, laufen +# irgendwann auseinander — und das Auseinanderlaufen zeigt sich erst auf einem +# Wirt, dessen Helfer zu alt ist. +# +# Angehoben wird sie, wenn install-agent.sh dem Helfer einen Schritt beibringt, +# auf den sich etwas anderes verlässt. Dann braucht JEDER Wirt einmal +# `sudo bash deploy/install-agent.sh` — das ist Absicht und die Grenze, hinter +# der root sitzt. +release_host_step_needs() { printf '%s' 3; } +``` + +In `deploy/update.sh` die Zeile `HOST_STEP_NEEDS=3` ersetzen durch: + +```bash +HOST_STEP_NEEDS="$(release_host_step_needs)" +``` + +(`deploy/lib/release.sh` wird dort bereits geladen — nachprüfen und, falls nicht, den Aufruf hinter das vorhandene `.`-Einbinden setzen.) + +In `deploy/update-agent.sh`, in `release_stuck_lock()`, `if (( have < 3 ))` ersetzen durch: + +```bash + if (( have < $(release_host_step_needs) )); then +``` + +- [ ] **Step 4: Implement — der Agent meldet den Vertrag** + +In `deploy/update-agent.sh`, vor `write_status()`, die Werte ermitteln. **Wichtig unter `set -e`:** ein fehlender oder fehlschlagender Helfer darf den Agenten nicht beenden. + +```bash +# Welchen Vertrag der Wirt-Helfer erfüllt — und welchen diese Fassung braucht. +# +# Gemeldet statt automatisiert: `sudoers` gewährt dem Dienstbenutzer genau +# drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem +# beschreibbaren Checkout ausführt, gäbe jedem, der je an diesen Benutzer +# kommt, Root auf dem Wirt. Die Grenze bleibt; die Konsole soll nur aufhören, +# den Betreiber raten zu lassen. +# +# `|| true` und der Zahlentest: ein fehlender Helfer, ein Helfer ohne diesen +# Schritt und ein Helfer, der etwas Unerwartetes druckt, sind alle „0" — und +# keiner davon darf den Agenten unter `set -e` beenden, bevor er eine +# Statusdatei schreibt. +HOST_STEP_HAVE="$( { /usr/local/sbin/clupilot-host-step contract 2>/dev/null || true; } | head -1 )" +[[ "$HOST_STEP_HAVE" =~ ^[0-9]+$ ]] || HOST_STEP_HAVE=0 +HOST_STEP_NEEDS="$(release_host_step_needs)" +``` + +In `write_status()` hinter der `"behind"`-Zeile ergänzen: + +```bash + "host_step_contract": ${HOST_STEP_HAVE:-0}, + "host_step_needs": ${HOST_STEP_NEEDS:-0}, +``` + +- [ ] **Step 5: Implement — die Konsole liest ihn** + +In `UpdateChannel::state()`, im `return`-Array: + +```php + // Der root-eigene Helfer auf dem Wirt. Fehlt die Meldung ganz + // (alte Agentenfassung, allererster Lauf), gilt er als in + // Ordnung: „ich weiß es nicht" ist nicht „zu alt", und ein + // Warnkasten, der auf jedem frisch aufgesetzten Wirt steht, wird + // nach zwei Tagen nicht mehr gelesen. + 'host_step_have' => isset($status['host_step_contract']) + ? (int) $status['host_step_contract'] + : null, + 'host_step_needs' => isset($status['host_step_needs']) + ? (int) $status['host_step_needs'] + : null, + 'host_step_ok' => ! isset($status['host_step_contract'], $status['host_step_needs']) + || (int) $status['host_step_contract'] >= (int) $status['host_step_needs'], +``` + +- [ ] **Step 6: Run tests, then commit** + +Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter="HostStepContract|ReleaseComparison|ReleaseCeiling|UpdateLockRelease"` +Expected: PASS + +```bash +git add deploy/lib/release.sh deploy/update.sh deploy/update-agent.sh app/Services/Deployment/UpdateChannel.php tests/Feature/HostStepContractTest.php +git commit -m "Die gebrauchte Vertragsversion steht an einer Stelle und wird gemeldet" +``` + +--- + +### Task 3: Tunnel-Rettung aus der Konsole, und die drei Anzeigen + +**Files:** +- Modify: `app/Services/Deployment/UpdateChannel.php` (neue Anfrageart) +- Modify: `deploy/update-agent.sh` (Anfrageart ausführen) +- Modify: `app/Livewire/Admin/Settings.php` +- Create: `app/Livewire/Admin/ConfirmRescueTunnel.php` + Blade +- Modify: `resources/views/livewire/admin/settings.blade.php` +- Modify: `lang/de/admin_settings.php`, `lang/en/admin_settings.php` +- Test: `tests/Feature/RescueTunnelTest.php` (neu) + +**Interfaces:** +- Consumes: `WatchdogLog::lastRun()` (Task 1), `state()['host_step_ok'|'host_step_have'|'host_step_needs']` (Task 2) +- Produces: `UpdateChannel::KIND_RESCUE_TUNNEL = 'rescue-tunnel'`, `UpdateChannel::requestRescueTunnel(string $by): bool` +- Produces: `storage/app/deploy/rescue-last-run.json` `{"state":"ok"|"failed","finished_at":"…","error":"…"}` und `storage/app/deploy/rescue-last-run.log` +- Produces: `state()['rescue_last_run']`, `Settings::rescueTunnel()` + +- [ ] **Step 1: Write the failing test** + +Neue Datei `tests/Feature/RescueTunnelTest.php`: + +```php +requestRescueTunnel('chef@example.com'))->toBeTrue(); + + $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); + + expect($request['kind'])->toBe('rescue-tunnel') + ->and($request['requested_by'])->toBe('chef@example.com'); +}); + +it('takes only one request at a time', function () { + $channel = app(UpdateChannel::class); + + expect($channel->requestRescueTunnel('chef@example.com'))->toBeTrue() + ->and($channel->requestRescueTunnel('chef@example.com'))->toBeFalse(); +}); + +it('rescues the tunnel from the console', function () { + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->call('rescueTunnel'); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue(); +}); + +it('refuses to rescue without site.manage', function () { + // Eine Livewire-Aktion ist ein oeffentlicher Endpunkt. + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(Settings::class) + ->call('rescueTunnel') + ->assertForbidden(); +}); + +it('reads the outcome without falling over when it is rubbish', function () { + File::put(storage_path('app/deploy/rescue-last-run.json'), 'kein json {'); + + expect(app(UpdateChannel::class)->state()['rescue_last_run'])->toBeNull(); +}); + +it('agent knows the kind', function () { + // Der Agent muss die Art kennen, sonst liegt die Anfrage bis zum Ablauf + // im Postkasten und niemand erfaehrt warum. + expect(File::get(base_path('deploy/update-agent.sh'))) + ->toContain('rescue-tunnel') + ->toContain('rescue-tunnel.sh'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test --filter=RescueTunnel` +Expected: FAIL + +- [ ] **Step 3: Implement — Anfrageart im Kanal** + +In `UpdateChannel`, bei den übrigen `KIND_*`: + +```php + /** + * Den Tunnel wieder hinbekommen. + * + * `deploy/rescue-tunnel.sh` stand in zwei Runbooks als Handarbeit. Es + * läuft als Dienstbenutzer und tut ausdrücklich nichts Zerstörerisches — + * kein Neubau, kein Neustart des Tunnel-Containers, weil genau das die + * Ursache wäre und nicht die Lösung. + */ + private const KIND_RESCUE_TUNNEL = 'rescue-tunnel'; + + private const RESCUE_LAST_RUN = 'deploy/rescue-last-run.json'; +``` + +Und die Methode, neben `requestCheck()`: + +```php + public function requestRescueTunnel(string $by): bool + { + return $this->submit($by, self::KIND_RESCUE_TUNNEL); + } +``` + +In `state()` im `return`-Array: + +```php + // `readJson` fängt kaputtes JSON bereits ab und liefert `[]`. + 'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== [] + ? $last + : null, +``` + +- [ ] **Step 4: Implement — der Agent führt sie aus** + +In `deploy/update-agent.sh`, dort wo die übrigen Anfragearten unterschieden werden (bei `KIND`/`kind`), einen Zweig ergänzen — **nach** dem Muster der bestehenden Arten, mit Frist und ohne den Lauf sterben zu lassen: + +```bash +if [[ "$REQUEST_KIND" == "rescue-tunnel" ]]; then + # Läuft als derselbe Dienstbenutzer wie dieser Agent — genau so, wie das + # Runbook es von Hand vorsah. Das Skript weigert sich als root zu laufen. + # + # Frist, weil dieser Aufruf die Sperre hält: die Tunnel-Rettung fasst + # `docker compose exec` an, und ein hängender Docker-Daemon hätte den + # Agenten sonst stundenlang blockiert — dieselbe Ausfallart, die diese + # Anlage schon zweimal hatte. + RESCUE_STATE=ok + RESCUE_ERROR='' + if ! timeout -k 10 180 bash "$ROOT/deploy/rescue-tunnel.sh" > "$STATE_DIR/rescue-last-run.log" 2>&1; then + RESCUE_STATE=failed + RESCUE_ERROR=rescue_failed + fi + + cat > "$STATE_DIR/rescue-last-run.json.tmp" <authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + $accepted = app(UpdateChannel::class)->requestRescueTunnel($operator->email); + + $this->dispatch('notify', message: __($accepted + ? 'admin_settings.rescue_requested' + : 'admin_settings.update_already_requested')); + } +``` + +In `render()` den Wächter-Zustand mitgeben: + +```php + $watchdog = app(WatchdogLog::class)->lastRun(); +``` + +und in die View-Daten aufnehmen (`'watchdog' => $watchdog`). + +`ConfirmRescueTunnel.php` und sein Blade nach dem Muster von `ConfirmReleaseUpdateLock` — **den bestehenden lesen und die Bauform übernehmen**, nicht erfinden. + +Im Blade, im selben Abschnitt wie die übrigen Update-Anzeigen: + +```blade +{{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet + ins Journal, und das liegt auf dem Wirt. --}} +@if ($watchdog) +

+ @if ($watchdog['stale']) + {{ __('admin_settings.watchdog_stale', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @elseif ($watchdog['outcome'] === 'healed') + {{ __('admin_settings.watchdog_healed', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} + @elseif ($watchdog['outcome'] === 'stood_down') + {{ __('admin_settings.watchdog_stood_down', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @else + {{ __('admin_settings.watchdog_idle', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @endif +

+@endif + +{{-- Der Wirt-Helfer. Steht VOR den Knoepfen, nicht als Fehler danach: wer + gleich „Sperre loesen" drueckt, soll vorher wissen, dass es scheitern + wird. --}} +@unless ($update['host_step_ok']) + + {{ __('admin_settings.host_step_old', [ + 'have' => $update['host_step_have'], + 'needs' => $update['host_step_needs'], + ]) }} + sudo bash /opt/clupilot/deploy/install-agent.sh + +@endunless +``` + +**R19 beachten:** `->local()` vor jeder Zeitausgabe. `diffForHumans()` ist davon ausgenommen (relativ), aber `->local()` schadet nicht und hält die Regel sichtbar. + +Der Knopf für die Tunnel-Rettung kommt in die **bestehende** Knopfleiste (siehe wie das Festnageln dort eingefügt wurde), mit `$dispatch('openModal', { component: 'admin.confirm-rescue-tunnel' })`. + +Sprachschlüssel (de **und** en), sinngemäß: +`watchdog_idle` „Zuletzt nachgesehen :when — nichts zu tun." · +`watchdog_healed` „Zuletzt eingegriffen :when: :what" · +`watchdog_stood_down` „:when zurückgetreten, weil ein Update lief." · +`watchdog_stale` „Der Wächter hat sich seit :when nicht gemeldet — er läuft vermutlich nicht mehr." · +`host_step_old` „Der Wirt-Helfer erfüllt Vertrag :have, gebraucht wird :needs. Einmalig auf diesem Wirt ausführen:" · +`rescue_requested` „Tunnel-Rettung angefordert." · +plus Titel/Text/Knöpfe für das Bestätigungs-Modal. + +- [ ] **Step 6: Ganze Suite, dann committen** + +Run: `cd /home/nexxo/clupilot && docker compose exec -T -u 1000:1000 -w /var/www/html/.worktrees/wirt-konsole app php artisan test` +Expected: PASS, vollständig. Achte auf `ConfirmInModalTest`, `ModalHeightTest`, `IconLayoutTest`, `DisplayTimezoneTest`, `TranslationParityTest`. + +```bash +git add app/Services/Deployment/UpdateChannel.php deploy/update-agent.sh \ + app/Livewire/Admin/Settings.php app/Livewire/Admin/ConfirmRescueTunnel.php \ + resources/views/livewire/admin/confirm-rescue-tunnel.blade.php \ + resources/views/livewire/admin/settings.blade.php \ + lang/de/admin_settings.php lang/en/admin_settings.php \ + tests/Feature/RescueTunnelTest.php +git commit -m "Tunnel-Rettung aus der Konsole, Waechter und Wirt-Helfer sichtbar" +``` From daeea1db0e88bacac72a52debe13627f36a9dd3e Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:06:02 +0200 Subject: [PATCH 2/8] 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(); +}); From 512fad11fbcd7d8276a48862c2ae8ff179a4bfb8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:21:54 +0200 Subject: [PATCH 3/8] Die gebrauchte Vertragsversion steht an einer Stelle und wird gemeldet --- app/Services/Deployment/UpdateChannel.php | 14 ++++ deploy/lib/release.sh | 14 ++++ deploy/update-agent.sh | 20 +++++- deploy/update.sh | 8 ++- tests/Feature/HostStepContractTest.php | 85 +++++++++++++++++++++++ 5 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/HostStepContractTest.php diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 20590ec..58eb505 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -325,6 +325,20 @@ final class UpdateChannel 'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null, 'checked_at' => $checkedAt, + // Der root-eigene Helfer auf dem Wirt. Fehlt die Meldung ganz + // (alte Agentenfassung, allererster Lauf), gilt er als in + // Ordnung: „ich weiß es nicht" ist nicht „zu alt", und ein + // Warnkasten, der auf jedem frisch aufgesetzten Wirt steht, wird + // nach zwei Tagen nicht mehr gelesen. + 'host_step_have' => isset($status['host_step_contract']) + ? (int) $status['host_step_contract'] + : null, + 'host_step_needs' => isset($status['host_step_needs']) + ? (int) $status['host_step_needs'] + : null, + 'host_step_ok' => ! isset($status['host_step_contract'], $status['host_step_needs']) + || (int) $status['host_step_contract'] >= (int) $status['host_step_needs'], + 'agent_seen' => $agentAlive, // Seit wann der Agent nur noch überspringt, und wer die Sperre // hält. Beides null, solange er arbeitet. diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index 25849bd..814ad63 100644 --- a/deploy/lib/release.sh +++ b/deploy/lib/release.sh @@ -216,3 +216,17 @@ json_escape() { # raw; dropping them beats emitting a broken document. printf '%s' "$s" | tr -d '\000-\037' } + +# release_host_step_needs — welche Vertragsversion des root-eigenen Helfers +# diese Fassung braucht. +# +# Sie stand zweimal im Repo: als `HOST_STEP_NEEDS=3` in update.sh und als +# hartkodierte 3 im Agenten. Zwei Zahlen, die zusammenpassen müssen, laufen +# irgendwann auseinander — und das Auseinanderlaufen zeigt sich erst auf einem +# Wirt, dessen Helfer zu alt ist. +# +# Angehoben wird sie, wenn install-agent.sh dem Helfer einen Schritt beibringt, +# auf den sich etwas anderes verlässt. Dann braucht JEDER Wirt einmal +# `sudo bash deploy/install-agent.sh` — das ist Absicht und die Grenze, hinter +# der root sitzt. +release_host_step_needs() { printf '%s' 3; } diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index d8b4273..77f4b0e 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -176,7 +176,7 @@ release_stuck_lock() { have="$("$step" contract 2>/dev/null || true)" [[ "$have" =~ ^[0-9]+$ ]] || have=0 - if (( have < 3 )); then + if (( have < $(release_host_step_needs) )); then write_unblock failed unblock_helper_old return 0 fi @@ -393,6 +393,22 @@ releases_json() { printf '%s' "${out%,}" } +# Welchen Vertrag der Wirt-Helfer erfüllt — und welchen diese Fassung braucht. +# +# Gemeldet statt automatisiert: `sudoers` gewährt dem Dienstbenutzer genau +# drei benannte Befehle, und etwas Root-Eigenes, das ungeprüft aus dem +# beschreibbaren Checkout ausführt, gäbe jedem, der je an diesen Benutzer +# kommt, Root auf dem Wirt. Die Grenze bleibt; die Konsole soll nur aufhören, +# den Betreiber raten zu lassen. +# +# `|| true` und der Zahlentest: ein fehlender Helfer, ein Helfer ohne diesen +# Schritt und ein Helfer, der etwas Unerwartetes druckt, sind alle „0" — und +# keiner davon darf den Agenten unter `set -e` beenden, bevor er eine +# Statusdatei schreibt. +HOST_STEP_HAVE="$( { /usr/local/sbin/clupilot-host-step contract 2>/dev/null || true; } | head -1 )" +[[ "$HOST_STEP_HAVE" =~ ^[0-9]+$ ]] || HOST_STEP_HAVE=0 +HOST_STEP_NEEDS="$(release_host_step_needs)" + write_status() { local state="$1" error="${2-}" cat > "$STATUS.tmp" <toContain('release_host_step_needs') + ->and($update)->toContain('release_host_step_needs') + ->and($agent)->toContain('release_host_step_needs'); + + // Und keine nackte Zahl mehr an den beiden alten Stellen. + expect($update)->not->toContain('HOST_STEP_NEEDS=3') + ->and($agent)->not->toContain('(( have < 3 ))'); +}); + +it('answers the needed contract version from the shell', function () { + $result = Process::path(base_path())->timeout(30)->run( + 'bash -c '.escapeshellarg('set -Eeuo pipefail; . deploy/lib/release.sh; release_host_step_needs') + ); + + expect($result->exitCode())->toBe(0) + ->and(trim($result->output()))->toMatch('/^[0-9]+$/'); +}); + +it('reports the helper as not ok when the host has an older one', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'host_step_contract' => 2, + 'host_step_needs' => 3, + ])); + + $state = app(UpdateChannel::class)->state(); + + expect($state['host_step_ok'])->toBeFalse() + ->and($state['host_step_have'])->toBe(2) + ->and($state['host_step_needs'])->toBe(3); +}); + +it('reports the helper as ok when it is current', function () { + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'host_step_contract' => 3, + 'host_step_needs' => 3, + ])); + + expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue(); +}); + +it('does not cry wolf when the agent has not reported yet', function () { + // Ein Wirt, dessen Agent die Zahlen noch nie gemeldet hat (alte Fassung, + // erster Lauf), darf nicht als kaputt dastehen. „Ich weiß es nicht" ist + // nicht dasselbe wie „zu alt". + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode(['state' => 'idle'])); + + expect(app(UpdateChannel::class)->state()['host_step_ok'])->toBeTrue(); +}); From 55afbf133d082fce2251cea768cfa78427a3d00c Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:22:05 +0200 Subject: [PATCH 4/8] =?UTF-8?q?UpdateLockReleaseOnTheHostTest=20an=20die?= =?UTF-8?q?=20eine=20Stelle=20f=C3=BCr=20die=20Vertragsversion=20anpassen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 hat HOST_STEP_NEEDS=3 in update.sh durch release_host_step_needs() ersetzt (deploy/lib/release.sh). Dieser Test prüfte bislang den nackten literalen String und wäre sonst der einzige verbliebene Ort, der die alte Verdopplung verlangt. --- tests/Feature/UpdateLockReleaseOnTheHostTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Feature/UpdateLockReleaseOnTheHostTest.php b/tests/Feature/UpdateLockReleaseOnTheHostTest.php index 67d05ea..e12c0ad 100644 --- a/tests/Feature/UpdateLockReleaseOnTheHostTest.php +++ b/tests/Feature/UpdateLockReleaseOnTheHostTest.php @@ -223,5 +223,9 @@ it('grants the new step in sudoers and raises the contract for it', function () expect($installer)->toContain('release-update-lock)') ->and($installer)->toContain('$HOST_STEP release-update-lock') ->and($installer)->toContain('CONTRACT=3') - ->and($updater)->toContain('HOST_STEP_NEEDS=3'); + // Die gebrauchte Vertragsversion steht seit deploy/lib/release.sh nur + // noch an einer Stelle (release_host_step_needs); update.sh fragt sie + // ab, statt sie ein zweites Mal als Zahl mitzuführen. Siehe + // tests/Feature/HostStepContractTest.php. + ->and($updater)->toContain('release_host_step_needs'); }); From a51220f2deaced39ab06079de8ebb0bb0d6e0cfb Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:42:25 +0200 Subject: [PATCH 5/8] HostStepTest: Vertragsversion zur Laufzeit vergleichen statt aus Text ziehen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seit Task 2 steht in update.sh HOST_STEP_NEEDS="$(release_host_step_needs)" statt einer nackten Zahl. Str::between() zog daraufhin die Kommandoersetzung selbst aus dem Text, (int) davon war 0 — der Test verglich 3 gegen 0 und war rot. Der vorgegebene Filter HostStepContract|... traf HostStepTest.php nicht (falscher Teilstring), deshalb fiel das erst im Review auf. Die rechte Seite des Vergleichs ruft jetzt release_host_step_needs() aus deploy/lib/release.sh tatsächlich per Shell auf (gleiches Muster wie HostStepContractTest), statt sie aus update.sh herauszulesen. Damit kommen beide verglichenen Zahlen aus zwei verschiedenen Dateien (install-agent.sh CONTRACT vs. release.sh release_host_step_needs) und die Kopplung ist wieder erzwungen — verifiziert, indem CONTRACT testweise auf 4 gesetzt wurde und der Test daraufhin rot wurde ("4 is identical to 3"), dann zurückgesetzt. --- tests/Feature/HostStepTest.php | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/Feature/HostStepTest.php b/tests/Feature/HostStepTest.php index 5e165c8..ed7019d 100644 --- a/tests/Feature/HostStepTest.php +++ b/tests/Feature/HostStepTest.php @@ -130,9 +130,31 @@ it('reports the contract version the updater compares against', function () { // failure mode this number exists for; if update.sh asked for a version the // current installer never writes, every up-to-date server would be told to // run the installer again forever. - $needs = Str::between(file_get_contents(base_path('deploy/update.sh')), 'HOST_STEP_NEEDS=', "\n"); + // + // Compared at RUNTIME against deploy/lib/release.sh's + // release_host_step_needs(), not by pulling the literal `HOST_STEP_NEEDS=` + // line out of update.sh: since Task 2 update.sh no longer carries the bare + // number itself, it asks that shell function for it — + // `HOST_STEP_NEEDS="$(release_host_step_needs)"`. Extracting the text + // between `HOST_STEP_NEEDS=` and the newline would hand back the command + // substitution string, not a number, and this test would silently compare + // "3" against "0" forever without ever catching the two halves drifting + // apart. The left side (CONTRACT) and the right side (release_host_step_needs) + // come from two different files — install-agent.sh and deploy/lib/release.sh + // — so raising one without the other still turns this test red. + $needsProcess = Process::fromShellCommandline( + 'bash -c '.escapeshellarg( + 'set -Eeuo pipefail; . '.escapeshellarg(base_path('deploy/lib/release.sh')).'; release_host_step_needs' + ) + ); + $needsProcess->run(); - expect((int) trim($process->getOutput()))->toBe((int) trim($needs)); + expect($needsProcess->getExitCode())->toBe(0); + + $needs = trim($needsProcess->getOutput()); + + expect($needs)->toMatch('/^\d+$/') + ->and((int) trim($process->getOutput()))->toBe((int) $needs); }); it('grants one command line, not a script the service account can rewrite', function () { From 9d1811beea8d26128c50a301a9e0970ffc4e9c68 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:59:39 +0200 Subject: [PATCH 6/8] Tunnel-Rettung aus der Konsole, Waechter und Wirt-Helfer sichtbar Neue Anfrageart rescue-tunnel im bestehenden Update-Postkasten (kein zweiter Weg): UpdateChannel::requestRescueTunnel(), der Agent fuehrt deploy/rescue-tunnel.sh mit Frist aus, die Konsole zeigt Ergebnis, Waechter-Stand und Wirt-Helfer-Vertrag in der Update-Karte, Bestaetigung im Modal (R23) nach dem Muster von ConfirmReleaseUpdateLock. Zusatzfix in deploy/watchdog.sh: der wg0-Block meldete geheilt=true schon, bevor geprueft war, ob wg-quick up wg0 wirklich gewirkt hat -- ein fehlgeschlagener Tunnelaufbau haette der Konsole "healed" vorgemacht. geheilt wird jetzt erst nach der zweiten wg-show-Probe gesetzt, mit Regressionstest in WatchdogVisibilityTest. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/ConfirmRescueTunnel.php | 33 +++++++++ app/Livewire/Admin/Settings.php | 26 +++++++ app/Services/Deployment/UpdateChannel.php | 26 +++++++ deploy/update-agent.sh | 28 +++++++ deploy/watchdog.sh | 8 +- lang/de/admin_settings.php | 23 ++++++ lang/en/admin_settings.php | 23 ++++++ .../admin/confirm-rescue-tunnel.blade.php | 17 +++++ .../views/livewire/admin/settings.blade.php | 39 ++++++++++ tests/Feature/RescueTunnelTest.php | 73 +++++++++++++++++++ tests/Feature/WatchdogVisibilityTest.php | 70 ++++++++++++++++++ 11 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 app/Livewire/Admin/ConfirmRescueTunnel.php create mode 100644 resources/views/livewire/admin/confirm-rescue-tunnel.blade.php create mode 100644 tests/Feature/RescueTunnelTest.php diff --git a/app/Livewire/Admin/ConfirmRescueTunnel.php b/app/Livewire/Admin/ConfirmRescueTunnel.php new file mode 100644 index 0000000..fb3fdb2 --- /dev/null +++ b/app/Livewire/Admin/ConfirmRescueTunnel.php @@ -0,0 +1,33 @@ +authorize('site.manage'); + } + + public function confirm(): void + { + $this->authorize('site.manage'); + + $this->dispatch('rescue-tunnel-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-rescue-tunnel'); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 77baa34..11e99a7 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -12,6 +12,7 @@ use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; use App\Services\Deployment\UpdateChannel; use App\Services\Deployment\UpdateWindow; +use App\Services\Deployment\WatchdogLog; use App\Services\Terminal\ServerIdentity; use App\Services\Terminal\ServerTerminalSetup; use App\Services\Terminal\ServerTicket; @@ -814,6 +815,28 @@ class Settings extends Component : 'admin_settings.release_lock_already_requested')); } + /** + * Den Tunnel wieder hinbekommen. + * + * Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht + * hier, wie bei jeder anderen Handlung dieser Seite. + */ + #[On('rescue-tunnel-confirmed')] + public function rescueTunnel(): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + $accepted = app(UpdateChannel::class)->requestRescueTunnel($operator->email); + + $this->dispatch('notify', message: __($accepted + ? 'admin_settings.rescue_requested' + : 'admin_settings.update_already_requested')); + } + public function render() { $operator = $this->currentOperator(); @@ -838,6 +861,9 @@ class Settings extends Component return view('livewire.admin.settings', [ 'update' => app(UpdateChannel::class)->state(), 'updateLog' => app(UpdateChannel::class)->lastLog(), + // Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — + // er redet ins Journal, und das liegt auf dem Wirt. + 'watchdog' => app(WatchdogLog::class)->lastRun(), 'sitePublic' => AppSettings::bool('site.public', true), 'canManageSite' => $operator?->can('site.manage') ?? false, // Shown so nobody has to guess why they still see the real site. diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 58eb505..77f4719 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -142,6 +142,18 @@ final class UpdateChannel */ private const ARCHIVE_KEY = 'deploy/archive-key.json'; + /** + * Den Tunnel wieder hinbekommen. + * + * `deploy/rescue-tunnel.sh` stand in zwei Runbooks als Handarbeit. Es + * läuft als Dienstbenutzer und tut ausdrücklich nichts Zerstörerisches — + * kein Neubau, kein Neustart des Tunnel-Containers, weil genau das die + * Ursache wäre und nicht die Lösung. + */ + private const KIND_RESCUE_TUNNEL = 'rescue-tunnel'; + + private const RESCUE_LAST_RUN = 'deploy/rescue-last-run.json'; + /** Written by the agent after every check and every run. */ private const STATUS = 'deploy/update-status.json'; @@ -407,6 +419,11 @@ final class UpdateChannel 'last_restart_state' => $restartLastRun['state'] ?? null, 'last_restart_finished_at' => $this->timestamp($restartLastRun['finished_at'] ?? null), 'last_restart_error' => $this->errorMessage($restartLastRun), + + // `readJson` fängt kaputtes JSON bereits ab und liefert `[]`. + 'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== [] + ? $last + : null, ]; } @@ -604,6 +621,15 @@ final class UpdateChannel return $this->submit($by, self::KIND_CHECK); } + /** + * Den Tunnel wieder hinbekommen — kein zweiter Weg, derselbe Postkasten + * wie jede andere Anfrageart. + */ + public function requestRescueTunnel(string $by): bool + { + return $this->submit($by, self::KIND_RESCUE_TUNNEL); + } + /** * Den Server auf eine Version festnageln — oder die Decke abnehmen. * diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 77f4b0e..7ac9f8c 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -681,6 +681,34 @@ if [[ "$REQUEST_KIND" == "proxy-hosts" ]]; then exit 0 fi +if [[ "$REQUEST_KIND" == "rescue-tunnel" ]]; then + # Läuft als derselbe Dienstbenutzer wie dieser Agent — genau so, wie das + # Runbook es von Hand vorsah. Das Skript weigert sich als root zu laufen. + # + # Frist, weil dieser Aufruf die Sperre hält: die Tunnel-Rettung fasst + # `docker compose exec` an, und ein hängender Docker-Daemon hätte den + # Agenten sonst stundenlang blockiert — dieselbe Ausfallart, die diese + # Anlage schon zweimal hatte. + RESCUE_STATE=ok + RESCUE_ERROR='' + if ! timeout -k 10 180 bash "$ROOT/deploy/rescue-tunnel.sh" > "$STATE_DIR/rescue-last-run.log" 2>&1; then + RESCUE_STATE=failed + RESCUE_ERROR=rescue_failed + fi + + cat > "$STATE_DIR/rescue-last-run.json.tmp" </dev/null 2>&1 || true - geheilt=true + # `geheilt` erst HIER, nach dem zweiten Blick: die Konsole + # zeigt `outcome` inzwischen einem Menschen (Task 3). Vorher + # stand die Zeile vor dieser Pruefung — ein fehlgeschlagenes + # `wg-quick up wg0` meldete sich trotzdem als "healed", weil + # der Text im ACHTUNG-Zweig darunter das `outcome` nicht mehr + # aendern konnte. Der Satz stimmte, das Feld nicht. if frist docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then say "wg0 steht wieder." + geheilt=true else say "ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md." fi diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 541f2d8..b30c2a8 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -139,6 +139,18 @@ return [ 'update_agent_blocked' => 'Der Update-Dienst läuft, kommt aber seit :since nicht an die Arbeit — ein anderer Vorgang hält die Sperre. Solange das so ist, stammen die Angaben oben von vorher. Löst es sich nicht von selbst, hilft ein Blick auf den Prozess unten.', 'update_log' => 'Protokoll des letzten Laufs', + // ── Der Wächter ────────────────────────────────────────────────────── + // Redet bisher nur ins Journal auf dem Wirt — die Konsole sah ihn bis + // August 2026 gar nicht (App\Services\Deployment\WatchdogLog). + 'watchdog_idle' => 'Zuletzt nachgesehen :when — nichts zu tun.', + 'watchdog_healed' => 'Zuletzt eingegriffen :when: :what', + 'watchdog_stood_down' => ':when zurückgetreten, weil ein Update lief.', + 'watchdog_stale' => 'Der Wächter hat sich seit :when nicht gemeldet — er läuft vermutlich nicht mehr.', + + // ── Der Wirt-Helfer ────────────────────────────────────────────────── + // Der root-eigene Helfer auf dem Wirt (UpdateChannel::state()['host_step_ok']). + 'host_step_old' => 'Der Wirt-Helfer erfüllt Vertrag :have, gebraucht wird :needs. Einmalig auf diesem Wirt ausführen:', + // ── Die Sperre lösen ───────────────────────────────────────────────── // Der Griff, der bisher per SSH auf dem Wirt lag. Er beendet einen // laufenden Prozess, deshalb nennt die Rückfrage ihn vorher beim Namen. @@ -152,6 +164,17 @@ return [ 'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.', 'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.', + // ── Tunnel-Rettung ─────────────────────────────────────────────────── + // deploy/rescue-tunnel.sh stand bisher in zwei Runbooks als Handarbeit. + // Derselbe Postkasten wie jede andere Anfrageart + // (UpdateChannel::KIND_RESCUE_TUNNEL) — kein zweiter Weg. + 'rescue_action' => 'Tunnel retten', + 'rescue_requested' => 'Tunnel-Rettung angefordert.', + 'rescue_title' => 'Den Tunnel jetzt retten?', + 'rescue_body' => 'Zieht das WireGuard-Interface auf dem Tunnel-Server neu hoch. Tut ausdrücklich nichts Zerstörerisches — kein Neubau, kein Neustart des Tunnel-Containers.', + 'rescue_cancel' => 'Abbrechen', + 'rescue_confirm' => 'Tunnel retten', + // ── Festnageln ──────────────────────────────────────────────────────── // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt // (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index ada1cd6..c8880c2 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -136,6 +136,18 @@ return [ 'update_agent_blocked' => 'The update service is running but has been unable to work since :since — another process holds the lock. While that lasts, the figures above are from before. If it does not clear on its own, look at the process named below.', 'update_log' => 'Log of the last run', + // ── The watchdog ───────────────────────────────────────────────────── + // It used to talk only to the host's journal — the console could not see + // it until August 2026 (App\Services\Deployment\WatchdogLog). + 'watchdog_idle' => 'Last checked :when — nothing to do.', + 'watchdog_healed' => 'Last intervened :when: :what', + 'watchdog_stood_down' => 'Stood down :when because an update was running.', + 'watchdog_stale' => 'The watchdog has not reported in since :when — it is probably no longer running.', + + // ── The host helper ────────────────────────────────────────────────── + // The root-owned helper on the host (UpdateChannel::state()['host_step_ok']). + 'host_step_old' => 'The host helper meets contract :have, this version needs :needs. Run once on this host:', + // ── Releasing the lock ─────────────────────────────────────────────── // The handle that used to live behind an SSH session. It ends a running // process, so the confirmation names it before it does. @@ -149,6 +161,17 @@ return [ 'release_lock_requested' => 'Releasing — the service reports back within a minute.', 'release_lock_already_requested' => 'Already requested — please wait a moment.', + // ── Rescuing the tunnel ────────────────────────────────────────────── + // deploy/rescue-tunnel.sh used to be manual work written down in two + // runbooks. The same mailbox as every other request kind + // (UpdateChannel::KIND_RESCUE_TUNNEL) — no second path. + 'rescue_action' => 'Rescue tunnel', + 'rescue_requested' => 'Tunnel rescue requested.', + 'rescue_title' => 'Rescue the tunnel now?', + 'rescue_body' => 'Raises the WireGuard interface on the tunnel server again. Deliberately does nothing destructive — no rebuild, no restart of the tunnel container.', + 'rescue_cancel' => 'Cancel', + 'rescue_confirm' => 'Rescue tunnel', + // ── Pinning a ceiling ──────────────────────────────────────────────── // The ceiling that limits both the update agent AND the maintenance // window (App\Services\Deployment\UpdateChannel::setCeiling()). Only diff --git a/resources/views/livewire/admin/confirm-rescue-tunnel.blade.php b/resources/views/livewire/admin/confirm-rescue-tunnel.blade.php new file mode 100644 index 0000000..8b0edea --- /dev/null +++ b/resources/views/livewire/admin/confirm-rescue-tunnel.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('admin_settings.rescue_title') }}

+

{{ __('admin_settings.rescue_body') }}

+
+
+
+ {{ __('admin_settings.rescue_cancel') }} + + {{ __('admin_settings.rescue_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index a24c59f..0c6563f 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -117,6 +117,35 @@
{{ $update['checked_at']->diffForHumans() }}
@endif + + {{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet + ins Journal, und das liegt auf dem Wirt. --}} + @if ($watchdog) +

+ @if ($watchdog['stale']) + {{ __('admin_settings.watchdog_stale', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @elseif ($watchdog['outcome'] === 'healed') + {{ __('admin_settings.watchdog_healed', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} + @elseif ($watchdog['outcome'] === 'stood_down') + {{ __('admin_settings.watchdog_stood_down', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @else + {{ __('admin_settings.watchdog_idle', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @endif +

+ @endif + + {{-- Der Wirt-Helfer. Steht VOR den Knoepfen, nicht als Fehler danach: wer + gleich „Sperre loesen" drueckt, soll vorher wissen, dass es scheitern + wird. --}} + @unless ($update['host_step_ok']) + + {{ __('admin_settings.host_step_old', [ + 'have' => $update['host_step_have'], + 'needs' => $update['host_step_needs'], + ]) }} + sudo bash /opt/clupilot/deploy/install-agent.sh + + @endunless {{-- Bound, not @disabled(): the directive compiles to inline PHP @@ -174,6 +203,16 @@ {{ __('admin_settings.update_now') }} + {{-- Tunnel-Rettung, in derselben Handlungsleiste wie die übrigen — + dieselbe Begründung wie beim Festnageln daneben (flex-wrap, + w-full sm:w-auto). Bestätigt im Modal (R23), weil es einen + laufenden Zustand auf dem Wirt anfasst. --}} + + + {{ __('admin_settings.rescue_action') }} + + {{-- Festnageln, in derselben Handlungsleiste wie Prüfen/Aktualisieren statt einer zweiten Leiste daneben — dieselbe Begründung wie bei den beiden Knöpfen selbst (flex-wrap, w-full sm:w-auto). Kein diff --git a/tests/Feature/RescueTunnelTest.php b/tests/Feature/RescueTunnelTest.php new file mode 100644 index 0000000..862257d --- /dev/null +++ b/tests/Feature/RescueTunnelTest.php @@ -0,0 +1,73 @@ +requestRescueTunnel('chef@example.com'))->toBeTrue(); + + $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); + + expect($request['kind'])->toBe('rescue-tunnel') + ->and($request['requested_by'])->toBe('chef@example.com'); +}); + +it('takes only one request at a time', function () { + $channel = app(UpdateChannel::class); + + expect($channel->requestRescueTunnel('chef@example.com'))->toBeTrue() + ->and($channel->requestRescueTunnel('chef@example.com'))->toBeFalse(); +}); + +it('rescues the tunnel from the console', function () { + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->call('rescueTunnel'); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue(); +}); + +it('refuses to rescue without site.manage', function () { + // Eine Livewire-Aktion ist ein oeffentlicher Endpunkt. + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(Settings::class) + ->call('rescueTunnel') + ->assertForbidden(); +}); + +it('reads the outcome without falling over when it is rubbish', function () { + File::put(storage_path('app/deploy/rescue-last-run.json'), 'kein json {'); + + expect(app(UpdateChannel::class)->state()['rescue_last_run'])->toBeNull(); +}); + +it('agent knows the kind', function () { + // Der Agent muss die Art kennen, sonst liegt die Anfrage bis zum Ablauf + // im Postkasten und niemand erfaehrt warum. + expect(File::get(base_path('deploy/update-agent.sh'))) + ->toContain('rescue-tunnel') + ->toContain('rescue-tunnel.sh'); +}); diff --git a/tests/Feature/WatchdogVisibilityTest.php b/tests/Feature/WatchdogVisibilityTest.php index 718e833..e5485ae 100644 --- a/tests/Feature/WatchdogVisibilityTest.php +++ b/tests/Feature/WatchdogVisibilityTest.php @@ -61,6 +61,58 @@ function runWatchdog(string $running = "app\nredis", bool $holdLock = false): ar return json_decode(File::get($dir.'/watchdog-last-run.json'), true); } +/** + * Der wg0-Block, isoliert: vpn-hub laeuft, seine Konfiguration existiert, + * "wg show wg0" scheitert — der Waechter darf eingreifen und ruft + * "wg-quick up wg0". Ob der Griff wirklich gewirkt hat, entscheidet + * $recovers: danach meldet "wg show wg0" entweder wieder oben (true) oder + * weiterhin unten (false) — genau der Fall aus dem Task-1-Review, in dem + * `geheilt=true` VOR dieser zweiten Pruefung stand. + */ +function runWatchdogTunnelRescue(bool $recovers): array +{ + $dir = storage_path('app/deploy'); + File::ensureDirectoryExists($dir); + File::delete(File::glob($dir.'/*')); + + $stub = $dir.'/stub'; + File::ensureDirectoryExists($stub); + + $wgShowAfterUp = $recovers ? 'exit 0' : 'exit 1'; + + File::put($stub.'/docker', <<timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'STUB_UP_CALLED' => $dir.'/.stub-up-called', + 'STUB_WG_UP_CALLED' => $dir.'/.stub-wg-up-called', + ])->run('bash deploy/watchdog.sh >/dev/null 2>&1 || true'); + + 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')); }); @@ -91,6 +143,24 @@ it('records that it stood down because the lock was held', function () { expect($run['outcome'])->toBe('stood_down'); }); +it('reports healed when raising wg0 actually brought the tunnel back', function () { + $run = runWatchdogTunnelRescue(recovers: true); + + expect($run['outcome'])->toBe('healed') + ->and(implode(' ', $run['actions']))->toContain('wg0 steht wieder'); +}); + +it('does not claim healed when wg-quick ran but the tunnel stayed down', function () { + // Der Befund aus dem Task-1-Review: `geheilt` wurde wahr, bevor geprueft + // war, ob `wg-quick up wg0` ueberhaupt gewirkt hat. Die Konsole zeigt + // `outcome` jetzt einem Menschen — eine Luege hier waere ein Fehler, kein + // Journal-Detail mehr. + $run = runWatchdogTunnelRescue(recovers: false); + + expect($run['outcome'])->not->toBe('healed') + ->and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen'); +}); + it('reads nothing rather than falling over when the file is absent', function () { File::ensureDirectoryExists(storage_path('app/deploy')); From 1a5843670c3b274710d708b4fb4e2157d2539477 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 18:22:01 +0200 Subject: [PATCH 7/8] Fix-Runde 1 (Task 3): Waechter-Fehlschlag sichtbar, Tunnel-Rettung nicht mehr stumm CRITICAL: der wg0-Fix aus Runde 0 (geheilt=true erst nach der zweiten Probe) liess einen fehlgeschlagenen Rettungsversuch auf outcome=idle fallen -- die einzige Zeile, die actions zeigte, war der healed-Zweig. Die Konsole meldete "nichts zu tun", waehrend kein Host erreichbar war. Neuer @elseif ($watchdog['actions'])-Zweig vor @else, text-warning, Schluessel watchdog_tried (de/en). Kein Eingriff in watchdog.sh noetig. IMPORTANT: rescue_last_run wurde geschrieben, aber von keinem Blade gelesen, und write_status idle (ohne Argument) verschluckte den Agentenfehler. write_status idle "$RESCUE_ERROR" wie beim proxy-hosts- Zweig daneben; UpdateChannel::state() liefert rescue_last_run jetzt strukturiert (finished_at als Carbon, error uebersetzt); neue Zeile in der Update-Karte, neuer Uebersetzungscode update_error.rescue_failed. Fuenf neue Tests, alle nachweislich rot gegen den vorherigen Stand (per git stash isoliert geprueft) und gruen mit dem Fix. Co-Authored-By: Claude Opus 5 --- app/Services/Deployment/UpdateChannel.php | 14 ++++- deploy/update-agent.sh | 9 ++- lang/de/admin_settings.php | 8 +++ lang/en/admin_settings.php | 8 +++ .../views/livewire/admin/settings.blade.php | 29 +++++++++- tests/Feature/RescueTunnelTest.php | 58 +++++++++++++++++++ tests/Feature/WatchdogVisibilityTest.php | 30 ++++++++++ 7 files changed, 153 insertions(+), 3 deletions(-) diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 77f4719..9aaf55f 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -421,8 +421,20 @@ final class UpdateChannel 'last_restart_error' => $this->errorMessage($restartLastRun), // `readJson` fängt kaputtes JSON bereits ab und liefert `[]`. + // + // Fix-Runde 1: dieses Feld existierte, aber kein Blade las es — + // ein gescheiterter Rettungsversuch war der Konsole unsichtbar, + // genau der Zustand, aus dem dieser Knopf befreien sollte. + // `finished_at` geht wie überall hier durch `timestamp()`, damit + // die View nicht selbst parsen muss; `error` durch dieselbe + // `errorMessage()`, die auch STATUS und LAST_RUN übersetzt — + // `rescue_failed` steht dafür jetzt in `update_error`. 'rescue_last_run' => ($last = $this->readJson(self::RESCUE_LAST_RUN)) !== [] - ? $last + ? [ + 'state' => $last['state'] ?? null, + 'finished_at' => $this->timestamp($last['finished_at'] ?? null), + 'error' => $this->errorMessage($last), + ] : null, ]; } diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 7ac9f8c..4fc6976 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -705,7 +705,14 @@ if [[ "$REQUEST_KIND" == "rescue-tunnel" ]]; then EOF mv -f "$STATE_DIR/rescue-last-run.json.tmp" "$STATE_DIR/rescue-last-run.json" - write_status idle + # Fix-Runde 1: `write_status idle` (ohne zweites Argument) verschluckte + # RESCUE_ERROR — ein Fehlschlag stand zwar in rescue-last-run.json, aber + # `update-status.json`s `error`-Feld blieb leer. Wie proxy-hosts daneben + # (Zeile ~674) wird der Fehler hier durchgereicht, auch wenn er wie bei + # jedem anderen Zweig hier nur bis zum naechsten Takt sichtbar bleibt — + # die dauerhafte Ablage ist rescue-last-run.json selbst, das die Konsole + # jetzt separat liest (UpdateChannel::state()['rescue_last_run']). + write_status idle "$RESCUE_ERROR" exit 0 fi diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index b30c2a8..28ca3ba 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -145,6 +145,10 @@ return [ 'watchdog_idle' => 'Zuletzt nachgesehen :when — nichts zu tun.', 'watchdog_healed' => 'Zuletzt eingegriffen :when: :what', 'watchdog_stood_down' => ':when zurückgetreten, weil ein Update lief.', + // Fix-Runde 1: gegriffen, aber ohne Erfolg — z. B. wg0 blieb trotz + // wg-quick up unten. Ohne diesen Satz verschwand der Fall in + // „nichts zu tun" (watchdog_idle), weil outcome dafür weiterhin idle ist. + 'watchdog_tried' => 'Zuletzt eingegriffen :when, ohne Erfolg: :what', 'watchdog_stale' => 'Der Wächter hat sich seit :when nicht gemeldet — er läuft vermutlich nicht mehr.', // ── Der Wirt-Helfer ────────────────────────────────────────────────── @@ -174,6 +178,9 @@ return [ 'rescue_body' => 'Zieht das WireGuard-Interface auf dem Tunnel-Server neu hoch. Tut ausdrücklich nichts Zerstörerisches — kein Neubau, kein Neustart des Tunnel-Containers.', 'rescue_cancel' => 'Abbrechen', 'rescue_confirm' => 'Tunnel retten', + // Fix-Runde 1: das Ergebnis war bisher unsichtbar — UpdateChannel::state() + // lieferte `rescue_last_run`, aber kein Blade las es. + 'rescue_last_run' => 'Letzte Tunnel-Rettung: :state, :when.', // ── Festnageln ──────────────────────────────────────────────────────── // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt @@ -204,6 +211,7 @@ return [ 'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.', 'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.', 'restart_failed' => 'Der Neustart der Dienste ist fehlgeschlagen. Bitte manuell ausführen: docker compose restart queue scheduler reverb.', + 'rescue_failed' => 'Die Tunnel-Rettung ist fehlgeschlagen. Protokoll auf dem Wirt: storage/app/deploy/rescue-last-run.log.', // Der Wirt kennt den Schritt noch nicht. Ein Knopf, der nichts tut und // nichts sagt, schickt den Betreiber genau dorthin zurück, wo er ohne // die Konsole schon war. diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index c8880c2..d4e8155 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -142,6 +142,10 @@ return [ 'watchdog_idle' => 'Last checked :when — nothing to do.', 'watchdog_healed' => 'Last intervened :when: :what', 'watchdog_stood_down' => 'Stood down :when because an update was running.', + // Fix round 1: intervened, but without success — e.g. wg0 stayed down + // despite wg-quick up. Without this sentence the case disappeared into + // "nothing to do" (watchdog_idle), because outcome is still idle. + 'watchdog_tried' => 'Last intervened :when, without success: :what', 'watchdog_stale' => 'The watchdog has not reported in since :when — it is probably no longer running.', // ── The host helper ────────────────────────────────────────────────── @@ -171,6 +175,9 @@ return [ 'rescue_body' => 'Raises the WireGuard interface on the tunnel server again. Deliberately does nothing destructive — no rebuild, no restart of the tunnel container.', 'rescue_cancel' => 'Cancel', 'rescue_confirm' => 'Rescue tunnel', + // Fix round 1: the outcome used to be invisible — UpdateChannel::state() + // returned `rescue_last_run`, but no blade read it. + 'rescue_last_run' => 'Last tunnel rescue: :state, :when.', // ── Pinning a ceiling ──────────────────────────────────────────────── // The ceiling that limits both the update agent AND the maintenance @@ -201,6 +208,7 @@ return [ 'detached_no_release' => 'The checkout is on no branch and pinned to no release.', 'update_failed' => 'The update failed (code :code). See the log.', 'restart_failed' => 'Restarting the services failed. Please run manually: docker compose restart queue scheduler reverb.', + 'rescue_failed' => 'The tunnel rescue failed. Log on the host: storage/app/deploy/rescue-last-run.log.', // The host does not know the step yet. A button that does nothing and // says nothing sends the operator right back where they were without // the console. diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 0c6563f..7c41b19 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -121,13 +121,21 @@ {{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet ins Journal, und das liegt auf dem Wirt. --}} @if ($watchdog) -

+ {{-- Fix-Runde 1 (Task 3): ein idle-Ausgang MIT Aktionen ist kein + "nichts zu tun" — der Waechter hat gegriffen, der Griff hat nur + nicht gewirkt (z. B. wg0 blieb trotz wg-quick up unten, siehe + watchdog.sh). Vorher fiel genau dieser Fall auf `watchdog_idle` + durch, weil das die einzige Verzweigung ohne `outcome`-Pruefung + war — eine ruhige graue Zeile waehrend kein Host erreichbar ist. --}} +

@if ($watchdog['stale']) {{ __('admin_settings.watchdog_stale', ['when' => $watchdog['at']->local()->diffForHumans()]) }} @elseif ($watchdog['outcome'] === 'healed') {{ __('admin_settings.watchdog_healed', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} @elseif ($watchdog['outcome'] === 'stood_down') {{ __('admin_settings.watchdog_stood_down', ['when' => $watchdog['at']->local()->diffForHumans()]) }} + @elseif ($watchdog['actions']) + {{ __('admin_settings.watchdog_tried', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} @else {{ __('admin_settings.watchdog_idle', ['when' => $watchdog['at']->local()->diffForHumans()]) }} @endif @@ -422,6 +430,25 @@

@endif + {{-- Fix-Runde 1 (Task 3): das Ergebnis der Tunnel-Rettung. Der Agent + schrieb `rescue-last-run.json` von Anfang an — nur las kein Blade + es. Ein Betreiber, der „Tunnel retten" drueckt und dessen Versuch + auf dem Wirt scheitert (oder in die Frist laeuft), sah danach + nichts: kein Fehler, kein Zeitstempel, kein Hinweis aufs + Protokoll — er musste auf den Wirt, genau dorthin, wovon dieser + Knopf ihn befreien sollte. --}} + @if ($update['rescue_last_run']) +

+ {{ __('admin_settings.rescue_last_run', [ + 'state' => __('admin_settings.update_state.'.($update['rescue_last_run']['state'] === 'ok' ? 'succeeded' : 'failed')), + 'when' => $update['rescue_last_run']['finished_at']?->diffForHumans() ?? '—', + ]) }} + @if ($update['rescue_last_run']['error']) + · {{ $update['rescue_last_run']['error'] }} + @endif +

+ @endif + {{-- Open while it runs: "läuft gerade" on its own tells an operator nothing about whether it is progressing or wedged. --}} @if ($updateLog) diff --git a/tests/Feature/RescueTunnelTest.php b/tests/Feature/RescueTunnelTest.php index 862257d..08041c4 100644 --- a/tests/Feature/RescueTunnelTest.php +++ b/tests/Feature/RescueTunnelTest.php @@ -71,3 +71,61 @@ it('agent knows the kind', function () { ->toContain('rescue-tunnel') ->toContain('rescue-tunnel.sh'); }); + +it('agent passes the rescue error through instead of swallowing it', function () { + // Fix-Runde 1: `write_status idle` (ohne zweites Argument) verschluckte + // RESCUE_ERROR. Der Zweig muss den Fehler weiterreichen, wie proxy-hosts + // es daneben schon tut. + expect(File::get(base_path('deploy/update-agent.sh'))) + ->toContain('write_status idle "$RESCUE_ERROR"'); +}); + +it('shapes the last rescue outcome for the view, with a local timestamp and a translated error', function () { + // Fix-Runde 1: das Feld existierte, aber nichts las es. Hier der + // Nachweis auf Datenebene, dass die Form stimmt, die das Blade braucht — + // `finished_at` als Carbon (R19: die View parst nicht selbst) und + // `error` schon uebersetzt (dieselbe errorMessage(), die auch STATUS und + // LAST_RUN uebersetzt). + File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([ + 'state' => 'failed', + 'finished_at' => now()->utc()->toIso8601String(), + 'error' => 'rescue_failed', + ])); + + $run = app(UpdateChannel::class)->state()['rescue_last_run']; + + expect($run['state'])->toBe('failed') + ->and($run['finished_at'])->toBeInstanceOf(\Illuminate\Support\Carbon::class) + ->and($run['error'])->toBe(__('admin_settings.update_error.rescue_failed')); +}); + +it('shows a failed rescue attempt in the console, instead of silence', function () { + // Das eigentliche Befund-Szenario: Betreiber drueckt "Tunnel retten", + // bekommt "angefordert", der Versuch scheitert auf dem Wirt. Vorher + // zeigte die Konsole danach exakt nichts. + File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([ + 'state' => 'failed', + 'finished_at' => now()->utc()->toIso8601String(), + 'error' => 'rescue_failed', + ])); + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee(__('admin_settings.update_state.failed')) + ->assertSee(__('admin_settings.update_error.rescue_failed'), false) + ->assertSeeHtml('text-danger'); +}); + +it('shows a successful rescue attempt too', function () { + File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([ + 'state' => 'ok', + 'finished_at' => now()->utc()->toIso8601String(), + 'error' => '', + ])); + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee(__('admin_settings.update_state.succeeded')); +}); diff --git a/tests/Feature/WatchdogVisibilityTest.php b/tests/Feature/WatchdogVisibilityTest.php index e5485ae..fc71646 100644 --- a/tests/Feature/WatchdogVisibilityTest.php +++ b/tests/Feature/WatchdogVisibilityTest.php @@ -1,8 +1,11 @@ and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen'); }); +it('shows the operator that an intervention failed, instead of "nothing to do"', function () { + // Fix-Runde 1 (Codex/Reviewer): auf Datenebene war der Fall (outcome + // idle, actions nicht leer) schon belegt — hier der Nachweis, dass er + // auch beim Betreiber ankommt. Vorher fiel er auf watchdog_idle durch: + // eine ruhige graue Zeile "nichts zu tun", waehrend "ACHTUNG" und "wg0" + // im HTML gar nicht mehr vorkamen. + 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' => [ + 'wg0 steht nicht — ziehe den Tunnel hoch.', + 'ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md.', + ], + ])); + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false) + ->assertSeeHtml('text-warning') + // Das war der Befund: watchdog_idle endet auf genau diesem Satzstueck, + // und es war der einzige Zweig, der auf einen outcome von "idle" ohne + // weitere Pruefung durchfiel. + ->assertDontSee('nichts zu tun'); +}); + it('reads nothing rather than falling over when the file is absent', function () { File::ensureDirectoryExists(storage_path('app/deploy')); From 8733686dd3421041667f2c32cb34787bdf46c234 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 19:09:02 +0200 Subject: [PATCH 8/8] Fix-Welle Schlussreview: Tunnel-Rettung und Waechter melden Misserfolg ehrlich C1: rescue-tunnel.sh zaehlt Probleme (kein Zugang geladen, conntrack ohne Root) und gibt sie als Exit-Code zurueck, statt bei jedem Fehlschlag Exit 0 zu melden. Die Konsole zeigt jetzt zusaetzlich das Protokoll der letzten Tunnel-Rettung (UpdateChannel::rescueLog(), dasselbe
-Muster wie das Update-Protokoll), und "erfolgreich" ist einem zurueckhaltenderen "durchgelaufen"/"completed" gewichen, das nicht mehr behauptet als das Skript wirklich weiss. I1: watchdog.sh gewann `geheilt=true` bisher unmittelbar nach `up -d`, `--force-recreate` und `artisan up`, ohne nachzupruefen, ob der Griff gewirkt hat. Ein neues `fehlgeschlagen`-Flag laesst einen Misserfolg den Lauf-Ausgang gewinnen, auch wenn ein anderer Zweig im selben Lauf erfolgreich war -- die Konsole zeigt jetzt outcome=tried statt sich hinter outcome=healed zu verstecken. M3: watchdog_stood_down nennt nicht mehr nur "ein Update" als Sperrenhalter -- die Sperre haelt inzwischen auch proxy-hosts, restart, archive-key und rescue-tunnel. M4: das Runbook nennt den Konsolen-Knopf jetzt vor der Kommandozeile. M7: ConfirmRescueTunnel hat jetzt einen Test, nach dem Vorbild von ConfirmReleaseUpdateLock -- mount()s authorize und die Ereignisverdrahtung Modal -> Seite waren ungeprueft. Volle Suite: 3036 bestanden, 0 fehlgeschlagen. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Settings.php | 5 + app/Services/Deployment/UpdateChannel.php | 35 ++++ deploy/rescue-tunnel.sh | 31 ++++ deploy/watchdog.sh | 70 +++++++- docs/runbooks/tunnel-recovery.md | 13 +- lang/de/admin_settings.php | 22 ++- lang/en/admin_settings.php | 22 ++- .../views/livewire/admin/settings.blade.php | 41 ++++- .../Feature/Admin/ConfirmRescueTunnelTest.php | 83 +++++++++ tests/Feature/RescueTunnelTest.php | 162 +++++++++++++++++- tests/Feature/WatchdogVisibilityTest.php | 107 +++++++++++- 11 files changed, 563 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/Admin/ConfirmRescueTunnelTest.php diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 11e99a7..a43eb93 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -861,6 +861,11 @@ class Settings extends Component return view('livewire.admin.settings', [ 'update' => app(UpdateChannel::class)->state(), 'updateLog' => app(UpdateChannel::class)->lastLog(), + // Review-Runde 2 (C1 Teil 2): derselbe
-Auszug wie + // updateLog, nur fürs Protokoll der letzten Tunnel-Rettung — der + // Betreiber soll den Satz mit dem Handgriff lesen können, ohne + // auf den Wirt zu gehen. + 'rescueLog' => app(UpdateChannel::class)->rescueLog(), // Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — // er redet ins Journal, und das liegt auf dem Wirt. 'watchdog' => app(WatchdogLog::class)->lastRun(), diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 9aaf55f..70910fb 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -154,6 +154,18 @@ final class UpdateChannel private const RESCUE_LAST_RUN = 'deploy/rescue-last-run.json'; + /** + * Der Protokollauszug der letzten Tunnel-Rettung. + * + * Dasselbe Muster wie LOG/lastLog() weiter unten, nur für + * deploy/rescue-tunnel.sh statt deploy/update.sh — deploy/update-agent.sh + * schreibt die volle Ausgabe des Skripts hierhin (Zeile ~694), Blade + * zeigt sie als
, wie das Protokoll der Aktualisierung auch. + * Review-Runde 2 (C1 Teil 2): der Betreiber soll den Satz mit dem + * Handgriff lesen können, ohne auf den Wirt zu gehen. + */ + private const RESCUE_LOG = 'deploy/rescue-last-run.log'; + /** Written by the agent after every check and every run. */ private const STATUS = 'deploy/update-status.json'; @@ -957,6 +969,29 @@ final class UpdateChannel } } + /** + * The tail of the last tunnel-rescue attempt — the same shape as + * lastLog() above, for RESCUE_LOG instead of LOG. Read separately from + * state(), same as `updateLog` is in App\Livewire\Admin\Settings::render(): + * this is something to show, not something to decide on. + */ + public function rescueLog(int $lines = 40): ?string + { + $path = storage_path('app/'.self::RESCUE_LOG); + + try { + if (! File::exists($path)) { + return null; + } + + $all = preg_split('/\R/', trim((string) File::get($path))) ?: []; + + return implode("\n", array_slice($all, -$lines)); + } catch (Throwable) { + return null; + } + } + /** @return array */ private function readJson(string $relative): array { diff --git a/deploy/rescue-tunnel.sh b/deploy/rescue-tunnel.sh index 94ca63a..d5a231f 100755 --- a/deploy/rescue-tunnel.sh +++ b/deploy/rescue-tunnel.sh @@ -22,6 +22,21 @@ ok() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } bad() { printf '\033[1;31m ✗\033[0m %s\n' "$*"; } +# Wie viele Stellen etwas gefunden haben, das nicht ausgerichtet ist, wie es +# sein sollte — und zwar am ENDE des Laufs, nicht dem, was ein einzelner Griff +# gerade behoben zu haben glaubt. Der Aufrufer (deploy/update-agent.sh) macht +# bisher NUR den Exit-Code zur Wahrheit; ohne diesen Zähler endete das Skript +# unter `set -uo pipefail` (kein `-e`) nach jedem `bad`/`warn` auf ein +# schlichtes `echo` weiter unten — Exit 0, auch wenn mittendrin etwas offen +# blieb. Nicht jede `bad`/`warn`-Zeile zählt: eine, die einen unmittelbar +# folgenden `exit 1` hat (Container startet nicht, wg0 laesst sich nicht +# hochziehen), braucht den Zähler nicht — der Exit-Code steht da schon fest. +# Und eine rein informative Meldung (dieser Server hat noch keinen eigenen +# Tunnel-Container) sagt nichts darüber, ob DIESER Lauf etwas nicht +# hinbekommen hat — sie stünde bei jedem Lauf auf einem älteren Wirt, auch +# wenn der Tunnel tadellos steht. +probleme=0 + if [[ $EUID -eq 0 ]]; then echo "Bitte als Dienstbenutzer starten, nicht als root:" >&2 echo " sudo -u clupilot bash $0" >&2 @@ -82,7 +97,11 @@ peers="$(hub wg show wg0 peers | grep -c . || true)" if [[ "${peers:-0}" -gt 0 ]]; then ok "$peers Zugänge geladen" else + # Nicht bloss eine Randnotiz: ohne einen einzigen Zugang tut der Tunnel + # nichts, egal wie gesund wg0 sonst aussieht. Vorher endete der Lauf hier + # trotzdem auf Exit 0. warn "Kein einziger Zugang geladen — /etc/wireguard/wg0.conf ansehen." + probleme=$((probleme + 1)) fi # ── 3. Kommt auch etwas an? ────────────────────────────────────────────────── @@ -108,12 +127,18 @@ if [[ "${stumm:-0}" -gt 0 ]]; then if sudo -n conntrack -D -p udp --dport "$WG_PORT" >/dev/null 2>&1; then ok "erledigt — die Zugänge bauen sich in den nächsten 30 Sekunden neu auf" else + # DAS Krankheitsbild, das nie von allein heilt (siehe Kopf dieser + # Datei): stumme Zugänge bleiben stumm, wenn dieser Griff scheitert. + # Der conntrack-Sudoers-Eintrag ist Handarbeit (siehe unten) und auf + # einem frischen Wirt der Normalfall, nicht die Ausnahme — genau + # deshalb darf ein gescheiterter Versuch hier nicht als Erfolg enden. bad "Dafür fehlt mir Root. Bitte einmal von Hand ausführen:" echo echo " sudo conntrack -D -p udp --dport $WG_PORT" echo warn "Damit ich das künftig selbst darf, siehe docs/runbooks/tunnel-recovery.md," warn "Abschnitt 'Einmal einrichten'." + probleme=$((probleme + 1)) fi else ok "Alle Zugänge haben schon Daten geschickt" @@ -136,3 +161,9 @@ echo " wieder auf — und muss danach zurückgenommen werden:" echo echo " /usr/local/sbin/clupilot-emergency-open-firewall.sh" echo + +# Der Aufrufer (deploy/update-agent.sh) macht den Exit-Code zur einzigen +# Wahrheitsquelle — siehe `probleme` oben. Kein Problem gezählt heisst hier +# tatsächlich Exit 0; eins oder mehr heisst, der Lauf hat nicht ausgerichtet, +# was nötig war, auch wenn er bis hierher durchgelaufen ist. +exit "$probleme" \ No newline at end of file diff --git a/deploy/watchdog.sh b/deploy/watchdog.sh index 9ee27bf..0023ae8 100755 --- a/deploy/watchdog.sh +++ b/deploy/watchdog.sh @@ -105,6 +105,16 @@ darf_eingreifen() { geheilt=false +# Ein Misserfolg muss den Ausgang gewinnen — auch wenn im selben Lauf ein +# ANDERER Griff gewirkt hat. Ohne dieses eigene Flag setzte ein erfolgreicher +# Zweig `geheilt=true`, und die Konsole zeigte "eingegriffen" in ruhiger +# Farbe, waehrend ein zweiter Zweig (z. B. wg0) im selben Lauf nicht gewirkt +# hat — der Satz, der genau fuer diesen Fall geschrieben wurde +# (watchdog_tried), wurde nie gedruckt, weil seine Bedingung bisher an +# `outcome === 'idle'` hing und `outcome` durch den erfolgreichen Zweig +# bereits `healed` war. +fehlgeschlagen=false + # Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus # einen Satz; das Journal hat weiterhin die Langfassung. AKTIONEN=() @@ -121,9 +131,21 @@ if [[ -n "$soll" ]]; then if [[ -n "$fehlt" ]] && darf_eingreifen; then say "Es fehlen Dienste: $fehlt — starte sie." lange_frist docker compose up -d >/dev/null 2>&1 || true - geheilt=true sleep 10 ist="$(frist docker compose ps --services --status running 2>/dev/null | sort || true)" + + # Zweiter Blick, wie beim wg0-Zweig weiter unten: `up -d` kann + # scheitern (Docker-Daemon nicht erreichbar, ein Abbild fehlt) und + # gab bisher trotzdem `geheilt=true` — die Konsole zeigte dann + # "eingegriffen" in ruhiger Farbe, waehrend der Stapel weiter unten + # lag. + fehlt_nach="$(comm -23 <(printf '%s\n' "$soll") <(printf '%s\n' "$ist") | tr '\n' ' ' | sed 's/ *$//')" + if [[ -z "$fehlt_nach" ]]; then + geheilt=true + else + say "ACHTUNG: es fehlen weiterhin Dienste: $fehlt_nach." + fehlgeschlagen=true + fi fi fi @@ -147,8 +169,19 @@ if printf '%s\n' "$ist" | grep -qx app && printf '%s\n' "$ist" | grep -qx redis; if darf_eingreifen && ! frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then say "Die Container finden einander nicht mehr (redis nicht auflösbar) — erzeuge sie neu." lange_frist docker compose up -d --force-recreate >/dev/null 2>&1 || true - geheilt=true sleep 15 + + # Dritter Blick: hat `--force-recreate` selbst gewirkt? Bisher + # stand `geheilt=true` schon VOR dieser Frage — derselbe Fehler + # wie beim wg0-Zweig, nur an einer teureren Stelle (dieser Griff + # reisst jede offene Verbindung ab, und tat es hier auch dann + # unwidersprochen, wenn Docker den Neubau selbst nicht schaffte). + if frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then + geheilt=true + else + say "ACHTUNG: redis ist weiterhin nicht auflösbar." + fehlgeschlagen=true + fi fi fi fi @@ -176,6 +209,14 @@ if printf '%s\n' "$ist" | grep -qx vpn-hub; then geheilt=true else say "ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md." + # Review-Runde 2: `outcome` blieb bis hierher `idle`, + # wenn kein ANDERER Zweig im selben Lauf gegriffen hatte + # — dann las die Konsole "actions" und zeigte + # watchdog_tried. Griff aber ein anderer Zweig ERFOLGREICH + # (z. B. fehlende Dienste), stand `outcome` schon auf + # `healed`, und dieser Fehlschlag verschwand darin. Das + # eigene Flag gewinnt jetzt unabhaengig davon. + fehlgeschlagen=true fi fi fi @@ -210,14 +251,24 @@ if [[ ! -f "$HOLD" ]] && frist docker compose exec -T -u www-data app test -f st && frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then say "Der Wartungsmodus haengt seit ueber einer halben Stunde ohne laufendes Update — beende ihn." lange_frist docker compose exec -T -u www-data app php artisan up >/dev/null 2>&1 || true - geheilt=true + + # Zweiter Blick, dasselbe Muster wie in den drei Zweigen oben: `up` + # kann scheitern (Docker-Daemon nicht erreichbar), und ohne diese + # Pruefung stand `geheilt=true` bereits fest, waehrend die Seite + # unten blieb. + if ! frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then + geheilt=true + else + say "ACHTUNG: der Wartungsmodus liess sich nicht beenden." + fehlgeschlagen=true + fi fi fi # Nur reden, wenn es etwas zu sagen gab. Ein Waechter, der jede Minute meldet, # dass alles in Ordnung ist, wird nach zwei Tagen nicht mehr gelesen — und dann # auch nicht mehr an dem Tag, an dem er etwas Wichtiges sagt. -if [[ "$geheilt" == true ]]; then +if [[ "$geheilt" == true || "$fehlgeschlagen" == true ]]; then say "Nachgesehen und eingegriffen." fi @@ -229,9 +280,12 @@ fi # 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: +# Vier Ausgaenge, weil sie vier verschiedene Dinge bedeuten: # idle — nachgesehen, nichts zu tun. Der Normalfall. -# healed — eingegriffen. Was, steht in `actions`. +# healed — eingegriffen, und JEDER Griff in diesem Lauf hat gewirkt. +# tried — eingegriffen, aber mindestens EIN Griff hat NICHT gewirkt — +# auch wenn ein anderer im selben Lauf erfolgreich war. Ein +# Misserfolg gewinnt den Ausgang; siehe `fehlgeschlagen` oben. # stood_down — nicht drangekommen, weil ein Update die Sperre hielt. # Betrieb, kein Fehler — aber es muss unterscheidbar sein. # @@ -239,7 +293,9 @@ fi # und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand # nachsieht. ausgang=idle -if [[ "$geheilt" == true ]]; then +if [[ "$fehlgeschlagen" == true ]]; then + ausgang=tried +elif [[ "$geheilt" == true ]]; then ausgang=healed elif [[ "$SPERRE" == verwehrt ]]; then ausgang=stood_down diff --git a/docs/runbooks/tunnel-recovery.md b/docs/runbooks/tunnel-recovery.md index b972b83..4325a4d 100644 --- a/docs/runbooks/tunnel-recovery.md +++ b/docs/runbooks/tunnel-recovery.md @@ -85,7 +85,18 @@ kommen, der einen zur Konsole bringt. --- -## Zuerst: ein Befehl, der das meiste allein macht +## Zuerst: der Knopf in der Konsole + +Einstellungen → „Tunnel retten". Derselbe Rettungsversuch wie unten, nur ohne +SSH: die Konsole legt eine Bitte ab, der Update-Agent auf dem Wirt fährt +`deploy/rescue-tunnel.sh` (dasselbe Skript, kein zweiter Weg) und meldet +zurück, was er getan hat — samt Protokollauszug, direkt unter dem Knopf. Der +Griff geht damit vom Wirt in die Konsole, wo dieser ganze Vorgang eigentlich +hingehört. + +Rückfall, wenn die Konsole selbst nicht erreichbar ist (dann zuerst zurück zu +„Die vier Wege hinein" oben) oder wenn man von Hand nachvollziehen will, was +das Skript tut: ```bash cd /opt/clupilot && sudo -u clupilot bash deploy/rescue-tunnel.sh diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 28ca3ba..d944e49 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -144,10 +144,16 @@ return [ // August 2026 gar nicht (App\Services\Deployment\WatchdogLog). 'watchdog_idle' => 'Zuletzt nachgesehen :when — nichts zu tun.', 'watchdog_healed' => 'Zuletzt eingegriffen :when: :what', - 'watchdog_stood_down' => ':when zurückgetreten, weil ein Update lief.', - // Fix-Runde 1: gegriffen, aber ohne Erfolg — z. B. wg0 blieb trotz - // wg-quick up unten. Ohne diesen Satz verschwand der Fall in - // „nichts zu tun" (watchdog_idle), weil outcome dafür weiterhin idle ist. + // M3: die Sperre halten inzwischen mehr Vorgaenge als ein Update — + // proxy-hosts, restart, archive-key, rescue-tunnel. „weil ein Update + // lief" war damit schon falsch, sobald einer der anderen die Sperre hielt. + 'watchdog_stood_down' => ':when zurückgetreten — ein anderer Vorgang hielt die Sperre.', + // Fix-Runde 1 / Review-Runde 2: gegriffen, aber mindestens ein Griff + // ohne Erfolg — auch wenn ein anderer im selben Lauf gewirkt hat (z. B. + // fehlende Dienste kamen hoch, wg0 blieb trotz wg-quick up unten). Ohne + // diesen Satz verschwand der Fall entweder in „nichts zu tun" + // (watchdog_idle) oder, seit der ersten Fix-Runde, in „eingegriffen" + // (watchdog_healed) — outcome traegt jetzt ein eigenes `tried`. 'watchdog_tried' => 'Zuletzt eingegriffen :when, ohne Erfolg: :what', 'watchdog_stale' => 'Der Wächter hat sich seit :when nicht gemeldet — er läuft vermutlich nicht mehr.', @@ -181,6 +187,14 @@ return [ // Fix-Runde 1: das Ergebnis war bisher unsichtbar — UpdateChannel::state() // lieferte `rescue_last_run`, aber kein Blade las es. 'rescue_last_run' => 'Letzte Tunnel-Rettung: :state, :when.', + // Review-Runde 2 (C1 Teil 3): eigenes Wort statt update_state.succeeded + // ("erfolgreich") — ein durchgelaufenes Skript hat nur geprüft, dass + // jeder Griff ausgerichtet hat, was nötig war (deploy/rescue-tunnel.sh), + // nicht dass der Tunnel jetzt wieder zweifelsfrei Daten empfängt. + 'rescue_state_ok' => 'durchgelaufen', + // Review-Runde 2 (C1 Teil 2): dasselbe Muster wie 'update_log', nur fürs + // Protokoll der letzten Tunnel-Rettung (UpdateChannel::rescueLog()). + 'rescue_log' => 'Protokoll der letzten Tunnel-Rettung', // ── Festnageln ──────────────────────────────────────────────────────── // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index d4e8155..f64c155 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -141,10 +141,16 @@ return [ // it until August 2026 (App\Services\Deployment\WatchdogLog). 'watchdog_idle' => 'Last checked :when — nothing to do.', 'watchdog_healed' => 'Last intervened :when: :what', - 'watchdog_stood_down' => 'Stood down :when because an update was running.', - // Fix round 1: intervened, but without success — e.g. wg0 stayed down - // despite wg-quick up. Without this sentence the case disappeared into - // "nothing to do" (watchdog_idle), because outcome is still idle. + // M3: more than an update now holds the lock — proxy-hosts, restart, + // archive-key, rescue-tunnel. "because an update was running" was already + // wrong the moment any of the others held it instead. + 'watchdog_stood_down' => 'Stood down :when — another process held the lock.', + // Fix round 1 / review round 2: intervened, but at least one attempt + // failed — even if another one in the same run worked (e.g. missing + // services came back up, wg0 stayed down despite wg-quick up). Without + // this sentence the case disappeared either into "nothing to do" + // (watchdog_idle) or, since the first fix round, into "intervened" + // (watchdog_healed) — outcome now carries its own `tried`. 'watchdog_tried' => 'Last intervened :when, without success: :what', 'watchdog_stale' => 'The watchdog has not reported in since :when — it is probably no longer running.', @@ -178,6 +184,14 @@ return [ // Fix round 1: the outcome used to be invisible — UpdateChannel::state() // returned `rescue_last_run`, but no blade read it. 'rescue_last_run' => 'Last tunnel rescue: :state, :when.', + // Review round 2 (C1 part 3): its own word instead of update_state.succeeded + // ("succeeded") — a run that completed only confirmed that every step + // lined up what was needed (deploy/rescue-tunnel.sh), not that the tunnel + // is now definitely receiving data again. + 'rescue_state_ok' => 'completed', + // Review round 2 (C1 part 2): same pattern as 'update_log', for the last + // tunnel-rescue attempt's log (UpdateChannel::rescueLog()). + 'rescue_log' => 'Log of the last tunnel rescue', // ── Pinning a ceiling ──────────────────────────────────────────────── // The ceiling that limits both the update agent AND the maintenance diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 7c41b19..5fc1ab4 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -121,20 +121,23 @@ {{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet ins Journal, und das liegt auf dem Wirt. --}} @if ($watchdog) - {{-- Fix-Runde 1 (Task 3): ein idle-Ausgang MIT Aktionen ist kein - "nichts zu tun" — der Waechter hat gegriffen, der Griff hat nur - nicht gewirkt (z. B. wg0 blieb trotz wg-quick up unten, siehe - watchdog.sh). Vorher fiel genau dieser Fall auf `watchdog_idle` - durch, weil das die einzige Verzweigung ohne `outcome`-Pruefung - war — eine ruhige graue Zeile waehrend kein Host erreichbar ist. --}} -

+ {{-- Review-Runde 2 (I1): `outcome` traegt jetzt selbst "tried" — + gesetzt in watchdog.sh, sobald mindestens EIN Griff im Lauf + nicht gewirkt hat, auch wenn ein ANDERER im selben Lauf + erfolgreich war (z. B. fehlende Dienste kamen hoch, wg0 aber + nicht). Die Bedingung haengt deshalb nicht mehr an `idle`: vorher + stand "gegriffen, aber wg0 blieb unten" nur dann als + watchdog_tried, wenn KEIN anderer Zweig im selben Lauf gegriffen + hatte — griff einer, war `outcome` schon `healed`, und der + Fehlschlag verschwand darin, ruhig eingefaerbt. --}} +

@if ($watchdog['stale']) {{ __('admin_settings.watchdog_stale', ['when' => $watchdog['at']->local()->diffForHumans()]) }} @elseif ($watchdog['outcome'] === 'healed') {{ __('admin_settings.watchdog_healed', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} @elseif ($watchdog['outcome'] === 'stood_down') {{ __('admin_settings.watchdog_stood_down', ['when' => $watchdog['at']->local()->diffForHumans()]) }} - @elseif ($watchdog['actions']) + @elseif ($watchdog['outcome'] === 'tried') {{ __('admin_settings.watchdog_tried', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} @else {{ __('admin_settings.watchdog_idle', ['when' => $watchdog['at']->local()->diffForHumans()]) }} @@ -438,9 +441,18 @@ Protokoll — er musste auf den Wirt, genau dorthin, wovon dieser Knopf ihn befreien sollte. --}} @if ($update['rescue_last_run']) + {{-- Review-Runde 2 (C1 Teil 3): „erfolgreich" (update_state.succeeded) + behauptet mehr, als ein durchgelaufenes Skript weiss — es prüft + am Ende nicht nach, ob der Tunnel jetzt wirklich wieder Daten + empfängt, nur ob jeder einzelne Griff ausgerichtet hat, was nötig + war (siehe deploy/rescue-tunnel.sh). Eigene, bewusst zurückhaltende + Formulierung statt der Beschriftung, die für eine echte + Aktualisierung (git pull, Container neu, Migration) gilt. --}}

{{ __('admin_settings.rescue_last_run', [ - 'state' => __('admin_settings.update_state.'.($update['rescue_last_run']['state'] === 'ok' ? 'succeeded' : 'failed')), + 'state' => $update['rescue_last_run']['state'] === 'ok' + ? __('admin_settings.rescue_state_ok') + : __('admin_settings.update_state.failed'), 'when' => $update['rescue_last_run']['finished_at']?->diffForHumans() ?? '—', ]) }} @if ($update['rescue_last_run']['error']) @@ -449,6 +461,17 @@

@endif + {{-- Review-Runde 2 (C1 Teil 2): dasselbe
-Muster wie das + Protokoll der Aktualisierung darunter, nur für die Tunnel-Rettung — + der Betreiber soll den Satz mit dem Handgriff lesen können, ohne auf + den Wirt zu gehen. --}} + @if ($rescueLog) +
+ {{ __('admin_settings.rescue_log') }} +
{{ $rescueLog }}
+
+ @endif + {{-- Open while it runs: "läuft gerade" on its own tells an operator nothing about whether it is progressing or wedged. --}} @if ($updateLog) diff --git a/tests/Feature/Admin/ConfirmRescueTunnelTest.php b/tests/Feature/Admin/ConfirmRescueTunnelTest.php new file mode 100644 index 0000000..4f25367 --- /dev/null +++ b/tests/Feature/Admin/ConfirmRescueTunnelTest.php @@ -0,0 +1,83 @@ +role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(ConfirmRescueTunnel::class) + ->assertSee(__('admin_settings.rescue_title')); +}); + +it('refuses to even mount the confirmation for an operator without site.manage', function () { + // Eine Livewire-Komponente ist ein oeffentlicher Endpunkt — auch das + // Modal selbst, nicht nur die Aktion, die es am Ende auslöst. + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(ConfirmRescueTunnel::class) + ->assertForbidden(); +}); + +it('leaves the deed to the page component, and only dispatches', function () { + // R23: das Modal mutiert nichts. Die Berechtigungsprüfung UND das + // eigentliche Anfordern bleiben an der einen Stelle, an der sie schon + // stehen (App\Livewire\Admin\Settings::rescueTunnel()). + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(ConfirmRescueTunnel::class) + ->call('confirm') + ->assertDispatched('rescue-tunnel-confirmed'); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); + +it('leaves a rescue request for the agent once the confirmation comes back', function () { + // Die Ereignis-Verdrahtung Modal → Seite: Settings faengt + // 'rescue-tunnel-confirmed' per #[On(...)] auf, genau wie beim Sperre- + // Loesen daneben. + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(AdminSettings::class) + ->dispatch('rescue-tunnel-confirmed'); + + $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); + + expect($request['kind'])->toBe('rescue-tunnel') + ->and($request['requested_by'])->toBe($owner->email); +}); + +it('refuses to rescue the tunnel for an operator without the capability, even via the event', function () { + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(AdminSettings::class) + ->dispatch('rescue-tunnel-confirmed') + ->assertForbidden(); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); diff --git a/tests/Feature/RescueTunnelTest.php b/tests/Feature/RescueTunnelTest.php index 08041c4..0cfbe86 100644 --- a/tests/Feature/RescueTunnelTest.php +++ b/tests/Feature/RescueTunnelTest.php @@ -4,6 +4,7 @@ use App\Livewire\Admin\Settings; use App\Models\Operator; use App\Services\Deployment\UpdateChannel; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Process; use Livewire\Livewire; /** @@ -21,6 +22,51 @@ afterEach(function () { File::deleteDirectory(storage_path('app/deploy')); }); +/** + * Baut eine `docker`- und `sudo`-Attrappe für deploy/rescue-tunnel.sh und + * gibt ihr Verzeichnis zurück. Hier läuft das ECHTE Skript, nicht eine + * Nachbildung seiner Logik — genau daran ist der letzte Fix schon einmal + * gescheitert (siehe WatchdogVisibilityTest). + * + * @param array $peers Zeilen für "wg show wg0 peers" (leer = kein Zugang) + * @param array $transfer Zeilen für "wg show wg0 transfer" ("pubkey empfangen gesendet") + */ +function rescueTunnelStub(array $peers = ['PEER1'], array $transfer = ['PEER1 0 999'], bool $sudoOk = true, bool $ownContainer = true): string +{ + $dir = storage_path('app/deploy'); + File::ensureDirectoryExists($dir); + + $stub = $dir.'/stub-rescue'; + File::ensureDirectoryExists($stub); + + $peersOut = implode('\n', $peers); + $transferOut = implode('\n', $transfer); + $cmdlineOut = $ownContainer ? 'vpn-hub' : 'some-other-process'; + + File::put($stub.'/docker', <<requestRescueTunnel('chef@example.com'))->toBeTrue(); @@ -127,5 +173,119 @@ it('shows a successful rescue attempt too', function () { Livewire::actingAs($owner, 'operator') ->test(Settings::class) - ->assertSee(__('admin_settings.update_state.succeeded')); + ->assertSee(__('admin_settings.rescue_state_ok')); +}); + +it('does not call a rescue attempt "succeeded" — a run that completed is not proof the tunnel works', function () { + // Review-Runde 2 (C1 Teil 3): update_state.succeeded ("erfolgreich") + // behauptet mehr, als ein durchgelaufenes rescue-tunnel.sh weiss. Dieser + // Test faellt auf den Vorzustand rot: dort las settings.blade.php das + // 'ok'-Ergebnis noch als admin_settings.update_state.succeeded. + File::put(storage_path('app/deploy/rescue-last-run.json'), json_encode([ + 'state' => 'ok', + 'finished_at' => now()->utc()->toIso8601String(), + 'error' => '', + ])); + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertDontSee(__('admin_settings.update_state.succeeded')); +}); + +it('shows the rescue log tail in the console, the same way the update log already does', function () { + // Review-Runde 2 (C1 Teil 2): der Log-Auszug gehoert in die Konsole, + // uebernimmt das Muster von UpdateChannel::lastLog() / 'update_log' + // statt eines neu erfundenen. + File::put(storage_path('app/deploy/rescue-last-run.log'), "Zeile eins\nACHTUNG: wg0 liess sich nicht hochziehen.\n"); + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee(__('admin_settings.rescue_log')) + ->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false); +}); + +it('shows nothing for the rescue log when no rescue has ever run', function () { + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertDontSee(__('admin_settings.rescue_log')); +}); + +// ── C1 Teil 1: das Skript selbst muss Misserfolg als Misserfolg melden ────── + +it('exits with a problem when not a single peer is loaded', function () { + // Der zweite der beiden im Review genannten Stellen: "Kein einziger + // Zugang geladen" endete bisher trotzdem auf Exit 0. + $stub = rescueTunnelStub(peers: [], transfer: []); + + $result = Process::path(base_path())->timeout(60)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + ])->run('bash deploy/rescue-tunnel.sh'); + + expect($result->exitCode())->not->toBe(0); +}); + +it('exits with a problem when stale conntrack entries cannot be cleared for lack of root', function () { + // DAS Fehlerszenario aus dem Review: 1 von 1 Zugaengen stumm, `sudo -n + // conntrack -D` scheitert (kein sudoers-Eintrag — laut Runbook auf einem + // frischen Wirt der Normalfall). Bisher: nur `bad(...)`, Exit 0. + $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false); + + $result = Process::path(base_path())->timeout(60)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + ])->run('bash deploy/rescue-tunnel.sh'); + + expect($result->exitCode())->not->toBe(0) + ->and($result->output())->toContain('Dafür fehlt mir Root'); +}); + +it('still exits clean when everything actually lines up, including on a host without its own tunnel container', function () { + // Gegenprobe zur Einteilung aus C1 Teil 1: die Meldung ueber einen noch + // fehlenden eigenen Tunnel-Container (Zeile 58/59) ist eine reine + // Versionsauskunft, kein Fehlschlag DIESES Laufs — sie darf den + // Rueckgabewert nicht anfassen, sonst meldete jeder Lauf auf einem + // aelteren Wirt "fehlgeschlagen", obwohl der Tunnel tadellos steht. + $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 100 999'], sudoOk: true, ownContainer: false); + + $result = Process::path(base_path())->timeout(60)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + ])->run('bash deploy/rescue-tunnel.sh'); + + expect($result->exitCode())->toBe(0) + ->and($result->output())->toContain('eigenen Tunnel-Container noch nicht'); +}); + +it('marks a rescue attempt failed end-to-end when conntrack cannot be cleared, and the console does not call it "succeeded"', function () { + // C1, der ganze Weg durch: das ECHTE deploy/update-agent.sh ruft das + // ECHTE deploy/rescue-tunnel.sh, genau wie auf dem Wirt. Der Nachweis + // wird — wie gefordert — im gerenderten HTML gefuehrt, nicht nur auf + // Datenebene: genau dort ist der vorige Fix schon einmal durchgerutscht. + $dir = storage_path('app/deploy'); + $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false); + + // `git` scheitert sofort — dieser Tick soll die bereits im Postkasten + // liegende Bitte abarbeiten, kein echtes `git fetch` versuchen. + File::put($stub.'/git', "#!/bin/sh\nexit 1\n"); + Process::run("chmod +x {$stub}/git"); + + $owner = Operator::factory()->role('Owner')->create(); + app(UpdateChannel::class)->requestRescueTunnel($owner->email); + + $result = Process::path(base_path())->timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + ])->run('bash deploy/update-agent.sh >/dev/null 2>&1 || true'); + + expect($result->successful())->toBeTrue($result->errorOutput()); + + $run = json_decode(File::get($dir.'/rescue-last-run.json'), true); + expect($run['state'])->toBe('failed'); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee(__('admin_settings.update_state.failed')) + ->assertDontSee(__('admin_settings.update_state.succeeded')) + ->assertSee(__('admin_settings.rescue_log')); }); diff --git a/tests/Feature/WatchdogVisibilityTest.php b/tests/Feature/WatchdogVisibilityTest.php index fc71646..4deff39 100644 --- a/tests/Feature/WatchdogVisibilityTest.php +++ b/tests/Feature/WatchdogVisibilityTest.php @@ -36,7 +36,19 @@ function runWatchdog(string $running = "app\nredis", bool $holdLock = false): ar #!/bin/sh case "\$*" in *config*) printf '%s\\n' app redis ;; - *"ps "*) printf '%s\\n' '$running' ;; + *"ps "*) + # Review-Runde 2 (I1): watchdog.sh prueft nach `up -d` jetzt + # NOCH EINMAL nach, ob es gewirkt hat. Ohne diese Reaktion auf + # den eigenen Aufruf blieb "app" fuer den zweiten Blick + # ebenso fehlend wie fuer den ersten, und ein wirklich + # erfolgreicher Heilversuch waere in diesem Test nicht mehr + # von einem gescheiterten zu unterscheiden gewesen. + if [ -f "\$STUB_UP_CALLED" ]; then + printf '%s\\n' app redis + else + printf '%s\\n' '$running' + fi + ;; *getent*) exit 0 ;; *storage/framework/down*) exit 1 ;; *" up "*) touch "\$STUB_UP_CALLED" ;; @@ -116,6 +128,53 @@ function runWatchdogTunnelRescue(bool $recovers): array return json_decode(File::get($dir.'/watchdog-last-run.json'), true); } +/** + * Zwei Zweige im selben Lauf: fehlende Dienste kommen mit `up -d` wieder + * hoch (Zweig 1 heilt erfolgreich), UND wg0 laesst sich trotz `wg-quick up` + * nicht hochziehen (Zweig 3 scheitert). Genau das Szenario aus I1 — ein + * Erfolg und ein Misserfolg im selben Lauf. + */ +function runWatchdogPartialFailure(): array +{ + $dir = storage_path('app/deploy'); + File::ensureDirectoryExists($dir); + File::delete(File::glob($dir.'/*')); + + $stub = $dir.'/stub'; + File::ensureDirectoryExists($stub); + + File::put($stub.'/docker', <<timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'STUB_APP_STARTED' => $dir.'/.stub-app-started', + ])->run('bash deploy/watchdog.sh >/dev/null 2>&1 || true'); + + 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')); }); @@ -170,10 +229,15 @@ it('shows the operator that an intervention failed, instead of "nothing to do"', // auch beim Betreiber ankommt. Vorher fiel er auf watchdog_idle durch: // eine ruhige graue Zeile "nichts zu tun", waehrend "ACHTUNG" und "wg0" // im HTML gar nicht mehr vorkamen. + // + // Review-Runde 2 (I1): `outcome` traegt seither ein eigenes `tried` statt + // `idle` mit Aktionen — watchdog.sh liefert diese Form fuer genau dieses + // Szenario jetzt selbst (siehe runWatchdogPartialFailure() weiter unten), + // und die Blade-Bedingung haengt nicht mehr an `idle`. 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', + 'outcome' => 'tried', 'actions' => [ 'wg0 steht nicht — ziehe den Tunnel hoch.', 'ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md.', @@ -191,6 +255,45 @@ it('shows the operator that an intervention failed, instead of "nothing to do"', ->assertDontSee('nichts zu tun'); }); +it('wins the outcome with "tried" even when another branch in the same run healed something', function () { + // I1, auf Datenebene: das ECHTE watchdog.sh, nicht eine Nachbildung + // seiner Logik. Zweig 1 (fehlende Dienste) heilt erfolgreich, Zweig 3 + // (wg0) scheitert im selben Lauf — vorher stand `outcome` dann auf + // `healed`, weil `geheilt=true` schon durch Zweig 1 galt und nichts den + // Fehlschlag aus Zweig 3 mehr zurücknehmen konnte. + $run = runWatchdogPartialFailure(); + + expect($run['outcome'])->toBe('tried') + ->and(implode(' ', $run['actions']))->toContain('Es fehlen Dienste: app — starte sie.') + ->and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen'); +}); + +it('does not read as calmly "intervened" when one branch failed, even though another one in the same run healed something', function () { + // I1, im gerenderten HTML: derselbe Zustand, den runWatchdogPartialFailure() + // oben tatsächlich erzeugt. Vor dem Fix stand `outcome` hier auf `healed` + // (Zweig 1 hatte gewirkt) — die Konsole zeigte watchdog_healed in ruhiger + // Farbe, und der ACHTUNG-Satz stand nur noch als Beiwerk im `:what` drin, + // ohne die Kennzeichnung "ohne Erfolg" und ohne text-warning. + 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' => 'tried', + 'actions' => [ + 'Es fehlen Dienste: app — starte sie.', + 'wg0 steht nicht — ziehe den Tunnel hoch.', + 'ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md.', + ], + ])); + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee('ohne Erfolg', false) + ->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false) + ->assertSeeHtml('text-warning') + ->assertDontSee('nichts zu tun'); +}); + it('reads nothing rather than falling over when the file is absent', function () { File::ensureDirectoryExists(storage_path('app/deploy'));