#!/usr/bin/env bash # ============================================================================= # TimeMaster – Deployment & Update Script # Lokal ausführen – verbindet sich per SSH auf Produktivserver # # Usage: # ./update.sh [OPTIONS] # # Options: # --no-tests Tests überspringen # --no-frontend Frontend-Build und -Sync überspringen # --server 137 Nur Server 192.168.1.137 # --server 164 Nur Server 192.168.1.164 # --server both Beide Server (Standard) # --dry-run Alle Befehle ausgeben, aber NICHT ausführen # --help Diese Hilfe anzeigen # # Schritte (Reihenfolge ist kritisch): # 1. Tests auf Server 137 ausführen (via SSH) # 2. Frontend bauen (lokal, npm run build) # 3. Backend-Code auf Server(n) synchronisieren (rsync) # 4. Alembic-Migration auf Server ausführen # 5. Service neustarten # 6. Health-Check (3 Versuche) # 7. Frontend-Dist synchronisieren # 8. Abschlussbericht # ============================================================================= set -euo pipefail # ============================================================================= # KONFIGURATION # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BACKEND_DIR="$SCRIPT_DIR/backend" FRONTEND_DIR="$SCRIPT_DIR/frontend" SERVER_137="root@192.168.1.137" SERVER_164="root@192.168.1.164" REMOTE_PATH="/opt/timemaster" REMOTE_VENV="$REMOTE_PATH/backend/venv" HEALTH_RETRIES=3 HEALTH_WAIT=3 # ============================================================================= # FARBEN & AUSGABE # ============================================================================= RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' step() { echo -e "\n${BLUE}${BOLD}▶ $*${RESET}"; } ok() { echo -e " ${GREEN}✓ $*${RESET}"; } fail() { echo -e " ${RED}✗ $*${RESET}"; } warn() { echo -e " ${YELLOW}⚠ $*${RESET}"; } info() { echo -e " ${RESET}$*${RESET}"; } hr() { echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"; } die() { echo "" fail "$1" echo "" hr echo -e "${RED}${BOLD} ABBRUCH: $1${RESET}" hr print_summary exit 1 } # ============================================================================= # ARGUMENT-PARSING # ============================================================================= OPT_TESTS=true OPT_FRONTEND=true OPT_SERVER="both" OPT_DRYRUN=false while [[ $# -gt 0 ]]; do case "$1" in --no-tests) OPT_TESTS=false shift ;; --no-frontend) OPT_FRONTEND=false shift ;; --server) if [[ -z "${2:-}" ]]; then die "--server benötigt einen Wert: 137, 164 oder both" fi OPT_SERVER="$2" if [[ "$OPT_SERVER" != "137" && "$OPT_SERVER" != "164" && "$OPT_SERVER" != "both" ]]; then die "--server muss 137, 164 oder both sein (nicht: $OPT_SERVER)" fi shift 2 ;; --dry-run) OPT_DRYRUN=true shift ;; --help|-h) # Zeige nur den Header-Kommentarblock (bis zur ersten Leerzeile im Kommentar-Block) awk '/^#!/{next} /^#/{sub(/^# ?/,""); print; next} /^[^#]/{exit}' "$0" exit 0 ;; *) die "Unbekanntes Argument: $1 (--help für Hilfe)" ;; esac done # Server-Liste aufbauen SERVERS=() case "$OPT_SERVER" in 137) SERVERS=("$SERVER_137") ;; 164) SERVERS=("$SERVER_164") ;; both) SERVERS=("$SERVER_137" "$SERVER_164") ;; esac # ============================================================================= # ZUSAMMENFASSUNGS-TRACKING # ============================================================================= START_TIME=$(date +%s) declare -A STEP_STATUS # name → "OK" | "FAIL" | "SKIP" | "WARN" declare -a STEP_ORDER=() step_record() { local name="$1" status="$2" STEP_STATUS["$name"]="$status" STEP_ORDER+=("$name") } print_summary() { local end_time elapsed end_time=$(date +%s) elapsed=$(( end_time - START_TIME )) echo "" hr echo -e "${BOLD} Abschlussbericht (Dauer: ${elapsed}s)${RESET}" hr for name in "${STEP_ORDER[@]}"; do local status="${STEP_STATUS[$name]}" case "$status" in OK) echo -e " ${GREEN}✓ $name${RESET}" ;; FAIL) echo -e " ${RED}✗ $name${RESET}" ;; SKIP) echo -e " ${YELLOW}– $name (übersprungen)${RESET}" ;; WARN) echo -e " ${YELLOW}⚠ $name${RESET}" ;; esac done hr } # ============================================================================= # HILFSFUNKTIONEN # ============================================================================= # Führt einen Befehl aus oder gibt ihn im Dry-Run nur aus run_cmd() { if $OPT_DRYRUN; then echo -e " ${YELLOW}[DRY-RUN]${RESET} $*" return 0 fi "$@" } # SSH-Befehl mit Fehlerbehandlung run_ssh() { local server="$1" shift local cmd="$*" if $OPT_DRYRUN; then echo -e " ${YELLOW}[DRY-RUN]${RESET} ssh $server \"$cmd\"" return 0 fi if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "$server" "$cmd"; then return 1 fi } # rsync mit Fehlerbehandlung run_rsync() { if $OPT_DRYRUN; then echo -e " ${YELLOW}[DRY-RUN]${RESET} rsync $*" return 0 fi rsync "$@" } # SSH-Erreichbarkeit prüfen check_ssh() { local server="$1" if $OPT_DRYRUN; then info "Dry-Run: SSH-Check für $server übersprungen" return 0 fi if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "$server" "true" 2>/dev/null; then return 1 fi } # ============================================================================= # HEADER # ============================================================================= hr echo -e "${BOLD} TimeMaster Deployment Script${RESET}" echo -e " Datum: $(date '+%Y-%m-%d %H:%M:%S')" echo -e " Server: ${SERVERS[*]}" echo -e " Tests: $(if $OPT_TESTS; then echo "ja"; else echo "nein (--no-tests)"; fi)" echo -e " Frontend: $(if $OPT_FRONTEND; then echo "ja"; else echo "nein (--no-frontend)"; fi)" if $OPT_DRYRUN; then echo -e " ${YELLOW}${BOLD}Modus: DRY-RUN (keine Änderungen werden durchgeführt)${RESET}" fi hr # ============================================================================= # PRE-FLIGHT: Lokale Verzeichnisse prüfen # ============================================================================= step "Pre-Flight: Lokale Verzeichnisse prüfen" if [[ ! -d "$BACKEND_DIR" ]]; then die "Backend-Verzeichnis nicht gefunden: $BACKEND_DIR" fi ok "Backend-Verzeichnis vorhanden: $BACKEND_DIR" if $OPT_FRONTEND; then if [[ ! -d "$FRONTEND_DIR" ]]; then die "Frontend-Verzeichnis nicht gefunden: $FRONTEND_DIR" fi if [[ ! -f "$FRONTEND_DIR/package.json" ]]; then die "package.json nicht gefunden: $FRONTEND_DIR/package.json" fi ok "Frontend-Verzeichnis vorhanden: $FRONTEND_DIR" fi # SSH-Erreichbarkeit aller Zielserver prüfen for server in "${SERVERS[@]}"; do step "Pre-Flight: SSH-Verbindung zu $server prüfen" if check_ssh "$server"; then ok "SSH-Verbindung zu $server erfolgreich" else die "SSH-Verbindung zu $server fehlgeschlagen.\n Prüfe: ssh $server\n SSH-Key konfiguriert?" fi done step_record "Pre-Flight" "OK" # ============================================================================= # SCHRITT 1: TESTS (auf Server 137) # ============================================================================= if ! $OPT_TESTS; then step "Schritt 1: Tests (übersprungen via --no-tests)" step_record "1. Tests" "SKIP" else step "Schritt 1: Tests auf $SERVER_137 ausführen" info "Befehl: cd /opt/timemaster/backend && source venv/bin/activate && python -m pytest -x -q" TEST_CMD="cd $REMOTE_PATH/backend && source $REMOTE_VENV/bin/activate && python -m pytest -x -q 2>&1" if $OPT_DRYRUN; then echo -e " ${YELLOW}[DRY-RUN]${RESET} ssh $SERVER_137 \"$TEST_CMD\"" step_record "1. Tests" "SKIP" else TEST_OUTPUT=$(ssh -o ConnectTimeout=30 -o BatchMode=yes "$SERVER_137" "$TEST_CMD" 2>&1) || true TEST_RC=${PIPESTATUS[0]:-$?} # Ausgabe der Tests anzeigen echo "$TEST_OUTPUT" | while IFS= read -r line; do info "$line" done # Prüfen ob Tests fehlgeschlagen (pytest gibt RC != 0 bei Fehler) if echo "$TEST_OUTPUT" | grep -qE "^(FAILED|ERROR|[0-9]+ failed)"; then fail "Tests FEHLGESCHLAGEN" echo "" echo -e " ${RED}Fehlerdetails:${RESET}" echo "$TEST_OUTPUT" | grep -A 5 -E "^(FAILED|ERROR)" | while IFS= read -r line; do echo -e " $line" done step_record "1. Tests" "FAIL" die "Tests fehlgeschlagen – Deploy abgebrochen. Keine Dateien wurden auf den Server übertragen." fi # Erfolgsmeldung aus pytest-Ausgabe extrahieren PASSED_LINE=$(echo "$TEST_OUTPUT" | grep -E "passed" | tail -1 || echo "") if [[ -n "$PASSED_LINE" ]]; then ok "Tests bestanden: $PASSED_LINE" else ok "Tests abgeschlossen ohne Fehler" fi step_record "1. Tests" "OK" fi fi # ============================================================================= # SCHRITT 2: FRONTEND BAUEN # ============================================================================= if ! $OPT_FRONTEND; then step "Schritt 2: Frontend-Build (übersprungen via --no-frontend)" step_record "2. Frontend-Build" "SKIP" else step "Schritt 2: Frontend bauen (npm run build)" info "Verzeichnis: $FRONTEND_DIR" if $OPT_DRYRUN; then echo -e " ${YELLOW}[DRY-RUN]${RESET} cd $FRONTEND_DIR && npm run build" step_record "2. Frontend-Build" "SKIP" else BUILD_OUTPUT=$(cd "$FRONTEND_DIR" && npm run build 2>&1) || BUILD_RC=$? BUILD_RC=${BUILD_RC:-0} if [[ $BUILD_RC -ne 0 ]]; then fail "Frontend-Build FEHLGESCHLAGEN (Exit-Code: $BUILD_RC)" echo "" echo -e " ${RED}Build-Ausgabe:${RESET}" echo "$BUILD_OUTPUT" | tail -30 | while IFS= read -r line; do echo -e " $line" done step_record "2. Frontend-Build" "FAIL" die "Frontend-Build fehlgeschlagen – Deploy abgebrochen." fi if [[ ! -d "$FRONTEND_DIR/dist" ]]; then step_record "2. Frontend-Build" "FAIL" die "Frontend-Build scheinbar erfolgreich, aber dist/-Verzeichnis fehlt: $FRONTEND_DIR/dist" fi DIST_SIZE=$(du -sh "$FRONTEND_DIR/dist" | cut -f1) ok "Frontend gebaut: $FRONTEND_DIR/dist ($DIST_SIZE)" step_record "2. Frontend-Build" "OK" fi fi # ============================================================================= # SCHRITT 3, 4, 5, 6, 7: PRO SERVER AUSFÜHREN # ============================================================================= SERVER_RESULTS=() ALL_SERVERS_OK=true for server in "${SERVERS[@]}"; do SERVER_SHORT="${server##*@}" # "192.168.1.137" aus "root@192.168.1.137" echo "" hr echo -e "${BOLD} Server: $server${RESET}" hr SERVER_OK=true # ------------------------------------------------------------------------- # SCHRITT 3: Backend synchronisieren # ------------------------------------------------------------------------- step "[$SERVER_SHORT] Schritt 3: Backend-Code synchronisieren" info "rsync: $BACKEND_DIR/ → $server:$REMOTE_PATH/backend/" RSYNC_EXCLUDE=( "--exclude=__pycache__" "--exclude=*.pyc" "--exclude=.env" "--exclude=venv/" "--exclude=.git/" "--exclude=*.egg-info/" "--exclude=.pytest_cache/" "--exclude=htmlcov/" "--exclude=.coverage" ) if run_rsync -avz "${RSYNC_EXCLUDE[@]}" \ "$BACKEND_DIR/" \ "$server:$REMOTE_PATH/backend/" ; then ok "Backend synchronisiert" step_record "3. Backend-Sync [$SERVER_SHORT]" "OK" else RSYNC_RC=$? fail "rsync FEHLGESCHLAGEN (Exit-Code: $RSYNC_RC)" fail "Befehl war: rsync -avz ${RSYNC_EXCLUDE[*]} $BACKEND_DIR/ $server:$REMOTE_PATH/backend/" step_record "3. Backend-Sync [$SERVER_SHORT]" "FAIL" SERVER_OK=false ALL_SERVERS_OK=false warn "Server $server wird übersprungen (rsync fehlgeschlagen)" continue fi # ------------------------------------------------------------------------- # SCHRITT 4: Alembic-Migration # ------------------------------------------------------------------------- step "[$SERVER_SHORT] Schritt 4: Alembic-Migration" MIGRATE_CMD="cd $REMOTE_PATH/backend && source $REMOTE_VENV/bin/activate && alembic upgrade head 2>&1" info "Befehl: $MIGRATE_CMD" if $OPT_DRYRUN; then echo -e " ${YELLOW}[DRY-RUN]${RESET} ssh $server \"$MIGRATE_CMD\"" step_record "4. Migration [$SERVER_SHORT]" "SKIP" else MIGRATE_OUTPUT=$(ssh -o ConnectTimeout=30 -o BatchMode=yes "$server" "$MIGRATE_CMD" 2>&1) || MIGRATE_RC=$? MIGRATE_RC=${MIGRATE_RC:-0} echo "$MIGRATE_OUTPUT" | while IFS= read -r line; do info "$line" done if [[ $MIGRATE_RC -ne 0 ]]; then fail "Alembic-Migration FEHLGESCHLAGEN (Exit-Code: $MIGRATE_RC)" echo "" warn "Service wird NICHT neugestartet (DB-Schema könnte inkonsistent sein)" echo "" echo -e " ${RED}Letzte 20 Server-Log-Zeilen:${RESET}" ssh -o ConnectTimeout=10 -o BatchMode=yes "$server" \ "journalctl -u timemaster -n 20 --no-pager 2>/dev/null || echo '(keine Logs verfügbar)'" \ | while IFS= read -r line; do echo -e " $line"; done echo "" echo -e " ${YELLOW}Diagnose-Befehle:${RESET}" echo -e " ssh $server 'cd $REMOTE_PATH/backend && source $REMOTE_VENV/bin/activate && alembic current'" echo -e " ssh $server 'cd $REMOTE_PATH/backend && source $REMOTE_VENV/bin/activate && alembic history --verbose'" step_record "4. Migration [$SERVER_SHORT]" "FAIL" SERVER_OK=false ALL_SERVERS_OK=false warn "Server $server wird übersprungen (Migration fehlgeschlagen, Service NICHT neugestartet)" continue fi # Prüfe ob Migration "up to date" ist (kein Fehler, aber auch keine Änderung) if echo "$MIGRATE_OUTPUT" | grep -qi "already up to date\|no migrations"; then ok "Migration: bereits aktuell (keine neuen Migrationen)" else ok "Migration erfolgreich" fi step_record "4. Migration [$SERVER_SHORT]" "OK" fi # ------------------------------------------------------------------------- # SCHRITT 5: Service neustarten # ------------------------------------------------------------------------- step "[$SERVER_SHORT] Schritt 5: Service neustarten" if run_ssh "$server" "systemctl restart timemaster"; then ok "Service neugestartet" step_record "5. Service-Restart [$SERVER_SHORT]" "OK" else RESTART_RC=$? fail "systemctl restart timemaster FEHLGESCHLAGEN (Exit-Code: $RESTART_RC)" echo "" echo -e " ${RED}Letzte 30 Journal-Zeilen:${RESET}" ssh -o ConnectTimeout=10 -o BatchMode=yes "$server" \ "journalctl -u timemaster -n 30 --no-pager 2>/dev/null || echo '(keine Logs verfügbar)'" \ | while IFS= read -r line; do echo -e " $line"; done echo "" echo -e " ${YELLOW}Diagnose-Befehle:${RESET}" echo -e " ssh $server 'systemctl status timemaster'" echo -e " ssh $server 'journalctl -u timemaster -n 100 --no-pager'" step_record "5. Service-Restart [$SERVER_SHORT]" "FAIL" SERVER_OK=false ALL_SERVERS_OK=false warn "Server $server: Service-Start fehlgeschlagen" continue fi # ------------------------------------------------------------------------- # SCHRITT 6: Health-Check # ------------------------------------------------------------------------- step "[$SERVER_SHORT] Schritt 6: Health-Check (${HEALTH_RETRIES} Versuche, ${HEALTH_WAIT}s Pause)" if $OPT_DRYRUN; then echo -e " ${YELLOW}[DRY-RUN]${RESET} ssh $server 'curl -sf http://localhost:8000/health'" step_record "6. Health-Check [$SERVER_SHORT]" "SKIP" else HEALTH_OK=false for attempt in $(seq 1 $HEALTH_RETRIES); do info "Versuch $attempt/$HEALTH_RETRIES..." HEALTH_OUTPUT=$(ssh -o ConnectTimeout=10 -o BatchMode=yes "$server" \ "curl -sf --connect-timeout 5 --max-time 10 http://localhost:8000/health 2>&1") \ && HEALTH_RC=0 || HEALTH_RC=$? if [[ $HEALTH_RC -eq 0 ]]; then ok "Health-Check erfolgreich (Versuch $attempt): $HEALTH_OUTPUT" HEALTH_OK=true break else warn "Versuch $attempt fehlgeschlagen" if [[ $attempt -lt $HEALTH_RETRIES ]]; then info "Warte ${HEALTH_WAIT}s..." sleep "$HEALTH_WAIT" fi fi done if ! $HEALTH_OK; then fail "Health-Check nach $HEALTH_RETRIES Versuchen FEHLGESCHLAGEN" echo "" warn "Service läuft möglicherweise nicht korrekt. Log-Befehle:" echo -e " ${YELLOW}ssh $server 'journalctl -u timemaster -n 50 --no-pager'${RESET}" echo -e " ${YELLOW}ssh $server 'systemctl status timemaster'${RESET}" echo -e " ${YELLOW}ssh $server 'curl -v http://localhost:8000/health'${RESET}" step_record "6. Health-Check [$SERVER_SHORT]" "WARN" SERVER_OK=false ALL_SERVERS_OK=false # KEIN Abbruch hier – nur Warnung, Frontend noch synchronisieren else step_record "6. Health-Check [$SERVER_SHORT]" "OK" fi fi # ------------------------------------------------------------------------- # SCHRITT 7: Frontend-Dist synchronisieren # ------------------------------------------------------------------------- if ! $OPT_FRONTEND; then step "[$SERVER_SHORT] Schritt 7: Frontend-Sync (übersprungen via --no-frontend)" step_record "7. Frontend-Sync [$SERVER_SHORT]" "SKIP" else step "[$SERVER_SHORT] Schritt 7: Frontend-Dist synchronisieren" info "rsync: $FRONTEND_DIR/dist/ → $server:$REMOTE_PATH/frontend/dist/" if [[ ! -d "$FRONTEND_DIR/dist" ]] && ! $OPT_DRYRUN; then warn "dist/-Verzeichnis nicht gefunden: $FRONTEND_DIR/dist" warn "Frontend-Build wurde eventuell nicht ausgeführt (--no-frontend?)" step_record "7. Frontend-Sync [$SERVER_SHORT]" "WARN" else if run_rsync -avz --delete \ "$FRONTEND_DIR/dist/" \ "$server:$REMOTE_PATH/frontend/dist/" ; then ok "Frontend-Dist synchronisiert" step_record "7. Frontend-Sync [$SERVER_SHORT]" "OK" else RSYNC_FE_RC=$? fail "Frontend-rsync FEHLGESCHLAGEN (Exit-Code: $RSYNC_FE_RC)" fail "Befehl war: rsync -avz --delete $FRONTEND_DIR/dist/ $server:$REMOTE_PATH/frontend/dist/" step_record "7. Frontend-Sync [$SERVER_SHORT]" "FAIL" SERVER_OK=false ALL_SERVERS_OK=false fi fi fi if $SERVER_OK; then SERVER_RESULTS+=("${GREEN}✓ $server${RESET}") else SERVER_RESULTS+=("${YELLOW}⚠ $server (mit Warnungen/Fehlern)${RESET}") fi done # Ende Server-Schleife # ============================================================================= # ABSCHLUSSBERICHT # ============================================================================= echo "" hr echo -e "${BOLD} Server-Ergebnisse:${RESET}" for result in "${SERVER_RESULTS[@]}"; do echo -e " $result" done print_summary if $ALL_SERVERS_OK; then echo -e "${GREEN}${BOLD} DEPLOYMENT ERFOLGREICH${RESET}" else echo -e "${YELLOW}${BOLD} DEPLOYMENT MIT WARNUNGEN ABGESCHLOSSEN${RESET}" echo -e "${YELLOW} Bitte die Fehlermeldungen oben prüfen.${RESET}" fi hr exit 0