diff --git a/app/Livewire/Admin/ConfirmRescueTunnel.php b/app/Livewire/Admin/ConfirmRescueTunnel.php new file mode 100644 index 0000000..fb3fdb2 --- /dev/null +++ b/app/Livewire/Admin/ConfirmRescueTunnel.php @@ -0,0 +1,33 @@ +authorize('site.manage'); + } + + public function confirm(): void + { + $this->authorize('site.manage'); + + $this->dispatch('rescue-tunnel-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-rescue-tunnel'); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 77baa34..a43eb93 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -12,6 +12,7 @@ use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; use App\Services\Deployment\UpdateChannel; use App\Services\Deployment\UpdateWindow; +use App\Services\Deployment\WatchdogLog; use App\Services\Terminal\ServerIdentity; use App\Services\Terminal\ServerTerminalSetup; use App\Services\Terminal\ServerTicket; @@ -814,6 +815,28 @@ class Settings extends Component : 'admin_settings.release_lock_already_requested')); } + /** + * Den Tunnel wieder hinbekommen. + * + * Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht + * hier, wie bei jeder anderen Handlung dieser Seite. + */ + #[On('rescue-tunnel-confirmed')] + public function rescueTunnel(): void + { + $this->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')); + } + public function render() { $operator = $this->currentOperator(); @@ -838,6 +861,14 @@ class Settings extends Component return view('livewire.admin.settings', [ 'update' => app(UpdateChannel::class)->state(), 'updateLog' => app(UpdateChannel::class)->lastLog(), + // Review-Runde 2 (C1 Teil 2): derselbe
-Auszug wie + // updateLog, nur fürs Protokoll der letzten Tunnel-Rettung — der + // Betreiber soll den Satz mit dem Handgriff lesen können, ohne + // auf den Wirt zu gehen. + 'rescueLog' => app(UpdateChannel::class)->rescueLog(), + // Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — + // er redet ins Journal, und das liegt auf dem Wirt. + 'watchdog' => app(WatchdogLog::class)->lastRun(), 'sitePublic' => AppSettings::bool('site.public', true), 'canManageSite' => $operator?->can('site.manage') ?? false, // Shown so nobody has to guess why they still see the real site. diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 20590ec..70910fb 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -142,6 +142,30 @@ final class UpdateChannel */ private const ARCHIVE_KEY = 'deploy/archive-key.json'; + /** + * 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'; + + /** + * Der Protokollauszug der letzten Tunnel-Rettung. + * + * Dasselbe Muster wie LOG/lastLog() weiter unten, nur für + * deploy/rescue-tunnel.sh statt deploy/update.sh — deploy/update-agent.sh + * schreibt die volle Ausgabe des Skripts hierhin (Zeile ~694), Blade + * zeigt sie als
, wie das Protokoll der Aktualisierung auch. + * Review-Runde 2 (C1 Teil 2): der Betreiber soll den Satz mit dem + * Handgriff lesen können, ohne auf den Wirt zu gehen. + */ + private const RESCUE_LOG = 'deploy/rescue-last-run.log'; + /** Written by the agent after every check and every run. */ private const STATUS = 'deploy/update-status.json'; @@ -325,6 +349,20 @@ final class UpdateChannel 'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null, 'checked_at' => $checkedAt, + // 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'], + 'agent_seen' => $agentAlive, // Seit wann der Agent nur noch überspringt, und wer die Sperre // hält. Beides null, solange er arbeitet. @@ -393,6 +431,23 @@ final class UpdateChannel 'last_restart_state' => $restartLastRun['state'] ?? null, 'last_restart_finished_at' => $this->timestamp($restartLastRun['finished_at'] ?? null), '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)) !== [] + ? [ + 'state' => $last['state'] ?? null, + 'finished_at' => $this->timestamp($last['finished_at'] ?? null), + 'error' => $this->errorMessage($last), + ] + : null, ]; } @@ -590,6 +645,15 @@ final class UpdateChannel return $this->submit($by, self::KIND_CHECK); } + /** + * Den Tunnel wieder hinbekommen — kein zweiter Weg, derselbe Postkasten + * wie jede andere Anfrageart. + */ + public function requestRescueTunnel(string $by): bool + { + return $this->submit($by, self::KIND_RESCUE_TUNNEL); + } + /** * Den Server auf eine Version festnageln — oder die Decke abnehmen. * @@ -905,6 +969,29 @@ final class UpdateChannel } } + /** + * The tail of the last tunnel-rescue attempt — the same shape as + * lastLog() above, for RESCUE_LOG instead of LOG. Read separately from + * state(), same as `updateLog` is in App\Livewire\Admin\Settings::render(): + * this is something to show, not something to decide on. + */ + public function rescueLog(int $lines = 40): ?string + { + $path = storage_path('app/'.self::RESCUE_LOG); + + try { + if (! File::exists($path)) { + return null; + } + + $all = preg_split('/\R/', trim((string) File::get($path))) ?: []; + + return implode("\n", array_slice($all, -$lines)); + } catch (Throwable) { + return null; + } + } + /** @return array */ private function readJson(string $relative): array { diff --git a/app/Services/Deployment/WatchdogLog.php b/app/Services/Deployment/WatchdogLog.php new file mode 100644 index 0000000..c3b270b --- /dev/null +++ b/app/Services/Deployment/WatchdogLog.php @@ -0,0 +1,66 @@ +, 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; + } + } +} diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh index 25849bd..814ad63 100644 --- a/deploy/lib/release.sh +++ b/deploy/lib/release.sh @@ -216,3 +216,17 @@ json_escape() { # raw; dropping them beats emitting a broken document. printf '%s' "$s" | tr -d '\000-\037' } + +# 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; } diff --git a/deploy/rescue-tunnel.sh b/deploy/rescue-tunnel.sh index 94ca63a..d5a231f 100755 --- a/deploy/rescue-tunnel.sh +++ b/deploy/rescue-tunnel.sh @@ -22,6 +22,21 @@ ok() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } bad() { printf '\033[1;31m ✗\033[0m %s\n' "$*"; } +# Wie viele Stellen etwas gefunden haben, das nicht ausgerichtet ist, wie es +# sein sollte — und zwar am ENDE des Laufs, nicht dem, was ein einzelner Griff +# gerade behoben zu haben glaubt. Der Aufrufer (deploy/update-agent.sh) macht +# bisher NUR den Exit-Code zur Wahrheit; ohne diesen Zähler endete das Skript +# unter `set -uo pipefail` (kein `-e`) nach jedem `bad`/`warn` auf ein +# schlichtes `echo` weiter unten — Exit 0, auch wenn mittendrin etwas offen +# blieb. Nicht jede `bad`/`warn`-Zeile zählt: eine, die einen unmittelbar +# folgenden `exit 1` hat (Container startet nicht, wg0 laesst sich nicht +# hochziehen), braucht den Zähler nicht — der Exit-Code steht da schon fest. +# Und eine rein informative Meldung (dieser Server hat noch keinen eigenen +# Tunnel-Container) sagt nichts darüber, ob DIESER Lauf etwas nicht +# hinbekommen hat — sie stünde bei jedem Lauf auf einem älteren Wirt, auch +# wenn der Tunnel tadellos steht. +probleme=0 + if [[ $EUID -eq 0 ]]; then echo "Bitte als Dienstbenutzer starten, nicht als root:" >&2 echo " sudo -u clupilot bash $0" >&2 @@ -82,7 +97,11 @@ peers="$(hub wg show wg0 peers | grep -c . || true)" if [[ "${peers:-0}" -gt 0 ]]; then ok "$peers Zugänge geladen" else + # Nicht bloss eine Randnotiz: ohne einen einzigen Zugang tut der Tunnel + # nichts, egal wie gesund wg0 sonst aussieht. Vorher endete der Lauf hier + # trotzdem auf Exit 0. warn "Kein einziger Zugang geladen — /etc/wireguard/wg0.conf ansehen." + probleme=$((probleme + 1)) fi # ── 3. Kommt auch etwas an? ────────────────────────────────────────────────── @@ -108,12 +127,18 @@ if [[ "${stumm:-0}" -gt 0 ]]; then if sudo -n conntrack -D -p udp --dport "$WG_PORT" >/dev/null 2>&1; then ok "erledigt — die Zugänge bauen sich in den nächsten 30 Sekunden neu auf" else + # DAS Krankheitsbild, das nie von allein heilt (siehe Kopf dieser + # Datei): stumme Zugänge bleiben stumm, wenn dieser Griff scheitert. + # Der conntrack-Sudoers-Eintrag ist Handarbeit (siehe unten) und auf + # einem frischen Wirt der Normalfall, nicht die Ausnahme — genau + # deshalb darf ein gescheiterter Versuch hier nicht als Erfolg enden. bad "Dafür fehlt mir Root. Bitte einmal von Hand ausführen:" echo echo " sudo conntrack -D -p udp --dport $WG_PORT" echo warn "Damit ich das künftig selbst darf, siehe docs/runbooks/tunnel-recovery.md," warn "Abschnitt 'Einmal einrichten'." + probleme=$((probleme + 1)) fi else ok "Alle Zugänge haben schon Daten geschickt" @@ -136,3 +161,9 @@ echo " wieder auf — und muss danach zurückgenommen werden:" echo echo " /usr/local/sbin/clupilot-emergency-open-firewall.sh" echo + +# Der Aufrufer (deploy/update-agent.sh) macht den Exit-Code zur einzigen +# Wahrheitsquelle — siehe `probleme` oben. Kein Problem gezählt heisst hier +# tatsächlich Exit 0; eins oder mehr heisst, der Lauf hat nicht ausgerichtet, +# was nötig war, auch wenn er bis hierher durchgelaufen ist. +exit "$probleme" \ No newline at end of file diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index dba71e3..58e5c9d 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -176,7 +176,7 @@ release_stuck_lock() { have="$("$step" contract 2>/dev/null || true)" [[ "$have" =~ ^[0-9]+$ ]] || have=0 - if (( have < 3 )); then + if (( have < $(release_host_step_needs) )); then write_unblock failed unblock_helper_old return 0 fi @@ -425,6 +425,22 @@ releases_json() { printf '%s' "${out%,}" } +# 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)" + write_status() { local state="$1" error="${2-}" cat > "$STATUS.tmp" < "$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" </dev/null || true @@ -101,6 +105,20 @@ darf_eingreifen() { geheilt=false +# Ein Misserfolg muss den Ausgang gewinnen — auch wenn im selben Lauf ein +# ANDERER Griff gewirkt hat. Ohne dieses eigene Flag setzte ein erfolgreicher +# Zweig `geheilt=true`, und die Konsole zeigte "eingegriffen" in ruhiger +# Farbe, waehrend ein zweiter Zweig (z. B. wg0) im selben Lauf nicht gewirkt +# hat — der Satz, der genau fuer diesen Fall geschrieben wurde +# (watchdog_tried), wurde nie gedruckt, weil seine Bedingung bisher an +# `outcome === 'idle'` hing und `outcome` durch den erfolgreichen Zweig +# bereits `healed` war. +fehlgeschlagen=false + +# Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus +# einen Satz; das Journal hat weiterhin die Langfassung. +AKTIONEN=() + # ── 1. Fehlt ein Dienst? ───────────────────────────────────────────────────── # # `config --services` liest die Profile aus der .env mit, vpn-dns und @@ -113,9 +131,21 @@ if [[ -n "$soll" ]]; then if [[ -n "$fehlt" ]] && darf_eingreifen; then say "Es fehlen Dienste: $fehlt — starte sie." lange_frist docker compose up -d >/dev/null 2>&1 || true - geheilt=true sleep 10 ist="$(frist docker compose ps --services --status running 2>/dev/null | sort || true)" + + # Zweiter Blick, wie beim wg0-Zweig weiter unten: `up -d` kann + # scheitern (Docker-Daemon nicht erreichbar, ein Abbild fehlt) und + # gab bisher trotzdem `geheilt=true` — die Konsole zeigte dann + # "eingegriffen" in ruhiger Farbe, waehrend der Stapel weiter unten + # lag. + fehlt_nach="$(comm -23 <(printf '%s\n' "$soll") <(printf '%s\n' "$ist") | tr '\n' ' ' | sed 's/ *$//')" + if [[ -z "$fehlt_nach" ]]; then + geheilt=true + else + say "ACHTUNG: es fehlen weiterhin Dienste: $fehlt_nach." + fehlgeschlagen=true + fi fi fi @@ -139,8 +169,19 @@ if printf '%s\n' "$ist" | grep -qx app && printf '%s\n' "$ist" | grep -qx redis; if darf_eingreifen && ! frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then say "Die Container finden einander nicht mehr (redis nicht auflösbar) — erzeuge sie neu." lange_frist docker compose up -d --force-recreate >/dev/null 2>&1 || true - geheilt=true sleep 15 + + # Dritter Blick: hat `--force-recreate` selbst gewirkt? Bisher + # stand `geheilt=true` schon VOR dieser Frage — derselbe Fehler + # wie beim wg0-Zweig, nur an einer teureren Stelle (dieser Griff + # reisst jede offene Verbindung ab, und tat es hier auch dann + # unwidersprochen, wenn Docker den Neubau selbst nicht schaffte). + if frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then + geheilt=true + else + say "ACHTUNG: redis ist weiterhin nicht auflösbar." + fehlgeschlagen=true + fi fi fi fi @@ -156,12 +197,26 @@ if printf '%s\n' "$ist" | grep -qx vpn-hub; then if darf_eingreifen; then say "wg0 steht nicht — ziehe den Tunnel hoch." lange_frist docker compose exec -T vpn-hub wg-quick up wg0 >/dev/null 2>&1 || true - geheilt=true + # `geheilt` erst HIER, nach dem zweiten Blick: die Konsole + # zeigt `outcome` inzwischen einem Menschen (Task 3). Vorher + # stand die Zeile vor dieser Pruefung — ein fehlgeschlagenes + # `wg-quick up wg0` meldete sich trotzdem als "healed", weil + # der Text im ACHTUNG-Zweig darunter das `outcome` nicht mehr + # aendern konnte. Der Satz stimmte, das Feld nicht. if frist docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then say "wg0 steht wieder." + geheilt=true else say "ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md." + # Review-Runde 2: `outcome` blieb bis hierher `idle`, + # wenn kein ANDERER Zweig im selben Lauf gegriffen hatte + # — dann las die Konsole "actions" und zeigte + # watchdog_tried. Griff aber ein anderer Zweig ERFOLGREICH + # (z. B. fehlende Dienste), stand `outcome` schon auf + # `healed`, und dieser Fehlschlag verschwand darin. Das + # eigene Flag gewinnt jetzt unabhaengig davon. + fehlgeschlagen=true fi fi fi @@ -196,13 +251,70 @@ if [[ ! -f "$HOLD" ]] && frist docker compose exec -T -u www-data app test -f st && frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then say "Der Wartungsmodus haengt seit ueber einer halben Stunde ohne laufendes Update — beende ihn." lange_frist docker compose exec -T -u www-data app php artisan up >/dev/null 2>&1 || true - geheilt=true + + # Zweiter Blick, dasselbe Muster wie in den drei Zweigen oben: `up` + # kann scheitern (Docker-Daemon nicht erreichbar), und ohne diese + # Pruefung stand `geheilt=true` bereits fest, waehrend die Seite + # unten blieb. + if ! frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then + geheilt=true + else + say "ACHTUNG: der Wartungsmodus liess sich nicht beenden." + fehlgeschlagen=true + fi fi fi # Nur reden, wenn es etwas zu sagen gab. Ein Waechter, der jede Minute meldet, # dass alles in Ordnung ist, wird nach zwei Tagen nicht mehr gelesen — und dann # auch nicht mehr an dem Tag, an dem er etwas Wichtiges sagt. -if [[ "$geheilt" == true ]]; then +if [[ "$geheilt" == true || "$fehlgeschlagen" == true ]]; then say "Nachgesehen und eingegriffen." fi + +# ── 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. +# +# Vier Ausgaenge, weil sie vier verschiedene Dinge bedeuten: +# idle — nachgesehen, nichts zu tun. Der Normalfall. +# healed — eingegriffen, und JEDER Griff in diesem Lauf hat gewirkt. +# tried — eingegriffen, aber mindestens EIN Griff hat NICHT gewirkt — +# auch wenn ein anderer im selben Lauf erfolgreich war. Ein +# Misserfolg gewinnt den Ausgang; siehe `fehlgeschlagen` oben. +# 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 [[ "$fehlgeschlagen" == true ]]; then + ausgang=tried +elif [[ "$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 diff --git a/docs/runbooks/tunnel-recovery.md b/docs/runbooks/tunnel-recovery.md index b972b83..4325a4d 100644 --- a/docs/runbooks/tunnel-recovery.md +++ b/docs/runbooks/tunnel-recovery.md @@ -85,7 +85,18 @@ kommen, der einen zur Konsole bringt. --- -## Zuerst: ein Befehl, der das meiste allein macht +## Zuerst: der Knopf in der Konsole + +Einstellungen → „Tunnel retten". Derselbe Rettungsversuch wie unten, nur ohne +SSH: die Konsole legt eine Bitte ab, der Update-Agent auf dem Wirt fährt +`deploy/rescue-tunnel.sh` (dasselbe Skript, kein zweiter Weg) und meldet +zurück, was er getan hat — samt Protokollauszug, direkt unter dem Knopf. Der +Griff geht damit vom Wirt in die Konsole, wo dieser ganze Vorgang eigentlich +hingehört. + +Rückfall, wenn die Konsole selbst nicht erreichbar ist (dann zuerst zurück zu +„Die vier Wege hinein" oben) oder wenn man von Hand nachvollziehen will, was +das Skript tut: ```bash cd /opt/clupilot && sudo -u clupilot bash deploy/rescue-tunnel.sh 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" +``` diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 541f2d8..d944e49 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -139,6 +139,28 @@ return [ 'update_agent_blocked' => 'Der Update-Dienst läuft, kommt aber seit :since nicht an die Arbeit — ein anderer Vorgang hält die Sperre. Solange das so ist, stammen die Angaben oben von vorher. Löst es sich nicht von selbst, hilft ein Blick auf den Prozess unten.', 'update_log' => 'Protokoll des letzten Laufs', + // ── Der Wächter ────────────────────────────────────────────────────── + // Redet bisher nur ins Journal auf dem Wirt — die Konsole sah ihn bis + // August 2026 gar nicht (App\Services\Deployment\WatchdogLog). + 'watchdog_idle' => 'Zuletzt nachgesehen :when — nichts zu tun.', + 'watchdog_healed' => 'Zuletzt eingegriffen :when: :what', + // M3: die Sperre halten inzwischen mehr Vorgaenge als ein Update — + // proxy-hosts, restart, archive-key, rescue-tunnel. „weil ein Update + // lief" war damit schon falsch, sobald einer der anderen die Sperre hielt. + 'watchdog_stood_down' => ':when zurückgetreten — ein anderer Vorgang hielt die Sperre.', + // Fix-Runde 1 / Review-Runde 2: gegriffen, aber mindestens ein Griff + // ohne Erfolg — auch wenn ein anderer im selben Lauf gewirkt hat (z. B. + // fehlende Dienste kamen hoch, wg0 blieb trotz wg-quick up unten). Ohne + // diesen Satz verschwand der Fall entweder in „nichts zu tun" + // (watchdog_idle) oder, seit der ersten Fix-Runde, in „eingegriffen" + // (watchdog_healed) — outcome traegt jetzt ein eigenes `tried`. + '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 ────────────────────────────────────────────────── + // Der root-eigene Helfer auf dem Wirt (UpdateChannel::state()['host_step_ok']). + 'host_step_old' => 'Der Wirt-Helfer erfüllt Vertrag :have, gebraucht wird :needs. Einmalig auf diesem Wirt ausführen:', + // ── Die Sperre lösen ───────────────────────────────────────────────── // Der Griff, der bisher per SSH auf dem Wirt lag. Er beendet einen // laufenden Prozess, deshalb nennt die Rückfrage ihn vorher beim Namen. @@ -152,6 +174,28 @@ return [ 'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.', 'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.', + // ── Tunnel-Rettung ─────────────────────────────────────────────────── + // deploy/rescue-tunnel.sh stand bisher in zwei Runbooks als Handarbeit. + // Derselbe Postkasten wie jede andere Anfrageart + // (UpdateChannel::KIND_RESCUE_TUNNEL) — kein zweiter Weg. + 'rescue_action' => 'Tunnel retten', + 'rescue_requested' => 'Tunnel-Rettung angefordert.', + 'rescue_title' => 'Den Tunnel jetzt retten?', + '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.', + // Review-Runde 2 (C1 Teil 3): eigenes Wort statt update_state.succeeded + // ("erfolgreich") — ein durchgelaufenes Skript hat nur geprüft, dass + // jeder Griff ausgerichtet hat, was nötig war (deploy/rescue-tunnel.sh), + // nicht dass der Tunnel jetzt wieder zweifelsfrei Daten empfängt. + 'rescue_state_ok' => 'durchgelaufen', + // Review-Runde 2 (C1 Teil 2): dasselbe Muster wie 'update_log', nur fürs + // Protokoll der letzten Tunnel-Rettung (UpdateChannel::rescueLog()). + 'rescue_log' => 'Protokoll der letzten Tunnel-Rettung', + // ── Festnageln ──────────────────────────────────────────────────────── // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt // (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird @@ -181,6 +225,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 ada1cd6..f64c155 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -136,6 +136,28 @@ return [ 'update_agent_blocked' => 'The update service is running but has been unable to work since :since — another process holds the lock. While that lasts, the figures above are from before. If it does not clear on its own, look at the process named below.', 'update_log' => 'Log of the last run', + // ── The watchdog ───────────────────────────────────────────────────── + // It used to talk only to the host's journal — the console could not see + // it until August 2026 (App\Services\Deployment\WatchdogLog). + 'watchdog_idle' => 'Last checked :when — nothing to do.', + 'watchdog_healed' => 'Last intervened :when: :what', + // M3: more than an update now holds the lock — proxy-hosts, restart, + // archive-key, rescue-tunnel. "because an update was running" was already + // wrong the moment any of the others held it instead. + 'watchdog_stood_down' => 'Stood down :when — another process held the lock.', + // Fix round 1 / review round 2: intervened, but at least one attempt + // failed — even if another one in the same run worked (e.g. missing + // services came back up, wg0 stayed down despite wg-quick up). Without + // this sentence the case disappeared either into "nothing to do" + // (watchdog_idle) or, since the first fix round, into "intervened" + // (watchdog_healed) — outcome now carries its own `tried`. + '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 ────────────────────────────────────────────────── + // The root-owned helper on the host (UpdateChannel::state()['host_step_ok']). + 'host_step_old' => 'The host helper meets contract :have, this version needs :needs. Run once on this host:', + // ── Releasing the lock ─────────────────────────────────────────────── // The handle that used to live behind an SSH session. It ends a running // process, so the confirmation names it before it does. @@ -149,6 +171,28 @@ return [ 'release_lock_requested' => 'Releasing — the service reports back within a minute.', 'release_lock_already_requested' => 'Already requested — please wait a moment.', + // ── Rescuing the tunnel ────────────────────────────────────────────── + // deploy/rescue-tunnel.sh used to be manual work written down in two + // runbooks. The same mailbox as every other request kind + // (UpdateChannel::KIND_RESCUE_TUNNEL) — no second path. + 'rescue_action' => 'Rescue tunnel', + 'rescue_requested' => 'Tunnel rescue requested.', + 'rescue_title' => 'Rescue the tunnel now?', + '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.', + // Review round 2 (C1 part 3): its own word instead of update_state.succeeded + // ("succeeded") — a run that completed only confirmed that every step + // lined up what was needed (deploy/rescue-tunnel.sh), not that the tunnel + // is now definitely receiving data again. + 'rescue_state_ok' => 'completed', + // Review round 2 (C1 part 2): same pattern as 'update_log', for the last + // tunnel-rescue attempt's log (UpdateChannel::rescueLog()). + 'rescue_log' => 'Log of the last tunnel rescue', + // ── Pinning a ceiling ──────────────────────────────────────────────── // The ceiling that limits both the update agent AND the maintenance // window (App\Services\Deployment\UpdateChannel::setCeiling()). Only @@ -178,6 +222,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/confirm-rescue-tunnel.blade.php b/resources/views/livewire/admin/confirm-rescue-tunnel.blade.php new file mode 100644 index 0000000..8b0edea --- /dev/null +++ b/resources/views/livewire/admin/confirm-rescue-tunnel.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('admin_settings.rescue_title') }}

