diff --git a/doc/tools/qubes-vm-update.rst b/doc/tools/qubes-vm-update.rst new file mode 100644 index 00000000..63971f75 --- /dev/null +++ b/doc/tools/qubes-vm-update.rst @@ -0,0 +1,123 @@ +=============== +qubes-vm-update +=============== + +NAME +==== +qubes-vm-update - update software in virtual machines (qubes) + +SYNOPSIS +======== +| qubes-vm-update [options] + +OPTIONS +======= + +Package Manager +--------------- +--no-refresh + Do not refresh available packages before upgrading vm +--force-upgrade, -f + Try upgrade even if errors are encountered (like a refresh error) +--leave-obsolete + Do not remove obsolete packages during upgrading + +Targeting +--------- +--skip SKIP + Comma separated list of VMs to be skipped, works with all other options. +--targets TARGETS + Comma separated list of VMs to target. Ignores conditions. +--templates, -T + Target all updatable TemplateVMs. +--standalones, -S + Target all updatable StandaloneVMs. +--apps, -A + Target running updatable AppVMs to update in place. Updates will be lost after vm restart. +--all + DEFAULT. Target all updatable VMs except AdminVM. Use explicitly with "--targets" to include both. + +Selecting +--------- +--update-if-available + Update targeted VMs with known updates available +--update-if-stale UPDATE_IF_STALE + DEFAULT. Attempt to update targeted VMs with known updates available or for which last update check was more than N days ago. (default: dom0 feature `qubes-vm-update-update-if-stale` if set or 7) +--force-update + Attempt to update all targeted VMs even if no updates are available + +Propagation +----------- +--apply-to-sys, --restart, -r + Restart not updated ServiceVMs whose template has been updated. +--apply-to-all, -R + Restart not updated ServiceVMs and shutdown not updated AppVMs whose template has been updated. +--no-apply + DEFAULT. Do not restart/shutdown any AppVMs. + +Auxiliary +--------- +--max-concurrency MAX_CONCURRENCY, -x MAX_CONCURRENCY + Maximum number of VMs configured simultaneously (default: number of cpus) +--log LOG + Provide logging level. Values: DEBUG, INFO (default), WARNING, ERROR, CRITICAL +--signal-no-updates + Return exit code 100 instead of 0 if there is no updates available. + +--no-progress + Do not show upgrading progress +--dry-run + Just print what happens +--no-cleanup + Do not remove updater files from target qube + +--help, -h + Show this help message and exit +--quiet, -q + Do not print anything to stdout +--show-output, --verbose, -v + Show output of management commands + + +How to correctly use targeting and selection? + +Targeting is used to choose the VMs that will be checked for available updates, and the three-level selection is used to check if the previously chosen VMs qualify for updates (i.e., there are, for example, updates available for them). + +Additionally, not all VMs in the system can be updated directly (such as AppVMs), and to update them, you must use one of the "propagation" options. This means, after updating the template, restarting the VM and applying the installed updates to it. Using at least the `--apply-to-sys` flag is recommended, which restarts all service VMs. Keep in mind that during this process, unsaved data may be lost. + +RETURN CODES +============ + +0: ok + +100: ok, returned if `--signal-no-updates` and no updates available + +1: general error + +2: usage error, unrecognized argument + +11: error of TemplateVM shutdown + +12: error of AppVM shutdown + +13: error of AppVM startup + +21: general error inside updated vm + +22: error inside updated vm during updating/installing prerequisites/patches + +23: repo-refresh error inside updated vm, check if vm is connected to network + +24: error inside updated vm during installing updates + +25: unhandled error inside updated vm + +40: qrexec error, communication across domains was interrupted + +64: usage error, wrong parameter value + +130: user interruption + +AUTHORS +======= +| Piotr Bartman-Szwarc diff --git a/vmupdate/agent/entrypoint.py b/vmupdate/agent/entrypoint.py index 4ec9fb57..87375842 100644 --- a/vmupdate/agent/entrypoint.py +++ b/vmupdate/agent/entrypoint.py @@ -7,6 +7,7 @@ from source.args import AgentArgs from source.utils import get_os_data from source.log_congfig import init_logs +from source.common.exit_codes import EXIT def main(args=None): @@ -33,6 +34,8 @@ def main(args=None): log.debug("Notify dom0 about upgrades.") os.system("/usr/lib/qubes/upgrades-status-notify") + if return_code not in EXIT.VM_HANDLED: + return_code = EXIT.ERR_VM_UNHANDLED return return_code @@ -90,4 +93,4 @@ def get_package_manager(os_data, log, log_handler, log_level, no_progress): sys.exit(main()) except RuntimeError as ex: print(ex) - sys.exit(1) + sys.exit(EXIT.ERR_VM_UNHANDLED) diff --git a/vmupdate/agent/source/apt/apt_api.py b/vmupdate/agent/source/apt/apt_api.py index 99a96929..2443cc61 100644 --- a/vmupdate/agent/source/apt/apt_api.py +++ b/vmupdate/agent/source/apt/apt_api.py @@ -27,6 +27,7 @@ import apt_pkg from source.common.process_result import ProcessResult +from source.common.exit_codes import EXIT from source.common.progress_reporter import ProgressReporter, Progress from .apt_cli import APTCLI @@ -64,11 +65,11 @@ def refresh(self, hard_fail: bool) -> ProcessResult: self.log.debug("Cache refresh successful.") else: self.log.warning("Cache refresh failed.") - result += ProcessResult(1) + result += ProcessResult(EXIT.ERR_VM_REFRESH) except Exception as exc: self.log.error( "An error occurred while refreshing packages: %s", str(exc)) - result += ProcessResult(2, out="", err=str(exc)) + result += ProcessResult(EXIT.ERR_VM_REFRESH, out="", err=str(exc)) return result @@ -92,7 +93,7 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: except Exception as exc: self.log.error( "An error occurred while upgrading packages: %s", str(exc)) - result += ProcessResult(3, out="", err=str(exc)) + result += ProcessResult(EXIT.ERR_VM_UPDATE, out="", err=str(exc)) return result diff --git a/vmupdate/agent/source/common/exit_codes.py b/vmupdate/agent/source/common/exit_codes.py new file mode 100644 index 00000000..774101ca --- /dev/null +++ b/vmupdate/agent/source/common/exit_codes.py @@ -0,0 +1,43 @@ +# 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. +from dataclasses import dataclass + + +@dataclass(frozen=True) +class EXIT: + OK = 0 + OK_NO_UPDATES = 100 + + ERR = 1 + ERR_SHUTDOWN_TMPL = 11 # unable to shut down some TemplateVMs + ERR_SHUTDOWN_APP = 12 # unable to shut down some AppVMs + ERR_START_APP = 13 # unable to start some AppVMs + + VM_HANDLED = (0, 100, 21, 22, 23, 24) + ERR_VM = 21 + ERR_VM_PRE = 22 + ERR_VM_REFRESH = 23 + ERR_VM_UPDATE = 24 + ERR_VM_UNHANDLED = 25 + + ERR_QREXEX = 40 + ERR_USAGE = 64 + SIGINT = 130 diff --git a/vmupdate/agent/source/common/package_manager.py b/vmupdate/agent/source/common/package_manager.py index a1199edf..46608718 100644 --- a/vmupdate/agent/source/common/package_manager.py +++ b/vmupdate/agent/source/common/package_manager.py @@ -24,6 +24,7 @@ import sys from typing import Optional, Dict, List from .process_result import ProcessResult +from .exit_codes import EXIT class PackageManager: @@ -77,36 +78,45 @@ def _upgrade( if requirements: print("Install requirements", flush=True) result_install = self.install_requirements(requirements, curr_pkg) + if result_install: + self.log.warning( + "Installing requirements failed with exit code: %d", + result_install.code) + result_install.code = EXIT.ERR_VM_PRE result += result_install - if result.code != 0: - self.log.warning("Installing requirements failed.") - if hard_fail: - self.log.error("Exiting due to a packages install error. " - "Use --force-upgrade to upgrade anyway.") - return result + if result and hard_fail: + self.log.error("Exiting due to a packages install error. " + "Use --force-upgrade to upgrade anyway.") + return result if refresh: print("Refreshing package info", flush=True) result_refresh = self.refresh(hard_fail) + if result_refresh: + self.log.warning("Refreshing failed with code: %d", + result_refresh.code) + result_refresh.code = EXIT.ERR_VM_REFRESH result += result_refresh - if result.code != 0: - self.log.warning("Refreshing failed.") - if hard_fail: - self.log.error("Exiting due to a refresh error. " - "Use --force-upgrade to upgrade anyway.") - return result + if result and hard_fail: + self.log.error("Exiting due to a refresh error. " + "Use --force-upgrade to upgrade anyway.") + return result result_upgrade = self.upgrade_internal(remove_obsolete) + if result_upgrade: + result_upgrade.code = EXIT.ERR_VM_UPDATE result += result_upgrade new_pkg = self.get_packages() changes = PackageManager.compare_packages(old=curr_pkg, new=new_pkg) summary = self._print_changes(changes) + if summary: + summary.code = EXIT.ERR_VM result += summary - if not result.code and not (changes["installed"] or changes["updated"]): - result.code = 100 # Nothing to upgrade + if not result and not (changes["installed"] or changes["updated"]): + result.code = EXIT.OK_NO_UPDATES return result diff --git a/vmupdate/agent/source/common/process_result.py b/vmupdate/agent/source/common/process_result.py index d4cc276f..5ce57027 100644 --- a/vmupdate/agent/source/common/process_result.py +++ b/vmupdate/agent/source/common/process_result.py @@ -21,6 +21,7 @@ import sys from copy import deepcopy from typing import Union, Optional +from .exit_codes import EXIT class ProcessResult: @@ -32,7 +33,7 @@ class ProcessResult: """ def __init__( self, - code: int = 0, out: str = "", err: str = "", + code: int = EXIT.OK, out: str = "", err: str = "", realtime: bool = False ): self.code: int = code @@ -75,7 +76,7 @@ def from_untrusted_out_err( untrusted_err_bytes = untrusted_err err = ProcessResult.sanitize_output(untrusted_err_bytes) - return cls(0, out, err) + return cls(EXIT.OK, out, err) @staticmethod def sanitize_output(untrusted_bytes: bytes, single: bool = False) -> str: @@ -114,4 +115,4 @@ def __repr__(self): def error_from_messages(self): out_lines = (self.out + '\n' + self.err).splitlines() if any(line.lower().startswith("err") for line in out_lines): - self.code = 1 + self.code = EXIT.ERR diff --git a/vmupdate/agent/source/dnf/dnf_api.py b/vmupdate/agent/source/dnf/dnf_api.py index 99a91e90..b9ce1502 100644 --- a/vmupdate/agent/source/dnf/dnf_api.py +++ b/vmupdate/agent/source/dnf/dnf_api.py @@ -26,6 +26,7 @@ import dnf.transaction from source.common.process_result import ProcessResult +from source.common.exit_codes import EXIT from source.common.progress_reporter import ProgressReporter, Progress from .dnf_cli import DNFCLI @@ -62,11 +63,11 @@ def refresh(self, hard_fail: bool) -> ProcessResult: self.log.debug("Cache refresh successful.") else: self.log.warning("Cache refresh failed.") - result += ProcessResult(1) + result += ProcessResult(EXIT.ERR_VM_REFRESH) except Exception as exc: self.log.error( "An error occurred while refreshing packages: %s", str(exc)) - result += ProcessResult(2, out="", err=str(exc)) + result += ProcessResult(EXIT.ERR_VM_REFRESH, out="", err=str(exc)) return result @@ -88,7 +89,7 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: trans = self.base.transaction if not trans: self.log.info("No packages to upgrade, quitting.") - return ProcessResult(0, out="", err="") + return ProcessResult(EXIT.OK, out="", err="") self.base.download_packages( trans.install_set, @@ -96,7 +97,7 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: ) result += sign_check(self.base, trans.install_set, self.log) - if result.code == 0: + if result.code == EXIT.OK: print("Updating packages.", flush=True) self.log.debug("Committing upgrade...") self.base.do_transaction(self.progress.upgrade_progress) @@ -107,7 +108,7 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: except Exception as exc: self.log.error( "An error occurred while upgrading packages: %s", str(exc)) - result += ProcessResult(3, out="", err=str(exc)) + result += ProcessResult(EXIT.ERR_VM_UPDATE, out="", err=str(exc)) finally: self.base.close() @@ -116,13 +117,13 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: def sign_check(base, packages, log) -> ProcessResult: """ - Check signature of packages. + Check a signature of packages. """ log.debug("Check signature of packages.") result = ProcessResult() for package in packages: ret_code, message = base.package_signature_check(package) - if ret_code != 0: + if ret_code != EXIT.OK: # Import key and re-try the check try: base.package_import_key(package, askcb=(lambda a, b, c: True)) @@ -133,10 +134,10 @@ def sign_check(base, packages, log) -> ProcessResult: # do that explicitly anyway, in case the behavior would change # (intentionally or not) ret_code, message = base.package_signature_check(package) - if ret_code != 0: + if ret_code != EXIT.OK: result += ProcessResult(ret_code, out="", err=message) else: - result += ProcessResult(0, out=message, err="") + result += ProcessResult(EXIT.OK, out=message, err="") return result diff --git a/vmupdate/tests/conftest.py b/vmupdate/tests/conftest.py index 48d2aafa..3bae3193 100644 --- a/vmupdate/tests/conftest.py +++ b/vmupdate/tests/conftest.py @@ -105,8 +105,8 @@ def test_manager(): class MPPool(Mock): - def apply_async(self, func, args, **_kwargs): - func(*args) + def apply_async(self, func, args, *, callback, **_kwargs): + callback(func(*args)) @pytest.fixture() @@ -175,6 +175,8 @@ def generate_vm_variations(app, variations): } klasses = list(reversed(sorted(list(domains['klass'].keys())))) + if "klass" not in variations: + klasses = klasses[:1] rest = [list(domains[key].keys()) if key in variations else list(domains[key].keys())[:1] for key in domains.keys() if key != "klass"] @@ -232,7 +234,7 @@ def generate_vm_variations(app, variations): vm = TestVM( k[0] + ext_suffix, app, klass=k, updateable=updatable, running=running, auto_cleanup=auto_cleanup, template=template, - features=Features("dom0", app, features), + features=Features(k[0] + ext_suffix, app, features), update_result=update_result) domains["klass"][k].add(vm) diff --git a/vmupdate/tests/test_vmupdate.py b/vmupdate/tests/test_vmupdate.py index d34dcf40..7cd8a36b 100644 --- a/vmupdate/tests/test_vmupdate.py +++ b/vmupdate/tests/test_vmupdate.py @@ -22,7 +22,11 @@ from unittest.mock import patch -from vmupdate.tests.conftest import generate_vm_variations, TestVM +import pytest + +import qubesadmin +from vmupdate.agent.source.common.exit_codes import EXIT +from vmupdate.tests.conftest import generate_vm_variations, TestVM, Features from vmupdate.agent.source.status import FinalStatus from vmupdate.vmupdate import main from vmupdate import vmupdate @@ -36,9 +40,9 @@ def test_no_options_do_nothing(_logger, _log_file, _chmod, _chown, test_qapp): test_qapp.domains = test_qapp.Domains() TestVM("dom0", test_qapp, klass="AdminVM") args = [] - assert main(args, test_qapp) == 0 + assert main(args, test_qapp) == EXIT.OK args = ['--signal-no-updates'] - assert main(args, test_qapp) == 100 + assert main(args, test_qapp) == EXIT.OK_NO_UPDATES @patch('vmupdate.update_manager.TerminalMultiBar.print') @@ -81,6 +85,7 @@ def test_preselection( expected = { (): default, + ("--skip", UpStandVM.name): default - {UpStandVM}, ("--all",): default, ("--all", "--apps",): default, ("--all", "--templates",): default, @@ -121,10 +126,11 @@ def test_preselection( ("--targets", RunNUpAppVM.name,): {RunNUpAppVM}, ("--targets", NRunAppVM.name,): {NRunAppVM}, ("--targets", StandVM.name,): {StandVM}, - ("--targets", AdminVM.name,): 100, # dom0 skipped, user warning - ("--targets", "unknown",): 128, + # dom0 skipped, user warning + ("--targets", AdminVM.name,): EXIT.OK_NO_UPDATES, + ("--targets", "unknown",): EXIT.ERR_USAGE, ("--targets", f"{TemplVM.name},{StandVM.name}",): {TemplVM, StandVM}, - ("--targets", f"{TemplVM.name},{TemplVM.name}",): 128, + ("--targets", f"{TemplVM.name},{TemplVM.name}",): EXIT.ERR_USAGE, ("--targets", TemplVM.name, "--skip", TemplVM.name,): {}, ("--targets", f"{TemplVM.name},{StandVM.name}", "--skip", TemplVM.name,): {StandVM}, } @@ -135,12 +141,13 @@ def test_preselection( feed = {} expected_exit = selected else: - feed = {vm.name: {'statuses': [FinalStatus.SUCCESS], 'retcode': 0} + feed = {vm.name: {'statuses': [FinalStatus.SUCCESS], + 'retcode': EXIT.OK} for vm in selected} if feed: - expected_exit = 0 + expected_exit = EXIT.OK else: - expected_exit = 100 + expected_exit = EXIT.OK_NO_UPDATES unexpected = [] agent_mng.side_effect = test_agent(feed, unexpected) @@ -204,14 +211,15 @@ def test_selection( monkeypatch.setattr( vmupdate, "preselect_targets", lambda *_: all) else: - feed = {vm.name: {'statuses': [FinalStatus.SUCCESS], 'retcode': 0} + feed = {vm.name: {'statuses': [FinalStatus.SUCCESS], + 'retcode': EXIT.OK} for vm in selected} monkeypatch.setattr( vmupdate, "preselect_targets", lambda *_: selected) if feed: - expected_exit = 0 + expected_exit = EXIT.OK else: - expected_exit = 100 + expected_exit = EXIT.OK_NO_UPDATES unexpected = [] agent_mng.side_effect = test_agent(feed, unexpected) @@ -325,3 +333,174 @@ def test_restarting( fails = {args: failed[args] for args in failed if failed[args]} assert not fails arun.asseert_called() + + +stat = FinalStatus + + +@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') +@pytest.mark.parametrize( + "tmpl_status, tmpl_retcode, app_status, app_retcode, expected_retcode", +( + pytest.param( + stat.NO_UPDATES, EXIT.OK, stat.NO_UPDATES, EXIT.OK, + EXIT.OK_NO_UPDATES, id="no updates: 2x OK"), + pytest.param( + stat.NO_UPDATES, EXIT.OK, stat.NO_UPDATES, EXIT.OK, + EXIT.OK_NO_UPDATES, id="no updates: tmpl OK"), + pytest.param( + stat.NO_UPDATES, EXIT.OK, stat.NO_UPDATES, EXIT.OK, + EXIT.OK_NO_UPDATES, id="no updates: app OK"), + pytest.param( + stat.ERROR, EXIT.OK, stat.NO_UPDATES, EXIT.OK, + EXIT.ERR, id="error: tmpl"), + pytest.param( + stat.SUCCESS, EXIT.OK, stat.ERROR, EXIT.OK, + EXIT.ERR, id="error: app"), + pytest.param( + stat.SUCCESS, EXIT.OK_NO_UPDATES, stat.SUCCESS, EXIT.OK, + EXIT.ERR_VM_UNHANDLED, id="unhandled retcode"), + pytest.param( + stat.SUCCESS, EXIT.ERR_VM, stat.ERROR, EXIT.ERR_VM_PRE, + EXIT.ERR_VM_PRE, id="vm inside error"), + pytest.param( + stat.SUCCESS, EXIT.ERR_VM_UPDATE, stat.ERROR, EXIT.ERR_VM_REFRESH, + EXIT.ERR_VM_UPDATE, id="vm inside error 2"), + pytest.param( + stat.SUCCESS, EXIT.ERR_VM, stat.SUCCESS, EXIT.OK, + EXIT.ERR_VM, id="vm general inside error"), + pytest.param( + stat.CANCELLED, EXIT.OK, stat.SUCCESS, EXIT.OK, + EXIT.SIGINT, id="cancelled"), + pytest.param( + stat.CANCELLED, EXIT.OK, stat.ERROR, EXIT.ERR_VM_UNHANDLED, + EXIT.SIGINT, id="cancelled with error"), + pytest.param( + stat.UNKNOWN, EXIT.OK, stat.SUCCESS, EXIT.OK, + EXIT.ERR_QREXEX, id="communication error"), +)) +def test_return_codes( + mp_manager, mp_pool, agent_mng, + _logger, _log_file, _chmod, _chown, _print, + test_qapp, test_manager, test_pool, test_agent, + monkeypatch, + tmpl_status, tmpl_retcode, app_status, app_retcode, expected_retcode +): + mp_manager.return_value = test_manager + mp_pool.return_value = test_pool + + _dom0 = TestVM("dom0", test_qapp, klass="AdminVM") + vm = TestVM("vm", test_qapp, klass="TemplateVM") + appvm = TestVM("appvm", test_qapp, klass="AppVM", template=vm) + + feed = { + vm.name: {'statuses': [tmpl_status], 'retcode': tmpl_retcode}, + appvm.name: {'statuses': [app_status], 'retcode': app_retcode}} + unexpected = [] + agent_mng.side_effect = test_agent(feed, unexpected) + + monkeypatch.setattr(vmupdate, "get_targets", lambda *_: [vm, appvm]) + + retcode = main( + ("--just-print-progress", "--all", "--force-update", + "--signal-no-updates"), test_qapp) + assert retcode == expected_retcode + + +@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_error( + 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 + + _dom0 = TestVM("dom0", test_qapp, klass="AdminVM") + vm = TestVM("vm", test_qapp, klass="TemplateVM") + appvm = TestVM("appvm", test_qapp, klass="AppVM", template=vm) + + feed = { + vm.name: {'statuses': [FinalStatus.ERROR], 'retcode': EXIT.OK}, + appvm.name: {'statuses': [FinalStatus.NO_UPDATES], 'retcode': EXIT.OK}} + unexpected = [] + agent_mng.side_effect = test_agent(feed, unexpected) + + monkeypatch.setattr(vmupdate, "get_targets", lambda *_: [vm, appvm]) + + retcode = main(( + "--just-print-progress", "--all", "--force-update"), test_qapp) + assert retcode == EXIT.ERR + + +@patch('vmupdate.update_manager.TerminalMultiBar.print') +@patch('os.chmod') +@patch('os.chown') +@patch('logging.FileHandler') +@patch('logging.getLogger') +@patch('asyncio.run') +@pytest.mark.parametrize( + "action, code", +( + pytest.param("template shutdown", EXIT.ERR_SHUTDOWN_TMPL), + pytest.param("app shutdown", EXIT.ERR_SHUTDOWN_APP), + pytest.param("app start", EXIT.ERR_START_APP), +)) +def test_error_apply( + _arun, _logger, _log_file, _chmod, _chown, _print, + test_qapp, monkeypatch, action, code +): + _dom0 = TestVM("dom0", test_qapp, klass="AdminVM") + vm = TestVM("vm", test_qapp, klass="TemplateVM") + appvm = TestVM( + "appvm", test_qapp, klass="AppVM", template=vm, + features=Features("appvm", test_qapp, {'servicevm': True})) + + monkeypatch.setattr(vmupdate, "get_targets", lambda *_: [vm, appvm]) + monkeypatch.setattr(vmupdate, "run_update", + lambda *_: [EXIT.OK, {"vm": FinalStatus.SUCCESS}]) + + def raiser(*_args, **_kwargs): + raise qubesadmin.exc.QubesVMError("foo") + if action == "template shutdown": + vm.shutdown = raiser + elif action == "app shutdown": + appvm.shutdown = raiser + elif action == "app start": + appvm.start = raiser + else: + raise ValueError() + + retcode = main(("--all", "--force-update", "--apply-to-all"), test_qapp) + assert retcode == code + + +@patch('vmupdate.update_manager.TerminalMultiBar.print') +@patch('os.chmod') +@patch('os.chown') +@patch('logging.FileHandler') +@patch('logging.getLogger') +def test_error_usage_wrong_param( + _logger, _log_file, _chmod, _chown, _print, test_qapp, +): + _dom0 = TestVM("dom0", test_qapp, klass="AdminVM") + + retcode = main(( + "--just-print-progress", "--targets", 'vm', "--force-update"), + test_qapp) + assert retcode == EXIT.ERR_USAGE diff --git a/vmupdate/update_manager.py b/vmupdate/update_manager.py index e10acbd4..e4977bc4 100644 --- a/vmupdate/update_manager.py +++ b/vmupdate/update_manager.py @@ -36,6 +36,7 @@ from .qube_connection import QubeConnection from vmupdate.agent.source.log_congfig import init_logs from vmupdate.agent.source.common.process_result import ProcessResult +from vmupdate.agent.source.common.exit_codes import EXIT class UpdateManager: @@ -53,7 +54,7 @@ def __init__(self, qubes, args, log): self.buffered = not args.just_print_progress and not args.no_progress self.buffer = "" self.cleanup = not args.no_cleanup - self.ret_code = 0 + self.ret_code = EXIT.OK self.log = log def run(self, agent_args): @@ -63,7 +64,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, {} + return EXIT.OK, {} show_progress = not self.quiet and not self.no_progress SimpleTerminalBar.reinit_class() @@ -93,11 +94,12 @@ def run(self, agent_args): stats = list(progress_bar.statuses.values()) if FinalStatus.CANCELLED in stats: - self.ret_code = max(self.ret_code, 130) + self.ret_code = max(self.ret_code, EXIT.SIGINT) if FinalStatus.ERROR in stats: - self.ret_code = max(self.ret_code, 5) + self.ret_code = max(self.ret_code, EXIT.ERR) if FinalStatus.UNKNOWN in stats: - self.ret_code = max(self.ret_code, 6) + # communication with vm fails + self.ret_code = max(self.ret_code, EXIT.ERR_QREXEX) if self.buffer: print(self.buffer) @@ -109,7 +111,15 @@ def collect_result(self, result_tuple: Tuple[str, ProcessResult]): Callback method to process `update_qube` output. """ qube_name, result = result_tuple - self.ret_code = max(self.ret_code, result.code) + + vm_code = result.code + if result.code not in EXIT.VM_HANDLED: + vm_code = EXIT.ERR_VM_UNHANDLED + if vm_code == EXIT.OK_NO_UPDATES: + # at this point, this code should be captured + vm_code = EXIT.ERR_VM_UNHANDLED + self.ret_code = max(self.ret_code, vm_code) + if self.show_output: for line in result.out.split('\n'): self.print(qube_name + ":out:", line) @@ -283,7 +293,7 @@ def update_qube( """ if termination.value: status_notifier.put(StatusInfo.done(qube, FinalStatus.CANCELLED)) - return qube.name, ProcessResult(130, "Canceled") + return qube.name, ProcessResult(EXIT.SIGINT, "Canceled") status_notifier.put(StatusInfo.updating(qube, 0)) try: @@ -300,7 +310,8 @@ def update_qube( ) 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, ProcessResult( + EXIT.ERR_VM_UNHANDLED, f"ERROR (exception {str(exc)})") return qube.name, result @@ -345,7 +356,7 @@ def run_agent( self.log.debug('agent output: %s', line) self.log.info('agent exit code: %d', result.code) if not agent_args.show_output or not output: - result.out = "OK" if result.code == 0 else \ + result.out = "OK" if result.code == EXIT.OK else \ f"ERROR (exit code {result.code}, details in {self.log_path})" return result @@ -375,7 +386,7 @@ def _run_agent( if termination.value: qconn.status = FinalStatus.CANCELLED - return ProcessResult(130, "", "Cancelled") + return ProcessResult(EXIT.SIGINT, "", "Cancelled") self.log.info( "The agent is starting the task in qube: %s", self.qube.name) diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index 14edf513..053f70f6 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -15,6 +15,7 @@ import qubesadmin.exc from qubesadmin.events.utils import wait_for_domain_shutdown from vmupdate.agent.source.status import FinalStatus +from vmupdate.agent.source.common.exit_codes import EXIT from . import update_manager from .agent.source.args import AgentArgs @@ -50,12 +51,12 @@ def main(args=None, app=qubesadmin.Qubes()): targets = get_targets(args, app) except ArgumentError as err: log.error(str(err)) - return 128 + return EXIT.ERR_USAGE if not targets: if not args.quiet: print("No qube selected for update") - return 100 if args.signal_no_updates else 0 + return EXIT.OK_NO_UPDATES if args.signal_no_updates else EXIT.OK independent = [target for target in targets if target.klass in ( 'TemplateVM', 'StandaloneVM')] @@ -65,18 +66,19 @@ def main(args=None, app=qubesadmin.Qubes()): # independent qubes first (TemplateVMs, StandaloneVMs) ret_code_independent, templ_statuses = run_update( independent, args, log, "templates and standalones") - no_updates = all(stat == FinalStatus.NO_UPDATES for stat in templ_statuses) + no_updates = all(stat == FinalStatus.NO_UPDATES + for stat in templ_statuses.values()) # then derived qubes (AppVMs...) 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 + no_updates = all(stat == FinalStatus.NO_UPDATES + for stat in app_statuses.values()) and no_updates 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 and args.signal_no_updates: - return 100 + if ret_code == EXIT.OK and no_updates and args.signal_no_updates: + return EXIT.OK_NO_UPDATES return ret_code @@ -99,7 +101,7 @@ def parse_args(args, app): help='Just print what happens.') parser.add_argument( '--signal-no-updates', action='store_true', - help='Return exit code 100 instread of 0 ' + help='Return exit code 100 instead of 0 ' 'if there is no updates available.') restart = parser.add_mutually_exclusive_group() @@ -262,13 +264,13 @@ def run_update( targets, args, log, qube_klass="qubes" ) -> Tuple[int, Dict[str, FinalStatus]]: if not targets: - return 0, {} + return EXIT.OK, {} message = f"Following {qube_klass} will be updated:" + \ ",".join((target.name for target in targets)) if args.dry_run: print(message) - return 0, {target.name: FinalStatus.SUCCESS for target in targets} + return EXIT.OK, {target.name: FinalStatus.SUCCESS for target in targets} else: log.debug(message) @@ -313,12 +315,12 @@ def apply_updates_to_appvm( Returns return codes: `0` - OK - `1` - unable to shut down some templateVMs - `2` - unable to shut down some AppVMs - `3` - unable to start some AppVMs + `11` - unable to shut down some templateVMs + `12` - unable to shut down some AppVMs + `13` - unable to start some AppVMs """ if not args.apply_to_sys and not args.apply_to_all: - return 0 + return EXIT.OK updated_tmpls = [ vm for vm in vm_updated @@ -337,18 +339,18 @@ def apply_updates_to_appvm( ",".join((target.name for target in to_restart))) print("Following qubes CAN be shutdown:", ",".join((target.name for target in to_shutdown))) - return 0 + return EXIT.OK # 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, log) - if ret_code != 0: + if ret_code != EXIT.OK: log.error("Shutdown of some templates fails with code %d", ret_code) log.warning( "Derived VMs of the following templates will be omitted: %s", ", ".join((t.name for t in updated_tmpls if t.is_running()))) - ret_code = 1 + ret_code = EXIT.ERR_SHUTDOWN_TMPL # Some templates are not down dur to errors, there is no point in # restarting their derived AppVMs ready_templates = [tmpl for tmpl in updated_tmpls @@ -391,7 +393,7 @@ def shutdown_domains(to_shutdown, log): """ Try to shut down vms and wait to finish. """ - ret_code = 0 + ret_code = EXIT.OK wait_for = [] for vm in to_shutdown: try: @@ -399,7 +401,7 @@ def shutdown_domains(to_shutdown, log): wait_for.append(vm) except qubesadmin.exc.QubesVMError as exc: log.error(str(exc)) - ret_code = 2 + ret_code = EXIT.ERR_SHUTDOWN_APP asyncio.run(wait_for_domain_shutdown(wait_for)) @@ -418,7 +420,7 @@ def restart_vms(to_restart, log): vm.start() except qubesadmin.exc.QubesVMError as exc: log.error(str(exc)) - ret_code = 3 + ret_code = EXIT.ERR_START_APP return ret_code