From 777796a0caa5c99df3694dd86db25f4851aeb001 Mon Sep 17 00:00:00 2001 From: Ali Mirjamali Date: Fri, 21 Feb 2025 21:57:34 +0330 Subject: [PATCH] Mark VMs with `prohibit-start` feature in updater Related: https://github.com/QubesOS/qubes-issues/issues/9622 --- qui/updater.glade | 8 +- qui/updater/intro_page.py | 45 ++++++++++- qui/updater/tests/conftest.py | 1 + qui/updater/tests/test_intro_page.py | 117 +++++++++++++++++++++++++++ qui/updater/updater.py | 1 + qui/updater/utils.py | 8 +- 6 files changed, 172 insertions(+), 8 deletions(-) diff --git a/qui/updater.glade b/qui/updater.glade index 6c456a05..1cc76491 100644 --- a/qui/updater.glade +++ b/qui/updater.glade @@ -32,6 +32,8 @@ + + @@ -173,6 +175,7 @@ Qube name + True 3 @@ -182,6 +185,7 @@ Updates available + True 0.5 4 @@ -192,6 +196,7 @@ Last checked + 150 0.5 5 @@ -202,6 +207,7 @@ Last updated + 150 0.5 6 @@ -230,7 +236,7 @@ 20 Qubes OS checks for updates for running and networked qubes and their templates. Updates may also be available in other qubes, marked as {MAYBE} above. -{OBSOLETE} qubes are based on templates that are no longer supported and no longer receive updates. Please install new templates using the Qubes Template Manager. +{OBSOLETE} qubes are based on templates that are no longer supported and no longer receive updates. Please install new templates using the Qubes Template Manager. {PROHIBITED} qubes have the `prohibit-start` feature set. Selected qubes will be automatically started if necessary and shutdown after successful update. True diff --git a/qui/updater/intro_page.py b/qui/updater/intro_page.py index 7b5395ca..e3dbcd0d 100644 --- a/qui/updater/intro_page.py +++ b/qui/updater/intro_page.py @@ -63,6 +63,8 @@ def __init__(self, builder, log, next_button): self.page: Gtk.Box = self.builder.get_object("list_page") self.stack: Gtk.Stack = self.builder.get_object("main_stack") self.vm_list: Gtk.TreeView = self.builder.get_object("vm_list") + self.vm_list.set_has_tooltip(True) + self.vm_list.connect("query-tooltip", self.on_query_tooltip) self.list_store: Optional[ListWrapper] = None checkbox_column: Gtk.TreeViewColumn = self.builder.get_object( @@ -93,6 +95,8 @@ def __init__(self, builder, log, next_button): "MAYBE", OBSOLETE=f'' "OBSOLETE", + PROHIBITED=f'' + "START PROHIBITED", ) ) @@ -307,6 +311,25 @@ def _handle_cli_dom0(dom0, to_update, cliargs): to_update = to_update.difference({"dom0"}) return to_update + def on_query_tooltip(self, widget, x, y, keyboard_tip, tooltip): + """Show appropriate qube tooltip. Currently only for prohibit-start.""" + if not widget.get_tooltip_context(x, y, keyboard_tip): + return False + _, x, y, model, path, iterator = widget.get_tooltip_context( + x, y, keyboard_tip + ) + if path: + status = model[iterator][4] + if status == type(status).PROHIBITED: + tooltip.set_text( + "Start prohibition rationale:\n{}".format( + str(model[iterator][9]) + ) + ) + widget.set_tooltip_cell(tooltip, path, None, None) + return True + return False + def is_stale(vm, expiration_period): if expiration_period is None: @@ -345,16 +368,20 @@ def __init__(self, list_store, vm, to_update: bool): icon = load_icon(vm.icon) name = QubeName(vm.name, str(vm.label)) + prohibit_rationale = vm.features.get("prohibit-start", False) raw_row = [ selected, icon, name, - UpdatesAvailable.from_features(updates_available, supported), + UpdatesAvailable.from_features( + updates_available, supported, bool(prohibit_rationale) + ), Date(last_updates_check), Date(last_update), 0, UpdateStatus.Undefined, + prohibit_rationale, ] super().__init__(list_store, vm, raw_row) @@ -390,6 +417,7 @@ def updates_available(self): @updates_available.setter def updates_available(self, value): + prohibited = bool(self.vm.features.get("prohibit-start", False)) updates_available = bool( self.vm.features.get("updates-available", False) ) @@ -398,7 +426,7 @@ def updates_available(self, value): if value and not updates_available: updates_available = None self.raw_row[self._UPDATES_AVAILABLE] = UpdatesAvailable.from_features( - updates_available, supported + updates_available, supported, prohibited ) @property @@ -497,11 +525,16 @@ class UpdatesAvailable(Enum): MAYBE = 1 NO = 2 EOL = 3 + PROHIBITED = 4 @staticmethod def from_features( - updates_available: Optional[bool], supported: Optional[str] = None + updates_available: Optional[bool], + supported: Optional[str] = None, + prohibited: Optional[str] = None, ) -> "UpdatesAvailable": + if prohibited: + return UpdatesAvailable.PROHIBITED if not supported: return UpdatesAvailable.EOL if updates_available: @@ -512,6 +545,8 @@ def from_features( @property def color(self): + if self is UpdatesAvailable.PROHIBITED: + return label_color_theme("red") if self is UpdatesAvailable.YES: return label_color_theme("green") if self is UpdatesAvailable.MAYBE: @@ -522,7 +557,9 @@ def color(self): return label_color_theme("red") def __str__(self): - if self is UpdatesAvailable.YES: + if self is UpdatesAvailable.PROHIBITED: + name = "START PROHIBITED" + elif self is UpdatesAvailable.YES: name = "YES" elif self is UpdatesAvailable.MAYBE: name = "MAYBE" diff --git a/qui/updater/tests/conftest.py b/qui/updater/tests/conftest.py index 1db8787a..79951d3a 100644 --- a/qui/updater/tests/conftest.py +++ b/qui/updater/tests/conftest.py @@ -208,6 +208,7 @@ def test_qapp_impl(): ) add_feature_to_all(qapp, "os-eol", []) add_feature_to_all(qapp, "skip-update", []) + add_feature_to_all(qapp, "prohibit-start", []) return qapp diff --git a/qui/updater/tests/test_intro_page.py b/qui/updater/tests/test_intro_page.py index 06dd37c0..fa4d97d9 100644 --- a/qui/updater/tests/test_intro_page.py +++ b/qui/updater/tests/test_intro_page.py @@ -172,6 +172,123 @@ def test_on_checkbox_toggled( assert not sut.checkbox_column_button.get_active() +def test_prohibit_start( + real_builder, test_qapp, mock_next_button, mock_settings, mock_list_store +): + mock_log = Mock() + test_qapp.expected_calls[ + ("test-standalone", "admin.vm.feature.Get", "prohibit-start", None) + ] = b"0\x00Control qube which should be un-selectable/un-updatable" + sut = IntroPage(real_builder, mock_log, mock_next_button) + + # populate_vm_list + sut.list_store = ListWrapper(UpdateRowWrapper, mock_list_store) + for vm in test_qapp.domains: + sut.list_store.append_vm(vm) + + assert len(sut.list_store) == 12 + + sut.head_checkbox.state = HeaderCheckbox.NONE + sut.head_checkbox.set_buttons() + + # If button is inconsistent we do not care if it is active or not + # (we do not use this value) + + # no selected row + assert not sut.checkbox_column_button.get_inconsistent() + assert not sut.checkbox_column_button.get_active() + + # only one row selected + sut.on_checkbox_toggled(_emitter=None, path=(3,)) + + assert sut.checkbox_column_button.get_inconsistent() + + for i in range(len(sut.list_store)): + sut.on_checkbox_toggled(_emitter=None, path=(i,)) + + # almost all rows selected (except one) + assert sut.checkbox_column_button.get_inconsistent() + + sut.on_checkbox_toggled(_emitter=None, path=(3,)) + + # almost all rows selected (except the start prohibited qube) + assert sut.checkbox_column_button.get_inconsistent() + assert not sut.checkbox_column_button.get_active() + + sut.on_checkbox_toggled(_emitter=None, path=(3,)) + + # almost all rows selected (except one) + assert sut.checkbox_column_button.get_inconsistent() + + for i in range(len(sut.list_store)): + if i == 3: + continue + sut.on_checkbox_toggled(_emitter=None, path=(i,)) + + # no selected row + assert not sut.checkbox_column_button.get_inconsistent() + assert not sut.checkbox_column_button.get_active() + + # emulate mouse hover over vm_list header & area between rows + side_effects = [False, True, (None, 1, 1, sut.list_store, False, None)] + sut.vm_list.get_tooltip_context = Mock(side_effect=side_effects) + sut.on_query_tooltip(sut.vm_list, 0, 0, None, None) + sut.on_query_tooltip(sut.vm_list, 1, 1, None, None) + + +def test_prohibit_start_rationale_tooltip( + real_builder, test_qapp, mock_next_button, mock_settings, mock_list_store +): + mock_log = Mock() + sut = IntroPage(real_builder, mock_log, mock_next_button) + + # emulate mouse hover over an ordinary updateable qube + side_effects = [ + True, + [ + None, + 2, + 2, + [[0, 1, 2, 3, UpdatesAvailable.YES, 5, 6, 7, 8, ""]], + True, + 0, + ], + ] + sut.vm_list.get_tooltip_context = Mock(side_effect=side_effects) + sut.on_query_tooltip(sut.vm_list, 2, 2, None, None) + + # emulate mouse hover over an start prohibited qube + side_effects = [ + True, + [ + None, + 2, + 2, + [ + [ + 0, + 1, + 2, + 3, + UpdatesAvailable.PROHIBITED, + 5, + 6, + 7, + 8, + "DO NOT UPDATE", + ], + ], + True, + 0, + ], + ] + sut.vm_list.get_tooltip_context = Mock(side_effect=side_effects) + sut.vm_list.set_tooltip_cell = Mock() + mock_tooltip = Mock() + sut.on_query_tooltip(sut.vm_list, 3, 3, None, mock_tooltip) + assert sut.vm_list.set_tooltip_cell.called + + doms = test_qapp_impl().domains _domains = {vm.name for vm in doms} _templates = {vm.name for vm in doms if vm.klass == "TemplateVM"} diff --git a/qui/updater/updater.py b/qui/updater/updater.py index d557d47d..b4c1b3e4 100644 --- a/qui/updater/updater.py +++ b/qui/updater/updater.py @@ -232,6 +232,7 @@ def cell_data_func(_column, cell, model, it, data): int(width * 1.2), self.main_window.get_screen().get_height() - 48 ) self.main_window.resize(width + 50, height) + self.main_window.set_size_request(800, 600) # Smaller is meaningless self.main_window.set_position(Gtk.WindowPosition.CENTER_ALWAYS) def open_settings_window(self, _emitter): diff --git a/qui/updater/utils.py b/qui/updater/utils.py index d45e1fe7..da5bdd52 100644 --- a/qui/updater/utils.py +++ b/qui/updater/utils.py @@ -307,9 +307,11 @@ def append_vm(self, vm, state: bool = False): def invert_selection(self, path): it = self.list_store_raw.get_iter(path) - self.list_store_raw[it][0].selected = not self.list_store_raw[it][ - 0 - ].selected + UpdatesAvailable = self.list_store_raw[it][4] + if "PROHIBITED" not in str(UpdatesAvailable): + self.list_store_raw[it][0].selected = not self.list_store_raw[it][ + 0 + ].selected def get_selected(self) -> "ListWrapper": empty_copy = Gtk.ListStore(