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" +```