diff --git a/.pylintrc b/.pylintrc index 689b0e6..0f926da 100644 --- a/.pylintrc +++ b/.pylintrc @@ -148,6 +148,8 @@ ext-import-graph= # not be disabled) int-import-graph= +ignored-modules=dnf + [DESIGN] diff --git a/ci/requirements.txt b/ci/requirements.txt index d89e944..b216357 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -10,3 +10,5 @@ lxml PyYAML xcffib asynctest +tqdm +pyxdg diff --git a/debian/control b/debian/control index ff4739f..30440e3 100644 --- a/debian/control +++ b/debian/control @@ -23,6 +23,7 @@ Package: qubes-core-admin-client Architecture: any Depends: python3-qubesadmin, + qubes-repo-templates, scrypt, ${python:Depends}, ${python3:Depends}, diff --git a/doc/manpages/qvm-template.rst b/doc/manpages/qvm-template.rst new file mode 100644 index 0000000..1bc179d --- /dev/null +++ b/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 +```` 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`. diff --git a/doc/qubesadmin.tools.rst b/doc/qubesadmin.tools.rst index 078e7f0..8c4250b 100644 --- a/doc/qubesadmin.tools.rst +++ b/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 ---------------------------------------------------- diff --git a/qubesadmin/tests/__init__.py b/qubesadmin/tests/__init__.py index 787c3d9..996276f 100644 --- a/qubesadmin/tests/__init__.py +++ b/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), ) diff --git a/qubesadmin/tests/tools/qvm_template.py b/qubesadmin/tests/tools/qvm_template.py new file mode 100644 index 0000000..e97d03c --- /dev/null +++ b/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-|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() diff --git a/qubesadmin/tests/tools/qvm_template_postprocess.py b/qubesadmin/tests/tools/qvm_template_postprocess.py index 4c4b048..e20ef37 100644 --- a/qubesadmin/tests/tools/qvm_template_postprocess.py +++ b/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 . +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() diff --git a/qubesadmin/tools/qvm_template.py b/qubesadmin/tools/qvm_template.py new file mode 100644 index 0000000..e01770f --- /dev/null +++ b/qubesadmin/tools/qvm_template.py @@ -0,0 +1,1549 @@ +# +# The Qubes OS Project, https://www.qubes-os.org/ +# +# Copyright (C) 2019 WillyPillow +# +# 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 ```` 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 ```` 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 ```` 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()) diff --git a/qubesadmin/tools/qvm_template_postprocess.py b/qubesadmin/tools/qvm_template_postprocess.py index ca5ec7f..b5b33d8 100644 --- a/qubesadmin/tools/qvm_template_postprocess.py +++ b/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 diff --git a/rpm_spec/qubes-core-admin-client.spec.in b/rpm_spec/qubes-core-admin-client.spec.in index 88502c4..a3015d5 100644 --- a/rpm_spec/qubes-core-admin-client.spec.in +++ b/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 diff --git a/test-packages/rpm.py b/test-packages/rpm.py new file mode 100644 index 0000000..37d0730 --- /dev/null +++ b/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)