#!/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