CluPilotCloud/deploy/watchdog.sh

137 lines
6.4 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 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