#!/usr/bin/env bash set -Eeuo pipefail ############################################################################### # kvm-lvm-full-restore.sh # # Stellt ein Vollbackup (erstellt mit dem passenden Backup-Skript) auf ein # Ziel-LVM zurück. Identische UI (Farben/Spinner/Quiet), Konfig-Lokation, # NFS-/Mount-Checks, Logging. Interaktive Auswahl listet NUR echte Backups: # (*.img | *.img.{zst,xz,gz}) – KEINE *.sha256. # # Ablauf: # 1) Config laden (conf/.conf relativ zum Skript) # 2) CLI-Argumente parsen (haben Vorrang vor Config) # 3) NFS-/Mount-Checks # 4) Backup-Liste anzeigen → Auswahl per Index ODER Dateipfad einfügen # 5) optional SHA256-Check # 6) VM sauber stoppen (virsh), ggf. force mit --yes # 7) Dekomprimieren → dd ins Ziel-LV # 8) Dauer & Ergebnis loggen, TTY-Ausgabe farbig und übersichtlich # # Aufruf: # kvm-lvm-full-restore.sh → interaktive Auswahl & Restore # kvm-lvm-full-restore.sh --list → nur auflisten # kvm-lvm-full-restore.sh --restore= → genau diese Datei einspielen # kvm-lvm-full-restore.sh --target-lv= → Ziel-LV überschreiben # kvm-lvm-full-restore.sh --yes → keine Rückfragen (force allowed) # kvm-lvm-full-restore.sh --no-verify → SHA256-Check überspringen # kvm-lvm-full-restore.sh --verify → SHA256-Check erzwingen (Default) # kvm-lvm-full-restore.sh -h|--help|-help|help → Hilfe ############################################################################### # ── Script meta ───────────────────────────────────────────────────────────── SCRIPT_PATH="$(readlink -f "$0")" SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" SCRIPT_NAME="$(basename "$SCRIPT_PATH")" SCRIPT_PREFIX="${SCRIPT_NAME%.*}" CONF_DIR="${SCRIPT_DIR}/conf" CONF_FILE="${CONF_DIR}/${SCRIPT_PREFIX}.conf" # ── Help vorab definieren (wird beim Arg-Parsing gebraucht) ──────────────── show_help() { cat <]) auf ein Ziel-LVM zurück. Optionen: -h, --help, -help, help Zeigt diese Hilfe. --list Listet verfügbare Backups (neueste zuerst). --restore= Spielt genau diese Backup-Datei zurück. --target-lv= Ziel-LV (Blockdevice) überschreiben. --yes Keine Rückfragen (Bestätigungen auto-OK, force-off erlaubt). --no-verify Überspringt SHA256-Prüfung. --verify Erzwingt SHA256-Prüfung (Standard). Ablauf: 1) Auswahl Backup (Index oder Pfad), optional SHA-Check. 2) VM stoppen (virsh shutdown, Timeout), ggf. force wenn --yes. 3) | dd of= bs= (status=progress in TTY). 4) Dauer & Ergebnis ins Log; farbige UI im Terminal. Wichtig: - Das Ziel-LV wird überschrieben. - Stelle sicher, dass die VM gestoppt ist und das Volume korrekt ist. EOF } # ── Defaults (können durch .conf überschrieben werden) ───────────────────── VM_NAME="${VM_NAME:-WinServer2025}" LV_TARGET="${LV_TARGET:-/dev/VG-Windows-Server/WinSystem}" BACKUP_DIR="${BACKUP_DIR:-/data/backup/WinServer2025}" FILE_PREFIX="${FILE_PREFIX:-WinSystem_full}" DD_BS="${DD_BS:-4M}" QUIET="${QUIET:-0}" VERIFY_SHA="${VERIFY_SHA:-1}" REQUIRE_MOUNTED="${REQUIRE_MOUNTED:-1}" REQUIRE_NFS_TYPE="${REQUIRE_NFS_TYPE:-0}" # ── Config ZUERST laden (damit CLI-Argumente Vorrang haben) ──────────────── if [[ -f "$CONF_FILE" ]]; then # shellcheck disable=SC1090 source "$CONF_FILE" fi # ── CLI-Argumente parsen (überschreiben Config) ──────────────────────────── RESTORE_FILE="" YES=0 LIST_ONLY=0 for arg in "$@"; do case "$arg" in --list) LIST_ONLY=1 ;; --restore=*) RESTORE_FILE="${arg#--restore=}" ;; --target-lv=*) LV_TARGET="${arg#--target-lv=}" ;; --yes) YES=1 ;; --no-verify) VERIFY_SHA=0 ;; --verify) VERIFY_SHA=1 ;; -h|--help|-help|help) show_help; exit 0 ;; *) ;; esac done # ── Timestamps / Log ─────────────────────────────────────────────────────── DATE="$(date +%Y%m%d-%H%M%S)" LOG_FILE="${BACKUP_DIR}/${FILE_PREFIX}_restore_${DATE}.log" START_TS=$(date +%s) # ── Farbe & UI ───────────────────────────────────────────────────────────── if [[ -t 1 ]]; then QUIET="${QUIET}" else QUIET=1 fi if [[ -t 1 ]]; then RST=$'\033[0m'; WHT=$'\033[37m'; GRN=$'\033[32m'; RED=$'\033[31m'; YEL=$'\033[33m' else RST=""; WHT=""; GRN=""; RED=""; YEL="" fi LBL_OK="${WHT}[ ${GRN}OK${WHT} ]${RST}" LBL_DONE="${WHT}[ ${GRN}Done${WHT} ]${RST}" LBL_ERR="${WHT}[ ${RED}Error${WHT} ]${RST}" LBL_WARN="${WHT}[ ${YEL}warn${WHT} ]${RST}" spinner() { local pid="$1" msg="$2" i=0 marks='|/-\' while kill -0 "$pid" 2>/dev/null; do if [[ "$QUIET" -eq 0 && -t 1 ]]; then printf "\r${WHT}[ %s ${WHT}]%s%s" "${marks:i%4:1}" "${RST} " "$msg" i=$(( (i+1) % 4 )) fi sleep 0.1 done if [[ "$QUIET" -eq 0 && -t 1 ]]; then printf "\r\033[K"; fi } # ── Logging helpers ──────────────────────────────────────────────────────── have_cmd() { command -v "$1" >/dev/null 2>&1; } log() { echo "[$(date +%F\ %T)] $*" >>"$LOG_FILE"; } out() { [[ "$QUIET" -eq 0 ]] && echo -e "$*"; log "$*"; } warn() { echo -e "${LBL_WARN} $*" >&2; log "WARN: $*"; } die() { echo -e "${LBL_ERR} $*" >&2; log "ERROR: $*"; exit 1; } run_step() { local msg="$1"; shift local cmd="$*" log "STEP: $msg :: $cmd" if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -n "$msg ... " else log "$msg ..." fi bash -c "$cmd" >>"$LOG_FILE" 2>&1 & local pid=$! spinner "$pid" "$msg" if ! wait "$pid"; then echo -e "${LBL_ERR} $msg" die "$msg fehlgeschlagen. Details: $LOG_FILE" fi if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_OK} $msg"; fi log "OK: $msg" } cleanup() { :; } trap cleanup EXIT # ── Checks: BACKUP_DIR mount/NFS ─────────────────────────────────────────── ensure_backup_source() { [[ -d "$BACKUP_DIR" ]] || die "BACKUP_DIR existiert nicht: ${BACKUP_DIR}" if [[ "${REQUIRE_MOUNTED}" -eq 1 ]]; then if ! mountpoint -q "$BACKUP_DIR"; then if ! findmnt -n "$BACKUP_DIR" >/dev/null 2>&1; then die "BACKUP_DIR ist nicht gemountet: ${BACKUP_DIR}" fi fi fi if [[ "${REQUIRE_NFS_TYPE}" -eq 1 ]]; then local fstype fstype="$(findmnt -no FSTYPE "$BACKUP_DIR" 2>/dev/null || true)" [[ "$fstype" == "nfs" || "$fstype" == "nfs4" ]] || die "BACKUP_DIR ist kein NFS-Mount (FSTYPE=$fstype)." fi } # ^^^ falls deine Shell 'end' nicht kennt, bitte durch 'fi' ersetzen # ── Gültige Backup-Dateien finden (ohne .sha256) ─────────────────────────── get_backup_files() { BACKUP_FILES=() shopt -s nullglob local candidates=( "${BACKUP_DIR}/${FILE_PREFIX}_"*.img "${BACKUP_DIR}/${FILE_PREFIX}_"*.img.zst "${BACKUP_DIR}/${FILE_PREFIX}_"*.img.xz "${BACKUP_DIR}/${FILE_PREFIX}_"*.img.gz ) shopt -u nullglob local existing=() for f in "${candidates[@]}"; do [[ -f "$f" ]] && existing+=( "$f" ) done ((${#existing[@]})) || return 1 IFS=$'\n' BACKUP_FILES=($(ls -1t "${existing[@]}")) ; unset IFS return 0 } # ── Liste anzeigen ───────────────────────────────────────────────────────── list_backups() { ensure_backup_source if ! get_backup_files; then echo "Keine Backups gefunden in: ${BACKUP_DIR}" return 1 fi printf "%s\n" "Verfügbare Backups (neueste zuerst):" local idx=1 for f in "${BACKUP_FILES[@]}"; do local size date if stat --version >/dev/null 2>&1; then size="$(stat -c %s "$f")" date="$(stat -c %y "$f" | cut -d'.' -f1)" else size="$(stat -f %z "$f")" date="$(stat -f %Sm -t '%Y-%m-%d %H:%M:%S' "$f")" fi local hsize; hsize="$(numfmt --to=iec --suffix=B "$size" 2>/dev/null || echo "${size}B")" printf " %2d) %s %8s %s\n" "$idx" "$date" "$hsize" "$f" idx=$((idx+1)) done return 0 } # ── Interaktive Auswahl ──────────────────────────────────────────────────── pick_backup_interactive() { list_backups || return 1 echo echo "Gib die Zahl (Index) ein ODER füge den vollständigen Dateipfad ein:" read -r choice [[ -n "$choice" ]] || return 1 # kompletter Pfad eingefügt? if [[ -f "$choice" ]]; then RESTORE_FILE="$choice" return 0 fi # Index gewählt? if [[ "$choice" =~ ^[0-9]+$ ]]; then local idx="$choice" if ! get_backup_files; then return 1; fi if (( idx>=1 && idx<=${#BACKUP_FILES[@]} )); then RESTORE_FILE="${BACKUP_FILES[$((idx-1))]}" return 0 fi fi return 1 } # ── SHA256 check ─────────────────────────────────────────────────────────── sha_check() { local file="$1" local sha="${file}.sha256" if [[ ! -f "$sha" ]]; then warn "Keine SHA256-Datei gefunden: ${sha} – überspringe Verifikation." return 0 fi run_step "SHA256 prüfen" "sha256sum -c \"$sha\"" } # ── VM stoppen ───────────────────────────────────────────────────────────── stop_vm_safely() { local name="$1" timeout="${2:-180}" if ! virsh dominfo "$name" >/dev/null 2>&1; then warn "VM ${name} ist in libvirt nicht definiert – überspringe Shutdown." return 0 fi local state; state="$(virsh domstate "$name" 2>/dev/null || true)" case "$state" in running|blocked|pmsuspended|paused) out "VM ${name} wird sauber heruntergefahren ..." if ! virsh shutdown "$name" >>"$LOG_FILE" 2>&1; then warn "virsh shutdown fehlgeschlagen." fi local t=0 while [[ $t -lt $timeout ]]; do state="$(virsh domstate "$name" 2>/dev/null || true)" [[ "$state" == "shut off" || "$state" == "shutoff" ]] && break sleep 2; t=$((t+2)) done if [[ "$state" != "shut off" && "$state" != "shutoff" ]]; then if [[ $YES -eq 1 ]]; then warn "Timeout – force off via virsh destroy." virsh destroy "$name" >>"$LOG_FILE" 2>&1 || warn "virsh destroy fehlgeschlagen." else die "VM ist noch aktiv (Zustand: $state). Starte erneut mit --yes, um force-off zu erlauben." fi fi ;; "shut off"|"shutoff"|crashed|nostate) out "VM ${name} ist nicht aktiv (state: $state)." ;; *) warn "Unbekannter VM-State: '$state' – fahre fort." ;; esac } # ── Dekompression bestimmen ──────────────────────────────────────────────── decomp_cmd_for() { local file="$1" case "$file" in *.zst) echo "zstd -d -c" ;; *.xz) echo "unxz -c" ;; *.gz) echo "gzip -d -c" ;; *.img) echo "cat" ;; *) echo "" ;; esac } # ── MAIN ─────────────────────────────────────────────────────────────────── if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${WHT}==>${RST} VM-Restore gestartet VM=${VM_NAME} Target=${LV_TARGET}" echo -e "${WHT}==>${RST} Backups aus: ${BACKUP_DIR}" echo fi log "Begin Restore: VM=${VM_NAME} Target=${LV_TARGET} BACKUP_DIR=${BACKUP_DIR} VERIFY_SHA=${VERIFY_SHA}" ensure_backup_source # Backup auswählen / listen if [[ -z "$RESTORE_FILE" && $LIST_ONLY -eq 1 ]]; then list_backups; exit $? fi if [[ -z "$RESTORE_FILE" ]]; then if [[ "$QUIET" -eq 1 ]]; then die "Keine Backup-Datei angegeben. Nutze --restore= oder --list (Quiet-Modus)." fi if ! pick_backup_interactive; then die "Keine gültige Auswahl getroffen." fi fi [[ -r "$RESTORE_FILE" ]] || die "Backup-Datei nicht lesbar: ${RESTORE_FILE}" [[ -b "$LV_TARGET" ]] || die "Ziel-LV nicht gefunden: ${LV_TARGET}" # Bestätigung (interaktiv) if [[ $YES -ne 1 && "$QUIET" -eq 0 && -t 1 ]]; then echo echo "Achtung: ${LV_TARGET} wird ÜBERSCHRIEBEN." echo "Backup: ${RESTORE_FILE}" read -r -p "Fortsetzen? (yes/NO): " ack [[ "$ack" == "yes" ]] || die "Abgebrochen." fi # SHA prüfen / überspringen if [[ $VERIFY_SHA -eq 1 ]]; then sha_check "$RESTORE_FILE" else warn "SHA-Prüfung deaktiviert (--no-verify)." fi # VM stoppen stop_vm_safely "$VM_NAME" 180 # Dekompressor bestimmen DECOMP="$(decomp_cmd_for "$RESTORE_FILE")" [[ -n "$DECOMP" ]] || die "Unbekannte Dateiendung, bitte gültige Backup-Datei angeben." # Restore durchführen if [[ "$QUIET" -eq 0 && -t 1 ]]; then out "Restore läuft -> dies kann je nach Größe/IO dauern ..." fi # Status=progress nur im TTY anzeigen, sonst leise if [[ -t 1 ]]; then run_step "Backup dekomprimieren & schreiben" \ "$DECOMP \"$RESTORE_FILE\" | dd of=\"$LV_TARGET\" bs=\"$DD_BS\" status=progress" else run_step "Backup dekomprimieren & schreiben" \ "$DECOMP \"$RESTORE_FILE\" | dd of=\"$LV_TARGET\" bs=\"$DD_BS\" status=none" fi # Dauer berechnen END_TS=$(date +%s) DURATION=$(( END_TS - START_TS )) D_H=$(( DURATION / 3600 )) D_M=$(( (DURATION % 3600) / 60 )) D_S=$(( DURATION % 60 )) log "Restore-Dauer: ${D_H}h ${D_M}m ${D_S}s (${DURATION}s)" if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo echo -e "${LBL_OK} Restore abgeschlossen." echo -e "${WHT} Quelle:${RST} ${RESTORE_FILE}" echo -e "${WHT} Ziel :${RST} ${LV_TARGET}" echo -e "${WHT} Dauer :${RST} ${D_H}h ${D_M}m ${D_S}s" echo echo "Hinweis:" echo " - Falls die VM-Definition neu eingespielt werden soll:" echo " virsh define \"${RESTORE_FILE%.img*}.xml\"" echo " - Danach starten:" echo " virsh start ${VM_NAME}" fi exit 0