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: