From fd1592c3b1ba7077293654d020699cc8ec9dc96b Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 7 Jul 2024 19:22:37 -0400 Subject: [PATCH] zfs snapshots?! awe yis! now I just need a straight forward way to `virt-restore` ... modified: .gitignore modified: virt-back --- .gitignore | 7 ++++ virt-back | 108 ++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 98 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 3b76227..ee77078 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ venv virt_back.egg-info + +*.tar +*.tar.gz + +*.tar.* +*.tar.gz.* +*.gz.* diff --git a/virt-back b/virt-back index 761133f..3aa71b4 100755 --- a/virt-back +++ b/virt-back @@ -22,7 +22,7 @@ Use the Domfetcher class to aquire lists of dom objects.""" import libvirt import tarfile import syslog -from re import findall +import re from time import sleep from datetime import date from sys import exit @@ -31,6 +31,8 @@ from os import path from os import remove from shutil import move, copy2 +import subprocess + try: from operator import methodcaller except ImportError: @@ -118,10 +120,11 @@ def backup(doms): xml = dom.XMLDesc(0) xmlfile = path.join(options.backpath, dom.name() + ".xml") - f = open(xmlfile, "w") - f.write(xml) - f.close() - disklist = findall("\n", xml) + with open(xmlfile, "w") as f: + f.write(xml) + + # Updated regular expression to match file= and dev= within elements + disklist = re.findall(r"", xml, re.DOTALL) logit("backup", "invoking backup for " + dom.name()) @@ -132,14 +135,55 @@ def backup(doms): ) continue - disk_file = disk_source.split("/")[-1] - disk_dest = path.join(options.backpath, disk_file) + if is_zfs_dataset(disk_source): + # Handle ZFS dataset + zfs_dataset = disk_source[len('/dev/zvol/'):] if disk_source.startswith('/dev/zvol/') else disk_source + zfs_snapshot_base = f"{zfs_dataset}@backup-{TODAY}" + zfs_snapshot = zfs_snapshot_base + suffix = 1 - logit( - "backup", - "copying %s to %s for %s" % (disk_source, disk_dest, dom.name()), - ) - copy2(disk_source, disk_dest) + # Increment snapshot name until an available name is found + while subprocess.run(["zfs", "list", zfs_snapshot], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0: + zfs_snapshot = f"{zfs_snapshot_base}-{suffix}" + suffix += 1 + + zfs_file = path.join(options.backpath, f"{dom.name()}-{TODAY}.zfs") + if not options.nogzip: + zfs_file += ".gz" + + # Create ZFS snapshot + logit("backup", f"creating ZFS snapshot {zfs_snapshot} for {dom.name()}") + try: + subprocess.run(["zfs", "snapshot", zfs_snapshot], check=True) + except subprocess.CalledProcessError as e: + logit("error", f"Failed to create ZFS snapshot {zfs_snapshot}: {e}") + continue + + # Send ZFS snapshot to file with optional compression + logit("backup", f"sending ZFS snapshot {zfs_snapshot} to {zfs_file} for {dom.name()}") + try: + with open(zfs_file, "wb") as f: + if options.nogzip: + subprocess.run(["zfs", "send", zfs_snapshot], stdout=f, check=True) + else: + send_proc = subprocess.Popen(["zfs", "send", zfs_snapshot], stdout=subprocess.PIPE) + gzip_proc = subprocess.Popen(["gzip"], stdin=send_proc.stdout, stdout=f) + send_proc.stdout.close() # Allow send_proc to receive a SIGPIPE if gzip_proc exits + gzip_proc.communicate() + except subprocess.CalledProcessError as e: + logit("error", f"Failed to send ZFS snapshot {zfs_snapshot}: {e}") + continue + else: + # Handle QCOW2 or other file-based disk + logit("backup", f"{disk_source} is not a ZFS dataset") + disk_file = disk_source.split("/")[-1] + disk_dest = path.join(options.backpath, disk_file) + + logit( + "backup", + "copying %s to %s for %s" % (disk_source, disk_dest, dom.name()), + ) + copy2(disk_source, disk_dest) if recreate: # if true, start guest after backup create([dom]) # start dom @@ -169,17 +213,47 @@ def backup(doms): if disk_source.endswith(".iso"): continue - disk_file = disk_source.split("/")[-1] - disk_dest = path.join(options.backpath, disk_file) - logit("backup", "archiving %s for %s" % (disk_dest, dom.name())) - tar.add(disk_dest) # add img to tar - remove(disk_dest) # cleanup tmp files + if is_zfs_dataset(disk_source): + zfs_file = path.join(options.backpath, f"{dom.name()}-{TODAY}.zfs") + if not options.nogzip: + zfs_file += ".gz" + if path.isfile(zfs_file): + logit("backup", "archiving %s for %s" % (zfs_file, dom.name())) + tar.add(zfs_file) # add zfs snapshot to tar + remove(zfs_file) # cleanup tmp files + else: + logit("error", f"ZFS snapshot file {zfs_file} not found for {dom.name()}") + else: + disk_file = disk_source.split("/")[-1] + disk_dest = path.join(options.backpath, disk_file) + logit("backup", "archiving %s for %s" % (disk_dest, dom.name())) + tar.add(disk_dest) # add img to tar + remove(disk_dest) # cleanup tmp files tar.close() logit("backup", "finished backup for " + dom.name()) +def is_zfs_dataset(disk_source): + """Check if the disk source is a ZFS dataset""" + logit("backup", f"checking if {disk_source} is a ZFS dataset") + try: + # Extract the ZFS dataset name from the device path + if disk_source.startswith('/dev/zvol/'): + zfs_dataset = disk_source[len('/dev/zvol/'):] + else: + zfs_dataset = disk_source + + result = subprocess.run(["zfs", "list", zfs_dataset], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + is_zfs = result.returncode == 0 + logit("backup", f"{zfs_dataset} is {'a' if is_zfs else 'not a'} ZFS dataset") + return is_zfs + except Exception as e: + logit("error", f"Error checking ZFS dataset: {e}") + return False + + def shutdown(doms, wait=180): """Accept a list of dom objects, attempt to shutdown the active ones""" # get all running guests from list and invoke shutdown