From 6bdab32b94ab8eec7a7cd3c8951e7a6ea696c584 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 16:54:23 +0200 Subject: [PATCH] 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" +```