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
parent
9d1811beea
commit
1a5843670c
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue