Skip to content

Commit

Permalink
tools: add qvm-backup tool
Browse files Browse the repository at this point in the history
New qvm-backup tool can either use pre-existing backup profile
(--profile), or - when running in dom0 - can create new one based on
used options (--save-profile).

This commit add a tool itself, update its man page, and add tests for
it.

Fixes QubesOS/qubes-issues#2931
  • Loading branch information
marmarek committed Jul 21, 2017
1 parent d8af76e commit 2d5d9d6
Show file tree
Hide file tree
Showing 4 changed files with 475 additions and 34 deletions.
1 change: 1 addition & 0 deletions ci/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ codecov
python-daemon
mock
lxml
PyYAML
50 changes: 16 additions & 34 deletions doc/manpages/qvm-backup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,29 @@
:program:`qvm-backup` -- Create a backup of Qubes
=================================================

.. warning::

This page was autogenerated from command-line parser. It shouldn't be 1:1
conversion, because it would add little value. Please revise it and add
more descriptive help, which normally won't fit in standard ``--help``
option.

After rewrite, please remove this admonition.

Synopsis
--------

:command:`qvm-backup` [-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 ...]]
:command:`qvm-backup` [-h] [--verbose] [--quiet] [--profile *PROFILE*] [--exclude EXCLUDE_LIST] [--dest-vm *APPVM*] [--encrypt] [--passphrase-file PASSPHRASE_FILE] [--compress] [--compress-filter *COMPRESSION*] [--save-profile SAVE_PROFILE] backup_location [vms [vms ...]]


Options
-------

.. option:: --profile

Specify backup profile to use. This option is mutually exclusive with all
other options. This is also the only working mode when running from non-dom0.

.. option:: --save-profile

Save backup profile based on given options. This is possible only when
running in dom0. Otherwise, prepared profile is printed on standard output
and user needs to manually place it into /etc/qubes/backup in dom0.

.. option:: --help, -h

show this help message and exit
show help message and exit

.. option:: --verbose, -v

Expand All @@ -32,10 +35,6 @@ Options

decrease verbosity

.. option:: --force-root

force to run as root

.. option:: --exclude, -x

Exclude the specified VM from the backup (may be repeated)
Expand All @@ -46,24 +45,12 @@ Options

.. option:: --encrypt, -e

Encrypt the backup

.. option:: --no-encrypt

Skip encryption even if sending the backup to a VM
Ignored, backup is always encrypted

.. 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
Expand All @@ -72,17 +59,12 @@ Options

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.
backup. If not specified, all qubes are included.

Authors
-------
Expand Down
237 changes: 237 additions & 0 deletions qubesadmin/tests/tools/qvm_backup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# -*- encoding: utf8 -*-
#
# The Qubes OS Project, http://www.qubes-os.org
#
# Copyright (C) 2017 Marek Marczykowski-Górecki
# <[email protected]>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program; if not, see <http://www.gnu.org/licenses/>.
import io
import os
import unittest.mock as mock

import asyncio

import qubesadmin.tests
import qubesadmin.tests.tools
import qubesadmin.tools.qvm_backup as qvm_backup

class TC_00_qvm_backup(qubesadmin.tests.QubesTestCase):
def test_000_write_backup_profile(self):
args = qvm_backup.parser.parse_args(['/var/tmp'], app=self.app)
profile = io.StringIO()
qvm_backup.write_backup_profile(profile, args)
expected_profile = (
'destination_path: /var/tmp\n'
'destination_vm: dom0\n'
'include: [\'$type:AppVM\', \'$type:TemplateVM\', '
'\'$type:StandaloneVM\']\n'
)
self.assertEqual(profile.getvalue(), expected_profile)

def test_001_write_backup_profile_include(self):
self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
b'0\0dom0 class=AdminVM state=Running\n' \
b'vm1 class=AppVM state=Halted\n' \
b'vm2 class=AppVM state=Halted\n' \
b'vm3 class=AppVM state=Halted\n'
args = qvm_backup.parser.parse_args(['/var/tmp', 'vm1', 'vm2'],
app=self.app)
profile = io.StringIO()
qvm_backup.write_backup_profile(profile, args)
expected_profile = (
'destination_path: /var/tmp\n'
'destination_vm: dom0\n'
'include: [vm1, vm2]\n'
)
self.assertEqual(profile.getvalue(), expected_profile)
self.assertAllCalled()

def test_002_write_backup_profile_exclude(self):
args = qvm_backup.parser.parse_args([
'-x', 'vm1', '-x', 'vm2', '/var/tmp'], app=self.app)
profile = io.StringIO()
qvm_backup.write_backup_profile(profile, args)
expected_profile = (
'destination_path: /var/tmp\n'
'destination_vm: dom0\n'
'exclude: [vm1, vm2]\n'
'include: [\'$type:AppVM\', \'$type:TemplateVM\', '
'\'$type:StandaloneVM\']\n'
)
self.assertEqual(profile.getvalue(), expected_profile)

def test_003_write_backup_with_passphrase(self):
args = qvm_backup.parser.parse_args(['/var/tmp'], app=self.app)
profile = io.StringIO()
qvm_backup.write_backup_profile(profile, args, passphrase='test123')
expected_profile = (
'destination_path: /var/tmp\n'
'destination_vm: dom0\n'
'include: [\'$type:AppVM\', \'$type:TemplateVM\', '
'\'$type:StandaloneVM\']\n'
'passphrase_text: test123\n'
)
self.assertEqual(profile.getvalue(), expected_profile)

@mock.patch('qubesadmin.tools.qvm_backup.backup_profile_dir', '/tmp')
@mock.patch('qubesadmin.tools.qvm_backup.input', create=True)
@mock.patch('getpass.getpass')
def test_010_main_save_profile_cancel(self, mock_getpass, mock_input):
asyncio.set_event_loop(asyncio.new_event_loop())
mock_input.return_value = 'n'
mock_getpass.return_value = 'some password'
self.app.qubesd_connection_type = 'socket'
self.app.expected_calls[('dom0', 'admin.backup.Info', 'test-profile',
None)] = \
b'0\0backup summary'
profile_path = '/tmp/test-profile.conf'
try:
qvm_backup.main(['--save-profile', 'test-profile', '/var/tmp'],
app=self.app)
expected_profile = (
'destination_path: /var/tmp\n'
'destination_vm: dom0\n'
'include: [\'$type:AppVM\', \'$type:TemplateVM\', '
'\'$type:StandaloneVM\']\n'
)
with open(profile_path) as f:
self.assertEqual(expected_profile, f.read())
finally:
os.unlink(profile_path)

@mock.patch('qubesadmin.tools.qvm_backup.backup_profile_dir', '/tmp')
@mock.patch('qubesadmin.tools.qvm_backup.input', create=True)
@mock.patch('getpass.getpass')
def test_011_main_save_profile_confirm(self, mock_getpass, mock_input):
asyncio.set_event_loop(asyncio.new_event_loop())
mock_input.return_value = 'y'
mock_getpass.return_value = 'some password'
self.app.qubesd_connection_type = 'socket'
self.app.expected_calls[('dom0', 'admin.backup.Info', 'test-profile',
None)] = \
b'0\0backup summary'
self.app.expected_calls[('dom0', 'admin.backup.Execute', 'test-profile',
None)] = \
b'0\0'
profile_path = '/tmp/test-profile.conf'
try:
qvm_backup.main(['--save-profile', 'test-profile', '/var/tmp'],
app=self.app)
expected_profile = (
'destination_path: /var/tmp\n'
'destination_vm: dom0\n'
'include: [\'$type:AppVM\', \'$type:TemplateVM\', '
'\'$type:StandaloneVM\']\n'
'passphrase_text: some password\n'
)
with open(profile_path) as f:
self.assertEqual(expected_profile, f.read())
finally:
os.unlink(profile_path)

