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 <noreply@anthropic.com>
main
nexxo 2026-08-04 18:22:01 +02:00
parent 9d1811beea
commit 1a5843670c
7 changed files with 153 additions and 3 deletions

View File

@ -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,
];
}

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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)
<p class="mt-2 text-sm {{ $watchdog['stale'] ? 'text-warning' : 'text-muted' }}">
{{-- 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. --}}
<p class="mt-2 text-sm {{ $watchdog['stale'] || ($watchdog['outcome'] === 'idle' && $watchdog['actions']) ? 'text-warning' : 'text-muted' }}">
@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 @@
</p>
@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'])
<p class="mt-3 text-xs {{ $update['rescue_last_run']['state'] === 'failed' ? 'text-danger' : 'text-muted' }}">
{{ __('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
</p>
@endif
{{-- Open while it runs: "läuft gerade" on its own tells an operator
nothing about whether it is progressing or wedged. --}}
@if ($updateLog)

View File

@ -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'));
});

View File

@ -1,8 +1,11 @@
<?php
use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Services\Deployment\WatchdogLog;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
/**
* Der Waechter redet bisher nur ins Journal und das Journal liegt auf dem
@ -161,6 +164,33 @@ it('does not claim healed when wg-quick ran but the tunnel stayed down', functio
->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'));