From e700af9eb2c1b39b065c476b2502a1f6a94d054e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Marczykowski-G=C3=B3recki?= Date: Mon, 29 Jul 2019 20:53:45 +0200 Subject: [PATCH] tools/qvm-volume: add 'import' action Add support for importing volume data with qvm-volume tool. This could be also used to clear volume by issuing: qvm-volume import --no-resize some-vm:private /dev/null QubesOS/qubes-issues#5192 --- doc/manpages/qvm-volume.rst | 26 +++++ qubesadmin/tests/tools/qvm_volume.py | 162 +++++++++++++++++++++++++++ qubesadmin/tools/qvm_volume.py | 47 ++++++++ 3 files changed, 235 insertions(+) diff --git a/doc/manpages/qvm-volume.rst b/doc/manpages/qvm-volume.rst index e893fcb..78d1029 100644 --- a/doc/manpages/qvm-volume.rst +++ b/doc/manpages/qvm-volume.rst @@ -110,6 +110,32 @@ Revert a volume to previous revision. aliases: rv, r +import +^^^^^^ +| :command:`qvm-volume import` [-h] [--size=SIZE|--no-resize] [--verbose] [--quiet] *VMNAME:VOLUME* *PATH* + +Import file *PATH* into volume *VMNAME:VOLUME*. Use `-` as *PATH* to import from +stdin. + +The tool will try to resize volume to match input size before the import. In +case of importing from stdin, you may need to provide size explicitly with +`--size` option. You can keep previous volume size by using `--no-resize` +option. + +A specific use case is importing empty data to clear private volume: + +| :command:`qvm-volume` import --no-resize some-vm:private /dev/null + +Old data will be stored as a revision, subject to `revisions_to_keep` limit. + +.. option:: --size + + Provide the size explicitly, instead of using *FILE* size. + +.. option:: --no-resize + + Do not resize volume before the import. + Authors ------- diff --git a/qubesadmin/tests/tools/qvm_volume.py b/qubesadmin/tests/tools/qvm_volume.py index 7f27b9c..37af3a8 100644 --- a/qubesadmin/tests/tools/qvm_volume.py +++ b/qubesadmin/tests/tools/qvm_volume.py @@ -17,6 +17,8 @@ # # You should have received a copy of the GNU Lesser General Public License along # with this program; if not, see . +import tempfile +import unittest.mock import qubesadmin.tests import qubesadmin.tests.tools @@ -487,3 +489,163 @@ class TC_00_qvm_volume(qubesadmin.tests.QubesTestCase): '200201010000\n' '200301010000\n') self.assertAllCalled() + + def test_050_import_file(self): + self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \ + b'0\x00testvm class=AppVM state=Running\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.List', None, None)] = \ + b'0\x00root\nprivate\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Info', 'private', None)] = \ + b'0\x00pool=lvm\n' \ + b'vid=qubes_dom0/vm-testvm-private\n' \ + b'size=2147483648\n' \ + b'usage=10000000\n' \ + b'rw=True\n' \ + b'save_on_stop=True\n' \ + b'snap_on_start=False\n' \ + b'revisions_to_keep=0\n' \ + b'is_outdated=False\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Resize', 'private', b'9')] = \ + b'0\x00' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Import', 'private', b'test-data')] = \ + b'0\x00' + with tempfile.NamedTemporaryFile() as input_file: + input_file.write(b'test-data') + input_file.seek(0) + input_file.flush() + self.assertEqual(0, + qubesadmin.tools.qvm_volume.main( + ['import', 'testvm:private', input_file.name], + app=self.app)) + self.assertAllCalled() + + def test_051_import_stdin(self): + self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \ + b'0\x00testvm class=AppVM state=Running\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.List', None, None)] = \ + b'0\x00root\nprivate\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Info', 'private', None)] = \ + b'0\x00pool=lvm\n' \ + b'vid=qubes_dom0/vm-testvm-private\n' \ + b'size=2147483648\n' \ + b'usage=10000000\n' \ + b'rw=True\n' \ + b'save_on_stop=True\n' \ + b'snap_on_start=False\n' \ + b'revisions_to_keep=0\n' \ + b'is_outdated=False\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Resize', 'private', b'9')] = \ + b'0\x00' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Import', 'private', b'test-data')] = \ + b'0\x00' + with tempfile.NamedTemporaryFile() as input_file: + input_file.write(b'test-data') + input_file.seek(0) + with unittest.mock.patch('sys.stdin') as mock_stdin: + mock_stdin.buffer = input_file + self.assertEqual(0, + qubesadmin.tools.qvm_volume.main( + ['import', 'testvm:private', '-'], + app=self.app)) + self.assertAllCalled() + + def test_052_import_file_size(self): + self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \ + b'0\x00testvm class=AppVM state=Running\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.List', None, None)] = \ + b'0\x00root\nprivate\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Info', 'private', None)] = \ + b'0\x00pool=lvm\n' \ + b'vid=qubes_dom0/vm-testvm-private\n' \ + b'size=2147483648\n' \ + b'usage=10000000\n' \ + b'rw=True\n' \ + b'save_on_stop=True\n' \ + b'snap_on_start=False\n' \ + b'revisions_to_keep=0\n' \ + b'is_outdated=False\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Resize', 'private', b'512')] = \ + b'0\x00' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Import', 'private', b'test-data')] = \ + b'0\x00' + with tempfile.NamedTemporaryFile() as input_file: + input_file.write(b'test-data') + input_file.seek(0) + input_file.flush() + self.assertEqual(0, + qubesadmin.tools.qvm_volume.main( + ['import', '--size=512', 'testvm:private', input_file.name], + app=self.app)) + self.assertAllCalled() + + def test_053_import_file_noresize(self): + self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \ + b'0\x00testvm class=AppVM state=Running\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.List', None, None)] = \ + b'0\x00root\nprivate\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Info', 'private', None)] = \ + b'0\x00pool=lvm\n' \ + b'vid=qubes_dom0/vm-testvm-private\n' \ + b'size=2147483648\n' \ + b'usage=10000000\n' \ + b'rw=True\n' \ + b'save_on_stop=True\n' \ + b'snap_on_start=False\n' \ + b'revisions_to_keep=0\n' \ + b'is_outdated=False\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Import', 'private', b'test-data')] = \ + b'0\x00' + with tempfile.NamedTemporaryFile() as input_file: + input_file.write(b'test-data') + input_file.seek(0) + input_file.flush() + self.assertEqual(0, + qubesadmin.tools.qvm_volume.main( + ['import', '--no-resize', 'testvm:private', input_file.name], + app=self.app)) + self.assertAllCalled() + + def test_053_import_file_matching_size(self): + self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \ + b'0\x00testvm class=AppVM state=Running\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.List', None, None)] = \ + b'0\x00root\nprivate\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Info', 'private', None)] = \ + b'0\x00pool=lvm\n' \ + b'vid=qubes_dom0/vm-testvm-private\n' \ + b'size=9\n' \ + b'usage=1\n' \ + b'rw=True\n' \ + b'save_on_stop=True\n' \ + b'snap_on_start=False\n' \ + b'revisions_to_keep=0\n' \ + b'is_outdated=False\n' + self.app.expected_calls[ + ('testvm', 'admin.vm.volume.Import', 'private', b'test-data')] = \ + b'0\x00' + with tempfile.NamedTemporaryFile() as input_file: + input_file.write(b'test-data') + input_file.seek(0) + input_file.flush() + self.assertEqual(0, + qubesadmin.tools.qvm_volume.main( + ['import', 'testvm:private', input_file.name], + app=self.app)) + self.assertAllCalled() diff --git a/qubesadmin/tools/qvm_volume.py b/qubesadmin/tools/qvm_volume.py index 62579d0..018e9f8 100644 --- a/qubesadmin/tools/qvm_volume.py +++ b/qubesadmin/tools/qvm_volume.py @@ -25,6 +25,7 @@ from __future__ import print_function import argparse +import os import sys import collections @@ -132,6 +133,37 @@ def config_volume(args): 'Invalid property: {}'.format(args.property)) setattr(volume, args.property, args.value) +def import_volume(args): + ''' Import a file into volume ''' + + volume = args.volume + old_size = volume.size + input_path = args.input_path + if input_path == '-': + input_file = sys.stdin.buffer + else: + input_file = open(input_path, 'rb') + try: + if not args.no_resize: + if args.size: + size = args.size + else: + try: + size = os.stat(input_file.fileno()).st_size + except OSError as e: + raise qubesadmin.exc.QubesException( + 'Failed to get %s file size, ' + 'specify it explicitly with --size, ' + 'or use --no-resize option', str(e)) + if size > old_size: + volume.resize(size) + volume.import_data(stream=input_file) + if not args.no_resize and size < old_size: + volume.resize(size) + finally: + if input_path != '-': + input_file.close() + def list_volumes(args): ''' Called by the parser to execute the qvm-volume list subcommand. ''' app = args.app @@ -276,6 +308,20 @@ def init_config_parser(sub_parsers): info_parser.add_argument(dest='value', action='store') info_parser.set_defaults(func=config_volume) +def init_import_parser(sub_parsers): + ''' Add 'import' action related options ''' + import_parser = sub_parsers.add_parser( + 'import', help='import volume data') + import_parser.add_argument(metavar='VM:VOLUME', dest='volume', + action=qubesadmin.tools.VMVolumeAction) + import_parser.add_argument('input_path', metavar='PATH', + help='File path to import, use \'-\' for standard input') + import_parser.add_argument('--size', action='store', type=int, + help='Set volume size to this value in bytes') + import_parser.add_argument('--no-resize', action='store_true', + help='Do not resize volume before importing data') + import_parser.set_defaults(func=import_volume) + def get_parser(): '''Create :py:class:`argparse.ArgumentParser` suitable for :program:`qvm-volume`. @@ -293,6 +339,7 @@ def get_parser(): init_extend_parser(sub_parsers) init_list_parser(sub_parsers) init_revert_parser(sub_parsers) + init_import_parser(sub_parsers) # default action parser.set_defaults(func=list_volumes)