+

{{ __('admin_settings.rescue_body') }}

+
+
+
+ {{ __('admin_settings.rescue_cancel') }} + + {{ __('admin_settings.rescue_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index bf60860..fa10c8b 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -117,6 +117,46 @@
{{ $update['checked_at']->diffForHumans() }}
@endif + + {{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet + ins Journal, und das liegt auf dem Wirt. --}} + @if ($watchdog) + {{-- Review-Runde 2 (I1): `outcome` traegt jetzt selbst "tried" — + gesetzt in watchdog.sh, sobald mindestens EIN Griff im Lauf + nicht gewirkt hat, auch wenn ein ANDERER im selben Lauf + erfolgreich war (z. B. fehlende Dienste kamen hoch, wg0 aber + nicht). Die Bedingung haengt deshalb nicht mehr an `idle`: vorher + stand "gegriffen, aber wg0 blieb unten" nur dann als + watchdog_tried, wenn KEIN anderer Zweig im selben Lauf gegriffen + hatte — griff einer, war `outcome` schon `healed`, und der + Fehlschlag verschwand darin, ruhig eingefaerbt. --}} +

+ @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['outcome'] === 'tried') + {{ __('admin_settings.watchdog_tried', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }} + @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 {{-- Bound, not @disabled(): the directive compiles to inline PHP @@ -185,6 +225,16 @@ {{ __('admin_settings.update_now') }} + {{-- Tunnel-Rettung, in derselben Handlungsleiste wie die übrigen — + dieselbe Begründung wie beim Festnageln daneben (flex-wrap, + w-full sm:w-auto). Bestätigt im Modal (R23), weil es einen + laufenden Zustand auf dem Wirt anfasst. --}} + + + {{ __('admin_settings.rescue_action') }} + + {{-- Festnageln, in derselben Handlungsleiste wie Prüfen/Aktualisieren statt einer zweiten Leiste daneben — dieselbe Begründung wie bei den beiden Knöpfen selbst (flex-wrap, w-full sm:w-auto). Kein @@ -394,6 +444,45 @@

@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']) + {{-- Review-Runde 2 (C1 Teil 3): „erfolgreich" (update_state.succeeded) + behauptet mehr, als ein durchgelaufenes Skript weiss — es prüft + am Ende nicht nach, ob der Tunnel jetzt wirklich wieder Daten + empfängt, nur ob jeder einzelne Griff ausgerichtet hat, was nötig + war (siehe deploy/rescue-tunnel.sh). Eigene, bewusst zurückhaltende + Formulierung statt der Beschriftung, die für eine echte + Aktualisierung (git pull, Container neu, Migration) gilt. --}} +

+ {{ __('admin_settings.rescue_last_run', [ + 'state' => $update['rescue_last_run']['state'] === 'ok' + ? __('admin_settings.rescue_state_ok') + : __('admin_settings.update_state.failed'), + 'when' => $update['rescue_last_run']['finished_at']?->diffForHumans() ?? '—', + ]) }} + @if ($update['rescue_last_run']['error']) + · {{ $update['rescue_last_run']['error'] }} + @endif +

+ @endif + + {{-- Review-Runde 2 (C1 Teil 2): dasselbe
-Muster wie das + Protokoll der Aktualisierung darunter, nur für die Tunnel-Rettung — + der Betreiber soll den Satz mit dem Handgriff lesen können, ohne auf + den Wirt zu gehen. --}} + @if ($rescueLog) +
+ {{ __('admin_settings.rescue_log') }} +
{{ $rescueLog }}
+
+ @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/Admin/ConfirmRescueTunnelTest.php b/tests/Feature/Admin/ConfirmRescueTunnelTest.php new file mode 100644 index 0000000..4f25367 --- /dev/null +++ b/tests/Feature/Admin/ConfirmRescueTunnelTest.php @@ -0,0 +1,83 @@ +role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(ConfirmRescueTunnel::class) + ->assertSee(__('admin_settings.rescue_title')); +}); + +it('refuses to even mount the confirmation for an operator without site.manage', function () { + // Eine Livewire-Komponente ist ein oeffentlicher Endpunkt — auch das + // Modal selbst, nicht nur die Aktion, die es am Ende auslöst. + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(ConfirmRescueTunnel::class) + ->assertForbidden(); +}); + +it('leaves the deed to the page component, and only dispatches', function () { + // R23: das Modal mutiert nichts. Die Berechtigungsprüfung UND das + // eigentliche Anfordern bleiben an der einen Stelle, an der sie schon + // stehen (App\Livewire\Admin\Settings::rescueTunnel()). + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(ConfirmRescueTunnel::class) + ->call('confirm') + ->assertDispatched('rescue-tunnel-confirmed'); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); + +it('leaves a rescue request for the agent once the confirmation comes back', function () { + // Die Ereignis-Verdrahtung Modal → Seite: Settings faengt + // 'rescue-tunnel-confirmed' per #[On(...)] auf, genau wie beim Sperre- + // Loesen daneben. + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(AdminSettings::class) + ->dispatch('rescue-tunnel-confirmed'); + + $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); + + expect($request['kind'])->toBe('rescue-tunnel') + ->and($request['requested_by'])->toBe($owner->email); +}); + +it('refuses to rescue the tunnel for an operator without the capability, even via the event', function () { + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(AdminSettings::class) + ->dispatch('rescue-tunnel-confirmed') + ->assertForbidden(); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); diff --git a/tests/Feature/HostStepContractTest.php b/tests/Feature/HostStepContractTest.php new file mode 100644 index 0000000..9826ab7 --- /dev/null +++ b/tests/Feature/HostStepContractTest.php @@ -0,0 +1,85 @@ +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(); +}); diff --git a/tests/Feature/HostStepTest.php b/tests/Feature/HostStepTest.php index 5e165c8..ed7019d 100644 --- a/tests/Feature/HostStepTest.php +++ b/tests/Feature/HostStepTest.php @@ -130,9 +130,31 @@ it('reports the contract version the updater compares against', function () { // failure mode this number exists for; if update.sh asked for a version the // current installer never writes, every up-to-date server would be told to // run the installer again forever. - $needs = Str::between(file_get_contents(base_path('deploy/update.sh')), 'HOST_STEP_NEEDS=', "\n"); + // + // Compared at RUNTIME against deploy/lib/release.sh's + // release_host_step_needs(), not by pulling the literal `HOST_STEP_NEEDS=` + // line out of update.sh: since Task 2 update.sh no longer carries the bare + // number itself, it asks that shell function for it — + // `HOST_STEP_NEEDS="$(release_host_step_needs)"`. Extracting the text + // between `HOST_STEP_NEEDS=` and the newline would hand back the command + // substitution string, not a number, and this test would silently compare + // "3" against "0" forever without ever catching the two halves drifting + // apart. The left side (CONTRACT) and the right side (release_host_step_needs) + // come from two different files — install-agent.sh and deploy/lib/release.sh + // — so raising one without the other still turns this test red. + $needsProcess = Process::fromShellCommandline( + 'bash -c '.escapeshellarg( + 'set -Eeuo pipefail; . '.escapeshellarg(base_path('deploy/lib/release.sh')).'; release_host_step_needs' + ) + ); + $needsProcess->run(); - expect((int) trim($process->getOutput()))->toBe((int) trim($needs)); + expect($needsProcess->getExitCode())->toBe(0); + + $needs = trim($needsProcess->getOutput()); + + expect($needs)->toMatch('/^\d+$/') + ->and((int) trim($process->getOutput()))->toBe((int) $needs); }); it('grants one command line, not a script the service account can rewrite', function () { diff --git a/tests/Feature/RescueTunnelTest.php b/tests/Feature/RescueTunnelTest.php new file mode 100644 index 0000000..0cfbe86 --- /dev/null +++ b/tests/Feature/RescueTunnelTest.php @@ -0,0 +1,291 @@ + $peers Zeilen für "wg show wg0 peers" (leer = kein Zugang) + * @param array $transfer Zeilen für "wg show wg0 transfer" ("pubkey empfangen gesendet") + */ +function rescueTunnelStub(array $peers = ['PEER1'], array $transfer = ['PEER1 0 999'], bool $sudoOk = true, bool $ownContainer = true): string +{ + $dir = storage_path('app/deploy'); + File::ensureDirectoryExists($dir); + + $stub = $dir.'/stub-rescue'; + File::ensureDirectoryExists($stub); + + $peersOut = implode('\n', $peers); + $transferOut = implode('\n', $transfer); + $cmdlineOut = $ownContainer ? 'vpn-hub' : 'some-other-process'; + + File::put($stub.'/docker', <<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'); +}); + +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.rescue_state_ok')); +}); + +it('does not call a rescue attempt "succeeded" — a run that completed is not proof the tunnel works', function () { + // Review-Runde 2 (C1 Teil 3): update_state.succeeded ("erfolgreich") + // behauptet mehr, als ein durchgelaufenes rescue-tunnel.sh weiss. Dieser + // Test faellt auf den Vorzustand rot: dort las settings.blade.php das + // 'ok'-Ergebnis noch als admin_settings.update_state.succeeded. + 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) + ->assertDontSee(__('admin_settings.update_state.succeeded')); +}); + +it('shows the rescue log tail in the console, the same way the update log already does', function () { + // Review-Runde 2 (C1 Teil 2): der Log-Auszug gehoert in die Konsole, + // uebernimmt das Muster von UpdateChannel::lastLog() / 'update_log' + // statt eines neu erfundenen. + File::put(storage_path('app/deploy/rescue-last-run.log'), "Zeile eins\nACHTUNG: wg0 liess sich nicht hochziehen.\n"); + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee(__('admin_settings.rescue_log')) + ->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false); +}); + +it('shows nothing for the rescue log when no rescue has ever run', function () { + $owner = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertDontSee(__('admin_settings.rescue_log')); +}); + +// ── C1 Teil 1: das Skript selbst muss Misserfolg als Misserfolg melden ────── + +it('exits with a problem when not a single peer is loaded', function () { + // Der zweite der beiden im Review genannten Stellen: "Kein einziger + // Zugang geladen" endete bisher trotzdem auf Exit 0. + $stub = rescueTunnelStub(peers: [], transfer: []); + + $result = Process::path(base_path())->timeout(60)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + ])->run('bash deploy/rescue-tunnel.sh'); + + expect($result->exitCode())->not->toBe(0); +}); + +it('exits with a problem when stale conntrack entries cannot be cleared for lack of root', function () { + // DAS Fehlerszenario aus dem Review: 1 von 1 Zugaengen stumm, `sudo -n + // conntrack -D` scheitert (kein sudoers-Eintrag — laut Runbook auf einem + // frischen Wirt der Normalfall). Bisher: nur `bad(...)`, Exit 0. + $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false); + + $result = Process::path(base_path())->timeout(60)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + ])->run('bash deploy/rescue-tunnel.sh'); + + expect($result->exitCode())->not->toBe(0) + ->and($result->output())->toContain('Dafür fehlt mir Root'); +}); + +it('still exits clean when everything actually lines up, including on a host without its own tunnel container', function () { + // Gegenprobe zur Einteilung aus C1 Teil 1: die Meldung ueber einen noch + // fehlenden eigenen Tunnel-Container (Zeile 58/59) ist eine reine + // Versionsauskunft, kein Fehlschlag DIESES Laufs — sie darf den + // Rueckgabewert nicht anfassen, sonst meldete jeder Lauf auf einem + // aelteren Wirt "fehlgeschlagen", obwohl der Tunnel tadellos steht. + $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 100 999'], sudoOk: true, ownContainer: false); + + $result = Process::path(base_path())->timeout(60)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + ])->run('bash deploy/rescue-tunnel.sh'); + + expect($result->exitCode())->toBe(0) + ->and($result->output())->toContain('eigenen Tunnel-Container noch nicht'); +}); + +it('marks a rescue attempt failed end-to-end when conntrack cannot be cleared, and the console does not call it "succeeded"', function () { + // C1, der ganze Weg durch: das ECHTE deploy/update-agent.sh ruft das + // ECHTE deploy/rescue-tunnel.sh, genau wie auf dem Wirt. Der Nachweis + // wird — wie gefordert — im gerenderten HTML gefuehrt, nicht nur auf + // Datenebene: genau dort ist der vorige Fix schon einmal durchgerutscht. + $dir = storage_path('app/deploy'); + $stub = rescueTunnelStub(peers: ['PEER1'], transfer: ['PEER1 0 999'], sudoOk: false); + + // `git` scheitert sofort — dieser Tick soll die bereits im Postkasten + // liegende Bitte abarbeiten, kein echtes `git fetch` versuchen. + File::put($stub.'/git', "#!/bin/sh\nexit 1\n"); + Process::run("chmod +x {$stub}/git"); + + $owner = Operator::factory()->role('Owner')->create(); + app(UpdateChannel::class)->requestRescueTunnel($owner->email); + + $result = Process::path(base_path())->timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + ])->run('bash deploy/update-agent.sh >/dev/null 2>&1 || true'); + + expect($result->successful())->toBeTrue($result->errorOutput()); + + $run = json_decode(File::get($dir.'/rescue-last-run.json'), true); + expect($run['state'])->toBe('failed'); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->assertSee(__('admin_settings.update_state.failed')) + ->assertDontSee(__('admin_settings.update_state.succeeded')) + ->assertSee(__('admin_settings.rescue_log')); +}); diff --git a/tests/Feature/UpdateLockReleaseOnTheHostTest.php b/tests/Feature/UpdateLockReleaseOnTheHostTest.php index 67d05ea..e12c0ad 100644 --- a/tests/Feature/UpdateLockReleaseOnTheHostTest.php +++ b/tests/Feature/UpdateLockReleaseOnTheHostTest.php @@ -223,5 +223,9 @@ it('grants the new step in sudoers and raises the contract for it', function () expect($installer)->toContain('release-update-lock)') ->and($installer)->toContain('$HOST_STEP release-update-lock') ->and($installer)->toContain('CONTRACT=3') - ->and($updater)->toContain('HOST_STEP_NEEDS=3'); + // Die gebrauchte Vertragsversion steht seit deploy/lib/release.sh nur + // noch an einer Stelle (release_host_step_needs); update.sh fragt sie + // ab, statt sie ein zweites Mal als Zahl mitzuführen. Siehe + // tests/Feature/HostStepContractTest.php. + ->and($updater)->toContain('release_host_step_needs'); }); diff --git a/tests/Feature/WatchdogVisibilityTest.php b/tests/Feature/WatchdogVisibilityTest.php new file mode 100644 index 0000000..4deff39 --- /dev/null +++ b/tests/Feature/WatchdogVisibilityTest.php @@ -0,0 +1,334 @@ +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); +} + +/** + * Der wg0-Block, isoliert: vpn-hub laeuft, seine Konfiguration existiert, + * "wg show wg0" scheitert — der Waechter darf eingreifen und ruft + * "wg-quick up wg0". Ob der Griff wirklich gewirkt hat, entscheidet + * $recovers: danach meldet "wg show wg0" entweder wieder oben (true) oder + * weiterhin unten (false) — genau der Fall aus dem Task-1-Review, in dem + * `geheilt=true` VOR dieser zweiten Pruefung stand. + */ +function runWatchdogTunnelRescue(bool $recovers): array +{ + $dir = storage_path('app/deploy'); + File::ensureDirectoryExists($dir); + File::delete(File::glob($dir.'/*')); + + $stub = $dir.'/stub'; + File::ensureDirectoryExists($stub); + + $wgShowAfterUp = $recovers ? 'exit 0' : 'exit 1'; + + File::put($stub.'/docker', <<timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'STUB_UP_CALLED' => $dir.'/.stub-up-called', + 'STUB_WG_UP_CALLED' => $dir.'/.stub-wg-up-called', + ])->run('bash deploy/watchdog.sh >/dev/null 2>&1 || true'); + + expect($result->successful())->toBeTrue($result->errorOutput()); + + return json_decode(File::get($dir.'/watchdog-last-run.json'), true); +} + +/** + * Zwei Zweige im selben Lauf: fehlende Dienste kommen mit `up -d` wieder + * hoch (Zweig 1 heilt erfolgreich), UND wg0 laesst sich trotz `wg-quick up` + * nicht hochziehen (Zweig 3 scheitert). Genau das Szenario aus I1 — ein + * Erfolg und ein Misserfolg im selben Lauf. + */ +function runWatchdogPartialFailure(): array +{ + $dir = storage_path('app/deploy'); + File::ensureDirectoryExists($dir); + File::delete(File::glob($dir.'/*')); + + $stub = $dir.'/stub'; + File::ensureDirectoryExists($stub); + + File::put($stub.'/docker', <<timeout(90)->env([ + 'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'STUB_APP_STARTED' => $dir.'/.stub-app-started', + ])->run('bash deploy/watchdog.sh >/dev/null 2>&1 || true'); + + 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('reports healed when raising wg0 actually brought the tunnel back', function () { + $run = runWatchdogTunnelRescue(recovers: true); + + expect($run['outcome'])->toBe('healed') + ->and(implode(' ', $run['actions']))->toContain('wg0 steht wieder'); +}); + +it('does not claim healed when wg-quick ran but the tunnel stayed down', function () { + // Der Befund aus dem Task-1-Review: `geheilt` wurde wahr, bevor geprueft + // war, ob `wg-quick up wg0` ueberhaupt gewirkt hat. Die Konsole zeigt + // `outcome` jetzt einem Menschen — eine Luege hier waere ein Fehler, kein + // Journal-Detail mehr. + $run = runWatchdogTunnelRescue(recovers: false); + + expect($run['outcome'])->not->toBe('healed') + ->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. + // + // Review-Runde 2 (I1): `outcome` traegt seither ein eigenes `tried` statt + // `idle` mit Aktionen — watchdog.sh liefert diese Form fuer genau dieses + // Szenario jetzt selbst (siehe runWatchdogPartialFailure() weiter unten), + // und die Blade-Bedingung haengt nicht mehr an `idle`. + 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' => 'tried', + '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('wins the outcome with "tried" even when another branch in the same run healed something', function () { + // I1, auf Datenebene: das ECHTE watchdog.sh, nicht eine Nachbildung + // seiner Logik. Zweig 1 (fehlende Dienste) heilt erfolgreich, Zweig 3 + // (wg0) scheitert im selben Lauf — vorher stand `outcome` dann auf + // `healed`, weil `geheilt=true` schon durch Zweig 1 galt und nichts den + // Fehlschlag aus Zweig 3 mehr zurücknehmen konnte. + $run = runWatchdogPartialFailure(); + + expect($run['outcome'])->toBe('tried') + ->and(implode(' ', $run['actions']))->toContain('Es fehlen Dienste: app — starte sie.') + ->and(implode(' ', $run['actions']))->toContain('ACHTUNG: wg0 liess sich nicht hochziehen'); +}); + +it('does not read as calmly "intervened" when one branch failed, even though another one in the same run healed something', function () { + // I1, im gerenderten HTML: derselbe Zustand, den runWatchdogPartialFailure() + // oben tatsächlich erzeugt. Vor dem Fix stand `outcome` hier auf `healed` + // (Zweig 1 hatte gewirkt) — die Konsole zeigte watchdog_healed in ruhiger + // Farbe, und der ACHTUNG-Satz stand nur noch als Beiwerk im `:what` drin, + // ohne die Kennzeichnung "ohne Erfolg" und ohne text-warning. + 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' => 'tried', + 'actions' => [ + 'Es fehlen Dienste: app — starte sie.', + '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('ohne Erfolg', false) + ->assertSee('ACHTUNG: wg0 liess sich nicht hochziehen', false) + ->assertSeeHtml('text-warning') + ->assertDontSee('nichts zu tun'); +}); + +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(); +});