#!/usr/bin/env bash # # CluPilot — der Wächter. # # Läuft jede Minute und stellt genau eine Frage: steht, was stehen soll? Wenn # nein, richtet er es. Er ist die Antwort auf einen Vorfall, bei dem ein # abgebrochenes Update den Stapel halb unten liegen ließ, der Tunnel weg war und # die Seite eine Stunde lang 500 antwortete — bis jemand von Hand nachsah. # # ER LÄUFT AUF DEM WIRT, nicht in einem Container. Das ist Absicht: ein Wächter # im Container müsste den Docker-Socket hineingereicht bekommen, und das ist # Root auf dem Wirt für jeden, der je in diesen Container kommt. Und er wäre # genau dann tot, wenn man ihn braucht — nämlich wenn der Stapel unten ist. # # WAS ER NICHT TUT: raten. Er kennt vier Fehlerbilder, alle vier sind heute # schon einmal echt passiert, und für jedes gibt es genau einen Griff. Was er # nicht kennt, protokolliert er und lässt es in Ruhe. Ein Wächter, der bei # Unbekanntem herumprobiert, ist der nächste Ausfall. # # Er fasst NICHTS an, solange ein Update läuft — er nimmt dafür dieselbe Sperre # wie der Update-Agent. Aber erst, wenn es etwas zu richten gibt: siehe unten. set -uo pipefail cd "$(cd "$(dirname "$0")/.." && pwd)" STATE_DIR="storage/app/deploy" LOCK="$STATE_DIR/.agent.lock" HOLD="$STATE_DIR/maintenance-hold" PHASE_FILE="$STATE_DIR/update-phase" LOG_TAG="clupilot-watchdog" # Ins Journal, nicht in eine eigene Datei: der Wächter läuft als systemd-Dienst, # und dort gehört seine Stimme hin. `logger` fehlt praktisch nie; wenn doch, # reicht die Standardausgabe, die systemd ohnehin einsammelt. 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+=("$*") } mkdir -p "$STATE_DIR" 2>/dev/null || true # ── Kein Aufruf nach draußen ohne Frist ────────────────────────────────────── # # Der Update-Agent hat das am 4. August 2026 gelernt, der Wächter nicht: ein # `docker compose exec`, das auf den Docker-Daemon wartet, hängt unbegrenzt. # Der Agent hielt dabei die Sperre stundenlang, und die Konsole meldete „kommt # seit 08:02 nicht an die Arbeit" — vierzehn Aufrufe hier hatten dieselbe # Lücke. # # `-k`, weil eine Frist ohne Nachdruck keine Frist ist: `timeout` schickt nach # der Zeit ein SIGTERM, und `docker compose exec` stirbt daran nicht, wenn es # auf den Daemon wartet — danach wartet `timeout` selbst unbegrenzt weiter. # # Zwei Längen, weil Nachsehen und Eingreifen nicht dasselbe kosten: ein Blick # ist in Sekunden vorbei oder er ist krank, ein Neuerzeugen von Containern darf # dauern. frist() { timeout -k 5 20 "$@"; } lange_frist() { timeout -k 10 120 "$@"; } # ── Die Sperre — erst beim Eingriff, nicht beim Nachsehen ──────────────────── # # Bis hierher nahm der Wächter die Sperre in seinen ersten Zeilen, für den # ganzen Lauf. Das war der Fehler, und er kostete Wochen Fehlsuche: # # Wächter und Update-Agent haben BEIDE einen minütlichen Zeitgeber und nahmen # BEIDE dieselbe Sperre, bevor sie wussten, ob sie überhaupt etwas tun würden. # Der Wächter tut an fast jedem Tag nichts — alle vier Prüfungen unten sind # lesend. Er hielt die Sperre trotzdem, jede Minute; der Agent kam eine Sekunde # später, ging leer aus und schrieb einen Übersprung-Vermerk. Und weil der # Agent der ist, der an die Konsole berichtet, stand dort eine Dauerstörung, # während beide Dienste genau das taten, was sie sollten. # # Versetzte Zeitgeber hätten das nur seltener gemacht, nicht weg: zwei Takte # derselben Länge wandern gegeneinander, und systemd zieht sie über AccuracySec # aktiv auf gemeinsame Weckpunkte zusammen. Eine zweite Sperre wäre schlimmer # als der Fehler — dann liefe der Wächter mitten in ein Update hinein. # # Also: nachsehen ohne Sperre, und die Sperre erst nehmen, wenn wirklich etwas # zu richten ist. Im gesunden Fall fasst der Wächter sie nie an. SPERRE=frei darf_eingreifen() { [[ "$SPERRE" == genommen ]] && return 0 [[ "$SPERRE" == verwehrt ]] && return 1 exec 9>"$LOCK" 2>/dev/null || { SPERRE=verwehrt; return 1; } if flock -n 9; then SPERRE=genommen return 0 fi # Einmal gemerkt und einmal gesagt: die folgenden Prüfungen sollen es nicht # erneut versuchen und die Meldung nicht wiederholen. SPERRE=verwehrt say "Es gaebe etwas zu richten, aber die Sperre wird gehalten — ein Update laeuft. Dieser Lauf greift nicht ein." return 1 } geheilt=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 # vpn-gateway zählen also nur, wo der Tunnel überhaupt eingerichtet ist. soll="$(frist docker compose config --services 2>/dev/null | sort || true)" ist="$(frist docker compose ps --services --status running 2>/dev/null | sort || true)" if [[ -n "$soll" ]]; then fehlt="$(comm -23 <(printf '%s\n' "$soll") <(printf '%s\n' "$ist") | tr '\n' ' ' | sed 's/ *$//')" 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)" fi fi # ── 2. Finden die Container einander noch? ─────────────────────────────────── # # Das Fehlerbild, das am teuersten war und am harmlosesten aussah: nach einem # Neuaufbau des Compose-Netzes hingen die nur NEU GESTARTETEN Container noch am # alten. Alle liefen, `docker compose ps` sah tadellos aus — und jede # Namensauflösung scheiterte. Die Seite antwortete 500, der Warteschlangen- # Arbeiter startete im Kreis, und nichts davon nannte die Ursache. # # Ein Neustart hilft dabei NICHT; die Container müssen neu erzeugt werden. if printf '%s\n' "$ist" | grep -qx app && printf '%s\n' "$ist" | grep -qx redis; then if ! frist docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then # Der einzige Griff hier, der weh tut: `--force-recreate` reißt jede # offene Verbindung ab. Deshalb wird NACH dem Nehmen der Sperre noch # einmal nachgesehen — der Blick oben war ungesperrt, und genau # dazwischen kann ein Update fertig geworden sein, das die Auflösung # längst wieder in Ordnung gebracht hat. Ohne diese zweite Frage # erzeugte der Wächter gesunde Container neu. 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 fi fi fi # ── 3. Steht der Tunnel? ───────────────────────────────────────────────────── # # Der wichtigste Punkt: ohne wg0 ist kein Host erreichbar, keine Provisionierung # möglich und kein Terminal. Ohne Konfiguration ist der Tunnel auf diesem Server # schlicht nicht eingerichtet — dann gibt es nichts zu heilen. if printf '%s\n' "$ist" | grep -qx vpn-hub; then if frist docker compose exec -T vpn-hub test -f /etc/wireguard/wg0.conf >/dev/null 2>&1; then if ! frist docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; 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` 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." fi fi fi fi fi # ── 4. Hängt der Wartungsmodus? ────────────────────────────────────────────── # # Ein Update, das mittendrin abbricht, lässt die Seite unten. Bis hierher blieb # sie das, bis jemand nachsah — im Ernstfall stundenlang. # # Die Sperre ist der eigentliche Schutz: läuft ein Update, bekommt der Wächter # sie nicht und lässt den Wartungsmodus in Ruhe. Die halbe Stunde darunter ist # der zweite Riegel für einen Lauf, der ohne Aufräumen gestorben ist und seine # Sperre nie freigab. # # `maintenance-hold` ist die Handbremse: wer die Seite bewusst unten haben will, # legt die Datei an, und der Wächter fasst den Wartungsmodus nicht mehr an. if [[ ! -f "$HOLD" ]] && frist docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then phase_alt=true if [[ -f "$PHASE_FILE" ]]; then alter=$(( $(date +%s) - $(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0) )) [[ "$alter" -lt 1800 ]] && phase_alt=false fi # Auch hier die zweite Frage nach dem Nehmen der Sperre: die Seite hochzu- # holen, während ein Update gerade migriert, ist der Schaden, gegen den # dieser ganze Block abgesichert gehört. Zwischen dem ungesperrten Blick # oben und der Sperre kann ein Update fertig geworden sein — dann ist die # Seite längst oben und es gibt nichts zu tun. if [[ "$phase_alt" == true ]] && darf_eingreifen \ && 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 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 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. # # 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