Browse Source

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
Marek Marczykowski-Górecki 4 years ago
parent
commit
e700af9eb2
3 changed files with 235 additions and 0 deletions
  1. 26 0
      doc/manpages/qvm-volume.rst
  2. 162 0
      qubesadmin/tests/tools/qvm_volume.py
  3. 47 0
      qubesadmin/tools/qvm_volume.py

+ 26 - 0
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
 -------
 

+ 162 - 0
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 <http://www.gnu.org/licenses/>.
+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()

+ 47 - 0
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)