From 1a5843670c3b274710d708b4fb4e2157d2539477 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 18:22:01 +0200 Subject: [PATCH] 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'));