#!/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 dieselbe Sperre wie # der Update-Agent und geht weg, wenn er sie nicht bekommt. 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' "$*" } # Solange ein Update läuft, ist Stillstand normal und Eingreifen schädlich. mkdir -p "$STATE_DIR" 2>/dev/null || true exec 9>"$LOCK" 2>/dev/null || exit 0 flock -n 9 || exit 0 geheilt=false # ── 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="$(docker compose config --services 2>/dev/null | sort || true)" ist="$(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" ]]; then say "Es fehlen Dienste: $fehlt — starte sie." docker compose up -d >/dev/null 2>&1 || true geheilt=true sleep 10 ist="$(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 ! 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." docker compose up -d --force-recreate >/dev/null 2>&1 || true geheilt=true sleep 15 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 docker compose ps --services --status running 2>/dev/null | grep -qx vpn-hub; then if docker compose exec -T vpn-hub test -f /etc/wireguard/wg0.conf >/dev/null 2>&1; then if ! docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then say "wg0 steht nicht — ziehe den Tunnel hoch." docker compose exec -T vpn-hub wg-quick up wg0 >/dev/null 2>&1 || true geheilt=true if 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 # ── 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 oben ist der eigentliche Schutz: läuft ein Update, kommt dieser # Code gar nicht erst dran. 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" ]] && 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 if [[ "$phase_alt" == true ]]; then say "Der Wartungsmodus haengt seit ueber einer halben Stunde ohne laufendes Update — beende ihn." 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