BackupScript/restore.sh

511 lines
23 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env bash
# =============================================================================
# /opt/windmill-restore/restore.sh
# Windmill Backup Restore Worker
# Version: 1.0.8
#
# WAS DIESES SCRIPT MACHT:
# ─────────────────────────────────────────────────────────────────────────────
# Läuft direkt auf dem Proxmox Restore-Server, gestartet von Windmill per SSH
# non-blocking (nohup ... &). Komplett eigenständig, keine offene Verbindung.
#
# Alle Pfade kommen aus der Datenbank via Windmill:
# --restore-mount Mountpoint des Restore-Volumes z.B. /mnt/4TB
# --restore-path PVE-Storage-Name für qmrestore z.B. local-lvm
# --rsync-target Rsync-Ziel auf Backup-Server z.B. /backup/incoming/TNP
# --pbs-storage PVE-Storage-ID des PBS-Datastores z.B. pbs-tnp-invest-gmbh
#
# KEYS beide werden per Rsync vom PBS-Server geholt (PBS_HOST aus pbs.conf):
# PBS Encrypt-Key: /root/Scripte/${DATASTORE}.keyfile → für qmrestore
# 7z-Passwort: /root/Scripte/password_7z.txt → für 7z-Archiv
# Format: "tnp-Invest-GmbH: Passwort123"
# Beide werden lokal gecacht bei mehreren VMs desselben Kunden
# nur einmal vom PBS-Server geholt.
#
# ABLAUF:
# [0] Keys holen Keyfile + 7z-Passwort per Rsync vom PBS-Server
# [1] Space-Check Freier Platz auf restore-mount prüfen
# [2] VM-ID ermitteln Original aus Backup-Pfad, Restore-ID ab 1000
# [3] qmrestore Direkt vom PBS-Storage mit --keyfile
# [4] VM vorbereiten unlock, cdrom/ide0 entfernen, alle Netzwerkkarten
# löschen, Agent aktivieren
# [5] VM starten & 120s auf qm-Agent warten (10s Schritte)
# Agent prüfen Kein Agent = qm_agent_ok=false, KEIN Abbruch
# [6] VM stoppen Sauberes Shutdown, nach 30s force-stop
# [7] 7z-Archiv VM-Images verschlüsselt zippen (7z-Passwort)
# [8] Rsync ZIP zum Backup-Server (Firmen-Zielverzeichnis)
# 3 Versuche + Größenvergleich
# [9] Aufräumen VM destroy, ZIP löschen
# [10] Webhook JSON → Windmill schreibt DB & startet nächsten
# =============================================================================
set -euo pipefail
# ── Konfigdatei laden (PBS-Credentials) ──────────────────────────────────────
# pbs.conf wird von Windmill Step C per SFTP deployt (chmod 600)
# Enthält: PBS_HOST, PBS_PORT, PBS_USER, PBS_PASSWORD, PBS_FINGERPRINT
CONF_FILE="/opt/windmill-restore/pbs.conf"
[[ ! -f "$CONF_FILE" ]] && { echo "FEHLER: $CONF_FILE fehlt!" >&2; exit 1; }
# shellcheck source=/dev/null
source "$CONF_FILE"
# ── Argument-Parser ───────────────────────────────────────────────────────────
# Kein --encrypt-key Keys werden per Rsync vom PBS-Server geholt
JOB_UUID=""
BACKUP_PATH=""
CLIENT_NAME=""
RESTORE_MOUNT=""
RESTORE_PATH=""
RSYNC_TARGET=""
PBS_STORAGE=""
WEBHOOK_URL=""
WEBHOOK_TOKEN=""
while [[ $# -gt 0 ]]; do
case $1 in
--job-uuid) JOB_UUID="$2"; shift 2 ;;
--backup-path) BACKUP_PATH="$2"; shift 2 ;;
--client) CLIENT_NAME="$2"; shift 2 ;;
--restore-mount) RESTORE_MOUNT="$2"; shift 2 ;;
--restore-path) RESTORE_PATH="$2"; shift 2 ;;
--rsync-target) RSYNC_TARGET="$2"; shift 2 ;;
--pbs-storage) PBS_STORAGE="$2"; shift 2 ;;
--webhook-url) WEBHOOK_URL="$2"; shift 2 ;;
--webhook-token) WEBHOOK_TOKEN="$2"; shift 2 ;;
*) echo "Unbekannter Parameter: $1" >&2; exit 1 ;;
esac
done
# Pflichtparameter prüfen kein Fallback
for var in JOB_UUID BACKUP_PATH CLIENT_NAME \
RESTORE_MOUNT RESTORE_PATH RSYNC_TARGET PBS_STORAGE WEBHOOK_URL; do
[[ -z "${!var}" ]] && { echo "FEHLER: --${var//_/-} fehlt" >&2; exit 1; }
done
[[ ! -d "$RESTORE_MOUNT" ]] && {
echo "FEHLER: Restore-Mount '$RESTORE_MOUNT' existiert nicht!" >&2
exit 1
}
# ── Logging ───────────────────────────────────────────────────────────────────
LOG_DIR="/opt/windmill-restore/logs"
mkdir -p "$LOG_DIR"
SAFE_CLIENT="${CLIENT_NAME//\//_}" # "vm/100" → "vm_100"
LOG_FILE="$LOG_DIR/${SAFE_CLIENT}_$(date +%Y%m%d_%H%M%S).log"
exec > >(tee -a "$LOG_FILE") 2>&1
# ── Backup-Pfad zerlegen ──────────────────────────────────────────────────────
# BACKUP_PATH Format: "tnp-Invest-GmbH:vm/100/2024-01-15T02:00:00Z"
# DATASTORE = "tnp-Invest-GmbH"
# SNAPSHOT_PATH = "vm/100/2024-01-15T02:00:00Z"
# PVE_BACKUP_REF = "pbs-tnp-invest-gmbh:backup/vm/100/2024-01-15T02:00:00Z"
DATASTORE=$(echo "$BACKUP_PATH" | cut -d: -f1)
SNAPSHOT_PATH=$(echo "$BACKUP_PATH" | cut -d: -f2)
PVE_BACKUP_REF="${PBS_STORAGE}:backup/${SNAPSHOT_PATH}"
# ── Pfade & Messvariablen ─────────────────────────────────────────────────────
ZIP_DIR="${RESTORE_MOUNT}/zips"
BACKUP_SERVER_HOST=$(cat /opt/windmill-restore/backup_server_host 2>/dev/null \
|| echo "backup-server")
RESTORE_START=$(date +%s)
STATUS="success"
ERROR_MSG=""
VM_ID_ORIGINAL=0
VM_ID_RESTORED=0
VM_NAME=""
ACTUAL_DISK_BYTES=0
ZIP_SIZE_BYTES=0
ZIP_DURATION=0
RSYNC_SIZE_BYTES=0
RSYNC_OK="true"
RSYNC_RETRIES=0
QM_AGENT_OK="false"
RESTORE_DURATION=0
ZIP_PASSWORD=""
KEYFILE_LOCAL=""
echo "============================================================"
echo " Windmill Restore Worker v1.0.8"
echo " Client: $CLIENT_NAME"
echo " Datastore: $DATASTORE"
echo " Backup: $BACKUP_PATH"
echo " PBS-Storage: $PBS_STORAGE"
echo " Restore-Mount: $RESTORE_MOUNT"
echo " Restore-Path: $RESTORE_PATH"
echo " Rsync-Target: $RSYNC_TARGET"
echo " Job-UUID: $JOB_UUID"
echo " Start: $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================================"
# ── Webhook-Funktion ──────────────────────────────────────────────────────────
send_webhook() {
local wh_status="$1"
local wh_error="${2:-}"
local duration=$(( $(date +%s) - RESTORE_START ))
local payload
payload=$(printf '{
"job_uuid": "%s",
"client_name": "%s",
"status": "%s",
"error_message": "%s",
"vm_id_original": %d,
"vm_id_restored": %d,
"restore_duration_sec": %d,
"actual_disk_used_bytes": %d,
"zip_size_bytes": %d,
"zip_duration_sec": %d,
"rsync_size_bytes": %d,
"rsync_ok": %s,
"rsync_retries": %d,
"qm_agent_ok": %s,
"log_file": "%s"
}' \
"$JOB_UUID" "$CLIENT_NAME" "$wh_status" "$wh_error" \
"$VM_ID_ORIGINAL" "$VM_ID_RESTORED" \
"$duration" "$ACTUAL_DISK_BYTES" \
"$ZIP_SIZE_BYTES" "$ZIP_DURATION" \
"$RSYNC_SIZE_BYTES" "$RSYNC_OK" "$RSYNC_RETRIES" \
"$QM_AGENT_OK" "$LOG_FILE")
echo ""
echo "==> Sende Webhook..."
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
${WEBHOOK_TOKEN:+-H "Authorization: Bearer ${WEBHOOK_TOKEN}"} \
-d "$payload")
echo " HTTP: $http_code"
[[ "$http_code" =~ ^2 ]] && echo " Webhook OK." \
|| echo " WARNUNG: HTTP $http_code"
}
trap 'STATUS="failed"
send_webhook "failed" "Abgebrochen in Zeile $LINENO $LOG_FILE"' ERR
# ═════════════════════════════════════════════════════════════════════════════
# [0/10] KEYS VOM PBS-SERVER HOLEN
# PBS_HOST kommt aus pbs.conf (bereits gesourct).
# Beide Dateien werden lokal gecacht bei mehreren VMs desselben Datastores
# werden sie nur einmal geholt, nicht bei jeder VM erneut.
#
# PBS Encrypt-Keyfile: /root/Scripte/${DATASTORE}.keyfile
# → wird an qmrestore --keyfile übergeben
# → entschlüsselt die PBS-VM-Images beim Restore
#
# 7z-Passwort: /root/Scripte/password_7z.txt
# → Format: "tnp-Invest-GmbH: Passwort123"
# → wird an 7z -p übergeben zum Verschlüsseln des ZIP-Archivs
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [0/10] Keys vom PBS-Server holen ($PBS_HOST)..."
KEY_DIR="/opt/windmill-restore/keys"
mkdir -p "$KEY_DIR"
chmod 700 "$KEY_DIR"
# ── PBS Encrypt-Keyfile ───────────────────────────────────────────────────────
KEYFILE_LOCAL="${KEY_DIR}/${DATASTORE}.keyfile"
if [[ -f "$KEYFILE_LOCAL" && -s "$KEYFILE_LOCAL" ]]; then
echo " Keyfile bereits vorhanden: $KEYFILE_LOCAL"
else
echo " Hole Keyfile: root@${PBS_HOST}:/root/Scripte/${DATASTORE}.keyfile"
rsync -az \
"root@${PBS_HOST}:/root/Scripte/${DATASTORE}.keyfile" \
"$KEYFILE_LOCAL" \
2>&1
chmod 600 "$KEYFILE_LOCAL"
echo " Keyfile gespeichert ✓"
fi
[[ ! -f "$KEYFILE_LOCAL" || ! -s "$KEYFILE_LOCAL" ]] && {
echo "FEHLER: Keyfile fehlt oder leer für Datastore '$DATASTORE'" >&2
exit 1
}
# ── 7z-Passwort aus password_7z.txt ──────────────────────────────────────────
PW_FILE_LOCAL="${KEY_DIR}/password_7z.txt"
if [[ ! -f "$PW_FILE_LOCAL" || ! -s "$PW_FILE_LOCAL" ]]; then
echo " Hole password_7z.txt: root@${PBS_HOST}:/root/Scripte/password_7z.txt"
rsync -az \
"root@${PBS_HOST}:/root/Scripte/password_7z.txt" \
"$PW_FILE_LOCAL" \
2>&1
chmod 600 "$PW_FILE_LOCAL"
echo " password_7z.txt gespeichert ✓"
else
echo " password_7z.txt bereits vorhanden."
fi
# Passwort für diesen Datastore extrahieren
# Format der Datei: "tnp-Invest-GmbH: Passwort123"
ZIP_PASSWORD=$(grep -m1 "^${DATASTORE}:" "$PW_FILE_LOCAL" \
| awk -F': ' '{print $2}' | tr -d '[:space:]')
[[ -z "$ZIP_PASSWORD" ]] && {
echo "FEHLER: Kein 7z-Passwort für '$DATASTORE' in password_7z.txt" >&2
exit 1
}
echo " 7z-Passwort geladen ✓"
# ═════════════════════════════════════════════════════════════════════════════
# [1/10] SPACE-CHECK
# Nur Warnung kein Abbruch. PBS-Restore bricht selbst ab bei vollem Disk.
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [1/10] Prüfe freien Speicherplatz auf $RESTORE_MOUNT..."
mkdir -p "$ZIP_DIR"
FREE_KB=$(df "$RESTORE_MOUNT" 2>/dev/null | awk 'NR==2{print $4}' || echo "0")
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
echo " Frei: ${FREE_GB} GB"
[[ $FREE_GB -lt 30 ]] && echo " WARNUNG: Weniger als 30 GB frei!"
# ═════════════════════════════════════════════════════════════════════════════
# [2/10] VM-ID ERMITTELN
# Original aus Snapshot-Pfad (vm/100/... → 100)
# Restore-ID: erste freie ab 1000, prüft QEMU + LXC
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [2/10] Ermittle VM-IDs..."
VM_ID_ORIGINAL=$(echo "$SNAPSHOT_PATH" | grep -oP '\d+' | head -1 || echo "0")
echo " Original VM-ID: $VM_ID_ORIGINAL"
VM_ID_RESTORED=$(
{
pvesh get /nodes/localhost/qemu --output-format json 2>/dev/null || echo "[]"
pvesh get /nodes/localhost/lxc --output-format json 2>/dev/null || echo "[]"
} | python3 -c "
import json, sys
data = []
for line in sys.stdin:
line = line.strip()
if line:
try: data.extend(json.loads(line))
except: pass
existing = {int(v.get('vmid', 0)) for v in data}
for i in range(1000, 2000):
if i not in existing:
print(i); break
" 2>/dev/null || echo "1000"
)
echo " Restore VM-ID: $VM_ID_RESTORED"
# ═════════════════════════════════════════════════════════════════════════════
# [3/10] QMRESTORE VOM PBS-STORAGE
# Restore direkt vom registrierten PBS-PVE-Storage.
# --storage aus DB (restore_path), z.B. "local-lvm", "local"
# --keyfile lokal gecachtes Keyfile vom PBS-Server
# --unique 1 verhindert Konflikte mit bestehenden VM-Configs
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [3/10] qmrestore vom PBS-Storage..."
echo " Backup-Ref: $PVE_BACKUP_REF"
echo " Storage: $RESTORE_PATH"
echo " Keyfile: $KEYFILE_LOCAL"
echo " VM-ID: $VM_ID_RESTORED"
RESTORE_START_INNER=$(date +%s)
qmrestore "$PVE_BACKUP_REF" "$VM_ID_RESTORED" \
--storage "$RESTORE_PATH" \
--keyfile "$KEYFILE_LOCAL" \
--unique 1 \
2>&1
RESTORE_DURATION=$(( $(date +%s) - RESTORE_START_INNER ))
echo " Restore abgeschlossen in ${RESTORE_DURATION}s"
VM_IMAGE_DIR="/var/lib/vz/images/${VM_ID_RESTORED}"
ACTUAL_DISK_BYTES=$(du -sb "$VM_IMAGE_DIR" 2>/dev/null | awk '{print $1}' || echo "0")
echo " Image-Größe: $(( ACTUAL_DISK_BYTES / 1024 / 1024 / 1024 )) GB"
# ═════════════════════════════════════════════════════════════════════════════
# [4/10] VM VORBEREITEN
# unlock → stop → cdrom/ide0 entfernen → alle Netzwerkkarten (net0-net10)
# löschen → Agent aktivieren.
# Kein Netzwerk = kein versehentlicher Zugang zum Produktivnetz beim Test.
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [4/10] VM vorbereiten..."
qm unlock "$VM_ID_RESTORED" 2>/dev/null || true
qm stop "$VM_ID_RESTORED" 2>/dev/null || true
sleep 3
qm set "$VM_ID_RESTORED" -delete cdrom 2>/dev/null || true
qm set "$VM_ID_RESTORED" -delete ide0 2>/dev/null || true
for ((net=0; net<=10; net++)); do
qm set "$VM_ID_RESTORED" -delete "net${net}" 2>/dev/null || true
done
qm set "$VM_ID_RESTORED" --agent "enabled=1,type=virtio" 2>/dev/null || true
echo " VM vorbereitet (Netzwerkkarten entfernt, Agent aktiviert)."
# ═════════════════════════════════════════════════════════════════════════════
# [5/10] VM STARTEN & QM-AGENT PRÜFEN
# 120s in 10s-Schritten. Agent läuft über QEMU Guest Agent Channel
# kein Netzwerk nötig. Kein Agent nach 120s = KEIN Abbruch,
# qm_agent_ok=false wird im Webhook an Windmill gemeldet → DB-Eintrag.
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [5/10] Starte VM & warte auf qm-Agent (max. 120s)..."
qm start "$VM_ID_RESTORED" 2>/dev/null || true
AGENT_WAIT=0
AGENT_MAX=120
AGENT_INTERVAL=10
QM_AGENT_OK="false"
while [[ $AGENT_WAIT -lt $AGENT_MAX ]]; do
sleep $AGENT_INTERVAL
AGENT_WAIT=$(( AGENT_WAIT + AGENT_INTERVAL ))
echo -n " [${AGENT_WAIT}s/${AGENT_MAX}s] qm-Agent... "
if qm agent "$VM_ID_RESTORED" ping 2>/dev/null | grep -qi "pong\|ping"; then
QM_AGENT_OK="true"
echo "ONLINE ✓"
hostname_info=$(qm agent "$VM_ID_RESTORED" get-host-name 2>/dev/null \
| grep host-name | tr -d '"' || true)
fs_info=$(qm agent "$VM_ID_RESTORED" get-fsinfo 2>/dev/null \
| grep mountpoint | tr -d '"' || true)
echo " Hostname: ${hostname_info:-unbekannt}"
echo " Mountpoints: ${fs_info:-unbekannt}"
break
else
echo "nicht erreichbar."
fi
done
if [[ "$QM_AGENT_OK" == "false" ]]; then
echo " qm-Agent nach ${AGENT_MAX}s nicht erreichbar."
echo " → qm_agent_ok=false in DB, Restore läuft weiter."
fi
# ═════════════════════════════════════════════════════════════════════════════
# [6/10] VM STOPPEN
# Sauberes Shutdown, nach 30s force-stop falls VM noch läuft.
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [6/10] Stoppe VM..."
qm shutdown "$VM_ID_RESTORED" 2>/dev/null || true
sleep 30
qm stop "$VM_ID_RESTORED" --skiplock 1 2>/dev/null || true
echo " VM gestoppt."
sleep 5
# ═════════════════════════════════════════════════════════════════════════════
# [7/10] 7Z-ARCHIV ERSTELLEN
# VM-Images aus /var/lib/vz/images/$VM_ID_RESTORED/* zippen.
# VM-Name aus qemu-server.conf lesen → ZIP bekommt echten Namen.
# Passwort kommt aus password_7z.txt (Schritt [0], pro Datastore).
# -mx=1 → schnellste Kompression (Images meist schon komprimiert)
# -mhe=on → Header-Verschlüsselung (Dateinamen ohne Key unsichtbar)
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [7/10] Erstelle verschlüsseltes 7z-Archiv..."
VM_CONF="${VM_IMAGE_DIR}/qemu-server.conf"
VM_NAME=$(grep -m1 "^name:" "$VM_CONF" 2>/dev/null \
| awk -F': ' '{print $2}' | tr -d '[:space:]' \
|| echo "$SAFE_CLIENT")
echo " VM-Name: $VM_NAME"
ZIP_FILE="${ZIP_DIR}/${VM_NAME}_$(date +%Y%m%d).7z"
ZIP_START=$(date +%s)
7z a -t7z \
-mmt=4 \
-mx=1 \
-md=16M \
-p"${ZIP_PASSWORD}" \
-mhe=on \
"$ZIP_FILE" \
"${VM_IMAGE_DIR}/"* \
2>&1 | tail -5
ZIP_DURATION=$(( $(date +%s) - ZIP_START ))
ZIP_SIZE_BYTES=$(stat -c%s "$ZIP_FILE" 2>/dev/null || echo "0")
echo " ZIP: $(( ZIP_SIZE_BYTES / 1024 / 1024 )) MB in ${ZIP_DURATION}s"
# ═════════════════════════════════════════════════════════════════════════════
# [8/10] RSYNC ZUM BACKUP-SERVER
# Zielverzeichnis aus DB (rsync_target) = Firmen-spezifischer Pfad.
# 3 Versuche mit 60s Pause + Größenvergleich lokal vs. remote.
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [8/10] Rsync → ${BACKUP_SERVER_HOST}:${RSYNC_TARGET}..."
MAX_RETRIES=3
rsync_transfer() {
rsync -avz --progress --timeout=300 \
"$ZIP_FILE" \
"${BACKUP_SERVER_HOST}:${RSYNC_TARGET}/" \
2>&1
}
ssh "$BACKUP_SERVER_HOST" "mkdir -p '${RSYNC_TARGET}'" 2>/dev/null || true
while [[ $RSYNC_RETRIES -lt $MAX_RETRIES ]]; do
if rsync_transfer; then
RSYNC_OK="true"
RSYNC_SIZE_BYTES=$ZIP_SIZE_BYTES
echo " Rsync OK: $(( RSYNC_SIZE_BYTES / 1024 / 1024 )) MB"
break
else
RSYNC_RETRIES=$(( RSYNC_RETRIES + 1 ))
if [[ $RSYNC_RETRIES -lt $MAX_RETRIES ]]; then
echo " Fehlgeschlagen ($RSYNC_RETRIES/$MAX_RETRIES). Warte 60s..."
sleep 60
else
RSYNC_OK="false"
STATUS="failed"
ERROR_MSG="Rsync fehlgeschlagen nach ${RSYNC_RETRIES} Versuchen"
echo " FEHLER: $ERROR_MSG"
fi
fi
done
if [[ "$RSYNC_OK" == "true" ]]; then
REMOTE_SIZE=$(ssh "$BACKUP_SERVER_HOST" \
"stat -c%s '${RSYNC_TARGET}/$(basename "$ZIP_FILE")'" \
2>/dev/null || echo "0")
if [[ "$REMOTE_SIZE" != "$ZIP_SIZE_BYTES" ]]; then
echo " WARNUNG: Remote ${REMOTE_SIZE}B != lokal ${ZIP_SIZE_BYTES}B"
RSYNC_OK="false"
STATUS="failed"
ERROR_MSG="Größenabweichung: lokal=${ZIP_SIZE_BYTES} remote=${REMOTE_SIZE}"
else
echo " Größenprüfung OK: ${REMOTE_SIZE} Bytes."
fi
fi
# ═════════════════════════════════════════════════════════════════════════════
# [9/10] AUFRÄUMEN
# VM destroy inkl. Disks (/var/lib/vz/images/$VM_ID wird durch destroy entfernt).
# Lokale ZIP löschen.
# Keys bleiben gecacht für nächste VM desselben Datastores/Kunden.
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "==> [9/10] Aufräumen..."
qm destroy "$VM_ID_RESTORED" \
--destroy-unreferenced-disks 1 \
--purge 1 \
2>/dev/null || echo " VM $VM_ID_RESTORED nicht mehr vorhanden."
rm -f "$ZIP_FILE"
echo " VM ${VM_ID_RESTORED} entfernt, ZIP gelöscht."
echo " Keys gecacht in $KEY_DIR (für nächste VM desselben Kunden)."
# ── Zusammenfassung & Webhook ─────────────────────────────────────────────────
TOTAL=$(( $(date +%s) - RESTORE_START ))
echo ""
echo "============================================================"
echo " Status: $STATUS"
echo " Gesamtdauer: ${TOTAL}s"
echo " VM-Name: ${VM_NAME:-$SAFE_CLIENT}"
echo " Datastore: $DATASTORE"
echo " qm-Agent: $QM_AGENT_OK"
echo " Rsync: $RSYNC_OK (Versuche: $RSYNC_RETRIES)"
echo " ZIP: $(( ZIP_SIZE_BYTES / 1024 / 1024 )) MB"
[[ -n "$ERROR_MSG" ]] && echo " Fehler: $ERROR_MSG"
echo "============================================================"
trap - ERR
send_webhook "$STATUS" "$ERROR_MSG"