Merge remote-tracking branch 'marmarek/core3-backup' into core3-devel
This commit is contained in:
commit
526f2c3751
@ -15,6 +15,15 @@ Options
|
|||||||
|
|
||||||
Show this help message and exit
|
Show this help message and exit
|
||||||
|
|
||||||
|
.. option:: --verbose, -v
|
||||||
|
|
||||||
|
Increase verbosity
|
||||||
|
|
||||||
|
.. option:: --quiet, -q
|
||||||
|
|
||||||
|
Decrease verbosity
|
||||||
|
|
||||||
|
|
||||||
.. option:: --verify-only
|
.. option:: --verify-only
|
||||||
|
|
||||||
Do not restore the data, only verify backup integrity
|
Do not restore the data, only verify backup integrity
|
||||||
@ -31,6 +40,10 @@ Options
|
|||||||
|
|
||||||
Do not restore VMs that are already present on the host
|
Do not restore VMs that are already present on the host
|
||||||
|
|
||||||
|
.. option:: --rename-conflicting
|
||||||
|
|
||||||
|
Restore VMs that are already present on the host under different names
|
||||||
|
|
||||||
.. option:: --force-root
|
.. option:: --force-root
|
||||||
|
|
||||||
Force to run, even with root privileges
|
Force to run, even with root privileges
|
||||||
@ -56,17 +69,11 @@ Options
|
|||||||
|
|
||||||
Restore from a backup located in a specific AppVM
|
Restore from a backup located in a specific AppVM
|
||||||
|
|
||||||
.. option:: --encrypted, -e
|
.. option:: --passphrase-file, -p
|
||||||
|
|
||||||
The backup is encrypted
|
Read passphrase from file, or use '-' to read from stdin
|
||||||
|
|
||||||
.. option:: --compressed. -z
|
|
||||||
|
|
||||||
The backup is compressed
|
|
||||||
|
|
||||||
.. option:: --debug
|
|
||||||
|
|
||||||
Enable (a lot of) debug output
|
|
||||||
|
|
||||||
Authors
|
Authors
|
||||||
=======
|
=======
|
||||||
|
@ -1,26 +1,86 @@
|
|||||||
.. program:: qvm-backup
|
.. program:: qvm-backup
|
||||||
|
|
||||||
=======================================================
|
:program:`qvm-backup` -- None
|
||||||
:program:`qvm-backup` -- Create backup of specified VMs
|
=============================
|
||||||
=======================================================
|
|
||||||
|
|
||||||
Synopsis
|
Synopsis
|
||||||
========
|
--------
|
||||||
:command:`qvm-backup` [*options*] <*backup-dir-path*>
|
|
||||||
|
:command:`qvm-backup` skel-manpage.py [-h] [--verbose] [--quiet] [--force-root] [--exclude EXCLUDE_LIST] [--dest-vm *APPVM*] [--encrypt] [--no-encrypt] [--passphrase-file PASS_FILE] [--enc-algo CRYPTO_ALGORITHM] [--hmac-algo HMAC_ALGORITHM] [--compress] [--compress-filter COMPRESS_FILTER] [--tmpdir *TMPDIR*] backup_location [vms [vms ...]]
|
||||||
|
|
||||||
Options
|
Options
|
||||||
=======
|
-------
|
||||||
|
|
||||||
.. option:: --help, -h
|
.. option:: --help, -h
|
||||||
|
|
||||||
Show this help message and exit
|
show this help message and exit
|
||||||
|
|
||||||
.. option:: --exclude=EXCLUDE_LIST, -x EXCLUDE_LIST
|
.. option:: --verbose, -v
|
||||||
|
|
||||||
Exclude the specified VM from backup (might be repeated)
|
increase verbosity
|
||||||
|
|
||||||
|
.. option:: --quiet, -q
|
||||||
|
|
||||||
|
decrease verbosity
|
||||||
|
|
||||||
|
.. option:: --force-root
|
||||||
|
|
||||||
|
force to run as root
|
||||||
|
|
||||||
|
.. option:: --exclude, -x
|
||||||
|
|
||||||
|
Exclude the specified VM from the backup (may be repeated)
|
||||||
|
|
||||||
|
.. option:: --dest-vm, -d
|
||||||
|
|
||||||
|
Specify the destination VM to which the backup will be sent (implies -e)
|
||||||
|
|
||||||
|
.. option:: --encrypt, -e
|
||||||
|
|
||||||
|
Encrypt the backup
|
||||||
|
|
||||||
|
.. option:: --no-encrypt
|
||||||
|
|
||||||
|
Skip encryption even if sending the backup to a VM
|
||||||
|
|
||||||
|
.. option:: --passphrase-file, -p
|
||||||
|
|
||||||
|
Read passphrase from a file, or use '-' to read from stdin
|
||||||
|
|
||||||
|
.. option:: --enc-algo, -E
|
||||||
|
|
||||||
|
Specify a non-default encryption algorithm. For a list of supported algorithms, execute 'openssl list-cipher-algorithms' (implies -e)
|
||||||
|
|
||||||
|
.. option:: --hmac-algo, -H
|
||||||
|
|
||||||
|
Specify a non-default HMAC algorithm. For a list of supported algorithms, execute 'openssl list-message-digest-algorithms'
|
||||||
|
|
||||||
|
.. option:: --compress, -z
|
||||||
|
|
||||||
|
Compress the backup
|
||||||
|
|
||||||
|
.. option:: --compress-filter, -Z
|
||||||
|
|
||||||
|
Specify a non-default compression filter program (default: gzip)
|
||||||
|
|
||||||
|
.. option:: --tmpdir
|
||||||
|
|
||||||
|
Specify a temporary directory (if you have at least 1GB free RAM in dom0, use of /tmp is advised) (default: /var/tmp)
|
||||||
|
|
||||||
|
Arguments
|
||||||
|
---------
|
||||||
|
|
||||||
|
The first positional parameter is the backup location (directory path, or
|
||||||
|
command to pipe backup to). After that you may specify the qubes you'd like to
|
||||||
|
backup. If not specified, all qubes with `include_in_backups` property set are
|
||||||
|
included.
|
||||||
|
|
||||||
Authors
|
Authors
|
||||||
=======
|
-------
|
||||||
|
|
||||||
| Joanna Rutkowska <joanna at invisiblethingslab dot com>
|
| Joanna Rutkowska <joanna at invisiblethingslab dot com>
|
||||||
| Rafal Wojtczuk <rafal at invisiblethingslab dot com>
|
| Rafal Wojtczuk <rafal at invisiblethingslab dot com>
|
||||||
| Marek Marczykowski <marmarek at invisiblethingslab dot com>
|
| Marek Marczykowski <marmarek at invisiblethingslab dot com>
|
||||||
|
| Wojtek Porczyk <woju at invisiblethingslab dot com>
|
||||||
|
|
||||||
|
.. vim: ts=3 sw=3 et tw=80
|
||||||
|
634
qubes/backup.py
634
qubes/backup.py
@ -26,6 +26,7 @@ import itertools
|
|||||||
import logging
|
import logging
|
||||||
from qubes.utils import size_to_human
|
from qubes.utils import size_to_human
|
||||||
import sys
|
import sys
|
||||||
|
import stat
|
||||||
import os
|
import os
|
||||||
import fcntl
|
import fcntl
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -214,7 +215,7 @@ class SendWorker(Process):
|
|||||||
|
|
||||||
class Backup(object):
|
class Backup(object):
|
||||||
class FileToBackup(object):
|
class FileToBackup(object):
|
||||||
def __init__(self, file_path, subdir=None):
|
def __init__(self, file_path, subdir=None, name=None):
|
||||||
sz = qubes.storage.file.get_disk_usage(file_path)
|
sz = qubes.storage.file.get_disk_usage(file_path)
|
||||||
|
|
||||||
if subdir is None:
|
if subdir is None:
|
||||||
@ -229,9 +230,16 @@ class Backup(object):
|
|||||||
if len(subdir) > 0 and not subdir.endswith('/'):
|
if len(subdir) > 0 and not subdir.endswith('/'):
|
||||||
subdir += '/'
|
subdir += '/'
|
||||||
|
|
||||||
|
#: real path to the file
|
||||||
self.path = file_path
|
self.path = file_path
|
||||||
|
#: size of the file
|
||||||
self.size = sz
|
self.size = sz
|
||||||
|
#: directory in backup archive where file should be placed
|
||||||
self.subdir = subdir
|
self.subdir = subdir
|
||||||
|
#: use this name in the archive (aka rename)
|
||||||
|
self.name = os.path.basename(file_path)
|
||||||
|
if name is not None:
|
||||||
|
self.name = name
|
||||||
|
|
||||||
class VMToBackup(object):
|
class VMToBackup(object):
|
||||||
def __init__(self, vm, files, subdir):
|
def __init__(self, vm, files, subdir):
|
||||||
@ -340,11 +348,10 @@ class Backup(object):
|
|||||||
subdir = None
|
subdir = None
|
||||||
|
|
||||||
vm_files = []
|
vm_files = []
|
||||||
# TODO this is file pool specific. Change it to a more general
|
|
||||||
# solution
|
|
||||||
if vm.volumes['private'] is not None:
|
if vm.volumes['private'] is not None:
|
||||||
path_to_private_img = vm.volumes['private'].path
|
path_to_private_img = vm.storage.export('private')
|
||||||
vm_files.append(self.FileToBackup(path_to_private_img, subdir))
|
vm_files.append(self.FileToBackup(path_to_private_img, subdir,
|
||||||
|
'private.img'))
|
||||||
|
|
||||||
vm_files.append(self.FileToBackup(vm.icon_path, subdir))
|
vm_files.append(self.FileToBackup(vm.icon_path, subdir))
|
||||||
vm_files.extend(self.FileToBackup(i, subdir)
|
vm_files.extend(self.FileToBackup(i, subdir)
|
||||||
@ -356,10 +363,9 @@ class Backup(object):
|
|||||||
vm_files.append(self.FileToBackup(firewall_conf, subdir))
|
vm_files.append(self.FileToBackup(firewall_conf, subdir))
|
||||||
|
|
||||||
if vm.updateable:
|
if vm.updateable:
|
||||||
# TODO this is file pool specific. Change it to a more general
|
path_to_root_img = vm.storage.export('root')
|
||||||
# solution
|
vm_files.append(self.FileToBackup(path_to_root_img, subdir,
|
||||||
path_to_root_img = vm.volumes['root'].path
|
'root.img'))
|
||||||
vm_files.append(self.FileToBackup(path_to_root_img, subdir))
|
|
||||||
files_to_backup[vm.qid] = self.VMToBackup(vm, vm_files, subdir)
|
files_to_backup[vm.qid] = self.VMToBackup(vm, vm_files, subdir)
|
||||||
|
|
||||||
# Dom0 user home
|
# Dom0 user home
|
||||||
@ -592,7 +598,7 @@ class Backup(object):
|
|||||||
|
|
||||||
backup_tempfile = os.path.join(
|
backup_tempfile = os.path.join(
|
||||||
self.tmpdir, file_info.subdir,
|
self.tmpdir, file_info.subdir,
|
||||||
os.path.basename(file_info.path))
|
file_info.name)
|
||||||
self.log.debug("Using temporary location: {}".format(
|
self.log.debug("Using temporary location: {}".format(
|
||||||
backup_tempfile))
|
backup_tempfile))
|
||||||
|
|
||||||
@ -609,13 +615,27 @@ class Backup(object):
|
|||||||
'-C', os.path.dirname(file_info.path)] +
|
'-C', os.path.dirname(file_info.path)] +
|
||||||
(['--dereference'] if
|
(['--dereference'] if
|
||||||
file_info.subdir != "dom0-home/" else []) +
|
file_info.subdir != "dom0-home/" else []) +
|
||||||
['--xform', 's:^%s:%s\\0:' % (
|
['--xform=s:^%s:%s\\0:' % (
|
||||||
os.path.basename(file_info.path),
|
os.path.basename(file_info.path),
|
||||||
file_info.subdir),
|
file_info.subdir),
|
||||||
os.path.basename(file_info.path)
|
os.path.basename(file_info.path)
|
||||||
])
|
])
|
||||||
|
file_stat = os.stat(file_info.path)
|
||||||
|
if stat.S_ISBLK(file_stat.st_mode) or \
|
||||||
|
file_info.name != os.path.basename(file_info.path):
|
||||||
|
# tar doesn't handle content of block device, use our
|
||||||
|
# writer
|
||||||
|
# also use our tar writer when renaming file
|
||||||
|
assert not stat.S_ISDIR(file_stat.st_mode),\
|
||||||
|
"Renaming directories not supported"
|
||||||
|
tar_cmdline = ['python', '-m', 'qubes.tarwriter',
|
||||||
|
'--override-name=%s' % (
|
||||||
|
os.path.join(file_info.subdir, os.path.basename(
|
||||||
|
file_info.name))),
|
||||||
|
file_info.path,
|
||||||
|
backup_pipe]
|
||||||
if self.compressed:
|
if self.compressed:
|
||||||
tar_cmdline.insert(-1,
|
tar_cmdline.insert(-2,
|
||||||
"--use-compress-program=%s" % self.compression_filter)
|
"--use-compress-program=%s" % self.compression_filter)
|
||||||
|
|
||||||
self.log.debug(" ".join(tar_cmdline))
|
self.log.debug(" ".join(tar_cmdline))
|
||||||
@ -859,25 +879,48 @@ class ExtractWorker2(Process):
|
|||||||
def __init__(self, queue, base_dir, passphrase, encrypted,
|
def __init__(self, queue, base_dir, passphrase, encrypted,
|
||||||
progress_callback, vmproc=None,
|
progress_callback, vmproc=None,
|
||||||
compressed=False, crypto_algorithm=DEFAULT_CRYPTO_ALGORITHM,
|
compressed=False, crypto_algorithm=DEFAULT_CRYPTO_ALGORITHM,
|
||||||
verify_only=False):
|
verify_only=False, relocate=None):
|
||||||
super(ExtractWorker2, self).__init__()
|
super(ExtractWorker2, self).__init__()
|
||||||
|
#: queue with files to extract
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
|
#: paths on the queue are relative to this dir
|
||||||
self.base_dir = base_dir
|
self.base_dir = base_dir
|
||||||
|
#: passphrase to decrypt/authenticate data
|
||||||
self.passphrase = passphrase
|
self.passphrase = passphrase
|
||||||
|
#: extract those files/directories to alternative locations (truncate,
|
||||||
|
# but not unlink target beforehand); if specific file is in the map,
|
||||||
|
# redirect it accordingly, otherwise check if the whole directory is
|
||||||
|
# there
|
||||||
|
self.relocate = relocate
|
||||||
|
#: is the backup encrypted?
|
||||||
self.encrypted = encrypted
|
self.encrypted = encrypted
|
||||||
|
#: is the backup compressed?
|
||||||
self.compressed = compressed
|
self.compressed = compressed
|
||||||
|
#: what crypto algorithm is used for encryption?
|
||||||
self.crypto_algorithm = crypto_algorithm
|
self.crypto_algorithm = crypto_algorithm
|
||||||
|
#: only verify integrity, don't extract anything
|
||||||
self.verify_only = verify_only
|
self.verify_only = verify_only
|
||||||
|
#: progress
|
||||||
self.blocks_backedup = 0
|
self.blocks_backedup = 0
|
||||||
|
#: inner tar layer extraction (subprocess.Popen instance)
|
||||||
self.tar2_process = None
|
self.tar2_process = None
|
||||||
|
#: current inner tar archive name
|
||||||
self.tar2_current_file = None
|
self.tar2_current_file = None
|
||||||
|
#: set size of this file when tar report it on stderr (adjust LVM
|
||||||
|
# volume size)
|
||||||
|
self.adjust_output_size = None
|
||||||
|
#: decompressor subprocess.Popen instance
|
||||||
self.decompressor_process = None
|
self.decompressor_process = None
|
||||||
|
#: decryptor subprocess.Popen instance
|
||||||
self.decryptor_process = None
|
self.decryptor_process = None
|
||||||
|
#: callback reporting progress to UI
|
||||||
self.progress_callback = progress_callback
|
self.progress_callback = progress_callback
|
||||||
|
#: process (subprocess.Popen instance) feeding the data into
|
||||||
|
# extraction tool
|
||||||
self.vmproc = vmproc
|
self.vmproc = vmproc
|
||||||
|
|
||||||
|
#: pipe to feed the data into tar (use pipe instead of stdin,
|
||||||
|
# as stdin is used for tar control commands)
|
||||||
self.restore_pipe = os.path.join(self.base_dir, "restore_pipe")
|
self.restore_pipe = os.path.join(self.base_dir, "restore_pipe")
|
||||||
|
|
||||||
self.log = logging.getLogger('qubes.backup.extract')
|
self.log = logging.getLogger('qubes.backup.extract')
|
||||||
@ -908,9 +951,33 @@ class ExtractWorker2(Process):
|
|||||||
debug_msg = filter(msg_re.match, new_lines)
|
debug_msg = filter(msg_re.match, new_lines)
|
||||||
self.log.debug('tar2_stderr: {}'.format('\n'.join(debug_msg)))
|
self.log.debug('tar2_stderr: {}'.format('\n'.join(debug_msg)))
|
||||||
new_lines = filter(lambda x: not msg_re.match(x), new_lines)
|
new_lines = filter(lambda x: not msg_re.match(x), new_lines)
|
||||||
|
if self.adjust_output_size:
|
||||||
|
# search for first file size reported by tar, after setting
|
||||||
|
# self.adjust_output_size (so don't look at self.tar2_stderr)
|
||||||
|
# this is used only when extracting single-file archive, so don't
|
||||||
|
# bother with checking file name
|
||||||
|
file_size_re = re.compile(r"^[^ ]+ [^ ]+/[^ ]+ *([0-9]+) .*")
|
||||||
|
for line in new_lines:
|
||||||
|
match = file_size_re.match(line)
|
||||||
|
if match:
|
||||||
|
file_size = match.groups()[0]
|
||||||
|
self.resize_lvm(self.adjust_output_size, file_size)
|
||||||
|
self.adjust_output_size = None
|
||||||
self.tar2_stderr += new_lines
|
self.tar2_stderr += new_lines
|
||||||
|
|
||||||
|
def resize_lvm(self, dev, size):
|
||||||
|
# FIXME: HACK
|
||||||
|
try:
|
||||||
|
subprocess.check_call(
|
||||||
|
['sudo', 'lvresize', '-f', '-L', str(size) + 'B', dev],
|
||||||
|
stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
if e.returncode == 3:
|
||||||
|
# already at the right size
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
self.__run__()
|
self.__run__()
|
||||||
@ -929,6 +996,63 @@ class ExtractWorker2(Process):
|
|||||||
self.log.error("ERROR: " + unicode(e))
|
self.log.error("ERROR: " + unicode(e))
|
||||||
raise e, None, exc_traceback
|
raise e, None, exc_traceback
|
||||||
|
|
||||||
|
def handle_dir_relocations(self, dirname):
|
||||||
|
''' Relocate files in given director when it's already extracted
|
||||||
|
|
||||||
|
:param dirname: directory path to handle (relative to backup root),
|
||||||
|
without trailing slash
|
||||||
|
'''
|
||||||
|
|
||||||
|
for old, new in self.relocate:
|
||||||
|
if not old.startswith(dirname + '/'):
|
||||||
|
continue
|
||||||
|
# if directory is relocated too (most likely is), the file
|
||||||
|
# is extracted there
|
||||||
|
if dirname in self.relocate:
|
||||||
|
old = old.replace(dirname, self.relocate[dirname], 1)
|
||||||
|
try:
|
||||||
|
stat_buf = os.stat(new)
|
||||||
|
if stat.S_ISBLK(stat_buf.st_mode):
|
||||||
|
# output file is block device (LVM) - adjust its
|
||||||
|
# size, otherwise it may fail
|
||||||
|
# from lack of space
|
||||||
|
self.resize_lvm(new, stat_buf.st_size)
|
||||||
|
except OSError: # ENOENT
|
||||||
|
pass
|
||||||
|
subprocess.check_call(
|
||||||
|
['dd', 'if='+old, 'of='+new, 'conv=sparse'])
|
||||||
|
os.unlink(old)
|
||||||
|
|
||||||
|
def cleanup_tar2(self, wait=True, terminate=False):
|
||||||
|
if self.tar2_process is None:
|
||||||
|
return
|
||||||
|
if terminate:
|
||||||
|
self.tar2_process.terminate()
|
||||||
|
if wait:
|
||||||
|
self.tar2_process.wait()
|
||||||
|
elif self.tar2_process.poll() is None:
|
||||||
|
return
|
||||||
|
if self.tar2_process.returncode != 0:
|
||||||
|
self.collect_tar_output()
|
||||||
|
self.log.error(
|
||||||
|
"ERROR: unable to extract files for {0}, tar "
|
||||||
|
"output:\n {1}".
|
||||||
|
format(self.tar2_current_file,
|
||||||
|
"\n ".join(self.tar2_stderr)))
|
||||||
|
else:
|
||||||
|
# Finished extracting the tar file
|
||||||
|
self.collect_tar_output()
|
||||||
|
self.tar2_process = None
|
||||||
|
# if that was whole-directory archive, handle
|
||||||
|
# relocated files now
|
||||||
|
inner_name = os.path.splitext(self.tar2_current_file)[0]\
|
||||||
|
.replace(self.base_dir + '/', '')
|
||||||
|
if os.path.basename(inner_name) == '.':
|
||||||
|
self.handle_dir_relocations(
|
||||||
|
os.path.dirname(inner_name))
|
||||||
|
self.tar2_current_file = None
|
||||||
|
self.adjust_output_size = None
|
||||||
|
|
||||||
def __run__(self):
|
def __run__(self):
|
||||||
self.log.debug("Started sending thread")
|
self.log.debug("Started sending thread")
|
||||||
self.log.debug("Moving to dir " + self.base_dir)
|
self.log.debug("Moving to dir " + self.base_dir)
|
||||||
@ -944,27 +1068,48 @@ class ExtractWorker2(Process):
|
|||||||
|
|
||||||
if filename.endswith('.000'):
|
if filename.endswith('.000'):
|
||||||
# next file
|
# next file
|
||||||
if self.tar2_process is not None:
|
self.cleanup_tar2(wait=True, terminate=False)
|
||||||
if self.tar2_process.wait() != 0:
|
|
||||||
self.collect_tar_output()
|
inner_name = filename.rstrip('.000').replace(
|
||||||
self.log.error(
|
self.base_dir + '/', '')
|
||||||
"ERROR: unable to extract files for {0}, tar "
|
redirect_stdout = None
|
||||||
"output:\n {1}".
|
if self.relocate and inner_name in self.relocate:
|
||||||
format(self.tar2_current_file,
|
# TODO: add `dd conv=sparse` when removing tar layer
|
||||||
"\n ".join(self.tar2_stderr)))
|
tar2_cmdline = ['tar',
|
||||||
else:
|
'-%sMvvOf' % ("t" if self.verify_only else "x"),
|
||||||
# Finished extracting the tar file
|
self.restore_pipe,
|
||||||
self.tar2_process = None
|
inner_name]
|
||||||
self.tar2_current_file = None
|
output_file = self.relocate[inner_name]
|
||||||
|
try:
|
||||||
|
stat_buf = os.stat(output_file)
|
||||||
|
if stat.S_ISBLK(stat_buf.st_mode):
|
||||||
|
# output file is block device (LVM) - adjust its
|
||||||
|
# size during extraction, otherwise it may fail
|
||||||
|
# from lack of space
|
||||||
|
self.adjust_output_size = output_file
|
||||||
|
except OSError: # ENOENT
|
||||||
|
pass
|
||||||
|
redirect_stdout = open(output_file, 'w')
|
||||||
|
elif self.relocate and \
|
||||||
|
os.path.dirname(inner_name) in self.relocate:
|
||||||
|
tar2_cmdline = ['tar',
|
||||||
|
'-%sMf' % ("t" if self.verify_only else "x"),
|
||||||
|
self.restore_pipe,
|
||||||
|
'-C', self.relocate[os.path.dirname(inner_name)],
|
||||||
|
# strip all directories - leave only final filename
|
||||||
|
'--strip-components', str(inner_name.count(os.sep)),
|
||||||
|
inner_name]
|
||||||
|
|
||||||
|
else:
|
||||||
|
tar2_cmdline = ['tar',
|
||||||
|
'-%sMkf' % ("t" if self.verify_only else "x"),
|
||||||
|
self.restore_pipe,
|
||||||
|
inner_name]
|
||||||
|
|
||||||
tar2_cmdline = ['tar',
|
|
||||||
'-%sMkvf' % ("t" if self.verify_only else "x"),
|
|
||||||
self.restore_pipe,
|
|
||||||
os.path.relpath(filename.rstrip('.000'))]
|
|
||||||
self.log.debug("Running command " + unicode(tar2_cmdline))
|
self.log.debug("Running command " + unicode(tar2_cmdline))
|
||||||
self.tar2_process = subprocess.Popen(tar2_cmdline,
|
self.tar2_process = subprocess.Popen(tar2_cmdline,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE)
|
stdout=redirect_stdout)
|
||||||
fcntl.fcntl(self.tar2_process.stderr.fileno(), fcntl.F_SETFL,
|
fcntl.fcntl(self.tar2_process.stderr.fileno(), fcntl.F_SETFL,
|
||||||
fcntl.fcntl(self.tar2_process.stderr.fileno(),
|
fcntl.fcntl(self.tar2_process.stderr.fileno(),
|
||||||
fcntl.F_GETFL) | os.O_NONBLOCK)
|
fcntl.F_GETFL) | os.O_NONBLOCK)
|
||||||
@ -1038,11 +1183,9 @@ class ExtractWorker2(Process):
|
|||||||
details = "\n".join(self.tar2_stderr)
|
details = "\n".join(self.tar2_stderr)
|
||||||
else:
|
else:
|
||||||
details = "%s failed" % run_error
|
details = "%s failed" % run_error
|
||||||
self.tar2_process.terminate()
|
|
||||||
self.tar2_process.wait()
|
|
||||||
self.tar2_process = None
|
|
||||||
self.log.error("Error while processing '{}': {}".format(
|
self.log.error("Error while processing '{}': {}".format(
|
||||||
self.tar2_current_file, details))
|
self.tar2_current_file, details))
|
||||||
|
self.cleanup_tar2(wait=True, terminate=True)
|
||||||
|
|
||||||
# Delete the file as we don't need it anymore
|
# Delete the file as we don't need it anymore
|
||||||
self.log.debug("Removing file " + filename)
|
self.log.debug("Removing file " + filename)
|
||||||
@ -1050,23 +1193,7 @@ class ExtractWorker2(Process):
|
|||||||
|
|
||||||
os.unlink(self.restore_pipe)
|
os.unlink(self.restore_pipe)
|
||||||
|
|
||||||
if self.tar2_process is not None:
|
self.cleanup_tar2(wait=True, terminate=(filename == QUEUE_ERROR))
|
||||||
if filename == QUEUE_ERROR:
|
|
||||||
self.tar2_process.terminate()
|
|
||||||
self.tar2_process.wait()
|
|
||||||
elif self.tar2_process.wait() != 0:
|
|
||||||
self.collect_tar_output()
|
|
||||||
raise qubes.exc.QubesException(
|
|
||||||
"unable to extract files for {0}.{1} Tar command "
|
|
||||||
"output: %s".
|
|
||||||
format(self.tar2_current_file,
|
|
||||||
(" Perhaps the backup is encrypted?"
|
|
||||||
if not self.encrypted else "",
|
|
||||||
"\n".join(self.tar2_stderr))))
|
|
||||||
else:
|
|
||||||
# Finished extracting the tar file
|
|
||||||
self.tar2_process = None
|
|
||||||
|
|
||||||
self.log.debug("Finished extracting thread")
|
self.log.debug("Finished extracting thread")
|
||||||
|
|
||||||
|
|
||||||
@ -1074,12 +1201,12 @@ class ExtractWorker3(ExtractWorker2):
|
|||||||
def __init__(self, queue, base_dir, passphrase, encrypted,
|
def __init__(self, queue, base_dir, passphrase, encrypted,
|
||||||
progress_callback, vmproc=None,
|
progress_callback, vmproc=None,
|
||||||
compressed=False, crypto_algorithm=DEFAULT_CRYPTO_ALGORITHM,
|
compressed=False, crypto_algorithm=DEFAULT_CRYPTO_ALGORITHM,
|
||||||
compression_filter=None, verify_only=False):
|
compression_filter=None, verify_only=False, relocate=None):
|
||||||
super(ExtractWorker3, self).__init__(queue, base_dir, passphrase,
|
super(ExtractWorker3, self).__init__(queue, base_dir, passphrase,
|
||||||
encrypted,
|
encrypted,
|
||||||
progress_callback, vmproc,
|
progress_callback, vmproc,
|
||||||
compressed, crypto_algorithm,
|
compressed, crypto_algorithm,
|
||||||
verify_only)
|
verify_only, relocate)
|
||||||
self.compression_filter = compression_filter
|
self.compression_filter = compression_filter
|
||||||
os.unlink(self.restore_pipe)
|
os.unlink(self.restore_pipe)
|
||||||
|
|
||||||
@ -1101,21 +1228,40 @@ class ExtractWorker3(ExtractWorker2):
|
|||||||
# next file
|
# next file
|
||||||
if self.tar2_process is not None:
|
if self.tar2_process is not None:
|
||||||
input_pipe.close()
|
input_pipe.close()
|
||||||
if self.tar2_process.wait() != 0:
|
self.cleanup_tar2(wait=True, terminate=False)
|
||||||
self.collect_tar_output()
|
|
||||||
self.log.error(
|
inner_name = filename.rstrip('.000').replace(
|
||||||
"ERROR: unable to extract files for {0}, tar "
|
self.base_dir + '/', '')
|
||||||
"output:\n {1}".
|
redirect_stdout = None
|
||||||
format(self.tar2_current_file,
|
if self.relocate and inner_name in self.relocate:
|
||||||
"\n ".join(self.tar2_stderr)))
|
# TODO: add dd conv=sparse when removing tar layer
|
||||||
else:
|
tar2_cmdline = ['tar',
|
||||||
# Finished extracting the tar file
|
'-%svvO' % ("t" if self.verify_only else "x"),
|
||||||
self.tar2_process = None
|
inner_name]
|
||||||
self.tar2_current_file = None
|
output_file = self.relocate[inner_name]
|
||||||
|
try:
|
||||||
|
stat_buf = os.stat(output_file)
|
||||||
|
if stat.S_ISBLK(stat_buf.st_mode):
|
||||||
|
# output file is block device (LVM) - adjust its
|
||||||
|
# size during extraction, otherwise it may fail
|
||||||
|
# from lack of space
|
||||||
|
self.adjust_output_size = output_file
|
||||||
|
except OSError: # ENOENT
|
||||||
|
pass
|
||||||
|
redirect_stdout = open(output_file, 'w')
|
||||||
|
elif self.relocate and \
|
||||||
|
os.path.dirname(inner_name) in self.relocate:
|
||||||
|
tar2_cmdline = ['tar',
|
||||||
|
'-%s' % ("t" if self.verify_only else "x"),
|
||||||
|
'-C', self.relocate[os.path.dirname(inner_name)],
|
||||||
|
# strip all directories - leave only final filename
|
||||||
|
'--strip-components', str(inner_name.count(os.sep)),
|
||||||
|
inner_name]
|
||||||
|
else:
|
||||||
|
tar2_cmdline = ['tar',
|
||||||
|
'-%sk' % ("t" if self.verify_only else "x"),
|
||||||
|
inner_name]
|
||||||
|
|
||||||
tar2_cmdline = ['tar',
|
|
||||||
'-%sk' % ("t" if self.verify_only else "x"),
|
|
||||||
os.path.relpath(filename.rstrip('.000'))]
|
|
||||||
if self.compressed:
|
if self.compressed:
|
||||||
if self.compression_filter:
|
if self.compression_filter:
|
||||||
tar2_cmdline.insert(-1,
|
tar2_cmdline.insert(-1,
|
||||||
@ -1140,12 +1286,14 @@ class ExtractWorker3(ExtractWorker2):
|
|||||||
self.tar2_process = subprocess.Popen(
|
self.tar2_process = subprocess.Popen(
|
||||||
tar2_cmdline,
|
tar2_cmdline,
|
||||||
stdin=self.decryptor_process.stdout,
|
stdin=self.decryptor_process.stdout,
|
||||||
|
stdout=redirect_stdout,
|
||||||
stderr=subprocess.PIPE)
|
stderr=subprocess.PIPE)
|
||||||
input_pipe = self.decryptor_process.stdin
|
input_pipe = self.decryptor_process.stdin
|
||||||
else:
|
else:
|
||||||
self.tar2_process = subprocess.Popen(
|
self.tar2_process = subprocess.Popen(
|
||||||
tar2_cmdline,
|
tar2_cmdline,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=redirect_stdout,
|
||||||
stderr=subprocess.PIPE)
|
stderr=subprocess.PIPE)
|
||||||
input_pipe = self.tar2_process.stdin
|
input_pipe = self.tar2_process.stdin
|
||||||
|
|
||||||
@ -1184,11 +1332,9 @@ class ExtractWorker3(ExtractWorker2):
|
|||||||
self.decryptor_process.terminate()
|
self.decryptor_process.terminate()
|
||||||
self.decryptor_process.wait()
|
self.decryptor_process.wait()
|
||||||
self.decryptor_process = None
|
self.decryptor_process = None
|
||||||
self.tar2_process.terminate()
|
|
||||||
self.tar2_process.wait()
|
|
||||||
self.tar2_process = None
|
|
||||||
self.log.error("Error while processing '{}': {}".format(
|
self.log.error("Error while processing '{}': {}".format(
|
||||||
self.tar2_current_file, details))
|
self.tar2_current_file, details))
|
||||||
|
self.cleanup_tar2(wait=True, terminate=True)
|
||||||
|
|
||||||
# Delete the file as we don't need it anymore
|
# Delete the file as we don't need it anymore
|
||||||
self.log.debug("Removing file " + filename)
|
self.log.debug("Removing file " + filename)
|
||||||
@ -1201,20 +1347,7 @@ class ExtractWorker3(ExtractWorker2):
|
|||||||
self.decryptor_process.terminate()
|
self.decryptor_process.terminate()
|
||||||
self.decryptor_process.wait()
|
self.decryptor_process.wait()
|
||||||
self.decryptor_process = None
|
self.decryptor_process = None
|
||||||
self.tar2_process.terminate()
|
self.cleanup_tar2(terminate=(filename == QUEUE_ERROR))
|
||||||
self.tar2_process.wait()
|
|
||||||
elif self.tar2_process.wait() != 0:
|
|
||||||
self.collect_tar_output()
|
|
||||||
raise qubes.exc.QubesException(
|
|
||||||
"unable to extract files for {0}.{1} Tar command "
|
|
||||||
"output: %s".
|
|
||||||
format(self.tar2_current_file,
|
|
||||||
(" Perhaps the backup is encrypted?"
|
|
||||||
if not self.encrypted else "",
|
|
||||||
"\n".join(self.tar2_stderr))))
|
|
||||||
else:
|
|
||||||
# Finished extracting the tar file
|
|
||||||
self.tar2_process = None
|
|
||||||
|
|
||||||
self.log.debug("Finished extracting thread")
|
self.log.debug("Finished extracting thread")
|
||||||
|
|
||||||
@ -1260,6 +1393,8 @@ class BackupRestoreOptions(object):
|
|||||||
self.rename_conflicting = True
|
self.rename_conflicting = True
|
||||||
#: list of VM names to exclude
|
#: list of VM names to exclude
|
||||||
self.exclude = []
|
self.exclude = []
|
||||||
|
#: restore VMs into selected storage pool
|
||||||
|
self.override_pool = None
|
||||||
|
|
||||||
|
|
||||||
class BackupRestore(object):
|
class BackupRestore(object):
|
||||||
@ -1304,6 +1439,7 @@ class BackupRestore(object):
|
|||||||
self.netvm = None
|
self.netvm = None
|
||||||
self.name = vm.name
|
self.name = vm.name
|
||||||
self.orig_template = None
|
self.orig_template = None
|
||||||
|
self.restored_vm = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def good_to_go(self):
|
def good_to_go(self):
|
||||||
@ -1576,7 +1712,7 @@ class BackupRestore(object):
|
|||||||
)
|
)
|
||||||
return header_data
|
return header_data
|
||||||
|
|
||||||
def _start_inner_extraction_worker(self, queue):
|
def _start_inner_extraction_worker(self, queue, relocate):
|
||||||
"""Start a worker process, extracting inner layer of bacup archive,
|
"""Start a worker process, extracting inner layer of bacup archive,
|
||||||
extract them to :py:attr:`tmpdir`.
|
extract them to :py:attr:`tmpdir`.
|
||||||
End the data by pushing QUEUE_FINISHED or QUEUE_ERROR to the queue.
|
End the data by pushing QUEUE_FINISHED or QUEUE_ERROR to the queue.
|
||||||
@ -1596,7 +1732,10 @@ class BackupRestore(object):
|
|||||||
'crypto_algorithm': self.header_data.crypto_algorithm,
|
'crypto_algorithm': self.header_data.crypto_algorithm,
|
||||||
'verify_only': self.options.verify_only,
|
'verify_only': self.options.verify_only,
|
||||||
'progress_callback': self.progress_callback,
|
'progress_callback': self.progress_callback,
|
||||||
|
'relocate': relocate,
|
||||||
}
|
}
|
||||||
|
self.log.debug('Starting extraction worker in {}, file relocation '
|
||||||
|
'map: {!r}'.format(self.tmpdir, relocate))
|
||||||
format_version = self.header_data.version
|
format_version = self.header_data.version
|
||||||
if format_version == 2:
|
if format_version == 2:
|
||||||
extract_proc = ExtractWorker2(**extractor_params)
|
extract_proc = ExtractWorker2(**extractor_params)
|
||||||
@ -1626,7 +1765,7 @@ class BackupRestore(object):
|
|||||||
queue.put("qubes.xml.000")
|
queue.put("qubes.xml.000")
|
||||||
queue.put(QUEUE_FINISHED)
|
queue.put(QUEUE_FINISHED)
|
||||||
|
|
||||||
extract_proc = self._start_inner_extraction_worker(queue)
|
extract_proc = self._start_inner_extraction_worker(queue, None)
|
||||||
extract_proc.join()
|
extract_proc.join()
|
||||||
if extract_proc.exitcode != 0:
|
if extract_proc.exitcode != 0:
|
||||||
raise qubes.exc.QubesException(
|
raise qubes.exc.QubesException(
|
||||||
@ -1643,7 +1782,7 @@ class BackupRestore(object):
|
|||||||
os.unlink(os.path.join(self.tmpdir, 'qubes.xml'))
|
os.unlink(os.path.join(self.tmpdir, 'qubes.xml'))
|
||||||
return backup_app
|
return backup_app
|
||||||
|
|
||||||
def _restore_vm_dirs(self, vms_dirs, vms_size):
|
def _restore_vm_dirs(self, vms_dirs, vms_size, relocate):
|
||||||
# Currently each VM consists of at most 7 archives (count
|
# Currently each VM consists of at most 7 archives (count
|
||||||
# file_to_backup calls in backup_prepare()), but add some safety
|
# file_to_backup calls in backup_prepare()), but add some safety
|
||||||
# margin for further extensions. Each archive is divided into 100MB
|
# margin for further extensions. Each archive is divided into 100MB
|
||||||
@ -1658,12 +1797,14 @@ class BackupRestore(object):
|
|||||||
|
|
||||||
# retrieve backup from the backup stream (either VM, or dom0 file)
|
# retrieve backup from the backup stream (either VM, or dom0 file)
|
||||||
(retrieve_proc, filelist_pipe, error_pipe) = \
|
(retrieve_proc, filelist_pipe, error_pipe) = \
|
||||||
self._start_retrieval_process(vms_dirs, limit_count, vms_size)
|
self._start_retrieval_process(
|
||||||
|
vms_dirs, limit_count, vms_size)
|
||||||
|
|
||||||
to_extract = Queue()
|
to_extract = Queue()
|
||||||
|
|
||||||
# extract data retrieved by retrieve_proc
|
# extract data retrieved by retrieve_proc
|
||||||
extract_proc = self._start_inner_extraction_worker(to_extract)
|
extract_proc = self._start_inner_extraction_worker(
|
||||||
|
to_extract, relocate)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
filename = None
|
filename = None
|
||||||
@ -1721,7 +1862,7 @@ class BackupRestore(object):
|
|||||||
|
|
||||||
if retrieve_proc.wait() != 0:
|
if retrieve_proc.wait() != 0:
|
||||||
raise qubes.exc.QubesException(
|
raise qubes.exc.QubesException(
|
||||||
"unable to read the qubes backup file {0} ({1}): {2}"
|
"unable to read the qubes backup file {0}: {1}"
|
||||||
.format(self.backup_location, error_pipe.read(
|
.format(self.backup_location, error_pipe.read(
|
||||||
MAX_STDERR_BYTES)))
|
MAX_STDERR_BYTES)))
|
||||||
# wait for other processes (if any)
|
# wait for other processes (if any)
|
||||||
@ -1959,11 +2100,10 @@ class BackupRestore(object):
|
|||||||
|
|
||||||
"updbl": {"func": "'Yes' if vm.updateable else ''"},
|
"updbl": {"func": "'Yes' if vm.updateable else ''"},
|
||||||
|
|
||||||
"template": {"func": "'n/a' if not hasattr(vm, 'template') is None "
|
"template": {"func": "'n/a' if not hasattr(vm, 'template') "
|
||||||
"else vm_info.template"},
|
"else vm_info.template"},
|
||||||
|
|
||||||
"netvm": {"func": "'n/a' if vm.provides_network else\
|
"netvm": {"func": "('*' if vm.property_is_default('netvm') else '') +\
|
||||||
('*' if vm.property_is_default('netvm') else '') +\
|
|
||||||
vm_info.netvm if vm_info.netvm is not None "
|
vm_info.netvm if vm_info.netvm is not None "
|
||||||
"else '-'"},
|
"else '-'"},
|
||||||
|
|
||||||
@ -2074,25 +2214,57 @@ class BackupRestore(object):
|
|||||||
"*** Error while copying file {0} to {1}".format(backup_src_dir,
|
"*** Error while copying file {0} to {1}".format(backup_src_dir,
|
||||||
dst_dir))
|
dst_dir))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _templates_first(vms):
|
||||||
|
def key_function(instance):
|
||||||
|
if isinstance(instance, qubes.vm.BaseVM):
|
||||||
|
return isinstance(instance, qubes.vm.templatevm.TemplateVM)
|
||||||
|
elif hasattr(instance, 'vm'):
|
||||||
|
return key_function(instance.vm)
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
return sorted(vms,
|
||||||
|
key=key_function,
|
||||||
|
reverse=True)
|
||||||
|
|
||||||
def restore_do(self, restore_info):
|
def restore_do(self, restore_info):
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
High level workflow:
|
||||||
|
1. Create VMs object in host collection (qubes.xml)
|
||||||
|
2. Create them on disk (vm.create_on_disk)
|
||||||
|
3. Restore VM data, overriding/converting VM files
|
||||||
|
4. Apply possible fixups and save qubes.xml
|
||||||
|
|
||||||
|
:param restore_info:
|
||||||
|
:return:
|
||||||
|
'''
|
||||||
|
|
||||||
# FIXME handle locking
|
# FIXME handle locking
|
||||||
|
|
||||||
|
self._restore_vms_metadata(restore_info)
|
||||||
|
|
||||||
# Perform VM restoration in backup order
|
# Perform VM restoration in backup order
|
||||||
vms_dirs = []
|
vms_dirs = []
|
||||||
|
relocate = {}
|
||||||
vms_size = 0
|
vms_size = 0
|
||||||
vms = {}
|
for vm_info in self._templates_first(restore_info.values()):
|
||||||
for vm_info in restore_info.values():
|
vm = vm_info.restored_vm
|
||||||
assert isinstance(vm_info, self.VMToRestore)
|
if vm:
|
||||||
if not vm_info.vm:
|
vms_size += int(vm_info.size)
|
||||||
continue
|
vms_dirs.append(vm_info.subdir)
|
||||||
if not vm_info.good_to_go:
|
relocate[vm_info.subdir.rstrip('/')] = vm.dir_path
|
||||||
continue
|
for name, volume in vm.volumes.items():
|
||||||
vm = vm_info.vm
|
if not volume.save_on_stop:
|
||||||
if self.header_data.version >= 2:
|
continue
|
||||||
if vm.features['backup-size']:
|
export_path = vm.storage.export(name)
|
||||||
vms_size += int(vm.features['backup-size'])
|
backup_path = os.path.join(
|
||||||
vms_dirs.append(vm.features['backup-path'])
|
vm_info.vm.dir_path, name + '.img')
|
||||||
vms[vm.name] = vm
|
if backup_path != export_path:
|
||||||
|
relocate[
|
||||||
|
os.path.join(vm_info.subdir, name + '.img')] = \
|
||||||
|
export_path
|
||||||
|
|
||||||
if self.header_data.version >= 2:
|
if self.header_data.version >= 2:
|
||||||
if 'dom0' in restore_info.keys() and \
|
if 'dom0' in restore_info.keys() and \
|
||||||
@ -2101,7 +2273,8 @@ class BackupRestore(object):
|
|||||||
vms_size += restore_info['dom0'].size
|
vms_size += restore_info['dom0'].size
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._restore_vm_dirs(vms_dirs=vms_dirs, vms_size=vms_size)
|
self._restore_vm_dirs(vms_dirs=vms_dirs, vms_size=vms_size,
|
||||||
|
relocate=relocate)
|
||||||
except qubes.exc.QubesException:
|
except qubes.exc.QubesException:
|
||||||
if self.options.verify_only:
|
if self.options.verify_only:
|
||||||
raise
|
raise
|
||||||
@ -2111,6 +2284,22 @@ class BackupRestore(object):
|
|||||||
"continuing anyway to restore at least some "
|
"continuing anyway to restore at least some "
|
||||||
"VMs")
|
"VMs")
|
||||||
else:
|
else:
|
||||||
|
for vm_info in self._templates_first(restore_info.values()):
|
||||||
|
vm = vm_info.restored_vm
|
||||||
|
if vm:
|
||||||
|
try:
|
||||||
|
self._restore_vm_dir_v1(vm_info.vm.dir_path,
|
||||||
|
os.path.dirname(vm.dir_path))
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
if self.options.verify_only:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.log.error(
|
||||||
|
"Failed to restore VM '{}': {}".format(
|
||||||
|
vm.name, str(e)))
|
||||||
|
vm.remove_from_disk()
|
||||||
|
del self.app.domains[vm]
|
||||||
|
|
||||||
if self.options.verify_only:
|
if self.options.verify_only:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Backup verification not supported for this backup format.")
|
"Backup verification not supported for this backup format.")
|
||||||
@ -2119,117 +2308,24 @@ class BackupRestore(object):
|
|||||||
shutil.rmtree(self.tmpdir)
|
shutil.rmtree(self.tmpdir)
|
||||||
return
|
return
|
||||||
|
|
||||||
# First load templates, then other VMs
|
for vm_info in self._templates_first(restore_info.values()):
|
||||||
for vm in sorted(vms.values(),
|
if not vm_info.restored_vm:
|
||||||
key=lambda x: isinstance(x, qubes.vm.templatevm.TemplateVM),
|
|
||||||
reverse=True):
|
|
||||||
if self.canceled:
|
|
||||||
# only break the loop to save qubes.xml
|
|
||||||
# with already restored VMs
|
|
||||||
break
|
|
||||||
self.log.info("-> Restoring {0}...".format(vm.name))
|
|
||||||
retcode = subprocess.call(
|
|
||||||
["mkdir", "-p", os.path.dirname(vm.dir_path)])
|
|
||||||
if retcode != 0:
|
|
||||||
self.log.error("*** Cannot create directory: {0}?!".format(
|
|
||||||
vm.dir_path))
|
|
||||||
self.log.warning("Skipping VM {}...".format(vm.name))
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
kwargs = {}
|
|
||||||
if hasattr(vm, 'template'):
|
|
||||||
template = restore_info[vm.name].template
|
|
||||||
# handle potentially renamed template
|
|
||||||
if template in restore_info \
|
|
||||||
and restore_info[template].good_to_go:
|
|
||||||
template = restore_info[template].name
|
|
||||||
kwargs['template'] = template
|
|
||||||
|
|
||||||
new_vm = None
|
|
||||||
vm_name = restore_info[vm.name].name
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# first only minimal set, later clone_properties
|
vm_info.restored_vm.fire_event('domain-restore')
|
||||||
# will be called
|
|
||||||
new_vm = self.app.add_new_vm(
|
|
||||||
vm.__class__,
|
|
||||||
name=vm_name,
|
|
||||||
label=vm.label,
|
|
||||||
installed_by_rpm=False,
|
|
||||||
**kwargs)
|
|
||||||
if os.path.exists(new_vm.dir_path):
|
|
||||||
move_to_path = tempfile.mkdtemp('', os.path.basename(
|
|
||||||
new_vm.dir_path), os.path.dirname(new_vm.dir_path))
|
|
||||||
try:
|
|
||||||
os.rename(new_vm.dir_path, move_to_path)
|
|
||||||
self.log.warning(
|
|
||||||
"*** Directory {} already exists! It has "
|
|
||||||
"been moved to {}".format(new_vm.dir_path,
|
|
||||||
move_to_path))
|
|
||||||
except OSError:
|
|
||||||
self.log.error(
|
|
||||||
"*** Directory {} already exists and "
|
|
||||||
"cannot be moved!".format(new_vm.dir_path))
|
|
||||||
self.log.warning("Skipping VM {}...".format(
|
|
||||||
vm.name))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if self.header_data.version == 1:
|
|
||||||
self._restore_vm_dir_v1(vm.dir_path,
|
|
||||||
os.path.dirname(new_vm.dir_path))
|
|
||||||
else:
|
|
||||||
shutil.move(os.path.join(self.tmpdir,
|
|
||||||
vm.features['backup-path']),
|
|
||||||
new_vm.dir_path)
|
|
||||||
|
|
||||||
new_vm.storage.verify()
|
|
||||||
except Exception as err:
|
|
||||||
self.log.error("ERROR: {0}".format(err))
|
|
||||||
self.log.warning("*** Skipping VM: {0}".format(vm.name))
|
|
||||||
if new_vm:
|
|
||||||
del self.app.domains[new_vm.qid]
|
|
||||||
continue
|
|
||||||
|
|
||||||
# remove no longer needed backup metadata
|
|
||||||
if 'backup-content' in vm.features:
|
|
||||||
del vm.features['backup-content']
|
|
||||||
del vm.features['backup-size']
|
|
||||||
del vm.features['backup-path']
|
|
||||||
try:
|
|
||||||
# exclude VM references - handled manually according to
|
|
||||||
# restore options
|
|
||||||
proplist = [prop for prop in new_vm.property_list()
|
|
||||||
if prop.clone and prop.__name__ not in
|
|
||||||
['template', 'netvm', 'dispvm_netvm']]
|
|
||||||
new_vm.clone_properties(vm, proplist=proplist)
|
|
||||||
except Exception as err:
|
|
||||||
self.log.error("ERROR: {0}".format(err))
|
|
||||||
self.log.warning("*** Some VM property will not be "
|
|
||||||
"restored")
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_vm.fire_event('domain-restore')
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
self.log.error("ERROR during appmenu restore: "
|
self.log.error("ERROR during appmenu restore: "
|
||||||
"{0}".format(err))
|
"{0}".format(err))
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"*** VM '{0}' will not have appmenus".format(vm.name))
|
"*** VM '{0}' will not have appmenus".format(vm_info.name))
|
||||||
|
|
||||||
# Set network dependencies - only non-default netvm setting
|
|
||||||
for vm in vms.values():
|
|
||||||
vm_info = restore_info[vm.name]
|
|
||||||
vm_name = vm_info.name
|
|
||||||
try:
|
try:
|
||||||
host_vm = self.app.domains[vm_name]
|
vm_info.restored_vm.storage.verify()
|
||||||
except KeyError:
|
except Exception as err:
|
||||||
# Failed/skipped VM
|
self.log.error("ERROR: {0}".format(err))
|
||||||
continue
|
if vm_info.restored_vm:
|
||||||
|
vm_info.restored_vm.remove_from_disk()
|
||||||
if not vm.property_is_default('netvm'):
|
del self.app.domains[vm_info.restored_vm]
|
||||||
if vm_info.netvm in restore_info:
|
|
||||||
host_vm.netvm = restore_info[vm_info.netvm].name
|
|
||||||
else:
|
|
||||||
host_vm.netvm = vm_info.netvm
|
|
||||||
|
|
||||||
self.app.save()
|
self.app.save()
|
||||||
|
|
||||||
@ -2279,4 +2375,114 @@ class BackupRestore(object):
|
|||||||
self.log.info("-> Done. Please install updates for all the restored "
|
self.log.info("-> Done. Please install updates for all the restored "
|
||||||
"templates.")
|
"templates.")
|
||||||
|
|
||||||
|
def _restore_vms_metadata(self, restore_info):
|
||||||
|
vms = {}
|
||||||
|
for vm_info in restore_info.values():
|
||||||
|
assert isinstance(vm_info, self.VMToRestore)
|
||||||
|
if not vm_info.vm:
|
||||||
|
continue
|
||||||
|
if not vm_info.good_to_go:
|
||||||
|
continue
|
||||||
|
vm = vm_info.vm
|
||||||
|
vms[vm.name] = vm
|
||||||
|
|
||||||
|
# First load templates, then other VMs
|
||||||
|
for vm in self._templates_first(vms.values()):
|
||||||
|
if self.canceled:
|
||||||
|
# only break the loop to save qubes.xml
|
||||||
|
# with already restored VMs
|
||||||
|
break
|
||||||
|
self.log.info("-> Restoring {0}...".format(vm.name))
|
||||||
|
kwargs = {}
|
||||||
|
if hasattr(vm, 'template'):
|
||||||
|
template = restore_info[vm.name].template
|
||||||
|
# handle potentially renamed template
|
||||||
|
if template in restore_info \
|
||||||
|
and restore_info[template].good_to_go:
|
||||||
|
template = restore_info[template].name
|
||||||
|
kwargs['template'] = template
|
||||||
|
|
||||||
|
new_vm = None
|
||||||
|
vm_name = restore_info[vm.name].name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# first only minimal set, later clone_properties
|
||||||
|
# will be called
|
||||||
|
cls = self.app.get_vm_class(vm.__class__.__name__)
|
||||||
|
new_vm = self.app.add_new_vm(
|
||||||
|
cls,
|
||||||
|
name=vm_name,
|
||||||
|
label=vm.label,
|
||||||
|
installed_by_rpm=False,
|
||||||
|
**kwargs)
|
||||||
|
if os.path.exists(new_vm.dir_path):
|
||||||
|
move_to_path = tempfile.mkdtemp('', os.path.basename(
|
||||||
|
new_vm.dir_path), os.path.dirname(new_vm.dir_path))
|
||||||
|
try:
|
||||||
|
os.rename(new_vm.dir_path, move_to_path)
|
||||||
|
self.log.warning(
|
||||||
|
"*** Directory {} already exists! It has "
|
||||||
|
"been moved to {}".format(new_vm.dir_path,
|
||||||
|
move_to_path))
|
||||||
|
except OSError:
|
||||||
|
self.log.error(
|
||||||
|
"*** Directory {} already exists and "
|
||||||
|
"cannot be moved!".format(new_vm.dir_path))
|
||||||
|
self.log.warning("Skipping VM {}...".format(
|
||||||
|
vm.name))
|
||||||
|
continue
|
||||||
|
except Exception as err:
|
||||||
|
self.log.error("ERROR: {0}".format(err))
|
||||||
|
self.log.warning("*** Skipping VM: {0}".format(vm.name))
|
||||||
|
if new_vm:
|
||||||
|
del self.app.domains[new_vm.qid]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# remove no longer needed backup metadata
|
||||||
|
if 'backup-content' in vm.features:
|
||||||
|
del vm.features['backup-content']
|
||||||
|
del vm.features['backup-size']
|
||||||
|
del vm.features['backup-path']
|
||||||
|
try:
|
||||||
|
# exclude VM references - handled manually according to
|
||||||
|
# restore options
|
||||||
|
proplist = [prop for prop in new_vm.property_list()
|
||||||
|
if prop.clone and prop.__name__ not in
|
||||||
|
['template', 'netvm', 'dispvm_netvm']]
|
||||||
|
new_vm.clone_properties(vm, proplist=proplist)
|
||||||
|
except Exception as err:
|
||||||
|
self.log.error("ERROR: {0}".format(err))
|
||||||
|
self.log.warning("*** Some VM property will not be "
|
||||||
|
"restored")
|
||||||
|
|
||||||
|
if not self.options.verify_only:
|
||||||
|
try:
|
||||||
|
# have it here, to (maybe) patch storage config before
|
||||||
|
# creating child VMs (template first)
|
||||||
|
# TODO: adjust volumes config - especially size
|
||||||
|
new_vm.create_on_disk(pool=self.options.override_pool)
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
self.log.warning("Failed to create VM {}: {}".format(
|
||||||
|
vm.name, str(e)))
|
||||||
|
del self.app.domains[new_vm]
|
||||||
|
continue
|
||||||
|
|
||||||
|
restore_info[vm.name].restored_vm = new_vm
|
||||||
|
|
||||||
|
# Set network dependencies - only non-default netvm setting
|
||||||
|
for vm in vms.values():
|
||||||
|
vm_info = restore_info[vm.name]
|
||||||
|
vm_name = vm_info.name
|
||||||
|
try:
|
||||||
|
host_vm = self.app.domains[vm_name]
|
||||||
|
except KeyError:
|
||||||
|
# Failed/skipped VM
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not vm.property_is_default('netvm'):
|
||||||
|
if vm_info.netvm in restore_info:
|
||||||
|
host_vm.netvm = restore_info[vm_info.netvm].name
|
||||||
|
else:
|
||||||
|
host_vm.netvm = vm_info.netvm
|
||||||
|
|
||||||
# vim:sw=4:et:
|
# vim:sw=4:et:
|
||||||
|
@ -181,6 +181,10 @@ class Core2Qubes(qubes.Qubes):
|
|||||||
"true":
|
"true":
|
||||||
kwargs[attr] = value
|
kwargs[attr] = value
|
||||||
kwargs['hvm'] = "HVm" in vm_class_name
|
kwargs['hvm'] = "HVm" in vm_class_name
|
||||||
|
kwargs['provides_network'] = \
|
||||||
|
vm_class_name in ('QubesNetVm', 'QubesProxyVm')
|
||||||
|
if vm_class_name == 'QubesNetVm':
|
||||||
|
kwargs['netvm'] = None
|
||||||
vm = self.add_new_vm(vm_class,
|
vm = self.add_new_vm(vm_class,
|
||||||
qid=int(element.get('qid')), **kwargs)
|
qid=int(element.get('qid')), **kwargs)
|
||||||
services = element.get('services')
|
services = element.get('services')
|
||||||
|
@ -463,6 +463,15 @@ class Storage(object):
|
|||||||
for target in parsed_xml.xpath(
|
for target in parsed_xml.xpath(
|
||||||
"//domain/devices/disk/target")])
|
"//domain/devices/disk/target")])
|
||||||
|
|
||||||
|
def export(self, volume):
|
||||||
|
''' Helper function to export volume (pool.export(volume))'''
|
||||||
|
assert isinstance(volume, (Volume, basestring)), \
|
||||||
|
"You need to pass a Volume or pool name as str"
|
||||||
|
if isinstance(volume, Volume):
|
||||||
|
return self.pools[volume.name].export(volume)
|
||||||
|
else:
|
||||||
|
return self.pools[volume].export(self.vm.volumes[volume])
|
||||||
|
|
||||||
|
|
||||||
class Pool(object):
|
class Pool(object):
|
||||||
''' A Pool is used to manage different kind of volumes (File
|
''' A Pool is used to manage different kind of volumes (File
|
||||||
|
@ -100,7 +100,12 @@ class ThinPool(qubes.storage.Pool):
|
|||||||
|
|
||||||
def export(self, volume):
|
def export(self, volume):
|
||||||
''' Returns an object that can be `open()`. '''
|
''' Returns an object that can be `open()`. '''
|
||||||
return '/dev/' + volume.vid
|
devpath = '/dev/' + volume.vid
|
||||||
|
if not os.access(devpath, os.R_OK):
|
||||||
|
# FIXME: convert to udev rules, and drop after introducing qubesd
|
||||||
|
subprocess.check_call(['sudo', 'chgrp', 'qubes', devpath])
|
||||||
|
subprocess.check_call(['sudo', 'chmod', 'g+rw', devpath])
|
||||||
|
return devpath
|
||||||
|
|
||||||
def init_volume(self, vm, volume_config):
|
def init_volume(self, vm, volume_config):
|
||||||
''' Initialize a :py:class:`qubes.storage.Volume` from `volume_config`.
|
''' Initialize a :py:class:`qubes.storage.Volume` from `volume_config`.
|
||||||
|
206
qubes/tarwriter.py
Normal file
206
qubes/tarwriter.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/python2
|
||||||
|
# -*- encoding: utf8 -*-
|
||||||
|
#
|
||||||
|
# The Qubes OS Project, http://www.qubes-os.org
|
||||||
|
#
|
||||||
|
# Copyright (C) 2016 Marek Marczykowski-Górecki
|
||||||
|
# <marmarek@invisiblethingslab.com>
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along
|
||||||
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
import argparse
|
||||||
|
import functools
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import io
|
||||||
|
|
||||||
|
BUF_SIZE = 409600
|
||||||
|
|
||||||
|
|
||||||
|
class TarSparseInfo(tarfile.TarInfo):
|
||||||
|
def __init__(self, name="", sparsemap=None):
|
||||||
|
super(TarSparseInfo, self).__init__(name)
|
||||||
|
if sparsemap is not None:
|
||||||
|
self.type = tarfile.GNUTYPE_SPARSE
|
||||||
|
self.sparsemap = list(sparsemap)
|
||||||
|
# compact size
|
||||||
|
self.size = functools.reduce(lambda x, y: x+y[1], sparsemap, 0)
|
||||||
|
else:
|
||||||
|
self.sparsemap = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def realsize(self):
|
||||||
|
if len(self.sparsemap):
|
||||||
|
return self.sparsemap[-1][0] + self.sparsemap[-1][1]
|
||||||
|
else:
|
||||||
|
return self.size
|
||||||
|
|
||||||
|
def sparse_header_chunk(self, index):
|
||||||
|
if index < len(self.sparsemap):
|
||||||
|
return ''.join([
|
||||||
|
tarfile.itn(self.sparsemap[index][0], 12, tarfile.GNU_FORMAT),
|
||||||
|
tarfile.itn(self.sparsemap[index][1], 12, tarfile.GNU_FORMAT),
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
return '\0' * 12 * 2
|
||||||
|
|
||||||
|
def get_gnu_header(self):
|
||||||
|
'''Part placed in 'prefix' field of posix header'''
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
tarfile.itn(self.mtime, 12, tarfile.GNU_FORMAT), # atime
|
||||||
|
tarfile.itn(self.mtime, 12, tarfile.GNU_FORMAT), # ctime
|
||||||
|
tarfile.itn(0, 12, tarfile.GNU_FORMAT), # offset
|
||||||
|
tarfile.stn('', 4), # longnames
|
||||||
|
'\0', # unused_pad2
|
||||||
|
]
|
||||||
|
parts += [self.sparse_header_chunk(i) for i in range(4)]
|
||||||
|
parts += [
|
||||||
|
'\1' if len(self.sparsemap) > 4 else '\0', # isextended
|
||||||
|
tarfile.itn(self.realsize, 12, tarfile.GNU_FORMAT), # realsize
|
||||||
|
]
|
||||||
|
return ''.join(parts)
|
||||||
|
|
||||||
|
def get_info(self, encoding, errors):
|
||||||
|
info = super(TarSparseInfo, self).get_info(encoding, errors)
|
||||||
|
# place GNU extension into
|
||||||
|
info['prefix'] = self.get_gnu_header()
|
||||||
|
return info
|
||||||
|
|
||||||
|
def tobuf(self, format=tarfile.DEFAULT_FORMAT, encoding=tarfile.ENCODING,
|
||||||
|
errors="strict"):
|
||||||
|
# pylint: disable=redefined-builtin
|
||||||
|
header_buf = super(TarSparseInfo, self).tobuf(format, encoding, errors)
|
||||||
|
if len(self.sparsemap) > 4:
|
||||||
|
return header_buf + ''.join(self.create_ext_sparse_headers())
|
||||||
|
else:
|
||||||
|
return header_buf
|
||||||
|
|
||||||
|
def create_ext_sparse_headers(self):
|
||||||
|
for ext_hdr in range(4, len(self.sparsemap), 21):
|
||||||
|
sparse_parts = [self.sparse_header_chunk(i) for i in
|
||||||
|
range(ext_hdr, ext_hdr+21)]
|
||||||
|
sparse_parts += '\1' if ext_hdr+21 < len(self.sparsemap) else '\0'
|
||||||
|
yield tarfile.stn(''.join(sparse_parts), 512)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sparse_map(input_file):
|
||||||
|
'''
|
||||||
|
Return map of the file where actual data is present, ignoring zero-ed
|
||||||
|
blocks. Last entry of the map spans to the end of file, even if that part is
|
||||||
|
zero-size (when file ends with zeros).
|
||||||
|
|
||||||
|
This function is performance critical.
|
||||||
|
|
||||||
|
:param input_file: io.File object
|
||||||
|
:return: iterable of (offset, size)
|
||||||
|
'''
|
||||||
|
zero_block = bytearray(tarfile.BLOCKSIZE)
|
||||||
|
buf = bytearray(BUF_SIZE)
|
||||||
|
in_data_block = False
|
||||||
|
data_block_start = 0
|
||||||
|
buf_start_offset = 0
|
||||||
|
while True:
|
||||||
|
buf_len = input_file.readinto(buf)
|
||||||
|
if not buf_len:
|
||||||
|
break
|
||||||
|
for offset in range(0, buf_len, tarfile.BLOCKSIZE):
|
||||||
|
if buf[offset:offset+tarfile.BLOCKSIZE] == zero_block:
|
||||||
|
if in_data_block:
|
||||||
|
in_data_block = False
|
||||||
|
yield (data_block_start,
|
||||||
|
buf_start_offset+offset-data_block_start)
|
||||||
|
else:
|
||||||
|
if not in_data_block:
|
||||||
|
in_data_block = True
|
||||||
|
data_block_start = buf_start_offset+offset
|
||||||
|
buf_start_offset += buf_len
|
||||||
|
if in_data_block:
|
||||||
|
yield (data_block_start, buf_start_offset-data_block_start)
|
||||||
|
else:
|
||||||
|
# always emit last slice to the input end - otherwise extracted file
|
||||||
|
# will be truncated
|
||||||
|
yield (buf_start_offset, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_sparse_data(input_stream, output_stream, sparse_map):
|
||||||
|
'''Copy data blocks from input to output according to sparse_map
|
||||||
|
|
||||||
|
:param input_stream: io.IOBase input instance
|
||||||
|
:param output_stream: io.IOBase output instance
|
||||||
|
:param sparse_map: iterable of (offset, size)
|
||||||
|
'''
|
||||||
|
|
||||||
|
buf = bytearray(BUF_SIZE)
|
||||||
|
|
||||||
|
for chunk in sparse_map:
|
||||||
|
input_stream.seek(chunk[0])
|
||||||
|
left = chunk[1]
|
||||||
|
while left:
|
||||||
|
if left > BUF_SIZE:
|
||||||
|
read = input_stream.readinto(buf)
|
||||||
|
output_stream.write(buf[:read])
|
||||||
|
else:
|
||||||
|
buf_trailer = input_stream.read(left)
|
||||||
|
read = len(buf_trailer)
|
||||||
|
output_stream.write(buf_trailer)
|
||||||
|
left -= read
|
||||||
|
if not read:
|
||||||
|
raise Exception('premature EOF')
|
||||||
|
|
||||||
|
def finalize(output):
|
||||||
|
'''Write EOF blocks'''
|
||||||
|
output.write('\0' * 512)
|
||||||
|
output.write('\0' * 512)
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--override-name', action='store', dest='override_name',
|
||||||
|
help='use this name in tar header')
|
||||||
|
parser.add_argument('--use-compress-program', default=None,
|
||||||
|
metavar='COMMAND', action='store', dest='use_compress_program',
|
||||||
|
help='Filter data through COMMAND.')
|
||||||
|
parser.add_argument('input_file',
|
||||||
|
help='input file name')
|
||||||
|
parser.add_argument('output_file', default='-', nargs='?',
|
||||||
|
help='output file name')
|
||||||
|
args = parser.parse_args(args)
|
||||||
|
input_file = io.open(args.input_file, 'rb')
|
||||||
|
sparse_map = list(get_sparse_map(input_file))
|
||||||
|
header_name = args.input_file
|
||||||
|
if args.override_name:
|
||||||
|
header_name = args.override_name
|
||||||
|
tar_info = TarSparseInfo(header_name, sparse_map)
|
||||||
|
if args.output_file == '-':
|
||||||
|
output = io.open('/dev/stdout', 'wb')
|
||||||
|
else:
|
||||||
|
output = io.open(args.output_file, 'wb')
|
||||||
|
if args.use_compress_program:
|
||||||
|
compress = subprocess.Popen([args.use_compress_program],
|
||||||
|
stdin=subprocess.PIPE, stdout=output)
|
||||||
|
output = compress.stdin
|
||||||
|
else:
|
||||||
|
compress = None
|
||||||
|
output.write(tar_info.tobuf(tarfile.GNU_FORMAT))
|
||||||
|
copy_sparse_data(input_file, output, sparse_map)
|
||||||
|
finalize(output)
|
||||||
|
input_file.close()
|
||||||
|
output.close()
|
||||||
|
if compress is not None:
|
||||||
|
compress.wait()
|
||||||
|
return compress.returncode
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
@ -33,27 +33,26 @@
|
|||||||
don't run the tests.
|
don't run the tests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import __builtin__
|
||||||
import collections
|
import collections
|
||||||
from distutils import spawn
|
|
||||||
import functools
|
import functools
|
||||||
import multiprocessing
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import unittest
|
import unittest
|
||||||
import __builtin__
|
from distutils import spawn
|
||||||
|
|
||||||
import lxml.etree
|
import lxml.etree
|
||||||
import time
|
|
||||||
|
|
||||||
|
import qubes.backup
|
||||||
import qubes.config
|
import qubes.config
|
||||||
import qubes.devices
|
import qubes.devices
|
||||||
import qubes.events
|
import qubes.events
|
||||||
import qubes.backup
|
|
||||||
import qubes.exc
|
import qubes.exc
|
||||||
import qubes.vm.standalonevm
|
import qubes.vm.standalonevm
|
||||||
|
|
||||||
@ -580,6 +579,25 @@ class SystemTestsMixin(object):
|
|||||||
else:
|
else:
|
||||||
os.unlink(dirpath)
|
os.unlink(dirpath)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_vm_disk_lvm(prefix=VMPREFIX):
|
||||||
|
''' Remove LVM volumes with given prefix
|
||||||
|
|
||||||
|
This is "a bit" drastic, as it removes volumes regardless of volume
|
||||||
|
group, thin pool etc. But we assume no important data on test system.
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
volumes = subprocess.check_output(
|
||||||
|
['sudo', 'lvs', '--noheadings', '-o', 'vg_name,name',
|
||||||
|
'--separator', '/'])
|
||||||
|
if ('/' + prefix) not in volumes:
|
||||||
|
return
|
||||||
|
subprocess.check_call(['sudo', 'lvremove', '-f'] +
|
||||||
|
[vol.strip() for vol in volumes.splitlines()
|
||||||
|
if ('/' + prefix) in vol],
|
||||||
|
stdout=open(os.devnull, 'w'))
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def remove_vms(cls, vms):
|
def remove_vms(cls, vms):
|
||||||
@ -624,6 +642,7 @@ class SystemTestsMixin(object):
|
|||||||
vmnames.add(name)
|
vmnames.add(name)
|
||||||
for vmname in vmnames:
|
for vmname in vmnames:
|
||||||
cls._remove_vm_disk(vmname)
|
cls._remove_vm_disk(vmname)
|
||||||
|
cls._remove_vm_disk_lvm(prefix)
|
||||||
|
|
||||||
def qrexec_policy(self, service, source, destination, allow=True):
|
def qrexec_policy(self, service, source, destination, allow=True):
|
||||||
"""
|
"""
|
||||||
@ -771,192 +790,6 @@ class SystemTestsMixin(object):
|
|||||||
shutil.rmtree(mountpoint)
|
shutil.rmtree(mountpoint)
|
||||||
subprocess.check_call(['sudo', 'losetup', '-d', loopdev])
|
subprocess.check_call(['sudo', 'losetup', '-d', loopdev])
|
||||||
|
|
||||||
# noinspection PyAttributeOutsideInit
|
|
||||||
class BackupTestsMixin(SystemTestsMixin):
|
|
||||||
class BackupErrorHandler(logging.Handler):
|
|
||||||
def __init__(self, errors_queue, level=logging.NOTSET):
|
|
||||||
super(BackupTestsMixin.BackupErrorHandler, self).__init__(level)
|
|
||||||
self.errors_queue = errors_queue
|
|
||||||
|
|
||||||
def emit(self, record):
|
|
||||||
self.errors_queue.put(record.getMessage())
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super(BackupTestsMixin, self).setUp()
|
|
||||||
try:
|
|
||||||
self.init_default_template(self.template)
|
|
||||||
except AttributeError:
|
|
||||||
self.init_default_template()
|
|
||||||
self.error_detected = multiprocessing.Queue()
|
|
||||||
self.verbose = False
|
|
||||||
|
|
||||||
if self.verbose:
|
|
||||||
print >>sys.stderr, "-> Creating backupvm"
|
|
||||||
|
|
||||||
self.backupdir = os.path.join(os.environ["HOME"], "test-backup")
|
|
||||||
if os.path.exists(self.backupdir):
|
|
||||||
shutil.rmtree(self.backupdir)
|
|
||||||
os.mkdir(self.backupdir)
|
|
||||||
|
|
||||||
self.error_handler = self.BackupErrorHandler(self.error_detected,
|
|
||||||
level=logging.WARNING)
|
|
||||||
backup_log = logging.getLogger('qubes.backup')
|
|
||||||
backup_log.addHandler(self.error_handler)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
super(BackupTestsMixin, self).tearDown()
|
|
||||||
shutil.rmtree(self.backupdir)
|
|
||||||
|
|
||||||
backup_log = logging.getLogger('qubes.backup')
|
|
||||||
backup_log.removeHandler(self.error_handler)
|
|
||||||
|
|
||||||
def fill_image(self, path, size=None, sparse=False):
|
|
||||||
block_size = 4096
|
|
||||||
|
|
||||||
if self.verbose:
|
|
||||||
print >>sys.stderr, "-> Filling %s" % path
|
|
||||||
f = open(path, 'w+')
|
|
||||||
if size is None:
|
|
||||||
f.seek(0, 2)
|
|
||||||
size = f.tell()
|
|
||||||
f.seek(0)
|
|
||||||
|
|
||||||
for block_num in xrange(size/block_size):
|
|
||||||
f.write('a' * block_size)
|
|
||||||
if sparse:
|
|
||||||
f.seek(block_size, 1)
|
|
||||||
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
# NOTE: this was create_basic_vms
|
|
||||||
def create_backup_vms(self):
|
|
||||||
template = self.app.default_template
|
|
||||||
|
|
||||||
vms = []
|
|
||||||
vmname = self.make_vm_name('test-net')
|
|
||||||
if self.verbose:
|
|
||||||
print >>sys.stderr, "-> Creating %s" % vmname
|
|
||||||
testnet = self.app.add_new_vm(qubes.vm.appvm.AppVM,
|
|
||||||
name=vmname, template=template, provides_network=True, label='red')
|
|
||||||
testnet.create_on_disk()
|
|
||||||
testnet.features['services/ntpd'] = True
|
|
||||||
vms.append(testnet)
|
|
||||||
self.fill_image(testnet.volumes['private'].path, 20*1024*1024)
|
|
||||||
|
|
||||||
vmname = self.make_vm_name('test1')
|
|
||||||
if self.verbose:
|
|
||||||
print >>sys.stderr, "-> Creating %s" % vmname
|
|
||||||
testvm1 = self.app.add_new_vm(qubes.vm.appvm.AppVM,
|
|
||||||
name=vmname, template=template, label='red')
|
|
||||||
testvm1.uses_default_netvm = False
|
|
||||||
testvm1.netvm = testnet
|
|
||||||
testvm1.create_on_disk()
|
|
||||||
vms.append(testvm1)
|
|
||||||
self.fill_image(testvm1.volumes['private'].path, 100*1024*1024)
|
|
||||||
|
|
||||||
vmname = self.make_vm_name('testhvm1')
|
|
||||||
if self.verbose:
|
|
||||||
print >>sys.stderr, "-> Creating %s" % vmname
|
|
||||||
testvm2 = self.app.add_new_vm(qubes.vm.standalonevm.StandaloneVM,
|
|
||||||
name=vmname,
|
|
||||||
hvm=True,
|
|
||||||
label='red')
|
|
||||||
testvm2.create_on_disk()
|
|
||||||
self.fill_image(testvm2.volumes['root'].path, 1024 * 1024 * 1024, True)
|
|
||||||
vms.append(testvm2)
|
|
||||||
|
|
||||||
vmname = self.make_vm_name('template')
|
|
||||||
if self.verbose:
|
|
||||||
print >>sys.stderr, "-> Creating %s" % vmname
|
|
||||||
testvm3 = self.app.add_new_vm(qubes.vm.templatevm.TemplateVM,
|
|
||||||
name=vmname, label='red')
|
|
||||||
testvm3.create_on_disk()
|
|
||||||
self.fill_image(testvm3.volumes['root'].path, 100 * 1024 * 1024, True)
|
|
||||||
vms.append(testvm3)
|
|
||||||
|
|
||||||
vmname = self.make_vm_name('custom')
|
|
||||||
if self.verbose:
|
|
||||||
print >>sys.stderr, "-> Creating %s" % vmname
|
|
||||||
testvm4 = self.app.add_new_vm(qubes.vm.appvm.AppVM,
|
|
||||||
name=vmname, template=testvm3, label='red')
|
|
||||||
testvm4.create_on_disk()
|
|
||||||
vms.append(testvm4)
|
|
||||||
|
|
||||||
self.app.save()
|
|
||||||
|
|
||||||
return vms
|
|
||||||
|
|
||||||
def make_backup(self, vms, target=None, expect_failure=False, **kwargs):
|
|
||||||
if target is None:
|
|
||||||
target = self.backupdir
|
|
||||||
try:
|
|
||||||
backup = qubes.backup.Backup(self.app, vms, **kwargs)
|
|
||||||
except qubes.exc.QubesException as e:
|
|
||||||
if not expect_failure:
|
|
||||||
self.fail("QubesException during backup_prepare: %s" % str(e))
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
backup.passphrase = 'qubes'
|
|
||||||
backup.target_dir = target
|
|
||||||
|
|
||||||
try:
|
|
||||||
backup.backup_do()
|
|
||||||
except qubes.exc.QubesException as e:
|
|
||||||
if not expect_failure:
|
|
||||||
self.fail("QubesException during backup_do: %s" % str(e))
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# FIXME why?
|
|
||||||
#self.reload_db()
|
|
||||||
|
|
||||||
def restore_backup(self, source=None, appvm=None, options=None,
|
|
||||||
expect_errors=None):
|
|
||||||
if source is None:
|
|
||||||
backupfile = os.path.join(self.backupdir,
|
|
||||||
sorted(os.listdir(self.backupdir))[-1])
|
|
||||||
else:
|
|
||||||
backupfile = source
|
|
||||||
|
|
||||||
with self.assertNotRaises(qubes.exc.QubesException):
|
|
||||||
restore_op = qubes.backup.BackupRestore(
|
|
||||||
self.app, backupfile, appvm, "qubes")
|
|
||||||
if options:
|
|
||||||
for key, value in options.iteritems():
|
|
||||||
setattr(restore_op.options, key, value)
|
|
||||||
restore_info = restore_op.get_restore_info()
|
|
||||||
if self.verbose:
|
|
||||||
print restore_op.get_restore_summary(restore_info)
|
|
||||||
|
|
||||||
with self.assertNotRaises(qubes.exc.QubesException):
|
|
||||||
restore_op.restore_do(restore_info)
|
|
||||||
|
|
||||||
# maybe someone forgot to call .save()
|
|
||||||
self.reload_db()
|
|
||||||
|
|
||||||
errors = []
|
|
||||||
if expect_errors is None:
|
|
||||||
expect_errors = []
|
|
||||||
else:
|
|
||||||
self.assertFalse(self.error_detected.empty(),
|
|
||||||
"Restore errors expected, but none detected")
|
|
||||||
while not self.error_detected.empty():
|
|
||||||
current_error = self.error_detected.get()
|
|
||||||
if any(map(current_error.startswith, expect_errors)):
|
|
||||||
continue
|
|
||||||
errors.append(current_error)
|
|
||||||
self.assertTrue(len(errors) == 0,
|
|
||||||
"Error(s) detected during backup_restore_do: %s" %
|
|
||||||
'\n'.join(errors))
|
|
||||||
if not appvm and not os.path.isdir(backupfile):
|
|
||||||
os.unlink(backupfile)
|
|
||||||
|
|
||||||
def create_sparse(self, path, size):
|
|
||||||
f = open(path, "w")
|
|
||||||
f.truncate(size)
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
|
|
||||||
def load_tests(loader, tests, pattern): # pylint: disable=unused-argument
|
def load_tests(loader, tests, pattern): # pylint: disable=unused-argument
|
||||||
# discard any tests from this module, because it hosts base classes
|
# discard any tests from this module, because it hosts base classes
|
||||||
@ -976,6 +809,7 @@ def load_tests(loader, tests, pattern): # pylint: disable=unused-argument
|
|||||||
'qubes.tests.vm.mix.net',
|
'qubes.tests.vm.mix.net',
|
||||||
'qubes.tests.vm.adminvm',
|
'qubes.tests.vm.adminvm',
|
||||||
'qubes.tests.app',
|
'qubes.tests.app',
|
||||||
|
'qubes.tests.tarwriter',
|
||||||
'qubes.tests.tools.qvm_device',
|
'qubes.tests.tools.qvm_device',
|
||||||
'qubes.tests.tools.qvm_firewall',
|
'qubes.tests.tools.qvm_firewall',
|
||||||
'qubes.tests.tools.qvm_ls',
|
'qubes.tests.tools.qvm_ls',
|
||||||
|
@ -22,24 +22,229 @@
|
|||||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
#
|
#
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import qubes
|
import qubes
|
||||||
|
import qubes.backup
|
||||||
import qubes.exc
|
import qubes.exc
|
||||||
|
import qubes.storage.lvm
|
||||||
import qubes.tests
|
import qubes.tests
|
||||||
|
import qubes.tests.storage_lvm
|
||||||
|
import qubes.vm
|
||||||
import qubes.vm.appvm
|
import qubes.vm.appvm
|
||||||
import qubes.vm.templatevm
|
import qubes.vm.templatevm
|
||||||
|
import qubes.vm.qubesvm
|
||||||
|
|
||||||
class TC_00_Backup(qubes.tests.BackupTestsMixin, qubes.tests.QubesTestCase):
|
# noinspection PyAttributeOutsideInit
|
||||||
def test_000_basic_backup(self):
|
class BackupTestsMixin(qubes.tests.SystemTestsMixin):
|
||||||
vms = self.create_backup_vms()
|
class BackupErrorHandler(logging.Handler):
|
||||||
self.make_backup(vms)
|
def __init__(self, errors_queue, level=logging.NOTSET):
|
||||||
self.remove_vms(reversed(vms))
|
super(BackupTestsMixin.BackupErrorHandler, self).__init__(level)
|
||||||
self.restore_backup()
|
self.errors_queue = errors_queue
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
self.errors_queue.put(record.getMessage())
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(BackupTestsMixin, self).setUp()
|
||||||
|
try:
|
||||||
|
self.init_default_template(self.template)
|
||||||
|
except AttributeError:
|
||||||
|
self.init_default_template()
|
||||||
|
self.error_detected = multiprocessing.Queue()
|
||||||
|
|
||||||
|
self.log.debug("Creating backupvm")
|
||||||
|
|
||||||
|
self.backupdir = os.path.join(os.environ["HOME"], "test-backup")
|
||||||
|
if os.path.exists(self.backupdir):
|
||||||
|
shutil.rmtree(self.backupdir)
|
||||||
|
os.mkdir(self.backupdir)
|
||||||
|
|
||||||
|
self.error_handler = self.BackupErrorHandler(self.error_detected,
|
||||||
|
level=logging.WARNING)
|
||||||
|
backup_log = logging.getLogger('qubes.backup')
|
||||||
|
backup_log.addHandler(self.error_handler)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super(BackupTestsMixin, self).tearDown()
|
||||||
|
shutil.rmtree(self.backupdir)
|
||||||
|
|
||||||
|
backup_log = logging.getLogger('qubes.backup')
|
||||||
|
backup_log.removeHandler(self.error_handler)
|
||||||
|
|
||||||
|
def fill_image(self, path, size=None, sparse=False):
|
||||||
|
block_size = 4096
|
||||||
|
|
||||||
|
self.log.debug("Filling %s" % path)
|
||||||
|
f = open(path, 'w+')
|
||||||
|
if size is None:
|
||||||
|
f.seek(0, 2)
|
||||||
|
size = f.tell()
|
||||||
|
f.seek(0)
|
||||||
|
|
||||||
|
for block_num in range(size/block_size):
|
||||||
|
if sparse:
|
||||||
|
f.seek(block_size, 1)
|
||||||
|
f.write('a' * block_size)
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
# NOTE: this was create_basic_vms
|
||||||
|
def create_backup_vms(self, pool=None):
|
||||||
|
template = self.app.default_template
|
||||||
|
|
||||||
|
vms = []
|
||||||
|
vmname = self.make_vm_name('test-net')
|
||||||
|
self.log.debug("Creating %s" % vmname)
|
||||||
|
testnet = self.app.add_new_vm(qubes.vm.appvm.AppVM,
|
||||||
|
name=vmname, template=template, provides_network=True,
|
||||||
|
label='red')
|
||||||
|
testnet.create_on_disk(pool=pool)
|
||||||
|
testnet.features['services/ntpd'] = True
|
||||||
|
vms.append(testnet)
|
||||||
|
self.fill_image(testnet.storage.export('private'), 20*1024*1024)
|
||||||
|
|
||||||
|
vmname = self.make_vm_name('test1')
|
||||||
|
self.log.debug("Creating %s" % vmname)
|
||||||
|
testvm1 = self.app.add_new_vm(qubes.vm.appvm.AppVM,
|
||||||
|
name=vmname, template=template, label='red')
|
||||||
|
testvm1.uses_default_netvm = False
|
||||||
|
testvm1.netvm = testnet
|
||||||
|
testvm1.create_on_disk(pool=pool)
|
||||||
|
vms.append(testvm1)
|
||||||
|
self.fill_image(testvm1.storage.export('private'), 100 * 1024 * 1024)
|
||||||
|
|
||||||
|
vmname = self.make_vm_name('testhvm1')
|
||||||
|
self.log.debug("Creating %s" % vmname)
|
||||||
|
testvm2 = self.app.add_new_vm(qubes.vm.standalonevm.StandaloneVM,
|
||||||
|
name=vmname,
|
||||||
|
hvm=True,
|
||||||
|
label='red')
|
||||||
|
testvm2.create_on_disk(pool=pool)
|
||||||
|
self.fill_image(testvm2.storage.export('root'), 1024 * 1024 * 1024, \
|
||||||
|
True)
|
||||||
|
vms.append(testvm2)
|
||||||
|
|
||||||
|
vmname = self.make_vm_name('template')
|
||||||
|
self.log.debug("Creating %s" % vmname)
|
||||||
|
testvm3 = self.app.add_new_vm(qubes.vm.templatevm.TemplateVM,
|
||||||
|
name=vmname, label='red')
|
||||||
|
testvm3.create_on_disk(pool=pool)
|
||||||
|
self.fill_image(testvm3.storage.export('root'), 100 * 1024 * 1024, True)
|
||||||
|
vms.append(testvm3)
|
||||||
|
|
||||||
|
vmname = self.make_vm_name('custom')
|
||||||
|
self.log.debug("Creating %s" % vmname)
|
||||||
|
testvm4 = self.app.add_new_vm(qubes.vm.appvm.AppVM,
|
||||||
|
name=vmname, template=testvm3, label='red')
|
||||||
|
testvm4.create_on_disk(pool=pool)
|
||||||
|
vms.append(testvm4)
|
||||||
|
|
||||||
|
self.app.save()
|
||||||
|
|
||||||
|
return vms
|
||||||
|
|
||||||
|
def make_backup(self, vms, target=None, expect_failure=False, **kwargs):
|
||||||
|
if target is None:
|
||||||
|
target = self.backupdir
|
||||||
|
try:
|
||||||
|
backup = qubes.backup.Backup(self.app, vms, **kwargs)
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
if not expect_failure:
|
||||||
|
self.fail("QubesException during backup_prepare: %s" % str(e))
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
backup.passphrase = 'qubes'
|
||||||
|
backup.target_dir = target
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup.backup_do()
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
if not expect_failure:
|
||||||
|
self.fail("QubesException during backup_do: %s" % str(e))
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# FIXME why?
|
||||||
|
#self.reload_db()
|
||||||
|
|
||||||
|
def restore_backup(self, source=None, appvm=None, options=None,
|
||||||
|
expect_errors=None):
|
||||||
|
if source is None:
|
||||||
|
backupfile = os.path.join(self.backupdir,
|
||||||
|
sorted(os.listdir(self.backupdir))[-1])
|
||||||
|
else:
|
||||||
|
backupfile = source
|
||||||
|
|
||||||
|
with self.assertNotRaises(qubes.exc.QubesException):
|
||||||
|
restore_op = qubes.backup.BackupRestore(
|
||||||
|
self.app, backupfile, appvm, "qubes")
|
||||||
|
if options:
|
||||||
|
for key, value in options.items():
|
||||||
|
setattr(restore_op.options, key, value)
|
||||||
|
restore_info = restore_op.get_restore_info()
|
||||||
|
self.log.debug(restore_op.get_restore_summary(restore_info))
|
||||||
|
|
||||||
|
with self.assertNotRaises(qubes.exc.QubesException):
|
||||||
|
restore_op.restore_do(restore_info)
|
||||||
|
|
||||||
|
# maybe someone forgot to call .save()
|
||||||
|
self.reload_db()
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if expect_errors is None:
|
||||||
|
expect_errors = []
|
||||||
|
else:
|
||||||
|
self.assertFalse(self.error_detected.empty(),
|
||||||
|
"Restore errors expected, but none detected")
|
||||||
|
while not self.error_detected.empty():
|
||||||
|
current_error = self.error_detected.get()
|
||||||
|
if any(map(current_error.startswith, expect_errors)):
|
||||||
|
continue
|
||||||
|
errors.append(current_error)
|
||||||
|
self.assertTrue(len(errors) == 0,
|
||||||
|
"Error(s) detected during backup_restore_do: %s" %
|
||||||
|
'\n'.join(errors))
|
||||||
|
if not appvm and not os.path.isdir(backupfile):
|
||||||
|
os.unlink(backupfile)
|
||||||
|
|
||||||
|
def create_sparse(self, path, size):
|
||||||
|
f = open(path, "w")
|
||||||
|
f.truncate(size)
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
def vm_checksum(self, vms):
|
||||||
|
hashes = {}
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
|
assert isinstance(vm, qubes.vm.qubesvm.QubesVM)
|
||||||
|
hashes[vm.name] = {}
|
||||||
|
for name, volume in vm.volumes.items():
|
||||||
|
if not volume.rw or not volume.save_on_stop:
|
||||||
|
continue
|
||||||
|
vol_path = vm.storage.get_pool(volume).export(volume)
|
||||||
|
hasher = hashlib.sha1()
|
||||||
|
with open(vol_path) as afile:
|
||||||
|
for buf in iter(lambda: afile.read(4096000), b''):
|
||||||
|
hasher.update(buf)
|
||||||
|
hashes[vm.name][name] = hasher.hexdigest()
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
def assertCorrectlyRestored(self, orig_vms, orig_hashes):
|
||||||
|
''' Verify if restored VMs are identical to those before backup.
|
||||||
|
|
||||||
|
:param orig_vms: collection of original QubesVM objects
|
||||||
|
:param orig_hashes: result of :py:meth:`vm_checksum` on original VMs,
|
||||||
|
before backup
|
||||||
|
:return:
|
||||||
|
'''
|
||||||
|
for vm in orig_vms:
|
||||||
self.assertIn(vm.name, self.app.domains)
|
self.assertIn(vm.name, self.app.domains)
|
||||||
restored_vm = self.app.domains[vm.name]
|
restored_vm = self.app.domains[vm.name]
|
||||||
for prop in ('name', 'kernel',
|
for prop in ('name', 'kernel',
|
||||||
@ -69,42 +274,56 @@ class TC_00_Backup(qubes.tests.BackupTestsMixin, qubes.tests.QubesTestCase):
|
|||||||
vm.name, prop))
|
vm.name, prop))
|
||||||
for dev_class in vm.devices.keys():
|
for dev_class in vm.devices.keys():
|
||||||
for dev in vm.devices[dev_class]:
|
for dev in vm.devices[dev_class]:
|
||||||
self.assertIn(dev, restored_vm.devices[dev_class])
|
self.assertIn(dev, restored_vm.devices[dev_class],
|
||||||
|
"VM {} - {} device not restored".format(
|
||||||
|
vm.name, dev_class))
|
||||||
|
|
||||||
# TODO: compare disk images
|
if orig_hashes:
|
||||||
|
hashes = self.vm_checksum([restored_vm])[restored_vm.name]
|
||||||
|
self.assertEqual(orig_hashes[vm.name], hashes,
|
||||||
|
"VM {} - disk images are not properly restored".format(
|
||||||
|
vm.name))
|
||||||
|
|
||||||
|
|
||||||
|
class TC_00_Backup(BackupTestsMixin, qubes.tests.QubesTestCase):
|
||||||
|
def test_000_basic_backup(self):
|
||||||
|
vms = self.create_backup_vms()
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
|
self.make_backup(vms)
|
||||||
|
self.remove_vms(reversed(vms))
|
||||||
|
self.restore_backup()
|
||||||
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
self.remove_vms(reversed(vms))
|
self.remove_vms(reversed(vms))
|
||||||
|
|
||||||
def test_001_compressed_backup(self):
|
def test_001_compressed_backup(self):
|
||||||
vms = self.create_backup_vms()
|
vms = self.create_backup_vms()
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
self.make_backup(vms, compressed=True)
|
self.make_backup(vms, compressed=True)
|
||||||
self.remove_vms(reversed(vms))
|
self.remove_vms(reversed(vms))
|
||||||
self.restore_backup()
|
self.restore_backup()
|
||||||
for vm in vms:
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
self.assertIn(vm.name, self.app.domains)
|
|
||||||
|
|
||||||
def test_002_encrypted_backup(self):
|
def test_002_encrypted_backup(self):
|
||||||
vms = self.create_backup_vms()
|
vms = self.create_backup_vms()
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
self.make_backup(vms, encrypted=True)
|
self.make_backup(vms, encrypted=True)
|
||||||
self.remove_vms(reversed(vms))
|
self.remove_vms(reversed(vms))
|
||||||
self.restore_backup()
|
self.restore_backup()
|
||||||
for vm in vms:
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
self.assertIn(vm.name, self.app.domains)
|
|
||||||
|
|
||||||
def test_003_compressed_encrypted_backup(self):
|
def test_003_compressed_encrypted_backup(self):
|
||||||
vms = self.create_backup_vms()
|
vms = self.create_backup_vms()
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
self.make_backup(vms, compressed=True, encrypted=True)
|
self.make_backup(vms, compressed=True, encrypted=True)
|
||||||
self.remove_vms(reversed(vms))
|
self.remove_vms(reversed(vms))
|
||||||
self.restore_backup()
|
self.restore_backup()
|
||||||
for vm in vms:
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
self.assertIn(vm.name, self.app.domains)
|
|
||||||
|
|
||||||
def test_004_sparse_multipart(self):
|
def test_004_sparse_multipart(self):
|
||||||
vms = []
|
vms = []
|
||||||
|
|
||||||
vmname = self.make_vm_name('testhvm2')
|
vmname = self.make_vm_name('testhvm2')
|
||||||
if self.verbose:
|
self.log.debug("Creating %s" % vmname)
|
||||||
print >>sys.stderr, "-> Creating %s" % vmname
|
|
||||||
|
|
||||||
hvmtemplate = self.app.add_new_vm(
|
hvmtemplate = self.app.add_new_vm(
|
||||||
qubes.vm.templatevm.TemplateVM, name=vmname, hvm=True, label='red')
|
qubes.vm.templatevm.TemplateVM, name=vmname, hvm=True, label='red')
|
||||||
@ -112,27 +331,33 @@ class TC_00_Backup(qubes.tests.BackupTestsMixin, qubes.tests.QubesTestCase):
|
|||||||
self.fill_image(
|
self.fill_image(
|
||||||
os.path.join(hvmtemplate.dir_path, '00file'),
|
os.path.join(hvmtemplate.dir_path, '00file'),
|
||||||
195 * 1024 * 1024 - 4096 * 3)
|
195 * 1024 * 1024 - 4096 * 3)
|
||||||
self.fill_image(hvmtemplate.volumes['private'].path,
|
self.fill_image(hvmtemplate.storage.export('private'),
|
||||||
195 * 1024 * 1024 - 4096 * 3)
|
195 * 1024 * 1024 - 4096 * 3)
|
||||||
self.fill_image(hvmtemplate.volumes['root'].path, 1024 * 1024 * 1024,
|
self.fill_image(hvmtemplate.storage.export('root'), 1024 * 1024 * 1024,
|
||||||
sparse=True)
|
sparse=True)
|
||||||
vms.append(hvmtemplate)
|
vms.append(hvmtemplate)
|
||||||
self.app.save()
|
self.app.save()
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
|
|
||||||
self.make_backup(vms)
|
self.make_backup(vms)
|
||||||
self.remove_vms(reversed(vms))
|
self.remove_vms(reversed(vms))
|
||||||
self.restore_backup()
|
self.restore_backup()
|
||||||
for vm in vms:
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
self.assertIn(vm.name, self.app.domains)
|
|
||||||
# TODO check vm.backup_timestamp
|
# TODO check vm.backup_timestamp
|
||||||
|
|
||||||
def test_005_compressed_custom(self):
|
def test_005_compressed_custom(self):
|
||||||
vms = self.create_backup_vms()
|
vms = self.create_backup_vms()
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
self.make_backup(vms, compression_filter="bzip2")
|
self.make_backup(vms, compression_filter="bzip2")
|
||||||
self.remove_vms(reversed(vms))
|
self.remove_vms(reversed(vms))
|
||||||
self.restore_backup()
|
self.restore_backup()
|
||||||
for vm in vms:
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
self.assertIn(vm.name, self.app.domains)
|
|
||||||
|
def test_010_selective_restore(self):
|
||||||
|
# create backup with internal dependencies (template, netvm etc)
|
||||||
|
# try restoring only AppVMs (but not templates, netvms) - should
|
||||||
|
# handle according to options set
|
||||||
|
self.skipTest('test not implemented')
|
||||||
|
|
||||||
def test_100_backup_dom0_no_restore(self):
|
def test_100_backup_dom0_no_restore(self):
|
||||||
# do not write it into dom0 home itself...
|
# do not write it into dom0 home itself...
|
||||||
@ -147,6 +372,7 @@ class TC_00_Backup(qubes.tests.BackupTestsMixin, qubes.tests.QubesTestCase):
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
vms = self.create_backup_vms()
|
vms = self.create_backup_vms()
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
self.make_backup(vms)
|
self.make_backup(vms)
|
||||||
self.remove_vms(reversed(vms))
|
self.remove_vms(reversed(vms))
|
||||||
test_dir = vms[0].dir_path
|
test_dir = vms[0].dir_path
|
||||||
@ -158,6 +384,7 @@ class TC_00_Backup(qubes.tests.BackupTestsMixin, qubes.tests.QubesTestCase):
|
|||||||
'*** Directory {} already exists! It has been moved'.format(
|
'*** Directory {} already exists! It has been moved'.format(
|
||||||
test_dir)
|
test_dir)
|
||||||
])
|
])
|
||||||
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
|
|
||||||
def test_210_auto_rename(self):
|
def test_210_auto_rename(self):
|
||||||
"""
|
"""
|
||||||
@ -176,8 +403,59 @@ class TC_00_Backup(qubes.tests.BackupTestsMixin, qubes.tests.QubesTestCase):
|
|||||||
if vm.netvm and not vm.property_is_default('netvm'):
|
if vm.netvm and not vm.property_is_default('netvm'):
|
||||||
self.assertEqual(restored_vm.netvm.name, vm.netvm.name + '1')
|
self.assertEqual(restored_vm.netvm.name, vm.netvm.name + '1')
|
||||||
|
|
||||||
|
def _find_pool(self, volume_group, thin_pool):
|
||||||
|
''' Returns the pool matching the specified ``volume_group`` &
|
||||||
|
``thin_pool``, or None.
|
||||||
|
'''
|
||||||
|
pools = [p for p in self.app.pools
|
||||||
|
if issubclass(p.__class__, qubes.storage.lvm.ThinPool)]
|
||||||
|
for pool in pools:
|
||||||
|
if pool.volume_group == volume_group \
|
||||||
|
and pool.thin_pool == thin_pool:
|
||||||
|
return pool
|
||||||
|
return None
|
||||||
|
|
||||||
class TC_10_BackupVMMixin(qubes.tests.BackupTestsMixin):
|
@qubes.tests.storage_lvm.skipUnlessLvmPoolExists
|
||||||
|
def test_300_backup_lvm(self):
|
||||||
|
volume_group, thin_pool = \
|
||||||
|
qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/', 1)
|
||||||
|
self.pool = self._find_pool(volume_group, thin_pool)
|
||||||
|
if not self.pool:
|
||||||
|
self.pool = self.app.add_pool(
|
||||||
|
**qubes.tests.storage_lvm.POOL_CONF)
|
||||||
|
self.created_pool = True
|
||||||
|
vms = self.create_backup_vms(pool=self.pool)
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
|
self.make_backup(vms)
|
||||||
|
self.remove_vms(reversed(vms))
|
||||||
|
self.restore_backup()
|
||||||
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
|
self.remove_vms(reversed(vms))
|
||||||
|
|
||||||
|
@qubes.tests.storage_lvm.skipUnlessLvmPoolExists
|
||||||
|
def test_301_restore_to_lvm(self):
|
||||||
|
volume_group, thin_pool = \
|
||||||
|
qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/', 1)
|
||||||
|
self.pool = self._find_pool(volume_group, thin_pool)
|
||||||
|
if not self.pool:
|
||||||
|
self.pool = self.app.add_pool(
|
||||||
|
**qubes.tests.storage_lvm.POOL_CONF)
|
||||||
|
self.created_pool = True
|
||||||
|
vms = self.create_backup_vms()
|
||||||
|
orig_hashes = self.vm_checksum(vms)
|
||||||
|
self.make_backup(vms)
|
||||||
|
self.remove_vms(reversed(vms))
|
||||||
|
self.restore_backup(options={'override_pool': self.pool.name})
|
||||||
|
self.assertCorrectlyRestored(vms, orig_hashes)
|
||||||
|
for vm in vms:
|
||||||
|
vm = self.app.domains[vm.name]
|
||||||
|
for volume in vm.volumes.values():
|
||||||
|
if volume.save_on_stop:
|
||||||
|
self.assertEqual(volume.pool, self.pool.name)
|
||||||
|
self.remove_vms(reversed(vms))
|
||||||
|
|
||||||
|
|
||||||
|
class TC_10_BackupVMMixin(BackupTestsMixin):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TC_10_BackupVMMixin, self).setUp()
|
super(TC_10_BackupVMMixin, self).setUp()
|
||||||
self.backupvm = self.app.add_new_vm(
|
self.backupvm = self.app.add_new_vm(
|
||||||
|
@ -31,6 +31,7 @@ import sys
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import qubes.tests
|
import qubes.tests
|
||||||
|
import qubes.tests.int.backup
|
||||||
|
|
||||||
QUBESXML_R2B2 = '''
|
QUBESXML_R2B2 = '''
|
||||||
<QubesVmCollection updatevm="3" default_kernel="3.7.6-2" default_netvm="3" default_fw_netvm="2" default_template="1" clockvm="2">
|
<QubesVmCollection updatevm="3" default_kernel="3.7.6-2" default_netvm="3" default_fw_netvm="2" default_template="1" clockvm="2">
|
||||||
@ -143,7 +144,8 @@ compressed={compressed}
|
|||||||
compression-filter=gzip
|
compression-filter=gzip
|
||||||
'''
|
'''
|
||||||
|
|
||||||
class TC_00_BackupCompatibility(qubes.tests.BackupTestsMixin, qubes.tests.QubesTestCase):
|
class TC_00_BackupCompatibility(
|
||||||
|
qubes.tests.int.backup.BackupTestsMixin, qubes.tests.QubesTestCase):
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.remove_test_vms(prefix="test-")
|
self.remove_test_vms(prefix="test-")
|
||||||
|
147
qubes/tests/tarwriter.py
Normal file
147
qubes/tests/tarwriter.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/python2
|
||||||
|
# -*- encoding: utf8 -*-
|
||||||
|
#
|
||||||
|
# The Qubes OS Project, http://www.qubes-os.org
|
||||||
|
#
|
||||||
|
# Copyright (C) 2016 Marek Marczykowski-Górecki
|
||||||
|
# <marmarek@invisiblethingslab.com>
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along
|
||||||
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
import qubes.tarwriter
|
||||||
|
import qubes.tests
|
||||||
|
|
||||||
|
|
||||||
|
class TC_00_TarWriter(qubes.tests.QubesTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(TC_00_TarWriter, self).setUp()
|
||||||
|
self.input_path = tempfile.mktemp()
|
||||||
|
self.output_path = tempfile.mktemp()
|
||||||
|
self.extract_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.input_path):
|
||||||
|
os.unlink(self.input_path)
|
||||||
|
if os.path.exists(self.output_path):
|
||||||
|
os.unlink(self.output_path)
|
||||||
|
if os.path.exists(self.extract_dir):
|
||||||
|
shutil.rmtree(self.extract_dir)
|
||||||
|
return super(TC_00_TarWriter, self).tearDown()
|
||||||
|
|
||||||
|
def assertTarExtractable(self, expected_name=None):
|
||||||
|
if expected_name is None:
|
||||||
|
expected_name = self.input_path
|
||||||
|
with self.assertNotRaises(subprocess.CalledProcessError):
|
||||||
|
tar_output = subprocess.check_output(
|
||||||
|
['tar', 'xvf', self.output_path],
|
||||||
|
cwd=self.extract_dir,
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
expected_output = expected_name + '\n'
|
||||||
|
if expected_name[0] == '/':
|
||||||
|
expected_output = (
|
||||||
|
'tar: Removing leading `/\' from member names\n' +
|
||||||
|
expected_output)
|
||||||
|
self.assertEqual(tar_output, expected_output)
|
||||||
|
extracted_path = os.path.join(self.extract_dir,
|
||||||
|
expected_name.lstrip('/'))
|
||||||
|
with self.assertNotRaises(subprocess.CalledProcessError):
|
||||||
|
subprocess.check_call(
|
||||||
|
['diff', '-q', self.input_path, extracted_path])
|
||||||
|
# make sure the file is still sparse
|
||||||
|
orig_stat = os.stat(self.input_path)
|
||||||
|
extracted_stat = os.stat(extracted_path)
|
||||||
|
self.assertEqual(orig_stat.st_blocks, extracted_stat.st_blocks)
|
||||||
|
self.assertEqual(orig_stat.st_size, extracted_stat.st_size)
|
||||||
|
|
||||||
|
def write_sparse_chunks(self, num_chunks):
|
||||||
|
with open(self.input_path, 'w') as f:
|
||||||
|
for i in range(num_chunks):
|
||||||
|
f.seek(8192 * i)
|
||||||
|
f.write('a' * 4096)
|
||||||
|
|
||||||
|
def test_000_simple(self):
|
||||||
|
self.write_sparse_chunks(1)
|
||||||
|
with open(self.input_path, 'w') as f:
|
||||||
|
f.write('a' * 4096)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_001_simple_sparse2(self):
|
||||||
|
self.write_sparse_chunks(2)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_002_simple_sparse3(self):
|
||||||
|
# tar header contains info about 4 chunks, check for off-by-one errors
|
||||||
|
self.write_sparse_chunks(3)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_003_simple_sparse4(self):
|
||||||
|
# tar header contains info about 4 chunks, check for off-by-one errors
|
||||||
|
self.write_sparse_chunks(4)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_004_simple_sparse5(self):
|
||||||
|
# tar header contains info about 4 chunks, check for off-by-one errors
|
||||||
|
self.write_sparse_chunks(5)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_005_simple_sparse24(self):
|
||||||
|
# tar header contains info about 4 chunks, next header contains 21 of
|
||||||
|
# them, check for off-by-one errors
|
||||||
|
self.write_sparse_chunks(24)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_006_simple_sparse25(self):
|
||||||
|
# tar header contains info about 4 chunks, next header contains 21 of
|
||||||
|
# them, check for off-by-one errors
|
||||||
|
self.write_sparse_chunks(25)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_007_simple_sparse26(self):
|
||||||
|
# tar header contains info about 4 chunks, next header contains 21 of
|
||||||
|
# them, check for off-by-one errors
|
||||||
|
self.write_sparse_chunks(26)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_010_override_name(self):
|
||||||
|
self.write_sparse_chunks(1)
|
||||||
|
qubes.tarwriter.main(['--override-name',
|
||||||
|
'different-name', self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable(expected_name='different-name')
|
||||||
|
|
||||||
|
def test_011_empty(self):
|
||||||
|
self.write_sparse_chunks(0)
|
||||||
|
qubes.tarwriter.main([self.input_path, self.output_path])
|
||||||
|
self.assertTarExtractable()
|
||||||
|
|
||||||
|
def test_012_gzip(self):
|
||||||
|
self.write_sparse_chunks(0)
|
||||||
|
qubes.tarwriter.main([
|
||||||
|
'--use-compress-program=gzip', self.input_path, self.output_path])
|
||||||
|
with self.assertNotRaises(subprocess.CalledProcessError):
|
||||||
|
subprocess.check_call(['gzip', '--test', self.output_path])
|
||||||
|
self.assertTarExtractable()
|
185
qubes/tools/qvm_backup.py
Normal file
185
qubes/tools/qvm_backup.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/python2
|
||||||
|
# -*- encoding: utf8 -*-
|
||||||
|
#
|
||||||
|
# The Qubes OS Project, http://www.qubes-os.org
|
||||||
|
#
|
||||||
|
# Copyright (C) 2016 Marek Marczykowski-Górecki
|
||||||
|
# <marmarek@invisiblethingslab.com>
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along
|
||||||
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
from __future__ import print_function
|
||||||
|
import getpass
|
||||||
|
import locale
|
||||||
|
import os
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import qubes.backup
|
||||||
|
import qubes.tools
|
||||||
|
import qubes.utils
|
||||||
|
|
||||||
|
parser = qubes.tools.QubesArgumentParser(want_force_root=True)
|
||||||
|
|
||||||
|
parser.add_argument("--exclude", "-x", action="append",
|
||||||
|
dest="exclude_list", default=[],
|
||||||
|
help="Exclude the specified VM from the backup (may be "
|
||||||
|
"repeated)")
|
||||||
|
parser.add_argument("--dest-vm", "-d", action="store",
|
||||||
|
dest="appvm", default=None,
|
||||||
|
help="Specify the destination VM to which the backup "
|
||||||
|
"will be sent (implies -e)")
|
||||||
|
parser.add_argument("--encrypt", "-e", action="store_true", dest="encrypted",
|
||||||
|
default=False,
|
||||||
|
help="Encrypt the backup")
|
||||||
|
parser.add_argument("--no-encrypt", action="store_true",
|
||||||
|
dest="no_encrypt", default=False,
|
||||||
|
help="Skip encryption even if sending the backup to a "
|
||||||
|
"VM")
|
||||||
|
parser.add_argument("--passphrase-file", "-p", action="store",
|
||||||
|
dest="pass_file", default=None,
|
||||||
|
help="Read passphrase from a file, or use '-' to read "
|
||||||
|
"from stdin")
|
||||||
|
parser.add_argument("--enc-algo", "-E", action="store",
|
||||||
|
dest="crypto_algorithm", default=None,
|
||||||
|
help="Specify a non-default encryption algorithm. For a "
|
||||||
|
"list of supported algorithms, execute 'openssl "
|
||||||
|
"list-cipher-algorithms' (implies -e)")
|
||||||
|
parser.add_argument("--hmac-algo", "-H", action="store",
|
||||||
|
dest="hmac_algorithm", default=None,
|
||||||
|
help="Specify a non-default HMAC algorithm. For a list "
|
||||||
|
"of supported algorithms, execute 'openssl "
|
||||||
|
"list-message-digest-algorithms'")
|
||||||
|
parser.add_argument("--compress", "-z", action="store_true", dest="compressed",
|
||||||
|
default=False,
|
||||||
|
help="Compress the backup")
|
||||||
|
parser.add_argument("--compress-filter", "-Z", action="store",
|
||||||
|
dest="compression_filter", default=False,
|
||||||
|
help="Specify a non-default compression filter program "
|
||||||
|
"(default: gzip)")
|
||||||
|
parser.add_argument("--tmpdir", action="store", dest="tmpdir", default=None,
|
||||||
|
help="Specify a temporary directory (if you have at least "
|
||||||
|
"1GB free RAM in dom0, use of /tmp is advised) ("
|
||||||
|
"default: /var/tmp)")
|
||||||
|
|
||||||
|
parser.add_argument("backup_location", action="store",
|
||||||
|
help="Backup location (directory path, or command to pipe backup to)")
|
||||||
|
|
||||||
|
parser.add_argument("vms", nargs="*", action=qubes.tools.VmNameAction,
|
||||||
|
help="Backup only those VMs")
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
|
appvm = None
|
||||||
|
if args.appvm:
|
||||||
|
try:
|
||||||
|
appvm = args.app.domains[args.appvm]
|
||||||
|
except KeyError:
|
||||||
|
parser.error('no such domain: {!r}'.format(args.appvm))
|
||||||
|
args.app.log.info(("NOTE: VM {} will be excluded because it is "
|
||||||
|
"the backup destination.").format(args.appvm))
|
||||||
|
|
||||||
|
if appvm:
|
||||||
|
args.exclude_list.append(appvm.name)
|
||||||
|
if args.appvm or args.crypto_algorithm:
|
||||||
|
args.encrypted = True
|
||||||
|
if args.no_encrypt:
|
||||||
|
args.encrypted = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup = qubes.backup.Backup(args.app,
|
||||||
|
args.domains if args.domains else None,
|
||||||
|
exclude_list=args.exclude_list)
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
parser.error_runtime(str(e))
|
||||||
|
# unreachable - error_runtime will raise SystemExit
|
||||||
|
return 1
|
||||||
|
|
||||||
|
backup.target_dir = args.backup_location
|
||||||
|
|
||||||
|
if not appvm:
|
||||||
|
if os.path.isdir(args.backup_location):
|
||||||
|
stat = os.statvfs(args.backup_location)
|
||||||
|
else:
|
||||||
|
stat = os.statvfs(os.path.dirname(args.backup_location))
|
||||||
|
backup_fs_free_sz = stat.f_bsize * stat.f_bavail
|
||||||
|
print()
|
||||||
|
if backup.total_backup_bytes > backup_fs_free_sz:
|
||||||
|
parser.error_runtime("Not enough space available on the "
|
||||||
|
"backup filesystem!")
|
||||||
|
|
||||||
|
args.app.log.info("Available space: {0}".format(
|
||||||
|
qubes.utils.size_to_human(backup_fs_free_sz)))
|
||||||
|
else:
|
||||||
|
stat = os.statvfs('/var/tmp')
|
||||||
|
backup_fs_free_sz = stat.f_bsize * stat.f_bavail
|
||||||
|
print()
|
||||||
|
if backup_fs_free_sz < 1000000000:
|
||||||
|
parser.error_runtime("Not enough space available "
|
||||||
|
"on the local filesystem (1GB required for temporary files)!")
|
||||||
|
|
||||||
|
if not appvm.is_running():
|
||||||
|
appvm.start()
|
||||||
|
|
||||||
|
if not args.encrypted:
|
||||||
|
args.app.log.info("WARNING: The backup will NOT be encrypted!")
|
||||||
|
|
||||||
|
if args.pass_file is not None:
|
||||||
|
pass_f = open(args.pass_file) if args.pass_file != "-" else sys.stdin
|
||||||
|
passphrase = pass_f.readline().rstrip()
|
||||||
|
if pass_f is not sys.stdin:
|
||||||
|
pass_f.close()
|
||||||
|
|
||||||
|
else:
|
||||||
|
if raw_input("Do you want to proceed? [y/N] ").upper() != "Y":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
prompt = ("Please enter the passphrase that will be used to {}verify "
|
||||||
|
"the backup: ").format('encrypt and ' if args.encrypted else '')
|
||||||
|
passphrase = getpass.getpass(prompt)
|
||||||
|
|
||||||
|
if getpass.getpass("Enter again for verification: ") != passphrase:
|
||||||
|
parser.error_runtime("Passphrase mismatch!")
|
||||||
|
|
||||||
|
backup.encrypted = args.encrypted
|
||||||
|
backup.compressed = args.compressed
|
||||||
|
if args.compression_filter:
|
||||||
|
backup.compression_filter = args.compression_filter
|
||||||
|
|
||||||
|
encoding = sys.stdin.encoding or locale.getpreferredencoding()
|
||||||
|
backup.passphrase = passphrase.decode(encoding)
|
||||||
|
|
||||||
|
if args.hmac_algorithm:
|
||||||
|
backup.hmac_algorithm = args.hmac_algorithm
|
||||||
|
if args.crypto_algorithm:
|
||||||
|
backup.crypto_algorithm = args.crypto_algorithm
|
||||||
|
if args.tmpdir:
|
||||||
|
backup.tmpdir = args.tmpdir
|
||||||
|
if appvm:
|
||||||
|
backup.target_vm = appvm
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup.backup_do()
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
parser.error_runtime(str(e))
|
||||||
|
|
||||||
|
print()
|
||||||
|
args.app.log.info("Backup completed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
261
qubes/tools/qvm_backup_restore.py
Normal file
261
qubes/tools/qvm_backup_restore.py
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/python2
|
||||||
|
# -*- encoding: utf8 -*-
|
||||||
|
#
|
||||||
|
# The Qubes OS Project, http://www.qubes-os.org
|
||||||
|
#
|
||||||
|
# Copyright (C) 2016 Marek Marczykowski-Górecki
|
||||||
|
# <marmarek@invisiblethingslab.com>
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along
|
||||||
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
from __future__ import print_function
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import locale
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import qubes.backup
|
||||||
|
import qubes.tools
|
||||||
|
import qubes.utils
|
||||||
|
|
||||||
|
parser = qubes.tools.QubesArgumentParser(want_force_root=True)
|
||||||
|
|
||||||
|
parser.add_argument("--verify-only", action="store_true",
|
||||||
|
dest="verify_only", default=False,
|
||||||
|
help="Verify backup integrity without restoring any "
|
||||||
|
"data")
|
||||||
|
|
||||||
|
parser.add_argument("--skip-broken", action="store_true", dest="skip_broken",
|
||||||
|
default=False,
|
||||||
|
help="Do not restore VMs that have missing TemplateVMs "
|
||||||
|
"or NetVMs")
|
||||||
|
|
||||||
|
parser.add_argument("--ignore-missing", action="store_true",
|
||||||
|
dest="ignore_missing", default=False,
|
||||||
|
help="Restore VMs even if their associated TemplateVMs "
|
||||||
|
"and NetVMs are missing")
|
||||||
|
|
||||||
|
parser.add_argument("--skip-conflicting", action="store_true",
|
||||||
|
dest="skip_conflicting", default=False,
|
||||||
|
help="Do not restore VMs that are already present on "
|
||||||
|
"the host")
|
||||||
|
|
||||||
|
parser.add_argument("--rename-conflicting", action="store_true",
|
||||||
|
dest="rename_conflicting", default=False,
|
||||||
|
help="Restore VMs that are already present on the host "
|
||||||
|
"under different names")
|
||||||
|
|
||||||
|
parser.add_argument("--replace-template", action="append",
|
||||||
|
dest="replace_template", default=[],
|
||||||
|
help="Restore VMs using another TemplateVM; syntax: "
|
||||||
|
"old-template-name:new-template-name (may be "
|
||||||
|
"repeated)")
|
||||||
|
|
||||||
|
parser.add_argument("-x", "--exclude", action="append", dest="exclude",
|
||||||
|
default=[],
|
||||||
|
help="Skip restore of specified VM (may be repeated)")
|
||||||
|
|
||||||
|
parser.add_argument("--skip-dom0-home", action="store_false", dest="dom0_home",
|
||||||
|
default=True,
|
||||||
|
help="Do not restore dom0 user home directory")
|
||||||
|
|
||||||
|
parser.add_argument("--ignore-username-mismatch", action="store_true",
|
||||||
|
dest="ignore_username_mismatch", default=False,
|
||||||
|
help="Ignore dom0 username mismatch when restoring home "
|
||||||
|
"directory")
|
||||||
|
|
||||||
|
parser.add_argument("-d", "--dest-vm", action="store", dest="appvm",
|
||||||
|
help="Specify VM containing the backup to be restored")
|
||||||
|
|
||||||
|
parser.add_argument("-p", "--passphrase-file", action="store",
|
||||||
|
dest="pass_file", default=None,
|
||||||
|
help="Read passphrase from file, or use '-' to read from stdin")
|
||||||
|
|
||||||
|
parser.add_argument('backup_location', action='store',
|
||||||
|
help="Backup directory name, or command to pipe from")
|
||||||
|
|
||||||
|
parser.add_argument('vms', nargs='*', action='store', default='[]',
|
||||||
|
help='Restore only those VMs')
|
||||||
|
|
||||||
|
|
||||||
|
def handle_broken(app, args, restore_info):
|
||||||
|
there_are_conflicting_vms = False
|
||||||
|
there_are_missing_templates = False
|
||||||
|
there_are_missing_netvms = False
|
||||||
|
dom0_username_mismatch = False
|
||||||
|
|
||||||
|
for vm_info in restore_info.values():
|
||||||
|
assert isinstance(vm_info, qubes.backup.BackupRestore.VMToRestore)
|
||||||
|
if qubes.backup.BackupRestore.VMToRestore.EXCLUDED in vm_info.problems:
|
||||||
|
continue
|
||||||
|
if qubes.backup.BackupRestore.VMToRestore.MISSING_TEMPLATE in \
|
||||||
|
vm_info.problems:
|
||||||
|
there_are_missing_templates = True
|
||||||
|
if qubes.backup.BackupRestore.VMToRestore.MISSING_NETVM in \
|
||||||
|
vm_info.problems:
|
||||||
|
there_are_missing_netvms = True
|
||||||
|
if qubes.backup.BackupRestore.VMToRestore.ALREADY_EXISTS in \
|
||||||
|
vm_info.problems:
|
||||||
|
there_are_conflicting_vms = True
|
||||||
|
if qubes.backup.BackupRestore.Dom0ToRestore.USERNAME_MISMATCH in \
|
||||||
|
vm_info.problems:
|
||||||
|
dom0_username_mismatch = True
|
||||||
|
|
||||||
|
|
||||||
|
if there_are_conflicting_vms:
|
||||||
|
app.log.error(
|
||||||
|
"*** There are VMs with conflicting names on the host! ***")
|
||||||
|
if args.skip_conflicting:
|
||||||
|
app.log.error(
|
||||||
|
"Those VMs will not be restored. "
|
||||||
|
"The host VMs will NOT be overwritten.")
|
||||||
|
else:
|
||||||
|
raise qubes.exc.QubesException(
|
||||||
|
"Remove VMs with conflicting names from the host "
|
||||||
|
"before proceeding.\n"
|
||||||
|
"Or use --skip-conflicting to restore only those VMs that "
|
||||||
|
"do not exist on the host.\n"
|
||||||
|
"Or use --rename-conflicting to restore those VMs under "
|
||||||
|
"modified names (with numbers at the end).")
|
||||||
|
|
||||||
|
app.log.info("The above VMs will be copied and added to your system.")
|
||||||
|
app.log.info("Exisiting VMs will NOT be removed.")
|
||||||
|
|
||||||
|
if there_are_missing_templates:
|
||||||
|
app.log.warning("*** One or more TemplateVMs are missing on the "
|
||||||
|
"host! ***")
|
||||||
|
if not (args.skip_broken or args.ignore_missing):
|
||||||
|
raise qubes.exc.QubesException(
|
||||||
|
"Install them before proceeding with the restore."
|
||||||
|
"Or pass: --skip-broken or --ignore-missing.")
|
||||||
|
elif args.skip_broken:
|
||||||
|
app.log.warning("Skipping broken entries: VMs that depend on "
|
||||||
|
"missing TemplateVMs will NOT be restored.")
|
||||||
|
elif args.ignore_missing:
|
||||||
|
app.log.warning("Ignoring missing entries: VMs that depend "
|
||||||
|
"on missing TemplateVMs will NOT be restored.")
|
||||||
|
else:
|
||||||
|
raise qubes.exc.QubesException(
|
||||||
|
"INTERNAL ERROR! Please report this to the Qubes OS team!")
|
||||||
|
|
||||||
|
if there_are_missing_netvms:
|
||||||
|
app.log.warning("*** One or more NetVMs are missing on the "
|
||||||
|
"host! ***")
|
||||||
|
if not (args.skip_broken or args.ignore_missing):
|
||||||
|
raise qubes.exc.QubesException(
|
||||||
|
"Install them before proceeding with the restore."
|
||||||
|
"Or pass: --skip-broken or --ignore-missing.")
|
||||||
|
elif args.skip_broken:
|
||||||
|
app.log.warning("Skipping broken entries: VMs that depend on "
|
||||||
|
"missing NetVMs will NOT be restored.")
|
||||||
|
elif args.ignore_missing:
|
||||||
|
app.log.warning("Ignoring missing entries: VMs that depend "
|
||||||
|
"on missing NetVMs will NOT be restored.")
|
||||||
|
else:
|
||||||
|
raise qubes.exc.QubesException(
|
||||||
|
"INTERNAL ERROR! Please report this to the Qubes OS team!")
|
||||||
|
|
||||||
|
if 'dom0' in restore_info.keys() and args.dom0_home:
|
||||||
|
if dom0_username_mismatch:
|
||||||
|
app.log.warning("*** Dom0 username mismatch! This can break "
|
||||||
|
"some settings! ***")
|
||||||
|
if not args.ignore_username_mismatch:
|
||||||
|
raise qubes.exc.QubesException(
|
||||||
|
"Skip restoring the dom0 home directory "
|
||||||
|
"(--skip-dom0-home), or pass "
|
||||||
|
"--ignore-username-mismatch to continue anyway.")
|
||||||
|
else:
|
||||||
|
app.log.warning("Continuing as directed.")
|
||||||
|
app.log.warning("NOTE: Before restoring the dom0 home directory, "
|
||||||
|
"a new directory named "
|
||||||
|
"'home-pre-restore-<current-time>' will be "
|
||||||
|
"created inside the dom0 home directory. If any "
|
||||||
|
"restored files conflict with existing files, "
|
||||||
|
"the existing files will be moved to this new "
|
||||||
|
"directory.")
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
# pylint: disable=too-many-return-statements
|
||||||
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
|
appvm = None
|
||||||
|
if args.appvm:
|
||||||
|
try:
|
||||||
|
appvm = args.app.domains[args.appvm]
|
||||||
|
except KeyError:
|
||||||
|
parser.error('no such domain: {!r}'.format(args.appvm))
|
||||||
|
|
||||||
|
if args.pass_file is not None:
|
||||||
|
pass_f = open(args.pass_file) if args.pass_file != "-" else sys.stdin
|
||||||
|
passphrase = pass_f.readline().rstrip()
|
||||||
|
if pass_f is not sys.stdin:
|
||||||
|
pass_f.close()
|
||||||
|
else:
|
||||||
|
passphrase = getpass.getpass("Please enter the passphrase to verify "
|
||||||
|
"and (if encrypted) decrypt the backup: ")
|
||||||
|
|
||||||
|
encoding = sys.stdin.encoding or locale.getpreferredencoding()
|
||||||
|
# pylint: disable=redefined-variable-type
|
||||||
|
passphrase = passphrase.decode(encoding)
|
||||||
|
|
||||||
|
args.app.log.info("Checking backup content...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup = qubes.backup.BackupRestore(args.app, args.backup_location,
|
||||||
|
appvm, passphrase)
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
parser.error_runtime(str(e))
|
||||||
|
# unreachable - error_runtime will raise SystemExit
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.ignore_missing:
|
||||||
|
backup.options.use_default_template = True
|
||||||
|
backup.options.use_default_netvm = True
|
||||||
|
if args.replace_template:
|
||||||
|
backup.options.replace_template = args.replace_template
|
||||||
|
if args.rename_conflicting:
|
||||||
|
backup.options.rename_conflicting = True
|
||||||
|
if not args.dom0_home:
|
||||||
|
backup.options.dom0_home = False
|
||||||
|
if args.ignore_username_mismatch:
|
||||||
|
backup.options.ignore_username_mismatch = True
|
||||||
|
if args.exclude:
|
||||||
|
backup.options.exclude = args.exclude
|
||||||
|
if args.verify_only:
|
||||||
|
backup.options.verify_only = True
|
||||||
|
|
||||||
|
restore_info = None
|
||||||
|
try:
|
||||||
|
restore_info = backup.get_restore_info()
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
parser.error_runtime(str(e))
|
||||||
|
|
||||||
|
print(backup.get_restore_summary(restore_info))
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle_broken(args.app, args, restore_info)
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
parser.error_runtime(str(e))
|
||||||
|
|
||||||
|
if args.pass_file is None:
|
||||||
|
if raw_input("Do you want to proceed? [y/N] ").upper() != "Y":
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup.restore_do(restore_info)
|
||||||
|
except qubes.exc.QubesException as e:
|
||||||
|
parser.error_runtime(str(e))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
@ -1559,7 +1559,7 @@ def _patch_pool_config(config, pool=None, pools=None):
|
|||||||
|
|
||||||
name = config['name']
|
name = config['name']
|
||||||
|
|
||||||
if pool and is_exportable:
|
if pool and is_exportable and config['pool'] == 'default':
|
||||||
config['pool'] = str(pool)
|
config['pool'] = str(pool)
|
||||||
elif pool and not is_exportable:
|
elif pool and not is_exportable:
|
||||||
pass
|
pass
|
||||||
|
@ -220,6 +220,7 @@ fi
|
|||||||
%{python_sitelib}/qubes/exc.py*
|
%{python_sitelib}/qubes/exc.py*
|
||||||
%{python_sitelib}/qubes/log.py*
|
%{python_sitelib}/qubes/log.py*
|
||||||
%{python_sitelib}/qubes/rngdoc.py*
|
%{python_sitelib}/qubes/rngdoc.py*
|
||||||
|
%{python_sitelib}/qubes/tarwriter.py*
|
||||||
%{python_sitelib}/qubes/utils.py*
|
%{python_sitelib}/qubes/utils.py*
|
||||||
|
|
||||||
%dir %{python_sitelib}/qubes/vm
|
%dir %{python_sitelib}/qubes/vm
|
||||||
@ -250,6 +251,8 @@ fi
|
|||||||
%{python_sitelib}/qubes/tools/qubes_prefs.py*
|
%{python_sitelib}/qubes/tools/qubes_prefs.py*
|
||||||
%{python_sitelib}/qubes/tools/qvm_block.py*
|
%{python_sitelib}/qubes/tools/qvm_block.py*
|
||||||
%{python_sitelib}/qubes/tools/qubes_lvm.py*
|
%{python_sitelib}/qubes/tools/qubes_lvm.py*
|
||||||
|
%{python_sitelib}/qubes/tools/qvm_backup.py*
|
||||||
|
%{python_sitelib}/qubes/tools/qvm_backup_restore.py*
|
||||||
%{python_sitelib}/qubes/tools/qvm_create.py*
|
%{python_sitelib}/qubes/tools/qvm_create.py*
|
||||||
%{python_sitelib}/qubes/tools/qvm_device.py*
|
%{python_sitelib}/qubes/tools/qvm_device.py*
|
||||||
%{python_sitelib}/qubes/tools/qvm_features.py*
|
%{python_sitelib}/qubes/tools/qvm_features.py*
|
||||||
@ -288,6 +291,7 @@ fi
|
|||||||
%{python_sitelib}/qubes/tests/storage.py*
|
%{python_sitelib}/qubes/tests/storage.py*
|
||||||
%{python_sitelib}/qubes/tests/storage_file.py*
|
%{python_sitelib}/qubes/tests/storage_file.py*
|
||||||
%{python_sitelib}/qubes/tests/storage_lvm.py*
|
%{python_sitelib}/qubes/tests/storage_lvm.py*
|
||||||
|
%{python_sitelib}/qubes/tests/tarwriter.py*
|
||||||
|
|
||||||
%dir %{python_sitelib}/qubes/tests/vm
|
%dir %{python_sitelib}/qubes/tests/vm
|
||||||
%{python_sitelib}/qubes/tests/vm/__init__.py*
|
%{python_sitelib}/qubes/tests/vm/__init__.py*
|
||||||
|
Loading…
Reference in New Issue
Block a user