@mock.patch('qubesadmin.tools.qvm_backup.backup_profile_dir', '/tmp')
@mock.patch('qubesadmin.tools.qvm_backup.input', create=True)
@mock.patch('getpass.getpass')
def test_012_main_existing_profile(self, mock_getpass, mock_input):
asyncio.set_event_loop(asyncio.new_event_loop())
mock_input.return_value = 'y'
mock_getpass.return_value = 'some password'
self.app.qubesd_connection_type = 'socket'
self.app.expected_calls[('dom0', 'admin.backup.Info', 'test-profile',
None)] = \
b'0\0backup summary'
self.app.expected_calls[('dom0', 'admin.backup.Execute', 'test-profile',
None)] = \
b'0\0'
self.app.expected_calls[('dom0', 'admin.Events', None,
None)] = \
b'0\0'
try:
patch = mock.patch(
'qubesadmin.events.EventsDispatcher._get_events_reader')
mock_events = patch.start()
self.addCleanup(patch.stop)
mock_events.side_effect = qubesadmin.tests.tools.MockEventsReader([
b'1\0\0connection-established\0\0',
b'1\0\0backup-progress\0backup_profile\0test-profile\0progress\x000'
b'.25\0\0',
])
except ImportError:
pass

qvm_backup.main(['--profile', 'test-profile'],
app=self.app)
self.assertFalse(os.path.exists('/tmp/test-profile.conf'))
self.assertFalse(mock_getpass.called)

@mock.patch('qubesadmin.tools.qvm_backup.backup_profile_dir', '/tmp')
@mock.patch('qubesadmin.tools.qvm_backup.input', create=True)
@mock.patch('getpass.getpass')
def test_013_main_new_profile_vm(self, mock_getpass, mock_input):
asyncio.set_event_loop(asyncio.new_event_loop())
mock_input.return_value = 'y'
mock_getpass.return_value = 'some password'
self.app.qubesd_connection_type = 'qrexec'
with qubesadmin.tests.tools.StdoutBuffer() as stdout:
qvm_backup.main(['-x', 'vm1', '/var/tmp'],
app=self.app)
expected_output = (
'To perform the backup according to selected options, create '
'backup profile (/tmp/profile_name.conf) in dom0 with following '
'content:\n'
'destination_path: /var/tmp\n'
'destination_vm: dom0\n'
'exclude: [vm1]\n'
'include: [\'$type:AppVM\', \'$type:TemplateVM\', '
'\'$type:StandaloneVM\']\n'
'# specify backup passphrase below\n'
'passphrase_text: ...\n'
)
self.assertEqual(stdout.getvalue(), expected_output)

@mock.patch('qubesadmin.tools.qvm_backup.backup_profile_dir', '/tmp')
@mock.patch('qubesadmin.tools.qvm_backup.input', create=True)
@mock.patch('getpass.getpass')
def test_014_main_passphrase_file(self, mock_getpass, mock_input):
asyncio.set_event_loop(asyncio.new_event_loop())
mock_input.return_value = 'y'
mock_getpass.return_value = 'some password'
self.app.qubesd_connection_type = 'socket'
self.app.expected_calls[('dom0', 'admin.backup.Info', 'test-profile',
None)] = \
b'0\0backup summary'
self.app.expected_calls[('dom0', 'admin.backup.Execute', 'test-profile',
None)] = \
b'0\0'
profile_path = '/tmp/test-profile.conf'
try:
stdin = io.StringIO()
stdin.write('other passphrase\n')
stdin.seek(0)
with mock.patch('sys.stdin', stdin):
qvm_backup.main(['--passphrase-file', '-', '--save-profile',
'test-profile', '/var/tmp'],
app=self.app)
expected_profile = (
'destination_path: /var/tmp\n'
'destination_vm: dom0\n'
'include: [\'$type:AppVM\', \'$type:TemplateVM\', '
'\'$type:StandaloneVM\']\n'
'passphrase_text: other passphrase\n'
)
with open(profile_path) as f:
self.assertEqual(expected_profile, f.read())
finally:
os.unlink(profile_path)
Loading

0 comments on commit 2d5d9d6

Please sign in to comment.