Fix-Welle Schlussreview: Tunnel-Rettung und Waechter melden Misserfolg ehrlich
C1: rescue-tunnel.sh zaehlt Probleme (kein Zugang geladen, conntrack ohne Root) und gibt sie als Exit-Code zurueck, statt bei jedem Fehlschlag Exit 0 zu melden. Die Konsole zeigt jetzt zusaetzlich das Protokoll der letzten Tunnel-Rettung (UpdateChannel::rescueLog(), dasselbe <details>-Muster wie das Update-Protokoll), und "erfolgreich" ist einem zurueckhaltenderen "durchgelaufen"/"completed" gewichen, das nicht mehr behauptet als das Skript wirklich weiss. I1: watchdog.sh gewann `geheilt=true` bisher unmittelbar nach `up -d`, `--force-recreate` und `artisan up`, ohne nachzupruefen, ob der Griff gewirkt hat. Ein neues `fehlgeschlagen`-Flag laesst einen Misserfolg den Lauf-Ausgang gewinnen, auch wenn ein anderer Zweig im selben Lauf erfolgreich war -- die Konsole zeigt jetzt outcome=tried statt sich hinter outcome=healed zu verstecken. M3: watchdog_stood_down nennt nicht mehr nur "ein Update" als Sperrenhalter -- die Sperre haelt inzwischen auch proxy-hosts, restart, archive-key und rescue-tunnel. M4: das Runbook nennt den Konsolen-Knopf jetzt vor der Kommandozeile. M7: ConfirmRescueTunnel hat jetzt einen Test, nach dem Vorbild von ConfirmReleaseUpdateLock -- mount()s authorize und die Ereignisverdrahtung Modal -> Seite waren ungeprueft. Volle Suite: 3036 bestanden, 0 fehlgeschlagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
parent
1a5843670c
commit
8733686dd3
|
|
@ -861,6 +861,11 @@ 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 <details>-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(),
|
||||
|
|
|
|||
|
|
@ -154,6 +154,18 @@ final class UpdateChannel
|
|||
|
||||
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 <details>, 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';
|
||||
|
||||
|
|
@ -957,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<string, mixed> */
|
||||
private function readJson(string $relative): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -105,6 +105,16 @@ 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=()
|
||||
|
|
@ -121,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
|
||||
|
||||
|
|
@ -147,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
|
||||
|
|
@ -176,6 +209,14 @@ if printf '%s\n' "$ist" | grep -qx vpn-hub; then
|
|||
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
|
||||
|
|
@ -210,14 +251,24 @@ 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
|
||||
|
||||
|
|
@ -229,9 +280,12 @@ fi
|
|||
# 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:
|
||||
# Vier Ausgaenge, weil sie vier verschiedene Dinge bedeuten:
|
||||
# idle — nachgesehen, nichts zu tun. Der Normalfall.
|
||||
# healed — eingegriffen. Was, steht in `actions`.
|
||||
# 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.
|
||||
#
|
||||
|
|
@ -239,7 +293,9 @@ fi
|
|||
# und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand
|
||||
# nachsieht.
|
||||
ausgang=idle
|
||||
if [[ "$geheilt" == true ]]; then
|
||||
if [[ "$fehlgeschlagen" == true ]]; then
|
||||
ausgang=tried
|
||||
elif [[ "$geheilt" == true ]]; then
|
||||
ausgang=healed
|
||||
elif [[ "$SPERRE" == verwehrt ]]; then
|
||||
ausgang=stood_down
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -144,10 +144,16 @@ return [
|
|||
// August 2026 gar nicht (App\Services\Deployment\WatchdogLog).
|
||||
'watchdog_idle' => 'Zuletzt nachgesehen :when — nichts zu tun.',
|
||||
'watchdog_healed' => 'Zuletzt eingegriffen :when: :what',
|
||||
'watchdog_stood_down' => ':when zurückgetreten, weil ein Update lief.',
|
||||
// Fix-Runde 1: gegriffen, aber ohne Erfolg — z. B. wg0 blieb trotz
|
||||
// wg-quick up unten. Ohne diesen Satz verschwand der Fall in
|
||||
// „nichts zu tun" (watchdog_idle), weil outcome dafür weiterhin idle ist.
|
||||
// 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.',
|
||||
|
||||
|
|
@ -181,6 +187,14 @@ return [
|
|||
// 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
|
||||
|
|
|
|||
|
|
@ -141,10 +141,16 @@ return [
|
|||
// it until August 2026 (App\Services\Deployment\WatchdogLog).
|
||||
'watchdog_idle' => 'Last checked :when — nothing to do.',
|
||||
'watchdog_healed' => 'Last intervened :when: :what',
|
||||
'watchdog_stood_down' => 'Stood down :when because an update was running.',
|
||||
// Fix round 1: intervened, but without success — e.g. wg0 stayed down
|
||||
// despite wg-quick up. Without this sentence the case disappeared into
|
||||
// "nothing to do" (watchdog_idle), because outcome is still idle.
|
||||
// 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.',
|
||||
|
||||
|
|
@ -178,6 +184,14 @@ return [
|
|||
// 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
|
||||
|
|
|
|||
|
|
@ -121,20 +121,23 @@
|
|||
{{-- Der Waechter. Bis August 2026 sah ihn die Konsole gar nicht — er redet
|
||||
ins Journal, und das liegt auf dem Wirt. --}}
|
||||
@if ($watchdog)
|
||||
{{-- Fix-Runde 1 (Task 3): ein idle-Ausgang MIT Aktionen ist kein
|
||||
"nichts zu tun" — der Waechter hat gegriffen, der Griff hat nur
|
||||
nicht gewirkt (z. B. wg0 blieb trotz wg-quick up unten, siehe
|
||||
watchdog.sh). Vorher fiel genau dieser Fall auf `watchdog_idle`
|
||||
durch, weil das die einzige Verzweigung ohne `outcome`-Pruefung
|
||||
war — eine ruhige graue Zeile waehrend kein Host erreichbar ist. --}}
|
||||
<p class="mt-2 text-sm {{ $watchdog['stale'] || ($watchdog['outcome'] === 'idle' && $watchdog['actions']) ? 'text-warning' : 'text-muted' }}">
|
||||
{{-- 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. --}}
|
||||
<p class="mt-2 text-sm {{ $watchdog['stale'] || $watchdog['outcome'] === 'tried' ? 'text-warning' : 'text-muted' }}">
|
||||
@if ($watchdog['stale'])
|
||||
{{ __('admin_settings.watchdog_stale', ['when' => $watchdog['at']->local()->diffForHumans()]) }}
|
||||
@elseif ($watchdog['outcome'] === 'healed')
|
||||
{{ __('admin_settings.watchdog_healed', ['when' => $watchdog['at']->local()->diffForHumans(), 'what' => implode('; ', $watchdog['actions'])]) }}
|
||||
@elseif ($watchdog['outcome'] === 'stood_down')
|
||||
{{ __('admin_settings.watchdog_stood_down', ['when' => $watchdog['at']->local()->diffForHumans()]) }}
|
||||
@elseif ($watchdog['actions'])
|
||||
@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()]) }}
|
||||
|
|
@ -438,9 +441,18 @@
|
|||
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. --}}
|
||||
<p class="mt-3 text-xs {{ $update['rescue_last_run']['state'] === 'failed' ? 'text-danger' : 'text-muted' }}">
|
||||
{{ __('admin_settings.rescue_last_run', [
|
||||
'state' => __('admin_settings.update_state.'.($update['rescue_last_run']['state'] === 'ok' ? 'succeeded' : 'failed')),
|
||||
'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'])
|
||||
|
|
@ -449,6 +461,17 @@
|
|||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Review-Runde 2 (C1 Teil 2): dasselbe <details>-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)
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.rescue_log') }}</summary>
|
||||
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $rescueLog }}</pre>
|
||||
</details>
|
||||
@endif
|
||||
|
||||
{{-- Open while it runs: "läuft gerade" on its own tells an operator
|
||||
nothing about whether it is progressing or wedged. --}}
|
||||
@if ($updateLog)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\ConfirmRescueTunnel;
|
||||
use App\Livewire\Admin\Settings as AdminSettings;
|
||||
use App\Models\Operator;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* M7 (Review-Runde 2): alle fünf Geschwister-Modals (ConfirmSaveSecret,
|
||||
* ConfirmForgetSecret, ConfirmReissueVpnPeer, ConfirmDisableTwoFactor,
|
||||
* ConfirmReleaseUpdateLock) haben einen Test — dieses hatte keinen. `mount()`s
|
||||
* `authorize` und die Ereignis-Verdrahtung Modal → Seite waren ungeprüft.
|
||||
*
|
||||
* Nach dem Vorbild von tests/Feature/Admin/UpdateLockReleaseTest.php.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::delete(File::glob(storage_path('app/deploy/*')));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
it('lets an operator with site.manage open the confirmation', function () {
|
||||
$owner = Operator::factory()->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();
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ use App\Livewire\Admin\Settings;
|
|||
use App\Models\Operator;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
|
|
@ -21,6 +22,51 @@ afterEach(function () {
|
|||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Baut eine `docker`- und `sudo`-Attrappe für deploy/rescue-tunnel.sh und
|
||||
* gibt ihr Verzeichnis zurück. Hier läuft das ECHTE Skript, nicht eine
|
||||
* Nachbildung seiner Logik — genau daran ist der letzte Fix schon einmal
|
||||
* gescheitert (siehe WatchdogVisibilityTest).
|
||||
*
|
||||
* @param array<int, string> $peers Zeilen für "wg show wg0 peers" (leer = kein Zugang)
|
||||
* @param array<int, string> $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', <<<SH
|
||||
#!/bin/sh
|
||||
case "\$*" in
|
||||
*"ps --status running vpn-hub"*) printf '%s\\n' vpn-hub ;;
|
||||
*"vpn-hub sh -c"*) printf '%s\\n' '$cmdlineOut' ;;
|
||||
*"vpn-hub wg-quick up wg0"*) exit 0 ;;
|
||||
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
|
||||
*"vpn-hub wg show wg0 peers"*) printf '%s\\n' '$peersOut' ;;
|
||||
*"vpn-hub wg show wg0 transfer"*) printf '%s\\n' '$transferOut' ;;
|
||||
*"vpn-hub wg show wg0"*) exit 0 ;;
|
||||
*"vpn-hub ls -l /etc/wireguard/"*) exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
SH);
|
||||
|
||||
File::put($stub.'/sudo', $sudoOk
|
||||
? "#!/bin/sh\nexit 0\n"
|
||||
: "#!/bin/sh\nexit 1\n");
|
||||
|
||||
Process::run("chmod +x {$stub}/docker {$stub}/sudo");
|
||||
|
||||
return $stub;
|
||||
}
|
||||
|
||||
it('puts a rescue request in the same mailbox as every other kind', function () {
|
||||
// Kein zweiter Weg: dieselbe Datei, derselbe Agent, derselbe Lauf.
|
||||
expect(app(UpdateChannel::class)->requestRescueTunnel('chef@example.com'))->toBeTrue();
|
||||
|
|
@ -127,5 +173,119 @@ it('shows a successful rescue attempt too', function () {
|
|||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(Settings::class)
|
||||
->assertSee(__('admin_settings.update_state.succeeded'));
|
||||
->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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,7 +36,19 @@ function runWatchdog(string $running = "app\nredis", bool $holdLock = false): ar
|
|||
#!/bin/sh
|
||||
case "\$*" in
|
||||
*config*) printf '%s\\n' app redis ;;
|
||||
*"ps "*) printf '%s\\n' '$running' ;;
|
||||
*"ps "*)
|
||||
# Review-Runde 2 (I1): watchdog.sh prueft nach `up -d` jetzt
|
||||
# NOCH EINMAL nach, ob es gewirkt hat. Ohne diese Reaktion auf
|
||||
# den eigenen Aufruf blieb "app" fuer den zweiten Blick
|
||||
# ebenso fehlend wie fuer den ersten, und ein wirklich
|
||||
# erfolgreicher Heilversuch waere in diesem Test nicht mehr
|
||||
# von einem gescheiterten zu unterscheiden gewesen.
|
||||
if [ -f "\$STUB_UP_CALLED" ]; then
|
||||
printf '%s\\n' app redis
|
||||
else
|
||||
printf '%s\\n' '$running'
|
||||
fi
|
||||
;;
|
||||
*getent*) exit 0 ;;
|
||||
*storage/framework/down*) exit 1 ;;
|
||||
*" up "*) touch "\$STUB_UP_CALLED" ;;
|
||||
|
|
@ -116,6 +128,53 @@ function runWatchdogTunnelRescue(bool $recovers): array
|
|||
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', <<<SH
|
||||
#!/bin/sh
|
||||
case "\$*" in
|
||||
*config*) printf '%s\\n' app redis ;;
|
||||
*"ps "*)
|
||||
if [ -f "\$STUB_APP_STARTED" ]; then
|
||||
printf '%s\\n' app redis vpn-hub
|
||||
else
|
||||
printf '%s\\n' redis vpn-hub
|
||||
fi
|
||||
;;
|
||||
*"vpn-hub test -f /etc/wireguard/wg0.conf"*) exit 0 ;;
|
||||
*"vpn-hub wg-quick up wg0"*) exit 0 ;;
|
||||
*"vpn-hub wg show wg0"*) exit 1 ;;
|
||||
*getent*) exit 0 ;;
|
||||
*storage/framework/down*) exit 1 ;;
|
||||
*" up "*) touch "\$STUB_APP_STARTED" ;;
|
||||
esac
|
||||
exit 0
|
||||
SH);
|
||||
Process::run("chmod +x {$stub}/docker");
|
||||
|
||||
$result = Process::path(base_path())->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'));
|
||||
});
|
||||
|
|
@ -170,10 +229,15 @@ it('shows the operator that an intervention failed, instead of "nothing to do"',
|
|||
// 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' => 'idle',
|
||||
'outcome' => 'tried',
|
||||
'actions' => [
|
||||
'wg0 steht nicht — ziehe den Tunnel hoch.',
|
||||
'ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md.',
|
||||
|
|
@ -191,6 +255,45 @@ it('shows the operator that an intervention failed, instead of "nothing to do"',
|
|||
->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'));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue