137 lines
6.4 KiB
Bash
Executable File
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
|