300 lines
11 KiB
Bash
300 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
# libvirt-backup.sh
|
|
# Offline-Backup persistenter libvirt/QEMU-VMs für eine Host-Neuinstallation.
|
|
set -Eeuo pipefail
|
|
IFS=$'\n\t'
|
|
umask 077
|
|
|
|
VERSION="1.2"
|
|
URI="${LIBVIRT_URI:-qemu:///system}"
|
|
BACKUP_ROOT="${1:-}"
|
|
RSYNC_OPTS=(-aHAX --numeric-ids --sparse --info=progress2)
|
|
ALLOW_RUNNING=0
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Aufruf:
|
|
sudo ./libvirt-backup.sh /pfad/zum/backup
|
|
sudo LIBVIRT_URI=qemu:///system ./libvirt-backup.sh /pfad/zum/backup
|
|
|
|
Umgebungsvariablen:
|
|
LIBVIRT_URI libvirt-Verbindung, Standard: qemu:///system
|
|
ALLOW_RUNNING=1 Unsicher: Backup trotz laufender VMs erlauben
|
|
|
|
Das Skript sichert:
|
|
- Domain-XMLs
|
|
- libvirt-Netzwerk-, Storage-Pool- und Interface-XMLs
|
|
- alle file-basierten Disk-/CDROM-/Floppy-Quellen aus den Domain-XMLs
|
|
- UEFI-NVRAM-Dateien
|
|
- dateibasierte TPM-Backend-Zustände
|
|
- qemu-img-Informationen und Prüfsummen
|
|
- PAW-Netzwerk aus 50-paw-install.yml inklusive br-lan- und Port-MACs
|
|
|
|
Blockgeräte wie LVM-LVs werden nur dokumentiert und NICHT kopiert.
|
|
EOF
|
|
}
|
|
|
|
die() { echo "FEHLER: $*" >&2; exit 1; }
|
|
warn() { echo "WARNUNG: $*" >&2; }
|
|
log() { printf '\n==> %s\n' "$*"; }
|
|
|
|
[[ "${ALLOW_RUNNING:-0}" == "1" ]] && ALLOW_RUNNING=1
|
|
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
|
[[ -n "$BACKUP_ROOT" ]] || { usage; exit 2; }
|
|
|
|
for cmd in virsh rsync sha256sum python3 qemu-img find sort awk sed findmnt; do
|
|
command -v "$cmd" >/dev/null || die "Benötigtes Programm fehlt: $cmd"
|
|
done
|
|
|
|
BACKUP_ROOT="$(readlink -m "$BACKUP_ROOT")"
|
|
[[ "$BACKUP_ROOT" != "/" ]] || die "Backup-Ziel darf nicht / sein."
|
|
mkdir -p "$BACKUP_ROOT"
|
|
[[ -z "$(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -print -quit)" ]] \
|
|
|| die "Backup-Ziel ist nicht leer: $BACKUP_ROOT"
|
|
|
|
# Früh erkennen, ob das Ziel beschreibbar ist und der Mount read-only ist.
|
|
mount_target="$(findmnt -no TARGET -T "$BACKUP_ROOT" 2>/dev/null || true)"
|
|
mount_opts="$(findmnt -no OPTIONS -T "$BACKUP_ROOT" 2>/dev/null || true)"
|
|
[[ ",$mount_opts," != *,ro,* ]] \
|
|
|| die "Backup-Dateisystem ist nur lesbar eingehängt: ${mount_target:-unbekannt}"
|
|
|
|
probe="$BACKUP_ROOT/.libvirt-backup-write-test.$$"
|
|
printf 'libvirt backup write test\n' > "$probe" \
|
|
|| die "Backup-Ziel ist nicht beschreibbar: $BACKUP_ROOT"
|
|
sync "$probe" 2>/dev/null || sync
|
|
grep -qx 'libvirt backup write test' "$probe" \
|
|
|| die "Schreib-/Lesetest auf dem Backup-Ziel fehlgeschlagen."
|
|
rm -f "$probe"
|
|
|
|
mkdir -p "$BACKUP_ROOT"/{domains,networks,pools,interfaces,files,metadata,logs}
|
|
exec > >(tee -a "$BACKUP_ROOT/logs/backup.log") 2>&1
|
|
|
|
log "Prüfe libvirt-Verbindung $URI"
|
|
virsh -c "$URI" uri >/dev/null
|
|
|
|
mapfile -t VMS < <(virsh -c "$URI" list --all --name | sed '/^[[:space:]]*$/d')
|
|
((${#VMS[@]} > 0)) || warn "Keine persistenten Domains gefunden."
|
|
|
|
if (( ! ALLOW_RUNNING )); then
|
|
running=()
|
|
for vm in "${VMS[@]}"; do
|
|
state="$(virsh -c "$URI" domstate "$vm" 2>/dev/null | head -n1 | tr '[:upper:]' '[:lower:]')"
|
|
case "$state" in
|
|
"shut off"|"abgeschaltet"|"ausgeschaltet") ;;
|
|
*) running+=("$vm ($state)") ;;
|
|
esac
|
|
done
|
|
if ((${#running[@]})); then
|
|
printf 'Nicht ausgeschaltete VMs:\n' >&2
|
|
printf ' - %s\n' "${running[@]}" >&2
|
|
die "Alle VMs sauber herunterfahren oder ausdrücklich ALLOW_RUNNING=1 setzen."
|
|
fi
|
|
fi
|
|
|
|
log "Schreibe Host- und Paketinformationen"
|
|
{
|
|
echo "backup_format_version=$VERSION"
|
|
echo "created_utc=$(date -u +%FT%TZ)"
|
|
echo "hostname=$(hostname -f 2>/dev/null || hostname)"
|
|
echo "libvirt_uri=$URI"
|
|
echo "kernel=$(uname -srmo)"
|
|
echo "virsh_version=$(virsh --version)"
|
|
echo "qemu_img_version=$(qemu-img --version | head -n1)"
|
|
} > "$BACKUP_ROOT/metadata/backup-info.txt"
|
|
|
|
cp -a /etc/os-release "$BACKUP_ROOT/metadata/os-release" 2>/dev/null || true
|
|
virsh -c "$URI" capabilities > "$BACKUP_ROOT/metadata/capabilities.xml" 2>/dev/null || true
|
|
virsh -c "$URI" nodeinfo > "$BACKUP_ROOT/metadata/nodeinfo.txt" 2>/dev/null || true
|
|
|
|
log "Exportiere Domain-Definitionen"
|
|
for vm in "${VMS[@]}"; do
|
|
safe="$(printf '%s' "$vm" | sed 's#[/[:cntrl:]]#_#g')"
|
|
virsh -c "$URI" dumpxml --inactive --security-info "$vm" \
|
|
> "$BACKUP_ROOT/domains/$safe.xml" \
|
|
|| virsh -c "$URI" dumpxml --inactive "$vm" > "$BACKUP_ROOT/domains/$safe.xml"
|
|
printf '%s\t%s\n' "$safe" "$vm" >> "$BACKUP_ROOT/metadata/domains.tsv"
|
|
virsh -c "$URI" domblklist "$vm" --details \
|
|
> "$BACKUP_ROOT/domains/$safe.domblklist.txt" || true
|
|
virsh -c "$URI" domiflist "$vm" \
|
|
> "$BACKUP_ROOT/domains/$safe.domiflist.txt" || true
|
|
virsh -c "$URI" snapshot-list "$vm" --tree \
|
|
> "$BACKUP_ROOT/domains/$safe.snapshots.txt" || true
|
|
done
|
|
|
|
export_objects() {
|
|
local kind="$1" list_cmd="$2" dump_cmd="$3" outdir="$4" manifest="$5"
|
|
local name safe
|
|
while IFS= read -r name; do
|
|
[[ -n "$name" ]] || continue
|
|
safe="$(printf '%s' "$name" | sed 's#[/[:cntrl:]]#_#g')"
|
|
if virsh -c "$URI" "$dump_cmd" "$name" > "$BACKUP_ROOT/$outdir/$safe.xml"; then
|
|
printf '%s\t%s\n' "$safe" "$name" >> "$BACKUP_ROOT/metadata/$manifest"
|
|
else
|
|
warn "$kind konnte nicht exportiert werden: $name"
|
|
rm -f "$BACKUP_ROOT/$outdir/$safe.xml"
|
|
fi
|
|
done < <(
|
|
virsh -c "$URI" "$list_cmd" --all --name 2>/dev/null |
|
|
sed 's/^[[:space:]]*//; s/[[:space:]]*$//; /^[[:space:]]*$/d'
|
|
)
|
|
}
|
|
|
|
log "Exportiere Netzwerke, Storage-Pools und Host-Interfaces"
|
|
export_objects "Netzwerk" net-list net-dumpxml networks networks.tsv
|
|
export_objects "Storage-Pool" pool-list pool-dumpxml pools pools.tsv
|
|
export_objects "Interface" iface-list iface-dumpxml interfaces interfaces.tsv
|
|
|
|
log "Ermittle referenzierte Dateien und Blockgeräte"
|
|
python3 - "$BACKUP_ROOT" <<'PY'
|
|
import os, sys, glob, csv
|
|
import xml.etree.ElementTree as ET
|
|
|
|
root = sys.argv[1]
|
|
file_paths = set()
|
|
block_paths = set()
|
|
rows = []
|
|
|
|
for xml_path in glob.glob(os.path.join(root, "domains", "*.xml")):
|
|
vm = os.path.basename(xml_path)[:-4]
|
|
tree = ET.parse(xml_path)
|
|
dom = tree.getroot()
|
|
|
|
for disk in dom.findall("./devices/disk"):
|
|
dtype = disk.get("type", "")
|
|
device = disk.get("device", "")
|
|
src = disk.find("source")
|
|
if src is None:
|
|
continue
|
|
if dtype == "file" and src.get("file"):
|
|
p = src.get("file")
|
|
file_paths.add(p)
|
|
rows.append((vm, "disk-file", device, p))
|
|
elif dtype == "block" and src.get("dev"):
|
|
p = src.get("dev")
|
|
block_paths.add(p)
|
|
rows.append((vm, "disk-block", device, p))
|
|
elif dtype == "dir" and src.get("dir"):
|
|
p = src.get("dir")
|
|
rows.append((vm, "disk-dir", device, p))
|
|
elif dtype == "volume":
|
|
rows.append((vm, "disk-volume", device,
|
|
f"{src.get('pool','')}/{src.get('volume','')}"))
|
|
elif dtype == "network":
|
|
rows.append((vm, "disk-network", device,
|
|
src.get("name") or src.get("protocol") or ""))
|
|
|
|
nvram = dom.find("./os/nvram")
|
|
if nvram is not None and nvram.text and nvram.text.strip():
|
|
p = nvram.text.strip()
|
|
file_paths.add(p)
|
|
rows.append((vm, "nvram", "", p))
|
|
|
|
for backend in dom.findall("./devices/tpm/backend"):
|
|
for source in backend.findall("source"):
|
|
p = source.get("path") or source.get("file")
|
|
if p:
|
|
file_paths.add(p)
|
|
rows.append((vm, "tpm-file", "", p))
|
|
|
|
with open(os.path.join(root, "metadata", "references.tsv"), "w",
|
|
newline="", encoding="utf-8") as f:
|
|
w = csv.writer(f, delimiter="\t", lineterminator="\n")
|
|
w.writerow(("domain_xml", "kind", "device", "source"))
|
|
w.writerows(sorted(rows))
|
|
|
|
with open(os.path.join(root, "metadata", "file-sources.txt"), "w",
|
|
encoding="utf-8") as f:
|
|
for p in sorted(file_paths):
|
|
f.write(p + "\n")
|
|
|
|
with open(os.path.join(root, "metadata", "block-sources.txt"), "w",
|
|
encoding="utf-8") as f:
|
|
for p in sorted(block_paths):
|
|
f.write(p + "\n")
|
|
PY
|
|
|
|
: > "$BACKUP_ROOT/metadata/file-map.tsv"
|
|
: > "$BACKUP_ROOT/metadata/missing-files.txt"
|
|
|
|
copy_source() {
|
|
local src="$1" rel dst
|
|
[[ -n "$src" ]] || return 0
|
|
|
|
if [[ ! -e "$src" ]]; then
|
|
warn "Referenzierte Datei fehlt: $src"
|
|
printf '%s\n' "$src" >> "$BACKUP_ROOT/metadata/missing-files.txt"
|
|
return 0
|
|
fi
|
|
|
|
# Originalpfad ohne führenden Slash innerhalb files/rootfs/ ablegen.
|
|
rel="${src#/}"
|
|
dst="$BACKUP_ROOT/files/rootfs/$rel"
|
|
mkdir -p "$(dirname "$dst")"
|
|
|
|
if [[ -d "$src" ]]; then
|
|
mkdir -p "$dst"
|
|
rsync "${RSYNC_OPTS[@]}" "$src/" "$dst/"
|
|
printf '%s\t%s\t%s\n' "directory" "$src" "files/rootfs/$rel" \
|
|
>> "$BACKUP_ROOT/metadata/file-map.tsv"
|
|
else
|
|
rsync "${RSYNC_OPTS[@]}" "$src" "$dst"
|
|
printf '%s\t%s\t%s\n' "file" "$src" "files/rootfs/$rel" \
|
|
>> "$BACKUP_ROOT/metadata/file-map.tsv"
|
|
|
|
case "$src" in
|
|
*.qcow|*.qcow2|*.qed|*.vmdk|*.vdi|*.raw|*.img)
|
|
qemu-img info --backing-chain --output=json "$src" \
|
|
> "$dst.qemu-img-info.json" 2>/dev/null || true
|
|
;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
log "Kopiere referenzierte Dateien"
|
|
while IFS= read -r src; do
|
|
copy_source "$src"
|
|
done < "$BACKUP_ROOT/metadata/file-sources.txt"
|
|
|
|
if [[ -s "$BACKUP_ROOT/metadata/block-sources.txt" ]]; then
|
|
warn "Blockgeräte wurden gefunden und nicht kopiert:"
|
|
sed 's/^/ - /' "$BACKUP_ROOT/metadata/block-sources.txt"
|
|
warn "Diese LVM-/Block-Datenträger separat sichern, z. B. per LVM-Snapshot, dd oder Storage-Replikation."
|
|
fi
|
|
|
|
log "Sichere PAW-Netzwerk und br-lan-MAC"
|
|
if [[ -x "$SCRIPT_DIR/paw-network-backup.sh" ]]; then
|
|
"$SCRIPT_DIR/paw-network-backup.sh" "$BACKUP_ROOT/network"
|
|
else
|
|
warn "paw-network-backup.sh fehlt neben dem Backup-Skript; Netzwerk wurde nicht gesichert."
|
|
fi
|
|
|
|
log "Prüfe Backup-Datenträger vor der Prüfsummenphase"
|
|
mount_opts="$(findmnt -no OPTIONS -T "$BACKUP_ROOT" 2>/dev/null || true)"
|
|
[[ ",$mount_opts," != *,ro,* ]] \
|
|
|| die "Backup-Dateisystem wurde während des Backups nur-lesbar. Backup ist unvollständig."
|
|
|
|
probe="$BACKUP_ROOT/.libvirt-backup-final-test.$$"
|
|
printf 'final write test\n' > "$probe" \
|
|
|| die "Abschließender Schreibtest fehlgeschlagen. Backup ist unvollständig."
|
|
sync "$probe" 2>/dev/null || sync
|
|
grep -qx 'final write test' "$probe" \
|
|
|| die "Abschließender Lesetest fehlgeschlagen. Backup ist unvollständig."
|
|
rm -f "$probe"
|
|
|
|
log "Erzeuge Dateiliste und SHA-256-Prüfsummen"
|
|
(
|
|
cd "$BACKUP_ROOT"
|
|
find . -type f \
|
|
! -path './metadata/SHA256SUMS' \
|
|
! -path './logs/backup.log' \
|
|
-print0 | sort -z | xargs -0 sha256sum > metadata/SHA256SUMS
|
|
)
|
|
|
|
sync
|
|
log "Backup abgeschlossen: $BACKUP_ROOT"
|
|
echo "Vor der Neuinstallation prüfen:"
|
|
echo " cd '$BACKUP_ROOT' && sha256sum -c metadata/SHA256SUMS"
|
|
[[ ! -s "$BACKUP_ROOT/metadata/block-sources.txt" ]] \
|
|
|| echo "ACHTUNG: Blockgeräte laut metadata/block-sources.txt separat sichern."
|