From 8e5e76d062d3d87b4531c632a215771313c06b8a Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 23 May 2026 21:16:24 +0200 Subject: [PATCH] add deployment updater scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update.sh: full deploy pipeline (tests → build → rsync → migrate → restart → health-check) supports --no-tests, --no-frontend, --server 137|164|both, --dry-run aborts before service restart if migration fails (DB protection) - scripts/check_migrations.sh: compare local Alembic head vs. server Co-Authored-By: Claude Sonnet 4.6 --- scripts/check_migrations.sh | 344 +++++++++++ update.sh | 1131 ++++++++++++++++------------------- 2 files changed, 876 insertions(+), 599 deletions(-) create mode 100755 scripts/check_migrations.sh mode change 100644 => 100755 update.sh diff --git a/scripts/check_migrations.sh b/scripts/check_migrations.sh new file mode 100755 index 0000000..3a7add3 --- /dev/null +++ b/scripts/check_migrations.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# ============================================================================= +# TimeMaster – Migrations-Sync-Check +# +# Prüft ob der lokale Alembic-Head mit dem Stand auf dem Server übereinstimmt. +# Gibt aus: lokaler Head, Server-Head, ob Sync nötig ist. +# +# Usage: +# ./scripts/check_migrations.sh [137|164|both] +# +# Argumente: +# 137 Nur Server 192.168.1.137 prüfen (Standard) +# 164 Nur Server 192.168.1.164 prüfen +# both Beide Server prüfen +# +# Exit-Codes: +# 0 Alles synchron (oder Dry-Run) +# 1 Mindestens ein Server ist nicht synchron oder Fehler +# ============================================================================= + +set -euo pipefail + +# ============================================================================= +# KONFIGURATION +# ============================================================================= +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKEND_DIR="$PROJECT_DIR/backend" + +SERVER_137="root@192.168.1.137" +SERVER_164="root@192.168.1.164" +REMOTE_PATH="/opt/timemaster" +REMOTE_VENV="$REMOTE_PATH/backend/venv" + +# ============================================================================= +# FARBEN & AUSGABE +# ============================================================================= +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { echo -e " ${GREEN}✓ $*${RESET}"; } +fail() { echo -e " ${RED}✗ $*${RESET}"; } +warn() { echo -e " ${YELLOW}⚠ $*${RESET}"; } +info() { echo -e " $*"; } +step() { echo -e "\n${BLUE}${BOLD}▶ $*${RESET}"; } +hr() { echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"; } + +die() { + echo "" + fail "$1" + exit 1 +} + +# ============================================================================= +# ARGUMENT-PARSING +# ============================================================================= +TARGET="${1:-137}" + +case "$TARGET" in + 137) SERVERS=("$SERVER_137") ;; + 164) SERVERS=("$SERVER_164") ;; + both) SERVERS=("$SERVER_137" "$SERVER_164") ;; + --help|-h) + awk '/^#!/{next} /^#/{sub(/^# ?/,""); print; next} /^[^#]/{exit}' "$0" + exit 0 + ;; + *) + die "Unbekanntes Argument: $TARGET (Erwartet: 137, 164 oder both)" + ;; +esac + +# ============================================================================= +# PRE-FLIGHT: Lokales Alembic verfügbar? +# ============================================================================= +hr +echo -e "${BOLD} TimeMaster – Migrations-Sync-Check${RESET}" +echo -e " Datum: $(date '+%Y-%m-%d %H:%M:%S')" +echo -e " Ziel-Server: ${SERVERS[*]}" +hr + +# Prüfe ob alembic.ini vorhanden +if [[ ! -f "$BACKEND_DIR/alembic.ini" ]]; then + die "alembic.ini nicht gefunden: $BACKEND_DIR/alembic.ini" +fi + +# Prüfe ob migrations/versions/ vorhanden +if [[ ! -d "$BACKEND_DIR/migrations/versions" ]]; then + die "Migrations-Verzeichnis nicht gefunden: $BACKEND_DIR/migrations/versions" +fi + +# ============================================================================= +# LOKALEN ALEMBIC-HEAD ERMITTELN +# ============================================================================= +step "Lokalen Alembic-Head ermitteln" + +# Lokaler Head aus den Migrations-Dateien extrahieren (ohne echte DB-Verbindung) +# Strategie: letzte Migration via "down_revision" Kette auflösen +# +# Schnellere Methode: Migrations-Datei mit der höchsten Revisions-ID finden, +# die keine "down_revision" von einer anderen Datei ist (also Head-Node). +# Wir suchen alle revision-IDs und down_revision-IDs und finden die, +# die keine down_revision einer anderen ist. + +LOCAL_HEAD="" +LOCAL_HEAD_FILE="" + +# Alle .py-Dateien in migrations/versions/ einlesen +declare -A REVISION_MAP # revision_id → dateiname +declare -A DOWN_REVISION # revision_id → down_revision + +while IFS= read -r migration_file; do + # Revision-ID extrahieren (Zeile: revision = 'XXXX') + rev=$(grep -oP "^revision\s*=\s*['\"]?\K[a-f0-9]+" "$migration_file" 2>/dev/null | head -1 || true) + down=$(grep -oP "^down_revision\s*=\s*['\"]?\K[a-f0-9]+" "$migration_file" 2>/dev/null | head -1 || true) + + if [[ -n "$rev" ]]; then + REVISION_MAP["$rev"]="$(basename "$migration_file")" + DOWN_REVISION["$rev"]="${down:-}" + fi +done < <(find "$BACKEND_DIR/migrations/versions" -name "*.py" -not -name "__pycache__" | sort) + +MIGRATION_COUNT=${#REVISION_MAP[@]} +info "Gefundene Migrations-Dateien: $MIGRATION_COUNT" + +if [[ $MIGRATION_COUNT -eq 0 ]]; then + warn "Keine Migrations-Dateien gefunden in $BACKEND_DIR/migrations/versions/" +else + # Head-Revision finden: diejenige, die von keiner anderen als down_revision referenziert wird + for rev in "${!REVISION_MAP[@]}"; do + IS_HEAD=true + for other_rev in "${!DOWN_REVISION[@]}"; do + if [[ "${DOWN_REVISION[$other_rev]}" == "$rev" ]]; then + IS_HEAD=false + break + fi + done + if $IS_HEAD; then + LOCAL_HEAD="$rev" + LOCAL_HEAD_FILE="${REVISION_MAP[$rev]}" + break + fi + done + + if [[ -n "$LOCAL_HEAD" ]]; then + ok "Lokaler Head: $LOCAL_HEAD" + info "Datei: $LOCAL_HEAD_FILE" + else + warn "Lokaler Head konnte nicht ermittelt werden (zyklische Abhängigkeiten oder keine Dateien?)" + info "Tipp: Prüfe manuell mit: cd $BACKEND_DIR && alembic heads" + fi +fi + +# Fallback: Neueste Datei nach Namenskonvention (Präfix wie 0022_) +if [[ -z "$LOCAL_HEAD" ]]; then + LATEST_FILE=$(ls -1 "$BACKEND_DIR/migrations/versions/"*.py 2>/dev/null | \ + grep -v '__' | sort -t'_' -k1,1 -V | tail -1 || true) + if [[ -n "$LATEST_FILE" ]]; then + LATEST_REV=$(grep -oP "^revision\s*=\s*['\"]?\K[a-f0-9]+" "$LATEST_FILE" 2>/dev/null | head -1 || true) + if [[ -n "$LATEST_REV" ]]; then + LOCAL_HEAD="$LATEST_REV" + LOCAL_HEAD_FILE="$(basename "$LATEST_FILE")" + warn "Fallback: Letzte Datei: $LOCAL_HEAD_FILE → $LOCAL_HEAD" + fi + fi +fi + +# ============================================================================= +# ALLE VERFÜGBAREN MIGRATIONEN ANZEIGEN +# ============================================================================= +step "Lokale Migrations-Kette" + +# Kette von unten nach oben aufbauen +if [[ ${#REVISION_MAP[@]} -gt 0 ]]; then + # Startnode finden (down_revision ist leer oder None) + CHAIN_CURRENT="" + for rev in "${!DOWN_REVISION[@]}"; do + if [[ -z "${DOWN_REVISION[$rev]}" ]]; then + CHAIN_CURRENT="$rev" + break + fi + done + + # Kette traversieren + CHAIN_COUNT=0 + while [[ -n "$CHAIN_CURRENT" ]]; do + CHAIN_FILE="${REVISION_MAP[$CHAIN_CURRENT]:-unbekannt}" + MARKER="" + [[ "$CHAIN_CURRENT" == "$LOCAL_HEAD" ]] && MARKER=" ${GREEN}← HEAD${RESET}" + info "$CHAIN_FILE ($CHAIN_CURRENT)$MARKER" + (( CHAIN_COUNT++ )) + + # Nächsten Node finden (der diesen als down_revision hat) + NEXT_REV="" + for rev in "${!DOWN_REVISION[@]}"; do + if [[ "${DOWN_REVISION[$rev]}" == "$CHAIN_CURRENT" ]]; then + NEXT_REV="$rev" + break + fi + done + CHAIN_CURRENT="$NEXT_REV" + done + + if [[ $CHAIN_COUNT -eq 0 ]]; then + info "(keine lineare Kette gefunden)" + fi +fi + +# ============================================================================= +# SERVER-HEADS PRÜFEN +# ============================================================================= +EXIT_CODE=0 +declare -A SERVER_HEADS + +for server in "${SERVERS[@]}"; do + SERVER_SHORT="${server##*@}" + step "Server $server: aktuellen Migrations-Stand abrufen" + + # SSH-Erreichbarkeit prüfen + if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "$server" "true" 2>/dev/null; then + fail "SSH-Verbindung zu $server fehlgeschlagen" + warn "Prüfe: ssh $server" + SERVER_HEADS["$SERVER_SHORT"]="FEHLER (SSH)" + EXIT_CODE=1 + continue + fi + + # alembic current auf dem Server ausführen + ALEMBIC_CMD="cd $REMOTE_PATH/backend && source $REMOTE_VENV/bin/activate && alembic current 2>&1" + + ALEMBIC_OUTPUT=$(ssh -o ConnectTimeout=20 -o BatchMode=yes "$server" "$ALEMBIC_CMD" 2>&1) \ + && ALEMBIC_RC=0 || ALEMBIC_RC=$? + + if [[ $ALEMBIC_RC -ne 0 ]]; then + fail "alembic current FEHLGESCHLAGEN auf $server (Exit-Code: $ALEMBIC_RC)" + echo "$ALEMBIC_OUTPUT" | while IFS= read -r line; do info "$line"; done + warn "Mögliche Ursachen:" + info " - venv nicht vorhanden: $REMOTE_VENV" + info " - alembic.ini nicht auf dem Server: $REMOTE_PATH/backend/alembic.ini" + info " - DB-Verbindung auf dem Server fehlgeschlagen" + info " - Diagnose: ssh $server 'cd $REMOTE_PATH/backend && source $REMOTE_VENV/bin/activate && alembic current'" + SERVER_HEADS["$SERVER_SHORT"]="FEHLER (alembic)" + EXIT_CODE=1 + continue + fi + + # Revision aus alembic-Ausgabe extrahieren + # alembic current gibt aus: "abcd1234 (head)" oder "abcd1234" + SERVER_REV=$(echo "$ALEMBIC_OUTPUT" | grep -oP "^[a-f0-9]{4,}" | head -1 || true) + SERVER_IS_HEAD=$(echo "$ALEMBIC_OUTPUT" | grep -c "(head)" || true) + + if [[ -z "$SERVER_REV" ]]; then + # Möglicherweise leere Datenbank (keine Migrationen gelaufen) + if echo "$ALEMBIC_OUTPUT" | grep -qi "no current"; then + SERVER_REV="(keine Migration gelaufen)" + warn "Server $server: Noch keine Migration ausgeführt" + else + SERVER_REV="(unbekannt)" + warn "Server $server: Revision konnte nicht aus alembic-Ausgabe extrahiert werden" + info "alembic-Ausgabe:" + echo "$ALEMBIC_OUTPUT" | while IFS= read -r line; do info " $line"; done + fi + SERVER_HEADS["$SERVER_SHORT"]="$SERVER_REV" + EXIT_CODE=1 + continue + fi + + SERVER_HEADS["$SERVER_SHORT"]="$SERVER_REV" + + info "alembic-Ausgabe: $ALEMBIC_OUTPUT" + + # Vergleich: lokaler Head vs. Server + if [[ -n "$LOCAL_HEAD" ]]; then + if [[ "$SERVER_REV" == "$LOCAL_HEAD" ]]; then + ok "Server $server ist SYNCHRON" + info " Lokaler Head : $LOCAL_HEAD ($LOCAL_HEAD_FILE)" + info " Server-Stand : $SERVER_REV" + if [[ "$SERVER_IS_HEAD" -gt 0 ]]; then + info " Status : (head)" + fi + else + fail "Server $server ist NICHT synchron – Migration nötig!" + info " Lokaler Head : $LOCAL_HEAD ($LOCAL_HEAD_FILE)" + info " Server-Stand : $SERVER_REV" + echo "" + echo -e " ${YELLOW}Zum Synchronisieren:${RESET}" + echo -e " ${YELLOW}ssh $server 'cd $REMOTE_PATH/backend && source $REMOTE_VENV/bin/activate && alembic upgrade head'${RESET}" + echo -e " ${YELLOW}# oder: ./update.sh --no-tests --no-frontend --server ${SERVER_SHORT}${RESET}" + EXIT_CODE=1 + fi + else + warn "Kein lokaler Head bekannt – kein Vergleich möglich" + info " Server-Stand : $SERVER_REV" + fi +done + +# ============================================================================= +# ZUSAMMENFASSUNG +# ============================================================================= +echo "" +hr +echo -e "${BOLD} Zusammenfassung${RESET}" +hr +echo "" +if [[ -n "$LOCAL_HEAD" ]]; then + echo -e " ${BOLD}Lokaler Head:${RESET} $LOCAL_HEAD" + echo -e " ($LOCAL_HEAD_FILE)" +else + echo -e " ${BOLD}Lokaler Head:${RESET} ${YELLOW}(konnte nicht ermittelt werden)${RESET}" +fi +echo "" + +for server in "${SERVERS[@]}"; do + SERVER_SHORT="${server##*@}" + server_rev="${SERVER_HEADS[$SERVER_SHORT]:-unbekannt}" + + if [[ -n "$LOCAL_HEAD" && "$server_rev" == "$LOCAL_HEAD" ]]; then + echo -e " ${GREEN}✓${RESET} ${BOLD}$server${RESET} → $server_rev ${GREEN}(synchron)${RESET}" + elif [[ "$server_rev" == FEHLER* ]]; then + echo -e " ${RED}✗${RESET} ${BOLD}$server${RESET} → ${RED}$server_rev${RESET}" + else + echo -e " ${RED}✗${RESET} ${BOLD}$server${RESET} → $server_rev ${YELLOW}(Migration nötig)${RESET}" + fi +done + +echo "" +if [[ $EXIT_CODE -eq 0 ]]; then + echo -e " ${GREEN}${BOLD}Alle geprüften Server sind synchron.${RESET}" +else + echo -e " ${YELLOW}${BOLD}Mindestens ein Server benötigt eine Migration oder hat Fehler.${RESET}" + echo "" + echo -e " ${YELLOW}Schnell-Sync (nur Migration + Restart):${RESET}" + case "$TARGET" in + 137) echo -e " ./update.sh --no-tests --no-frontend --server 137" ;; + 164) echo -e " ./update.sh --no-tests --no-frontend --server 164" ;; + both) echo -e " ./update.sh --no-tests --no-frontend --server both" ;; + esac +fi +hr + +exit $EXIT_CODE diff --git a/update.sh b/update.sh old mode 100644 new mode 100755 index e12d9d3..4f81796 --- a/update.sh +++ b/update.sh @@ -1,628 +1,561 @@ -#!/bin/bash +#!/usr/bin/env bash # ============================================================================= -# TimeMaster – Umfassendes Update- und Health-Check-Script -# Server: root@192.168.1.137 -# ============================================================================= -# Phasen: -# 0 Pre-Flight Check -# 1 Backup (DB-Dump, pip freeze, 30-Tage-Retention) -# 2 System-Update (apt) + Erkennung PG/Python-Versionssprung -# 3 Python-Abhängigkeiten prüfen und aktualisieren -# 4 Post-Update Funktionstest (API-Endpunkte + nginx) -# 5 Performance-Test (curl, psql, redis-cli) -# 6 Zusammenfassung +# 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 # ============================================================================= -# --- Globale Einstellungen --------------------------------------------------- -APP_DIR="/opt/timemaster" -BACKEND_DIR="$APP_DIR/backend" -VENV="$BACKEND_DIR/venv" -REQ="$BACKEND_DIR/requirements.txt" -BACKUP_DIR="/tank/backup" -DB_NAME="timemaster_db" -API_BASE="http://localhost:8000" -FRONTEND_BASE="http://localhost" -MIN_FREE_MB=500 -PERF_RUNS=5 # Anzahl Messungen für Durchschnitt -EXIT_CODE=0 # wird auf 1 gesetzt bei Problemen +set -euo pipefail -# --- Farben ------------------------------------------------------------------ +# ============================================================================= +# 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' -YELLOW='\033[1;33m' GREEN='\033[0;32m' -CYAN='\033[0;36m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' -# --- Hilfsfunktionen --------------------------------------------------------- -ok() { echo -e "${GREEN}✅ $*${RESET}"; } -warn() { echo -e "${YELLOW}⚠️ $*${RESET}"; EXIT_CODE=1; } -err() { echo -e "${RED}❌ $*${RESET}"; EXIT_CODE=1; } -info() { echo -e "${CYAN}ℹ $*${RESET}"; } -hdr() { echo -e "\n${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"; \ - echo -e "${BOLD} $*${RESET}"; \ - echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"; } +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}"; } -# Misst Response-Zeit eines curl-Aufrufs in ms, gibt HTTP-Status und Zeit aus -# Aufruf: measure_curl -# Ausgabe (stdout, TAB-getrennt): HTTP_STATUSMS -measure_curl() { - local url="$1" - local result - result=$(curl -s -o /dev/null -w "%{http_code}\t%{time_total}" \ - --connect-timeout 5 --max-time 10 "$url" 2>/dev/null || echo "000\t0") - local http_code ms_float - http_code=$(echo "$result" | cut -f1) - ms_float=$(echo "$result" | cut -f2) - # Sekunden → Millisekunden (ganzzahlig) - local ms - ms=$(awk "BEGIN {printf \"%d\", $ms_float * 1000}") - echo "${http_code}"$'\t'"${ms}" -} - -# Durchschnitt aus N curl-Messungen (nur ms) -avg_curl_ms() { - local url="$1" - local n="${2:-$PERF_RUNS}" - local total=0 - local i - for (( i=0; i/dev/null; then - ok "Service $SVC läuft" - else - err "Service $SVC ist NICHT aktiv" - PREFLIGHT_OK=false - fi -done - -# --- Festplattenplatz -------------------------------------------------------- -FREE_MB=$(df -m / | awk 'NR==2 {print $4}') -if [[ "$FREE_MB" -ge "$MIN_FREE_MB" ]]; then - ok "Festplatte: ${FREE_MB} MB frei (Minimum: ${MIN_FREE_MB} MB)" -else - err "Zu wenig Festplattenplatz: ${FREE_MB} MB frei (Minimum: ${MIN_FREE_MB} MB)" - PREFLIGHT_OK=false -fi - -# --- PostgreSQL erreichbar --------------------------------------------------- -if su -c "psql -d $DB_NAME -c 'SELECT 1' -q --tuples-only" postgres &>/dev/null; then - ok "PostgreSQL erreichbar (DB: $DB_NAME)" -else - err "PostgreSQL NICHT erreichbar (DB: $DB_NAME)" - PREFLIGHT_OK=false -fi - -# --- Redis erreichbar -------------------------------------------------------- -if redis-cli ping 2>/dev/null | grep -q "PONG"; then - ok "Redis erreichbar" -else - err "Redis NICHT erreichbar" - PREFLIGHT_OK=false -fi - -# --- API Health-Endpoint ----------------------------------------------------- -API_PRE=$(measure_curl "$API_BASE/health") -API_PRE_STATUS=$(echo "$API_PRE" | cut -f1) -if [[ "$API_PRE_STATUS" == "200" ]]; then - ok "API /health antwortet mit HTTP 200" -else - err "API /health antwortet NICHT (HTTP $API_PRE_STATUS)" - PREFLIGHT_OK=false -fi - -# --- Abbruch bei Pre-Flight-Fehler ------------------------------------------ -if ! $PREFLIGHT_OK; then +die() { echo "" - err "Pre-Flight Check FEHLGESCHLAGEN. Script wird abgebrochen." - err "Diagnose-Befehle:" - err " journalctl -u timemaster -n 50" - err " journalctl -u nginx -n 20" - err " systemctl status postgresql" - err " redis-cli ping" + fail "$1" + echo "" + hr + echo -e "${RED}${BOLD} ABBRUCH: $1${RESET}" + hr + print_summary exit 1 -fi - -summary_add "${GREEN}Phase 0: Pre-Flight Check OK${RESET}" - -# ============================================================================= -# PHASE 1 – BACKUP -# ============================================================================= -hdr "Phase 1 · Backup" - -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -mkdir -p "$BACKUP_DIR" - -# PostgreSQL Dump -DB_DUMP="$BACKUP_DIR/db_${TIMESTAMP}.dump" -info "Erstelle PostgreSQL-Dump → $DB_DUMP" -if su -c "pg_dump -Fc $DB_NAME" postgres > "$DB_DUMP" 2>/tmp/pg_dump_err; then - DUMP_SIZE=$(du -sh "$DB_DUMP" | cut -f1) - ok "DB-Dump erstellt: $DB_DUMP ($DUMP_SIZE)" - summary_add "${GREEN}Phase 1: DB-Backup OK ($DUMP_SIZE)${RESET}" -else - warn "DB-Dump FEHLGESCHLAGEN: $(cat /tmp/pg_dump_err)" - summary_add "${YELLOW}Phase 1: DB-Backup FEHLGESCHLAGEN${RESET}" -fi - -# pip freeze sichern -PIP_FREEZE="$BACKUP_DIR/pip_freeze_${TIMESTAMP}.txt" -info "Sichere pip freeze → $PIP_FREEZE" -if "$VENV/bin/pip" freeze > "$PIP_FREEZE" 2>/dev/null; then - ok "pip freeze gesichert: $PIP_FREEZE" -else - warn "pip freeze konnte nicht gesichert werden" -fi - -# 30-Tage-Retention (DB-Dumps) -info "Lösche DB-Dumps älter als 30 Tage..." -find "$BACKUP_DIR" -name "db_*.dump" -mtime +30 -delete 2>/dev/null && ok "Alte Backups bereinigt" || true -# pip-freeze-Dateien ebenfalls bereinigen -find "$BACKUP_DIR" -name "pip_freeze_*.txt" -mtime +30 -delete 2>/dev/null || true - -# ============================================================================= -# PHASE 2 – SYSTEM-UPDATE (apt) -# ============================================================================= -hdr "Phase 2 · System-Update (apt)" - -# Versionen VOR Update sichern -PG_BEFORE=$(pg_lsclusters -h 2>/dev/null | awk 'NR==1 {print $1}' || echo "unbekannt") -PYTHON_BEFORE_FULL=$(grep "^version = " "$VENV/pyvenv.cfg" 2>/dev/null | cut -d' ' -f3 || echo "unbekannt") -PYTHON_BEFORE_MAJOR=$(echo "$PYTHON_BEFORE_FULL" | cut -d. -f1-2) - -info "PostgreSQL vor Update : $PG_BEFORE" -info "Python (venv) vor Update: $PYTHON_BEFORE_FULL (Major: $PYTHON_BEFORE_MAJOR)" - -# apt-Operationen (kein set -e hier, damit Fehler nicht alles abbricht) -info "Führe apt update aus..." -APT_UPDATE_OUT=$(apt update 2>&1) -APT_UPDATE_RC=$? -if [[ $APT_UPDATE_RC -eq 0 ]]; then - ok "apt update erfolgreich" -else - warn "apt update mit Warnungen: $(echo "$APT_UPDATE_OUT" | tail -3)" -fi - -info "Führe apt upgrade -y aus (kann etwas dauern)..." -APT_UPGRADE_OUT=$(DEBIAN_FRONTEND=noninteractive apt upgrade -y 2>&1) -APT_UPGRADE_RC=$? -# Zähle aktualisierte Pakete -APT_UPGRADED=$(echo "$APT_UPGRADE_OUT" | grep -oP '^\d+ upgraded' | grep -oP '^\d+' || echo "0") -APT_INSTALLED=$(echo "$APT_UPGRADE_OUT" | grep -oP '\d+ newly installed' | grep -oP '^\d+' || echo "0") - -if [[ $APT_UPGRADE_RC -eq 0 ]]; then - ok "apt upgrade erfolgreich (${APT_UPGRADED:-0} Pakete aktualisiert, ${APT_INSTALLED:-0} neu installiert)" - summary_add "${GREEN}Phase 2: apt upgrade OK (${APT_UPGRADED:-0} Pakete)${RESET}" -else - warn "apt upgrade mit Fehler (RC=$APT_UPGRADE_RC)" - summary_add "${YELLOW}Phase 2: apt upgrade mit Warnungen${RESET}" -fi - -info "Führe apt autoremove -y aus..." -DEBIAN_FRONTEND=noninteractive apt autoremove -y &>/dev/null && ok "apt autoremove OK" || warn "apt autoremove mit Fehler" - -# --- PostgreSQL Major-Versionssprung erkennen -------------------------------- -PG_AFTER=$(pg_lsclusters -h 2>/dev/null | awk 'NR==1 {print $1}' || echo "unbekannt") -info "PostgreSQL nach Update: $PG_AFTER" - -if [[ "$PG_BEFORE" != "$PG_AFTER" && "$PG_BEFORE" != "unbekannt" ]]; then - warn "PostgreSQL Major-Versionssprung erkannt: $PG_BEFORE → $PG_AFTER" - echo "" - echo -e "${YELLOW} Das Backup liegt in: $BACKUP_DIR${RESET}" - echo -e "${YELLOW} Verfügbare Cluster:${RESET}" - pg_lsclusters - echo "" - read -r -p " pg_upgradecluster $PG_BEFORE main → $PG_AFTER jetzt ausführen? [j/N] " PG_ANSWER - if [[ "$PG_ANSWER" =~ ^[jJ]$ ]]; then - info "Stoppe timemaster Service..." - systemctl stop timemaster - info "Führe pg_upgradecluster $PG_BEFORE main aus..." - if pg_upgradecluster "$PG_BEFORE" main; then - ok "pg_upgradecluster erfolgreich" - summary_add "${GREEN}Phase 2: PostgreSQL-Cluster-Upgrade $PG_BEFORE→$PG_AFTER OK${RESET}" - else - err "pg_upgradecluster FEHLGESCHLAGEN – manuell eingreifen!" - summary_add "${RED}Phase 2: PostgreSQL-Upgrade FEHLGESCHLAGEN${RESET}" - fi - info "Starte timemaster Service..." - systemctl start timemaster - sleep 3 - else - warn "pg_upgradecluster übersprungen. Alter Cluster $PG_BEFORE ist noch aktiv!" - warn "Bitte manuell migrieren: pg_upgradecluster $PG_BEFORE main" - summary_add "${YELLOW}Phase 2: PostgreSQL-Upgrade übersprungen${RESET}" - fi -else - ok "PostgreSQL: kein Major-Versionssprung ($PG_AFTER)" - summary_add "${GREEN}Phase 2: PostgreSQL stabil (Version $PG_AFTER)${RESET}" -fi - -# --- Python Major-Versionssprung erkennen ------------------------------------ -PYTHON_AFTER_FULL=$(python3 --version 2>/dev/null | cut -d' ' -f2 || echo "unbekannt") -PYTHON_AFTER_MAJOR=$(echo "$PYTHON_AFTER_FULL" | cut -d. -f1-2) - -info "Python nach Update: $PYTHON_AFTER_FULL (Major: $PYTHON_AFTER_MAJOR)" - -if [[ "$PYTHON_BEFORE_MAJOR" != "$PYTHON_AFTER_MAJOR" && "$PYTHON_BEFORE_MAJOR" != "unbekannt" ]]; then - warn "Python Major-Versionssprung: $PYTHON_BEFORE_MAJOR → $PYTHON_AFTER_MAJOR" - info "Baue venv automatisch neu..." - - systemctl stop timemaster || true - - cd "$BACKEND_DIR" - mv venv "venv.old.${PYTHON_BEFORE_MAJOR}" 2>/dev/null || true - - if python3 -m venv venv; then - "$VENV/bin/pip" install --upgrade pip -q - if "$VENV/bin/pip" install -r "$REQ" -q; then - ok "venv neu gebaut mit Python $PYTHON_AFTER_FULL" - summary_add "${GREEN}Phase 2: venv neu gebaut (Python $PYTHON_BEFORE_MAJOR→$PYTHON_AFTER_MAJOR)${RESET}" - else - err "pip install -r requirements.txt FEHLGESCHLAGEN" - summary_add "${RED}Phase 2: venv-Neubau FEHLGESCHLAGEN${RESET}" - fi - else - err "python3 -m venv fehlgeschlagen" - summary_add "${RED}Phase 2: venv-Neubau FEHLGESCHLAGEN${RESET}" - fi - - systemctl start timemaster || true - sleep 3 -else - ok "Python: kein Major-Versionssprung ($PYTHON_AFTER_MAJOR)" - summary_add "${GREEN}Phase 2: Python stabil (Version $PYTHON_AFTER_FULL)${RESET}" -fi - -# ============================================================================= -# PHASE 3 – PYTHON-ABHÄNGIGKEITEN PRÜFEN UND AKTUALISIEREN -# ============================================================================= -hdr "Phase 3 · Python-Abhängigkeiten" - -if [[ ! -f "$REQ" ]]; then - warn "requirements.txt nicht gefunden: $REQ" - summary_add "${YELLOW}Phase 3: requirements.txt fehlt${RESET}" -else - # Installierte Pakete als assoziatives Array: name → version (lowercase) - # pip list --format=freeze liefert "Paketname==Version" – cut auf erstes == aufteilen - declare -A INSTALLED_PKGS - while IFS= read -r line; do - [[ -z "$line" || "$line" =~ ^# ]] && continue - pkg=$(echo "$line" | cut -d= -f1) - ver=$(echo "$line" | cut -d= -f3) # f1=name, f2="" (wegen ==), f3=version - pname=$(echo "$pkg" | tr '[:upper:]' '[:lower:]' | tr '_' '-' | xargs) - pver=$(echo "$ver" | xargs) - [[ -n "$pname" && -n "$pver" ]] && INSTALLED_PKGS["$pname"]="$pver" - done < <("$VENV/bin/pip" list --format=freeze 2>/dev/null) - - MISSING_PKGS=() - WRONG_VERSION_PKGS=() - OK_PKGS=0 - - # requirements.txt parsen (ignoriere Kommentare, leere Zeilen, git+-Zeilen) - while IFS= read -r line; do - # Leere Zeilen und Kommentare überspringen - [[ -z "$line" || "$line" =~ ^# ]] && continue - # git+https etc. überspringen - [[ "$line" =~ ^-e|^git\+ ]] && continue - - # Paketname und Version extrahieren - req_name=$(echo "$line" | grep -oP '^[A-Za-z0-9_.-]+' | tr '[:upper:]' '[:lower:]' | tr '_' '-') - req_op=$(echo "$line" | grep -oP '(==|>=|<=|~=|!=)' | head -1 || true) - req_ver=$(echo "$line" | grep -oP '(==|>=|<=|~=|!=)\K[0-9A-Za-z._-]+' | head -1 || true) - - [[ -z "$req_name" ]] && continue - - inst_ver="${INSTALLED_PKGS[$req_name]:-}" - - if [[ -z "$inst_ver" ]]; then - MISSING_PKGS+=("$line") - elif [[ -n "$req_op" && "$req_op" == "==" && "$inst_ver" != "$req_ver" ]]; then - WRONG_VERSION_PKGS+=("$req_name==$req_ver (installiert: $inst_ver)") - else - (( OK_PKGS++ )) || true - fi - done < "$REQ" - - ok "$OK_PKGS Pakete bereits korrekt installiert" - - CHANGED_PKGS=() - - if [[ ${#MISSING_PKGS[@]} -gt 0 ]]; then - echo -e "${YELLOW}⚠️ ${#MISSING_PKGS[@]} fehlende Pakete gefunden – werden jetzt installiert:${RESET}" - for p in "${MISSING_PKGS[@]}"; do info " + $p"; done - - info "Stoppe timemaster Service für pip install..." - systemctl stop timemaster || true - - INSTALL_FAILED=() - for p in "${MISSING_PKGS[@]}"; do - info " Installiere: $p" - if "$VENV/bin/pip" install "$p" -q; then - ok " Installiert: $p" - CHANGED_PKGS+=("+ $p") - else - err " FEHLER bei Installation: $p" - INSTALL_FAILED+=("$p") - fi - done - - info "Starte timemaster Service..." - systemctl start timemaster || true - sleep 3 - - # Nur Exit-Code setzen wenn Installation tatsächlich fehlschlug - if [[ ${#INSTALL_FAILED[@]} -gt 0 ]]; then - warn "${#INSTALL_FAILED[@]} Pakete konnten NICHT installiert werden" - else - ok "Alle fehlenden Pakete erfolgreich nachinstalliert" - fi - else - ok "Keine fehlenden Pakete" - fi - - if [[ ${#WRONG_VERSION_PKGS[@]} -gt 0 ]]; then - echo -e "${YELLOW}⚠️ ${#WRONG_VERSION_PKGS[@]} Pakete mit falscher Version – werden aktualisiert:${RESET}" - for p in "${WRONG_VERSION_PKGS[@]}"; do info " ~ $p"; done - - # Service nur stoppen wenn nicht schon gestoppt - if systemctl is-active --quiet timemaster 2>/dev/null; then - info "Stoppe timemaster Service für pip upgrade..." - systemctl stop timemaster || true - RESTART_AFTER_PIP=true - else - RESTART_AFTER_PIP=false - fi - - UPGRADE_FAILED=() - for entry in "${WRONG_VERSION_PKGS[@]}"; do - pkg_spec=$(echo "$entry" | cut -d' ' -f1) - info " Aktualisiere: $pkg_spec" - if "$VENV/bin/pip" install "$pkg_spec" -q; then - ok " Aktualisiert: $pkg_spec" - CHANGED_PKGS+=("~ $pkg_spec") - else - err " FEHLER beim Update: $pkg_spec" - UPGRADE_FAILED+=("$pkg_spec") - fi - done - - if $RESTART_AFTER_PIP; then - info "Starte timemaster Service..." - systemctl start timemaster || true - sleep 3 - fi - - if [[ ${#UPGRADE_FAILED[@]} -gt 0 ]]; then - warn "${#UPGRADE_FAILED[@]} Pakete konnten NICHT aktualisiert werden" - else - ok "Alle Versionskonflikte erfolgreich behoben" - fi - else - ok "Alle Paketversionen korrekt" - fi - - if [[ ${#CHANGED_PKGS[@]} -gt 0 ]]; then - summary_add "${GREEN}Phase 3: ${#CHANGED_PKGS[@]} Pakete geändert:${RESET}" - for c in "${CHANGED_PKGS[@]}"; do - summary_add " ${CYAN}$c${RESET}" - done - else - summary_add "${GREEN}Phase 3: Keine Paketänderungen notwendig${RESET}" - fi -fi - -# ============================================================================= -# PHASE 4 – POST-UPDATE FUNKTIONSTEST -# ============================================================================= -hdr "Phase 4 · Post-Update Funktionstest" - -# Service sicherheitshalber aktivieren -if ! systemctl is-active --quiet timemaster 2>/dev/null; then - info "Starte timemaster Service..." - systemctl start timemaster || true - sleep 5 -fi - -FUNC_OK=true - -# GET /health → 200 -RES=$(measure_curl "$API_BASE/health") -STATUS=$(echo "$RES" | cut -f1) -MS=$(echo "$RES" | cut -f2) -if [[ "$STATUS" == "200" ]]; then - ok "GET /health → HTTP $STATUS (${MS}ms)" -else - err "GET /health → HTTP $STATUS (erwartet: 200)" - FUNC_OK=false -fi - -# GET /api/v1/auth/me → 401 (kein Token) -RES=$(measure_curl "$API_BASE/api/v1/auth/me") -STATUS=$(echo "$RES" | cut -f1) -MS=$(echo "$RES" | cut -f2) -if [[ "$STATUS" == "401" ]]; then - ok "GET /api/v1/auth/me → HTTP $STATUS (kein Token = korrekt, ${MS}ms)" -else - err "GET /api/v1/auth/me → HTTP $STATUS (erwartet: 401)" - FUNC_OK=false -fi - -# GET / nginx Frontend → 200 mit HTML -NGINX_RES=$(curl -s -o /tmp/nginx_root.html -w "%{http_code}" \ - --connect-timeout 5 --max-time 10 "$FRONTEND_BASE/" 2>/dev/null || echo "000") -if [[ "$NGINX_RES" == "200" ]]; then - # Prüfe ob HTML zurückkommt - if grep -qi "/dev/null; then - ok "GET / → HTTP 200 mit HTML (nginx liefert Frontend aus)" - else - warn "GET / → HTTP 200, aber kein HTML-Inhalt (prüfe nginx-Konfiguration)" - fi -else - err "GET / → HTTP $NGINX_RES (nginx Frontend nicht erreichbar)" - FUNC_OK=false -fi -rm -f /tmp/nginx_root.html - -if $FUNC_OK; then - summary_add "${GREEN}Phase 4: Alle Funktionstests bestanden${RESET}" -else - summary_add "${RED}Phase 4: Funktionstests FEHLGESCHLAGEN${RESET}" -fi - -# ============================================================================= -# PHASE 5 – PERFORMANCE-TEST -# ============================================================================= -hdr "Phase 5 · Performance-Test (je $PERF_RUNS Messungen)" - -# Tabellen-Header -printf "\n${BOLD}%-40s %14s %10s${RESET}\n" "Endpunkt / Ressource" "Ø Zeit (ms)" "Status" -printf "%-40s %14s %10s\n" "$(printf '%0.s─' {1..40})" "$(printf '%0.s─' {1..14})" "$(printf '%0.s─' {1..10})" - -PERF_ISSUES=0 - -# Funktion: Zeile in Tabelle ausgeben -print_perf_row() { - local label="$1" ms="$2" threshold="$3" - local label_status - label_status=$(perf_label "$ms" "$threshold") - local col - col=$(perf_color "$ms" "$threshold") - if [[ "$label_status" != "OK" ]]; then (( PERF_ISSUES++ )) || true; fi - printf "${col}%-40s %14s %10s${RESET}\n" "$label" "${ms}ms" "$label_status" } -# GET /health (Schwellwert 100ms) -info "Messe GET /health ($PERF_RUNS Runs)..." -HEALTH_MS=$(avg_curl_ms "$API_BASE/health") -print_perf_row "GET /health" "$HEALTH_MS" 100 +# ============================================================================= +# ARGUMENT-PARSING +# ============================================================================= +OPT_TESTS=true +OPT_FRONTEND=true +OPT_SERVER="both" +OPT_DRYRUN=false -# GET /api/v1/auth/me (Schwellwert 200ms) -info "Messe GET /api/v1/auth/me ($PERF_RUNS Runs)..." -AUTH_MS=$(avg_curl_ms "$API_BASE/api/v1/auth/me") -print_perf_row "GET /api/v1/auth/me" "$AUTH_MS" 200 - -# PostgreSQL SELECT 1 (Schwellwert 100ms) -info "Messe PostgreSQL SELECT 1 ($PERF_RUNS Runs)..." -PG_TOTAL=0 -for (( i=0; i/dev/null || true - PG_END=$( date +%s%N ) - PG_DIFF=$(( (PG_END - PG_START) / 1000000 )) - PG_TOTAL=$(( PG_TOTAL + PG_DIFF )) +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 -PG_AVG=$(( PG_TOTAL / PERF_RUNS )) -print_perf_row "PostgreSQL SELECT 1" "$PG_AVG" 100 -# Redis PING (Schwellwert 50ms) -info "Messe Redis PING ($PERF_RUNS Runs)..." -REDIS_TOTAL=0 -for (( i=0; i/dev/null || true - R_END=$( date +%s%N ) - R_DIFF=$(( (R_END - R_START) / 1000000 )) - REDIS_TOTAL=$(( REDIS_TOTAL + R_DIFF )) -done -REDIS_AVG=$(( REDIS_TOTAL / PERF_RUNS )) - -# Redis PONG prüfen -REDIS_PING_RESULT=$(redis-cli ping 2>/dev/null || echo "FEHLER") -if [[ "$REDIS_PING_RESULT" != "PONG" ]]; then - printf "${RED}%-40s %14s %10s${RESET}\n" "Redis PING" "${REDIS_AVG}ms" "FEHLER" - (( PERF_ISSUES++ )) || true -else - print_perf_row "Redis PING" "$REDIS_AVG" 50 -fi - -echo "" -if [[ $PERF_ISSUES -eq 0 ]]; then - ok "Performance: Alle Werte im grünen Bereich" - summary_add "${GREEN}Phase 5: Performance OK (health ${HEALTH_MS}ms, auth/me ${AUTH_MS}ms, PG ${PG_AVG}ms, Redis ${REDIS_AVG}ms)${RESET}" -else - warn "Performance: $PERF_ISSUES Wert(e) außerhalb des Schwellwerts" - summary_add "${YELLOW}Phase 5: Performance-Warnung ($PERF_ISSUES langsame Werte)${RESET}" -fi +# Server-Liste aufbauen +SERVERS=() +case "$OPT_SERVER" in + 137) SERVERS=("$SERVER_137") ;; + 164) SERVERS=("$SERVER_164") ;; + both) SERVERS=("$SERVER_137" "$SERVER_164") ;; +esac # ============================================================================= -# PHASE 6 – ZUSAMMENFASSUNG +# ZUSAMMENFASSUNGS-TRACKING # ============================================================================= -hdr "Phase 6 · Zusammenfassung" +START_TIME=$(date +%s) +declare -A STEP_STATUS # name → "OK" | "FAIL" | "SKIP" | "WARN" +declare -a STEP_ORDER=() -echo "" -for line in "${SUMMARY_LINES[@]}"; do - echo -e " $line" -done +step_record() { + local name="$1" status="$2" + STEP_STATUS["$name"]="$status" + STEP_ORDER+=("$name") +} -echo "" -echo -e "${BOLD}Service-Status:${RESET}" -for SVC in timemaster nginx postgresql redis; do - if systemctl is-active --quiet "$SVC" 2>/dev/null; then - ok " $SVC" +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 - err " $SVC (NICHT aktiv)" + die "SSH-Verbindung zu $server fehlgeschlagen.\n Prüfe: ssh $server\n SSH-Key konfiguriert?" fi done -echo "" -echo -e "${BOLD}Log-Befehle (bei Problemen):${RESET}" -echo -e " ${CYAN}journalctl -u timemaster -n 100 --no-pager${RESET}" -echo -e " ${CYAN}journalctl -u nginx -n 50 --no-pager${RESET}" -echo -e " ${CYAN}journalctl -u postgresql -n 50 --no-pager${RESET}" -echo -e " ${CYAN}journalctl -u redis -n 20 --no-pager${RESET}" -echo -e " ${CYAN}pg_lsclusters${RESET}" +step_record "Pre-Flight" "OK" -echo "" -echo -e "${BOLD}Backups:${RESET}" -echo -e " Verzeichnis: $BACKUP_DIR" -ls -lh "$BACKUP_DIR"/*.dump 2>/dev/null | tail -5 || echo " (keine Dumps gefunden)" - -echo "" -FINISH_TIME=$(date) -echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" -if [[ $EXIT_CODE -eq 0 ]]; then - echo -e "${GREEN}${BOLD}✅ Update & Health-Check ERFOLGREICH abgeschlossen.${RESET}" +# ============================================================================= +# SCHRITT 1: TESTS (auf Server 137) +# ============================================================================= +if ! $OPT_TESTS; then + step "Schritt 1: Tests (übersprungen via --no-tests)" + step_record "1. Tests" "SKIP" else - echo -e "${YELLOW}${BOLD}⚠️ Update & Health-Check abgeschlossen – PROBLEME GEFUNDEN.${RESET}" - echo -e "${YELLOW} Bitte obige Fehlermeldungen und Log-Befehle prüfen.${RESET}" -fi -echo -e " Fertig: $FINISH_TIME" -echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" + step "Schritt 1: Tests auf $SERVER_137 ausführen" + info "Befehl: cd /opt/timemaster/backend && source venv/bin/activate && python -m pytest -x -q" -exit $EXIT_CODE + 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