From e1b15191098784e92b89acc719ccc0f27c3e155b Mon Sep 17 00:00:00 2001 From: Milkey Mouse Date: Wed, 29 Nov 2017 13:45:27 -0800 Subject: [PATCH] Refactor into package --- MANIFEST.in | 1 + backup-vm.py | 606 ------------------------------------------ backup_vm/__init__.py | 0 backup_vm/backup.py | 66 +++++ backup_vm/builder.py | 47 ++++ backup_vm/multi.py | 236 ++++++++++++++++ backup_vm/parse.py | 272 +++++++++++++++++++ backup_vm/snapshot.py | 171 ++++++++++++ setup.py | 41 +++ 9 files changed, 834 insertions(+), 606 deletions(-) create mode 100644 MANIFEST.in delete mode 100755 backup-vm.py create mode 100755 backup_vm/__init__.py create mode 100755 backup_vm/backup.py create mode 100644 backup_vm/builder.py create mode 100644 backup_vm/multi.py create mode 100755 backup_vm/parse.py create mode 100644 backup_vm/snapshot.py create mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c4bf456 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.rst \ No newline at end of file diff --git a/backup-vm.py b/backup-vm.py deleted file mode 100755 index d9050ab..0000000 --- a/backup-vm.py +++ /dev/null @@ -1,606 +0,0 @@ -#!/usr/bin/env python3 - -from tempfile import TemporaryDirectory -from xml.etree import ElementTree -from base64 import b64encode -from textwrap import dedent -from getpass import getpass -from pty import openpty -from time import sleep -import subprocess -import selectors -import termios -import fcntl -import json -import sys -import os -import re -import libvirt - - -class Location: - # https://github.com/borgbackup/borg/blob/5e2de8b/src/borg/helpers/parseformat.py#L277 - proto = user = _host = port = path = archive = None - optional_user_re = r"""(?:(?P[^@:/]+)@)?""" - scp_path_re = r"""(?!(:|//|ssh://))(?P([^:]|(:(?!:)))+)""" - file_path_re = r"""(?P(([^/]*)/([^:]|(:(?!:)))+))""" - abs_path_re = r"""(?P(/([^:]|(:(?!:)))+))""" - optional_archive_re = r"""(?:::(?P[^/]+))?$""" - ssh_re = re.compile(r"""(?Pssh)://""" + optional_user_re + - r"""(?P([^:/]+|\[[0-9a-fA-F:.]+\]))(?::(?P\d+))?""" + abs_path_re + optional_archive_re, re.VERBOSE) - file_re = re.compile(r"""(?Pfile)://""" + file_path_re + optional_archive_re, re.VERBOSE) - scp_re = re.compile(r"""(""" + optional_user_re + - r"""(?P([^:/]+|\[[0-9a-fA-F:.]+\])):)?""" + scp_path_re + optional_archive_re, re.VERBOSE) - env_re = re.compile(r"""(?:::$)|""" + optional_archive_re, re.VERBOSE) - - def __init__(self, text=""): - self.orig = text - self.extra_args = [] - if not self.parse(self.orig): - raise ValueError("Location: parse failed: %s" % self.orig) - - def parse(self, text): - # text = replace_placeholders(text) - valid = self._parse(text) - if valid: - return True - m = self.env_re.match(text) - if not m: - return False - repo = os.environ.get("BORG_REPO") - if repo is None: - return False - valid = self._parse(repo) - if not valid: - return False - self.archive = m.group("archive") - return True - - def _parse(self, text): - def normpath_special(p): - # avoid that normpath strips away our relative path hack and even makes p absolute - relative = p.startswith("/./") - p = os.path.normpath(p) - return ("/." + p) if relative else p - - m = self.ssh_re.match(text) - if m: - self.proto = m.group("proto") - self.user = m.group("user") - self._host = m.group("host") - self.port = m.group("port") and int(m.group("port")) or None - self.path = normpath_special(m.group("path")) - self.archive = m.group("archive") - return True - m = self.file_re.match(text) - if m: - self.proto = m.group("proto") - self.path = normpath_special(m.group("path")) - self.archive = m.group("archive") - return True - m = self.scp_re.match(text) - if m: - self.user = m.group("user") - self._host = m.group("host") - self.path = normpath_special(m.group("path")) - self.archive = m.group("archive") - self.proto = self._host and "ssh" or "file" - return True - return False - - @classmethod - def try_location(cls, text): - try: - return Location(text) - except ValueError: - return None - return loc.path is not None and loc.archive is not None and (loc.proto == "file" or loc._host is not None) - - def canonicalize_path(self, cwd=None): - if self.proto == "ssh" or os.path.isabs(self.path): - return - if cwd is None: - cwd = os.getcwd() - self.path = os.path.normpath(os.path.join(cwd, self.path)) - - def __str__(self): - # http://borgbackup.readthedocs.io/en/stable/usage/general.html#repository-urls - # re-creation needs to be done dynamically instead of returning self.orig because - # we change values to make paths absolute, etc. - if self.proto == "file": - repo = self.path - elif self.proto == "ssh": - _user = self.user + "@" if self.user is not None else "" - if self.port is not None: - # URI form needs "./" prepended to relative dirs - _path = os.path.join(".", self.path) if not os.path.isabs(self.path) else self.path - repo = "ssh://{}{}:{}/{}".format(_user, self._host, self.port, _path) - else: - repo = "{}{}:{}".format(_user, self._host, self.path) - if self.archive is not None: - return repo + "::" + self.archive - else: - return repo - - def __hash__(self): - return hash(str(self)) - - -class ArgumentParser: - - def __init__(self, args): - self.prog = os.path.basename(args[0]) if len(args) > 0 else "backup-vm" - self.domain = None - self.memory = False - self.progress = sys.stdout.isatty() - self.disks = set() - self.archives = [] - self.parse_args(args) - - def parse_args(self, args): - if len(args) == 1: - self.help() - sys.exit(2) - parsing_borg_args = False - for arg in args[1:]: - if arg in {"-h", "--help"}: - self.help() - sys.exit() - l = Location.try_location(arg) - if [l, l.path, l.archive].count(None) == 0 and (l.proto == "file" or l._host is not None): - parsing_borg_args = False - l.canonicalize_path() - self.archives.append(l) - elif arg == "--borg-args": - if len(self.archives) == 0: - self.error("--borg-args must come after an archive path") - else: - parsing_borg_args = True - elif parsing_borg_args: - self.archives[-1].extra_args.append(arg) - elif arg in {"-m", "--memory"}: - self.memory = True - elif arg in {"-p", "--progress"}: - self.progress = True - elif arg.startswith("-"): - # handle multiple flags in one arg (e.g. -hmp) - for c in arg[1:]: - if c == "h": - self.help() - sys.exit() - elif c == "m": - self.memory = True - elif c == "p": - self.progress = True - elif self.domain is None: - self.domain = arg - else: - self.disks.add(arg) - if self.domain is None or len(self.archives) == 0: - self.error("the following arguments are required: domain, archive") - sys.exit(2) - - def error(self, msg): - self.help() - print(self.prog + ": error: " + msg, file=sys.stderr) - sys.exit(2) - - def help(self, short=False): - print(dedent(""" - usage: {} [-hmp] domain [disk [disk ...]] archive - [--borg-args ...] [archive [--borg-args ...] ...] - """.format(self.prog).lstrip("\n"))) - if not short: - print(dedent(""" - Back up a libvirt-based VM using borg. - - positional arguments: - domain libvirt domain to back up - disk a domain block device to back up (default: all disks) - archive a borg archive path (same format as borg create) - - optional arguments: - -h, --help show this help message and exit - -m, --memory (experimental) snapshot the memory state as well - -p, --progress force progress display even if stdout isn't a tty - --borg-args ... extra arguments passed straight to borg - """).strip("\n")) - - -class Disk: - - def __init__(self, xml): - self.xml = xml - self.format = xml.find("driver").attrib["type"] - self.target = xml.find("target").get("dev") - # sometimes there won't be a source entry, e.g. a cd drive without a virtual cd in it - self.type, self.path = next(iter(xml.find("source").attrib.items()), (None, None)) - self.snapshot_path = None - self.failed = False - - def __repr__(self): - if self.type == "file": - return "<" + self.path + " (device)>" - elif self.type == "dev": - return "<" + self.path + " (block device)>" - else: - return "<" + self.path + " (unknown type)>" - - -class Snapshot: - - def __init__(self, dom, disks, memory=None, progress=True): - self.dom = dom - self.disks = disks - self.memory = memory - self.progress = progress - self.snapshotted = False - self._do_snapshot() - - def _do_snapshot(self): - snapshot_flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_NO_METADATA | libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC - if self.memory is None: - snapshot_flags |= libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY - else: - snapshot_flags |= libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_LIVE - guest_agent_installed = False - libvirt.ignored_errors = [libvirt.VIR_ERR_OPERATION_INVALID, libvirt.VIR_ERR_ARGUMENT_UNSUPPORTED] - try: - - self.dom.fsFreeze() - guest_agent_installed = True - except libvirt.libvirtError: - # qemu guest agent is not installed - pass - libvirt.ignored_errors = [] - try: - self.dom.snapshotCreateXML(self._generate_snapshot_xml(), snapshot_flags) - except libvirt.libvirtError: - print("Failed to create domain snapshot", file=sys.stderr) - sys.exit(1) - finally: - if guest_agent_installed: - self.dom.fsThaw() - self.snapshotted = True - - def _generate_snapshot_xml(self): - root_xml = ElementTree.Element("domainsnapshot") - name_xml = ElementTree.SubElement(root_xml, "name") - name_xml.text = self.dom.name() + "-tempsnap" - desc_xml = ElementTree.SubElement(root_xml, "description") - desc_xml.text = "Temporary snapshot used while backing up " + self.dom.name() - memory_xml = ElementTree.SubElement(root_xml, "memory") - if self.memory is not None: - memory_xml.attrib["snapshot"] = "external" - memory_xml.attrib["file"] = self.memory - else: - memory_xml.attrib["snapshot"] = "no" - disks_xml = ElementTree.SubElement(root_xml, "disks") - for disk in self.disks: - disk_xml = ElementTree.SubElement(disks_xml, "disk") - if disk.snapshot_path is not None: - disk_xml.attrib["name"] = disk.path - source_xml = ElementTree.SubElement(disk_xml, "source") - source_xml.attrib["file"] = disk.snapshot_path - driver_xml = ElementTree.SubElement(disk_xml, "driver") - driver_xml.attrib["type"] = "qcow2" - else: - disk_xml.attrib["name"] = disk.target - disk_xml.attrib["snapshot"] = "no" - return ElementTree.tostring(root_xml).decode("utf-8") - - def __enter__(self): - return self - - def __exit__(self, *args): - if not self.snapshotted: - return False - disks_to_backup = [x for x in self.disks if x.snapshot_path is not None] - if self.dom.isActive(): - # the domain is online. we can use libvirt's blockcommit feature - # to commit the contents & automatically pivot afterwards - blockcommit_flags = libvirt.VIR_DOMAIN_BLOCK_COMMIT_ACTIVE | libvirt.VIR_DOMAIN_BLOCK_COMMIT_SHALLOW - for idx, disk in enumerate(disks_to_backup): - if self.dom.blockCommit(disk.target, None, None, flags=blockcommit_flags) < 0: - print("Failed to start block commit for disk '{}'".format(disk.target).ljust(65), file=sys.stderr) - disk.failed = True - try: - while True: - info = self.dom.blockJobInfo(disk.target, 0) - if info is not None: - if self.progress: - progress = (idx + info["cur"] / info["end"]) / len(disks_to_backup) - print("block commit progress ({}): {}%".format( - disk.target, int(100 * progress)).ljust(65), end="\u001b[65D") - else: - print("Failed to query block jobs for disk '{}'".format( - disk.target).ljust(65), file=sys.stderr) - disk.failed = True - break - if info["cur"] == info["end"]: - break - sleep(0.5) - finally: - if self.progress: - print("...pivoting...".ljust(65), end="\u001b[65D") - if self.dom.blockJobAbort(disk.target, libvirt.VIR_DOMAIN_BLOCK_JOB_ABORT_PIVOT) < 0: - print("Pivot failed for disk '{}', it may be in an inconsistent state".format( - disk.target).ljust(65), file=sys.stderr) - disk.failed = True - else: - os.remove(disk.snapshot_path) - else: - # the domain is offline, use qemu-img for offline commit instead. - # libvirt doesn't support external snapshots as well as internal, - # hence this workaround - if self.progress: - print("image commit progress: 0%".ljust(65), end="\u001b[65D") - else: - print("committing disk images") - for idx, disk in enumerate(disks_to_backup): - try: - subprocess.run(["qemu-img", "commit", disk.snapshot_path], stdout=subprocess.DEVNULL, check=True) - # restore the original image in domain definition - # this is done automatically when pivoting for live commit - new_xml = ElementTree.tostring(disk.xml).decode("utf-8") - try: - self.dom.updateDeviceFlags(new_xml) - except libvirt.libvirtError: - print("Device flags update failed for disk '{}'".format(disk.target).ljust(65), file=sys.stderr) - print("Try replacing the path manually with 'virsh edit'", file=sys.stderr) - disk.failed = True - continue - os.remove(disk.snapshot_path) - if self.progress: - progress = (idx + 1) / len(disks_to_backup) - print("image commit progress ({}): {}%".format( - disk.target, int(100 * progress)).ljust(65), end="\u001b[65D") - except FileNotFoundError: - # not very likely as the qemu-img tool is normally installed - # along with the libvirt/virsh stuff - print("Please install qemu-img to commit changes offline".ljust(65), file=sys.stderr) - disk.failed = True - break - except subprocess.CalledProcessError: - print("Commit failed for disk '{}'".format(disk.target).ljust(65)) - disk.failed = True - if self.progress: - print() - return False - - -def error_handler(ctx, err): - if err[0] not in libvirt.ignored_errors: - print("libvirt: error code {}: {}".format(err[0], err[2]), file=sys.stderr) - - -def log(p, msg, end="\n"): - if isinstance(p, subprocess.Popen): - name = p.archive.orig - elif isinstance(p, Location): - name = p.orig - else: - name = p - for l in msg[:-1]: - print("[{}] {}".format(name, l), file=sys.stderr) - print("[{}] {}".format(name, msg[-1]), file=sys.stderr, end=end) - - -prompt_answers = {} - - -def process_line(p, line, total_size): - global prompt_answers - if len(p.json_buf) > 0 or line.startswith("{"): - p.json_buf.append(line) - if len(p.json_buf) > 0 and line.endswith("}"): - try: - msg = json.loads("\n".join(p.json_buf)) - p.json_buf = [] - if msg["type"] == "archive_progress": - p.progress = msg["original_size"] / total_size - elif msg["type"] == "log_message": - log(p, msg["message"].split("\n")) - elif msg["type"].startswith("question"): - if "msgid" in msg: - prompt_id = msg["msgid"] - elif "message" in msg: - prompt_id = msg["message"] - else: - raise ValueError("No msgid or message for prompt") - if msg.get("is_prompt", False) or msg["type"].startswith("question_prompt"): - if prompt_id not in prompt_answers: - log(p, msg["message"].split("\n"), end="") - prompt_answers[prompt_id] = input() - print(prompt_answers[prompt_id], file=p.stdin, flush=True) - elif not msg["type"].startswith("question_accepted"): - log(p, msg["message"].split("\n")) - except json.decoder.JSONDecodeError: - log(p, p.json_buf) - p.json_buf = [] - elif line.startswith("Enter passphrase for key "): - log(p, [line], end="") - passphrase = getpass("") - print(passphrase, file=p.stdin, flush=True) - print("", file=sys.stderr) - elif line != "": - # line is not json? - log(p, [line]) - # TODO: process password here for efficiency & simplicity - - -def main(): - args = ArgumentParser(sys.argv) - - libvirt.ignored_errors = [] - libvirt.registerErrorHandler(error_handler, None) - conn = libvirt.open() - if conn is None: - print("Failed to open connection to libvirt", file=sys.stderr) - sys.exit(1) - try: - dom = conn.lookupByName(args.domain) - except libvirt.libvirtError: - print("Domain '{}' not found".format(args.domain)) - sys.exit(1) - - if args.memory and not dom.isActive(): - print("Domain is shut off, cannot save memory contents", file=sys.stderr) - args.memory = False - - tree = ElementTree.fromstring(dom.XMLDesc(0)) - disks = [d for d in map(Disk, tree.findall("devices/disk")) if d.type is not None] - if len(disks) == 0: - print("Domain has no disks(!)", file=sys.stderr) - sys.exit(1) - - if not args.disks: - args.disks = {x.target for x in disks} - else: - nonexistent_disks = args.disks - {x.target for x in disks} - if len(nonexistent_disks) > 0: - print("Some disks to be backed up don't exist on the domain:", *sorted(nonexistent_disks), file=sys.stderr) - sys.exit(1) - - for disk in disks: - if disk.target not in args.disks: - continue - filename = args.domain + "-" + disk.target + "-tempsnap.qcow2" - if disk.type == "dev": - # we probably can't write the temporary snapshot to the same directory - # as the original disk, so use the default libvirt images directory - disk.snapshot_path = os.path.join("/var/lib/libvirt/images", filename) - else: - disk.snapshot_path = os.path.join(os.path.dirname(disk.path), filename) - - if args.memory: - memory = os.path.join(tmpdir, "memory.bin") - else: - memory = None - - passphrases = {} - if sys.stdout.isatty(): - for archive in args.archives: - repo = str(archive).split("::")[0] - # check if we need a password as recommended by the docs: - # https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#passphrase-prompts - env = os.environ.copy() - if len({"BORG_PASSPHRASE", "BORG_PASSCOMMAND", "BORG_NEWPASSPHRASE"} - set(env)) == 3: - env["BORG_PASSPHRASE"] = b64encode(os.urandom(16)).decode("utf-8") - with subprocess.Popen(["borg", "list", repo], stdin=subprocess.PIPE, - stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env) as proc: - proc.stdin.close() # manually close stdin instead of /dev/null so it knows it won't get input - proc.stdin = None - err = proc.communicate(input)[1].decode("utf-8").rstrip("\n").split("\n") - if proc.poll() != 0: - # exact error message changes between borg versions - if err[-1].startswith("passphrase supplied") and err[-1].endswith("is incorrect."): - passphrases[archive] = getpass("Enter passphrase for key {}: ".format(repo.split(":")[0])) - else: - # the error will re-manifest later (with better I/O formatting), so just ignore it - pass - - with TemporaryDirectory() as tmpdir, Snapshot(dom, disks, memory, args.progress): - total_size = 0 - for disk in disks: - if disk.target in args.disks: - realpath = os.path.realpath(disk.path) - with open(realpath) as f: - f.seek(0, os.SEEK_END) - total_size += f.tell() - linkpath = os.path.join(tmpdir, disk.target + "." + disk.format) - # following symlinks for --read-special is still broken :( - # when issue gets fixed should switch to symlinks: - # https://github.com/borgbackup/borg/issues/1215 - # os.symlink(realpath, linkpath) - with open(linkpath, "w") as f: - pass # simulate 'touch' - subprocess.run(["mount", "--bind", realpath, linkpath], check=True) - - try: - # borg <1.1 doesn't support --log-json for the progress display - version_bytes = subprocess.run(["borg", "--version"], stdout=subprocess.PIPE, check=True).stdout - borg_version = [*map(int, version_bytes.decode("utf-8").split(" ")[1].split("."))] - if borg_version[0] < 1 or borg_version[1] < 1: - print("You are using an old version of borg, progress indication is disabled", file=sys.stderr) - old_borg = True - args.progress = False - else: - old_borg = False - - os.chdir(tmpdir) - borg_processes = [] - try: - with selectors.DefaultSelector() as sel: - for idx, archive in enumerate(args.archives): - if args.progress: - archive.extra_args.append("--progress") - if not old_borg: - archive.extra_args.append("--log-json") - env = os.environ.copy() - passphrase = passphrases.get(archive, os.environ.get("BORG_PASSPHRASE")) - if passphrase is not None: - env["BORG_PASSPHRASE"] = passphrase - master, slave = openpty() - settings = termios.tcgetattr(master) - settings[3] &= ~termios.ECHO - termios.tcsetattr(master, termios.TCSADRAIN, settings) - proc = subprocess.Popen(["borg", "create", str(archive), ".", "--read-special", *archive.extra_args], - stdout=slave, stderr=slave, stdin=slave, close_fds=True, env=env, start_new_session=True) - fl = fcntl.fcntl(master, fcntl.F_GETFL) - fcntl.fcntl(master, fcntl.F_SETFL, fl | os.O_NONBLOCK) - proc.stdin = os.fdopen(master, "w") - proc.stdout = os.fdopen(master, "r") - proc.archive = archive - proc.json_buf = [] - proc.progress = 0 - borg_processes.append(proc) - sel.register(proc.stdout, selectors.EVENT_READ, data=proc) - - borg_failed = False - if args.progress: - print("backup progress: 0%".ljust(25), end="\u001b[25D", flush=True) - else: - # give the user some feedback so the program doesn't look frozen - print("starting backup", flush=True) - while len(sel.get_map()) > 0: - for key, mask in sel.select(1): - for line in iter(key.fileobj.readline, ""): - process_line(key.data, line.rstrip("\n"), total_size) - for key in [*sel.get_map().values()]: - if key.data.poll() is not None: - key.data.wait() - key.data.progress = 1 - if key.data.returncode != 0: - borg_failed = True - sel.unregister(key.fileobj) - if args.progress: - progress = int(sum(p.progress for p in borg_processes) / len(borg_processes) * 100) - print("backup progress: {}%".format(progress).ljust(25), end="\u001b[25D") - if args.progress: - print() - finally: - for p in borg_processes: - if p.poll() is not None: - p.kill() - try: - p.communicate() - except (ValueError, OSError): - p.wait() - finally: - for disk in disks: - if disk.target in args.disks: - subprocess.run(["umount", os.path.join(tmpdir, disk.target + "." + disk.format)], check=True) - - # bug in libvirt python wrapper(?): sometimes it tries to delete - # the connection object before the domain, which references it - del dom - del conn - - if borg_failed or any(disk.failed for disk in disks): - sys.exit(1) - else: - sys.exit() - - -main() diff --git a/backup_vm/__init__.py b/backup_vm/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backup_vm/backup.py b/backup_vm/backup.py new file mode 100755 index 0000000..11e45cf --- /dev/null +++ b/backup_vm/backup.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +import os.path +import sys +import libvirt +from . import parse +from . import multi +from . import builder +from . import snapshot + + +def main(): + args = parse.ArgumentParser(sys.argv) + conn = libvirt.open() + if conn is None: + print("Failed to open connection to libvirt", file=sys.stderr) + sys.exit(1) + try: + dom = conn.lookupByName(args.domain) + except libvirt.libvirtError: + print("Domain '{}' not found".format(args.domain)) + sys.exit(1) + + if args.memory and not dom.isActive(): + print("Domain is shut off, cannot save memory contents", file=sys.stderr) + args.memory = False + + all_disks = set(parse.Disk.get_disks(dom)) + if len(all_disks) == 0: + print("Domain has no disks(!)", file=sys.stderr) + sys.exit(1) + + disks_to_backup = args.disks and {x for x in all_disks if x.target in args.disks} or all_disks + if len(disks_to_backup) != len(args.disks or all_disks): + print("Some disks to be backed up don't exist on the domain:", + *sorted(args.disks - {x.target for x in disks}), file=sys.stderr) + sys.exit(1) + + for disk in all_disks: + disk.failed = False + if disk not in disks_to_backup: + disk.snapshot_path = None + continue + filename = args.domain + "-" + disk.target + "-tempsnap.qcow2" + if disk.type == "dev": + # we probably can't write the temporary snapshot to the same directory + # as the original disk, so use the default libvirt images directory + disk.snapshot_path = os.path.join("/var/lib/libvirt/images", filename) + else: + disk.snapshot_path = os.path.join(os.path.dirname(disk.path), filename) + + memory = os.path.join(tmpdir, "memory.bin") if args.memory else None + with snapshot.Snapshot(dom, all_disks, memory, args.progress), \ + builder.ArchiveBuilder(disks_to_backup) as archive_dir: + if args.progress: + borg_failed = multi.assimilate(args.archives, archive_dir.total_size) + else: + borg_failed = multi.assimilate(args.archives) + pass + + # bug in libvirt python wrapper(?): sometimes it tries to delete + # the connection object before the domain, which references it + del dom + del conn + + sys.exit(borg_failed or any(disk.failed for disk in disks_to_backup)) diff --git a/backup_vm/builder.py b/backup_vm/builder.py new file mode 100644 index 0000000..2dcabf1 --- /dev/null +++ b/backup_vm/builder.py @@ -0,0 +1,47 @@ +import subprocess +import tempfile +import os.path + + +class ArchiveBuilder(tempfile.TemporaryDirectory): + + """Creates the folder to be turned into a VM backup. + + Creates a temporary folder populated with symlinks to each disk to backup. + Essentially lays out the contents of the archive to be created. + + Attributes: + name: The path of the temporary directory. + total_size: The total size of every disk linked to in the directory. + """ + + def __init__(self, disks, *args, **kwargs): + super().__init__(*args, **kwargs) + self.total_size = 0 + self.disks = disks + self.old_cwd = os.getcwd() + os.chdir(self.name) + + def __enter__(self): + for disk in self.disks: + realpath = os.path.realpath(disk.path) + with open(realpath) as f: + # add size of disk to total + f.seek(0, os.SEEK_END) + self.total_size += f.tell() + linkpath = disk.target + "." + disk.format + with open(linkpath, "w") as f: + # simulate 'touch' + pass + # following symlinks for --read-special is still broken :( + # when issue gets fixed should switch to symlinks: + # https://github.com/borgbackup/borg/issues/1215 + subprocess.run(["mount", "--bind", realpath, linkpath], check=True) + return self + + def cleanup(self): + for disk in self.disks: + linkpath = disk.target + "." + disk.format + subprocess.run(["umount", linkpath], check=True) + os.chdir(self.old_cwd) + return super().cleanup() diff --git a/backup_vm/multi.py b/backup_vm/multi.py new file mode 100644 index 0000000..370a151 --- /dev/null +++ b/backup_vm/multi.py @@ -0,0 +1,236 @@ +from distutils.version import LooseVersion +from base64 import b64encode +from getpass import getpass +from pty import openpty +from copy import copy +import subprocess +import selectors +import termios +import fcntl +import json +import sys +import pty +import os + + +def get_passphrases(archives): + """Prompts the user for their archive passphrases. + + Checks for archives that won't open without a (non-blank, non-random) + BORG_PASSPHRASE and prompts the user for their passphrases. + + Args: + archives: A list of Location objects to check the repositories of. + + Returns: + A dictionary mapping archives to their (purported) passphrases. The + entered passphrases are not checked to actually open the archives. + """ + passphrases = {} + env = os.environ.copy() + for archive in archives: + repo = copy(archive) + repo.archive = None + # check if we need a password as recommended by the docs: + # https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#passphrase-prompts + if len({"BORG_PASSPHRASE", "BORG_PASSCOMMAND", "BORG_NEWPASSPHRASE"} - set(env)) == 3: + # generate random password that would be incorrect were it needed + env["BORG_PASSPHRASE"] = b64encode(os.urandom(16)).decode("utf-8") + with subprocess.Popen(["borg", "list", str(repo)], stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env) as proc: + # manually close stdin instead of /dev/null so borg knows it won't get input + proc.stdin.close() + proc.stdin = None + err = proc.communicate(input)[1].decode("utf-8").rstrip("\n").split("\n")[-1] + if proc.poll() != 0: + # exact error message changes between borg versions + if err.startswith("passphrase supplied") and err.endswith("is incorrect."): + passphrases[archive] = getpass("Enter passphrase for key {!s}: ".format(repo)) + return passphrases + + +def log(name, msg, *args, file=sys.stderr, end="\n", **kwargs): + """Logs a string to a file, prepending a "tag" to each line. + + Logs a string to a file (by default stderr), with a "tag" added to the + beginning of each line, in the format of this example:: + + [repo::archive] Hello world! + + Args: + name: The text to be put in the "tag" part of each line. + msg: The string to be tagged & logged. + end: The ending of the last line printed. + + Any other arguments passed will be passed onto print(). + """ + for l in msg[:-1]: + print("[{}] {}".format(name, l), file=file, **kwargs) + print("[{}] {}".format(name, msg[-1]), file=file, end=end, **kwargs) + + +def process_line(p, line, total_size=None, prompt_answers={}): + """Process a line coming from a borg process. + + Processes JSON emitted by a borg process with --log-json turned on. The + lines are cached, so 1 line does not have to equal 1 JSON message. + + Args: + p: The process the line came from (with some extra properties added to + the Popen object). + line: The line read from the process's stdout or stderr. If it contains + progress information, update the stored progress value. If it is a + prompt for the user, ask for and return the answer (& cache it for + later.) If it is a log message or some other non-JSON, print it out. + total_size: The total size of all files being backed up. This can be set + to None to disable progress calculation. + prompt_answers: A dictionary of previous answers from users' prompts. + Prompts with msgids in the dictionary will be automatically answered + with the value given (ostensibly from an earlier prompt). + """ + if len(p.json_buf) > 0 or line.startswith("{"): + p.json_buf.append(line) + if len(p.json_buf) > 0 and line.endswith("}"): + try: + msg = json.loads("\n".join(p.json_buf)) + p.json_buf = [] + if msg["type"] == "archive_progress" and total_size is not None: + p.progress = msg["original_size"] / total_size + elif msg["type"] == "log_message": + log(p.archive.orig, msg["message"].split("\n")) + elif msg["type"].startswith("question"): + if "msgid" in msg: + prompt_id = msg["msgid"] + elif "message" in msg: + prompt_id = msg["message"] + else: + raise ValueError("No msgid or message for prompt") + if msg.get("is_prompt", False) or msg["type"].startswith("question_prompt"): + if prompt_id not in prompt_answers: + log(p.archive.orig, msg["message"].split("\n"), end="") + try: + prompt_answers[prompt_id] = input() + print(prompt_answers[prompt_id], file=p.stdin, flush=True) + except EOFError: + p.stdin.close() + elif not msg["type"].startswith("question_accepted"): + log(p.archive.orig, msg["message"].split("\n")) + except json.decoder.JSONDecodeError: + log(p.archive.orig, p.json_buf) + p.json_buf = [] + elif line.startswith("Enter passphrase for key "): + log(p.archive.orig, [line], end="") + passphrase = getpass("") + print(passphrase, file=p.stdin, flush=True) + print("", file=sys.stderr) + elif line != "": + # line is not json? + log(p.archive.orig, [line]) + # TODO: process password here for efficiency & simplicity + + +def get_borg_version(): + """ + Get the version of the system borg. + + Returns: + The version of the system borg as a distutils.version.LooseVersion (for + easy comparison with other versions). + """ + version_bytes = subprocess.run(["borg", "--version"], stdout=subprocess.PIPE, check=True).stdout + return LooseVersion(version_bytes.decode("utf-8").split(" ")[1]) + + +def assimilate(archives, total_size=None, dir_to_archive=".", passphrases=None): + """ + Run and manage multiple `borg create` commands. + + Args: + archives: A list containing Location objects for the archives to create. + total_size: The total size of all files being backed up. As borg + normally only makes one pass over the data, it can't calculate + percentages on its own. Setting this to None disables progress + calculation. + dir_to_archive: The directory to archive. Defaults to the current + directory. + + Returns: + A boolean indicating if any borg processes failed (True = failed). + """ + + if passphrases is None and sys.stdout.isatty(): + passphrases = get_passphrases(archives) + + if get_borg_version() < LooseVersion("1.1.0"): + # borg <1.1 doesn't support --log-json for the progress display + print("You are using an old version of borg, progress indication is disabled", file=sys.stderr) + recent_borg = False + progress = False + else: + recent_borg = True + progress = total_size is not None + + borg_processes = [] + borg_failed = False + try: + with selectors.DefaultSelector() as sel: + for idx, archive in enumerate(archives): + if progress: + archive.extra_args.append("--progress") + if recent_borg: + archive.extra_args.append("--log-json") + env = os.environ.copy() + passphrase = passphrases.get(archive, os.environ.get("BORG_PASSPHRASE")) + if passphrase is not None: + env["BORG_PASSPHRASE"] = passphrase + master, slave = openpty() + settings = termios.tcgetattr(master) + settings[3] &= ~termios.ECHO + termios.tcsetattr(master, termios.TCSADRAIN, settings) + proc = subprocess.Popen(["borg", "create", str(archive), dir_to_archive, "--read-special", *archive.extra_args], + stdout=slave, stderr=slave, stdin=slave, close_fds=True, env=env, start_new_session=True) + fl = fcntl.fcntl(master, fcntl.F_GETFL) + fcntl.fcntl(master, fcntl.F_SETFL, fl | os.O_NONBLOCK) + proc.stdin = os.fdopen(master, "w") + proc.stdout = os.fdopen(master, "r") + proc.archive = archive + proc.json_buf = [] + proc.progress = 0 + borg_processes.append(proc) + sel.register(proc.stdout, selectors.EVENT_READ, data=proc) + + if progress: + print("backup progress: 0%".ljust(25), end="\u001b[25D", flush=True) + else: + # give the user some feedback so the program doesn't look frozen + print("starting backup", flush=True) + while len(sel.get_map()) > 0: + for key, mask in sel.select(1): + for line in iter(key.fileobj.readline, ""): + process_line(key.data, line.rstrip("\n"), total_size) + for key in [*sel.get_map().values()]: + if key.data.poll() is not None: + key.data.wait() + key.data.progress = 1 + if key.data.returncode != 0: + borg_failed = True + sel.unregister(key.fileobj) + if progress: + total_progress = sum(p.progress for p in borg_processes) + print("backup progress: {}%".format( + int(total_progress / len(borg_processes) * 100)).ljust(25), end="\u001b[25D") + if progress: + print() + finally: + for p in borg_processes: + if p.poll() is not None: + p.kill() + try: + p.communicate() + except (ValueError, OSError): + p.wait() + return borg_failed + + +def main(): + pass diff --git a/backup_vm/parse.py b/backup_vm/parse.py new file mode 100755 index 0000000..4277255 --- /dev/null +++ b/backup_vm/parse.py @@ -0,0 +1,272 @@ +from abc import ABCMeta, abstractmethod +from xml.etree import ElementTree +from textwrap import dedent +import sys +import os +import re + + +class Location: + # see https://github.com/borgbackup/borg/blob/5e2de8b/src/borg/helpers/parseformat.py#L277 + proto = user = _host = port = path = archive = None + optional_user_re = r""" + (?:(?P[^@:/]+)@)? + """ + scp_path_re = r""" + (?!(:|//|ssh://)) + (?P([^:]|(:(?!:)))+) + """ + file_path_re = r""" + (?P(([^/]*)/([^:]|(:(?!:)))+)) + """ + abs_path_re = r""" + (?P(/([^:]|(:(?!:)))+)) + """ + optional_archive_re = r""" + (?: + :: + (?P[^/]+) + )?$""" + ssh_re = re.compile(r""" + (?Pssh):// + """ + optional_user_re + r""" + (?P([^:/]+|\[[0-9a-fA-F:.]+\]))(?::(?P\d+))? + """ + abs_path_re + optional_archive_re, re.VERBOSE) + file_re = re.compile(r""" + (?Pfile):// + """ + file_path_re + optional_archive_re, re.VERBOSE) + scp_re = re.compile(r""" + ( + """ + optional_user_re + r""" + (?P([^:/]+|\[[0-9a-fA-F:.]+\])): + )? + """ + scp_path_re + optional_archive_re, re.VERBOSE) + env_re = re.compile(r""" + (?:::$) + | + """ + optional_archive_re, re.VERBOSE) + + def __init__(self, text=""): + self.orig = text + self.extra_args = [] + if not self.parse(self.orig): + raise ValueError("Location: parse failed: %s" % self.orig) + + def parse(self, text): + # text = replace_placeholders(text) + valid = self._parse(text) + if valid: + return True + m = self.env_re.match(text) + if not m: + return False + repo = os.environ.get("BORG_REPO") + if repo is None: + return False + valid = self._parse(repo) + if not valid: + return False + self.archive = m.group("archive") + return True + + def _parse(self, text): + def normpath_special(p): + # avoid that normpath strips away our relative path hack and even + # makes p absolute + relative = p.startswith("/./") + p = os.path.normpath(p) + return ("/." + p) if relative else p + + m = self.ssh_re.match(text) + if m: + self.proto = m.group("proto") + self.user = m.group("user") + self._host = m.group("host") + self.port = m.group("port") and int(m.group("port")) or None + self.path = normpath_special(m.group("path")) + self.archive = m.group("archive") + return True + m = self.file_re.match(text) + if m: + self.proto = m.group("proto") + self.path = normpath_special(m.group("path")) + self.archive = m.group("archive") + return True + m = self.scp_re.match(text) + if m: + self.user = m.group("user") + self._host = m.group("host") + self.path = normpath_special(m.group("path")) + self.archive = m.group("archive") + self.proto = self._host and "ssh" or "file" + return True + return False + + @classmethod + def try_location(cls, text): + try: + return Location(text) + except ValueError: + return None + + def canonicalize_path(self, cwd=None): + if self.proto == "file" and not os.path.isabs(self.path): + if cwd is None: + cwd = os.getcwd() + self.path = os.path.normpath(os.path.join(cwd, self.path)) + + def __str__(self): + # https://borgbackup.readthedocs.io/en/stable/usage/general.html#repository-urls + # the path needs to be re-created instead of returning self.orig because + # we change values to make paths absolute, etc. + if self.proto == "file": + repo = self.path + elif self.proto == "ssh": + _user = self.user + "@" if self.user is not None else "" + if self.port is not None: + # URI form needs "./" prepended to relative dirs + if os.path.isabs(self.path): + _path = self.path + else: + _path = os.path.join(".", self.path) + repo = "ssh://{}{}:{}/{}".format(_user, self._host, self.port, _path) + else: + repo = "{}{}:{}".format(_user, self._host, self.path) + if self.archive is not None: + return repo + "::" + self.archive + else: + return repo + + def __hash__(self): + return hash(str(self)) + + +class Disk: + + """Holds information about a single disk on a libvirt domain. + + Attributes: + xml: The original XML element representing the disk. + format: The format of the disk image (qcow2, raw, etc.) + target: The block device name on the guest (sda, xvdb, etc.) + type: The type of storage backing the disk (file, block, etc.) + path: The location of the disk storage (image file, block device, etc.) + """ + + def __init__(self, xml): + self.xml = xml + self.format = xml.find("driver").attrib["type"] + self.target = xml.find("target").get("dev") + # sometimes there won't be a source entry, e.g. a cd drive without a + # virtual cd in it + if len(xml.find("source").attrib.items()) >= 1: + self.type, self.path = next(iter(xml.find("source").attrib.items())) + else: + self.type = self.path = None + + def __repr__(self): + if self.type == "file": + return "<" + self.path + " (device)>" + elif self.type == "dev": + return "<" + self.path + " (block device)>" + else: + return "<" + self.path + " (unknown type)>" + + @classmethod + def get_disks(cls, dom): + """Generates a list of Disks representing the disks on a libvirt domain. + + Args: + dom: A libvirt domain object. + + Yields: + Disk objects representing each disk on the domain. + """ + tree = ElementTree.fromstring(dom.XMLDesc(0)) + yield from {d for d in map(cls, tree.findall("devices/disk")) if d.type is not None} + + +class ArgumentParser(metaclass=ABCMeta): + + """Base class for backup-vm parsers. + + Parses arguments common to all scripts in the backup-vm package (with + --borg-args, multiple archive locations, etc.). + """ + + def __init__(self, args=sys.argv, default_name="backup-vm"): + self.prog = os.path.basename(args[0]) if len(args) > 0 else default_name + self.domain = None + self.memory = False + self.progress = sys.stdout.isatty() + self.disks = set() + self.archives = [] + self.parse_args(args[1:]) + + def parse_arg(self, arg): + # TODO: add --version + if arg in {"-h", "--help"}: + self.help() + sys.exit() + l = Location.try_location(arg) + if l is not None and l.path is not None and l.archive is not None and \ + (l.proto == "file" or l._host is not None): + self.parsing_borg_args = False + l.canonicalize_path() + self.archives.append(l) + elif arg == "--borg-args": + if len(self.archives) == 0: + self.error("--borg-args must come after an archive path") + else: + self.parsing_borg_args = True + elif self.parsing_borg_args: + self.archives[-1].extra_args.append(arg) + elif arg in {"-m", "--memory"}: + self.memory = True + elif arg in {"-p", "--progress"}: + self.progress = True + elif self.domain is None: + self.domain = arg + else: + self.disks.add(arg) + + def parse_args(self, args): + if len(args) == 1: + self.help() + sys.exit(2) + self.parsing_borg_args = False + for arg in args: + if arg.startswith("-") and not arg.startswith("--"): + for c in arg[1:]: + self.parse_arg("-" + c) + else: + self.parse_arg(arg) + if self.domain is None or len(self.archives) == 0: + self.error("the following arguments are required: domain, archive") + sys.exit(2) + + def error(self, msg): + self.help(True) + print(self.prog + ": error: " + msg, file=sys.stderr) + sys.exit(2) + + def help(self, short=False): + print(dedent(""" + usage: {} [-hmp] domain [disk [disk ...]] archive + [--borg-args ...] [archive [--borg-args ...] ...] + """.format(self.prog).lstrip("\n"))) + if not short: + print(dedent(""" + Back up a libvirt-based VM using borg. + + positional arguments: + domain libvirt domain to back up + disk a domain block device to back up (default: all disks) + archive a borg archive path (same format as borg create) + + optional arguments: + -h, --help show this help message and exit + -m, --memory (experimental) snapshot the memory state as well + -p, --progress force progress display even if stdout isn't a tty + --borg-args ... extra arguments passed straight to borg + """).strip("\n")) diff --git a/backup_vm/snapshot.py b/backup_vm/snapshot.py new file mode 100644 index 0000000..bcf5b34 --- /dev/null +++ b/backup_vm/snapshot.py @@ -0,0 +1,171 @@ +from xml.etree import ElementTree +import subprocess +import time +import sys +import os +import libvirt + + +def error_handler(ctx, err): + if err[0] not in libvirt.ignored_errors: + print("libvirt: error code {0}: {2}".format(*err), file=sys.stderr) + + +libvirt.ignored_errors = [] +libvirt.registerErrorHandler(error_handler, None) + + +class Snapshot: + + def __init__(self, dom, disks, memory=None, progress=True): + self.dom = dom + self.disks = disks + self.memory = memory + self.progress = progress + self.snapshotted = False + self._do_snapshot() + + def _do_snapshot(self): + snapshot_flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_NO_METADATA \ + | libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC + if self.memory is None: + snapshot_flags |= libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY + else: + snapshot_flags |= libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_LIVE + libvirt.ignored_errors = [ + libvirt.VIR_ERR_OPERATION_INVALID, + libvirt.VIR_ERR_ARGUMENT_UNSUPPORTED + ] + try: + self.dom.fsFreeze() + guest_agent_installed = True + except libvirt.libvirtError: + guest_agent_installed = False + libvirt.ignored_errors = [] + try: + snapshot_xml = self.generate_snapshot_xml() + self.dom.snapshotCreateXML(snapshot_xml, snapshot_flags) + except libvirt.libvirtError: + print("Failed to create domain snapshot", file=sys.stderr) + sys.exit(1) + finally: + if guest_agent_installed: + self.dom.fsThaw() + self.snapshotted = True + + def generate_snapshot_xml(self): + root_xml = ElementTree.Element("domainsnapshot") + name_xml = ElementTree.SubElement(root_xml, "name") + name_xml.text = self.dom.name() + "-tempsnap" + desc_xml = ElementTree.SubElement(root_xml, "description") + desc_xml.text = "Temporary snapshot used while backing up " + self.dom.name() + memory_xml = ElementTree.SubElement(root_xml, "memory") + if self.memory is not None: + memory_xml.attrib["snapshot"] = "external" + memory_xml.attrib["file"] = self.memory + else: + memory_xml.attrib["snapshot"] = "no" + disks_xml = ElementTree.SubElement(root_xml, "disks") + for disk in self.disks: + disk_xml = ElementTree.SubElement(disks_xml, "disk") + if disk.snapshot_path is not None: + disk_xml.attrib["name"] = disk.path + source_xml = ElementTree.SubElement(disk_xml, "source") + source_xml.attrib["file"] = disk.snapshot_path + driver_xml = ElementTree.SubElement(disk_xml, "driver") + driver_xml.attrib["type"] = "qcow2" + else: + disk_xml.attrib["name"] = disk.target + disk_xml.attrib["snapshot"] = "no" + return ElementTree.tostring(root_xml).decode("utf-8") + + def blockcommit(self, disks): + for idx, disk in enumerate(disks): + if self.dom.blockCommit( + disk.target, None, None, + flags=libvirt.VIR_DOMAIN_BLOCK_COMMIT_ACTIVE + | libvirt.VIR_DOMAIN_BLOCK_COMMIT_SHALLOW) < 0: + print("Failed to start block commit for disk '{}'".format( + disk.target).ljust(65), file=sys.stderr) + disk.failed = True + try: + while True: + info = self.dom.blockJobInfo(disk.target, 0) + if info is not None and self.progress: + progress = (idx + info["cur"] / info["end"]) / len(disks) + print("block commit progress ({}): {}%".format( + disk.target, int(100 * progress)).ljust(65), end="\u001b[65D") + elif info is None: + print("Failed to query block jobs for disk '{}'".format( + disk.target).ljust(65), file=sys.stderr) + disk.failed = True + break + if info["cur"] == info["end"]: + break + time.sleep(1) + finally: + if self.progress: + print("...pivoting...".ljust(65), end="\u001b[65D") + if self.dom.blockJobAbort( + disk.target, + libvirt.VIR_DOMAIN_BLOCK_JOB_ABORT_PIVOT) < 0: + print("Pivot failed for disk '{}', it may be in an inconsistent state".format( + disk.target).ljust(65), file=sys.stderr) + disk.failed = True + else: + os.remove(disk.snapshot_path) + + def offline_commit(self, disks): + if self.progress: + print("image commit progress: 0%".ljust(65), end="\u001b[65D") + else: + print("committing disk images") + for idx, disk in enumerate(disks): + try: + subprocess.run(["qemu-img", "commit", disk.snapshot_path], + stdout=subprocess.DEVNULL, check=True) + # restore the original image in domain definition + # this is done automatically when pivoting for live commit + new_xml = ElementTree.tostring(disk.xml).decode("utf-8") + try: + self.dom.updateDeviceFlags(new_xml) + except libvirt.libvirtError: + print("Device flags update failed for disk '{}'".format( + disk.target).ljust(65), file=sys.stderr) + print("Try replacing the path manually with 'virsh edit'", file=sys.stderr) + disk.failed = True + continue + os.remove(disk.snapshot_path) + if self.progress: + progress = (idx + 1) / len(disks) + print("image commit progress ({}): {}%".format( + disk.target, int(100 * progress)).ljust(65), end="\u001b[65D") + except FileNotFoundError: + # not very likely as the qemu-img tool is normally installed + # along with the libvirt/virsh stuff + print("Install qemu-img to commit changes offline".ljust(65), file=sys.stderr) + disk.failed = True + break + except subprocess.CalledProcessError: + print("Commit failed for disk '{}'".format(disk.target).ljust(65)) + disk.failed = True + + def __enter__(self): + return self + + def __exit__(self, *args): + if not self.snapshotted: + return False + disks_to_backup = [x for x in self.disks if x.snapshot_path is not None] + if self.dom.isActive(): + # the domain is online. we can use libvirt's blockcommit feature + # to commit the contents & automatically pivot afterwards + self.blockcommit(disks_to_backup) + else: + # the domain is offline, use qemu-img for offline commit instead. + # libvirt doesn't support external snapshots as well as internal, + # hence this workaround + self.offline_commit(disks_to_backup) + if self.progress: + print() + return False diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cef9e03 --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup + + +def readme(): + with open("README.rst") as f: + return f.read() + + +setup(name="backup-vm", + version="0.1", + description="Backup libvirt VMs with borg", + long_description=readme(), + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Topic :: System :: Archiving :: Backup", + ], + keywords="borg backup libvirt vm snapshot", + url="https://github.com/milkey-mouse/backup-vm", + author="Milkey Mouse", + author_email="milkeymouse@meme.institute", + license="MIT", + packages=["backup_vm"], + install_requires=[ + "libvirt-python", + ], + entry_points={ + "console_scripts": [ + "backup-vm=backup_vm.backup:main", + #"borg-multi=backup_vm.multi:main", + ], + }, + include_package_data=True, + zip_safe=False)