Browse Source

Merge remote-tracking branch 'origin/pr/145'

* origin/pr/145: (119 commits)
  qvm-template: fix downloading template for install
  tests: add tests for other qvm-template functions
  tests: improve TestProcess behavior
  tests: add tests for qvm-template reinstall/up/downgrade when nothing needs to be done
  tests: fix mock return values of get_dl_list when testing `qvm-template reinstall`
  qvm-template: update comments to reflect e424c7d
  qvm-template: only ask for confirmation during install if something is being done
  tests: add more tests re. install, remove, and get_keys_for_repos
  qvm-template: test != 1 instead of == 0 for template-dummy feature
  tests: fix tests for verify_rpm involving incorrect template names
  tests: add tests for qvm-template remove
  tests: some more for qvm-template
  qvm-template: mute pylint complains about typing.NamedTuple
  tests: qvm-template-postprocess - template.conf handling
  qvm-template-postprocess: fix allowed features list
  qvm-template-postprocess: extract config handling into separate function
  qvm-template-postprocess: treat missing appmenus files as warnings only
  qvm-template: default confirm to 'n'
  qvm-template: verify template package signature directly at download
  qvm-template: improve error reporting
  ...
Marek Marczykowski-Górecki 3 years ago
parent
commit
7978e17aeb

+ 2 - 0
.pylintrc

@@ -148,6 +148,8 @@ ext-import-graph=
 # not be disabled)
 int-import-graph=
 
+ignored-modules=dnf
+
 
 [DESIGN]
 

+ 2 - 0
ci/requirements.txt

@@ -10,3 +10,5 @@ lxml
 PyYAML
 xcffib
 asynctest
+tqdm
+pyxdg

+ 1 - 0
debian/control

@@ -23,6 +23,7 @@ Package: qubes-core-admin-client
 Architecture: any
 Depends:
  python3-qubesadmin,
+ qubes-repo-templates,
  scrypt,
  ${python:Depends},
  ${python3:Depends},

+ 497 - 0
doc/manpages/qvm-template.rst

