Browse Source

Merge branch 'master' of https://github.com/QubesOS/qubes-manager into cascade

donoban 3 years ago
parent
commit
d86c254031

+ 44 - 0
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 <marmarek@invisiblethingslab.com>  Thu, 25 Feb 2021 17:47:25 +0100
+
 qubes-manager (4.1.14-1) unstable; urgency=medium
 
   [ Frédéric Pierret (fepitre) ]

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

+ 163 - 2
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}'<br>"
+                "to Template <b>'{1}'</b>?").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}'<br>"
+                "to Network <b>'{1}'</b>?").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("<br>Can not change netvm to a halted Qube.<br>"
+                        "Do you want to start the Qube <b>'{0}'</b>?").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

+ 11 - 0
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! <br> <small>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.</small>"))
+
     def check_warn_dispvmnetvm(self):
         if not hasattr(self.vm, 'default_dispvm'):
             self.warn_netvm_dispvm.setVisible(False)

+ 16 - 13
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(

+ 287 - 23
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

+ 20 - 0
ui/qubemanager.ui

@@ -350,6 +350,24 @@ Template</string>
     <property name="title">
      <string>&amp;Qube</string>
     </property>
+    <widget class="QMenu" name="template_menu">
+     <property name="title">
+      <string>Template</string>
+     </property>
+     <property name="icon">
+      <iconset resource="../resources.qrc">
+       <normaloff>:/templatevm.png</normaloff>:/templatevm.png</iconset>
+     </property>
+    </widget>
+    <widget class="QMenu" name="network_menu">
+     <property name="title">
+      <string>Network</string>
+     </property>
+     <property name="icon">
+      <iconset>
+       <normaloff>:/netvm.png</normaloff>:/netvm.png</iconset>
+     </property>
+    </widget>
     <addaction name="action_createvm"/>
     <addaction name="action_removevm"/>
     <addaction name="action_clonevm"/>
@@ -361,6 +379,8 @@ Template</string>
     <addaction name="action_killvm"/>
     <addaction name="separator"/>
     <addaction name="action_settings"/>
+    <addaction name="template_menu"/>
+    <addaction name="network_menu"/>
     <addaction name="action_editfwrules"/>
     <addaction name="action_appmenus"/>
     <addaction name="action_updatevm"/>

+ 10 - 1
ui/settingsdlg.ui

@@ -981,11 +981,17 @@ The qube must be running to disable seamless mode; this setting is not persisten
               <item row="2" column="1">
                <widget class="QLabel" name="kernel_opts">
                 <property name="sizePolicy">
-                 <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+                 <sizepolicy hsizetype="Maximum" vsizetype="Minimum">
                   <horstretch>0</horstretch>
                   <verstretch>0</verstretch>
                  </sizepolicy>
                 </property>
+                <property name="maximumSize">
+                 <size>
+                  <width>250</width>
+                  <height>16777215</height>
+                 </size>
+                </property>
                 <property name="font">
                  <font>
                   <weight>50</weight>
@@ -996,6 +1002,9 @@ The qube must be running to disable seamless mode; this setting is not persisten
                 <property name="text">
                  <string>[]</string>
                 </property>
+                <property name="wordWrap">
+                 <bool>true</bool>
+                </property>
                </widget>
               </item>
              </layout>

+ 1 - 1
version

@@ -1 +1 @@
-4.1.14
+4.1.15