From ca2b6731e0c5bc2dcdf2effd06160510363822f9 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 12 Oct 2025 13:23:48 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 3 + conf/kvm-lvm-full-backup.conf.sample | 31 ++ conf/kvm-lvm-full-restore.conf.sample | 26 ++ kvm-lvm-full-backup.sh | 395 +++++++++++++++++++++++++ kvm-lvm-full-restore.sh | 408 ++++++++++++++++++++++++++ 5 files changed, 863 insertions(+) create mode 100644 .gitignore create mode 100644 conf/kvm-lvm-full-backup.conf.sample create mode 100644 conf/kvm-lvm-full-restore.conf.sample create mode 100755 kvm-lvm-full-backup.sh create mode 100755 kvm-lvm-full-restore.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db4f39f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.swp +*.log +conf/*.conf diff --git a/conf/kvm-lvm-full-backup.conf.sample b/conf/kvm-lvm-full-backup.conf.sample new file mode 100644 index 0000000..ca58451 --- /dev/null +++ b/conf/kvm-lvm-full-backup.conf.sample @@ -0,0 +1,31 @@ +# ===== vm-full-backup.conf ===== +VM_NAME="WinServer2025" +LV_ORIG="/dev/VG-Windows-Server/WinSystem" + +# 1 TiB LV: Snapshot-Startwert 150 GiB (bei hoher Last ggf. erhöhen) +SNAP_SIZE="150G" + +# Zielordner (kann NFS sein) +BACKUP_DIR="/data/backup/${VM_NAME}" + +# Optional: sicherstellen, dass BACKUP_DIR gemountet ist (z.B. NFS) +REQUIRE_MOUNTED=0 # 1 = BACKUP_DIR muss gemountet sein +REQUIRE_NFS_TYPE=0 # 1 = BACKUP_DIR muss nfs/nfs4 Typ sein + +# Dateiprefix für Backup/Logs/XML +FILE_PREFIX="WinSystem_full" + +# Aufbewahrung +KEEP=2 + +# Speed-orientiert: +# +# DD_BS="4M" # Größe der von DD auf einmal geschriebenen Blöcke +# COMPRESS='cat' # keine Kompression (sehr schnell, aber riesig) +# +DD_BS="4M" +COMPRESS='cat' + +# QUIET wird bei Cron/kein TTY automatisch 1 +# QUIET=0 + diff --git a/conf/kvm-lvm-full-restore.conf.sample b/conf/kvm-lvm-full-restore.conf.sample new file mode 100644 index 0000000..21bed72 --- /dev/null +++ b/conf/kvm-lvm-full-restore.conf.sample @@ -0,0 +1,26 @@ +# ===== vm-full-restore.conf ===== +# libvirt VM-Name (zum Stoppen vor Restore) +VM_NAME="WinServer2025" + +# Ziel-Blockdevice (wird überschrieben!) +LV_TARGET="/dev/VG-Windows-Server/WinSystem" + +# Speicherort der Backups (muss die .img. + .sha256 + .xml enthalten) +BACKUP_DIR="/data/backup/${VM_NAME}" + +# Dateiprefix der Backups (muss zum Backup-Skript passen) +FILE_PREFIX="WinSystem_full" + +# dd Blocksize (Performance) +DD_BS="4M" + +# SHA-Verifikation standardmäßig an (1) oder aus (0) +VERIFY_SHA=0 + +# Quiet wird automatisch 1 ohne TTY +# QUIET=0 + +# NFS/Mount-Schutz +REQUIRE_MOUNTED=0 # 1: BACKUP_DIR muss gemountet sein +REQUIRE_NFS_TYPE=0 # 1: FSTYPE muss nfs/nfs4 sein + diff --git a/kvm-lvm-full-backup.sh b/kvm-lvm-full-backup.sh new file mode 100755 index 0000000..8c1d54d --- /dev/null +++ b/kvm-lvm-full-backup.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +############################################################################### +# kvm-lvm-full-backup.sh +# +# Vollbackup einer KVM/libvirt-VM, deren Disk auf einem LVM-Volume liegt. +# +# Funktionsweise / Ablauf: +# 1. Liest Konfigurationsdatei .conf aus conf/-Unterordner des Skripts. +# 2. Optional: Guest-Freeze (qemu-guest-agent/VSS) zur Dateikonsistenz. +# → Nur wenn Guest-Agent via 'guest-ping' erreichbar ist. +# 3. Erzeugt LVM-Snapshot des Original-LV (Copy-on-Write). +# 4. Exportiert die VM-Definition (XML) in den Backup-Ordner. +# 5. Liest den Snapshot blockweise mit dd und komprimiert (zstd/xz/gzip/...). +# 6. Schreibt Ergebnis als Vollbackup (*.img[.]) in BACKUP_DIR. +# 7. Testet Archiv (Integrität) und erzeugt SHA256-Hashdatei. +# 8. Entfernt Snapshot wieder. +# 9. Rotiert ältere Backups (löscht Archiv + SHA + XML + Log gleicher Generation). +# 10. Schreibt Laufzeit & Ergebnis ins Log; im Terminal farbige Statusausgabe & Spinner. +# +# Backup-Typ: Vollsicherung (kompletter Inhalt des LV wird gelesen/gesichert). +# +# Aufruf: +# kvm-lvm-full-backup.sh -> Startet Backup gemäß Konfiguration +# kvm-lvm-full-backup.sh -h|--help|-help|help -> Hilfe anzeigen +# +############################################################################### + +### ── 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" + +# Defaults (werden durch .conf überschrieben) +VM_NAME="${VM_NAME:-WinServer2025}" +LV_ORIG="${LV_ORIG:-/dev/VG-Windows-Server/WinSystem}" +SNAP_SIZE="${SNAP_SIZE:-150G}" # 1 TiB LV → Startwert 150G +BACKUP_DIR="${BACKUP_DIR:-/data/backup/WinServer2025}" +FILE_PREFIX="${FILE_PREFIX:-WinSystem_full}" +KEEP="${KEEP:-7}" +DD_BS="${DD_BS:-4M}" # größer für Speed +COMPRESS="${COMPRESS:-zstd -T0 -3}" # Speed-orientiert +QUIET="${QUIET:-0}" # bei Cron/kein TTY wird auto=1 gesetzt + +# NFS-Checks (optional) +REQUIRE_MOUNTED="${REQUIRE_MOUNTED:-1}" # 1 = BACKUP_DIR muss gemountet sein +REQUIRE_NFS_TYPE="${REQUIRE_NFS_TYPE:-0}" # 1 = BACKUP_DIR muss nfs/nfs4 sein + +# Rotation Dry-Run (nur anzeigen, was gelöscht würde) +ROTATION_DRY_RUN="${ROTATION_DRY_RUN:-0}" + +# Timestamps / Log +DATE="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="${BACKUP_DIR}/${FILE_PREFIX}_${DATE}.log" +START_TS=$(date +%s) + +### ── Help ────────────────────────────────────────────────────────────────── +show_help() { + cat <.conf). + 2. Guest-Agent Probe ('guest-ping'); optional Freeze/Thaw. + 3. Erzeugt LVM-Snapshot (Copy-on-Write). + 4. Exportiert VM-XML in BACKUP_DIR. + 5. Liest Snapshot blockweise (dd) und komprimiert (zstd/xz/gzip/...). + 6. Schreibt Vollbackup (*.img[.]) nach BACKUP_DIR. + 7. Testet Archiv und erzeugt SHA256-Hash. + 8. Entfernt Snapshot. + 9. Rotiert alte Backups inkl. XML & Log gleicher Generation. + +Ergebnis: + - Backup-Image (*.img bzw. *.img.) im BACKUP_DIR + - SHA256-Hash (*.sha256) + - VM-XML (*.xml) + - Logdatei im Skriptordner + +Restore-Beispiele (Produktiv): + lvcreate -L -n WinSystem /dev/VG-Windows-Server + sha256sum -c /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img..sha256 + /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img. \\ + | dd of=/dev/VG-Windows-Server/WinSystem bs=4M status=progress + virsh define /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.xml + virsh start WinServer2025 + +Probe-Restore (Test ohne Produktiv-Volume zu berühren): + lvcreate -L -n WinSystem_TEST /dev/VG-Windows-Server + /data/backup/WinServer2025/WinSystem_full_YYYYmmdd-HHMMSS.img. \\ + | dd of=/dev/VG-Windows-Server/WinSystem_TEST bs=4M status=progress + kpartx -av /dev/VG-Windows-Server/WinSystem_TEST + mount -o ro -t ntfs3 /dev/mapper/VG--Windows--Server-WinSystem_TESTp2 /mnt/wintest + umount /mnt/wintest && kpartx -dv /dev/VG-Windows-Server/WinSystem_TEST && lvremove -f /dev/VG-Windows-Server/WinSystem_TEST +EOF +} + +case "${1:-}" in + -h|--help|-help|help) + show_help + exit 0 + ;; +esac + +### ── Farbe & UI ──────────────────────────────────────────────────────────── +# Quiet automatisch bei kein TTY +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 (Snapshot/Thaw) ─────────────────────────────────────────────── +cleanup() { + # Thaw sicherheitshalber (falls Freeze zuvor OK) + if virsh qemu-agent-command "$VM_NAME" '{"execute":"guest-ping"}' >/dev/null 2>&1; then + virsh domfsthaw "$VM_NAME" >/dev/null 2>&1 || true + fi + if [[ -n "${LV_SNAP:-}" ]] && lvdisplay "$LV_SNAP" >/dev/null 2>&1; then + lvremove -f "$LV_SNAP" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +### ── Start & Config laden ────────────────────────────────────────────────── +if [[ -f "$CONF_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CONF_FILE" +fi + +DATE="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="${BACKUP_DIR}/${FILE_PREFIX}_${DATE}.log" + +# NFS/mount-Prüfungen +ensure_backup_target() { + mkdir -p "$BACKUP_DIR" || die "Kann BACKUP_DIR nicht anlegen/schreiben: ${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} (NFS/Remote offline?)" + 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 +} + +# Kompressions-Endung & Test +case "$COMPRESS" in + *xz*) EXT="xz"; TEST_CMD=(xz -t) ;; + *zstd*) EXT="zst"; TEST_CMD=(zstd -t) ;; + *gzip*|*pigz*) EXT="gz"; TEST_CMD=(gzip -t) ;; + *cat*) EXT="img"; TEST_CMD=() ;; # keine Kompression → nur .img + *) EXT="bin"; TEST_CMD=() ;; +esac + +OUT_BASENAME="${FILE_PREFIX}_${DATE}" +# Dateinamen korrekt bilden: bei COMPRESS='cat' nur .img, sonst .img. +if [[ "$EXT" == "img" ]]; then + ARCHIVE="${BACKUP_DIR}/${OUT_BASENAME}.img" +else + ARCHIVE="${BACKUP_DIR}/${OUT_BASENAME}.img.${EXT}" +fi +SHA="${ARCHIVE}.sha256" +XML="${BACKUP_DIR}/${OUT_BASENAME}.xml" + +### ── Header ──────────────────────────────────────────────────────────────── +if [[ "$QUIET" -eq 0 && -t 1 ]]; then + echo -e "${WHT}==>${RST} Starte Vollbackup: VM=${VM_NAME}, LV=${LV_ORIG}" + echo -e "${WHT}==>${RST} Ziel: ${ARCHIVE}" + echo +fi +log "Begin Backup: VM=${VM_NAME} LV=${LV_ORIG} -> ${ARCHIVE}" + +[[ -b "$LV_ORIG" ]] || die "LV nicht gefunden: ${LV_ORIG}" +ensure_backup_target + +### ── Guest-Agent robust prüfen & ggf. Freeze ─────────────────────────────── +agent_ok=0 +if virsh qemu-agent-command "$VM_NAME" '{"execute":"guest-ping"}' >/dev/null 2>&1; then + agent_ok=1 +fi +if (( agent_ok )); then + if ! virsh domfsfreeze "$VM_NAME" >>"$LOG_FILE" 2>&1; then + warn "domfsfreeze fehlgeschlagen – fahre ohne Freeze fort." + else + if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_DONE} Gast-Dateisystem(e) gefreezt"; fi + log "Guest-Freeze OK" + fi +else + if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_WARN} Guest-Agent nicht erreichbar – ohne Freeze"; fi + log "Guest agent ping failed; skipping freeze." +fi + +### ── Snapshot erstellen ──────────────────────────────────────────────────── +SNAP_NAME="$(basename "$LV_ORIG")_snap" +LV_SNAP="$(dirname "$LV_ORIG")/${SNAP_NAME}" +run_step "LVM-Snapshot erstellen (${SNAP_SIZE})" "lvcreate -L \"$SNAP_SIZE\" -s -n \"$SNAP_NAME\" \"$LV_ORIG\"" + +# Sofort Thaw (falls Freeze OK) +if (( agent_ok )); then + if ! virsh domfsthaw "$VM_NAME" >>"$LOG_FILE" 2>&1; then + warn "domfsthaw fehlgeschlagen – fahre fort." + else + if [[ "$QUIET" -eq 0 && -t 1 ]]; then echo -e "${LBL_DONE} Gast-Dateisystem(e) wieder aufgetaut"; fi + log "Guest-Thaw OK" + fi +fi + +### ── VM-XML exportieren ──────────────────────────────────────────────────── +run_step "VM-XML exportieren" "virsh dumpxml \"$VM_NAME\" > \"$XML\"" + +### ── Snapshot sichern & komprimieren ─────────────────────────────────────── +SIZE_BYTES="$(blockdev --getsize64 "$LV_ORIG")" +if have_cmd pv; then + run_step "Snapshot sichern & komprimieren" \ + "dd if=\"$LV_SNAP\" bs=\"$DD_BS\" status=none | pv -ptrab -s \"$SIZE_BYTES\" >>\"$LOG_FILE\" 2>&1 | $COMPRESS > \"$ARCHIVE\"" +else + run_step "Snapshot sichern & komprimieren" \ + "dd if=\"$LV_SNAP\" bs=\"$DD_BS\" status=none | $COMPRESS > \"$ARCHIVE\"" +fi + +### ── LVM Snapshot entfernen ──────────────────────────────────────────────────── +run_step "LVM-Snapshot entfernen" "lvremove -f \"$LV_SNAP\"" + +### ── Archiv testen & Hash erzeugen ──────────────────────────────────────── +if ((${#TEST_CMD[@]})); then + run_step "Archiv-Integrität prüfen" "${TEST_CMD[*]} \"$ARCHIVE\"" +else + warn "Kein integrierter Test für COMPRESS='$COMPRESS' konfiguriert." +fi +run_step "SHA256 erzeugen" "sha256sum \"$ARCHIVE\" | tee \"$SHA\" >/dev/null" + +### ── Rotation (Report + Dry-Run) ─────────────────────────────────────────── +# Bewahre nur die letzten $KEEP Generationen (Basis: FILE_PREFIX_YYYYmmdd-HHMMSS) +shopt -s nullglob +backups=( "${BACKUP_DIR}/${FILE_PREFIX}_"*.img* ) +removed_bases=() +removed_files=() + +if ((${#backups[@]}>0)); then + IFS=$'\n' backups_sorted=($(ls -1t "${backups[@]}")) ; unset IFS + + declare -A seen=() + basen=() + for f in "${backups_sorted[@]}"; do + base="$f" + base="${base%.sha256}" # evtl. .sha256 abwerfen + base="${base%.*}" # letzte Endung (.zst/.xz/.gz/.img) abwerfen + base="${base%.img}" # .img abwerfen → reine Generation + base="$(basename "$base")" + if [[ -z "${seen[$base]:-}" ]]; then + seen[$base]=1 + basen+=( "$base" ) + fi + done + + if ((${#basen[@]}>KEEP)); then + for base in "${basen[@]:$KEEP}"; do + cand=( \ + "${BACKUP_DIR}/${base}.img" \ + "${BACKUP_DIR}/${base}.img."* \ + "${BACKUP_DIR}/${base}.img."*".sha256" \ + "${BACKUP_DIR}/${base}.sha256" \ + "${BACKUP_DIR}/${base}.xml" \ + "${SCRIPT_DIR}/${base}.log" \ + ) + for f in "${cand[@]}"; do + [[ -e "$f" ]] || continue + if [[ "${ROTATION_DRY_RUN:-0}" -eq 1 ]]; then + removed_files+=( "(würde löschen) $f" ) + log "Rotation (dry-run): würde entfernen: $f" + else + if rm -f -- "$f"; then + removed_files+=( "$f" ) + log "Rotation: entfernt: $f" + else + warn "Rotation: konnte nicht löschen: $f" + fi + fi + done + removed_bases+=( "$base" ) + done + else + log "Rotation: nichts zu entfernen (Generationen=${#basen[@]}, KEEP=$KEEP)." + fi +else + log "Rotation: keine Backups gefunden, übersprungen." +fi +shopt -u nullglob + +# Zusammenfassung (TTY kurz, Log detailliert) +if ((${#removed_bases[@]}>0)); then + if [[ "${ROTATION_DRY_RUN:-0}" -eq 1 ]]; then + log "Rotation (dry-run): ${#removed_bases[@]} Generation(en) betroffen: ${removed_bases[*]}" + if [[ "$QUIET" -eq 0 && -t 1 ]]; then + echo -e "${LBL_DONE} Rotation (dry-run): ${#removed_bases[@]} Generation(en) betroffen:" + for b in "${removed_bases[@]}"; do echo " - ${b}"; done + fi + else + log "Rotation: ${#removed_bases[@]} Generation(en) entfernt: ${removed_bases[*]}" + if [[ "$QUIET" -eq 0 && -t 1 ]]; then + echo -e "${LBL_DONE} Rotation: ${#removed_bases[@]} Generation(en) entfernt:" + for b in "${removed_bases[@]}"; do echo " - ${b}"; done + fi + fi +else + log "Rotation: keine Generation entfernt." + if [[ "$QUIET" -eq 0 && -t 1 ]]; then + echo -e "${LBL_OK} Rotation: nichts zu entfernen" + fi +fi + +### ── Dauer berechnen & Abschluss ─────────────────────────────────────────── +END_TS=$(date +%s) +DURATION=$(( END_TS - START_TS )) +D_H=$(( DURATION / 3600 )) +D_M=$(( (DURATION % 3600) / 60 )) +D_S=$(( DURATION % 60 )) +log "Backup-Dauer: ${D_H}h ${D_M}m ${D_S}s (${DURATION}s)" + +if [[ "$QUIET" -eq 0 && -t 1 ]]; then + echo + echo -e "${LBL_OK} Backup abgeschlossen: ${ARCHIVE}" + echo -e "${WHT} XML:${RST} ${XML}" + echo -e "${WHT} SHA:${RST} ${SHA}" + echo -e "${WHT} Dauer:${RST} ${D_H}h ${D_M}m ${D_S}s" +fi + +exit 0 + diff --git a/kvm-lvm-full-restore.sh b/kvm-lvm-full-restore.sh new file mode 100755 index 0000000..8807b0f --- /dev/null +++ b/kvm-lvm-full-restore.sh @@ -0,0 +1,408 @@ +#!/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 +