@@ -0,0 +1,497 @@
+.. program:: qvm-template
+
+:program:`qvm-template` -- Manage template VMs
+==============================================
+
+Synopsis
+--------
+
+:command:`qvm-template` [-h] [--repo-files *REPO_FILES*] [--keyring *KEYRING*] [--updatevm *UPDATEVM*] [--enablerepo *REPOID*] [--disablerepo *REPOID*] [--repoid *REPOID*] [--releasever *RELEASEVER*] [--refresh] [--cachedir *CACHEDIR*] [--yes] [--quiet] *SUBCOMMAND*
+
+See Section `Subcommands`_ for available subcommands.
+
+Options
+-------
+
+.. option:: --help, -h
+
+   Show help message and exit.
+
+.. option:: --repo-files REPO_FILES
+
+   Specify files containing DNF repository configuration. Can be
+   used more than once. (default:
+   ['/usr/share/qubes/repo-templates/qubes-templates.repo'])
+
+.. option:: --keyring KEYRING
+
+   Specify directory containing RPM public keys. (default:
+   /usr/share/qubes/repo-templates/keys)
+
+.. option:: --updatevm UPDATEVM
+
+   Specify VM to download updates from. (Set to empty string to specify the
+   current VM.) (default: same as UpdateVM - see ``qubes-prefs``)
+
+.. option:: --enablerepo REPOID
+
+   Enable additional repositories by an id or a glob. Can be used more than
+   once.
+
+.. option:: --disablerepo REPOID
+
+   Disable certain repositories by an id or a glob. Can be used more than once.
+
+.. option:: --repoid REPOID
+
+   Enable just specific repositories by an id or a glob. Can be used more than
+   once.
+
+.. option:: --releasever RELEASEVER
+
+   Override Qubes release version.
+
+.. option:: --refresh
+
+   Set repository metadata as expired before running the command.
+
+.. option:: --cachedir CACHEDIR
+
+   Specify cache directory. (default: ~/.cache/qvm-template)
+
+.. option:: --yes
+
+   Assume "yes" to questions.
+
+.. option:: --quiet
+
+   Decrease verbosity.
+
+Subcommands
+===========
+
+install
+-------
+
+Install template packages.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template install` [-h] [--pool *POOL*] [--nogpgcheck] [--allow-pv] [--downloaddir *DOWNLOADDIR*] [--retries *RETRIES*] [*TEMPLATESPEC* [*TEMPLATESPEC* ...]]
+
+See Section `Template Spec`_ for an explanation of *TEMPLATESPEC*.
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+.. option:: --pool POOL
+
+   Specify pool to store created VMs in.
+
+.. option:: --nogpgcheck
+
+   Disable signature checks.
+
+.. option:: --allow-pv
+
+   Allow templates that set virt_mode to pv.
+
+.. option:: --downloaddir DOWNLOADDIR
+
+   Specify download directory. (default: .)
+
+.. option:: --retries RETRIES
+
+   Specify maximum number of retries for downloads. (default: 5)
+
+{reinstall,downgrade,upgrade}
+-----------------------------
+
+Reinstall/downgrade/upgrade template packages.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template {reinstall,downgrade,upgrade}` [-h] [--nogpgcheck] [--allow-pv] [--downloaddir *DOWNLOADDIR*] [--retries *RETRIES*] [*TEMPLATESPEC* [*TEMPLATESPEC* ...]]
+
+See Section `Template Spec`_ for an explanation of *TEMPLATESPEC*.
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+.. option:: --nogpgcheck
+
+   Disable signature checks.
+
+.. option:: --allow-pv
+
+   Allow templates that set virt_mode to pv.
+
+.. option:: --downloaddir DOWNLOADDIR
+
+   Specify download directory. (default: .)
+
+.. option:: --retries RETRIES
+
+   Specify maximum number of retries for downloads. (default: 5)
+
+download
+--------
+
+Download template packages.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template download` [-h] [--downloaddir *DOWNLOADDIR*] [--retries *RETRIES*] [*TEMPLATESPEC* [*TEMPLATESPEC* ...]]
+
+See Section `Template Spec`_ for an explanation of *TEMPLATESPEC*.
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+.. option:: --downloaddir DOWNLOADDIR
+
+   Specify download directory. (default: .)
+
+.. option:: --retries RETRIES
+
+   Specify maximum number of retries for downloads. (default: 5)
+
+list
+----
+
+List templates.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template list` [-h] [--all] [--installed] [--available] [--extras] [--upgrades] [--machine-readable | --machine-readable-json] [*TEMPLATESPEC* [*TEMPLATESPEC* ...]]
+
+See Section `Template Spec`_ for an explanation of *TEMPLATESPEC*.
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+.. option:: --all
+
+   Show all templates (default).
+
+.. option:: --installed
+
+   Show installed templates.
+
+.. option:: --available
+
+   Show available templates.
+
+.. option:: --extras
+
+   Show extras (e.g., ones that exist locally but not in repos)
+   templates.
+
+.. option:: --upgrades
+
+   Show available upgrades.
+
+.. option:: --machine-readable
+
+   Enable machine-readable output.
+
+   Format
+       Each line describes a template in the following format:
+
+       ::
+
+           {status}|{name}|{evr}|{reponame}
+
+       Where ``{status}`` can be one of ``installed``, ``available``,
+       ``extra``, or ``upgradable``.
+
+       The field ``{evr}`` contains version information in the form of
+       ``{epoch}:{version}-{release}``.
+
+.. option:: --machine-readable-json
+
+   Enable machine-readable output (JSON).
+
+   Format
+       The resulting JSON document is in the following format:
+
+       ::
+
+           {
+               STATUS: [
+                   {
+                       "name": str,
+                       "evr": str,
+                       "reponame": str
+                   },
+                   ...
+               ],
+               ...
+           }
+
+       Where ``STATUS`` can be one of ``"installed"``, ``"available"``,
+       ``"extra"``, or ``"upgradable"``.
+
+       The fields ``buildtime`` and ``installtime`` are in ``%Y-%m-%d
+       %H:%M:%S`` format in UTC.
+
+       The field ``{evr}`` contains version information in the form of
+       ``{epoch}:{version}-{release}``.
+
+info
+----
+
+Display details about templates.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template list` [-h] [--all] [--installed] [--available] [--extras] [--upgrades] [--machine-readable | --machine-readable-json] [*TEMPLATESPEC* [*TEMPLATESPEC* ...]]
+
+See Section `Template Spec`_ for an explanation of *TEMPLATESPEC*.
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+.. option:: --all
+
+   Show all templates (default).
+
+.. option:: --installed
+
+   Show installed templates.
+
+.. option:: --available
+
+   Show available templates.
+
+.. option:: --extras
+
+   Show extras (e.g., ones that exist locally but not in repos)
+   templates.
+
+.. option:: --upgrades
+
+   Show available upgrades.
+
+.. option:: --machine-readable
+
+   Enable machine-readable output.
+
+   Format
+       Each line describes a template in the following format:
+
+       ::
+
+           {status}|{name}|{epoch}|{version}|{release}|{reponame}|{size}|{buildtime}|{installtime}|{license}|{url}|{summary}|{description}
+
+       Where ``{status}`` can be one of ``installed``, ``available``,
+       ``extra``, or ``upgradable``.
+
+       The fields ``buildtime`` and ``installtime`` are in ``%Y-%m-%d
+       %H:%M:%S`` format in UTC.
+
+       Newlines in the ``{description}`` field are replaced with pipe
+       characters (``|``) for easier processing.
+
+.. option:: --machine-readable-json
+
+   Enable machine-readable output (JSON).
+
+   Format
+       The resulting JSON document is in the following format:
+
+       ::
+
+           {
+               STATUS: [
+                   {
+                       "name": str,
+                       "epoch": str,
+                       "version": str,
+                       "release": str,
+                       "reponame": str,
+                       "size": int,
+                       "buildtime": str,
+                       "installtime": str,
+                       "license": str,
+                       "url": str,
+                       "summary": str,
+                       "description": str
+                   },
+                   ...
+               ],
+               ...
+           }
+
+       Where ``STATUS`` can be one of ``"installed"``, ``"available"``,
+       ``"extra"``, or ``"upgradable"``.
+
+       The fields ``buildtime`` and ``installtime`` are in ``%Y-%m-%d
+       %H:%M:%S`` format in UTC.
+
+search
+------
+
+Search template details for the given string.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template search` [-h] [--all] [*PATTERN* [*PATTERN* ...]]
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+.. option:: --all
+
+   Search also in the template description and URL. In addition, the criterion
+   are evaluated with OR instead of AND.
+
+remove
+------
+
+Remove installed templates.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template remove` [-h] [--disassoc] [*TEMPLATE* [*TEMPLATE* ...]]
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+.. option:: --disassoc
+
+   Also disassociate VMs from the templates to be removed. This
+   creates a *dummy* template for the VMs to link with.
+
+purge
+-----
+
+Remove installed templates and associated VMs.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template purge` [-h] [*TEMPLATE* [*TEMPLATE* ...]]
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+clean
+-----
+
+Remove locally cached packages.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template clean` [-h]
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+repolist
+--------
+
+Show configured repositories.
+
+Synopsis
+^^^^^^^^
+
+:command:`qvm-template repolist` [-h] [--all | --enabled | --disabled] [*REPOS* [*REPOS* ...]]
+
+Options
+^^^^^^^
+
+.. option:: -h, --help
+
+   Show help message and exit.
+
+.. option:: --all
+
+   Show all repos.
+
+.. option:: --enabled
+
+   Show only enabled repos (default).
+
+.. option:: --disabled
+
+   Show only disabled repos.
+
+Template Spec
+-------------
+
+Subcommands such as ``install`` and ``download`` accept one or more
+*TEMPLATESPEC* strings. The format is, in essence, almost identical to
+``<package-name-spec>`` described in the DNF documentation.
+
+In short, the spec is matched against the following list of NEVRA forms, in
+decreasing orders of priority:
+
+* ``name-[epoch:]version-release``
+* ``name``
+* ``name-[epoch:]version``
+
+Note that unlike DNF, ``arch`` is currently ignored as the template packages
+should all be of ``noarch``.
+
+One can also use globs in spec strings. See Section `Globs`_ for details.
+
+Refer to Section *NEVRA Matching* in the DNF documentation for details.
+
+Globs
+-----
+
+`Template Spec`_ strings, repo ids, and search patterns support glob pattern
+matching. In particular, the following special characters can be used:
+
+* ``*``: Matches any number of characters.
+* ``?``: Matches exactly one character.
+* ``[]``: Matches any enclosed character.
+* ``[!]``: Matches any character except those enclosed.
+
+In particular, note that ``{}``, while supported by DNF, is not supported by
+`qvm-template`.

+ 8 - 0
doc/qubesadmin.tools.rst

@@ -180,6 +180,14 @@ qubesadmin\.tools\.qvm\_tags module
     :undoc-members:
     :show-inheritance:
 
+qubesadmin\.tools\.qvm\_template module
+---------------------------------------
+
+.. automodule:: qubesadmin.tools.qvm_template
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 qubesadmin\.tools\.qvm\_template\_postprocess module
 ----------------------------------------------------
 

+ 13 - 6
qubesadmin/tests/__init__.py

@@ -52,7 +52,7 @@ class TestVMCollection(dict):
 
 
 class TestProcess(object):
-    def __init__(self, input_callback=None, stdout=None, stderr=None):
+    def __init__(self, input_callback=None, stdout=None, stderr=None, stdout_data=None):
         self.input_callback = input_callback
         self.got_any_input = False
         self.stdin = io.BytesIO()
@@ -60,14 +60,20 @@ class TestProcess(object):
         self.stdin_close = self.stdin.close
         self.stdin.close = self.store_input
         self.stdin.flush = self.store_input
-        if stdout == subprocess.PIPE:
+        if stdout == subprocess.PIPE or stdout == subprocess.DEVNULL \
+                or stdout is None:
             self.stdout = io.BytesIO()
         else:
             self.stdout = stdout
-        if stderr == subprocess.PIPE:
+        if stderr == subprocess.PIPE or stderr == subprocess.DEVNULL \
+                or stderr is None:
             self.stderr = io.BytesIO()
         else:
             self.stderr = stderr
+        if stdout_data:
+            self.stdout.write(stdout_data)
+            # Seek to head so that it can be read later
+            self.stdout.seek(0)
         self.returncode = 0
 
     def store_input(self):
@@ -82,14 +88,14 @@ class TestProcess(object):
             self.stdin.write(input)
         self.stdin.close()
         self.stdin_close()
-        return self.stdout, self.stderr
+        return self.stdout.read(), self.stderr.read()
 
     def wait(self):
         self.stdin_close()
         return 0
 
     def poll(self):
-        return None
+        return self.returncode
 
 
 class _AssertNotRaisesContext(object):
@@ -165,11 +171,12 @@ class QubesTest(qubesadmin.app.QubesBase):
         #     raise AssertionError('Unexpected service call {!r}'.format(call_key))
         if call_key in self.expected_service_calls:
             kwargs = kwargs.copy()
-            kwargs['stdout'] = io.BytesIO(self.expected_service_calls[call_key])
+            kwargs['stdout_data'] = self.expected_service_calls[call_key]
         return TestProcess(lambda input: self.service_calls.append((dest,
             service, input)),
             stdout=kwargs.get('stdout', None),
             stderr=kwargs.get('stderr', None),
+            stdout_data=kwargs.get('stdout_data', None),
         )
 
 

+ 5289 - 0
qubesadmin/tests/tools/qvm_template.py

@@ -0,0 +1,5289 @@
+import re
+from unittest import mock
+import argparse
+import asyncio
+import datetime
+import io
+import os
+import pathlib
+import subprocess
+import tempfile
+
+import fcntl
+import rpm
+
+import qubesadmin.tests
+import qubesadmin.tools.qvm_template
+
+class re_str(str):
+    def __eq__(self, other):
+        return bool(re.match(self, other))
+
+    def __hash__(self):
+        return super().__hash__()
+
+class TC_00_qvm_template(qubesadmin.tests.QubesTestCase):
+    def setUp(self):
+        # Print str(list) directly so that the output is consistent no matter
+        # which implementation of `column` we use
+        self.mock_table = mock.patch('qubesadmin.tools.print_table')
+        mock_table = self.mock_table.start()
+        def print_table(table, *args):
+            print(str(table))
+        mock_table.side_effect = print_table
+
+        super().setUp()
+
+    def tearDown(self):
+        self.mock_table.stop()
+        super().tearDown()
+
+    @mock.patch('rpm.TransactionSet')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('subprocess.check_output')
+    def test_000_verify_rpm_success(self, mock_proc, mock_call, mock_ts):
+        # Just return a dict instead of rpm.hdr
+        hdr = {
+            rpm.RPMTAG_SIGPGP: 'xxx', # non-empty
+            rpm.RPMTAG_SIGGPG: 'xxx', # non-empty
+            rpm.RPMTAG_NAME: 'qubes-template-test-vm',
+        }
+        mock_ts.return_value.hdrFromFdno.return_value = hdr
+        mock_proc.return_value = b'dummy.rpm: digests signatures OK\n'
+        ret = qubesadmin.tools.qvm_template.verify_rpm('/dev/null',
+            '/path/to/key', template_name='test-vm')
+        mock_call.assert_called_once()
+        mock_proc.assert_called_once()
+        self.assertEqual(hdr, ret)
+        self.assertAllCalled()
+
+    @mock.patch('rpm.TransactionSet')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('subprocess.check_output')
+    def test_001_verify_rpm_nosig_fail(self, mock_proc, mock_call, mock_ts):
+        # Just return a dict instead of rpm.hdr
+        hdr = {
+            rpm.RPMTAG_SIGPGP: None, # empty
+            rpm.RPMTAG_SIGGPG: None, # empty
+        }
+        mock_ts.return_value.hdrFromFdno.return_value = hdr
+        mock_proc.return_value = b'dummy.rpm: digests OK\n'
+        with self.assertRaises(Exception) as e:
+            qubesadmin.tools.qvm_template.verify_rpm('/dev/null',
+                '/path/to/key')
+        mock_call.assert_called_once()
+        mock_proc.assert_called_once()
+        self.assertIn('Signature verification failed', e.exception.args[0])
+        mock_ts.assert_not_called()
+        self.assertAllCalled()
+
+    @mock.patch('rpm.TransactionSet')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('subprocess.check_output')
+    def test_002_verify_rpm_nosig_success(self, mock_proc, mock_call, mock_ts):
+        # Just return a dict instead of rpm.hdr
+        hdr = {
+            rpm.RPMTAG_SIGPGP: None, # empty
+            rpm.RPMTAG_SIGGPG: None, # empty
+        }
+        mock_ts.return_value.hdrFromFdno.return_value = hdr
+        mock_proc.return_value = b'dummy.rpm: digests OK\n'
+        ret = qubesadmin.tools.qvm_template.verify_rpm('/dev/null',
+            '/path/to/key', True)
+        mock_proc.assert_not_called()
+        mock_call.assert_not_called()
+        self.assertEqual(ret, hdr)
+        self.assertAllCalled()
+
+    @mock.patch('rpm.TransactionSet')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('subprocess.check_output')
+    def test_003_verify_rpm_badsig_fail(self, mock_proc, mock_call, mock_ts):
+        mock_proc.side_effect = subprocess.CalledProcessError(1,
+            ['rpmkeys', '--checksig'], b'/dev/null: digests SIGNATURES NOT OK\n')
+        with self.assertRaises(Exception) as e:
+            qubesadmin.tools.qvm_template.verify_rpm('/dev/null',
+                '/path/to/key')
+        mock_call.assert_called_once()
+        mock_proc.assert_called_once()
+        self.assertIn('Signature verification failed', e.exception.args[0])
+        mock_ts.assert_not_called()
+        self.assertAllCalled()
+
+    @mock.patch('rpm.TransactionSet')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('subprocess.check_output')
+    def test_004_verify_rpm_badname(self, mock_proc, mock_call, mock_ts):
+        mock_proc.return_value = b'/dev/null: digests signatures OK\n'
+        hdr = {
+            rpm.RPMTAG_SIGPGP: 'xxx', # non-empty
+            rpm.RPMTAG_SIGGPG: 'xxx', # non-empty
+            rpm.RPMTAG_NAME: 'qubes-template-unexpected',
+        }
+        mock_ts.return_value.hdrFromFdno.return_value = hdr
+        with self.assertRaises(
+                qubesadmin.tools.qvm_template.SignatureVerificationError) as e:
+            qubesadmin.tools.qvm_template.verify_rpm('/dev/null',
+                '/path/to/key', template_name='test-vm')
+        mock_call.assert_called_once()
+        mock_proc.assert_called_once()
+        self.assertIn('package does not match expected template name',
+            e.exception.args[0])
+        mock_ts.assert_called_once()
+        self.assertAllCalled()
+
+    @mock.patch('subprocess.Popen')
+    def test_010_extract_rpm_success(self, mock_popen):
+        pipe = mock.Mock()
+        mock_popen.return_value.stdout = pipe
+        mock_popen.return_value.wait.return_value = 0
+        with tempfile.NamedTemporaryFile() as fd, \
+                tempfile.TemporaryDirectory() as dir:
+            path = fd.name
+            dirpath = dir
+            ret = qubesadmin.tools.qvm_template.extract_rpm(
+                'test-vm', path, dirpath)
+        self.assertEqual(ret, True)
+        self.assertEqual(mock_popen.mock_calls, [
+            mock.call(['rpm2cpio', path], stdout=subprocess.PIPE),
+            mock.call([
+                    'cpio',
+                    '-idm',
+                    '-D',
+                    dirpath,
+                    './var/lib/qubes/vm-templates/test-vm/*'
+                ], stdin=pipe, stdout=subprocess.DEVNULL),
+            mock.call().wait(),
+            mock.call().wait()
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('subprocess.Popen')
+    def test_011_extract_rpm_fail(self, mock_popen):
+        pipe = mock.Mock()
+        mock_popen.return_value.stdout = pipe
+        mock_popen.return_value.wait.return_value = 1
+        with tempfile.NamedTemporaryFile() as fd, \
+                tempfile.TemporaryDirectory() as dir:
+            path = fd.name
+            dirpath = dir
+            ret = qubesadmin.tools.qvm_template.extract_rpm(
+                'test-vm', path, dirpath)
+        self.assertEqual(ret, False)
+        self.assertEqual(mock_popen.mock_calls, [
+            mock.call(['rpm2cpio', path], stdout=subprocess.PIPE),
+            mock.call([
+                    'cpio',
+                    '-idm',
+                    '-D',
+                    dirpath,
+                    './var/lib/qubes/vm-templates/test-vm/*'
+                ], stdin=pipe, stdout=subprocess.DEVNULL),
+            mock.call().wait()
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.get_keys_for_repos')
+    def test_090_install_lock(self, mock_get_keys):
+        class SuccessError(Exception):
+            pass
+        mock_get_keys.side_effect = SuccessError
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'):
+            with self.subTest('not locked'):
+                with self.assertRaises(SuccessError):
+                    # args don't matter
+                    qubesadmin.tools.qvm_template.install(mock.MagicMock(), None)
+                self.assertFalse(os.path.exists('/tmp/test.lock'))
+
+            with self.subTest('lock exists but unlocked'):
+                with open('/tmp/test.lock', 'w') as f:
+                    with self.assertRaises(SuccessError):
+                        # args don't matter
+                        qubesadmin.tools.qvm_template.install(mock.MagicMock(), None)
+                self.assertFalse(os.path.exists('/tmp/test.lock'))
+            with self.subTest('locked'):
+                with open('/tmp/test.lock', 'w') as f:
+                    fcntl.flock(f, fcntl.LOCK_EX)
+                    with self.assertRaises(
+                            qubesadmin.tools.qvm_template.AlreadyRunning):
+                        # args don't matter
+                        qubesadmin.tools.qvm_template.install(mock.MagicMock(), None)
+                    # and not cleaned up then
+                    self.assertTrue(os.path.exists('/tmp/test.lock'))
+
+    def add_new_vm_side_effect(self, *args, **kwargs):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\0test-vm class=TemplateVM state=Halted\n'
+        self.app.domains.clear_cache()
+        return self.app.domains['test-vm']
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_100_install_local_success(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = b'0\0'
+        build_time = '2020-09-01 14:30:00' # 1598970600
+        install_time = '2020-09-01 15:30:00'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020'),
+                ('reponame', '@commandline'),
+                ('buildtime', build_time),
+                ('installtime', install_time),
+                ('license', 'GPL'),
+                ('url', 'https://qubes-os.org'),
+                ('summary', 'Summary'),
+                ('description', 'Desc|desc')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Set',
+                f'template-{key}',
+                val.encode())] = b'0\0'
+        mock_verify.return_value = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_dl_list.return_value = {}
+        mock_call.side_effect = self.add_new_vm_side_effect
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        mock_time.now.return_value = \
+            datetime.datetime(2020, 9, 1, 15, 30, tzinfo=datetime.timezone.utc)
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file:
+            path = template_file.name
+            args = argparse.Namespace(
+                templates=[path],
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            qubesadmin.tools.qvm_template.install(args, self.app)
+            # Downloaded package should not be removed
+            self.assertTrue(os.path.exists(path))
+        # Attempt to get download list
+        selector = qubesadmin.tools.qvm_template.VersionSelector.LATEST
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        # Nothing downloaded
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list={}, version_selector=selector)
+        mock_verify.assert_called_once_with(template_file.name, '/tmp/keyring.gpg',
+            nogpgcheck=False)
+        # Package is extracted
+        mock_extract.assert_called_with('test-vm', path,
+            '/var/tmp/qvm-template-tmpdir')
+        # No packages overwritten, so no confirm needed
+        self.assertEqual(mock_confirm.mock_calls, [])
+        # qvm-template-postprocess is called
+        self.assertEqual(mock_call.mock_calls, [
+            mock.call([
+                'qvm-template-postprocess',
+                '--really',
+                '--no-installed-by-rpm',
+                'post-install',
+                'test-vm',
+                '/var/tmp/qvm-template-tmpdir'
+                    '/var/lib/qubes/vm-templates/test-vm'
+            ])
+        ])
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        # No templates downloaded, thus no renames needed
+        self.assertEqual(mock_rename.mock_calls, [])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_101_install_local_postprocargs_success(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = b'0\0'
+        build_time = '2020-09-01 14:30:00' # 1598970600
+        install_time = '2020-09-01 15:30:00'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020'),
+                ('reponame', '@commandline'),
+                ('buildtime', build_time),
+                ('installtime', install_time),
+                ('license', 'GPL'),
+                ('url', 'https://qubes-os.org'),
+                ('summary', 'Summary'),
+                ('description', 'Desc|desc')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Set',
+                f'template-{key}',
+                val.encode())] = b'0\0'
+        mock_verify.return_value = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_dl_list.return_value = {}
+        mock_call.side_effect = self.add_new_vm_side_effect
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        mock_time.now.return_value = \
+            datetime.datetime(2020, 9, 1, 15, 30, tzinfo=datetime.timezone.utc)
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file:
+            path = template_file.name
+            args = argparse.Namespace(
+                templates=[path],
+                keyring='/tmp',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                allow_pv=True,
+                pool='my-pool'
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            qubesadmin.tools.qvm_template.install(args, self.app)
+        # Attempt to get download list
+        selector = qubesadmin.tools.qvm_template.VersionSelector.LATEST
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        # Nothing downloaded
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list={}, version_selector=selector)
+        # Package is extracted
+        mock_extract.assert_called_with('test-vm', path,
+            '/var/tmp/qvm-template-tmpdir')
+        # No packages overwritten, so no confirm needed
+        self.assertEqual(mock_confirm.mock_calls, [])
+        # qvm-template-postprocess is called
+        self.assertEqual(mock_call.mock_calls, [
+            mock.call([
+                'qvm-template-postprocess',
+                '--really',
+                '--no-installed-by-rpm',
+                '--allow-pv',
+                '--pool',
+                'my-pool',
+                'post-install',
+                'test-vm',
+                '/var/tmp/qvm-template-tmpdir'
+                    '/var/lib/qubes/vm-templates/test-vm'
+            ])
+        ])
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        # No templates downloaded, thus no renames needed
+        self.assertEqual(mock_rename.mock_calls, [])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_102_install_local_badsig_fail(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        mock_verify.return_value = None
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file:
+            path = template_file.name
+            args = argparse.Namespace(
+                templates=[path],
+                keyring='/tmp',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            # Should raise parser.error
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.install(args, self.app)
+            # Check error message
+            self.assertTrue('verification failed' in mock_err.getvalue())
+        # Should not be executed:
+        self.assertEqual(mock_dl_list.mock_calls, [])
+        self.assertEqual(mock_dl.mock_calls, [])
+        self.assertEqual(mock_extract.mock_calls, [])
+        self.assertEqual(mock_confirm.mock_calls, [])
+        self.assertEqual(mock_call.mock_calls, [])
+        self.assertEqual(mock_rename.mock_calls, [])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_103_install_local_exists_fail(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\0test-vm class=TemplateVM state=Halted\n'
+        mock_verify.return_value = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_dl_list.return_value = {}
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file:
+            path = template_file.name
+            args = argparse.Namespace(
+                templates=[path],
+                keyring='/tmp',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            qubesadmin.tools.qvm_template.install(args, self.app)
+            # Check warning message
+            self.assertTrue('already installed' in mock_err.getvalue())
+        # Attempt to get download list
+        selector = qubesadmin.tools.qvm_template.VersionSelector.LATEST
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        # Nothing downloaded
+        self.assertEqual(mock_dl.mock_calls, [
+            mock.call(args, self.app,
+                path_override='/var/cache/qvm-template',
+                dl_list={}, version_selector=selector)
+        ])
+        # Should not be executed:
+        self.assertEqual(mock_extract.mock_calls, [])
+        self.assertEqual(mock_confirm.mock_calls, [])
+        self.assertEqual(mock_call.mock_calls, [])
+        self.assertEqual(mock_rename.mock_calls, [])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_104_install_local_badpkgname_fail(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        mock_verify.return_value = {
+            rpm.RPMTAG_NAME        : 'Xqubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file:
+            path = template_file.name
+            args = argparse.Namespace(
+                templates=[path],
+                keyring='/tmp',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.install(args, self.app)
+            # Check error message
+            self.assertTrue('Illegal package name' in mock_err.getvalue())
+        # Should not be executed:
+        self.assertEqual(mock_dl_list.mock_calls, [])
+        self.assertEqual(mock_dl.mock_calls, [])
+        self.assertEqual(mock_extract.mock_calls, [])
+        self.assertEqual(mock_confirm.mock_calls, [])
+        self.assertEqual(mock_call.mock_calls, [])
+        self.assertEqual(mock_rename.mock_calls, [])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_106_install_local_badpath_fail(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            path = '/var/tmp/ShOulD-NoT-ExIsT.rpm'
+            args = argparse.Namespace(
+                templates=[path],
+                keyring='/tmp',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.install(args, self.app)
+            # Check error message
+            self.assertTrue(f"RPM file '{path}' not found" \
+                in mock_err.getvalue())
+        # Should not be executed:
+        self.assertEqual(mock_verify.mock_calls, [])
+        self.assertEqual(mock_dl_list.mock_calls, [])
+        self.assertEqual(mock_dl.mock_calls, [])
+        self.assertEqual(mock_extract.mock_calls, [])
+        self.assertEqual(mock_confirm.mock_calls, [])
+        self.assertEqual(mock_call.mock_calls, [])
+        self.assertEqual(mock_rename.mock_calls, [])
+        self.assertAllCalled()
+
+    @mock.patch('os.remove')
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_107_install_download_success(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename,
+            mock_remove):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = b'0\0'
+        build_time = '2020-09-01 14:30:00' # 1598970600
+        install_time = '2020-09-01 15:30:00'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020'),
+                ('reponame', 'qubes-templates-itl'),
+                ('buildtime', build_time),
+                ('installtime', install_time),
+                ('license', 'GPL'),
+                ('url', 'https://qubes-os.org'),
+                ('summary', 'Summary'),
+                ('description', 'Desc|desc')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Set',
+                f'template-{key}',
+                val.encode())] = b'0\0'
+        mock_dl.return_value = {'test-vm': {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }}
+        dl_list = {
+            'test-vm': qubesadmin.tools.qvm_template.DlEntry(
+                ('1', '4.1', '20200101'), 'qubes-templates-itl', 1048576)
+        }
+        mock_dl_list.return_value = dl_list
+        mock_call.side_effect = self.add_new_vm_side_effect
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        mock_time.now.return_value = \
+            datetime.datetime(2020, 9, 1, 15, 30, tzinfo=datetime.timezone.utc)
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            args = argparse.Namespace(
+                templates='test-vm',
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                keep_cache=False,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            qubesadmin.tools.qvm_template.install(args, self.app)
+        # Attempt to get download list
+        selector = qubesadmin.tools.qvm_template.VersionSelector.LATEST
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list=dl_list, version_selector=selector)
+        # download already verify the package internally
+        self.assertEqual(mock_verify.mock_calls, [])
+        # Package is extracted
+        mock_extract.assert_called_with('test-vm',
+            '/var/cache/qvm-template/qubes-template-test-vm-1:4.1-20200101.rpm',
+            '/var/tmp/qvm-template-tmpdir')
+        # No packages overwritten, so no confirm needed
+        self.assertEqual(mock_confirm.mock_calls, [])
+        # qvm-template-postprocess is called
+        self.assertEqual(mock_call.mock_calls, [
+            mock.call([
+                'qvm-template-postprocess',
+                '--really',
+                '--no-installed-by-rpm',
+                'post-install',
+                'test-vm',
+                '/var/tmp/qvm-template-tmpdir'
+                    '/var/lib/qubes/vm-templates/test-vm'
+            ])
+        ])
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        # No templates downloaded, thus no renames needed
+        self.assertEqual(mock_rename.mock_calls, [])
+        # Downloaded template is removed
+        self.assertEqual(mock_remove.mock_calls, [
+            mock.call('/var/cache/qvm-template/' \
+                'qubes-template-test-vm-1:4.1-20200101.rpm'),
+            mock.call('/tmp/test.lock')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_108_install_download_fail_exists(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        mock_dl.return_value = {'test-vm': {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }}
+        dl_list = {
+            'test-vm': qubesadmin.tools.qvm_template.DlEntry(
+                ('1', '4.1', '20200101'), 'qubes-templates-itl', 1048576)
+        }
+        mock_dl_list.return_value = dl_list
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        mock_time.now.return_value = \
+            datetime.datetime(2020, 9, 1, 15, 30, tzinfo=datetime.timezone.utc)
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            args = argparse.Namespace(
+                templates='test-vm',
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                keep_cache=True,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            qubesadmin.tools.qvm_template.install(args, self.app)
+            self.assertIn('already installed, skipping', mock_err.getvalue())
+        # Attempt to get download list
+        selector = qubesadmin.tools.qvm_template.VersionSelector.LATEST
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        # Nothing downloaded nor installed
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list={}, version_selector=selector)
+        mock_verify.assert_not_called()
+        mock_extract.assert_not_called()
+        mock_confirm.assert_not_called()
+        mock_call.assert_not_called()
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        # No templates downloaded, thus no renames needed
+        self.assertEqual(mock_rename.mock_calls, [])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_109_install_fail_extract(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = b'0\0'
+        mock_verify.return_value = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_dl_list.return_value = {}
+        mock_call.side_effect = self.add_new_vm_side_effect
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        mock_time.now.return_value = \
+            datetime.datetime(2020, 9, 1, 15, 30, tzinfo=datetime.timezone.utc)
+        # Extraction error
+        mock_extract.return_value = False
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file:
+            path = template_file.name
+            args = argparse.Namespace(
+                templates=[path],
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            with self.assertRaises(Exception) as e:
+                qubesadmin.tools.qvm_template.install(args, self.app)
+        self.assertIn('Failed to extract', e.exception.args[0])
+
+        # Attempt to get download list
+        selector = qubesadmin.tools.qvm_template.VersionSelector.LATEST
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        # Nothing downloaded
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list={}, version_selector=selector)
+        mock_verify.assert_called_once_with(template_file.name,
+            '/tmp/keyring.gpg',
+            nogpgcheck=False)
+        # Package is (attempted to be) extracted
+        mock_extract.assert_called_with('test-vm', path,
+            '/var/tmp/qvm-template-tmpdir')
+        # No packages overwritten, so no confirm needed
+        self.assertEqual(mock_confirm.mock_calls, [])
+        # No VM created
+        mock_call.assert_not_called()
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        # No templates downloaded, thus no renames needed
+        self.assertEqual(mock_rename.mock_calls, [])
+        self.assertAllCalled()
+
+    def test_110_qrexec_payload_refresh_success(self):
+        with tempfile.NamedTemporaryFile() as repo_conf1, \
+                tempfile.NamedTemporaryFile() as repo_conf2:
+            repo_str1 = \
+'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_str2 = \
+'''[qubes-templates-itl-testing]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl-testing
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl-testing
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl-testing/repodata/repomd.xml.metalink
+enabled = 0
+fastestmirror = 1
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_conf1.write(repo_str1.encode())
+            repo_conf1.flush()
+            repo_conf2.write(repo_str2.encode())
+            repo_conf2.flush()
+            args = argparse.Namespace(
+                enablerepo=['repo1', 'repo2'],
+                disablerepo=['repo3', 'repo4', 'repo5'],
+                repoid=[],
+                releasever='4.1',
+                repo_files=[repo_conf1.name, repo_conf2.name]
+            )
+            res = qubesadmin.tools.qvm_template.qrexec_payload(args, self.app,
+                'qubes-template-fedora-32', True)
+            self.assertEqual(res,
+'''--enablerepo=repo1
+--enablerepo=repo2
+--disablerepo=repo3
+--disablerepo=repo4
+--disablerepo=repo5
+--refresh
+--releasever=4.1
+qubes-template-fedora-32
+---
+''' + repo_str1 + '\n' + repo_str2 + '\n')
+        self.assertAllCalled()
+
+    def test_111_qrexec_payload_norefresh_success(self):
+        with tempfile.NamedTemporaryFile() as repo_conf1:
+            repo_str1 = \
+'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_conf1.write(repo_str1.encode())
+            repo_conf1.flush()
+            args = argparse.Namespace(
+                enablerepo=[],
+                disablerepo=[],
+                repoid=['repo1', 'repo2'],
+                releasever='4.1',
+                repo_files=[repo_conf1.name]
+            )
+            res = qubesadmin.tools.qvm_template.qrexec_payload(args, self.app,
+                'qubes-template-fedora-32', False)
+            self.assertEqual(res,
+'''--repoid=repo1
+--repoid=repo2
+--releasever=4.1
+qubes-template-fedora-32
+---
+''' + repo_str1 + '\n')
+        self.assertAllCalled()
+
+    def test_112_qrexec_payload_specnewline_fail(self):
+        with tempfile.NamedTemporaryFile() as repo_conf1:
+            repo_str1 = \
+'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_conf1.write(repo_str1.encode())
+            repo_conf1.flush()
+            args = argparse.Namespace(
+                enablerepo=[],
+                disablerepo=[],
+                repoid=['repo1', 'repo2'],
+                releasever='4.1',
+                repo_files=[repo_conf1.name]
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                with self.assertRaises(SystemExit):
+                    qubesadmin.tools.qvm_template.qrexec_payload(args,
+                        self.app, 'qubes-template-fedora\n-32', False)
+                # Check error message
+                self.assertTrue('Malformed template name'
+                    in mock_err.getvalue())
+                self.assertTrue("argument should not contain '\\n'"
+                    in mock_err.getvalue())
+        self.assertAllCalled()
+
+    def test_113_qrexec_payload_enablereponewline_fail(self):
+        with tempfile.NamedTemporaryFile() as repo_conf1:
+            repo_str1 = \
+'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_conf1.write(repo_str1.encode())
+            repo_conf1.flush()
+            args = argparse.Namespace(
+                enablerepo=['repo\n0'],
+                disablerepo=[],
+                repoid=['repo1', 'repo2'],
+                releasever='4.1',
+                repo_files=[repo_conf1.name]
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                with self.assertRaises(SystemExit):
+                    qubesadmin.tools.qvm_template.qrexec_payload(args,
+                        self.app, 'qubes-template-fedora-32', False)
+                # Check error message
+                self.assertTrue('Malformed --enablerepo'
+                    in mock_err.getvalue())
+                self.assertTrue("argument should not contain '\\n'"
+                    in mock_err.getvalue())
+        self.assertAllCalled()
+
+    def test_114_qrexec_payload_disablereponewline_fail(self):
+        with tempfile.NamedTemporaryFile() as repo_conf1:
+            repo_str1 = \
+'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_conf1.write(repo_str1.encode())
+            repo_conf1.flush()
+            args = argparse.Namespace(
+                enablerepo=[],
+                disablerepo=['repo\n0'],
+                repoid=['repo1', 'repo2'],
+                releasever='4.1',
+                repo_files=[repo_conf1.name]
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                with self.assertRaises(SystemExit):
+                    qubesadmin.tools.qvm_template.qrexec_payload(args,
+                        self.app, 'qubes-template-fedora-32', False)
+                    # Check error message
+                    self.assertTrue('Malformed --disablerepo'
+                        in mock_err.getvalue())
+                    self.assertTrue("argument should not contain '\\n'"
+                        in mock_err.getvalue())
+        self.assertAllCalled()
+
+    def test_115_qrexec_payload_repoidnewline_fail(self):
+        with tempfile.NamedTemporaryFile() as repo_conf1:
+            repo_str1 = \
+'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_conf1.write(repo_str1.encode())
+            repo_conf1.flush()
+            args = argparse.Namespace(
+                enablerepo=[],
+                disablerepo=[],
+                repoid=['repo\n1', 'repo2'],
+                releasever='4.1',
+                repo_files=[repo_conf1.name]
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                with self.assertRaises(SystemExit):
+                    qubesadmin.tools.qvm_template.qrexec_payload(args,
+                        self.app, 'qubes-template-fedora-32', False)
+                # Check error message
+                self.assertTrue('Malformed --repoid'
+                    in mock_err.getvalue())
+                self.assertTrue("argument should not contain '\\n'"
+                    in mock_err.getvalue())
+        self.assertAllCalled()
+
+    def test_116_qrexec_payload_releasevernewline_fail(self):
+        with tempfile.NamedTemporaryFile() as repo_conf1:
+            repo_str1 = \
+'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_conf1.write(repo_str1.encode())
+            repo_conf1.flush()
+            args = argparse.Namespace(
+                enablerepo=[],
+                disablerepo=[],
+                repoid=['repo1', 'repo2'],
+                releasever='4\n.1',
+                repo_files=[repo_conf1.name]
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                with self.assertRaises(SystemExit):
+                    qubesadmin.tools.qvm_template.qrexec_payload(args,
+                        self.app, 'qubes-template-fedora-32', False)
+                # Check error message
+                self.assertTrue('Malformed --releasever'
+                    in mock_err.getvalue())
+                self.assertTrue("argument should not contain '\\n'"
+                    in mock_err.getvalue())
+        self.assertAllCalled()
+
+    def test_117_qrexec_payload_specdash_fail(self):
+        with tempfile.NamedTemporaryFile() as repo_conf1:
+            repo_str1 = \
+'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+'''
+            repo_conf1.write(repo_str1.encode())
+            repo_conf1.flush()
+            args = argparse.Namespace(
+                enablerepo=[],
+                disablerepo=[],
+                repoid=['repo1', 'repo2'],
+                releasever='4.1',
+                repo_files=[repo_conf1.name]
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                with self.assertRaises(SystemExit):
+                    qubesadmin.tools.qvm_template.qrexec_payload(args,
+                        self.app, '---', False)
+                # Check error message
+                self.assertTrue('Malformed template name'
+                    in mock_err.getvalue())
+                self.assertTrue("argument should not be '---'"
+                    in mock_err.getvalue())
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_120_qrexec_repoquery_success(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template\n for fedora-32\n|
+qubes-template-fedora-32|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+'''
+        res = qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+            'qubes-template-fedora-32')
+        self.assertEqual(res, [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '1',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for fedora-32 v2',
+                'Qubes template\n for fedora-32 v2\n'
+            )
+        ])
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_121_qrexec_repoquery_refresh_success(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template\n for fedora-32\n|
+qubes-template-fedora-32|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+'''
+        res = qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+            'qubes-template-fedora-32', True)
+        self.assertEqual(res, [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '1',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for fedora-32 v2',
+                'Qubes template\n for fedora-32 v2\n'
+            )
+        ])
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', True)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_122_qrexec_repoquery_ignorenonspec_success(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-debian-10|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for debian-10|Qubes template for debian-10\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        res = qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+            'qubes-template-fedora-32')
+        self.assertEqual(res, [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template for fedora-32\n'
+            )
+        ])
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_123_qrexec_repoquery_ignorebadname_success(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''template-fedora-32|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        res = qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+            'qubes-template-fedora-32')
+        self.assertEqual(res, [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template for fedora-32\n'
+            )
+        ])
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_124_qrexec_repoquery_searchfail_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        with mock.patch('qubesadmin.tests.TestProcess.wait') \
+                as mock_wait:
+            mock_wait.return_value = 1
+            with self.assertRaises(ConnectionError):
+                qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                    'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_125_qrexec_repoquery_extrafield_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Extra field|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        with self.assertRaisesRegex(ConnectionError,
+                "unexpected data format"):
+            qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_125_qrexec_repoquery_missingfield_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        with self.assertRaisesRegex(ConnectionError,
+                "unexpected data format"):
+            qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_126_qrexec_repoquery_badfieldname_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-(32)|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        with self.assertRaisesRegex(ConnectionError,
+                "unexpected data format"):
+            qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_126_qrexec_repoquery_badfieldepoch_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|!1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        with self.assertRaisesRegex(ConnectionError,
+                "unexpected data format"):
+            qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_126_qrexec_repoquery_badfieldreponame_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|1|4.2|20200201|qubes-templates-itl-<testing>|2048576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        with self.assertRaisesRegex(ConnectionError,
+                "unexpected data format"):
+            qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_126_qrexec_repoquery_badfielddlsize_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|1|4.2|20200201|qubes-templates-itl-testing|2048a576|2020-02-23 04:56|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        with self.assertRaisesRegex(ConnectionError,
+                "unexpected data format"):
+            qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_126_qrexec_repoquery_badfielddate_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23|GPLv2|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        with self.assertRaisesRegex(ConnectionError,
+                "unexpected data format"):
+            qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_payload')
+    def test_126_qrexec_repoquery_license_fail(self, mock_payload):
+        args = argparse.Namespace(updatevm='test-vm')
+        mock_payload.return_value = 'str1\nstr2'
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+                ('test-vm', 'qubes.TemplateSearch')] = \
+b'''qubes-template-fedora-32|1|4.2|20200201|qubes-templates-itl-testing|2048576|2020-02-23 04:56|GPLv2:)|https://qubes-os.org/?|Qubes template for fedora-32 v2|Qubes template\n for fedora-32 v2\n|
+qubes-template-fedora-32|0|4.1|20200101|qubes-templates-itl|1048576|2020-01-23 04:56|GPL|https://qubes-os.org|Qubes template for fedora-32|Qubes template for fedora-32\n|
+'''
+        with self.assertRaisesRegex(ConnectionError,
+                "unexpected data format"):
+            qubesadmin.tools.qvm_template.qrexec_repoquery(args, self.app,
+                'qubes-template-fedora-32')
+        self.assertEqual(self.app.service_calls, [
+            ('test-vm', 'qubes.TemplateSearch',
+                {'filter_esc': True, 'stdout': subprocess.PIPE}),
+            ('test-vm', 'qubes.TemplateSearch', b'str1\nstr2')
+        ])
+        self.assertEqual(mock_payload.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32', False)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_130_get_dl_list_latest_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for fedora-32 v2',
+                'Qubes template\n for fedora-32 v2\n'
+            )
+        ]
+        args = argparse.Namespace(
+            templates=['some.local.file.rpm', 'fedora-32']
+        )
+        ret = qubesadmin.tools.qvm_template.get_dl_list(args, self.app)
+        self.assertEqual(ret, {
+            'fedora-32': qubesadmin.tools.qvm_template.DlEntry(
+                ('1', '4.1', '20200101'), 'qubes-templates-itl', 1048576)
+        })
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_131_get_dl_list_latest_notfound_fail(self, mock_query):
+        mock_query.return_value = []
+        args = argparse.Namespace(
+            templates=['some.local.file.rpm', 'fedora-31']
+        )
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.get_dl_list(args, self.app)
+            self.assertTrue('not found' in mock_err.getvalue())
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-31')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_132_get_dl_list_multimerge0_success(self, mock_query):
+        counter = 0
+        def f(*args):
+            nonlocal counter
+            counter += 1
+            if counter == 1:
+                return [
+                    qubesadmin.tools.qvm_template.Template(
+                        'fedora-32',
+                        '0',
+                        '4.2',
+                        '20200201',
+                        'qubes-templates-itl-testing',
+                        2048576,
+                        datetime.datetime(2020, 2, 23, 4, 56),
+                        'GPLv2',
+                        'https://qubes-os.org/?',
+                        'Qubes template for fedora-32 v2',
+                        'Qubes template\n for fedora-32 v2\n'
+                    )
+                ]
+            return [
+                qubesadmin.tools.qvm_template.Template(
+                    'fedora-32',
+                    '1',
+                    '4.1',
+                    '20200101',
+                    'qubes-templates-itl',
+                    1048576,
+                    datetime.datetime(2020, 1, 23, 4, 56),
+                    'GPL',
+                    'https://qubes-os.org',
+                    'Qubes template for fedora-32',
+                    'Qubes template\n for fedora-32\n'
+                )
+            ]
+        mock_query.side_effect = f
+        args = argparse.Namespace(
+            templates=['some.local.file.rpm', 'fedora-32:0', 'fedora-32:1']
+        )
+        ret = qubesadmin.tools.qvm_template.get_dl_list(args, self.app)
+        self.assertEqual(ret, {
+            'fedora-32': qubesadmin.tools.qvm_template.DlEntry(
+                ('1', '4.1', '20200101'), 'qubes-templates-itl', 1048576)
+        })
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32:0'),
+            mock.call(args, self.app, 'qubes-template-fedora-32:1')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_132_get_dl_list_multimerge1_success(self, mock_query):
+        counter = 0
+        def f(*args):
+            nonlocal counter
+            counter += 1
+            if counter == 1:
+                return [
+                    qubesadmin.tools.qvm_template.Template(
+                        'fedora-32',
+                        '2',
+                        '4.2',
+                        '20200201',
+                        'qubes-templates-itl-testing',
+                        2048576,
+                        datetime.datetime(2020, 2, 23, 4, 56),
+                        'GPLv2',
+                        'https://qubes-os.org/?',
+                        'Qubes template for fedora-32 v2',
+                        'Qubes template\n for fedora-32 v2\n'
+                    )
+                ]
+            return [
+                qubesadmin.tools.qvm_template.Template(
+                    'fedora-32',
+                    '1',
+                    '4.1',
+                    '20200101',
+                    'qubes-templates-itl',
+                    1048576,
+                    datetime.datetime(2020, 1, 23, 4, 56),
+                    'GPL',
+                    'https://qubes-os.org',
+                    'Qubes template for fedora-32',
+                    'Qubes template\n for fedora-32\n'
+                )
+            ]
+        mock_query.side_effect = f
+        args = argparse.Namespace(
+            templates=['some.local.file.rpm', 'fedora-32:2', 'fedora-32:1']
+        )
+        ret = qubesadmin.tools.qvm_template.get_dl_list(args, self.app)
+        self.assertEqual(ret, {
+            'fedora-32': qubesadmin.tools.qvm_template.DlEntry(
+                ('2', '4.2', '20200201'),
+                'qubes-templates-itl-testing',
+                2048576)
+        })
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-32:2'),
+            mock.call(args, self.app, 'qubes-template-fedora-32:1')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_133_get_dl_list_reinstall_success(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-name',
+            None)] = b'0\0test-vm'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-epoch',
+            None)] = b'0\x000'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-version',
+            None)] = b'0\x004.2'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-release',
+            None)] = b'0\x0020200201'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for test-vm v2',
+                'Qubes template\n for test-vm v2\n'
+            )
+        ]
+        args = argparse.Namespace(
+            templates=['some.local.file.rpm', 'test-vm']
+        )
+        ret = qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+            qubesadmin.tools.qvm_template.VersionSelector.REINSTALL)
+        self.assertEqual(ret, {
+            'test-vm': qubesadmin.tools.qvm_template.DlEntry(
+                ('0', '4.2', '20200201'),
+                'qubes-templates-itl-testing',
+                2048576
+            )
+        })
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_134_get_dl_list_reinstall_nolocal_fail(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for test-vm v2',
+                'Qubes template\n for test-vm v2\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+                    qubesadmin.tools.qvm_template.VersionSelector.REINSTALL)
+            self.assertTrue('not already installed' in mock_err.getvalue())
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_135_get_dl_list_reinstall_nonmanaged_fail(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for test-vm v2',
+                'Qubes template\n for test-vm v2\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        def qubesd_call(dest, method,
+                arg=None, payload=None, payload_stream=None,
+                orig_func=self.app.qubesd_call):
+            if method == 'admin.vm.feature.Get':
+                raise KeyError
+            return orig_func(dest, method, arg, payload, payload_stream)
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                mock.patch.object(self.app, 'qubesd_call') as mock_call:
+            mock_call.side_effect = qubesd_call
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+                    qubesadmin.tools.qvm_template.VersionSelector.REINSTALL)
+            self.assertTrue('not managed' in mock_err.getvalue())
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_135_get_dl_list_reinstall_nonmanagednoname_fail(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-name',
+            None)] = b'0\0test-vm-2'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for test-vm v2',
+                'Qubes template\n for test-vm v2\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+                    qubesadmin.tools.qvm_template.VersionSelector.REINSTALL)
+            self.assertTrue('not managed' in mock_err.getvalue())
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_136_get_dl_list_downgrade_success(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-name',
+            None)] = b'0\0test-vm'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-epoch',
+            None)] = b'0\x000'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-version',
+            None)] = b'0\x004.3'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-release',
+            None)] = b'0\x0020200201'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for test-vm v2',
+                'Qubes template\n for test-vm v2\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        ret = qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+            qubesadmin.tools.qvm_template.VersionSelector.LATEST_LOWER)
+        self.assertEqual(ret, {
+            'test-vm': qubesadmin.tools.qvm_template.DlEntry(
+                ('0', '4.2', '20200201'),
+                'qubes-templates-itl-testing',
+                2048576
+            )
+        })
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_137_get_dl_list_downgrade_nonmanaged_fail(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-name',
+            None)] = b'0\0test-vm-2'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.2',
+                '20200201',
+                'qubes-templates-itl-testing',
+                2048576,
+                datetime.datetime(2020, 2, 23, 4, 56),
+                'GPLv2',
+                'https://qubes-os.org/?',
+                'Qubes template for test-vm v2',
+                'Qubes template\n for test-vm v2\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+                    qubesadmin.tools.qvm_template.VersionSelector.REINSTALL)
+            self.assertTrue('not managed' in mock_err.getvalue())
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_138_get_dl_list_downgrade_notfound_skip(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-name',
+            None)] = b'0\0test-vm'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-epoch',
+            None)] = b'0\x000'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-version',
+            None)] = b'0\x004.3'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-release',
+            None)] = b'0\x0020200201'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            ret = qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+                qubesadmin.tools.qvm_template.VersionSelector.LATEST_LOWER)
+            self.assertTrue('lowest version' in mock_err.getvalue())
+        self.assertEqual(ret, {})
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_139_get_dl_list_upgrade_success(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-name',
+            None)] = b'0\0test-vm'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-epoch',
+            None)] = b'0\x000'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-version',
+            None)] = b'0\x004.3'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-release',
+            None)] = b'0\x0020200201'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        ret = qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+            qubesadmin.tools.qvm_template.VersionSelector.LATEST_HIGHER)
+        self.assertEqual(ret, {
+            'test-vm': qubesadmin.tools.qvm_template.DlEntry(
+                ('1', '4.1', '20200101'), 'qubes-templates-itl', 1048576
+            )
+        })
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_140_get_dl_list_downgrade_notfound_skip(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-name',
+            None)] = b'0\0test-vm'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-epoch',
+            None)] = b'0\x000'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-version',
+            None)] = b'0\x004.3'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-release',
+            None)] = b'0\x0020200201'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            ret = qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+                qubesadmin.tools.qvm_template.VersionSelector.LATEST_HIGHER)
+            self.assertTrue('highest version' in mock_err.getvalue())
+        self.assertEqual(ret, {})
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_141_get_dl_list_reinstall_notfound_fail(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-name',
+            None)] = b'0\0test-vm'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-epoch',
+            None)] = b'0\x000'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-version',
+            None)] = b'0\x004.3'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Get',
+            f'template-release',
+            None)] = b'0\x0020200201'
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for test-vm\n'
+            )
+        ]
+        args = argparse.Namespace(templates=['test-vm'])
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.get_dl_list(args, self.app,
+                    qubesadmin.tools.qvm_template.VersionSelector.REINSTALL)
+            self.assertTrue('Same version' in mock_err.getvalue())
+            self.assertTrue('not found' in mock_err.getvalue())
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-test-vm')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_150_list_templates_installed_success(self, mock_query):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n' \
+            b'test-vm-2 class=TemplateVM state=Halted\n' \
+            b'non-spec class=TemplateVM state=Halted\n'
+        build_time = '2020-09-01 14:30:00' # 1598970600
+        install_time = '2020-09-01 15:30:00'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020'),
+                ('reponame', '@commandline'),
+                ('buildtime', build_time),
+                ('installtime', install_time),
+                ('license', 'GPL'),
+                ('url', 'https://qubes-os.org'),
+                ('summary', 'Summary'),
+                ('description', 'Desc|desc')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [('name', 'test-vm-2-not-managed')]:
+            self.app.expected_calls[(
+                'test-vm-2',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'non-spec'),
+                ('epoch', '0'),
+                ('version', '4.3'),
+                ('release', '20200201')]:
+            self.app.expected_calls[(
+                'non-spec',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        args = argparse.Namespace(
+            all=False,
+            installed=True,
+            available=False,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=['test-vm*']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out, \
+                mock.patch.object(self.app.domains['test-vm'],
+                    'get_disk_utilization') as mock_disk:
+            mock_disk.return_value = 1234321
+            qubesadmin.tools.qvm_template.list_templates(
+                args, self.app, 'list')
+            self.assertEqual(mock_out.getvalue(),
+'''Installed Templates
+[('test-vm', '2:4.1-2020', '@commandline')]
+''')
+            self.assertEqual(mock_disk.mock_calls, [mock.call()])
+        self.assertEqual(mock_query.mock_calls, [])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_151_list_templates_available_success(self, mock_query):
+        counter = 0
+        def f(*args):
+            nonlocal counter
+            counter += 1
+            if counter == 1:
+                return [
+                    qubesadmin.tools.qvm_template.Template(
+                        'fedora-32',
+                        '0',
+                        '4.2',
+                        '20200201',
+                        'qubes-templates-itl-testing',
+                        2048576,
+                        datetime.datetime(2020, 2, 23, 4, 56),
+                        'GPLv2',
+                        'https://qubes-os.org/?',
+                        'Qubes template for fedora-32 v2',
+                        'Qubes template\n for fedora-32 v2\n'
+                    )
+                ]
+            return [
+                qubesadmin.tools.qvm_template.Template(
+                    'fedora-31',
+                    '1',
+                    '4.1',
+                    '20200101',
+                    'qubes-templates-itl',
+                    1048576,
+                    datetime.datetime(2020, 1, 23, 4, 56),
+                    'GPL',
+                    'https://qubes-os.org',
+                    'Qubes template for fedora-31',
+                    'Qubes template\n for fedora-31\n'
+                )
+            ]
+        mock_query.side_effect = f
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=True,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=['fedora-32', 'fedora-31']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.list_templates(
+                args, self.app, 'list')
+            # Order not determinstic because of sets
+            expected = [
+                ('fedora-31', '1:4.1-20200101', 'qubes-templates-itl'),
+                ('fedora-32', '0:4.2-20200201', 'qubes-templates-itl-testing')
+            ]
+            self.assertTrue(mock_out.getvalue() == \
+f'''Available Templates
+{str([expected[1], expected[0]])}
+''' \
+                    or mock_out.getvalue() == \
+f'''Available Templates
+{str([expected[0], expected[1]])}
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'fedora-32'),
+            mock.call(args, self.app, 'fedora-31')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_151_list_templates_available_all_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '1',
+                '4.1',
+                '20190101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2019, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+        ]
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=True,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=[]
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.list_templates(
+                args, self.app, 'list')
+            self.assertEqual(mock_out.getvalue(),
+'''Available Templates
+[('fedora-31', '1:4.1-20190101', 'qubes-templates-itl'), ('fedora-31', '1:4.1-20200101', 'qubes-templates-itl')]
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_151_list_templates_available_only_latest_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '1',
+                '4.1',
+                '20190101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2019, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '1',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+        ]
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=True,
+            extras=False,
+            upgrades=False,
+            all_versions=False,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=[]
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.list_templates(
+                args, self.app, 'list')
+            self.assertEqual(mock_out.getvalue(),
+'''Available Templates
+[('fedora-31', '1:4.1-20200101', 'qubes-templates-itl')]
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_152_list_templates_extras_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n' \
+            b'test-vm-2 class=TemplateVM state=Halted\n' \
+            b'test-vm-3 class=TemplateVM state=Halted\n' \
+            b'non-spec class=TemplateVM state=Halted\n'
+        for key, val in [('name', 'test-vm')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'test-vm-2'),
+                ('epoch', '1'),
+                ('version', '4.0'),
+                ('release', '2019'),
+                ('reponame', 'qubes-template-itl'),
+                ('buildtime', '2020-09-02 14:30:00'),
+                ('installtime', '2020-09-02 15:30:00'),
+                ('license', 'GPLv2'),
+                ('url', 'https://qubes-os.org/?'),
+                ('summary', 'Summary2'),
+                ('description', 'Desc|desc|2')]:
+            self.app.expected_calls[(
+                'test-vm-2',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [('name', 'test-vm-3-non-managed')]:
+            self.app.expected_calls[(
+                'test-vm-3',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'non-spec'),
+                ('epoch', '1'),
+                ('version', '4.0'),
+                ('release', '2019')]:
+            self.app.expected_calls[(
+                'non-spec',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=False,
+            extras=True,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=['test-vm*']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out, \
+                mock.patch.object(self.app.domains['test-vm-2'],
+                    'get_disk_utilization') as mock_disk:
+            mock_disk.return_value = 1234321
+            qubesadmin.tools.qvm_template.list_templates(
+                args, self.app, 'list')
+            self.assertEqual(mock_out.getvalue(),
+'''Extra Templates
+[('test-vm-2', '1:4.0-2019', 'qubes-template-itl')]
+''')
+            self.assertEqual(mock_disk.mock_calls, [mock.call()])
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'test-vm*')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_153_list_templates_upgrades_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm-3',
+                '0',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n' \
+            b'test-vm-2 class=TemplateVM state=Halted\n' \
+            b'test-vm-3 class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '1'),
+                ('version', '4.0'),
+                ('release', '2019')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'test-vm-2'),
+                ('epoch', '1'),
+                ('version', '4.0'),
+                ('release', '2019')]:
+            self.app.expected_calls[(
+                'test-vm-2',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [('name', 'test-vm-3-non-managed')]:
+            self.app.expected_calls[(
+                'test-vm-3',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=False,
+            extras=False,
+            upgrades=True,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=['test-vm*']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out, \
+                mock.patch.object(self.app.domains['test-vm-2'],
+                    'get_disk_utilization') as mock_disk:
+            mock_disk.return_value = 1234321
+            qubesadmin.tools.qvm_template.list_templates(
+                args, self.app, 'list')
+            self.assertEqual(mock_out.getvalue(),
+'''Available Upgrades
+[('test-vm', '2:4.1-2020', 'qubes-templates-itl')]
+''')
+            self.assertEqual(mock_disk.mock_calls, [])
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'test-vm*')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def __test_list_templates_all_success(self, operation,
+            args, expected, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm-2 class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'test-vm-2'),
+                ('epoch', '1'),
+                ('version', '4.0'),
+                ('release', '2019'),
+                ('reponame', '@commandline'),
+                ('buildtime', '2020-09-02 14:30:00'),
+                ('installtime', '2020-09-02 15:30:00'),
+                ('license', 'GPL'),
+                ('url', 'https://qubes-os.org'),
+                ('summary', 'Summary'),
+                ('description', 'Desc|desc')]:
+            self.app.expected_calls[(
+                'test-vm-2',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out, \
+                mock.patch.object(self.app.domains['test-vm-2'],
+                    'get_disk_utilization') as mock_disk:
+            mock_disk.return_value = 1234321
+            qubesadmin.tools.qvm_template.list_templates(
+                args, self.app, operation)
+            self.assertEqual(mock_out.getvalue(), expected)
+            self.assertEqual(mock_disk.mock_calls, [mock.call()])
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app, 'test-vm*')
+        ])
+        self.assertAllCalled()
+
+    def test_154_list_templates_all_success(self):
+        args = argparse.Namespace(
+            all=True,
+            installed=False,
+            available=False,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=['test-vm*']
+        )
+        expected = \
+'''Installed Templates
+[('test-vm-2', '1:4.0-2019', '@commandline')]
+Available Templates
+[('test-vm', '2:4.1-2020', 'qubes-templates-itl')]
+'''
+        self.__test_list_templates_all_success('list', args, expected)
+
+    def test_155_list_templates_all_implicit_success(self):
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=False,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=['test-vm*']
+        )
+        expected = \
+'''Installed Templates
+[('test-vm-2', '1:4.0-2019', '@commandline')]
+Available Templates
+[('test-vm', '2:4.1-2020', 'qubes-templates-itl')]
+'''
+        self.__test_list_templates_all_success('list', args, expected)
+
+    def test_156_list_templates_info_all_success(self):
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=False,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=['test-vm*']
+        )
+        expected = \
+'''Installed Templates
+[('Name', ':', 'test-vm-2'), ('Epoch', ':', '1'), ('Version', ':', '4.0'), ('Release', ':', '2019'), ('Size', ':', '1.2 MiB'), ('Repository', ':', '@commandline'), ('Buildtime', ':', '2020-09-02 14:30:00'), ('Install time', ':', '2020-09-02 15:30:00'), ('URL', ':', 'https://qubes-os.org'), ('License', ':', 'GPL'), ('Summary', ':', 'Summary'), ('Description', ':', 'Desc'), ('', ':', 'desc'), (' ', ' ', ' ')]
+Available Templates
+[('Name', ':', 'test-vm'), ('Epoch', ':', '2'), ('Version', ':', '4.1'), ('Release', ':', '2020'), ('Size', ':', '1.0 MiB'), ('Repository', ':', 'qubes-templates-itl'), ('Buildtime', ':', '2020-09-01 14:30:00+00:00'), ('URL', ':', 'https://qubes-os.org'), ('License', ':', 'GPL'), ('Summary', ':', 'Qubes template for fedora-31'), ('Description', ':', 'Qubes template'), ('', ':', ' for fedora-31'), (' ', ' ', ' ')]
+'''
+        self.__test_list_templates_all_success('info', args, expected)
+
+    def test_157_list_templates_list_all_machinereadable_success(self):
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=False,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=True,
+            machine_readable_json=False,
+            templates=['test-vm*']
+        )
+        expected = \
+'''installed|test-vm-2|1:4.0-2019|@commandline
+available|test-vm|2:4.1-2020|qubes-templates-itl
+'''
+        self.__test_list_templates_all_success('list', args, expected)
+
+    def test_158_list_templates_info_all_machinereadable_success(self):
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=False,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=True,
+            machine_readable_json=False,
+            templates=['test-vm*']
+        )
+        expected = \
+'''installed|test-vm-2|1|4.0|2019|@commandline|1234321|2020-09-02 14:30:00|2020-09-02 15:30:00|GPL|https://qubes-os.org|Summary|Desc|desc
+available|test-vm|2|4.1|2020|qubes-templates-itl|1048576|2020-09-01 14:30:00||GPL|https://qubes-os.org|Qubes template for fedora-31|Qubes template| for fedora-31|
+'''
+        self.__test_list_templates_all_success('info', args, expected)
+
+    def test_159_list_templates_list_all_machinereadablejson_success(self):
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=False,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=True,
+            templates=['test-vm*']
+        )
+        expected = \
+'''{"installed": [{"name": "test-vm-2", "evr": "1:4.0-2019", "reponame": "@commandline"}], "available": [{"name": "test-vm", "evr": "2:4.1-2020", "reponame": "qubes-templates-itl"}]}
+'''
+        self.__test_list_templates_all_success('list', args, expected)
+
+    def test_160_list_templates_info_all_machinereadablejson_success(self):
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=False,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=True,
+            templates=['test-vm*']
+        )
+        expected = \
+r'''{"installed": [{"name": "test-vm-2", "epoch": "1", "version": "4.0", "release": "2019", "reponame": "@commandline", "size": "1234321", "buildtime": "2020-09-02 14:30:00", "installtime": "2020-09-02 15:30:00", "license": "GPL", "url": "https://qubes-os.org", "summary": "Summary", "description": "Desc\ndesc"}], "available": [{"name": "test-vm", "epoch": "2", "version": "4.1", "release": "2020", "reponame": "qubes-templates-itl", "size": "1048576", "buildtime": "2020-09-01 14:30:00", "installtime": "", "license": "GPL", "url": "https://qubes-os.org", "summary": "Qubes template for fedora-31", "description": "Qubes template\n for fedora-31\n"}]}
+'''
+        self.__test_list_templates_all_success('info', args, expected)
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_161_list_templates_noresults_fail(self, mock_query):
+        mock_query.return_value = []
+        args = argparse.Namespace(
+            all=False,
+            installed=False,
+            available=True,
+            extras=False,
+            upgrades=False,
+            all_versions=True,
+            machine_readable=False,
+            machine_readable_json=False,
+            templates=[]
+        )
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.list_templates(
+                    args, self.app, 'list')
+            self.assertTrue('No matching templates' in mock_err.getvalue())
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_170_search_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '0',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Older Qubes template for fedora-31',
+                'Older Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'should-not-match-3',
+                '0',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org/test-vm',
+                'Qubes template for fedora-31',
+                'test-vm Qubes template\n for fedora-31\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm-2 class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'test-vm-2'),
+                ('epoch', '1'),
+                ('version', '4.0'),
+                ('release', '2019'),
+                ('reponame', '@commandline'),
+                ('buildtime', '2020-09-02 14:30:00'),
+                ('license', 'GPL'),
+                ('url', 'https://qubes-os.org'),
+                ('summary', 'Summary'),
+                ('description', 'Desc|desc')]:
+            self.app.expected_calls[(
+                'test-vm-2',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        args = argparse.Namespace(
+            all=False,
+            templates=['test-vm']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out, \
+                mock.patch.object(self.app.domains['test-vm-2'],
+                    'get_disk_utilization') as mock_disk:
+            mock_disk.return_value = 1234321
+            qubesadmin.tools.qvm_template.search(args, self.app)
+            self.assertEqual(mock_out.getvalue(),
+'''=== Name Exactly Matched: test-vm ===
+test-vm : Qubes template for fedora-31
+=== Name Matched: test-vm ===
+test-vm-2 : Summary
+''')
+            self.assertEqual(mock_disk.mock_calls, [mock.call()])
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_171_search_summary_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-template',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm :)',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-template-exact',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm-2',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for test-vm-2',
+                'Qubes template\n for fedora-31\n'
+            ),
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            all=False,
+            templates=['test-vm']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.search(args, self.app)
+            self.assertEqual(mock_out.getvalue(),
+'''=== Name & Summary Matched: test-vm ===
+test-vm : Qubes template for test-vm
+test-vm-2 : Qubes template for test-vm-2
+=== Summary Matched: test-vm ===
+test-template : Qubes template for test-vm :)
+=== Summary Exactly Matched: test-vm ===
+test-template-exact : test-vm
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_172_search_namesummaryexact_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-template-exact',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm-2',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm',
+                'Qubes template\n for fedora-31\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            all=False,
+            templates=['test-vm']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.search(args, self.app)
+            self.assertEqual(mock_out.getvalue(),
+'''=== Name & Summary Exactly Matched: test-vm ===
+test-vm : test-vm
+=== Name & Summary Matched: test-vm ===
+test-vm-2 : test-vm
+=== Summary Exactly Matched: test-vm ===
+test-template-exact : test-vm
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_173_search_multiquery_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-template-exact',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'should-not-match',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Summary',
+                'test-vm Qubes template\n for fedora-31\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            all=False,
+            templates=['test-vm', 'test-template']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.search(args, self.app)
+            self.assertEqual(mock_out.getvalue(),
+'''=== Name & Summary Matched: test-template, test-vm ===
+test-template-exact : test-vm
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_174_search_multiquery_exact_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'summary',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'summary',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm Summary',
+                'Qubes template\n for fedora-31\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            all=False,
+            templates=['test-vm', 'summary']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.search(args, self.app)
+            self.assertEqual(mock_out.getvalue(),
+'''=== Name & Summary Matched: summary, test-vm ===
+summary : test-vm Summary
+=== Name & Summary Exactly Matched: summary, test-vm ===
+test-vm : summary
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_175_search_all_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org/keyword-url',
+                'summary',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm-exact',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm Summary',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm-exac2',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'test-vm-exac2',
+                'test-vm Summary',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm-2',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'test-vm Summary',
+                'keyword-desc'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'should-not-match',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Summary',
+                'Description'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            all=True,
+            templates=['test-vm-exact', 'test-vm-exac2',
+                'keyword-url', 'keyword-desc']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.search(args, self.app)
+            self.assertEqual(mock_out.getvalue(),
+'''=== Name & URL Exactly Matched: test-vm-exac2 ===
+test-vm-exac2 : test-vm Summary
+=== Name Exactly Matched: test-vm-exact ===
+test-vm-exact : test-vm Summary
+=== Description Exactly Matched: keyword-desc ===
+test-vm-2 : test-vm Summary
+=== URL Matched: keyword-url ===
+test-vm : summary
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_repoquery')
+    def test_176_search_wildcard_success(self, mock_query):
+        mock_query.return_value = [
+            qubesadmin.tools.qvm_template.Template(
+                'test-vm',
+                '2',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'should-not-match-3',
+                '0',
+                '4.1',
+                '2020',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 9, 1, 14, 30,
+                    tzinfo=datetime.timezone.utc),
+                'GPL',
+                'https://qubes-os.org/test-vm',
+                'Qubes template for fedora-31',
+                'test-vm Qubes template\n for fedora-31\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            all=False,
+            templates=['t?st-vm']
+        )
+        with mock.patch('sys.stdout', new=io.StringIO()) as mock_out:
+            qubesadmin.tools.qvm_template.search(args, self.app)
+            self.assertEqual(mock_out.getvalue(),
+'''=== Name Matched: t?st-vm ===
+test-vm : Qubes template for fedora-31
+''')
+        self.assertEqual(mock_query.mock_calls, [
+            mock.call(args, self.app)
+        ])
+        self.assertAllCalled()
+
+    def _mock_qrexec_download(self, args, app, spec, path,
+                              dlsize=None, refresh=False):
+        self.assertFalse(os.path.exists(path),
+            '{} should not exist before'.format(path))
+        # just create an empty file
+        with open(path, 'wb') as f:
+            if f is not None:
+                f.truncate(dlsize)
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_180_download_success(self, mock_qrexec, mock_dllist,
+                                  mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        with tempfile.TemporaryDirectory() as dir:
+            args = argparse.Namespace(
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                retries=1
+            )
+            qubesadmin.tools.qvm_template.download(args, self.app, dir, {
+                    'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                        ('1', '2', '3'), 'qubes-templates-itl', 1048576),
+                    'fedora-32': qubesadmin.tools.qvm_template.DlEntry(
+                        ('0', '1', '2'),
+                        'qubes-templates-itl-testing',
+                        2048576)
+                })
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576),
+                mock.call(args, self.app, 'qubes-template-fedora-32-0:1-2',
+                    re_str(dir + '/.*/qubes-template-fedora-32-0:1-2.rpm.UNTRUSTED'),
+                    2048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [])
+            self.assertEqual(mock_verify_rpm.mock_calls, [
+                mock.call(re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                          '/tmp/keyring.gpg', template_name='fedora-31'),
+                mock.call(re_str(dir + '/.*/qubes-template-fedora-32-0:1-2.rpm.UNTRUSTED'),
+                          '/tmp/keyring.gpg', template_name='fedora-32'),
+            ])
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_181_download_success_nosuffix(self, mock_qrexec, mock_dllist,
+                                           mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        with tempfile.TemporaryDirectory() as dir:
+            args = argparse.Namespace(
+                retries=1,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                downloaddir=dir
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                qubesadmin.tools.qvm_template.download(args, self.app, None, {
+                        'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                            ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+                    })
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [])
+            self.assertEqual(mock_verify_rpm.mock_calls, [
+                mock.call(re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                          '/tmp/keyring.gpg', template_name='fedora-31'),
+            ])
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_182_download_success_getdllist(self, mock_qrexec, mock_dllist,
+                                            mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        mock_dllist.return_value = {
+            'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+        }
+        with tempfile.TemporaryDirectory() as dir:
+            args = argparse.Namespace(
+                retries=1,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+            )
+            qubesadmin.tools.qvm_template.download(args, self.app,
+                dir, None,
+                qubesadmin.tools.qvm_template.VersionSelector.LATEST_LOWER)
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [
+                mock.call(args, self.app,
+                    version_selector=\
+                        qubesadmin.tools.qvm_template.\
+                        VersionSelector.LATEST_LOWER)
+            ])
+            self.assertEqual(mock_verify_rpm.mock_calls, [
+                mock.call(re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                          '/tmp/keyring.gpg', template_name='fedora-31'),
+            ])
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_183_download_success_downloaddir(self, mock_qrexec, mock_dllist,
+                                              mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        with tempfile.TemporaryDirectory() as dir:
+            args = argparse.Namespace(
+                retries=1,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                downloaddir=dir
+            )
+            qubesadmin.tools.qvm_template.download(args, self.app, None, {
+                    'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                        ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+                })
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [])
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_184_download_success_exists(self, mock_qrexec, mock_dllist,
+                                         mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        with tempfile.TemporaryDirectory() as dir:
+            with open(os.path.join(
+                        dir, 'qubes-template-fedora-31-1:2-3.rpm'),
+                    'w') as _:
+                pass
+            args = argparse.Namespace(
+                retries=1,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                downloaddir=dir
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                qubesadmin.tools.qvm_template.download(args, self.app, None, {
+                        'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                            ('1', '2', '3'), 'qubes-templates-itl', 1048576),
+                        'fedora-32': qubesadmin.tools.qvm_template.DlEntry(
+                            ('0', '1', '2'),
+                            'qubes-templates-itl-testing',
+                            2048576)
+                    })
+                self.assertTrue('already exists, skipping'
+                    in mock_err.getvalue())
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-32-0:1-2',
+                    re_str(dir + '/.*/qubes-template-fedora-32-0:1-2.rpm.UNTRUSTED'),
+                    2048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [])
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_185_download_success_existsmove(self, mock_qrexec, mock_dllist,
+                                             mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        with tempfile.TemporaryDirectory() as dir:
+            with open(os.path.join(
+                        dir, 'qubes-template-fedora-31-1:2-3.rpm'),
+                    'w') as _:
+                pass
+            args = argparse.Namespace(
+                retries=1,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                downloaddir=dir
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                qubesadmin.tools.qvm_template.download(args, self.app, None, {
+                        'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                            ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+                    })
+                self.assertTrue('already exists, skipping'
+                    in mock_err.getvalue())
+            self.assertEqual(mock_qrexec.mock_calls, [])
+            self.assertEqual(mock_dllist.mock_calls, [])
+            self.assertTrue(os.path.exists(
+                dir + '/qubes-template-fedora-31-1:2-3.rpm'))
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_186_download_success_existsnosuffix(self, mock_qrexec, mock_dllist,
+                                                 mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        with tempfile.TemporaryDirectory() as dir:
+            with open(os.path.join(
+                        dir, 'qubes-template-fedora-31-1:2-3.rpm'),
+                    'w') as _:
+                pass
+            args = argparse.Namespace(
+                retries=1,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                downloaddir=dir
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+                qubesadmin.tools.qvm_template.download(args, self.app, None, {
+                        'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                            ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+                    })
+                self.assertTrue('already exists, skipping'
+                    in mock_err.getvalue())
+            self.assertEqual(mock_qrexec.mock_calls, [])
+            self.assertEqual(mock_dllist.mock_calls, [])
+            self.assertTrue(os.path.exists(
+                dir + '/qubes-template-fedora-31-1:2-3.rpm'))
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_187_download_success_retry(self, mock_qrexec, mock_dllist,
+                                        mock_verify_rpm):
+        counter = 0
+        def f(*args, **kwargs):
+            nonlocal counter
+            counter += 1
+            if counter == 1:
+                raise ConnectionError
+            self._mock_qrexec_download(*args, **kwargs)
+        mock_qrexec.side_effect = f
+        with tempfile.TemporaryDirectory() as dir:
+            args = argparse.Namespace(
+                retries=2,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                downloaddir=dir
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                    mock.patch('os.remove') as mock_rm:
+                qubesadmin.tools.qvm_template.download(args, self.app, None, {
+                        'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                            ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+                    })
+                self.assertTrue('retrying...' in mock_err.getvalue())
+                self.assertEqual(mock_rm.mock_calls, [
+                    mock.call(re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'))
+                ])
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576),
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [])
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_188_download_fail_retry(self, mock_qrexec, mock_dllist,
+                                     mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        counter = 0
+        def f(*args, **kwargs):
+            nonlocal counter
+            counter += 1
+            if counter <= 3:
+                raise ConnectionError
+            self._mock_qrexec_download(*args, **kwargs)
+        mock_qrexec.side_effect = f
+        with tempfile.TemporaryDirectory() as dir:
+            args = argparse.Namespace(
+                retries=3,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                downloaddir=dir
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                    mock.patch('os.remove') as mock_rm:
+                with self.assertRaises(SystemExit):
+                    qubesadmin.tools.qvm_template.download(
+                        args, self.app, None, {
+                            'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                                ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+                        })
+                self.assertEqual(mock_err.getvalue().count('retrying...'), 2)
+                self.assertTrue('download failed' in mock_err.getvalue())
+                self.assertEqual(mock_rm.mock_calls, [
+                    mock.call(re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED')),
+                    mock.call(re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED')),
+                    mock.call(re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'))
+                ])
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576),
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576),
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                    1048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [])
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_189_download_fail_interrupt(self, mock_qrexec, mock_dllist,
+                                         mock_verify_rpm):
+        def f(*args):
+            raise RuntimeError
+        mock_qrexec.side_effect = f
+        with tempfile.TemporaryDirectory() as dir:
+            args = argparse.Namespace(
+                retries=3,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                downloaddir=dir
+            )
+            with mock.patch('sys.stderr', new=io.StringIO()) as mock_err, \
+                    mock.patch('os.remove') as mock_rm:
+                with self.assertRaises(RuntimeError):
+                    qubesadmin.tools.qvm_template.download(
+                        args, self.app, None, {
+                            'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                                ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+                        })
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm'),
+                    1048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [])
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_190_download_fail_verify(self, mock_qrexec, mock_dllist,
+                                         mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download
+        mock_verify_rpm.side_effect = \
+            qubesadmin.tools.qvm_template.SignatureVerificationError
+
+        with tempfile.TemporaryDirectory() as dir:
+            args = argparse.Namespace(
+                retries=3,
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=True,  # make sure it gets ignored
+                downloaddir=dir
+            )
+            with self.assertRaises(qubesadmin.tools.qvm_template.SignatureVerificationError):
+                qubesadmin.tools.qvm_template.download(
+                    args, self.app, None, {
+                        'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                            ('1', '2', '3'), 'qubes-templates-itl', 1048576)
+                    })
+            self.assertEqual(mock_qrexec.mock_calls, [
+                mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                    re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm'),
+                    1048576)
+            ])
+            self.assertEqual(mock_dllist.mock_calls, [])
+            self.assertEqual(os.listdir(dir), [])
+            self.assertEqual(mock_verify_rpm.mock_calls, [
+                mock.call(re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                          '/tmp/keyring.gpg', template_name='fedora-31'),
+            ])
+
+    def _mock_qrexec_download_short(self, args, app, spec, path,
+                              dlsize=None, refresh=False):
+        self.assertFalse(os.path.exists(path),
+            '{} should not exist before'.format(path))
+        # just create an empty file
+        with open(path, 'wb') as f:
+            if f is not None:
+                f.truncate(dlsize // 2)
+
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.qrexec_download')
+    def test_191_download_fail_short(self, mock_qrexec, mock_dllist,
+                                  mock_verify_rpm):
+        mock_qrexec.side_effect = self._mock_qrexec_download_short
+        with tempfile.TemporaryDirectory() as dir, \
+            self.assertRaises(SystemExit):
+            args = argparse.Namespace(
+                repo_files=[],
+                keyring='/tmp/keyring.gpg',
+                releasever='4.1',
+                nogpgcheck=False,
+                retries=1
+            )
+            qubesadmin.tools.qvm_template.download(args, self.app, dir, {
+                    'fedora-31': qubesadmin.tools.qvm_template.DlEntry(
+                        ('1', '2', '3'), 'qubes-templates-itl', 1048576),
+                })
+        self.assertEqual(mock_qrexec.mock_calls, [
+            mock.call(args, self.app, 'qubes-template-fedora-31-1:2-3',
+                re_str(dir + '/.*/qubes-template-fedora-31-1:2-3.rpm.UNTRUSTED'),
+                1048576),
+        ])
+        self.assertEqual(mock_dllist.mock_calls, [])
+        self.assertEqual(mock_verify_rpm.mock_calls, [])
+
+
+    @mock.patch('os.remove')
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_200_reinstall_success(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename,
+            mock_remove):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        build_time = '2020-09-01 14:30:00' # 1598970600
+        install_time = '2020-09-01 15:30:00'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020'),
+                ('reponame', 'qubes-templates-itl'),
+                ('buildtime', build_time),
+                ('installtime', install_time),
+                ('license', 'GPL'),
+                ('url', 'https://qubes-os.org'),
+                ('summary', 'Summary'),
+                ('description', 'Desc|desc')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Set',
+                f'template-{key}',
+                val.encode())] = b'0\0'
+        rpm_hdr = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_dl.return_value = {'test-vm': rpm_hdr}
+        dl_list = {
+            'test-vm': qubesadmin.tools.qvm_template.DlEntry(
+                ('2', '4.1', '2020'), 'qubes-templates-itl', 1048576)
+        }
+        mock_dl_list.return_value = dl_list
+        mock_call.side_effect = self.add_new_vm_side_effect
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        mock_time.now.return_value = \
+            datetime.datetime(2020, 9, 1, 15, 30, tzinfo=datetime.timezone.utc)
+        selector = qubesadmin.tools.qvm_template.VersionSelector.REINSTALL
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir:
+            args = argparse.Namespace(
+                templates=['test-vm'],
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                keep_cache=True,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            qubesadmin.tools.qvm_template.install(args, self.app,
+                version_selector=selector,
+                override_existing=True)
+        # Attempt to get download list
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list=dl_list, version_selector=selector)
+        # already verified by download()
+        self.assertEqual(mock_verify.mock_calls, [])
+        # Package is extracted
+        mock_extract.assert_called_with('test-vm',
+            '/var/cache/qvm-template/qubes-template-test-vm-2:4.1-2020.rpm',
+            '/var/tmp/qvm-template-tmpdir')
+        # Expect override confirmation
+        self.assertEqual(mock_confirm.mock_calls,
+            [mock.call(re_str(r'.*override changes.*:'), ['test-vm'])])
+        # qvm-template-postprocess is called
+        self.assertEqual(mock_call.mock_calls, [
+            mock.call([
+                'qvm-template-postprocess',
+                '--really',
+                '--no-installed-by-rpm',
+                'post-install',
+                'test-vm',
+                '/var/tmp/qvm-template-tmpdir'
+                    '/var/lib/qubes/vm-templates/test-vm'
+            ])
+        ])
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        # Downloaded package should not be removed
+        self.assertEqual(mock_remove.mock_calls, [
+            mock.call('/tmp/test.lock')
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_201_reinstall_fail_noversion(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2021')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        rpm_hdr = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_dl.return_value = {'test-vm': rpm_hdr}
+        dl_list = {
+            'test-vm': qubesadmin.tools.qvm_template.DlEntry(
+                ('1', '4.1', '2020'), 'qubes-templates-itl', 1048576)
+        }
+        mock_dl_list.return_value = dl_list
+        selector = qubesadmin.tools.qvm_template.VersionSelector.REINSTALL
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                self.assertRaises(SystemExit) as e, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            args = argparse.Namespace(
+                templates=['test-vm'],
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                keep_cache=True,
+                allow_pv=False,
+                pool=None
+            )
+            qubesadmin.tools.qvm_template.install(args, self.app,
+                version_selector=selector,
+                override_existing=True)
+            self.assertIn(
+                'Same version of template \'test-vm\' not found',
+                mock_err.getvalue())
+        # Attempt to get download list
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list=dl_list, version_selector=selector)
+        # already verified by download()
+        self.assertEqual(mock_verify.mock_calls, [])
+        # Expect override confirmation
+        self.assertEqual(mock_confirm.mock_calls,
+            [mock.call(re_str(r'.*override changes.*:'), ['test-vm'])])
+        # Nothing extracted / installed
+        mock_extract.assert_not_called()
+        mock_call.assert_not_called()
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_202_reinstall_local_success(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        build_time = '2020-09-01 14:30:00' # 1598970600
+        install_time = '2020-09-01 15:30:00'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020'),
+                ('reponame', '@commandline'),
+                ('buildtime', build_time),
+                ('installtime', install_time),
+                ('license', 'GPL'),
+                ('url', 'https://qubes-os.org'),
+                ('summary', 'Summary'),
+                ('description', 'Desc|desc')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Set',
+                f'template-{key}',
+                val.encode())] = b'0\0'
+        rpm_hdr = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_verify.return_value = rpm_hdr
+        dl_list = {}
+        mock_dl_list.return_value = dl_list
+        mock_call.side_effect = self.add_new_vm_side_effect
+        mock_time = mock.Mock(wraps=datetime.datetime)
+        mock_time.now.return_value = \
+            datetime.datetime(2020, 9, 1, 15, 30, tzinfo=datetime.timezone.utc)
+        selector = qubesadmin.tools.qvm_template.VersionSelector.REINSTALL
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                mock.patch('datetime.datetime', new=mock_time), \
+                mock.patch('tempfile.TemporaryDirectory') as mock_tmpdir, \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file:
+            path = template_file.name
+            args = argparse.Namespace(
+                templates=[path],
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                allow_pv=False,
+                pool=None
+            )
+            mock_tmpdir.return_value.__enter__.return_value = \
+                '/var/tmp/qvm-template-tmpdir'
+            qubesadmin.tools.qvm_template.install(args, self.app,
+                version_selector=selector,
+                override_existing=True)
+            # Package is extracted
+            mock_extract.assert_called_with(
+                'test-vm',
+                path,
+                '/var/tmp/qvm-template-tmpdir')
+            # Package verified
+            self.assertEqual(mock_verify.mock_calls, [
+                mock.call(path, '/tmp/keyring.gpg', nogpgcheck=False)
+            ])
+        # Attempt to get download list
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list=dl_list, version_selector=selector)
+        # Expect override confirmation
+        self.assertEqual(mock_confirm.mock_calls,
+            [mock.call(re_str(r'.*override changes.*:'), ['test-vm'])])
+        # qvm-template-postprocess is called
+        self.assertEqual(mock_call.mock_calls, [
+            mock.call([
+                'qvm-template-postprocess',
+                '--really',
+                '--no-installed-by-rpm',
+                'post-install',
+                'test-vm',
+                '/var/tmp/qvm-template-tmpdir'
+                    '/var/lib/qubes/vm-templates/test-vm'
+            ])
+        ])
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_remove.main')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    def test_210_remove_success(self, mock_confirm, mock_remove):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = (
+            b'0\x00vm1 class=TemplateVM state=Halted\n'
+            b'vm2 class=TemplateVM state=Halted\n'
+        )
+        args = argparse.Namespace(
+            templates=['vm1', 'vm2'],
+            yes=False
+        )
+        qubesadmin.tools.qvm_template.remove(args, self.app)
+        self.assertEqual(mock_confirm.mock_calls,
+            [mock.call(re_str(r'.*completely remove.*'), ['vm1', 'vm2'])])
+        self.assertEqual(mock_remove.mock_calls, [
+            mock.call(['--force', '--', 'vm1', 'vm2'], self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_kill.main')
+    @mock.patch('qubesadmin.tools.qvm_remove.main')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    def test_211_remove_purge_disassoc_success(
+            self,
+            mock_confirm,
+            mock_remove,
+            mock_kill):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = (
+            b'0\x00vm1 class=TemplateVM state=Halted\n'
+            b'vm2 class=TemplateVM state=Halted\n'
+            b'vm3 class=TemplateVM state=Halted\n'
+            b'vm4 class=TemplateVM state=Halted\n'
+            b'dummy class=TemplateVM state=Halted\n'
+            b'dummy-1 class=TemplateVM state=Halted\n'
+        )
+        self.app.expected_calls[
+                ('dummy', 'admin.vm.feature.Get', 'template-dummy', None)] = \
+            b'0\x000'
+        self.app.expected_calls[
+                ('dummy-1', 'admin.vm.feature.Get',
+                    'template-dummy', None)] = \
+            b'0\x001'
+        self.app.expected_calls[
+                ('vm2', 'admin.vm.property.Set',
+                    'default_template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm2', 'admin.vm.property.Set', 'template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm3', 'admin.vm.property.Set', 'netvm', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm3', 'admin.vm.property.Set', 'template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm4', 'admin.vm.property.Set', 'netvm', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm4', 'admin.vm.property.Set', 'template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('dom0', 'admin.property.Set', 'updatevm', b'')] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            templates=['vm1'],
+            yes=False
+        )
+        def deps(app, vm):
+            if vm == 'vm1':
+                return [(self.app.domains['vm2'], 'default_template'),
+                        (self.app.domains['vm3'], 'netvm')]
+            if vm == 'vm2' or vm == 'vm3':
+                return [(self.app.domains['vm4'], 'netvm')]
+            if vm == 'vm4':
+                return [(None, 'updatevm')]
+            return []
+        with mock.patch('qubesadmin.utils.vm_dependencies') as mock_deps:
+            mock_deps.side_effect = deps
+            qubesadmin.tools.qvm_template.remove(args, self.app, purge=True)
+            # Once for purge (dependency detection) and
+            # one for disassoc (actually disassociating the dependencies
+            self.assertEqual(mock_deps.mock_calls, [
+                mock.call(self.app, self.app.domains['vm1']),
+                mock.call(self.app, self.app.domains['vm2']),
+                mock.call(self.app, self.app.domains['vm3']),
+                mock.call(self.app, self.app.domains['vm4']),
+                mock.call(self.app, self.app.domains['vm1']),
+                mock.call(self.app, self.app.domains['vm2']),
+                mock.call(self.app, self.app.domains['vm3']),
+                mock.call(self.app, self.app.domains['vm4'])
+            ])
+        self.assertEqual(mock_confirm.mock_calls, [
+            mock.call(re_str(r'.*completely remove.*'),
+                ['vm1', 'vm2', 'vm3', 'vm4']),
+            mock.call(re_str(r'.*completely remove.*'),
+                ['vm1', 'vm2', 'vm3', 'vm4']),
+            mock.call(re_str(r'.*completely remove.*'),
+                ['vm1', 'vm2', 'vm3', 'vm4'])
+        ])
+        self.assertEqual(mock_remove.mock_calls, [
+            mock.call(['--force', '--', 'vm1', 'vm2', 'vm3', 'vm4', 'dummy-1'],
+                self.app)
+        ])
+        self.assertEqual(mock_kill.mock_calls, [
+            mock.call(['--', 'vm1', 'vm2', 'vm3', 'vm4', 'dummy-1'], self.app)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_kill.main')
+    @mock.patch('qubesadmin.tools.qvm_remove.main')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    def test_212_remove_disassoc_success(
+            self,
+            mock_confirm,
+            mock_remove,
+            mock_kill):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = (
+            b'0\x00vm1 class=TemplateVM state=Halted\n'
+            b'vm2 class=TemplateVM state=Halted\n'
+            b'vm3 class=TemplateVM state=Halted\n'
+            b'vm4 class=TemplateVM state=Halted\n'
+            b'dummy class=TemplateVM state=Halted\n'
+            b'dummy-1 class=TemplateVM state=Halted\n'
+        )
+        self.app.expected_calls[
+                ('dummy', 'admin.vm.feature.Get', 'template-dummy', None)] = \
+            b'0\x000'
+        self.app.expected_calls[
+                ('dummy-1', 'admin.vm.feature.Get',
+                    'template-dummy', None)] = \
+            b'0\x001'
+        self.app.expected_calls[
+                ('vm2', 'admin.vm.property.Set',
+                    'default_template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm2', 'admin.vm.property.Set', 'template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm3', 'admin.vm.property.Set', 'netvm', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm3', 'admin.vm.property.Set', 'template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm4', 'admin.vm.property.Set', 'netvm', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm4', 'admin.vm.property.Set', 'template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('dom0', 'admin.property.Set', 'updatevm', b'')] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            templates=['vm1', 'vm2', 'vm3', 'vm4'],
+            yes=False
+        )
+        def deps(app, vm):
+            if vm == 'vm1':
+                return [(self.app.domains['vm2'], 'default_template'),
+                        (self.app.domains['vm3'], 'netvm')]
+            if vm == 'vm2' or vm == 'vm3':
+                return [(self.app.domains['vm4'], 'netvm')]
+            if vm == 'vm4':
+                return [(None, 'updatevm')]
+            return []
+        with mock.patch('qubesadmin.utils.vm_dependencies') as mock_deps:
+            mock_deps.side_effect = deps
+            qubesadmin.tools.qvm_template.remove(args, self.app, disassoc=True)
+            self.assertEqual(mock_deps.mock_calls, [
+                mock.call(self.app, self.app.domains['vm1']),
+                mock.call(self.app, self.app.domains['vm2']),
+                mock.call(self.app, self.app.domains['vm3']),
+                mock.call(self.app, self.app.domains['vm4'])
+            ])
+        self.assertEqual(mock_confirm.mock_calls, [
+            mock.call(re_str(r'.*completely remove.*'),
+                ['vm1', 'vm2', 'vm3', 'vm4'])
+        ])
+        self.assertEqual(mock_remove.mock_calls, [
+            mock.call(['--force', '--', 'vm1', 'vm2', 'vm3', 'vm4'],
+                self.app)
+        ])
+        self.assertEqual(mock_kill.mock_calls, [
+            mock.call(['--', 'vm1', 'vm2', 'vm3', 'vm4'], self.app)
+        ])
+        self.assertAllCalled()
+
+    def test_213_remove_fail_nodomain(self):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00vm1 class=TemplateVM state=Halted\n'
+        args = argparse.Namespace(
+            templates=['vm0'],
+            yes=False
+        )
+        with mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            with self.assertRaises(SystemExit):
+                qubesadmin.tools.qvm_template.remove(args, self.app)
+            self.assertTrue('no such domain:' in mock_err.getvalue())
+        self.assertAllCalled()
+
+    @mock.patch('qubesadmin.tools.qvm_kill.main')
+    @mock.patch('qubesadmin.tools.qvm_remove.main')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    def test_214_remove_disassoc_success_newdummy(
+            self,
+            mock_confirm,
+            mock_remove,
+            mock_kill):
+        def append_new_vm_side_effect(*args, **kwargs):
+            self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] += \
+                b'dummy-1 class=TemplateVM state=Halted\n'
+            self.app.domains.clear_cache()
+            return self.app.domains['dummy-1']
+        self.app.add_new_vm = mock.Mock(side_effect=append_new_vm_side_effect)
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = (
+            b'0\x00vm1 class=TemplateVM state=Halted\n'
+            b'vm2 class=TemplateVM state=Halted\n'
+            b'dummy class=TemplateVM state=Halted\n'
+        )
+        self.app.expected_calls[
+                ('dummy', 'admin.vm.feature.Get', 'template-dummy', None)] = \
+            b'0\x000'
+        self.app.expected_calls[
+                ('dummy-1', 'admin.vm.feature.Set',
+                    'template-dummy', b'1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm2', 'admin.vm.property.Set',
+                    'default_template', b'dummy-1')] = \
+            b'0\x00'
+        self.app.expected_calls[
+                ('vm2', 'admin.vm.property.Set',
+                    'template', b'dummy-1')] = \
+            b'0\x00'
+        args = argparse.Namespace(
+            templates=['vm1'],
+            yes=False
+        )
+        def deps(app, vm):
+            if vm == 'vm1':
+                return [(self.app.domains['vm2'], 'default_template')]
+            return []
+        with mock.patch('qubesadmin.utils.vm_dependencies') as mock_deps:
+            mock_deps.side_effect = deps
+            qubesadmin.tools.qvm_template.remove(args, self.app, disassoc=True)
+            self.assertEqual(mock_deps.mock_calls, [
+                mock.call(self.app, self.app.domains['vm1'])
+            ])
+        self.assertEqual(mock_confirm.mock_calls, [
+            mock.call(re_str(r'.*completely remove.*'), ['vm1'])
+        ])
+        self.assertEqual(mock_remove.mock_calls, [
+            mock.call(['--force', '--', 'vm1'], self.app)
+        ])
+        self.assertEqual(mock_kill.mock_calls, [
+            mock.call(['--', 'vm1'], self.app)
+        ])
+        self.assertAllCalled()
+
+    def test_220_get_keys_for_repos_success(self):
+        with tempfile.NamedTemporaryFile() as f:
+            f.write(
+b'''[qubes-templates-itl]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
+enabled = 1
+fastestmirror = 1
+metadata_expire = 7d
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+[qubes-templates-itl-testing-nokey]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl-testing
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl-testing
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl-testing/repodata/repomd.xml.metalink
+enabled = 0
+fastestmirror = 1
+gpgcheck = 1
+#gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
+[qubes-templates-itl-testing]
+name = Qubes Templates repository
+#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl-testing
+#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl-testing
+metalink = https://yum.qubes-os.org/r$releasever/templates-itl-testing/repodata/repomd.xml.metalink
+enabled = 0
+fastestmirror = 1
+gpgcheck = 1
+gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary-testing
+''')
+            f.flush()
+            ret = qubesadmin.tools.qvm_template.get_keys_for_repos(
+                [f.name], 'r4.1')
+            self.assertEqual(ret, {
+                'qubes-templates-itl':
+                    '/etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-r4.1-primary',
+                'qubes-templates-itl-testing':
+                    '/etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-r4.1-primary-testing'
+            })
+            self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_220_downgrade_skip_lower(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2020')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        rpm_hdr = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2021',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_verify.return_value = rpm_hdr
+        mock_dl_list.return_value = {}
+        selector = qubesadmin.tools.qvm_template.VersionSelector.LATEST_LOWER
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            args = argparse.Namespace(
+                templates=[template_file.name],
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                keep_cache=True,
+                allow_pv=False,
+                pool=None
+            )
+            qubesadmin.tools.qvm_template.install(args, self.app,
+                version_selector=selector,
+                override_existing=True)
+            self.assertIn(
+                'lower version already installed',
+                mock_err.getvalue())
+        # Attempt to get download list
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list={}, version_selector=selector)
+        self.assertEqual(mock_verify.mock_calls, [
+            mock.call(template_file.name, '/tmp/keyring.gpg', nogpgcheck=False)
+        ])
+        # No confirmation since nothing needs to be done
+        mock_confirm.assert_not_called()
+        # Nothing extracted / installed
+        mock_extract.assert_not_called()
+        mock_call.assert_not_called()
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('os.rename')
+    @mock.patch('os.makedirs')
+    @mock.patch('subprocess.check_call')
+    @mock.patch('qubesadmin.tools.qvm_template.confirm_action')
+    @mock.patch('qubesadmin.tools.qvm_template.extract_rpm')
+    @mock.patch('qubesadmin.tools.qvm_template.download')
+    @mock.patch('qubesadmin.tools.qvm_template.get_dl_list')
+    @mock.patch('qubesadmin.tools.qvm_template.verify_rpm')
+    def test_221_upgrade_skip_higher(
+            self,
+            mock_verify,
+            mock_dl_list,
+            mock_dl,
+            mock_extract,
+            mock_confirm,
+            mock_call,
+            mock_mkdirs,
+            mock_rename):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'test-vm'),
+                ('epoch', '2'),
+                ('version', '4.1'),
+                ('release', '2021')]:
+            self.app.expected_calls[(
+                'test-vm',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        rpm_hdr = {
+            rpm.RPMTAG_NAME        : 'qubes-template-test-vm',
+            rpm.RPMTAG_BUILDTIME   : 1598970600,
+            rpm.RPMTAG_DESCRIPTION : 'Desc\ndesc',
+            rpm.RPMTAG_EPOCHNUM    : 2,
+            rpm.RPMTAG_LICENSE     : 'GPL',
+            rpm.RPMTAG_RELEASE     : '2020',
+            rpm.RPMTAG_SUMMARY     : 'Summary',
+            rpm.RPMTAG_URL         : 'https://qubes-os.org',
+            rpm.RPMTAG_VERSION     : '4.1'
+        }
+        mock_verify.return_value = rpm_hdr
+        mock_dl_list.return_value = {}
+        selector = qubesadmin.tools.qvm_template.VersionSelector.LATEST_HIGHER
+        with mock.patch('qubesadmin.tools.qvm_template.LOCK_FILE', '/tmp/test.lock'), \
+                tempfile.NamedTemporaryFile(suffix='.rpm') as template_file, \
+                mock.patch('sys.stderr', new=io.StringIO()) as mock_err:
+            args = argparse.Namespace(
+                templates=[template_file.name],
+                keyring='/tmp/keyring.gpg',
+                nogpgcheck=False,
+                cachedir='/var/cache/qvm-template',
+                repo_files=[],
+                releasever='4.1',
+                yes=False,
+                keep_cache=True,
+                allow_pv=False,
+                pool=None
+            )
+            qubesadmin.tools.qvm_template.install(args, self.app,
+                version_selector=selector,
+                override_existing=True)
+            self.assertIn(
+                'higher version already installed',
+                mock_err.getvalue())
+        # Attempt to get download list
+        self.assertEqual(mock_dl_list.mock_calls, [
+            mock.call(args, self.app, version_selector=selector)
+        ])
+        mock_dl.assert_called_with(args, self.app,
+            path_override='/var/cache/qvm-template',
+            dl_list={}, version_selector=selector)
+        self.assertEqual(mock_verify.mock_calls, [
+            mock.call(template_file.name, '/tmp/keyring.gpg', nogpgcheck=False)
+        ])
+        # No confirmation since nothing needs to be done
+        mock_confirm.assert_not_called()
+        # Nothing extracted / installed
+        mock_extract.assert_not_called()
+        mock_call.assert_not_called()
+        # Cache directory created
+        self.assertEqual(mock_mkdirs.mock_calls, [
+            mock.call(args.cachedir, exist_ok=True)
+        ])
+        self.assertAllCalled()
+
+    def test_230_filter_version_latest(self):
+        query_res = [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl-testing',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            )
+        ]
+        results = qubesadmin.tools.qvm_template.filter_version(
+            query_res,
+            self.app
+        )
+        self.assertEqual(sorted(results), [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            )
+        ])
+        self.assertAllCalled()
+
+    def test_231_filter_version_reinstall(self):
+        query_res = [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl-testing',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00fedora-31 class=TemplateVM state=Halted\n' \
+            b'fedora-32 class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'fedora-31'),
+                ('epoch', '0'),
+                ('version', '4.1'),
+                ('release', '20200101')]:
+            self.app.expected_calls[(
+                'fedora-31',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'fedora-32'),
+                ('epoch', '0'),
+                ('version', '4.1'),
+                ('release', '20200101')]:
+            self.app.expected_calls[(
+                'fedora-32',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        results = qubesadmin.tools.qvm_template.filter_version(
+            query_res,
+            self.app,
+            qubesadmin.tools.qvm_template.VersionSelector.REINSTALL
+        )
+        self.assertEqual(sorted(results), [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            )
+        ])
+        self.assertAllCalled()
+
+    def test_232_filter_version_upgrade(self):
+        query_res = [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl-testing',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00fedora-31 class=TemplateVM state=Halted\n' \
+            b'fedora-32 class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'fedora-31'),
+                ('epoch', '0'),
+                ('version', '4.1'),
+                ('release', '20200101')]:
+            self.app.expected_calls[(
+                'fedora-31',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'fedora-32'),
+                ('epoch', '0'),
+                ('version', '4.1'),
+                ('release', '20200101')]:
+            self.app.expected_calls[(
+                'fedora-32',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        results = qubesadmin.tools.qvm_template.filter_version(
+            query_res,
+            self.app,
+            qubesadmin.tools.qvm_template.VersionSelector.LATEST_HIGHER
+        )
+        self.assertEqual(sorted(results), [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            )
+        ])
+        self.assertAllCalled()
+
+    def test_233_filter_version_downgrade(self):
+        query_res = [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-31',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-31',
+                'Qubes template\n for fedora-31\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            ),
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200102',
+                'qubes-templates-itl-testing',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            )
+        ]
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00fedora-31 class=TemplateVM state=Halted\n' \
+            b'fedora-32 class=TemplateVM state=Halted\n'
+        for key, val in [
+                ('name', 'fedora-31'),
+                ('epoch', '0'),
+                ('version', '4.1'),
+                ('release', '20200101')]:
+            self.app.expected_calls[(
+                'fedora-31',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        for key, val in [
+                ('name', 'fedora-32'),
+                ('epoch', '0'),
+                ('version', '4.1'),
+                ('release', '20200102')]:
+            self.app.expected_calls[(
+                'fedora-32',
+                'admin.vm.feature.Get',
+                f'template-{key}',
+                None)] = b'0\0' + val.encode()
+        results = qubesadmin.tools.qvm_template.filter_version(
+            query_res,
+            self.app,
+            qubesadmin.tools.qvm_template.VersionSelector.LATEST_LOWER
+        )
+        self.assertEqual(sorted(results), [
+            qubesadmin.tools.qvm_template.Template(
+                'fedora-32',
+                '0',
+                '4.1',
+                '20200101',
+                'qubes-templates-itl',
+                1048576,
+                datetime.datetime(2020, 1, 23, 4, 56),
+                'GPL',
+                'https://qubes-os.org',
+                'Qubes template for fedora-32',
+                'Qubes template\n for fedora-32\n'
+            )
+        ])
+        self.assertAllCalled()
+
+    @mock.patch('os.path.exists')
+    def test_240_qubes_release(self, mock_exists):
+        # /usr/share/qubes/marker-vm does not exist
+        mock_exists.return_value = False
+        marker_vm = '''
+NAME=Qubes
+VERSION="4.2 (R4.2)"
+ID=qubes
+# Some comments here
+VERSION_ID=4.2
+PRETTY_NAME="Qubes 4.2 (R4.2)"
+ANSI_COLOR="0;31"
+CPE_NAME="cpe:/o:ITL:qubes:4.2"
+'''
+        with mock.patch('builtins.open', mock.mock_open(read_data=marker_vm)) \
+                as mock_open:
+            ret = qubesadmin.tools.qvm_template.qubes_release()
+            self.assertEqual(ret, '4.2')
+            self.assertEqual(mock_exists.mock_calls, [
+                mock.call('/usr/share/qubes/marker-vm')
+            ])
+            mock_open.assert_called_with('/etc/os-release', 'r')
+        self.assertAllCalled()
+
+    @mock.patch('os.path.exists')
+    def test_241_qubes_release_quotes(self, mock_exists):
+        # /usr/share/qubes/marker-vm does not exist
+        mock_exists.return_value = False
+        os_rel = '''
+NAME=Qubes
+VERSION="4.2 (R4.2)"
+ID=qubes
+# Some comments here
+VERSION_ID="4.2"
+PRETTY_NAME="Qubes 4.2 (R4.2)"
+ANSI_COLOR="0;31"
+CPE_NAME="cpe:/o:ITL:qubes:4.2"
+'''
+        with mock.patch('builtins.open', mock.mock_open(read_data=os_rel)) \
+                as mock_open:
+            ret = qubesadmin.tools.qvm_template.qubes_release()
+            self.assertEqual(ret, '4.2')
+            self.assertEqual(mock_exists.mock_calls, [
+                mock.call('/usr/share/qubes/marker-vm')
+            ])
+            mock_open.assert_called_with('/etc/os-release', 'r')
+        self.assertAllCalled()
+
+    @mock.patch('os.path.exists')
+    def test_242_qubes_release_quotes(self, mock_exists):
+        # /usr/share/qubes/marker-vm does exist
+        mock_exists.return_value = True
+        marker_vm = '''
+# This is just a marker file for Qubes OS VM.
+# This VM have tools for Qubes version:
+4.2
+'''
+        with mock.patch('builtins.open', mock.mock_open(read_data=marker_vm)) \
+                as mock_open:
+            ret = qubesadmin.tools.qvm_template.qubes_release()
+            self.assertEqual(ret, '4.2')
+            self.assertEqual(mock_exists.mock_calls, [
+                mock.call('/usr/share/qubes/marker-vm')
+            ])
+            mock_open.assert_called_with('/usr/share/qubes/marker-vm', 'r')
+        self.assertAllCalled()
+
+    def test_250_qrexec_download_success(self):
+        rand_bytes = os.urandom(128)
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+            ('test-vm', 'qubes.TemplateDownload')] = rand_bytes
+        args = argparse.Namespace(
+            repo_files=[],
+            releasever='4.1',
+            updatevm='test-vm',
+            enablerepo=[],
+            disablerepo=[],
+            repoid=[],
+            quiet=True
+        )
+        with tempfile.NamedTemporaryFile() as fd:
+            qubesadmin.tools.qvm_template.qrexec_download(
+                args, self.app, 'fedora-31:4.0', path=fd.name)
+            with open(fd.name, 'rb') as fd2:
+                result = fd2.read()
+            self.assertEqual(rand_bytes, result)
+        self.assertAllCalled()
+
+    def test_251_qrexec_download_fail(self):
+        rand_bytes = os.urandom(128)
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\x00test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_service_calls[
+            ('test-vm', 'qubes.TemplateDownload')] = rand_bytes
+        args = argparse.Namespace(
+            repo_files=[],
+            releasever='4.1',
+            updatevm='test-vm',
+            enablerepo=[],
+            disablerepo=[],
+            repoid=[],
+            quiet=True
+        )
+        with tempfile.NamedTemporaryFile() as fd, \
+                mock.patch('qubesadmin.tests.TestProcess.wait') as mock_wait:
+            mock_wait.return_value = 1
+            with self.assertRaises(ConnectionError):
+                qubesadmin.tools.qvm_template.qrexec_download(
+                    args, self.app, 'fedora-31:4.0', path=fd.name)
+        self.assertAllCalled()

+ 190 - 17
qubesadmin/tests/tools/qvm_template_postprocess.py

@@ -17,6 +17,7 @@
 #
 # 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 argparse
 import asyncio
 import os
 import subprocess
@@ -176,23 +177,51 @@ class TC_00_qvm_template_postprocess(qubesadmin.tests.QubesTestCase):
         self.assertAllCalled()
 
     def test_010_import_appmenus(self):
+        default_menu_items = [
+            'org.gnome.Terminal.desktop',
+            'firefox.desktop']
+        menu_items = [
+            'org.gnome.Terminal.desktop',
+            'org.gnome.Software.desktop',
+            'gnome-control-center.desktop']
+        netvm_menu_items = [
+            'org.gnome.Terminal.desktop',
+            'nm-connection-editor.desktop']
         with open(os.path.join(self.source_dir.name,
                 'vm-whitelisted-appmenus.list'), 'w') as f:
-            f.write('org.gnome.Terminal.desktop\n')
-            f.write('firefox.desktop\n')
+            for entry in default_menu_items:
+                f.write(entry + '\n')
         with open(os.path.join(self.source_dir.name,
                 'whitelisted-appmenus.list'), 'w') as f:
-            f.write('org.gnome.Terminal.desktop\n')
-            f.write('org.gnome.Software.desktop\n')
-            f.write('gnome-control-center.desktop\n')
+            for entry in menu_items:
+                f.write(entry + '\n')
+        with open(os.path.join(self.source_dir.name,
+                'netvm-whitelisted-appmenus.list'), 'w') as f:
+            for entry in netvm_menu_items:
+                f.write(entry + '\n')
 
         self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
             b'0\0test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Set',
+            'default-menu-items',
+            ' '.join(default_menu_items).encode())] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Set',
+            'menu-items',
+            ' '.join(menu_items).encode())] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Set',
+            'netvm-menu-items',
+            ' '.join(netvm_menu_items).encode())] = b'0\0'
 
         vm = self.app.domains['test-vm']
         with mock.patch('subprocess.check_call') as mock_proc:
             qubesadmin.tools.qvm_template_postprocess.import_appmenus(
-                vm, self.source_dir.name)
+                vm, self.source_dir.name, skip_generate=False)
         self.assertEqual(mock_proc.mock_calls, [
             mock.call(['qvm-appmenus',
                 '--set-default-whitelist=' + os.path.join(self.source_dir.name,
@@ -205,15 +234,43 @@ class TC_00_qvm_template_postprocess(qubesadmin.tests.QubesTestCase):
     @mock.patch('grp.getgrnam')
     @mock.patch('os.getuid')
     def test_011_import_appmenus_as_root(self, mock_getuid, mock_getgrnam):
+        default_menu_items = [
+            'org.gnome.Terminal.desktop',
+            'firefox.desktop']
+        menu_items = [
+            'org.gnome.Terminal.desktop',
+            'org.gnome.Software.desktop',
+            'gnome-control-center.desktop']
+        netvm_menu_items = [
+            'org.gnome.Terminal.desktop',
+            'nm-connection-editor.desktop']
         with open(os.path.join(self.source_dir.name,
                 'vm-whitelisted-appmenus.list'), 'w') as f:
-            f.write('org.gnome.Terminal.desktop\n')
-            f.write('firefox.desktop\n')
+            for entry in default_menu_items:
+                f.write(entry + '\n')
         with open(os.path.join(self.source_dir.name,
                 'whitelisted-appmenus.list'), 'w') as f:
-            f.write('org.gnome.Terminal.desktop\n')
-            f.write('org.gnome.Software.desktop\n')
-            f.write('gnome-control-center.desktop\n')
+            for entry in menu_items:
+                f.write(entry + '\n')
+        with open(os.path.join(self.source_dir.name,
+                'netvm-whitelisted-appmenus.list'), 'w') as f:
+            for entry in netvm_menu_items:
+                f.write(entry + '\n')
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Set',
+            'default-menu-items',
+            ' '.join(default_menu_items).encode())] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Set',
+            'menu-items',
+            ' '.join(menu_items).encode())] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm',
+            'admin.vm.feature.Set',
+            'netvm-menu-items',
+            ' '.join(netvm_menu_items).encode())] = b'0\0'
 
         self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
             b'0\0test-vm class=TemplateVM state=Halted\n'
@@ -226,7 +283,7 @@ class TC_00_qvm_template_postprocess(qubesadmin.tests.QubesTestCase):
         vm = self.app.domains['test-vm']
         with mock.patch('subprocess.check_call') as mock_proc:
             qubesadmin.tools.qvm_template_postprocess.import_appmenus(
-                vm, self.source_dir.name)
+                vm, self.source_dir.name, skip_generate=False)
         self.assertEqual(mock_proc.mock_calls, [
             mock.call(['runuser', '-u', 'user', '--', 'env', 'DISPLAY=:0',
                 'qvm-appmenus',
@@ -260,7 +317,7 @@ class TC_00_qvm_template_postprocess(qubesadmin.tests.QubesTestCase):
         vm = self.app.domains['test-vm']
         with mock.patch('subprocess.check_call') as mock_proc:
             qubesadmin.tools.qvm_template_postprocess.import_appmenus(
-                vm, self.source_dir.name)
+                vm, self.source_dir.name, skip_generate=False)
         self.assertEqual(mock_proc.mock_calls, [])
         self.assertAllCalled()
 
@@ -313,11 +370,11 @@ class TC_00_qvm_template_postprocess(qubesadmin.tests.QubesTestCase):
             app=self.app)
         self.assertEqual(ret, 0)
         self.app.add_new_vm.assert_called_once_with('TemplateVM',
-            name='test-vm', label='black')
+            name='test-vm', label='black', pool=None)
         mock_import_root_img.assert_called_once_with(self.app.domains[
             'test-vm'], self.source_dir.name)
         mock_import_appmenus.assert_called_once_with(self.app.domains[
-            'test-vm'], self.source_dir.name)
+            'test-vm'], self.source_dir.name, skip_generate=True)
         if qubesadmin.tools.qvm_template_postprocess.have_events:
             mock_domain_shutdown.assert_called_once_with([self.app.domains[
                 'test-vm']])
@@ -372,7 +429,7 @@ class TC_00_qvm_template_postprocess(qubesadmin.tests.QubesTestCase):
         mock_reset_private_img.assert_called_once_with(self.app.domains[
             'test-vm'])
         mock_import_appmenus.assert_called_once_with(self.app.domains[
-            'test-vm'], self.source_dir.name)
+            'test-vm'], self.source_dir.name, skip_generate=True)
         if qubesadmin.tools.qvm_template_postprocess.have_events:
             mock_domain_shutdown.assert_called_once_with([self.app.domains[
                 'test-vm']])
@@ -413,7 +470,7 @@ class TC_00_qvm_template_postprocess(qubesadmin.tests.QubesTestCase):
         mock_reset_private_img.assert_called_once_with(self.app.domains[
             'test-vm'])
         mock_import_appmenus.assert_called_once_with(self.app.domains[
-            'test-vm'], self.source_dir.name)
+            'test-vm'], self.source_dir.name, skip_generate=False)
         if qubesadmin.tools.qvm_template_postprocess.have_events:
             self.assertFalse(mock_domain_shutdown.called)
         self.assertEqual(self.app.service_calls, [])
@@ -466,3 +523,119 @@ class TC_00_qvm_template_postprocess(qubesadmin.tests.QubesTestCase):
                 'post-install', 'test-vm', self.source_dir.name],
                 app=self.app)
         self.assertAllCalled()
+
+    def test_050_template_config(self):
+        template_config = """gui=1
+qrexec=1
+linux-stubdom=1
+net.fake-ip=192.168.1.100
+net.fake-netmask=255.255.255.0
+net.fake-gateway=192.168.1.1
+virt-mode=hvm
+kernel=
+"""
+        template_conf = os.path.join(self.source_dir.name, 'template.conf')
+        with open(template_conf, 'w') as f:
+            f.write(template_config)
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\0test-vm class=TemplateVM state=Halted\n'
+        self.app.expected_calls[(
+            'test-vm', 'admin.vm.feature.Set', 'gui', b'1')] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm', 'admin.vm.feature.Set', 'qrexec', b'1')] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm', 'admin.vm.feature.Set', 'linux-stubdom', b'1')] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm', 'admin.vm.feature.Set', 'net.fake-ip', b'192.168.1.100')] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm', 'admin.vm.feature.Set', 'net.fake-netmask', b'255.255.255.0')] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm', 'admin.vm.feature.Set', 'net.fake-gateway', b'192.168.1.1')] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm', 'admin.vm.property.Set', 'virt_mode', b'hvm')] = b'0\0'
+        self.app.expected_calls[(
+            'test-vm', 'admin.vm.property.Set', 'kernel', b'')] = b'0\0'
+
+        vm = self.app.domains['test-vm']
+        args = argparse.Namespace(
+            allow_pv=False,
+        )
+        qubesadmin.tools.qvm_template_postprocess.import_template_config(
+            args, template_conf, vm)
+        self.assertAllCalled()
+
+    def test_051_template_config_invalid(self):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\0test-vm class=TemplateVM state=Halted\n'
+        vm = self.app.domains['test-vm']
+        args = argparse.Namespace(
+            allow_pv=False,
+        )
+        with self.subTest('invalid feature value'):
+            template_config = "gui=false\n"
+            template_conf = os.path.join(self.source_dir.name, 'template.conf')
+            with open(template_conf, 'w') as f:
+                f.write(template_config)
+            qubesadmin.tools.qvm_template_postprocess.import_template_config(
+                args, template_conf, vm)
+
+        with self.subTest('invalid feature name'):
+            template_config = "invalid=1\n"
+            template_conf = os.path.join(self.source_dir.name, 'template.conf')
+            with open(template_conf, 'w') as f:
+                f.write(template_config)
+            qubesadmin.tools.qvm_template_postprocess.import_template_config(
+                args, template_conf, vm)
+
+        with self.subTest('invalid ip'):
+            template_config = "net.fake-ip=1.2.3.4.5\n"
+            template_conf = os.path.join(self.source_dir.name, 'template.conf')
+            with open(template_conf, 'w') as f:
+                f.write(template_config)
+            qubesadmin.tools.qvm_template_postprocess.import_template_config(
+                args, template_conf, vm)
+
+        with self.subTest('invalid virt-mode'):
+            template_config = "virt-mode=invalid\n"
+            template_conf = os.path.join(self.source_dir.name, 'template.conf')
+            with open(template_conf, 'w') as f:
+                f.write(template_config)
+            qubesadmin.tools.qvm_template_postprocess.import_template_config(
+                args, template_conf, vm)
+
+        with self.subTest('invalid kernel'):
+            template_config = "kernel=1.2.3.4.5\n"
+            template_conf = os.path.join(self.source_dir.name, 'template.conf')
+            with open(template_conf, 'w') as f:
+                f.write(template_config)
+            qubesadmin.tools.qvm_template_postprocess.import_template_config(
+                args, template_conf, vm)
+        self.assertAllCalled()
+
+    def test_052_template_config_virt_mode_pv(self):
+        self.app.expected_calls[('dom0', 'admin.vm.List', None, None)] = \
+            b'0\0test-vm class=TemplateVM state=Halted\n'
+        vm = self.app.domains['test-vm']
+        args = argparse.Namespace(
+            allow_pv=False,
+        )
+        with self.subTest('not allowed'):
+            template_config = "virt-mode=pv\n"
+            template_conf = os.path.join(self.source_dir.name, 'template.conf')
+            with open(template_conf, 'w') as f:
+                f.write(template_config)
+            qubesadmin.tools.qvm_template_postprocess.import_template_config(
+                args, template_conf, vm)
+        with self.subTest('allowed'):
+            args = argparse.Namespace(
+                allow_pv=True,
+            )
+            self.app.expected_calls[(
+                'test-vm', 'admin.vm.property.Set', 'virt_mode', b'pv')] = b'0\0'
+            template_config = "virt-mode=pv\n"
+            template_conf = os.path.join(self.source_dir.name, 'template.conf')
+            with open(template_conf, 'w') as f:
+                f.write(template_config)
+            qubesadmin.tools.qvm_template_postprocess.import_template_config(
+                args, template_conf, vm)
+        self.assertAllCalled()

+ 1549 - 0
qubesadmin/tools/qvm_template.py

@@ -0,0 +1,1549 @@
+#
+# The Qubes OS Project, https://www.qubes-os.org/
+#
+# Copyright (C) 2019  WillyPillow <wp@nerde.pw>
+#
+# 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, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+'''Tool for managing VM templates.'''
+
+import argparse
+import collections
+import configparser
+import datetime
+import enum
+import fcntl
+import fnmatch
+import functools
+import itertools
+import json
+import operator
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import typing
+
+import tqdm
+import xdg.BaseDirectory
+import rpm
+
+import qubesadmin
+import qubesadmin.tools
+import qubesadmin.tools.qvm_kill
+import qubesadmin.tools.qvm_remove
+
+PATH_PREFIX = '/var/lib/qubes/vm-templates'
+TEMP_DIR = '/var/tmp'
+PACKAGE_NAME_PREFIX = 'qubes-template-'
+CACHE_DIR = os.path.join(xdg.BaseDirectory.xdg_cache_home, 'qvm-template')
+UNVERIFIED_SUFFIX = '.unverified'
+LOCK_FILE = '/var/tmp/qvm-template.lck'
+DATE_FMT = '%Y-%m-%d %H:%M:%S'
+
+UPDATEVM = str('global UpdateVM')
+
+class AlreadyRunning(Exception):
+    """Another qvm-template is already running"""
+
+class SignatureVerificationError(Exception):
+    """Package signature is invalid or missing"""
+
+def qubes_release() -> str:
+    """Return the Qubes release."""
+    if os.path.exists('/usr/share/qubes/marker-vm'):
+        with open('/usr/share/qubes/marker-vm', 'r') as fd:
+            # Get last line (in the format `x.x`)
+            return fd.readlines()[-1].strip()
+    with open('/etc/os-release', 'r') as fd:
+        for line in fd:
+            line = line.strip()
+            if not line or line[0] == '#':
+                continue
+            key, val = line.split('=', 1)
+            if key != 'VERSION_ID':
+                continue
+            val = val.strip('\'"') # strip possible quotes
+            return val
+    # Return default value instead of throwing so that it works on CI
+    return '4.1'
+
+def get_parser() -> argparse.ArgumentParser:
+    """Generate argument parser for the application."""
+    formatter = argparse.ArgumentDefaultsHelpFormatter
+    parser_main = qubesadmin.tools.QubesArgumentParser(description=__doc__,
+        formatter_class=formatter)
+    parser_main.register('action', 'parsers',
+        qubesadmin.tools.AliasedSubParsersAction)
+    subparsers = parser_main.add_subparsers(dest='command',
+        description='Command to run.')
+
+    def parser_add_command(cmd, help_str):
+        return subparsers.add_parser(
+            cmd,
+            formatter_class=formatter,
+            help=help_str,
+            description=help_str)
+
+    parser_main.add_argument('--repo-files', action='append',
+        default=['/etc/qubes/repo-templates/qubes-templates.repo'],
+        help=('Specify files containing DNF repository configuration.'
+            ' Can be used more than once.'))
+    parser_main.add_argument('--keyring',
+        default='/etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-4.1-primary',
+        help='Specify a file containing default RPM public key. '
+             'Individual repositories may point at repo-specific key '
+             'using \'gpgkey\' option')
+    parser_main.add_argument('--updatevm', default=UPDATEVM,
+        help=('Specify VM to download updates from.'
+            ' (Set to empty string to specify the current VM.)'))
+    parser_main.add_argument('--enablerepo', action='append', default=[],
+        metavar='REPOID',
+        help=('Enable additional repositories by an id or a glob.'
+            ' Can be used more than once.'))
+    parser_main.add_argument('--disablerepo', action='append', default=[],
+        metavar='REPOID',
+        help=('Disable certain repositories by an id or a glob.'
+            ' Can be used more than once.'))
+    parser_main.add_argument('--repoid', action='append', default=[],
+        help=('Enable just specific repositories by an id or a glob.'
+            ' Can be used more than once.'))
+    parser_main.add_argument('--releasever', default=qubes_release(),
+        help='Override Qubes release version.')
+    parser_main.add_argument('--refresh', action='store_true',
+        help='Set repository metadata as expired before running the command.')
+    parser_main.add_argument('--cachedir', default=CACHE_DIR,
+        help='Specify cache directory.')
+    parser_main.add_argument('--keep-cache', action='store_true', default=False,
+        help='Keep downloaded packages in cache dir')
+    parser_main.add_argument('--yes', action='store_true',
+        help='Assume "yes" to questions.')
+    # qvm-template {install,reinstall,downgrade,upgrade}
+    parser_install = parser_add_command('install',
+        help_str='Install template packages.')
+    parser_install.add_argument('--pool',
+        help='Specify storage pool to store created templates in.')
+    parser_reinstall = parser_add_command('reinstall',
+        help_str='Reinstall template packages.')
+    parser_downgrade = parser_add_command('downgrade',
+        help_str='Downgrade template packages.')
+    parser_upgrade = parser_add_command('upgrade',
+        help_str='Upgrade template packages.')
+    for parser_x in [parser_install, parser_reinstall,
+            parser_downgrade, parser_upgrade]:
+        parser_x.add_argument('--allow-pv', action='store_true',
+            help='Allow templates that set virt_mode to pv.')
+        parser_x.add_argument('templates', nargs='*', metavar='TEMPLATESPEC')
+    # qvm-template download
+    parser_download = parser_add_command('download',
+        help_str='Download template packages.')
+    for parser_x in [parser_install, parser_reinstall,
+            parser_downgrade, parser_upgrade, parser_download]:
+        parser_x.add_argument('--downloaddir', default='.',
+            help='Specify download directory.')
+        parser_x.add_argument('--retries', default=5, type=int,
+            help='Specify maximum number of retries for downloads.')
+        parser_x.add_argument('--nogpgcheck', action='store_true',
+            help='Disable signature checks.')
+    parser_download.add_argument('templates', nargs='*',
+        metavar='TEMPLATESPEC')
+    # qvm-template {list,info}
+    parser_list = parser_add_command('list',
+        help_str='List templates.')
+    parser_info = parser_add_command('info',
+        help_str='Display details about templates.')
+    for parser_x in [parser_list, parser_info]:
+        parser_x.add_argument('--all', action='store_true',
+            help='Show all templates (default).')
+        parser_x.add_argument('--installed', action='store_true',
+            help='Show installed templates.')
+        parser_x.add_argument('--available', action='store_true',
+            help='Show available templates.')
+        parser_x.add_argument('--extras', action='store_true',
+            help=('Show extras (e.g., ones that exist'
+                ' locally but not in repos) templates.'))
+        parser_x.add_argument('--upgrades', action='store_true',
+            help='Show available upgrades.')
+        parser_x.add_argument('--all-versions', action='store_true',
+            help='Show all available versions, not only the latest.')
+        readable = parser_x.add_mutually_exclusive_group()
+        readable.add_argument('--machine-readable', action='store_true',
+            help='Enable machine-readable output.')
+        readable.add_argument('--machine-readable-json', action='store_true',
+            help='Enable machine-readable output (JSON).')
+        parser_x.add_argument('templates', nargs='*', metavar='TEMPLATESPEC')
+    # qvm-template search
+    parser_search = parser_add_command('search',
+        help_str='Search template details for the given string.')
+    parser_search.add_argument('--all', action='store_true',
+        help=('Search also in the template description and URL. In addition,'
+            ' the criterion are evaluated with OR instead of AND.'))
+    parser_search.add_argument('templates', nargs='*', metavar='PATTERN')
+    # qvm-template remove
+    parser_remove = parser_add_command('remove',
+        help_str='Remove installed templates.')
+    parser_remove.add_argument('--disassoc', action='store_true',
+            help=('Also disassociate VMs from the templates to be removed.'
+                ' This creates a dummy template for the VMs to link with.'))
+    parser_remove.add_argument('templates', nargs='*', metavar='TEMPLATE')
+    # qvm-template purge
+    parser_purge = parser_add_command('purge',
+        help_str='Remove installed templates and associated VMs.')
+    parser_purge.add_argument('templates', nargs='*', metavar='TEMPLATE')
+    # qvm-template clean
+    parser_clean = parser_add_command('clean',
+        help_str='Remove locally cached packages.')
+    _ = parser_clean # unused
+    # qvm-template repolist
+    parser_repolist = parser_add_command('repolist',
+        help_str='Show configured repositories.')
+    repolim = parser_repolist.add_mutually_exclusive_group()
+    repolim.add_argument('--all', action='store_true',
+        help='Show all repos.')
+    repolim.add_argument('--enabled', action='store_true',
+        help='Show only enabled repos (default).')
+    repolim.add_argument('--disabled', action='store_true',
+        help='Show only disabled repos.')
+    parser_repolist.add_argument('repos', nargs='*', metavar='REPOS')
+
+    return parser_main
+
+parser = get_parser()
+
+class TemplateState(enum.Enum):
+    """Enum representing the state of a template."""
+    INSTALLED = 'installed'
+    AVAILABLE = 'available'
+    EXTRA = 'extra'
+    UPGRADABLE = 'upgradable'
+
+    def title(self) -> str:
+        """Return a long description of the state. Can be used as headings."""
+        #pylint: disable=invalid-name
+        TEMPLATE_TITLES = {
+            TemplateState.INSTALLED: 'Installed Templates',
+            TemplateState.AVAILABLE: 'Available Templates',
+            TemplateState.EXTRA: 'Extra Templates',
+            TemplateState.UPGRADABLE: 'Available Upgrades'
+        }
+        return TEMPLATE_TITLES[self]
+
+class VersionSelector(enum.Enum):
+    """Enum representing how the candidate template version is chosen."""
+    LATEST = enum.auto()
+    """Install latest version."""
+    REINSTALL = enum.auto()
+    """Reinstall current version."""
+    LATEST_LOWER = enum.auto()
+    """Downgrade to the highest version that is lower than the current one."""
+    LATEST_HIGHER = enum.auto()
+    """Upgrade to the highest version that is higher than the current one."""
+
+
+# pylint: disable=too-few-public-methods,inherit-non-class
+class Template(typing.NamedTuple):
+    """Details of a template."""
+    name: str
+    epoch: str
+    version: str
+    release: str
+    reponame: str
+    dlsize: int
+    buildtime: datetime.datetime
+    licence: str
+    url: str
+    summary: str
+    description: str
+
+    @property
+    def evr(self):
+        """Return a tuple of (EPOCH, VERSION, RELEASE)"""
+        return self.epoch, self.version, self.release
+
+
+class DlEntry(typing.NamedTuple):
+    """Information about a template to be downloaded."""
+    evr: typing.Tuple[str, str, str]
+    reponame: str
+    dlsize: int
+# pylint: enable=too-few-public-methods,inherit-non-class
+
+
+def build_version_str(evr: typing.Tuple[str, str, str]) -> str:
+    """Return version string described by ``evr``, which is in (epoch, version,
+    release) format."""
+    return '%s:%s-%s' % evr
+
+def is_match_spec(name: str, epoch: str, version: str, release: str, spec: str
+        ) -> typing.Tuple[bool, float]:
+    """Check whether (name, epoch, version, release) matches the spec string.
+
+    For the algorithm, refer to section "NEVRA Matching" in the DNF
+    documentation.
+
+    Note that currently ``arch`` is ignored as the templates should be of
+    ``noarch``.
+
+    :return: A tuple. The first element indicates whether there is a match; the
+        second element represents the priority of the match (lower is better)
+    """
+    if epoch != '0':
+        targets = [
+            f'{name}-{epoch}:{version}-{release}',
+            f'{name}',
+            f'{name}-{epoch}:{version}'
+        ]
+    else:
+        targets = [
+            f'{name}-{epoch}:{version}-{release}',
+            f'{name}-{version}-{release}',
+            f'{name}',
+            f'{name}-{epoch}:{version}',
+            f'{name}-{version}'
+        ]
+    for prio, target in enumerate(targets):
+        if fnmatch.fnmatch(target, spec):
+            return True, prio
+    return False, float('inf')
+
+def query_local(vm: qubesadmin.vm.QubesVM) -> Template:
+    """Return Template object associated with ``vm``.
+
+    Requires the VM to be managed by qvm-template.
+    """
+    return Template(
+        vm.features['template-name'],
+        vm.features['template-epoch'],
+        vm.features['template-version'],
+        vm.features['template-release'],
+        vm.features['template-reponame'],
+        vm.get_disk_utilization(),
+        datetime.datetime.strptime(vm.features['template-buildtime'], DATE_FMT),
+        vm.features['template-license'],
+        vm.features['template-url'],
+        vm.features['template-summary'],
+        vm.features['template-description'].replace('|', '\n'))
+
+def query_local_evr(vm: qubesadmin.vm.QubesVM) -> typing.Tuple[str, str, str]:
+    """Return the (epoch, version, release) of ``vm``.
+
+    Requires the VM to be managed by qvm-template.
+    """
+    return (
+        vm.features['template-epoch'],
+        vm.features['template-version'],
+        vm.features['template-release'])
+
+def is_managed_template(vm: qubesadmin.vm.QubesVM) -> bool:
+    """Return whether the VM is managed by qvm-template."""
+    return vm.features.get('template-name', None) == vm.name
+
+def get_managed_template_vm(app: qubesadmin.app.QubesBase, name: str
+        ) -> qubesadmin.vm.QubesVM:
+    """Return the QubesVM object associated with the given name if it exists
+    and is managed by qvm-template, otherwise raise a parser error."""
+    if name not in app.domains:
+        parser.error("Template '%s' not already installed." % name)
+    vm = app.domains[name]
+    if not is_managed_template(vm):
+        parser.error("Template '%s' is not managed by qvm-template." % name)
+    return vm
+
+def confirm_action(msg: str, affected: typing.List[str]) -> None:
+    """Confirm user action."""
+    print(msg)
+    for name in affected:
+        print('  ' + name)
+
+    confirm = ''
+    while confirm != 'y':
+        confirm = input('Are you sure? [y/N] ').lower()
+        if confirm != 'y':
+            print('command cancelled.')
+            sys.exit(1)
+
+def qrexec_popen(
+        args: argparse.Namespace,
+        app: qubesadmin.app.QubesBase,
+        service: str,
+        stdout: typing.Union[int, typing.IO] = subprocess.PIPE,
+        filter_esc: bool = True) -> subprocess.Popen:
+    """Return ``Popen`` object that communicates with the given qrexec call in
+    ``args.updatevm``.
+
+    Note that this falls back to invoking ``/etc/qubes-rpc/*`` directly if
+    ``args.updatevm`` is empty string.
+
+    :param args: Arguments received by the application. ``args.updatevm`` is
+        used
+    :param app: Qubes application object
+    :param service: The qrexec call to invoke
+    :param stdout: Where the process stdout points to. This is passed directly
+        to ``subprocess.Popen``. Defaults to ``subprocess.PIPE``
+
+        Note that stderr is always set to ``subprocess.PIPE``
+    :param filter_esc: Whether to filter out escape sequences from
+        stdout/stderr. Defaults to True
+
+    :returns: ``Popen`` object that communicates with the given qrexec call
+    """
+    if args.updatevm:
+        return app.domains[args.updatevm].run_service(
+            service,
+            filter_esc=filter_esc,
+            stdout=stdout)
+    return subprocess.Popen([
+            '/etc/qubes-rpc/%s' % service,
+        ],
+        stdin=subprocess.PIPE,
+        stdout=stdout,
+        stderr=subprocess.PIPE)
+
+def qrexec_payload(args: argparse.Namespace, app: qubesadmin.app.QubesBase,
+        spec: str, refresh: bool) -> str:
+    """Return payload string for the ``qubes.Template*`` qrexec calls.
+
+    :param args: Arguments received by the application. Specifically,
+        ``args.{enablerepo,disablerepo,repoid,releasever,repo_files}`` are used
+    :param app: Qubes application object
+    :param spec: Package spec to query (refer to ``<package-name-spec>`` in the
+        DNF documentation)
+    :param refresh: Whether to force refresh repo metadata
+
+    :return: Payload string
+
+    :raises: Parser error if spec equals ``---`` or input contains ``\\n``
+    """
+    _ = app # unused
+
+    if spec == '---':
+        parser.error("Malformed template name: argument should not be '---'.")
+
+    def check_newline(string, name):
+        if '\n' in string:
+            parser.error(f"Malformed {name}:" +
+                " argument should not contain '\\n'.")
+
+    payload = ''
+    for repo in args.enablerepo:
+        check_newline(repo, '--enablerepo')
+        payload += '--enablerepo=%s\n' % repo
+    for repo in args.disablerepo:
+        check_newline(repo, '--disablerepo')
+        payload += '--disablerepo=%s\n' % repo
+    for repo in args.repoid:
+        check_newline(repo, '--repoid')
+        payload += '--repoid=%s\n' % repo
+    if refresh:
+        payload += '--refresh\n'
+    check_newline(args.releasever, '--releasever')
+    payload += '--releasever=%s\n' % args.releasever
+    check_newline(spec, 'template name')
+    payload += spec + '\n'
+    payload += '---\n'
+    for path in args.repo_files:
+        with open(path, 'r') as fd:
+            payload += fd.read() + '\n'
+    return payload
+
+def qrexec_repoquery(
+        args: argparse.Namespace,
+        app: qubesadmin.app.QubesBase,
+        spec: str = '*',
+        refresh: bool = False) -> typing.List[Template]:
+    """Query template information from repositories.
+
+    :param args: Arguments received by the application. Specifically,
+        ``args.{enablerepo,disablerepo,repoid,releasever,repo_files,updatevm}``
+        are used
+    :param app: Qubes application object
+    :param spec: Package spec to query (refer to ``<package-name-spec>`` in the
+        DNF documentation). Defaults to ``*``
+    :param refresh: Whether to force refresh repo metadata. Defaults to False
+
+    :raises ConnectionError: if the qrexec call fails
+
+    :return: List of ``Template`` objects representing the result of the query
+    """
+    payload = qrexec_payload(args, app, spec, refresh)
+    proc = qrexec_popen(args, app, 'qubes.TemplateSearch')
+    stdout, stderr = proc.communicate(payload.encode('UTF-8'))
+    stdout = stdout.decode('ASCII')
+    if proc.wait() != 0:
+        for line in stderr.decode('ASCII').rstrip().split('\n'):
+            print('[Qrexec] %s' % line, file=sys.stderr)
+        raise ConnectionError("qrexec call 'qubes.TemplateSearch' failed.")
+    name_re = re.compile(r'^[A-Za-z0-9._+-]*$')
+    evr_re = re.compile(r'^[A-Za-z0-9._+~]*$')
+    date_re = re.compile(r'^\d+-\d+-\d+ \d+:\d+$')
+    licence_re = re.compile(r'^[A-Za-z0-9._+()-]*$')
+    result = []
+    # FIXME: This breaks when \n is the first character of the description
+    for line in stdout.split('|\n'):
+        # Note that there's an empty entry at the end as .strip() is not used.
+        # This is because if .strip() is used, the .split() will not work.
+        if line == '':
+            continue
+        entry = line.split('|')
+        try:
+            # If there is an incorrect number of entries, raise an error
+            # Unpack manually instead of stuffing into `Template` right away
+            # so that it's easier to mutate stuff.
+            name, epoch, version, release, reponame, dlsize, \
+                buildtime, licence, url, summary, description = entry
+
+            # Ignore packages that are not templates
+            if not name.startswith(PACKAGE_NAME_PREFIX):
+                continue
+            name = name[len(PACKAGE_NAME_PREFIX):]
+
+            # Check that the values make sense
+            if not re.fullmatch(name_re, name):
+                raise ValueError
+            for val in [epoch, version, release]:
+                if not re.fullmatch(evr_re, val):
+                    raise ValueError
+            if not re.fullmatch(name_re, reponame):
+                raise ValueError
+            dlsize = int(dlsize)
+            # First verify that the date does not look weird, then parse it
+            if not re.fullmatch(date_re, buildtime):
+                raise ValueError
+            buildtime = datetime.datetime.strptime(buildtime, '%Y-%m-%d %H:%M')
+            # XXX: Perhaps whitelist licenses directly?
+            if not re.fullmatch(licence_re, licence):
+                raise ValueError
+            # Check name actually matches spec
+            if not is_match_spec(PACKAGE_NAME_PREFIX + name,
+                    epoch, version, release, spec)[0]:
+                continue
+
+            result.append(Template(name, epoch, version, release, reponame,
+                dlsize, buildtime, licence, url, summary, description))
+        except (TypeError, ValueError):
+            raise ConnectionError(("qrexec call 'qubes.TemplateSearch' failed:"
+                " unexpected data format."))
+    return result
+
+def qrexec_download(
+        args: argparse.Namespace,
+        app: qubesadmin.app.QubesBase,
+        spec: str,
+        path: str,
+        dlsize: typing.Optional[int] = None,
+        refresh: bool = False) -> None:
+    """Download a template from repositories.
+
+    :param args: Arguments received by the application. Specifically,
+        ``args.{enablerepo,disablerepo,repoid,releasever,repo_files,updatevm,
+        quiet}`` are used
+    :param app: Qubes application object
+    :param spec: Package spec to query (refer to ``<package-name-spec>`` in the
+        DNF documentation)
+    :param path: Path to place the downloaded template
+    :param dlsize: Size of template to be downloaded. Used for the progress
+        bar. Optional
+    :param refresh: Whether to force refresh repo metadata. Defaults to False
+
+    :raises ConnectionError: if the qrexec call fails
+    """
+    with open(path, 'wb') as fd:
+        payload = qrexec_payload(args, app, spec, refresh)
+        # Don't filter ESCs for binary files
+        proc = qrexec_popen(args, app, 'qubes.TemplateDownload',
+            stdout=fd, filter_esc=False)
+        proc.stdin.write(payload.encode('UTF-8'))
+        proc.stdin.close()
+        with tqdm.tqdm(desc=spec, total=dlsize, unit_scale=True,
+                unit_divisor=1000, unit='B', disable=args.quiet) as pbar:
+            last = 0
+            while proc.poll() is None:
+                cur = fd.tell()
+                pbar.update(cur - last)
+                last = cur
+                time.sleep(0.1)
+        if proc.wait() != 0:
+            raise ConnectionError(
+                "qrexec call 'qubes.TemplateDownload' failed.")
+
+
+def get_keys_for_repos(repo_files: typing.List[str],
+                       releasever: str) -> typing.Dict[str, str]:
+    """List gpg keys
+
+    Returns a dict reponame -> key path
+    """
+    keys = {}
+    for repo_file in repo_files:
+        repo_config = configparser.ConfigParser()
+        repo_config.read(repo_file)
+        for repo in repo_config.sections():
+            try:
+                gpgkey_url = repo_config.get(repo, 'gpgkey')
+            except configparser.NoOptionError:
+                continue
+            gpgkey_url = gpgkey_url.replace('$releasever', releasever)
+            # support only file:// urls
+            if gpgkey_url.startswith('file://'):
+                keys[repo] = gpgkey_url[len('file://'):]
+    return keys
+
+
+def verify_rpm(
+        path: str,
+        key: str,
+        nogpgcheck: bool = False,
+        template_name: typing.Optional[str] = None
+        ) -> rpm.hdr:
+    """Verify the digest and signature of a RPM package and return the package
+    header.
+
+    Note that verifying RPMs this way is prone to TOCTOU. This is okay for
+    local files, but may create problems if multiple instances of
+    **qvm-template** are downloading the same file, so a lock is needed in that
+    case.
+
+    :param path: Location of the RPM package
+    :param nogpgcheck: Whether to allow invalid GPG signatures
+    :param template_name: expected template name - if specified, verifies if
+           the package name matches expected template name
+
+    :return: RPM package header. If verification fails, raises an exception.
+    """
+    if not nogpgcheck:
+        with tempfile.TemporaryDirectory() as rpmdb_dir:
+            subprocess.check_call(
+                ['rpmkeys', '--dbpath=' + rpmdb_dir, '--import', key])
+            try:
+                output = subprocess.check_output(
+                    ['rpmkeys', '--dbpath=' + rpmdb_dir, '--checksig', path])
+            except subprocess.CalledProcessError as e:
+                raise SignatureVerificationError(
+                    'Signature verification failed: {}'.format(
+                        e.output.decode()))
+            if not output.endswith(b': digests signatures OK\n'):
+                raise SignatureVerificationError(
+                    'Signature verification failed: {}'.format(output.decode()))
+    with open(path, 'rb') as fd:
+        tset = rpm.TransactionSet()
+        tset.setVSFlags(rpm.RPMVSF_MASK_NOSIGNATURES)
+        hdr = tset.hdrFromFdno(fd)
+    if template_name is not None:
+        if hdr[rpm.RPMTAG_NAME] != PACKAGE_NAME_PREFIX + template_name:
+            raise SignatureVerificationError(
+                'Downloaded package does not match expected template name')
+    return hdr
+
+def extract_rpm(name: str, path: str, target: str) -> bool:
+    """Extract a template RPM package.
+
+    :param name: Name of the template
+    :param path: Location of the RPM package
+    :param target: Target path to extract to
+
+    :return: Whether the extraction succeeded
+    """
+    rpm2cpio = subprocess.Popen(['rpm2cpio', path], stdout=subprocess.PIPE)
+    # `-D` is GNUism
+    cpio = subprocess.Popen([
+            'cpio',
+            '-idm',
+            '-D',
+            target,
+            '.%s/%s/*' % (PATH_PREFIX, name)
+        ], stdin=rpm2cpio.stdout, stdout=subprocess.DEVNULL)
+    return rpm2cpio.wait() == 0 and cpio.wait() == 0
+
+
+def filter_version(
+        query_res,
+        app: qubesadmin.app.QubesBase,
+        version_selector: VersionSelector = VersionSelector.LATEST):
+    """Select only one version for given template name"""
+    # We only select one package for each distinct package name
+    results: typing.Dict[str, Template] = {}
+
+    for entry in query_res:
+        evr = (entry.epoch, entry.version, entry.release)
+        insert = False
+        if version_selector == VersionSelector.LATEST:
+            if entry.name not in results:
+                insert = True
+            if entry.name in results \
+                    and rpm.labelCompare(results[entry.name].evr, evr) < 0:
+                insert = True
+            if entry.name in results \
+                    and rpm.labelCompare(results[entry.name].evr, evr) == 0 \
+                    and 'testing' not in entry.reponame:
+                # for the same-version matches, prefer non-testing one
+                insert = True
+        elif version_selector == VersionSelector.REINSTALL:
+            vm = get_managed_template_vm(app, entry.name)
+            cur_ver = query_local_evr(vm)
+            if rpm.labelCompare(evr, cur_ver) == 0:
+                insert = True
+        elif version_selector in [VersionSelector.LATEST_LOWER,
+                                  VersionSelector.LATEST_HIGHER]:
+            vm = get_managed_template_vm(app, entry.name)
+            cur_ver = query_local_evr(vm)
+            cmp_res = -1 \
+                if version_selector == VersionSelector.LATEST_LOWER \
+                else 1
+            if rpm.labelCompare(evr, cur_ver) == cmp_res:
+                if entry.name not in results \
+                        or rpm.labelCompare(results[entry.name].evr, evr) < 0:
+                    insert = True
+        if insert:
+            results[entry.name] = entry
+
+    return results.values()
+
+def get_dl_list(
+        args: argparse.Namespace,
+        app: qubesadmin.app.QubesBase,
+        version_selector: VersionSelector = VersionSelector.LATEST
+        ) -> typing.Dict[str, DlEntry]:
+    """Return list of templates that needs to be downloaded.
+
+    :param args: Arguments received by the application.
+    :param app: Qubes application object
+    :param version_selector: Specify algorithm to select the candidate version
+        of a package.  Defaults to ``VersionSelector.LATEST``
+
+    :return: Dictionary that maps to ``DlEntry`` the names of templates that
+        needs to be downloaded
+    """
+    full_candid: typing.Dict[str, DlEntry] = {}
+    for template in args.templates:
+        # Skip local RPMs
+        if template.endswith('.rpm'):
+            continue
+
+        query_res = qrexec_repoquery(args, app, PACKAGE_NAME_PREFIX + template)
+
+        # We only select one package for each distinct package name
+        query_res = filter_version(query_res, app, version_selector)
+        # XXX: As it's possible to include version information in `template`,
+        #      perhaps the messages can be improved
+        if len(query_res) == 0:
+            if version_selector == VersionSelector.LATEST:
+                parser.error('Template \'%s\' not found.' % template)
+            elif version_selector == VersionSelector.REINSTALL:
+                parser.error('Same version of template \'%s\' not found.' \
+                    % template)
+            # Copy behavior of DNF and do nothing if version not found
+            elif version_selector == VersionSelector.LATEST_LOWER:
+                print(("Template '%s' of lowest version"
+                    " already installed, skipping..." % template),
+                    file=sys.stderr)
+            elif version_selector == VersionSelector.LATEST_HIGHER:
+                print(("Template '%s' of highest version"
+                    " already installed, skipping..." % template),
+                    file=sys.stderr)
+
+        # Merge & choose the template with the highest version
+        for entry in query_res:
+            if entry.name not in full_candid \
+                    or rpm.labelCompare(full_candid[entry.name].evr,
+                                        entry.evr) < 0:
+                full_candid[entry.name] = \
+                    DlEntry(entry.evr, entry.reponame, entry.dlsize)
+
+    return full_candid
+
+def download(
+        args: argparse.Namespace,
+        app: qubesadmin.app.QubesBase,
+        path_override: typing.Optional[str] = None,
+        dl_list: typing.Optional[typing.Dict[str, DlEntry]] = None,
+        version_selector: VersionSelector = VersionSelector.LATEST) \
+        -> typing.Dict[str, rpm.hdr]:
+    """Command that downloads template packages.
+
+    :param args: Arguments received by the application.
+    :param app: Qubes application object
+    :param path_override: Override path to store downloads. If not set or set
+        to None, ``args.downloaddir`` is used. Optional
+    :param dl_list: Override list of templates to download. If not set or set
+        to None, ``get_dl_list`` is called, which generates the list from
+        ``args``.  Optional
+    :param version_selector: Specify algorithm to select the candidate version
+        of a package.  Defaults to ``VersionSelector.LATEST``
+    :return package headers of downloaded templates
+    """
+    if dl_list is None:
+        dl_list = get_dl_list(args, app, version_selector=version_selector)
+
+    keys = get_keys_for_repos(args.repo_files, args.releasever)
+
+    package_hdrs = {}
+
+    path = path_override if path_override is not None else args.downloaddir
+    with tempfile.TemporaryDirectory(dir=path) as dl_dir:
+        for name, entry in dl_list.items():
+            version_str = build_version_str(entry.evr)
+            spec = PACKAGE_NAME_PREFIX + name + '-' + version_str
+            target = os.path.join(path, '%s.rpm' % spec)
+            target_temp = os.path.join(dl_dir, '%s.rpm.UNTRUSTED' % spec)
+            repo_key = keys.get(entry.reponame)
+            if repo_key is None:
+                repo_key = args.keyring
+            if os.path.exists(target):
+                print('\'%s\' already exists, skipping...' % target,
+                    file=sys.stderr)
+                # but still verify the package
+                verify_rpm(target, repo_key, template_name=name)
+                continue
+            print('Downloading \'%s\'...' % spec, file=sys.stderr)
+            done = False
+            for attempt in range(args.retries):
+                try:
+                    qrexec_download(args, app, spec, target_temp,
+                        entry.dlsize)
+                    size = os.path.getsize(target_temp)
+                    if size != entry.dlsize:
+                        raise ConnectionError(
+                            'Downloaded file is {} bytes, expected {}'.format(
+                                size, entry.dlsize))
+                    done = True
+                    break
+                except ConnectionError:
+                    os.remove(target_temp)
+                    if attempt + 1 < args.retries:
+                        print('\'%s\' download failed, retrying...' % spec,
+                            file=sys.stderr)
+            if not done:
+                print('Error: \'%s\' download failed.' % spec, file=sys.stderr)
+                sys.exit(1)
+
+            if args.nogpgcheck:
+                print(
+                    'Warning: --nogpgcheck is ignored for downloaded templates',
+                    file=sys.stderr)
+            package_hdr = verify_rpm(target_temp, repo_key, template_name=name)
+            # after package is verified, rename to the target location
+            os.rename(target_temp, target)
+            package_hdrs[name] = package_hdr
+    return package_hdrs
+
+def locked(func):
+    """Execute given function under a lock in *LOCK_FILE*"""
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        with open(LOCK_FILE, 'w') as lock:
+            try:
+                fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+            except OSError:
+                raise AlreadyRunning(
+                    ('cannot get lock on %s. '
+                     'Perhaps another instance of qvm-template is running?')
+                         % LOCK_FILE)
+            try:
+                return func(*args, **kwargs)
+            finally:
+                os.remove(LOCK_FILE)
+    return wrapper
+
+@locked
+def install(
+        args: argparse.Namespace,
+        app: qubesadmin.app.QubesBase,
+        version_selector: VersionSelector = VersionSelector.LATEST,
+        override_existing: bool = False) -> None:
+    """Command that installs template packages.
+
+    This command creates a lock file to ensure that two instances are not
+    running at the same time.
+
+    :param args: Arguments received by the application.
+    :param app: Qubes application object
+    :param version_selector: Specify algorithm to select the candidate version
+        of a package.  Defaults to ``VersionSelector.LATEST``
+    :param override_existing: Whether to override existing packages. Used for
+        reinstall, upgrade, and downgrade operations
+    """
+    keys = get_keys_for_repos(args.repo_files, args.releasever)
+
+    unverified_rpm_list = [] # rpmfile, reponame
+    verified_rpm_list = []
+    def verify(rpmfile, reponame, package_hdr=None):
+        """Verify package signature and version and parse package header.
+
+        If package_hdr is provided, the signature check is skipped and only
+        other checks are performed."""
+        if package_hdr is None:
+            repo_key = keys.get(reponame)
+            if repo_key is None:
+                repo_key = args.keyring
+            package_hdr = verify_rpm(rpmfile, repo_key,
+                                     nogpgcheck=args.nogpgcheck)
+            if not package_hdr:
+                parser.error('Package \'%s\' verification failed.' % rpmfile)
+
+        package_name = package_hdr[rpm.RPMTAG_NAME]
+        if not package_name.startswith(PACKAGE_NAME_PREFIX):
+            parser.error(
+                'Illegal package name for package \'%s\'.' % rpmfile)
+        # Remove prefix to get the real template name
+        name = package_name[len(PACKAGE_NAME_PREFIX):]
+
+        # Check if already installed
+        if not override_existing and name in app.domains:
+            print(('Template \'%s\' already installed, skipping...'
+                ' (You may want to use the'
+                ' {reinstall,upgrade,downgrade}'
+                ' operations.)') % name, file=sys.stderr)
+            return
+
+        # Check if version is really what we want
+        if override_existing:
+            vm = get_managed_template_vm(app, name)
+            pkg_evr = (
+                str(package_hdr[rpm.RPMTAG_EPOCHNUM]),
+                package_hdr[rpm.RPMTAG_VERSION],
+                package_hdr[rpm.RPMTAG_RELEASE])
+            vm_evr = query_local_evr(vm)
+            cmp_res = rpm.labelCompare(pkg_evr, vm_evr)
+            if version_selector == VersionSelector.REINSTALL \
+                    and cmp_res != 0:
+                parser.error(
+                    'Same version of template \'%s\' not found.' \
+                    % name)
+            elif version_selector == VersionSelector.LATEST_LOWER \
+                    and cmp_res != -1:
+                print(("Template '%s' of lower version"
+                    " already installed, skipping..." % name),
+                    file=sys.stderr)
+                return
+            elif version_selector == VersionSelector.LATEST_HIGHER \
+                    and cmp_res != 1:
+                print(("Template '%s' of higher version"
+                    " already installed, skipping..." % name),
+                    file=sys.stderr)
+                return
+
+        verified_rpm_list.append((rpmfile, reponame, name, package_hdr))
+
+    # Process local templates
+    for template in args.templates:
+        if template.endswith('.rpm'):
+            if not os.path.exists(template):
+                parser.error('RPM file \'%s\' not found.' % template)
+            unverified_rpm_list.append((template, '@commandline'))
+
+    # First verify local RPMs and extract header
+    for rpmfile, reponame in unverified_rpm_list:
+        verify(rpmfile, reponame)
+    unverified_rpm_list = {}
+
+    os.makedirs(args.cachedir, exist_ok=True)
+
+    # Get list of templates to download
+    dl_list = get_dl_list(args, app, version_selector=version_selector)
+    dl_list_copy = dl_list.copy()
+    for name, entry in dl_list.items():
+        # Should be ensured by checks in repoquery
+        assert entry.reponame != '@commandline'
+        # Verify that the templates to be downloaded are not yet installed
+        # Note that we *still* have to do this again in verify() for
+        # already-downloaded templates
+        if not override_existing and name in app.domains:
+            print(('Template \'%s\' already installed, skipping...'
+                ' (You may want to use the'
+                ' {reinstall,upgrade,downgrade}'
+                ' operations.)') % name, file=sys.stderr)
+            del dl_list_copy[name]
+        else:
+            # XXX: Perhaps this is better returned by download()
+            version_str = build_version_str(entry.evr)
+            target_file = \
+                '%s%s-%s.rpm' % (PACKAGE_NAME_PREFIX, name, version_str)
+            unverified_rpm_list[name] = (
+                (os.path.join(args.cachedir, target_file), entry.reponame))
+    dl_list = dl_list_copy
+
+    # Ask the user for confirmation before we actually download stuff
+    if override_existing and not args.yes:
+        override_tpls = []
+        # Local templates, already verified
+        for _, _, name, _ in verified_rpm_list:
+            override_tpls.append(name)
+        # Templates not yet downloaded
+        for name in dl_list:
+            override_tpls.append(name)
+
+        # Only confirm if we have something to do
+        # since confiming w/ an empty list is probably silly
+        if override_tpls:
+            confirm_action(
+                'This will override changes made in the following VMs:',
+                override_tpls)
+
+    package_hdrs = download(args, app,
+        dl_list=dl_list,
+        path_override=args.cachedir,
+        version_selector=version_selector)
+
+    # Verify downloaded templates
+    for name, (rpmfile, reponame) in unverified_rpm_list.items():
+        verify(rpmfile, reponame, package_hdrs[name])
+    del unverified_rpm_list
+
+    # Unpack and install
+    for rpmfile, reponame, name, package_hdr in verified_rpm_list:
+        with tempfile.TemporaryDirectory(dir=TEMP_DIR) as target:
+            print('Installing template \'%s\'...' % name, file=sys.stderr)
+            if not extract_rpm(name, rpmfile, target):
+                raise Exception(
+                    'Failed to extract {} template'.format(name))
+            cmdline = [
+                'qvm-template-postprocess',
+                '--really',
+                '--no-installed-by-rpm',
+            ]
+            if args.allow_pv:
+                cmdline.append('--allow-pv')
+            if not override_existing and args.pool:
+                cmdline += ['--pool', args.pool]
+            subprocess.check_call(cmdline + [
+                'post-install',
+                name,
+                target + PATH_PREFIX + '/' + name])
+
+            app.domains.refresh_cache(force=True)
+            tpl = app.domains[name]
+
+            tpl.features['template-name'] = name
+            tpl.features['template-epoch'] = \
+                package_hdr[rpm.RPMTAG_EPOCHNUM]
+            tpl.features['template-version'] = \
+                package_hdr[rpm.RPMTAG_VERSION]
+            tpl.features['template-release'] = \
+                package_hdr[rpm.RPMTAG_RELEASE]
+            tpl.features['template-reponame'] = reponame
+            tpl.features['template-buildtime'] = \
+                datetime.datetime.fromtimestamp(
+                        int(package_hdr[rpm.RPMTAG_BUILDTIME]),
+                        tz=datetime.timezone.utc) \
+                    .strftime(DATE_FMT)
+            tpl.features['template-installtime'] = \
+                datetime.datetime.now(
+                    tz=datetime.timezone.utc).strftime(DATE_FMT)
+            tpl.features['template-license'] = \
+                package_hdr[rpm.RPMTAG_LICENSE]
+            tpl.features['template-url'] = \
+                package_hdr[rpm.RPMTAG_URL]
+            tpl.features['template-summary'] = \
+                package_hdr[rpm.RPMTAG_SUMMARY]
+            tpl.features['template-description'] = \
+                package_hdr[rpm.RPMTAG_DESCRIPTION].replace('\n', '|')
+        if rpmfile.startswith(args.cachedir) and not args.keep_cache:
+            os.remove(rpmfile)
+
+def list_templates(args: argparse.Namespace,
+        app: qubesadmin.app.QubesBase, command: str) -> None:
+    """Command that lists templates.
+
+    :param args: Arguments received by the application.
+    :param app: Qubes application object
+    :param command: If set to ``list``, display a listing similar to ``dnf
+        list``. If set to ``info``, display detailed template information
+        similar to ``dnf info``. Otherwise, an ``AssertionError`` is raised.
+    """
+    tpl_list = []
+
+    def append_list(data, status, install_time=None):
+        _ = install_time # unused
+        version_str = build_version_str(
+            (data.epoch, data.version, data.release))
+        tpl_list.append((status, data.name, version_str, data.reponame))
+
+    def append_info(data, status, install_time=None):
+        tpl_list.append((status, data, install_time))
+
+    def list_to_human_output(tpls):
+        outputs = []
+        for status, grp in itertools.groupby(tpls, lambda x: x[0]):
+            def convert(row):
+                return row[1:]
+            outputs.append((status, list(map(convert, grp))))
+        return outputs
+
+    def list_to_machine_output(tpls):
+        outputs = {}
+        for status, grp in itertools.groupby(tpls, lambda x: x[0]):
+            def convert(row):
+                _, name, evr, reponame = row
+                return {'name': name, 'evr': evr, 'reponame': reponame}
+            outputs[status.value] = list(map(convert, grp))
+        return outputs
+
+    def info_to_human_output(tpls):
+        outputs = []
+        for status, grp in itertools.groupby(tpls, lambda x: x[0]):
+            output = []
+            for _, data, install_time in grp:
+                output.append(('Name', ':', data.name))
+                output.append(('Epoch', ':', data.epoch))
+                output.append(('Version', ':', data.version))
+                output.append(('Release', ':', data.release))
+                output.append(('Size', ':',
+                    qubesadmin.utils.size_to_human(data.dlsize)))
+                output.append(('Repository', ':', data.reponame))
+                output.append(('Buildtime', ':', str(data.buildtime)))
+                if install_time:
+                    output.append(('Install time', ':', str(install_time)))
+                output.append(('URL', ':', data.url))
+                output.append(('License', ':', data.licence))
+                output.append(('Summary', ':', data.summary))
+                # Only show "Description" for the first line
+                title = 'Description'
+                for line in data.description.splitlines():
+                    output.append((title, ':', line))
+                    title = ''
+                output.append((' ', ' ', ' ')) # empty line
+            outputs.append((status, output))
+        return outputs
+
+    def info_to_machine_output(tpls, replace_newline=True):
+        outputs = {}
+        for status, grp in itertools.groupby(tpls, lambda x: x[0]):
+            output = []
+            for _, data, install_time in grp:
+                name, epoch, version, release, reponame, dlsize, \
+                    buildtime, licence, url, summary, description = data
+                dlsize = str(dlsize)
+                buildtime = buildtime.strftime(DATE_FMT)
+                install_time = install_time if install_time else ''
+                if replace_newline:
+                    description = description.replace('\n', '|')
+                output.append({
+                    'name': name,
+                    'epoch': epoch,
+                    'version': version,
+                    'release': release,
+                    'reponame': reponame,
+                    'size': dlsize,
+                    'buildtime': buildtime,
+                    'installtime': install_time,
+                    'license': licence,
+                    'url': url,
+                    'summary': summary,
+                    'description': description})
+            outputs[status.value] = output
+        return outputs
+
+    if command == 'list':
+        append = append_list
+    elif command == 'info':
+        append = append_info
+    else:
+        assert False and 'Unknown command'
+
+    def append_vm(vm, status):
+        append(query_local(vm), status, vm.features['template-installtime'])
+
+    def check_append(name, evr):
+        return not args.templates or \
+            any(is_match_spec(name, *evr, spec)[0]
+                for spec in args.templates)
+
+    if not (args.installed or args.available or args.extras or args.upgrades):
+        args.all = True
+
+    if args.all or args.available or args.extras or args.upgrades:
+        if args.templates:
+            query_res_set: typing.Set[Template] = set()
+            for spec in args.templates:
+                query_res_set |= set(qrexec_repoquery(args, app, spec))
+            query_res = list(query_res_set)
+        else:
+            query_res = qrexec_repoquery(args, app)
+        if not args.all_versions:
+            query_res = filter_version(query_res, app)
+
+    if args.installed or args.all:
+        for vm in app.domains:
+            if is_managed_template(vm) and \
+                    check_append(vm.name, query_local_evr(vm)):
+                append_vm(vm, TemplateState.INSTALLED)
+
+    if args.available or args.all:
+        # Spec should already be checked by repoquery
+        for data in query_res:
+            append(data, TemplateState.AVAILABLE)
+
+    if args.extras:
+        remote = set()
+        for data in query_res:
+            remote.add(data.name)
+        for vm in app.domains:
+            if is_managed_template(vm) and vm.name not in remote and \
+                    check_append(vm.name, query_local_evr(vm)):
+                append_vm(vm, TemplateState.EXTRA)
+
+    if args.upgrades:
+        local = {}
+        for vm in app.domains:
+            if is_managed_template(vm):
+                local[vm.name] = query_local_evr(vm)
+        # Spec should already be checked by repoquery
+        for entry in query_res:
+            evr = (entry.epoch, entry.version, entry.release)
+            if entry.name in local:
+                if rpm.labelCompare(local[entry.name], evr) < 0:
+                    append(entry, TemplateState.UPGRADABLE)
+
+    if len(tpl_list) == 0:
+        parser.error('No matching templates to list')
+
+    if args.machine_readable:
+        if command == 'info':
+            tpl_list_dict = info_to_machine_output(tpl_list)
+        elif command == 'list':
+            tpl_list_dict = list_to_machine_output(tpl_list)
+        for status, grp in tpl_list_dict.items():
+            for line in grp:
+                print('|'.join([status] + list(line.values())))
+    elif args.machine_readable_json:
+        if command == 'info':
+            tpl_list_dict = \
+                info_to_machine_output(tpl_list, replace_newline=False)
+        elif command == 'list':
+            tpl_list_dict = list_to_machine_output(tpl_list)
+        print(json.dumps(tpl_list_dict))
+    else:
+        if command == 'info':
+            tpl_list = info_to_human_output(tpl_list)
+        elif command == 'list':
+            tpl_list = list_to_human_output(tpl_list)
+        for status, grp in tpl_list:
+            print(status.title())
+            qubesadmin.tools.print_table(grp)
+
+def search(args: argparse.Namespace, app: qubesadmin.app.QubesBase) -> None:
+    """Command that searches template details for given patterns.
+
+    :param args: Arguments received by the application.
+    :param app: Qubes application object
+    """
+    # Search in both installed and available templates
+    query_res = qrexec_repoquery(args, app)
+    for vm in app.domains:
+        if is_managed_template(vm):
+            query_res.append(query_local(vm))
+
+    # Get latest version for each template
+    query_res_tmp = []
+    for _, grp in itertools.groupby(sorted(query_res), lambda x: x[0]):
+        def compare(lhs, rhs):
+            return lhs if rpm.labelCompare(lhs[1:4], rhs[1:4]) > 0 else rhs
+        query_res_tmp.append(functools.reduce(compare, grp))
+    query_res = query_res_tmp
+
+    #pylint: disable=invalid-name
+    WEIGHT_NAME_EXACT = 1 << 4
+    WEIGHT_NAME = 1 << 3
+    WEIGHT_SUMMARY = 1 << 2
+    WEIGHT_DESCRIPTION = 1 << 1
+    WEIGHT_URL = 1 << 0
+
+    WEIGHT_TO_FIELD = [
+        (WEIGHT_NAME_EXACT, 'Name'),
+        (WEIGHT_NAME, 'Name'),
+        (WEIGHT_SUMMARY, 'Summary'),
+        (WEIGHT_DESCRIPTION, 'Description'),
+        (WEIGHT_URL, 'URL')]
+
+    search_res_by_idx: \
+            typing.Dict[int, typing.List[typing.Tuple[int, str, bool]]] = \
+        collections.defaultdict(list)
+    for keyword in args.templates:
+        for idx, entry in enumerate(query_res):
+            needle_types = \
+                [(entry.name, WEIGHT_NAME), (entry.summary, WEIGHT_SUMMARY)]
+            if args.all:
+                needle_types += [(entry.description, WEIGHT_DESCRIPTION),
+                            (entry.url, WEIGHT_URL)]
+            for key, weight in needle_types:
+                if fnmatch.fnmatch(key, '*' + keyword + '*'):
+                    exact = keyword == key
+                    if exact and weight == WEIGHT_NAME:
+                        weight = WEIGHT_NAME_EXACT
+                    search_res_by_idx[idx].append((weight, keyword, exact))
+
+    if not args.all:
+        keywords = set(args.templates)
+        idxs = list(search_res_by_idx.keys())
+        for idx in idxs:
+            if keywords != set(x[1] for x in search_res_by_idx[idx]):
+                del search_res_by_idx[idx]
+
+    def key_func(x):
+        # ORDER BY weight DESC, list_of_needles ASC, name ASC
+        idx, needles = x
+        weight = sum(t[0] for t in needles)
+        name = query_res[idx][0]
+        return (-weight, needles, name)
+
+    search_res = sorted(search_res_by_idx.items(), key=key_func)
+
+    def gen_header(needles):
+        fields = []
+        weight_types = set(x[0] for x in needles)
+        for weight, field in WEIGHT_TO_FIELD:
+            if weight in weight_types:
+                fields.append(field)
+        exact = all(x[-1] for x in needles)
+        match = 'Exactly Matched' if exact else 'Matched'
+        keywords = sorted(list(set(x[1] for x in needles)))
+        return ' & '.join(fields) + ' ' + match + ': ' + ', '.join(keywords)
+
+    last_header = ''
+    for idx, needles in search_res:
+        # Print headers
+        cur_header = gen_header(needles)
+        if last_header != cur_header:
+            last_header = cur_header
+            # XXX: The style is different from that of DNF
+            print('===', cur_header, '===')
+        print(query_res[idx].name, ':', query_res[idx].summary)
+
+def remove(
+        args: argparse.Namespace,
+        app: qubesadmin.app.QubesBase,
+        disassoc: bool = False,
+        purge: bool = False,
+        dummy: str = 'dummy'
+        ) -> None:
+    """Command that remove templates.
+
+    :param args: Arguments received by the application.
+    :param app: Qubes application object
+    :param disassoc: Whether to disassociate VMs from the templates
+    :param purge: Whether to remove VMs based on the templates
+    :param dummy: Name of dummy VM if disassoc is used
+    """
+    # NOTE: While QubesArgumentParser provide similar functionality
+    #       it does not seem to work as a parent parser
+    for tpl in args.templates:
+        if tpl not in app.domains:
+            parser.error("no such domain: '%s'" % tpl)
+
+    remove_list = args.templates
+    if purge:
+        # Not disassociating first may result in dependency ordering issues
+        disassoc = True
+        # Remove recursively via BFS
+        remove_set = set(remove_list) # visited
+        idx = 0
+        while idx < len(remove_list):
+            tpl = remove_list[idx]
+            idx += 1
+            vm = app.domains[tpl]
+            for holder, prop in qubesadmin.utils.vm_dependencies(app, vm):
+                if holder is not None and holder.name not in remove_set:
+                    remove_list.append(holder.name)
+                    remove_set.add(holder.name)
+
+    if not args.yes:
+        repeat = 3 if purge else 1
+        # XXX: Mutating the list later seems to break the tests...
+        remove_list_copy = remove_list.copy()
+        for _ in range(repeat):
+            confirm_action(
+                'This will completely remove the selected VM(s)...',
+                remove_list_copy)
+
+    if disassoc:
+        # Remove the dummy afterwards if we're purging
+        # as nothing should depend on it in the end
+        remove_dummy = purge
+        # Create dummy template; handle name collisions
+        orig_dummy = dummy
+        cnt = 1
+        while dummy in app.domains \
+                and app.domains[dummy].features.get(
+                    'template-dummy', '0') != '1':
+            dummy = '%s-%d' % (orig_dummy, cnt)
+            cnt += 1
+        if dummy not in app.domains:
+            dummy_vm = app.add_new_vm('TemplateVM', dummy, 'red')
+            dummy_vm.features['template-dummy'] = 1
+        else:
+            dummy_vm = app.domains[dummy]
+
+        for tpl in remove_list:
+            vm = app.domains[tpl]
+            for holder, prop in qubesadmin.utils.vm_dependencies(app, vm):
+                if holder:
+                    setattr(holder, prop, dummy_vm)
+                    holder.template = dummy_vm
+                    print("Property '%s' of '%s' set to '%s'." % (
+                        prop, holder.name, dummy), file=sys.stderr)
+                else:
+                    print("Global property '%s' set to ''." % prop,
+                        file=sys.stderr)
+                    setattr(app, prop, '')
+        if remove_dummy:
+            remove_list.append(dummy)
+
+    if disassoc or purge:
+        qubesadmin.tools.qvm_kill.main(['--'] + remove_list, app)
+    qubesadmin.tools.qvm_remove.main(['--force', '--'] + remove_list, app)
+
+def clean(args: argparse.Namespace, app: qubesadmin.app.QubesBase) -> None:
+    """Command that cleans the local package cache.
+
+    :param args: Arguments received by the application.
+    :param app: Qubes application object
+    """
+    # TODO: More fine-grained options?
+    _ = app # unused
+
+    shutil.rmtree(args.cachedir)
+
+def repolist(args: argparse.Namespace, app: qubesadmin.app.QubesBase) -> None:
+    """Command that lists configured repositories.
+
+    :param args: Arguments received by the application.
+    :param app: Qubes application object
+    """
+    _ = app # unused
+
+    # python-dnf is not packaged on Debian
+    # As this is not an "essential operation", the module is imported here
+    # instead of top-level so that other operations still work.
+    try:
+        import dnf
+    except ModuleNotFoundError:
+        print("Error: Python module 'dnf' not found.", file=sys.stderr)
+        sys.exit(1)
+
+    if not args.all and not args.disabled:
+        args.enabled = True
+
+    with tempfile.TemporaryDirectory(dir=TEMP_DIR) as reposdir:
+        for idx, path in enumerate(args.repo_files):
+            src = os.path.abspath(path)
+            # Use index as file name in case of collisions
+            dst = os.path.join(reposdir, '%d.repo' % idx)
+            os.symlink(src, dst)
+        conf = dnf.conf.Conf()
+        conf.substitutions['releasever'] = args.releasever
+        conf.reposdir = reposdir
+        base = dnf.Base(conf)
+        base.read_all_repos()
+        if args.repoid:
+            base.repos.get_matching('*').disable()
+            for repo in args.repoid:
+                base.repos.get_matching(repo).enable()
+        else:
+            for repo in args.enablerepo:
+                base.repos.get_matching(repo).enable()
+            for repo in args.disablerepo:
+                base.repos.get_matching(repo).disable()
+
+        repos: typing.List[dnf.repo.Repo]
+        if args.repos:
+            repos = []
+            for repo in args.repos:
+                repos += list(base.repos.get_matching(repo))
+            repos = list(set(repos))
+            repos.sort(key=operator.attrgetter('id'))
+        else:
+            repos = list(base.repos.values())
+            repos.sort(key=operator.attrgetter('id'))
+
+        table = []
+        for repo in repos:
+            if args.all or (args.enabled == repo.enabled):
+                state = 'enabled' if repo.enabled else 'disabled'
+                table.append((repo.id, repo.name, state))
+
+        qubesadmin.tools.print_table(table)
+
+def main(args: typing.Optional[typing.Sequence[str]] = None,
+        app: typing.Optional[qubesadmin.app.QubesBase] = None) -> int:
+    """Main routine of **qvm-template**.
+
+    :param args: Override arguments received by the application. Optional
+    :param app: Override Qubes application object. Optional
+
+    :return: Return code of the application
+    """
+    # do two passes to allow global options after command name too
+    p_args, args = parser.parse_known_args(args)
+    p_args = parser.parse_args(args, p_args)
+
+    if not p_args.command:
+        parser.error('A command needs to be specified.')
+
+    # If the user specified other repo files...
+    if len(p_args.repo_files) > 1:
+        # ...remove the default entry
+        p_args.repo_files.pop(0)
+
+    if app is None:
+        app = qubesadmin.Qubes()
+
+    if p_args.updatevm is UPDATEVM:
+        p_args.updatevm = app.updatevm
+
+    try:
+        if p_args.refresh:
+            qrexec_repoquery(p_args, app, refresh=True)
+
+        if p_args.command == 'download':
+            download(p_args, app)
+        elif p_args.command == 'install':
+            install(p_args, app)
+        elif p_args.command == 'reinstall':
+            install(p_args, app, version_selector=VersionSelector.REINSTALL,
+                override_existing=True)
+        elif p_args.command == 'downgrade':
+            install(p_args, app, version_selector=VersionSelector.LATEST_LOWER,
+                override_existing=True)
+        elif p_args.command == 'upgrade':
+            install(p_args, app, version_selector=VersionSelector.LATEST_HIGHER,
+                override_existing=True)
+        elif p_args.command == 'list':
+            list_templates(p_args, app, 'list')
+        elif p_args.command == 'info':
+            list_templates(p_args, app, 'info')
+        elif p_args.command == 'search':
+            search(p_args, app)
+        elif p_args.command == 'remove':
+            remove(p_args, app, disassoc=p_args.disassoc)
+        elif p_args.command == 'purge':
+            remove(p_args, app, purge=True)
+        elif p_args.command == 'clean':
+            clean(p_args, app)
+        elif p_args.command == 'repolist':
+            repolist(p_args, app)
+        else:
+            parser.error('Command \'%s\' not supported.' % p_args.command)
+    except Exception as e:  # pylint: disable=broad-except
+        print('ERROR: ' + str(e), file=sys.stderr)
+        app.log.debug(str(e), exc_info=sys.exc_info())
+        return 1
+
+    return 0
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 133 - 12
qubesadmin/tools/qvm_template_postprocess.py

@@ -23,6 +23,7 @@
 import asyncio
 import glob
 import os
+import pathlib
 
 import shutil
 import subprocess
@@ -49,6 +50,12 @@ parser.add_argument('--skip-start', action='store_true',
     help='Do not start the VM - do not retrieve menu entries etc.')
 parser.add_argument('--keep-source', action='store_true',
     help='Do not remove source data (*dir* directory) after import')
+parser.add_argument('--no-installed-by-rpm', action='store_true',
+    help='Do not set installed_by_rpm')
+parser.add_argument('--allow-pv', action='store_true',
+    help='Allow setting virt_mode to pv in configuration file.')
+parser.add_argument('--pool',
+    help='Specify pool to store created VMs in.')
 parser.add_argument('action', choices=['post-install', 'pre-remove'],
     help='Action to perform')
 parser.add_argument('name', action='store',
@@ -60,9 +67,13 @@ parser.add_argument('dir', action='store',
 def get_root_img_size(source_dir):
     '''Extract size of root.img to be imported'''
     root_path = os.path.join(source_dir, 'root.img')
-    if os.path.exists(root_path + '.part.00'):
+    # deal with both cases: split tar and non-split tar
+    part_path = root_path + '.part.00'
+    tar_path = root_path + '.tar'
+    if os.path.exists(part_path) or os.path.exists(tar_path):
         # get just file root_size from the tar header
-        p = subprocess.Popen(['tar', 'tvf', root_path + '.part.00'],
+        path = part_path if os.path.exists(part_path) else tar_path
+        p = subprocess.Popen(['tar', 'tvf', path],
             stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
         (stdout, _) = p.communicate()
         # -rw-r--r-- 0/0      1073741824 1970-01-01 01:00 root.img
@@ -98,6 +109,12 @@ def import_root_img(vm, source_dir):
             raise qubesadmin.exc.QubesException('root.img extraction failed')
         if cat.wait() != 0:
             raise qubesadmin.exc.QubesException('root.img extraction failed')
+    elif os.path.exists(root_path + '.tar'):
+        tar = subprocess.Popen(['tar', 'xSOf', root_path + '.tar'],
+            stdout=subprocess.PIPE)
+        vm.volumes['root'].import_data(stream=tar.stdout)
+        if tar.wait() != 0:
+            raise qubesadmin.exc.QubesException('root.img extraction failed')
     elif os.path.exists(root_path):
         if vm.app.qubesd_connection_type == 'socket':
             # check if root.img was already overwritten, i.e. if the source
@@ -127,8 +144,14 @@ def reset_private_img(vm):
     vm.volumes['private'].clear_data()
 
 
-def import_appmenus(vm, source_dir):
-    '''Import appmenus settings into VM object (later: GUI VM)'''
+def import_appmenus(vm, source_dir, skip_generate=True):
+    """Import appmenus settings into VM object (later: GUI VM)
+
+    :param vm: QubesVM object of just imported template
+    :param source_dir: directory with source files
+    :param skip_generate: do not generate actual menu entries,
+            only set item lists
+    """
     if os.getuid() == 0:
         try:
             qubes_group = grp.getgrnam('qubes')
@@ -141,18 +164,50 @@ def import_appmenus(vm, source_dir):
     else:
         cmd_prefix = []
 
+    # store the whitelists in VM features
+    # separated by spaces should be ok as there should be no spaces in the file
+    # name according to the FreeDesktop spec
+    source_dir = pathlib.Path(source_dir)
+    try:
+        with open(source_dir / 'vm-whitelisted-appmenus.list', 'r') as fd:
+            vm.features['default-menu-items'] = \
+                ' '.join([x.rstrip() for x in fd])
+    except FileNotFoundError as e:
+        vm.log.warning('Cannot set default-menu-items, %s not found',
+                       e.filename)
+    try:
+        with open(source_dir / 'whitelisted-appmenus.list', 'r') as fd:
+            vm.features['menu-items'] = ' '.join([x.rstrip() for x in fd])
+    except FileNotFoundError as e:
+        vm.log.warning('Cannot set menu-items, %s not found',
+                       e.filename)
+    try:
+        with open(source_dir / 'netvm-whitelisted-appmenus.list', 'r') as fd:
+            vm.features['netvm-menu-items'] = ' '.join([x.rstrip() for x in fd])
+    except FileNotFoundError as e:
+        vm.log.warning('Cannot set netvm-menu-items, %s not found',
+                       e.filename)
+
+    if skip_generate:
+        return
+
     # TODO: change this to qrexec calls to GUI VM, when GUI VM will be
     # implemented
     try:
         subprocess.check_call(cmd_prefix + ['qvm-appmenus',
-            '--set-default-whitelist={}'.format(os.path.join(source_dir,
-                'vm-whitelisted-appmenus.list')), vm.name])
+            '--set-default-whitelist={!s}'.format(
+                source_dir / 'vm-whitelisted-appmenus.list'), vm.name])
         subprocess.check_call(cmd_prefix + ['qvm-appmenus',
-            '--set-whitelist={}'.format(os.path.join(source_dir,
-                'whitelisted-appmenus.list')), vm.name])
+            '--set-whitelist={!s}'.format(
+                source_dir / 'whitelisted-appmenus.list'), vm.name])
     except subprocess.CalledProcessError as e:
         vm.log.warning('Failed to set default application list: %s', e)
 
+def parse_template_config(path):
+    '''Parse template.conf from template package. (KEY=VALUE format)'''
+    with open(path, 'r') as fd:
+        return dict(line.rstrip('\n').split('=', 1) for line in fd)
+
 @asyncio.coroutine
 def call_postinstall_service(vm):
     '''Call qubes.PostInstall service
@@ -197,6 +252,12 @@ def call_postinstall_service(vm):
     finally:
         vm.netvm = qubesadmin.DEFAULT
 
+def validate_ip(ip):
+    """Check if given string has a valid IP address syntax"""
+    try:
+        return all(0 <= int(part) <= 255 for part in ip.split('.', 3))
+    except ValueError:
+        return False
 
 @asyncio.coroutine
 def post_install(args):
@@ -226,7 +287,8 @@ def post_install(args):
 
         vm = app.add_new_vm('TemplateVM',
             name=args.name,
-            label=qubesadmin.config.defaults['template_label'])
+            label=qubesadmin.config.defaults['template_label'],
+            pool=args.pool)
         vm_created = True
 
     vm.log.info('Importing data')
@@ -240,8 +302,14 @@ def post_install(args):
     if not vm_created:
         vm.log.info('Clearing private volume')
         reset_private_img(vm)
-    vm.installed_by_rpm = True
-    import_appmenus(vm, args.dir)
+    vm.installed_by_rpm = not args.no_installed_by_rpm
+    # do not generate actual menu entries, if post-install service will be
+    # executed anyway
+    import_appmenus(vm, args.dir, skip_generate=not args.skip_start)
+
+    conf_path = os.path.join(args.dir, 'template.conf')
+    if os.path.exists(conf_path):
+        import_template_config(args, conf_path, vm)
 
     if not args.skip_start:
         yield from call_postinstall_service(vm)
@@ -263,6 +331,60 @@ def post_install(args):
     return 0
 
 
+def import_template_config(args, conf_path, vm):
+    """
+    Parse template.conf and apply its content to the just installed TemplateVM
+
+    :param args: arguments for qvm-template-postprocess (used for --allow-pv
+        option and possibly some other in the future)
+    :param conf_path: path to the template.conf
+    :param vm: Template to operate on
+    :return:
+    """
+    conf = parse_template_config(conf_path)
+    # Import qvm-feature tags
+    for key in (
+            'no-monitor-layout',
+            'pci-e820-host',
+            'linux-stubdom',
+            'gui',
+            'gui-emulated',
+            'qrexec'):
+        if key in conf:
+            if conf[key] == '1':
+                vm.features[key] = conf[key]
+            else:
+                vm.log.warning(
+                    'ignoring boolean config flags that are not \'1\'')
+    for key in (
+            'net.fake-ip',
+            'net.fake-gateway',
+            'net.fake-netmask'):
+        if key in conf:
+            if validate_ip(conf[key]):
+                vm.features[key] = conf[key]
+            else:
+                vm.log.warning(
+                    'ignoring invalid value for \'%s\'', key)
+    if 'virt-mode' in conf:
+        if conf['virt-mode'] == 'pv' and args.allow_pv:
+            vm.virt_mode = 'pv'
+        elif conf['virt-mode'] == 'pv':
+            vm.log.warning(
+                '--allow-pv not set, ignoring request to change virt-mode')
+        elif conf['virt-mode'] in ('pvh', 'hvm'):
+            vm.virt_mode = conf['virt-mode']
+        else:
+            vm.log.warning('ignoring invalid value for virt-mode')
+
+    if 'kernel' in conf:
+        if conf['kernel'] == '':
+            vm.kernel = ''
+        else:
+            vm.log.warning(
+                'Currently only supports setting kernel to (none)')
+
+
 def pre_remove(args):
     '''Handle pre-removal tasks'''
     app = args.app
@@ -287,7 +409,6 @@ def is_chroot():
             stat_root.st_dev != stat_init_root.st_dev or
             stat_root.st_ino != stat_init_root.st_ino)
     except IOError:
-        print('Stat failed, assuming not chroot', file=sys.stderr)
         return False
 
 

+ 1 - 0
rpm_spec/qubes-core-admin-client.spec.in

@@ -15,6 +15,7 @@ BuildRequires:	python%{python3_pkgversion}-lxml
 BuildRequires:	python%{python3_pkgversion}-xcffib
 Requires:   python%{python3_pkgversion}-qubesadmin
 Requires:   python%{python3_pkgversion}-yaml
+Requires:   qubes-repo-templates
 Requires:   scrypt
 BuildArch:  noarch
 Source0: %{name}-%{version}.tar.gz

+ 46 - 0
test-packages/rpm.py

@@ -0,0 +1,46 @@
+# RPM header tags
+# Generated with the following command:
+# ``grep -Po '(RPMTAG[A-Z_]*)' tools/qvm_template.py | sort | uniq``
+RPMTAG_BUILDTIME   = 1
+RPMTAG_DESCRIPTION = 2
+RPMTAG_EPOCHNUM    = 3
+RPMTAG_LICENSE     = 4
+RPMTAG_NAME        = 5
+RPMTAG_RELEASE     = 6
+RPMTAG_SIGGPG      = 7
+RPMTAG_SIGPGP      = 8
+RPMTAG_SUMMARY     = 9
+RPMTAG_URL         = 10
+RPMTAG_VERSION     = 11
+
+RPMVSF_MASK_NOSIGNATURES = 0xc0c00
+
+class error(BaseException):
+    def __init__(self, msg):
+        self.msg = msg
+
+    def __str__(self):
+        return self.msg
+
+class hdr():
+    def __getitem__(self, key):
+        pass
+
+class keyring():
+    def addKey(self, *args):
+        pass
+
+class pubkey():
+    pass
+
+class TransactionSet():
+    def setVSFlags(self, flags):
+        pass
+    def setKeyring(self, *args):
+        pass
+    def hdrFromFdno(self, fdno) -> hdr:
+        return hdr()
+
+def labelCompare(a, b):
+    # Pretend that we're comparing the versions lexographically in the stub
+    return (a > b) - (a < b)