#!/usr/bin/env bash # ============================================================================= # update.sh - aktualisiert das Projekt (git pull --ff-only, falls dieses # Verzeichnis ein Git-Checkout ist) und die Caddy-/HAProxy-Images, validiert # die neue Konfiguration VOR jedem Neustart und macht bei fehlgeschlagenem # Healthcheck automatisch ein Rollback. # # Verzeichnismodell: dieses Verzeichnis IST der Git-Checkout (analog zu # mailcow-dockerized) - kein separates Laufzeitverzeichnis, kein Sync-Schritt. # `git clone /opt/reverse-proxy` und hier direkt arbeiten. # # Aufruf: # update/update.sh interaktiv: fragt vor dem Anwenden nach # update/update.sh --dry-run zeigt verfügbare Updates, fragt nichts, ändert nichts # update/update.sh -f|--force wendet ohne Rückfrage an (für Cron/systemd) # update/update.sh --yes Alias für --force (Kompatibilität) # # Hooks (optional, siehe update/*_update_hook.sh.example): # update/pre_update_hook.sh läuft ganz am Anfang, falls vorhanden # update/post_update_hook.sh läuft nach einem angewendeten Update # (erfolgreich oder mit Rollback), falls vorhanden # # Exit-Codes (bewusst für Monitoring/CheckMK-Einbindung gewählt): # 0 = keine Änderungen bzw. Dry-Run ohne Befund bzw. vom Nutzer abgelehnt # 1 = Update erfolgreich angewendet # 2 = Update fehlgeschlagen (mit oder ohne erfolgreichem Rollback), oder # Bestätigung erforderlich aber nicht möglich (nicht-interaktiv ohne --force) # - siehe update/update.log # ============================================================================= set -euo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_DIR" LOG_DIR="$PROJECT_DIR/update" LOG_FILE="$LOG_DIR/update.log" mkdir -p "$LOG_DIR" DRY_RUN=0 FORCE=0 HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; -f|--force|--yes) FORCE=1 ;; -h|--help) echo "Usage: $0 [--dry-run] [-f|--force]" echo " --dry-run zeigt verfuegbare Updates, fragt nichts, aendert nichts" echo " -f, --force wendet ohne interaktive Rueckfrage an (fuer Cron/systemd)" echo " --yes Alias fuer --force" echo "Ohne Optionen: interaktiv, fragt vor dem Anwenden nach." exit 0 ;; *) echo "Unbekannte Option: $arg" >&2 exit 64 ;; esac done # .env laden (gleiche Datei, die auch docker-compose.yml verwendet) ----------- if [ -f "$PROJECT_DIR/.env" ]; then set -a # shellcheck disable=SC1091 source "$PROJECT_DIR/.env" set +a fi HAPROXY_IMAGE="${HAPROXY_IMAGE:-haproxy:3.2-alpine}" CADDY_IMAGE="${CADDY_IMAGE:-caddy:2.11-alpine}" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" } run_hook() { local hook_file="$PROJECT_DIR/update/$1" if [ -f "$hook_file" ]; then log "Fuehre $1 aus..." if bash "$hook_file" >> "$LOG_FILE" 2>&1; then log "$1 erfolgreich." else log "WARNUNG: $1 ist mit Fehler beendet worden (Exit-Code $?), mache trotzdem weiter." fi fi } # einfache Log-Rotation, keine externen Abhängigkeiten (z.B. logrotate) nötig if [ -f "$LOG_FILE" ] && [ "$(wc -c < "$LOG_FILE")" -gt 5242880 ]; then mv "$LOG_FILE" "$LOG_FILE.1" fi log "=== Update-Lauf gestartet (dry-run=$DRY_RUN, force=$FORCE) ===" trap 'log "=== Update-Lauf abgebrochen (unerwarteter Fehler, Zeile $LINENO) ==="' ERR run_hook "pre_update_hook.sh" PROJECT_CHANGED=0 # ----------------------------------------------------------------------- # 1. Projekt selbst aktualisieren (git pull --ff-only, falls dieses # Verzeichnis ein Git-Checkout mit konfiguriertem Upstream ist). # Fast-forward-only, damit lokale Änderungen niemals überschrieben # werden - schlägt der Pull fehl, wird nur gewarnt, nicht erzwungen. # ----------------------------------------------------------------------- if [ -d "$PROJECT_DIR/.git" ]; then if git fetch --quiet 2>>"$LOG_FILE"; then BEHIND="$(git rev-list 'HEAD..@{u}' --count 2>/dev/null || echo 0)" if [ "$BEHIND" -gt 0 ]; then log "Projekt: $BEHIND neue(r) Commit(s) verfuegbar" else log "Projekt: bereits aktuell" fi else log "Projekt: WARNUNG - git fetch fehlgeschlagen (kein Netzwerk/kein Remote konfiguriert?), Selbst-Update uebersprungen" BEHIND=0 fi else log "Projekt: kein Git-Checkout - Selbst-Update uebersprungen (config/sites.yaml muss manuell gepflegt werden)" BEHIND=0 fi # ----------------------------------------------------------------------- # 2. Aktuellen Image-Stand merken (Grundlage für Rollback) + neue Images # pruefen. Bleibt bewusst auf der in .env gepinnten Minor-Version # (z.B. haproxy:3.2-alpine) - nur neue Patch-/Security-Builds, keine # automatischen Major/Minor-Sprünge. Größere Versionssprünge: Tag in # .env manuell ändern, dann `make reload`. # ----------------------------------------------------------------------- OLD_HAPROXY_ID="$(docker compose images -q haproxy 2>/dev/null || true)" OLD_CADDY_ID="$(docker compose images -q caddy 2>/dev/null || true)" log "Pruefe auf neue Images ($HAPROXY_IMAGE, $CADDY_IMAGE)..." docker compose pull haproxy caddy >> "$LOG_FILE" 2>&1 NEW_HAPROXY_ID="$(docker compose images -q haproxy)" NEW_CADDY_ID="$(docker compose images -q caddy)" IMAGES_CHANGED=0 if [ "$OLD_HAPROXY_ID" != "$NEW_HAPROXY_ID" ]; then log " -> haproxy: neues Image (${OLD_HAPROXY_ID:0:12} -> ${NEW_HAPROXY_ID:0:12})" IMAGES_CHANGED=1 else log " -> haproxy: bereits aktuell (${NEW_HAPROXY_ID:0:12})" fi if [ "$OLD_CADDY_ID" != "$NEW_CADDY_ID" ]; then log " -> caddy: neues Image (${OLD_CADDY_ID:0:12} -> ${NEW_CADDY_ID:0:12})" IMAGES_CHANGED=1 else log " -> caddy: bereits aktuell (${NEW_CADDY_ID:0:12})" fi if [ "$DRY_RUN" -eq 1 ]; then log "=== Dry-Run beendet, nichts wurde angewendet ===" exit 0 fi if [ "$IMAGES_CHANGED" -eq 0 ] && [ "$BEHIND" -eq 0 ]; then log "=== Keine Aenderungen, fertig ===" exit 0 fi # ----------------------------------------------------------------------- # 3. Bestätigung einholen (mailcow-Stil): interaktiv nachfragen, außer # --force/-f/--yes wurde übergeben. Ohne Terminal UND ohne --force wird # sauber abgebrochen statt zu hängen oder blind anzuwenden. # ----------------------------------------------------------------------- if [ "$FORCE" -ne 1 ]; then if [ -t 0 ]; then echo "" read -r -p "Update anwenden? Caddy und HAProxy werden neu gestartet. [y/N] " response < /dev/tty if [[ ! "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then log "=== Vom Nutzer abgelehnt, nichts wurde angewendet ===" exit 0 fi else log "FEHLER: Aenderungen verfuegbar, aber nicht interaktiv und --force nicht gesetzt - breche ab." exit 2 fi fi # ----------------------------------------------------------------------- # 4. Projekt-Update jetzt tatsächlich ziehen (falls vorhanden) # ----------------------------------------------------------------------- if [ "$BEHIND" -gt 0 ]; then if git pull --ff-only --quiet 2>>"$LOG_FILE"; then log "Projekt: aktualisiert ($BEHIND Commit(s))" PROJECT_CHANGED=1 else log "Projekt: WARNUNG - git pull --ff-only fehlgeschlagen (lokale Aenderungen? divergierter Branch?), uebersprungen" fi fi # ----------------------------------------------------------------------- # 5. Konfiguration neu generieren (reines Bash+AWK, kein Docker-Image # nötig) und VOR jedem Neustart validieren. Bei Fehlern wird nichts # angefasst. # ----------------------------------------------------------------------- log "Generiere Konfiguration aus config/sites.yaml..." if ! "$PROJECT_DIR/generate.sh" >> "$LOG_FILE" 2>&1; then log "FEHLER: Config-Generierung fehlgeschlagen - breche ab, nichts wurde neu gestartet." run_hook "post_update_hook.sh" exit 2 fi log "Validiere generated/Caddyfile..." if ! docker compose run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >> "$LOG_FILE" 2>&1; then log "FEHLER: generated/Caddyfile ist ungueltig - breche ab, nichts wurde neu gestartet." run_hook "post_update_hook.sh" exit 2 fi log "Validiere generated/haproxy.cfg mit dem neuen Image..." if ! docker compose run --rm --no-deps haproxy haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg >> "$LOG_FILE" 2>&1; then log "FEHLER: generated/haproxy.cfg ist mit dem neuen Image ungueltig - breche ab, nichts wurde neu gestartet." run_hook "post_update_hook.sh" exit 2 fi # ----------------------------------------------------------------------- # 6. Neu starten + Healthcheck abwarten # ----------------------------------------------------------------------- wait_healthy() { local svc="$1" elapsed=0 cid status cid="$(docker compose ps -q "$svc")" [ -z "$cid" ] && return 1 while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do status="$(docker inspect --format='{{.State.Health.Status}}' "$cid" 2>/dev/null || echo none)" [ "$status" = "healthy" ] && return 0 [ "$status" = "unhealthy" ] && return 1 sleep 3 elapsed=$((elapsed + 3)) done return 1 } log "Starte caddy + haproxy mit aktualisierten Images neu..." docker compose up -d --no-deps caddy haproxy >> "$LOG_FILE" 2>&1 ALL_HEALTHY=1 for svc in caddy haproxy; do if wait_healthy "$svc"; then log " -> $svc: healthy" else log " -> $svc: NICHT healthy innerhalb von ${HEALTH_TIMEOUT}s" ALL_HEALTHY=0 fi done if [ "$ALL_HEALTHY" -eq 1 ]; then log "=== Update erfolgreich angewendet ===" run_hook "post_update_hook.sh" exit 1 fi # ----------------------------------------------------------------------- # 7. Rollback: alte, lokal noch vorhandene Image-IDs zurück auf den in .env # gepinnten Tag mappen und neu starten. # ----------------------------------------------------------------------- log "Healthcheck fehlgeschlagen - fuehre Rollback durch..." [ -n "$OLD_HAPROXY_ID" ] && docker tag "$OLD_HAPROXY_ID" "$HAPROXY_IMAGE" [ -n "$OLD_CADDY_ID" ] && docker tag "$OLD_CADDY_ID" "$CADDY_IMAGE" docker compose up -d --no-deps caddy haproxy >> "$LOG_FILE" 2>&1 ROLLBACK_OK=1 for svc in caddy haproxy; do if wait_healthy "$svc"; then log " -> $svc: nach Rollback wieder healthy" else log " -> $svc: KRITISCH - auch nach Rollback nicht healthy, bitte manuell pruefen (docker compose logs $svc)" ROLLBACK_OK=0 fi done if [ "$ROLLBACK_OK" -eq 1 ]; then log "=== Update fehlgeschlagen, Rollback erfolgreich ===" else log "=== Update fehlgeschlagen, Rollback NICHT vollstaendig erfolgreich - manuelles Eingreifen noetig ===" fi run_hook "post_update_hook.sh" exit 2