259 lines
12 KiB
Bash
Executable File
259 lines
12 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=true
|
|
|
|
if frist docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then
|
|
say "wg0 steht wieder."
|
|
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
|