CluPilotCloud/deploy/watchdog.sh

265 lines
13 KiB
Bash
Executable File

#!/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 <<EOF && \
mv -f "$STATE_DIR/watchdog-last-run.json.tmp" "$STATE_DIR/watchdog-last-run.json" 2>/dev/null || true
{
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"outcome": "$ausgang",
"actions": [${eintraege%,}]
}
EOF