diff --git a/debian/changelog b/debian/changelog index 88126bb..98fab97 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,47 @@ +qubes-manager (4.1.15-1) unstable; urgency=medium + + [ Marek Marczykowski-Górecki ] + * create_new_vm: enable/disable "install system" option on template + change + * tests: update for recent changes + + [ Marta Marczykowska-Górecka ] + * Enable word wrap for kernel opts in VM settings + + [ donoban ] + * Just warning message improve + * Added template_menu + * Added properly handling when templates are added and removed + * added network_menu + * Added 'None' netvm option + * Added try/except for change_network + * Added network_menu updates + * Added QMessageBox if netvm is halted and user wants to start it + * Moved change_* funcs after __init__() + * Added Template Change Confirmation + * Added change network confirmation + * Changed checkboxes to icons + * Added proper error handling and Check netvm_name is not None + * Added error message to dialogs + * Added try/except for starting netvm + * Better dialog creation + * Added wait argument to start_vm + * Wrap warnings message in self.tr() + * Added default option for network change + * Fix possible 'None' default error + * Display default netvm + * Disable network menu for templates + * Add warning if trying to change template VM + * Fix too long line + * Fix coherence in network menu when adding/removing domains + + [ Marek Marczykowski-Górecki ] + * Restore checkboxes to show/hide columns + * tests: add a decorator for keeping event listener alive + * tests: changing netvm and template via right click + + -- Marek Marczykowski-Górecki Thu, 25 Feb 2021 17:47:25 +0100 + qubes-manager (4.1.14-1) unstable; urgency=medium [ Frédéric Pierret (fepitre) ] diff --git a/qubesmanager/create_new_vm.py b/qubesmanager/create_new_vm.py index 5dff684..a95792d 100644 --- a/qubesmanager/create_new_vm.py +++ b/qubesmanager/create_new_vm.py @@ -168,6 +168,8 @@ class NewVmDlg(QtWidgets.QDialog, Ui_NewVMDlg): self.vm_type.currentIndexChanged.connect(self.type_change) + self.template_vm.currentIndexChanged.connect(self.template_change) + self.launch_settings.stateChanged.connect(self.settings_change) self.install_system.stateChanged.connect(self.install_change) @@ -287,6 +289,17 @@ class NewVmDlg(QtWidgets.QDialog, Ui_NewVMDlg): self.template_vm.setCurrentIndex(0) self.template_type = "template" + def template_change(self): + template = self.template_vm.currentData() + klass = self.vm_type.currentData() + + if klass in ['TemplateVM', 'StandaloneVM'] and template is None: + self.install_system.setEnabled(True) + self.install_system.setChecked(True) + else: + self.install_system.setEnabled(False) + self.install_system.setChecked(False) + def install_change(self): if self.install_system.isChecked(): self.launch_settings.setChecked(False) diff --git a/qubesmanager/qube_manager.py b/qubesmanager/qube_manager.py index 8ada6a1..bde0775 100644 --- a/qubesmanager/qube_manager.py +++ b/qubesmanager/qube_manager.py @@ -707,6 +707,8 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): self.frame_width = 0 self.frame_height = 0 + self.init_template_menu() + self.init_network_menu() self.__init_context_menu() self.tools_context_menu = QMenu(self) @@ -738,6 +740,8 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): self.proxy.setFilterKeyColumn(2) self.proxy.setFilterCaseSensitivity(Qt.CaseInsensitive) self.proxy.layoutChanged.connect(self.save_sorting) + self.proxy.layoutChanged.connect(self.update_template_menu) + self.proxy.layoutChanged.connect(self.update_network_menu) self.show_running.stateChanged.connect(self.invalidate) self.show_halted.stateChanged.connect(self.invalidate) @@ -820,9 +824,77 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): self.check_updates() + def change_template(self, template): + selected_vms = self.get_selected_vms() + reply = QMessageBox.question( + self, self.tr("Template Change Confirmation"), + self.tr("Do you want to change '{0}'
" + "to Template '{1}'?").format( + ', '.join(vm.name for vm in selected_vms), template), + QMessageBox.Yes | QMessageBox.Cancel) + + if reply == QMessageBox.Yes: + errors = [] + for info in selected_vms: + try: + info.vm.template = template + except exc.QubesValueError as ex: + errors.append((info.name, str(ex))) + + for error in errors: + QMessageBox.warning(self, self.tr("{0} template change failed!") + .format(error[0]), error[1]) + + + def change_network(self, netvm_name): + selected_vms = self.get_selected_vms() + reply = QMessageBox.question( + self, self.tr("Network Change Confirmation"), + self.tr("Do you want to change '{0}'
" + "to Network '{1}'?").format( + ', '.join(vm.name for vm in selected_vms), netvm_name), + QMessageBox.Yes | QMessageBox.Cancel) + + if reply != QMessageBox.Yes: + return + + if netvm_name not in [None, 'default']: + check_power = any(info.state['power'] == 'Running' for info + in self.get_selected_vms()) + netvm = self.qubes_cache.get_vm(name=netvm_name) + if check_power and netvm.state['power'] != 'Running': + reply = QMessageBox.question( + self, self.tr("Qube Start Confirmation"), + self.tr("
Can not change netvm to a halted Qube.
" + "Do you want to start the Qube '{0}'?").format( + netvm_name), + QMessageBox.Yes | QMessageBox.Cancel) + + if reply == QMessageBox.Yes: + self.start_vm(netvm.vm, True) + else: + return + + errors = [] + for info in self.get_selected_vms(): + try: + if netvm_name == 'default': + delattr(info.vm, 'netvm') + else: + info.vm.netvm = netvm_name + except exc.QubesValueError as ex: + errors.append((info.name, str(ex))) + + for error in errors: + QMessageBox.warning(self, self.tr("{0} network change failed!") + .format(error[0]), error[1]) + + def __init_context_menu(self): self.context_menu = QMenu(self) self.context_menu.addAction(self.action_settings) + self.context_menu.addAction(self.template_menu.menuAction()) + self.context_menu.addAction(self.network_menu.menuAction()) self.context_menu.addAction(self.action_editfwrules) self.context_menu.addAction(self.action_appmenus) self.context_menu.addAction(self.action_set_keyboard_layout) @@ -883,6 +955,33 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): progress.setValue(row_no) + def init_template_menu(self): + self.template_menu.clear() + for vm in self.qubes_app.domains: + if vm.klass == 'TemplateVM': + action = self.template_menu.addAction(vm.name) + action.setData(vm.name) + action.triggered.connect(partial(self.change_template, vm.name)) + + def _get_default_netvm(self): + for vm in self.qubes_app.domains: + if vm.klass == 'AppVM': + return vm.property_get_default('netvm') + + def init_network_menu(self): + default = self._get_default_netvm() + self.network_menu.clear() + action = self.network_menu.addAction("None") + action.triggered.connect(partial(self.change_network, None)) + action = self.network_menu.addAction("default ({0})".format(default)) + action.triggered.connect(partial(self.change_network, 'default')) + + for vm in self.qubes_app.domains: + if vm.qid != 0 and vm.provides_network: + action = self.network_menu.addAction(vm.name) + action.setData(vm.name) + action.triggered.connect(partial(self.change_network, vm.name)) + def setup_application(self): self.qt_app.setApplicationName(self.tr("Qube Manager")) self.qt_app.setWindowIcon(QIcon.fromTheme("qubes-manager")) @@ -940,12 +1039,16 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): domain = self.qubes_app.domains[vm] self.qubes_cache.add_vm(domain) self.proxy.invalidate() + if domain.klass == 'TemplateVM': + self.init_template_menu() except (exc.QubesException, KeyError): pass def on_domain_removed(self, _submitter, _event, **kwargs): self.qubes_cache.remove_vm(name=kwargs['vm']) self.proxy.invalidate() + self.init_template_menu() + self.init_network_menu() def on_domain_status_changed(self, vm, event, **_kwargs): try: @@ -975,6 +1078,8 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): return try: + if event.endswith(':provides_network'): + self.init_network_menu() self.qubes_cache.get_vm(qid=vm.qid).update(event=event) self.proxy.invalidate() except exc.QubesDaemonAccessError: @@ -1059,6 +1164,8 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): def table_selection_changed(self): # Since selection could have multiple domains # enable all first and then filter them + self.template_menu.setEnabled(True) + self.network_menu.setEnabled(True) for action in self.toolbar.actions() + self.context_menu.actions(): action.setEnabled(True) @@ -1069,17 +1176,20 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): ['Running', 'Transient', 'Halting', 'Dying']: self.action_resumevm.setEnabled(False) self.action_removevm.setEnabled(False) + self.template_menu.setEnabled(False) elif vm.state['power'] == 'Paused': self.action_removevm.setEnabled(False) self.action_pausevm.setEnabled(False) self.action_set_keyboard_layout.setEnabled(False) self.action_restartvm.setEnabled(False) self.action_open_console.setEnabled(False) + self.template_menu.setEnabled(False) elif vm.state['power'] == 'Suspend': self.action_set_keyboard_layout.setEnabled(False) self.action_removevm.setEnabled(False) self.action_pausevm.setEnabled(False) self.action_open_console.setEnabled(False) + self.template_menu.setEnabled(False) elif vm.state['power'] == 'Halted': self.action_set_keyboard_layout.setEnabled(False) self.action_pausevm.setEnabled(False) @@ -1102,9 +1212,15 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): self.action_editfwrules.setEnabled(False) self.action_set_keyboard_layout.setEnabled(False) self.action_run_command_in_vm.setEnabled(False) + self.template_menu.setEnabled(False) + self.network_menu.setEnabled(False) elif vm.klass == 'DispVM': self.action_appmenus.setEnabled(False) self.action_restartvm.setEnabled(False) + self.template_menu.setEnabled(False) + elif vm.klass == 'TemplateVM': + self.template_menu.setEnabled(False) + self.network_menu.setEnabled(False) if vm.vm.features.get('internal', False): self.action_appmenus.setEnabled(False) @@ -1112,6 +1228,47 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): if not vm.updateable and vm.klass != 'AdminVM': self.action_updatevm.setEnabled(False) + self.update_template_menu() + self.update_network_menu() + + def update_template_menu(self): + if not self.template_menu.isEnabled(): + return + + for entry in self.template_menu.actions(): + entry.setIcon(QIcon()) + + vms = self.get_selected_vms() + for vm in vms: + for entry in self.template_menu.actions(): + if entry.data() == vm.template: + if len(vms) == 1: + entry.setIcon(QIcon(":/on.png")) + else: + entry.setIcon(QIcon(":/transient.png")) + + def update_network_menu(self): + if not self.network_menu.isEnabled(): + return + + for entry in self.network_menu.actions(): + entry.setIcon(QIcon()) + + if len(self.get_selected_vms()) == 1: + icon = QIcon(":/on.png") + else: + icon = QIcon(":/transient.png") + + for vm in self.get_selected_vms(): + if vm.netvm == "n/a": + self.network_menu.actions()[0].setIcon(QIcon(icon)) + elif vm.vm.property_is_default("netvm"): + self.network_menu.actions()[1].setIcon(QIcon(icon)) + else: + for entry in self.network_menu.actions(): + if entry.data() == vm.netvm: + entry.setIcon(icon) + # noinspection PyArgumentList @pyqtSlot(name='on_action_createvm_triggered') def action_createvm_triggered(self): @@ -1200,7 +1357,7 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): self.start_vm(vm) - def start_vm(self, vm): + def start_vm(self, vm, wait=False): if manager_utils.is_running(vm, False): return @@ -1209,6 +1366,10 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): thread.finished.connect(self.clear_threads) thread.start() + if wait: + with common_threads.busy_cursor(): + thread.wait() + # noinspection PyArgumentList @pyqtSlot(name='on_action_startvm_tools_install_triggered') # TODO: replace with boot from device @@ -1579,7 +1740,7 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QMainWindow): self, self.tr("Error"), self.tr( - "No log files where found for the current selection.")) + "No log files were found for the selected qubes.")) except exc.QubesDaemonAccessError: pass diff --git a/qubesmanager/settings.py b/qubesmanager/settings.py index c55a266..ce77905 100644 --- a/qubesmanager/settings.py +++ b/qubesmanager/settings.py @@ -432,6 +432,7 @@ class VMSettingsWindow(ui_settingsdlg.Ui_SettingsDialog, QtWidgets.QDialog): self.netVM.setCurrentIndex(-1) self.netVM.currentIndexChanged.connect(self.check_warn_dispvmnetvm) + self.netVM.currentIndexChanged.connect(self.check_warn_templatenetvm) try: self.include_in_backups.setChecked(self.vm.include_in_backups) @@ -625,6 +626,16 @@ class VMSettingsWindow(ui_settingsdlg.Ui_SettingsDialog, QtWidgets.QDialog): self.init_mem.value() * 10 < self.max_mem_size.value(): self.warn_too_much_mem_label.setVisible(True) + def check_warn_templatenetvm(self): + if self.vm.klass == 'TemplateVM': + QtWidgets.QMessageBox.warning( + self, + self.tr("Warning!"), + self.tr("Connecting a TemplateVM directly to a network is higly" + " discouraged!
You are breaking a basic par" + "t of Qubes security and there is probably no real need" + " to do so. Continue at your own risk.")) + def check_warn_dispvmnetvm(self): if not hasattr(self.vm, 'default_dispvm'): self.warn_netvm_dispvm.setVisible(False) diff --git a/qubesmanager/tests/test_create_new_vm.py b/qubesmanager/tests/test_create_new_vm.py index f56c357..2b3c483 100644 --- a/qubesmanager/tests/test_create_new_vm.py +++ b/qubesmanager/tests/test_create_new_vm.py @@ -171,8 +171,7 @@ class NewVmTest(unittest.TestCase): self.dialog.name.setText("test-vm") for i in range(self.dialog.vm_type.count()): opt_text = self.dialog.vm_type.itemText(i).lower() - if "standalone" in opt_text and "template" in opt_text and\ - "not based" not in opt_text and "empty" not in opt_text: + if "standalone" in opt_text: self.dialog.vm_type.setCurrentIndex(i) break @@ -187,10 +186,11 @@ class NewVmTest(unittest.TestCase): self.dialog.name.setText("test-vm") for i in range(self.dialog.vm_type.count()): opt_text = self.dialog.vm_type.itemText(i).lower() - if "standalone" in opt_text and\ - ("not based" in opt_text or "empty" in opt_text): + if "standalone" in opt_text: self.dialog.vm_type.setCurrentIndex(i) break + # select "(none)" template + self.dialog.template_vm.setCurrentIndex(self.dialog.template_vm.count()-1) self.__click_ok() self.mock_thread.assert_called_once_with( @@ -210,10 +210,11 @@ class NewVmTest(unittest.TestCase): for i in range(self.dialog.vm_type.count()): opt_text = self.dialog.vm_type.itemText(i).lower() - if "standalone" in opt_text and\ - ("not based" in opt_text or "empty" in opt_text): + if "standalone" in opt_text: self.dialog.vm_type.setCurrentIndex(i) break + # select "(none)" template + self.dialog.template_vm.setCurrentIndex(self.dialog.template_vm.count()-1) self.dialog.install_system.setChecked(False) @@ -232,7 +233,7 @@ class NewVmTest(unittest.TestCase): # cannot install system on a template-based appvm for i in range(self.dialog.vm_type.count()): opt_text = self.dialog.vm_type.itemText(i).lower() - if "appvm" in opt_text and "standalone" not in opt_text: + if "appvm" in opt_text: self.dialog.vm_type.setCurrentIndex(i) break self.assertFalse(self.dialog.install_system.isEnabled()) @@ -242,24 +243,26 @@ class NewVmTest(unittest.TestCase): # or on a standalone vm cloned from a template for i in range(self.dialog.vm_type.count()): opt_text = self.dialog.vm_type.itemText(i).lower() - if "standalone" in opt_text and "template" in opt_text and\ - "not based" not in opt_text and "empty" not in opt_text: + if "standalone" in opt_text: self.dialog.vm_type.setCurrentIndex(i) break + # select default template + self.dialog.template_vm.setCurrentIndex(0) self.assertFalse(self.dialog.install_system.isEnabled()) self.assertTrue(self.dialog.launch_settings.isEnabled()) self.assertTrue(self.dialog.template_vm.isEnabled()) - # cannot set a template but can install system on a truly empty AppVM + # can install system on a truly empty AppVM for i in range(self.dialog.vm_type.count()): opt_text = self.dialog.vm_type.itemText(i).lower() - if "standalone" in opt_text and\ - ("not based" in opt_text or "empty" in opt_text): + if "standalone" in opt_text: self.dialog.vm_type.setCurrentIndex(i) break + self.assertTrue(self.dialog.template_vm.isEnabled()) + # select "(none)" template + self.dialog.template_vm.setCurrentIndex(self.dialog.template_vm.count()-1) self.assertTrue(self.dialog.install_system.isEnabled()) self.assertTrue(self.dialog.launch_settings.isEnabled()) - self.assertFalse(self.dialog.template_vm.isEnabled()) def __click_ok(self): okwidget = self.dialog.buttonBox.button( diff --git a/qubesmanager/tests/test_qube_manager.py b/qubesmanager/tests/test_qube_manager.py index 324cc5c..d126535 100644 --- a/qubesmanager/tests/test_qube_manager.py +++ b/qubesmanager/tests/test_qube_manager.py @@ -21,6 +21,7 @@ # import asyncio import contextlib +import functools import logging.handlers import unittest import unittest.mock @@ -41,6 +42,24 @@ from qubesmanager.tests import init_qtapp icon_size = qube_manager.icon_size +def listen_for_events(func): + """Wrapper for a test that needs events listener to be registered all the time. + Note the test still needs to yield to the event loop to actually handle events. + """ + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + events_listener = \ + asyncio.ensure_future(self.dispatcher.listen_for_events()) + # let it connect (run until first yield/await) + self.loop.run_until_complete(asyncio.sleep(0)) + try: + return func(self, *args, **kwargs) + finally: + events_listener.cancel() + self.loop.call_soon(self.loop.stop) + self.loop.run_forever() + return wrapper + class QubeManagerTest(unittest.TestCase): def setUp(self): super(QubeManagerTest, self).setUp() @@ -156,10 +175,8 @@ class QubeManagerTest(unittest.TestCase): for row in range(self.dialog.table.model().rowCount()): vm = self._get_table_vm(row) - incl_backups_item = self._get_table_item(row, "Backup", - Qt.CheckStateRole) + incl_backups_item = self._get_table_item(row, "Backup", Qt.CheckStateRole) == Qt.Checked incl_backups_value = getattr(vm, 'include_in_backups', False) - incl_backups_value = Qt.Checked if incl_backups_value else Qt.Unchecked self.assertEqual( incl_backups_value, incl_backups_item, @@ -187,7 +204,7 @@ class QubeManagerTest(unittest.TestCase): def_dispvm_item = self._get_table_item(row, "Default DispVM") if vm.property_is_default("default_dispvm"): def_dispvm_value = "default ({})".format( - self.qapp.default_dispvm) + vm.property_get_default("default_dispvm")) else: def_dispvm_value = getattr(vm, "default_dispvm", None) @@ -314,6 +331,8 @@ class QubeManagerTest(unittest.TestCase): def test_204_vm_keyboard(self, mock_message): selected_vm = self._select_non_admin_vm(running=True) self.assertIsNotNone(selected_vm, "No valid non-admin VM found") + if 'supported-feature.keyboard-layout' not in selected_vm.features: + self.skipTest("VM {!s} does not support new layout change".format(selected_vm)) widget = self.dialog.toolbar.widgetForAction( self.dialog.action_set_keyboard_layout) with unittest.mock.patch.object(selected_vm, 'run') as mock_run: @@ -334,9 +353,6 @@ class QubeManagerTest(unittest.TestCase): QtCore.Qt.LeftButton) self.assertEqual(mock_run.call_count, 0, "Keyboard change called on a halted VM") - self.assertEqual(mock_message.call_count, 0, - "Keyboard change called on a halted VM with" - " obsolete keyboard-layout handling") def test_206_dom0_keyboard(self): self._select_admin_vm() @@ -715,6 +731,241 @@ class QubeManagerTest(unittest.TestCase): self.assertEqual(expected_number, actual_number, "Incorrect number of vms shown for cleared search box") + @unittest.mock.patch('PyQt5.QtWidgets.QMessageBox.question') + @listen_for_events + def test_240_network_menu_single(self, mock_question): + mock_question.return_value = QtWidgets.QMessageBox.Yes + target_vm_name = 'work' + + self._run_command_and_process_events( + ['qvm-prefs', '-D', target_vm_name, 'netvm'], timeout=20) + self._select_vms(['work']) + selected_vm = self.qapp.domains[target_vm_name] + # reset to default even in case of failure + self.addCleanup(functools.partial(delattr, selected_vm, 'netvm')) + + # this is the method to get '==' operator working on icons... + on_icon = QIcon(":/on.png").pixmap(64).toImage() + off_icon = QIcon().pixmap(64).toImage() + for action in self.dialog.network_menu.actions(): + if action.text().startswith('default '): + self.assertEqual(action.icon().pixmap(64).toImage(), on_icon) + break + else: + self.fail('default netvm not found') + + # change to specific value + for action in self.dialog.network_menu.actions(): + if action.text() == 'sys-net': + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + action.trigger() + break + else: + self.fail('sys-net netvm not found') + # process events + self.loop.run_until_complete(asyncio.sleep(0)) + mock_question.assert_called() + self.assertEqual(str(selected_vm.netvm), 'sys-net') + mock_question.reset_mock() + + # change to none + for action in self.dialog.network_menu.actions(): + if action.text() == 'None': + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + action.trigger() + break + else: + self.fail('"none" netvm not found') + # process events + self.loop.run_until_complete(asyncio.sleep(0)) + mock_question.assert_called() + self.assertIsNone(selected_vm.netvm) + mock_question.reset_mock() + + # then go back to the default + for action in self.dialog.network_menu.actions(): + if action.text().startswith('default '): + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + action.trigger() + break + # process events + self.loop.run_until_complete(asyncio.sleep(0)) + + mock_question.assert_called() + self.assertTrue(selected_vm.property_is_default('netvm')) + + @unittest.mock.patch('PyQt5.QtWidgets.QMessageBox.question') + @listen_for_events + def test_241_network_menu_multiple(self, mock_question): + mock_question.return_value = QtWidgets.QMessageBox.Yes + target_vm_names = ['work', 'personal', 'vault'] + work = self.qapp.domains['work'] + personal = self.qapp.domains['personal'] + vault = self.qapp.domains['vault'] + # reset to default even in case of failure + self.addCleanup(functools.partial(delattr, work, 'netvm')) + self.addCleanup(functools.partial(delattr, personal, 'netvm')) + self.addCleanup(functools.partial(setattr, vault, 'netvm', None)) + + self._run_command_and_process_events( + ['qvm-prefs', '-D', 'work', 'netvm'], timeout=5) + self._run_command_and_process_events( + ['qvm-prefs', '-D', 'personal', 'netvm'], timeout=5) + self._run_command_and_process_events( + ['qvm-prefs', 'vault', ''], timeout=5) + self._select_vms(target_vm_names) + + # this is the method to get '==' operator working on icons... + on_icon = QIcon(":/on.png").pixmap(64).toImage() + transient_icon = QIcon(":/transient.png").pixmap(64).toImage() + off_icon = QIcon().pixmap(64).toImage() + for action in self.dialog.network_menu.actions(): + if action.text().startswith('default '): + # work, personal + self.assertEqual(action.icon().pixmap(64).toImage(), transient_icon) + elif action.text() == 'None': + # vault + self.assertEqual(action.icon().pixmap(64).toImage(), transient_icon) + else: + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + + # change to specific value + for action in self.dialog.network_menu.actions(): + if action.text() == 'sys-net': + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + action.trigger() + break + else: + self.fail('sys-net netvm not found') + # process events + self.loop.run_until_complete(asyncio.sleep(0)) + mock_question.assert_called() + self.assertEqual(str(work.netvm), 'sys-net') + self.assertEqual(str(personal.netvm), 'sys-net') + self.assertEqual(str(vault.netvm), 'sys-net') + mock_question.reset_mock() + + @unittest.mock.patch('PyQt5.QtWidgets.QMessageBox.question') + @listen_for_events + def test_250_template_menu_single(self, mock_question): + mock_question.return_value = QtWidgets.QMessageBox.Yes + target_vm_name = 'work' + selected_vm = self.qapp.domains[target_vm_name] + if selected_vm.is_running(): + self.skipTest( + 'VM {!s} is running, please stop it first'.format(selected_vm)) + current_template = selected_vm.template + new_template = self._select_templatevm( + different_than=[str(current_template)]) + + self._select_vms(['work']) + + # reset to previous value even in case of failure + self.addCleanup(functools.partial( + setattr, selected_vm, 'template', str(current_template))) + + # this is the method to get '==' operator working on icons... + on_icon = QIcon(":/on.png").pixmap(64).toImage() + off_icon = QIcon().pixmap(64).toImage() + found = False + for action in self.dialog.template_menu.actions(): + if action.text() == str(current_template): + self.assertEqual(action.icon().pixmap(64).toImage(), on_icon) + found = True + else: + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + + if not found: + self.fail( + 'current template value ({!s}) not found in the menu'.format( + current_template)) + + # change to specific value + for action in self.dialog.template_menu.actions(): + if action.text() == str(new_template): + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + action.trigger() + break + else: + self.fail('template {!s} not found in the menu'.format(new_template)) + # process events + self.loop.run_until_complete(asyncio.sleep(0)) + mock_question.assert_called() + # compare str(), to have better error message on mismatch + self.assertEqual(str(selected_vm.template), str(new_template)) + mock_question.reset_mock() + + @unittest.mock.patch('PyQt5.QtWidgets.QMessageBox.question') + @listen_for_events + def test_251_template_menu_multiple(self, mock_question): + mock_question.return_value = QtWidgets.QMessageBox.Yes + target_vm_names = ['work', 'personal', 'untrusted'] + work = self.qapp.domains['work'] + personal = self.qapp.domains['personal'] + untrusted = self.qapp.domains['untrusted'] + if any(vm.is_running() for vm in [work, personal, untrusted]): + self.skipTest('Any of work, personal, untrusted VM is running') + + old_template = work.template + new_template = self._select_templatevm( + different_than=[str(work.template), + str(personal.template), + str(untrusted.template)]) + # reset to previous value even in case of failure + self.addCleanup(functools.partial( + setattr, work, 'template', str(work.template))) + self.addCleanup(functools.partial( + setattr, personal, 'template', str(personal.template))) + self.addCleanup(functools.partial( + setattr, untrusted, 'template', str(untrusted.template))) + + # set all to the same value + self._run_command_and_process_events( + ['qvm-prefs', 'personal', 'template', str(work.template)], timeout=5) + self._run_command_and_process_events( + ['qvm-prefs', 'untrusted', 'template', str(work.template)], timeout=5) + + self._select_vms(target_vm_names) + + # this is the method to get '==' operator working on icons... + on_icon = QIcon(":/on.png").pixmap(64).toImage() + transient_icon = QIcon(":/transient.png").pixmap(64).toImage() + off_icon = QIcon().pixmap(64).toImage() + for action in self.dialog.template_menu.actions(): + if action.text() == str(old_template): + self.assertIn( + action.icon().pixmap(64).toImage(), + (on_icon, transient_icon)) + else: + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + + # make one different + self._run_command_and_process_events( + ['qvm-prefs', 'work', 'template', str(new_template)], timeout=5) + + for action in self.dialog.template_menu.actions(): + if action.text() == str(old_template): + self.assertEqual(action.icon().pixmap(64).toImage(), transient_icon) + elif action.text() == str(new_template): + self.assertEqual(action.icon().pixmap(64).toImage(), transient_icon) + else: + self.assertEqual(action.icon().pixmap(64).toImage(), off_icon) + + # change all to the same value + for action in self.dialog.template_menu.actions(): + if action.text() == str(new_template): + action.trigger() + break + else: + self.fail('{!s} template not found'.format(new_template)) + # process events + self.loop.run_until_complete(asyncio.sleep(0)) + mock_question.assert_called() + self.assertEqual(str(work.template), str(new_template)) + self.assertEqual(str(personal.template), str(new_template)) + self.assertEqual(str(untrusted.template), str(new_template)) + + @unittest.mock.patch('PyQt5.QtWidgets.QMessageBox.information') @unittest.mock.patch('PyQt5.QtWidgets.QMessageBox.warning') def test_300_clear_threads(self, mock_warning, mock_info): @@ -1133,27 +1384,31 @@ class QubeManagerTest(unittest.TestCase): self.assertEqual(call_count, 0) @unittest.mock.patch('qubesmanager.log_dialog.LogDialog') - def test_500_logs(self, mock_logDialog): + def test_500_logs(self, mock_log_dialog): self._select_admin_vm() - self.assertTrue(self.dialog.action_show_logs.isEnabled()) self.dialog.action_show_logs.trigger() - dom0_logs = mock_logDialog.call_args.args[1] - self.assertIn('/var/log/xen/console/hypervisor.log', dom0_logs, - "Log for dom0 does not contain 'hypervisor'") + mock_log_dialog.assert_called_once() + dom0_logs = mock_log_dialog.mock_calls[0][1][1] + for c in dom0_logs: + self.assertIn("hypervisor", c, + "Log for dom0 does not contain 'hypervisor'") + + mock_log_dialog.reset_mock() selected_vm = self._select_non_admin_vm(running=True).name - self.assertTrue(self.dialog.action_show_logs.isEnabled()) self.dialog.action_show_logs.trigger() - vm_logs = mock_logDialog.call_args.args[1] - self.assertIn( - selected_vm, - ",".join(vm_logs), - "Log for {} does not contain its name".format(selected_vm)) + mock_log_dialog.assert_called_once() + vm_logs = mock_log_dialog.mock_calls[0][1][1] + for c in vm_logs: + self.assertIn( + selected_vm, + c, + "Log for {} does not contain its name".format(selected_vm)) self.assertNotEqual(dom0_logs, vm_logs, - "Same logs found for dom0 and non-adminVM") + "Same logs found for dom0 and non-adminVM") def _find_vm_row(self, vm_name): for row in range(self.dialog.table.model().rowCount()): @@ -1227,7 +1482,7 @@ class QubeManagerTest(unittest.TestCase): for row in range(self.dialog.table.model().rowCount()): template = self._get_table_item(row, "Template") vm = self._get_table_vm(row) - if template != 'AdminVM' and \ + if template != 'AdminVM' and not vm.provides_network and \ (running is None or (running and vm.is_running()) or (not running and not vm.is_running())): @@ -1236,19 +1491,28 @@ class QubeManagerTest(unittest.TestCase): return vm return None - def _select_templatevm(self, running=None): + def _select_templatevm(self, running=None, different_than=()): for row in range(self.dialog.table.model().rowCount()): template = self._get_table_item(row, "Template") vm = self._get_table_vm(row) if template == 'TemplateVM' and \ + (template not in different_than) and \ (running is None - or (running and vm.is_running()) - or (not running and not vm.is_running())): + or (bool(running) == bool(vm.is_running()))): index = self.dialog.table.model().index(row, 0) self.dialog.table.setCurrentIndex(index) return vm return None + def _select_vms(self, vms: list): + self.dialog.table.selectionModel().clear() + mode = QtCore.QItemSelectionModel.Select | QtCore.QItemSelectionModel.Rows + for row in range(self.dialog.table.model().rowCount()): + vm = self._get_table_vm(row) + if str(vm) in vms: + index = self.dialog.table.model().index(row, 0) + self.dialog.table.selectionModel().select(index, mode) + def __check_sorting(self, column_name): last_text = None last_vm = None diff --git a/ui/qubemanager.ui b/ui/qubemanager.ui index bbed67c..26cd341 100644 --- a/ui/qubemanager.ui +++ b/ui/qubemanager.ui @@ -350,6 +350,24 @@ Template &Qube + + + Template + + + + :/templatevm.png:/templatevm.png + + + + + Network + + + + :/netvm.png:/netvm.png + + @@ -361,6 +379,8 @@ Template + + diff --git a/ui/settingsdlg.ui b/ui/settingsdlg.ui index ec867dc..ed77fa1 100644 --- a/ui/settingsdlg.ui +++ b/ui/settingsdlg.ui @@ -981,11 +981,17 @@ The qube must be running to disable seamless mode; this setting is not persisten - + 0 0 + + + 250 + 16777215 + + 50 @@ -996,6 +1002,9 @@ The qube must be running to disable seamless mode; this setting is not persisten [] + + true + diff --git a/version b/version index 5888f1a..796262d 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.1.14 +4.1.15