From 637836b1f3b322a13354c4492b92d58ec9264b9e Mon Sep 17 00:00:00 2001 From: Piotr Bartman Date: Wed, 8 May 2024 18:50:45 +0200 Subject: [PATCH 1/8] updater: return 100 if no vm chosen --- vmupdate/vmupdate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index 7ba10a3..eeb6ee5 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -53,6 +53,11 @@ def main(args=None, app=qubesadmin.Qubes()): log.error(str(err)) return 128 + if not targets: + if not args.quiet: + print("No qube selected for update") + return 100 + independent = [target for target in targets if target.klass in ( 'TemplateVM', 'StandaloneVM')] derived = [target for target in targets if target.klass not in ( From 21e7e22325e51bbd864ccf5165ff46b3f71f6d6b Mon Sep 17 00:00:00 2001 From: Piotr Bartman-Szwarc Date: Mon, 27 May 2024 10:16:23 +0200 Subject: [PATCH 2/8] updater: unify cli options with gui updater --- vmupdate/vmupdate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index eeb6ee5..a25d4e8 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -92,6 +92,9 @@ def parse_args(args): '--apply-to-all', '-R', action='store_true', help='Restart Service VMs and shutdown AppVMs whose template ' 'has been updated.') + restart.add_argument( + '--no-apply', action='store_true', + help='Do not restart/shutdown any AppVMs.') parser.add_argument('--no-cleanup', action='store_true', help='Do not remove updater files from target qube') From b643024a18e623d496a73e594d0d618ec5e18426 Mon Sep 17 00:00:00 2001 From: Piotr Bartman-Szwarc Date: Mon, 27 May 2024 11:00:26 +0200 Subject: [PATCH 3/8] updater: return 100 if no vm was updated --- vmupdate/update_manager.py | 2 ++ vmupdate/vmupdate.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/vmupdate/update_manager.py b/vmupdate/update_manager.py index bb01795..7a90a1d 100644 --- a/vmupdate/update_manager.py +++ b/vmupdate/update_manager.py @@ -94,6 +94,8 @@ def run(self, agent_args): self.log.info("Update Manager: Finished, collecting success info") stats = list(progress_bar.statuses.values()) + if FinalStatus.CANCELLED in stats: + self.ret_code = max(self.ret_code, 130) if FinalStatus.ERROR in stats: self.ret_code = max(self.ret_code, 5) if FinalStatus.UNKNOWN in stats: diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index a25d4e8..710b0ab 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -66,12 +66,18 @@ def main(args=None, app=qubesadmin.Qubes()): # independent qubes first (TemplateVMs, StandaloneVMs) ret_code_independent, templ_statuses = run_update( independent, args, "templates and stanalones") + no_updates = all(stat == FinalStatus.NO_UPDATES for stat in templ_statuses) # then derived qubes (AppVMs...) - ret_code_appvm, _ = run_update(derived, args) + ret_code_appvm, app_statuses = run_update(derived, args) + no_updates = all(stat == FinalStatus.NO_UPDATES for stat in app_statuses + ) and no_updates ret_code_restart = apply_updates_to_appvm(args, independent, templ_statuses) - return max(ret_code_independent, ret_code_appvm, ret_code_restart) + ret_code = max(ret_code_independent, ret_code_appvm, ret_code_restart) + if ret_code == 0 and no_updates: + return 100 + return ret_code def parse_args(args): From 1718936f40bc8f2e7554a61ac5fc2ea8db544f6e Mon Sep 17 00:00:00 2001 From: Piotr Bartman-Szwarc Date: Tue, 28 May 2024 02:10:44 +0200 Subject: [PATCH 4/8] updater: add some tests --- vmupdate/tests/conftest.py | 266 ++++++++++++++++++++++++++ vmupdate/tests/test_vmupdate.py | 324 ++++++++++++++++++++++++++++++++ vmupdate/update_manager.py | 5 +- vmupdate/vmupdate.py | 227 ++++++++++++---------- 4 files changed, 724 insertions(+), 98 deletions(-) create mode 100644 vmupdate/tests/conftest.py create mode 100644 vmupdate/tests/test_vmupdate.py diff --git a/vmupdate/tests/conftest.py b/vmupdate/tests/conftest.py new file mode 100644 index 0000000..48d2aaf --- /dev/null +++ b/vmupdate/tests/conftest.py @@ -0,0 +1,266 @@ +# coding=utf-8 +# +# The Qubes OS Project, https://www.qubes-os.org +# +# Copyright (C) 2024 Piotr Bartman-Szwarc +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# 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 General Public License for more details. +# +# You should have received a copy of the GNU 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. +import itertools +import queue +from unittest.mock import Mock + +import pytest + +from vmupdate.agent.source.common.process_result import ProcessResult +from vmupdate.agent.source.status import StatusInfo, FinalStatus + + +class TestApp: + class Domains(dict): + def __iter__(self): + return iter(self.values()) + + def __init__(self): + self.domains = TestApp.Domains() + + +class TestVM: + def __init__(self, name, app, klass, template=None, **kwargs): + self.name = name + self.app = app + self.app.domains[name] = self + self.klass = klass + self.running = True + if self.klass in ('AppVM', 'DispVM'): + template.derived_vms.append(self) + self.derived_vms = [] + self.auto_cleanup = False + self.features = Features(name, app) + self.shutdown = Mock() + self.start = Mock() + for key, value in kwargs.items(): + setattr(self, key, value) + + def is_running(self): + return self.running + + def __str__(self): + return self.name + + def __lt__(self, other): + if isinstance(other, TestVM): + return self.name < other.name + return NotImplemented + + +class Features(dict): + def __init__(self, qname, app, *args, **kwargs): + super().__init__(*args, **kwargs) + self.qname = qname + self.app = app + + def check_with_template(self, key, default=None): + if key in self: + return self[key] + for vm in self.app.domains: + if self.qname in vm.derived_vms: + return vm.features.get(key, default) + return default + + +class MPManager: + class Value: + def __init__(self, _type, value): + self.value = value + + class Queue: + def __init__(self): + self._queue = [] + + def get(self, block): + if not self._queue: + raise queue.Empty + return self._queue.pop(0) + + def put(self, obj): + self._queue.append(obj) + + +@pytest.fixture() +def test_manager(): + return MPManager() + + +class MPPool(Mock): + def apply_async(self, func, args, **_kwargs): + func(*args) + + +@pytest.fixture() +def test_pool(): + return MPPool() + + +@pytest.fixture +def test_qapp(): + app = TestApp() + return app + + +@pytest.fixture() +def test_agent(): + def closure(results, unexpected): + class UpdateAgentManager: + def __init__(self, app, qube, agent_args, show_progress): + self.qube = qube + + def run_agent(self, agent_args, status_notifier, termination): + if self.qube.name not in results: + status_notifier.put( + StatusInfo.done(self.qube, FinalStatus.UNKNOWN)) + unexpected.append(self.qube.name) + return ProcessResult(code=99) + for status in results[self.qube.name]["statuses"]: + status_notifier.put( + StatusInfo.done(self.qube, status)) + result = ProcessResult(code=results[self.qube.name]["retcode"]) + del results[self.qube.name] + return result + + return UpdateAgentManager + + return closure + + +def generate_vm_variations(app, variations): + """ + Generate all possible variations of vms for the given list of features. + """ + dom0 = TestVM("dom0", app, klass="AdminVM", updateable=True, running=True, + update_result=FinalStatus.UNKNOWN, + features=Features("dom0", app, {'updates-available': True})) + domains = { + "klass": {"TemplateVM": set(), "StandaloneVM": set(), "AppVM": set(), + "DispVM": set()}, + "is_running": {False: set(), True: set()}, + "servicevm": {False: set(), True: set()}, + "auto_cleanup": {False: set(), True: set()}, + "updatable": {True: set(), False: set()}, + "updates_available": {False: set(), True: set()}, + "last_updates_check": {None: set(), '2020-01-01 00:00:00': set(), + '3020-01-01 00:00:00': set()}, + "qrexec": {False: set(), True: set()}, + "os": {'Linux': set(), 'BSD': set()}, + "updated": {FinalStatus.UNKNOWN: set(), FinalStatus.SUCCESS: set(), + FinalStatus.NO_UPDATES: set(), FinalStatus.ERROR: set(), + FinalStatus.CANCELLED: set(), + }, + "has_template_updated": { + FinalStatus.SUCCESS: set(), FinalStatus.NO_UPDATES: set(), + FinalStatus.ERROR: set(), FinalStatus.CANCELLED: set(), + FinalStatus.UNKNOWN: set()}, + } + + klasses = list(reversed(sorted(list(domains['klass'].keys())))) + rest = [list(domains[key].keys()) + if key in variations else list(domains[key].keys())[:1] + for key in domains.keys() if key != "klass"] + for k in klasses: + for (running, servicevm, auto_cleanup, updatable, updates_available, + last_check, qrexec, os, updated, template_updated + ) in itertools.product(*rest): + + if not updatable and (updates_available or last_check): + # do not consider features about updates for non-updatable vms + continue + if auto_cleanup and k != "DispVM": + # `auto_cleanup` is applicable only to DispVM + continue + if (os or qrexec) and updates_available: + # if `updates_available` we never use qrexec or check os + continue + if updated != FinalStatus.UNKNOWN and k not in ("DispVM", "AppVM"): + # result of updating for templates and standalones bases on + # `template_updated` + continue + + lc_enc = {None: '0', '2020-01-01 00:00:00': '1', + '3020-01-01 00:00:00': '2'} + os_enc = {'Linux': '0', 'BSD': '1'} + f_map = {FinalStatus.SUCCESS: "0", FinalStatus.ERROR: "1", + FinalStatus.CANCELLED: "2", FinalStatus.NO_UPDATES: "3", + FinalStatus.UNKNOWN: "4"} + txt = lambda x: str(int(x)) + suffix = (txt(running) + txt(servicevm) + lc_enc[last_check] + + txt(updates_available) + txt(qrexec) + os_enc[os] + + txt(updatable) + txt(auto_cleanup)) + if k in ('DispVM', 'AppVM'): + template = app.domains[ + 'T' + f_map[template_updated] + "4" + suffix[:-1] + "0"] + ext_suffix = f_map[updated] + f_map[template_updated] + suffix + update_result = updated + else: + template = None + ext_suffix = f_map[template_updated] + "4" + suffix + update_result = template_updated + + features = {} + if servicevm: + features['servicevm'] = True + if updates_available: + features['updates-available'] = True + if last_check: + features['last-updates-check'] = last_check + if qrexec: + features['qrexec'] = qrexec + if os: + features['os'] = os + + vm = TestVM( + k[0] + ext_suffix, app, klass=k, updateable=updatable, + running=running, auto_cleanup=auto_cleanup, template=template, + features=Features("dom0", app, features), + update_result=update_result) + + domains["klass"][k].add(vm) + domains["is_running"][running].add(vm) + domains["servicevm"][servicevm].add(vm) + domains["auto_cleanup"][auto_cleanup].add(vm) + domains["updatable"][updatable].add(vm) + domains["updates_available"][updates_available].add(vm) + domains["last_updates_check"][last_check].add(vm) + domains["qrexec"][qrexec].add(vm) + domains["os"][os].add(vm) + if k in ('DispVM', 'AppVM'): + domains["updated"][updated].add(vm) + domains["has_template_updated"][template_updated].add(vm) + else: + domains["updated"][template_updated].add(vm) + domains["has_template_updated"][updated].add(vm) + + domains["klass"]["AdminVM"] = {dom0} + dom_prop = { + "is_running": True, "servicevm": False, "auto_cleanup": False, + "updatable": True, "updates_available": True, + "last_updates_check": None, "updated": FinalStatus.UNKNOWN, + "has_template_updated": FinalStatus.UNKNOWN} + for key, subkey in dom_prop.items(): + try: + domains[key][subkey].add(dom0) + except KeyError: + pass + + return domains diff --git a/vmupdate/tests/test_vmupdate.py b/vmupdate/tests/test_vmupdate.py new file mode 100644 index 0000000..6b8ab53 --- /dev/null +++ b/vmupdate/tests/test_vmupdate.py @@ -0,0 +1,324 @@ +# coding=utf-8 +# +# The Qubes OS Project, https://www.qubes-os.org +# +# Copyright (C) 2024 Piotr Bartman-Szwarc +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# 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 General Public License for more details. +# +# You should have received a copy of the GNU 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. +import itertools + +from unittest.mock import patch + +from vmupdate.tests.conftest import generate_vm_variations +from vmupdate.agent.source.status import FinalStatus +from vmupdate.vmupdate import main +from vmupdate import vmupdate + + +@patch('os.chmod') +@patch('os.chown') +@patch('logging.FileHandler') +@patch('logging.getLogger') +def test_no_options_do_nothing(_logger, _log_file, _chmod, _chown, test_qapp): + args = [] + test_qapp.domains = () + assert main(args, test_qapp) == 100 + + +@patch('vmupdate.update_manager.TerminalMultiBar.print') +@patch('os.chmod') +@patch('os.chown') +@patch('logging.FileHandler') +@patch('logging.getLogger') +@patch('vmupdate.update_manager.UpdateAgentManager') +@patch('multiprocessing.Pool') +@patch('multiprocessing.Manager') +def test_preselection( + mp_manager, mp_pool, agent_mng, + _logger, _log_file, _chmod, _chown, _print, + test_qapp, test_manager, test_pool, test_agent, +): + mp_manager.return_value = test_manager + mp_pool.return_value = test_pool + + domains = generate_vm_variations( + test_qapp, ["klass", "updatable", "is_running"]) + + updatable = domains["updatable"][True] + is_running = domains["is_running"][True] + admin = domains["klass"]["AdminVM"] + templ = domains["klass"]["TemplateVM"] + stand = domains["klass"]["StandaloneVM"] + app = domains["klass"]["AppVM"] + disp = domains["klass"]["DispVM"] + run_up_app = updatable & app & is_running + default = updatable & ((templ | stand) | (is_running & (disp | app))) + + AdminVM = next(iter(admin)) + TemplVM = next(iter(templ)) + StandVM = next(iter(stand)) + UpStandVM = next(iter(updatable & stand)) + NUpStandVM = next(iter(domains["updatable"][False] & stand)) + RunUpAppVM = next(iter(updatable & app & is_running)) + RunNUpAppVM = next(iter(domains["updatable"][False] & is_running & app)) + NRunAppVM = next(iter(domains["is_running"][False] & app)) + + expected = { + (): default, + ("--all",): default, + ("--all", "--apps",): default, + ("--all", "--templates",): default, + ("--all", "--standalones",): default, + ("--all", "--skip", UpStandVM.name,): default - {UpStandVM}, + ("--all", "--targets", UpStandVM.name,): default, + ("--all", "--targets", RunNUpAppVM.name,): default | {RunNUpAppVM}, + ("--all", "--targets", NRunAppVM.name,): default | {NRunAppVM}, + ("--all", "--targets", NUpStandVM.name,): default | {NUpStandVM}, + ("--apps",): updatable & app & is_running, + ("--templates",): updatable & templ, + ("--standalones",): updatable & stand, + ("--templates", "--apps",): updatable & (templ | (app & is_running)), + ("--templates", "--standalones",): updatable & (templ | stand), + ("--templates", "--standalones", "--apps",): + updatable & (templ | stand | (app & is_running)), + ("--standalones", "--skip", StandVM.name,): + (updatable & stand) - {StandVM}, + ("--standalones", "--skip", TemplVM.name,): (updatable & stand), + ("--standalones", "--targets", UpStandVM.name,): (updatable & stand), + ("--standalones", "--targets", NUpStandVM.name,): + (updatable & stand) | {NUpStandVM}, + ("--standalones", "--targets", TemplVM.name,): + (updatable & stand) | {TemplVM}, + ("--apps", "--skip", RunUpAppVM.name,): run_up_app - {RunUpAppVM}, + ("--apps", "--skip", RunNUpAppVM.name,): run_up_app, + ("--apps", "--skip", NRunAppVM.name,): run_up_app, + ("--apps", "--skip", StandVM.name,): run_up_app, + ("--apps", "--targets", RunUpAppVM.name,): + (updatable & app & is_running), + ("--apps", "--targets", RunNUpAppVM.name,): + (updatable & app & is_running) | {RunNUpAppVM}, + ("--apps", "--targets", NRunAppVM.name,): + (updatable & app & is_running) | {NRunAppVM}, + ("--apps", "--targets", TemplVM.name,): + (updatable & app & is_running) | {TemplVM}, + ("--targets", RunUpAppVM.name,): {RunUpAppVM}, + ("--targets", RunNUpAppVM.name,): {RunNUpAppVM}, + ("--targets", NRunAppVM.name,): {NRunAppVM}, + ("--targets", StandVM.name,): {StandVM}, + ("--targets", AdminVM.name,): 100, # dom0 skipped, user warning + ("--targets", "unknown",): 128, + ("--targets", f"{TemplVM.name},{StandVM.name}",): {TemplVM, StandVM}, + ("--targets", f"{TemplVM.name},{TemplVM.name}",): 128, + ("--targets", TemplVM.name, "--skip", TemplVM.name,): {}, + ("--targets", f"{TemplVM.name},{StandVM.name}", "--skip", TemplVM.name,): {StandVM}, + } + + failed = {} + for args, selected in expected.items(): + if isinstance(selected, int): + feed = {} + expected_exit = selected + else: + feed = {vm.name: {'statuses': [FinalStatus.SUCCESS], 'retcode': 0} + for vm in selected} + if feed: + expected_exit = 0 + else: + expected_exit = 100 + + unexpected = [] + agent_mng.side_effect = test_agent(feed, unexpected) + retcode = main(("--force-update", "--just-print-progress", *args), test_qapp) + + failed[args] = {} + if retcode != expected_exit: + failed[args]["unexpected exit code"] = retcode + failed[args]["unexpected vm"] = unexpected + failed[args]["leftover feed"] = feed + failed[args] = {key: value + for key, value in failed[args].items() if value} + + fails = {args: failed[args] for args in failed if failed[args]} + assert not fails + + +@patch('vmupdate.update_manager.TerminalMultiBar.print') +@patch('os.chmod') +@patch('os.chown') +@patch('logging.FileHandler') +@patch('logging.getLogger') +@patch('vmupdate.update_manager.UpdateAgentManager') +@patch('multiprocessing.Pool') +@patch('multiprocessing.Manager') +def test_selection( + mp_manager, mp_pool, agent_mng, + _logger, _log_file, _chmod, _chown, _print, + test_qapp, test_manager, test_pool, test_agent, + monkeypatch +): + mp_manager.return_value = test_manager + mp_pool.return_value = test_pool + + domains = generate_vm_variations( + test_qapp, + ["klass", "updates_available", "last_updates_check", "qrexec", "os"]) + + all = domains["updatable"][True] + qlinux = domains["qrexec"][True] & domains["os"]["Linux"] + to_update = domains["updates_available"][True] + stale = qlinux & (domains["updates_available"][False] & + (domains["last_updates_check"][None] | + domains["last_updates_check"]['2020-01-01 00:00:00'])) + + expected = { + ("--force-update",): all, + (): to_update | stale, + ("--update-if-stale", "0"): to_update | stale, + ("--update-if-stale", "1"): to_update | stale, + ("--update-if-stale", "7"): to_update | stale, + ("--update-if-stale", "365"): to_update | stale, + ("--update-if-available",): to_update, + } + + failed = {} + for args, selected in expected.items(): + if isinstance(selected, int): + feed = {} + expected_exit = selected + monkeypatch.setattr( + vmupdate, "preselect_targets", lambda *_: all) + else: + feed = {vm.name: {'statuses': [FinalStatus.SUCCESS], 'retcode': 0} + for vm in selected} + monkeypatch.setattr( + vmupdate, "preselect_targets", lambda *_: selected) + if feed: + expected_exit = 0 + else: + expected_exit = 100 + + unexpected = [] + agent_mng.side_effect = test_agent(feed, unexpected) + retcode = main(("--just-print-progress", *args), test_qapp) + + failed[args] = {} + if retcode != expected_exit: + failed[args]["unexpected exit code"] = retcode + failed[args]["unexpected vm"] = unexpected + failed[args]["leftover feed"] = feed + failed[args] = {key: value + for key, value in failed[args].items() if value} + + fails = {args: failed[args] for args in failed if failed[args]} + assert not fails + + +@patch('vmupdate.update_manager.TerminalMultiBar.print') +@patch('os.chmod') +@patch('os.chown') +@patch('logging.FileHandler') +@patch('logging.getLogger') +@patch('vmupdate.update_manager.UpdateAgentManager') +@patch('multiprocessing.Pool') +@patch('multiprocessing.Manager') +@patch('asyncio.run') +def test_restarting( + arun, mp_manager, mp_pool, agent_mng, + _logger, _log_file, _chmod, _chown, _print, + test_qapp, test_manager, test_pool, test_agent, + monkeypatch +): + mp_manager.return_value = test_manager + mp_pool.return_value = test_pool + + domains = generate_vm_variations( + test_qapp, + ["klass", "is_running", "servicevm", "auto_cleanup", + "updated", "has_template_updated"]) + + all = domains["updatable"][True] + service = domains["servicevm"][True] + disp = domains["klass"]["DispVM"] + app = domains["klass"]["AppVM"] + templ = domains["klass"]["TemplateVM"] + derived = disp | app + auto_cleanup = domains["auto_cleanup"][True] + updated = domains["updated"][FinalStatus.SUCCESS] + not_updated = all - domains["updated"][FinalStatus.SUCCESS] + running = domains["is_running"][True] + template_updated = domains["has_template_updated"][FinalStatus.SUCCESS] + applicable = (derived & not_updated & running & template_updated + ) - (auto_cleanup & disp) + + expected = { + (): {"halted": set(), + "restarted": set(), + "untouched": all}, + ("--no-apply",): { + "halted": set(), + "restarted": set(), + "untouched": all}, + ("--apply-to-sys",): { + "halted": updated & running & templ, + "restarted": applicable & service, + "untouched": all - (updated & running & templ) - (applicable & service)}, + ("--apply-to-all",): { + "halted": (updated & running & templ) | (applicable - service), + "restarted": applicable & service, + "untouched": all - (updated & running & templ) - applicable}, + } + + failed = {} + for args, selected in expected.items(): + monkeypatch.setattr(vmupdate, "get_targets", lambda *_: all) + feed = {vm.name: {'statuses': [vm.update_result], + 'retcode': None} # we don't care + for vm in all} + + unexpected = [] + agent_mng.side_effect = test_agent(feed, unexpected) + main(("--just-print-progress", *args), test_qapp) + + failed[args] = {} + + failed[args]["unexpected vm"] = unexpected + failed[args]["leftover feed"] = feed + + halted = {vm for vm in all + if vm.shutdown.called and not vm.start.called} + restarted = {vm for vm in all + if vm.shutdown.called and vm.start.called} + untouched = {vm for vm in all + if not vm.shutdown.called and not vm.start.called} + failed[args]["unexpected restart"] = set(map( + lambda vm: vm.name, restarted - selected["restarted"])) + failed[args]["not restarted"] = set(map( + lambda vm: vm.name, selected["restarted"] - restarted)) + failed[args]["unexpected shutdown"] = set(map( + lambda vm: vm.name, halted - selected["halted"])) + failed[args]["not halted"] = set(map( + lambda vm: vm.name, selected["halted"] - halted)) + failed[args]["unexpected untouched"] = set(map( + lambda vm: vm.name, untouched - selected["untouched"])) + failed[args]["unexpected touched"] = set(map( + lambda vm: vm.name, selected["untouched"] - untouched)) + + failed[args] = {key: value + for key, value in failed[args].items() if value} + + fails = {args: failed[args] for args in failed if failed[args]} + assert not fails + arun.asseert_called() diff --git a/vmupdate/update_manager.py b/vmupdate/update_manager.py index 7a90a1d..e10acbd 100644 --- a/vmupdate/update_manager.py +++ b/vmupdate/update_manager.py @@ -32,8 +32,6 @@ from tqdm import tqdm -import qubesadmin.vm -import qubesadmin.exc from .agent.source.status import StatusInfo, FinalStatus, Status from .qube_connection import QubeConnection from vmupdate.agent.source.log_congfig import init_logs @@ -65,7 +63,7 @@ def run(self, agent_args): self.log.info("Update Manager: New batch of qubes to update") if not self.qubes: self.log.info("Update Manager: No qubes to update, quiting.") - return 0, {q.name: FinalStatus.SUCCESS for q in self.qubes} + return 0, {} show_progress = not self.quiet and not self.no_progress SimpleTerminalBar.reinit_class() @@ -301,6 +299,7 @@ def update_qube( termination=termination ) except Exception as exc: # pylint: disable=broad-except + status_notifier.put(StatusInfo.done(qube, FinalStatus.ERROR)) return qube.name, ProcessResult(1, f"ERROR (exception {str(exc)})") return qube.name, result diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index 710b0ab..c3b0b72 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -18,16 +18,9 @@ from . import update_manager from .agent.source.args import AgentArgs - LOGPATH = '/var/log/qubes/qubes-vm-update.log' LOG_FORMAT = '%(asctime)s %(message)s' -log_handler = logging.FileHandler(LOGPATH, encoding='utf-8') -log_formatter = logging.Formatter(LOG_FORMAT) -log_handler.setFormatter(log_formatter) - -log = logging.getLogger('vm-update') - class ArgumentError(Exception): """Nonsense arguments @@ -37,6 +30,11 @@ class ArgumentError(Exception): def main(args=None, app=qubesadmin.Qubes()): args = parse_args(args) + log_handler = logging.FileHandler(LOGPATH, encoding='utf-8') + log_formatter = logging.Formatter(LOG_FORMAT) + log_handler.setFormatter(log_formatter) + + log = logging.getLogger('vm-update') log.setLevel(args.log) log.addHandler(log_handler) try: @@ -44,7 +42,7 @@ def main(args=None, app=qubesadmin.Qubes()): os.chown(LOGPATH, -1, gid) os.chmod(LOGPATH, 0o664) except (PermissionError, KeyError): - # do it on best effort basis + # do it on the best effort basis pass try: @@ -65,14 +63,15 @@ def main(args=None, app=qubesadmin.Qubes()): # independent qubes first (TemplateVMs, StandaloneVMs) ret_code_independent, templ_statuses = run_update( - independent, args, "templates and stanalones") + independent, args, log, "templates and standalones") no_updates = all(stat == FinalStatus.NO_UPDATES for stat in templ_statuses) # then derived qubes (AppVMs...) - ret_code_appvm, app_statuses = run_update(derived, args) + ret_code_appvm, app_statuses = run_update(derived, args, log) no_updates = all(stat == FinalStatus.NO_UPDATES for stat in app_statuses ) and no_updates - ret_code_restart = apply_updates_to_appvm(args, independent, templ_statuses) + ret_code_restart = apply_updates_to_appvm( + args, independent, templ_statuses, app_statuses, log) ret_code = max(ret_code_independent, ret_code_appvm, ret_code_restart) if ret_code == 0 and no_updates: @@ -88,52 +87,60 @@ def parse_args(args): help='Maximum number of VMs configured simultaneously ' '(default: number of cpus)', type=int) + parser.add_argument('--no-cleanup', action='store_true', + help='Do not remove updater files from target qube') + parser.add_argument('--dry-run', action='store_true', + help='Just print what happens.') restart = parser.add_mutually_exclusive_group() restart.add_argument( - '--restart', '--apply-to-sys', '-r', + '--apply-to-sys', '--restart', '-r', action='store_true', - help='Restart Service VMs whose template has been updated.') + help='Restart not updated ServiceVMs whose template has been updated.') restart.add_argument( '--apply-to-all', '-R', action='store_true', - help='Restart Service VMs and shutdown AppVMs whose template ' - 'has been updated.') + help='Restart not updated ServiceVMs and shutdown not updated AppVMs ' + 'whose template has been updated.') restart.add_argument( '--no-apply', action='store_true', - help='Do not restart/shutdown any AppVMs.') - - parser.add_argument('--no-cleanup', action='store_true', - help='Do not remove updater files from target qube') - - targets = parser.add_mutually_exclusive_group() - targets.add_argument('--targets', action='store', - help='Comma separated list of VMs to target') - targets.add_argument('--all', action='store_true', - help='Target all non-disposable VMs (TemplateVMs and ' - 'AppVMs)') - targets.add_argument( + help='DEFAULT. Do not restart/shutdown any AppVMs.') + + update_state = parser.add_mutually_exclusive_group() + update_state.add_argument( + '--force-update', action='store_true', + help='Attempt to update all targeted VMs ' + 'even if no updates are available') + update_state.add_argument( '--update-if-stale', action='store', help='DEFAULT. ' - 'Target all TemplateVMs with known updates or for ' - 'which last update check was more than N days ' - 'ago. (default: %(default)d)', - type=int, default=7) - - parser.add_argument('--skip', action='store', - help='Comma separated list of VMs to be skipped, ' - 'works with all other options.', default="") - parser.add_argument('--templates', '-T', - action='store_true', - help='Target all TemplatesVMs') - parser.add_argument('--standalones', '-S', - action='store_true', - help='Target all StandaloneVMs') - parser.add_argument('--app', '-A', - action='store_true', - help='Target all AppVMs') - - parser.add_argument('--dry-run', action='store_true', - help='Just print what happens.') + 'Attempt to update targeted VMs with known updates available ' + 'or for which last update check was more than N days ago. ' + '(default: %(default)d)', + type=int, default=7, choices=range(0, 366)) + update_state.add_argument( + '--update-if-available', action='store_true', + help='Update targeted VMs with known updates available.') + + parser.add_argument( + '--skip', action='store', + help='Comma separated list of VMs to be skipped, ' + 'works with all other options.', default="") + parser.add_argument( + '--targets', action='store', + help='Comma separated list of VMs to target. Ignores conditions.') + parser.add_argument( + '--templates', '-T', action='store_true', + help='Target all updatable TemplateVMs.') + parser.add_argument( + '--standalones', '-S', action='store_true', + help='Target all updatable StandaloneVMs.') + parser.add_argument( + '--apps', '-A', action='store_true', + help='Target running updatable AppVMs to update in place.') + parser.add_argument( + '--all', action='store_true', + help='DEFAULT. Target all updatable VMs except AdminVM. ' + 'Use explicitly with "--targets" to include both.') AgentArgs.add_arguments(parser) args = parser.parse_args(args) @@ -142,33 +149,45 @@ def parse_args(args): def get_targets(args, app) -> Set[qubesadmin.vm.QubesVM]: + preselected_targets = preselect_targets(args, app) + selected_targets = select_targets(preselected_targets, args) + return selected_targets + + +def preselect_targets(args, app) -> Set[qubesadmin.vm.QubesVM]: targets = set() - if args.templates: - targets.update([vm for vm in app.domains.values() - if vm.klass == 'TemplateVM']) - if args.standalones: - targets.update([vm for vm in app.domains.values() - if vm.klass == 'StandaloneVM']) - if args.app: - targets.update([vm for vm in app.domains.values() - if vm.klass == 'AppVM']) - if args.all: - # all but DispVMs - targets.update([vm for vm in app.domains.values() - if vm.klass != 'DispVM']) - elif args.targets: + updatable = {vm for vm in app.domains if getattr(vm, 'updateable', False)} + default_targeting = (not args.templates and not args.standalones and + not args.apps and not args.targets) + if args.all or default_targeting: + # filter out stopped AppVMs and DispVMs (?) + targets = {vm for vm in updatable + if vm.klass not in ("AppVM", "DispVM") or vm.is_running()} + else: + # if not all updatable are included, target a specific classes + if args.templates: + targets.update([vm for vm in updatable + if vm.klass == 'TemplateVM']) + if args.standalones: + targets.update([vm for vm in updatable + if vm.klass == 'StandaloneVM']) + if args.apps: + targets.update({vm for vm in updatable + if vm.klass == 'AppVM' and vm.is_running()}) + + # user can target non-updatable vm if she like + if args.targets: names = args.targets.split(',') - targets = {vm for vm in app.domains.values() if vm.name in names} - if len(names) != len(targets): - target_names = {q.name for q in targets} + explicit_targets = {vm for vm in app.domains if vm.name in names} + if len(names) != len(explicit_targets): + target_names = {q.name for q in explicit_targets} unknowns = set(names) - target_names plural = len(unknowns) != 1 raise ArgumentError( f"Unknown qube name{'s' if plural else ''}" f": {', '.join(unknowns) if plural else ''.join(unknowns)}" ) - else: - targets.update(smart_targeting(app, args)) + targets.update(explicit_targets) # remove skipped qubes and dom0 - not a target to_skip = args.skip.split(',') @@ -179,25 +198,35 @@ def get_targets(args, app) -> Set[qubesadmin.vm.QubesVM]: return targets -def smart_targeting(app, args) -> Set[qubesadmin.vm.QubesVM]: - targets = set() - for vm in app.domains: - if getattr(vm, 'updateable', False) and vm.klass != 'AdminVM': - try: - to_update = vm.features.get('updates-available', False) - except qubesadmin.exc.QubesDaemonCommunicationError: - to_update = False +def select_targets(targets, args) -> Set[qubesadmin.vm.QubesVM]: + # try to update all preselected targets + if args.force_update: + return targets + + selected = set() + for vm in targets: + try: + to_update = vm.features.get('updates-available', False) + except qubesadmin.exc.QubesDaemonCommunicationError: + to_update = False - if not to_update: - to_update = stale_update_info(vm, args) + # there are updates available => select + if to_update: + selected.add(vm) + continue - if to_update: - targets.add(vm) + # update vm only if there are updates available + # and that's not true at this point => skip + if args.update_if_available: + continue - return targets + if is_stale(vm, expiration_period=args.update_if_stale): + selected.add(vm) + + return selected -def stale_update_info(vm, args): +def is_stale(vm, expiration_period): today = datetime.today() try: if not ('qrexec' in vm.features.keys() @@ -209,7 +238,7 @@ def stale_update_info(vm, args): datetime.fromtimestamp(0).strftime('%Y-%m-%d %H:%M:%S') ) last_update = datetime.fromisoformat(last_update_str) - if (today - last_update).days > args.update_if_stale: + if (today - last_update).days > expiration_period: return True except qubesadmin.exc.QubesDaemonCommunicationError: pass @@ -217,7 +246,7 @@ def stale_update_info(vm, args): def run_update( - targets, args, qube_klass="qubes" + targets, args, log, qube_klass="qubes" ) -> Tuple[int, Dict[str, FinalStatus]]: if not targets: return 0, {} @@ -260,7 +289,11 @@ def get_boolean_feature(vm, feature_name, default=False): def apply_updates_to_appvm( - args, vm_updated: Iterable, status: Dict[str, FinalStatus] + args, + vm_updated: Iterable, + template_statuses: Dict[str, FinalStatus], + derived_statuses: Dict[str, FinalStatus], + log ) -> int: """ Shutdown running templates and then restart/shutdown derived AppVMs. @@ -271,14 +304,15 @@ def apply_updates_to_appvm( `2` - unable to shut down some AppVMs `3` - unable to start some AppVMs """ - if not args.restart and not args.apply_to_all: + if not args.apply_to_sys and not args.apply_to_all: return 0 updated_tmpls = [ vm for vm in vm_updated - if bool(status[vm.name]) and vm.klass == 'TemplateVM' + if bool(template_statuses[vm.name]) and vm.klass == 'TemplateVM' ] - to_restart, to_shutdown = get_derived_vm_to_apply(updated_tmpls) + to_restart, to_shutdown = get_derived_vm_to_apply( + updated_tmpls, derived_statuses) templates_to_shutdown = [template for template in updated_tmpls if template.is_running()] @@ -294,7 +328,7 @@ def apply_updates_to_appvm( # first shutdown templates to apply changes to the root volume # they are no need to start templates automatically - ret_code, _ = shutdown_domains(templates_to_shutdown) + ret_code, _ = shutdown_domains(templates_to_shutdown, log) if ret_code != 0: log.error("Shutdown of some templates fails with code %d", ret_code) @@ -306,20 +340,21 @@ def apply_updates_to_appvm( # restarting their derived AppVMs ready_templates = [tmpl for tmpl in updated_tmpls if not tmpl.is_running()] - to_restart, to_shutdown = get_derived_vm_to_apply(ready_templates) + to_restart, to_shutdown = get_derived_vm_to_apply( + ready_templates, derived_statuses) # both flags `restart` and `apply-to-all` include service vms - ret_code_ = restart_vms(to_restart) + ret_code_ = restart_vms(to_restart, log) ret_code = max(ret_code, ret_code_) if args.apply_to_all: # there is no need to start plain AppVMs automatically - ret_code_, _ = shutdown_domains(to_shutdown) + ret_code_, _ = shutdown_domains(to_shutdown, log) ret_code = max(ret_code, ret_code_) return ret_code -def get_derived_vm_to_apply(templates): +def get_derived_vm_to_apply(templates, derived_statuses): possibly_changed_vms = set() for template in templates: possibly_changed_vms.update(template.derived_vms) @@ -328,7 +363,9 @@ def get_derived_vm_to_apply(templates): to_shutdown = set() for vm in possibly_changed_vms: - if vm.is_running() and (vm.klass != 'DispVM' or not vm.auto_cleanup): + if (not bool(derived_statuses.get(vm.name, False)) + and vm.is_running() + and (vm.klass != 'DispVM' or not vm.auto_cleanup)): if get_boolean_feature(vm, 'servicevm', False): to_restart.add(vm) else: @@ -337,7 +374,7 @@ def get_derived_vm_to_apply(templates): return to_restart, to_shutdown -def shutdown_domains(to_shutdown): +def shutdown_domains(to_shutdown, log): """ Try to shut down vms and wait to finish. """ @@ -356,11 +393,11 @@ def shutdown_domains(to_shutdown): return ret_code, wait_for -def restart_vms(to_restart): +def restart_vms(to_restart, log): """ Try to restart vms. """ - ret_code, shutdowns = shutdown_domains(to_restart) + ret_code, shutdowns = shutdown_domains(to_restart, log) # restart shutdown qubes for vm in shutdowns: From 35a9ae044de942dc985cbb9a5668eba21490a3cf Mon Sep 17 00:00:00 2001 From: Piotr Bartman-Szwarc Date: Mon, 10 Jun 2024 15:14:57 +0200 Subject: [PATCH 5/8] updater: make help prettier --- vmupdate/vmupdate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index c3b0b72..e46e768 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -116,7 +116,7 @@ def parse_args(args): 'Attempt to update targeted VMs with known updates available ' 'or for which last update check was more than N days ago. ' '(default: %(default)d)', - type=int, default=7, choices=range(0, 366)) + type=int, default=7) update_state.add_argument( '--update-if-available', action='store_true', help='Update targeted VMs with known updates available.') @@ -145,6 +145,9 @@ def parse_args(args): AgentArgs.add_arguments(parser) args = parser.parse_args(args) + if args.update_if_stale < 0: + raise ArgumentError("Wrong value for --update-if-stale") + return args From ad50c1b45dca4cdba3198506981fdbebf0dae55c Mon Sep 17 00:00:00 2001 From: Piotr Bartman-Szwarc Date: Tue, 11 Jun 2024 11:30:35 +0200 Subject: [PATCH 6/8] updater: get default value of '--update-if-stale' from dom0 settings --- vmupdate/tests/test_vmupdate.py | 5 +++-- vmupdate/vmupdate.py | 12 +++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/vmupdate/tests/test_vmupdate.py b/vmupdate/tests/test_vmupdate.py index 6b8ab53..2147d16 100644 --- a/vmupdate/tests/test_vmupdate.py +++ b/vmupdate/tests/test_vmupdate.py @@ -22,7 +22,7 @@ from unittest.mock import patch -from vmupdate.tests.conftest import generate_vm_variations +from vmupdate.tests.conftest import generate_vm_variations, TestVM from vmupdate.agent.source.status import FinalStatus from vmupdate.vmupdate import main from vmupdate import vmupdate @@ -34,7 +34,8 @@ @patch('logging.getLogger') def test_no_options_do_nothing(_logger, _log_file, _chmod, _chown, test_qapp): args = [] - test_qapp.domains = () + test_qapp.domains = test_qapp.Domains() + TestVM("dom0", test_qapp, klass="AdminVM") assert main(args, test_qapp) == 100 diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index e46e768..9ae8caf 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -18,6 +18,7 @@ from . import update_manager from .agent.source.args import AgentArgs +DEFAULT_UPDATE_IF_STALE = 7 LOGPATH = '/var/log/qubes/qubes-vm-update.log' LOG_FORMAT = '%(asctime)s %(message)s' @@ -28,7 +29,7 @@ class ArgumentError(Exception): def main(args=None, app=qubesadmin.Qubes()): - args = parse_args(args) + args = parse_args(args, app) log_handler = logging.FileHandler(LOGPATH, encoding='utf-8') log_formatter = logging.Formatter(LOG_FORMAT) @@ -79,8 +80,13 @@ def main(args=None, app=qubesadmin.Qubes()): return ret_code -def parse_args(args): +def parse_args(args, app): parser = argparse.ArgumentParser() + try: + default_update_if_stale = int(app.domains["dom0"].features.get( + "qubes-vm-update-update-if-stale", DEFAULT_UPDATE_IF_STALE)) + except qubesadmin.exc.QubesDaemonAccessError: + default_update_if_stale = DEFAULT_UPDATE_IF_STALE parser.add_argument('--max-concurrency', '-x', action='store', @@ -116,7 +122,7 @@ def parse_args(args): 'Attempt to update targeted VMs with known updates available ' 'or for which last update check was more than N days ago. ' '(default: %(default)d)', - type=int, default=7) + type=int, default=default_update_if_stale) update_state.add_argument( '--update-if-available', action='store_true', help='Update targeted VMs with known updates available.') From cd36a0e97990e619272e56351a58665b7a5ffc39 Mon Sep 17 00:00:00 2001 From: Piotr Bartman-Szwarc Date: Tue, 11 Jun 2024 17:39:48 +0200 Subject: [PATCH 7/8] run ci tests --- .gitlab-ci.yml | 16 ++++++++++++++ ci/codecov-keys.asc | 52 +++++++++++++++++++++++++++++++++++++++++++++ ci/codecov-wrapper | 24 +++++++++++++++++++++ ci/coveragerc | 5 +++++ ci/requirements.txt | 12 +++++++++++ run-tests.sh | 3 +++ 6 files changed, 112 insertions(+) create mode 100644 ci/codecov-keys.asc create mode 100755 ci/codecov-wrapper create mode 100644 ci/coveragerc create mode 100644 ci/requirements.txt create mode 100755 run-tests.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 30615e2..41d65b9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,3 +3,19 @@ include: project: QubesOS/qubes-continuous-integration - file: /r4.2/gitlab-host.yml project: QubesOS/qubes-continuous-integration + +checks:tests: + stage: checks + variables: + PYTEST_ADDOPTS: "--color=yes" + before_script: &before-script + - "PATH=$PATH:$HOME/.local/bin" + - sudo dnf install -y python3-pytest python3-coverage + - pip3 install --quiet -r ci/requirements.txt + - git clone https://github.com/QubesOS/qubes-core-admin-client ~/core-admin-client + script: + - export PATH="$HOME/.local/bin:$PATH" + - PYTHONPATH=~/core-admin-client ./run-tests.sh + after_script: + - "PATH=$PATH:$HOME/.local/bin" + - ci/codecov-wrapper \ No newline at end of file diff --git a/ci/codecov-keys.asc b/ci/codecov-keys.asc new file mode 100644 index 0000000..b7dfce0 --- /dev/null +++ b/ci/codecov-keys.asc @@ -0,0 +1,52 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=ch7z +-----END PGP PUBLIC KEY BLOCK----- diff --git a/ci/codecov-wrapper b/ci/codecov-wrapper new file mode 100755 index 0000000..c345ae4 --- /dev/null +++ b/ci/codecov-wrapper @@ -0,0 +1,24 @@ +#!/bin/bash + +set -xe + +curl -Os https://uploader.codecov.io/latest/linux/codecov +curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM +curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig + +sqv --keyring ci/codecov-keys.asc codecov.SHA256SUM.sig codecov.SHA256SUM +shasum -a 256 -c codecov.SHA256SUM + +chmod +x codecov + +if [[ "$CI_COMMIT_BRANCH" =~ ^pr- ]]; then + PR=${CI_COMMIT_BRANCH#pr-} + parents=$(git show -s --format='%P %ae') + if [ $(wc -w <<<"$parents") -eq 3 ] && [ "${parents##* }" = "fepitre-bot@qubes-os.org" ]; then + commit_sha=$(cut -f 2 -d ' ' <<<"${parents}") + else + commit_sha=$(git show -s --format='%H') + fi + exec ./codecov --pr "$PR" --sha "$commit_sha" "$@" +fi +exec ./codecov "$@" diff --git a/ci/coveragerc b/ci/coveragerc new file mode 100644 index 0000000..b31dc6e --- /dev/null +++ b/ci/coveragerc @@ -0,0 +1,5 @@ +[run] +source = vmupdate +omit = + vmupdate/agent/* + vmupdate/tests/* diff --git a/ci/requirements.txt b/ci/requirements.txt new file mode 100644 index 0000000..925cf6e --- /dev/null +++ b/ci/requirements.txt @@ -0,0 +1,12 @@ +# WARNING: those requirements are used only for travis-ci.org +# they SHOULD NOT be used under normal conditions; use system package manager +docutils +pylint +sphinx +python-daemon +mock +lxml +PyYAML +xcffib +tqdm +pyxdg diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..50616e6 --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +python3 -m coverage run -m pytest -vv vmupdate/tests From 51f9465181be5240e5a49dae64606f3da2d22177 Mon Sep 17 00:00:00 2001 From: Piotr Bartman-Szwarc Date: Wed, 26 Jun 2024 17:36:08 +0200 Subject: [PATCH 8/8] updater: add --signal-no-updates flag --- vmupdate/tests/test_vmupdate.py | 4 +++- vmupdate/vmupdate.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/vmupdate/tests/test_vmupdate.py b/vmupdate/tests/test_vmupdate.py index 2147d16..d34dcf4 100644 --- a/vmupdate/tests/test_vmupdate.py +++ b/vmupdate/tests/test_vmupdate.py @@ -33,9 +33,11 @@ @patch('logging.FileHandler') @patch('logging.getLogger') def test_no_options_do_nothing(_logger, _log_file, _chmod, _chown, test_qapp): - args = [] test_qapp.domains = test_qapp.Domains() TestVM("dom0", test_qapp, klass="AdminVM") + args = [] + assert main(args, test_qapp) == 0 + args = ['--signal-no-updates'] assert main(args, test_qapp) == 100 diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index 9ae8caf..14edf51 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -55,7 +55,7 @@ def main(args=None, app=qubesadmin.Qubes()): if not targets: if not args.quiet: print("No qube selected for update") - return 100 + return 100 if args.signal_no_updates else 0 independent = [target for target in targets if target.klass in ( 'TemplateVM', 'StandaloneVM')] @@ -75,7 +75,7 @@ def main(args=None, app=qubesadmin.Qubes()): args, independent, templ_statuses, app_statuses, log) ret_code = max(ret_code_independent, ret_code_appvm, ret_code_restart) - if ret_code == 0 and no_updates: + if ret_code == 0 and no_updates and args.signal_no_updates: return 100 return ret_code @@ -97,6 +97,10 @@ def parse_args(args, app): help='Do not remove updater files from target qube') parser.add_argument('--dry-run', action='store_true', help='Just print what happens.') + parser.add_argument( + '--signal-no-updates', action='store_true', + help='Return exit code 100 instread of 0 ' + 'if there is no updates available.') restart = parser.add_mutually_exclusive_group() restart.add_argument(