diff --git a/icons/ban.svg b/icons/ban.svg new file mode 100644 index 00000000..5ead57ea --- /dev/null +++ b/icons/ban.svg @@ -0,0 +1 @@ + diff --git a/qubesmanager/qube_manager.py b/qubesmanager/qube_manager.py index 1ddb7424..f6901877 100644 --- a/qubesmanager/qube_manager.py +++ b/qubesmanager/qube_manager.py @@ -103,6 +103,7 @@ def __init__(self): "Halted" : QIcon(":/blank") } self.outdatedIcons = { + "blocked" : QIcon(":/ban"), "update" : QIcon(":/updateable"), "outdated" : QIcon(":/outdated"), "to-be-outdated" : QIcon(":/outdated"), @@ -110,6 +111,8 @@ def __init__(self): "skipped": QIcon(':/skipped') } self.outdatedTooltips = { + "blocked" : self.tr( + "The qube is prohibited from starting"), "update" : self.tr("Updates available"), "outdated" : self.tr( "The qube must be restarted for recent changes in " @@ -239,6 +242,12 @@ def update_power_state(self): self.state['outdated'] = "" try: + if self.vm.klass != "AdminVM" and manager_utils.get_feature( + self.vm, 'prohibit-start', False): + # Special case where being outdated, eol & skipped is irrelevant + self.state['outdated'] = 'blocked' + return + if manager_utils.is_running(self.vm, False): if hasattr(self.vm, 'template') and \ manager_utils.is_running(self.vm.template, False): @@ -264,6 +273,8 @@ def update_power_state(self): eol = datetime.strptime(eol_string, '%Y-%m-%d') if datetime.now() > eol: self.state['outdated'] = 'eol' + else: + self.state['outdated'] = None except exc.QubesDaemonAccessError: pass @@ -879,6 +890,10 @@ def __init__(self, qt_app, qubes_app, dispatcher, _parent=None): self.on_domain_updates_available) dispatcher.add_handler('domain-feature-delete:skip-update', self.on_domain_updates_available) + dispatcher.add_handler('domain-feature-set:prohibit-start', + self.on_domain_updates_available) + dispatcher.add_handler('domain-feature-delete:prohibit-start', + self.on_domain_updates_available) self.installEventFilter(self) @@ -1125,11 +1140,16 @@ def check_updates(self, info=None): try: if info.vm.klass in {'TemplateVM', 'StandaloneVM'}: if manager_utils.get_feature( + info.vm, 'prohibit-start', False): + info.state['outdated'] = 'blocked' + elif manager_utils.get_feature( info.vm, 'skip-update', False): info.state['outdated'] = 'skipped' elif manager_utils.get_feature( info.vm, 'updates-available', False): info.state['outdated'] = 'update' + else: + info.state['outdated'] = None except exc.QubesDaemonAccessError: return @@ -1352,6 +1372,17 @@ def table_selection_changed(self): if not vm.updateable and vm.klass != 'AdminVM': self.action_updatevm.setEnabled(False) + if vm.state['outdated'] == 'blocked': + self.action_open_console.setEnabled(False) + self.action_resumevm.setEnabled(False) + self.action_startvm_tools_install.setEnabled(False) + self.action_pausevm.setEnabled(False) + self.action_restartvm.setEnabled(False) + self.action_killvm.setEnabled(False) + self.action_shutdownvm.setEnabled(False) + self.action_updatevm.setEnabled(False) + self.action_run_command_in_vm.setEnabled(False) + self.update_template_menu() self.update_network_menu() diff --git a/qubesmanager/tests/conftest.py b/qubesmanager/tests/conftest.py index d303e7f8..13e16c95 100644 --- a/qubesmanager/tests/conftest.py +++ b/qubesmanager/tests/conftest.py @@ -27,6 +27,8 @@ def test_qubes_app(): test_qapp = MockQubesComplete() test_qapp._qubes['sys-usb'].features[ 'supported-feature.keyboard-layout'] = '1' + test_qapp._qubes['test-standalone'].features['prohibit-start'] = \ + 'Control qube which should start prohibited from Manager launch' test_qapp.update_vm_calls() return test_qapp diff --git a/qubesmanager/tests/test_qube_manager.py b/qubesmanager/tests/test_qube_manager.py index d9a93385..542cdb8b 100644 --- a/qubesmanager/tests/test_qube_manager.py +++ b/qubesmanager/tests/test_qube_manager.py @@ -1604,3 +1604,59 @@ def test_704_check_later(mock_timer, mock_question): assert mock_question.call_count == 0 assert mock_timer.call_count == 1 + + +@pytest.mark.asyncio(loop_scope="module") +async def test_705_prohibit_start_vms(qubes_manager): + # `prohibit-start` is enabled for `test-standalone` before manager launch + # Flip `prohibit-start` feature for two qubes during Manager running + # Check the status of `start/resume` menu before and after. + + _select_vm(qubes_manager, 'test-standalone') + assert not qubes_manager.action_resumevm.isEnabled() + _select_vm(qubes_manager, 'test-red') + assert qubes_manager.action_resumevm.isEnabled() + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(qubes_manager.dispatcher.listen_for_events(), 1) + + # Now flip `prohibit-start` feature for two qubes + prohibition = ( + 'test-standalone', + 'admin.vm.feature.Set', + 'prohibit-start', + None, + ) + assert prohibition not in qubes_manager.qubes_app.actual_calls + qubes_manager.qubes_app.expected_calls[prohibition] = b'0\x00' + + prohibition = ( + 'test-red', + 'admin.vm.feature.Set', + 'prohibit-start', + 'Don not start this qube from now on', + ) + assert prohibition not in qubes_manager.qubes_app.actual_calls + qubes_manager.qubes_app.expected_calls[prohibition] = b'0\x00' + + qubes_manager.qubes_app._qubes['test-standalone'].features[ \ + 'prohibit-start'] = None + qubes_manager.qubes_app._qubes['test-red'].features[ \ + 'prohibit-start'] = 'Do not start this qube from now on' + + qubes_manager.dispatcher.add_expected_event( + MockEvent('test-standalone', + 'domain-feature-set', + [('name', 'prohibit-start'), + ('newvalue', '')])) + qubes_manager.dispatcher.add_expected_event( + MockEvent('test-red', + 'domain-feature-set', + [('name', 'prohibit-start'), + ('newvalue', 'Do not start this qube from now on')])) + + # Finally test if their status within Qube Manager is flipped correctly + _select_vm(qubes_manager, 'test-standalone') + assert qubes_manager.action_resumevm.isEnabled() + _select_vm(qubes_manager, 'test-red') + assert not qubes_manager.action_resumevm.isEnabled() diff --git a/resources.qrc b/resources.qrc index 9c4d87d2..524d6496 100644 --- a/resources.qrc +++ b/resources.qrc @@ -3,6 +3,7 @@ icons/add.svg icons/apps.svg icons/backup.svg + icons/ban.svg icons/blank.svg icons/checked.svg icons/checkmark.svg