diff --git a/dom0-updates/qubes-dom0-update b/dom0-updates/qubes-dom0-update index cc4bdb3..73546ed 100755 --- a/dom0-updates/qubes-dom0-update +++ b/dom0-updates/qubes-dom0-update @@ -28,6 +28,7 @@ if [ "$1" = "--help" ]; then echo " --force-xen-upgrade force major Xen upgrade even if some qubes are running" echo " --console does nothing; ignored for backward compatibility" echo " --show-output does nothing; ignored for backward compatibility" + echo " --quiet do not print anything to stdout" echo " --preserve-terminal does nothing; ignored for backward compatibility" echo " --skip-boot-check does not check if /boot & /boot/efi should be mounted" echo " --switch-audio-server-to=(pulseaudio|pipewire) switch audio daemon to pipewire or pulseaudio" @@ -46,6 +47,7 @@ YUM_OPTS=() UPDATEVM_OPTS=() QVMTEMPLATE_OPTS=() GUI= +PROGRESS_REPORTING= CHECK_ONLY= CLEAN= TEMPLATE= @@ -79,6 +81,12 @@ while [ $# -gt 0 ]; do --show-output) # ignore ;; + --quiet) + exec > /dev/null + ;; + --just-print-progress) + PROGRESS_REPORTING=1 + ;; --check-only) CHECK_ONLY=1 UPDATEVM_OPTS+=( "$1" ) @@ -310,29 +318,102 @@ qvm-run --nogui -q -- "$UPDATEVM" "rm -rf -- '$dom0_updates_dir/etc' '$dom0_upda exit "$status" } -CMD="/usr/lib/qubes/qubes-download-dom0-updates.sh --doit --nogui" +QVMRUN_OPTS=(--quiet --filter-escape-chars --nogui --pass-io) -# We avoid using bash’s own facilities for this, as they produce $'\n'-style -# strings in certain cases. These are not portable, whereas the string produced -# by the following is. -for i in "${UPDATEVM_OPTS[@]}"; do CMD+=" '${i//\'/\'\\\'\'}'"; done +progress_agent_version="4.3" -QVMRUN_OPTS=(--quiet --filter-escape-chars --nogui --pass-io) -if [[ -t 1 ]] && [[ -t 2 ]]; then - # Use ‘script’ to emulate a TTY, so that we get status bars and other - # progress output. Since stdout and stderr are both terminals, qvm-run - # will automatically sanitize them, but we explicitly tell it to anyway - # as a precaution. - # - # We MUST NOT use ‘exec script’ here. That causes ‘script’ to - # inherit the child processes of the shell. ‘script’ mishandles - # this and enters an infinite loop. - CMD="script --quiet --return --command '${CMD//\'/\'\\\'\'}' /dev/null" +get_base_vm() { + # Resolve base VM (TemplateVM or StandaloneVM) + local vm="$1" + while true; do + # If it's a TemplateVM or StandaloneVM, stop + if qvm-check --template "$vm" &>/dev/null || qvm-check --standalone "$vm" &>/dev/null; then + echo "$vm" + return + fi + # Try to get its template + vm=$(qvm-prefs "$vm" template 2>/dev/null) || return 1 + done +} +version_check() { + # Compare version numbers (e.g., 4.2 < 4.3) + awk 'BEGIN {exit !(ARGV[1] < ARGV[2])}' "$1" "$2" +} +base_vm=$(get_base_vm $UPDATEVM) +OLD_VERSION=0 +if [ -n "$base_vm" ]; then + agent_version=$(qvm-features "$base_vm" qubes-agent-version 2>/dev/null) + + if [ -n "$agent_version" ] && version_check "$agent_version" "$progress_agent_version"; then + OLD_VERSION=1 + fi fi -qvm-run "${QVMRUN_OPTS[@]}" -- "$UPDATEVM" "$CMD" < /dev/null + +if [ "$PROGRESS_REPORTING" == "1" ] && [ "$OLD_VERSION" == "0" ]; then + CMD="/usr/lib/qubes/qubes-download-dom0-updates-init.sh" + qvm-run "${QVMRUN_OPTS[@]}" -- "$UPDATEVM" "$CMD" + + update_agent_log="/var/log/qubes/qubes-update" + qvm-run --nogui -q -u root -- "$UPDATEVM" "user=\$(qubesdb-read /default-user) && chown -R -- \"\$user:qubes\" '$update_agent_log'" || exit 1 + + # "--no-cleanup" is needed since fakeroot cannot remove entrypoint + qubes-vm-update --force-update --targets "$UPDATEVM" --signal-no-updates --just-print-progress --display-name dom0 --download-only --no-cleanup --show-output --log=DEBUG + RETCODE=$? + if [ "$RETCODE" -eq 100 ]; then + echo "$(hostname):out: Nothing to do." + echo "$(hostname) done no_updates" >&2 + exit 100 + fi + if [ "$RETCODE" -ne 0 ]; then + echo "$(hostname) done error" >&2 + exit $RETCODE + fi + + # qubes-vm-update leaves the downloaded packages with root ownership + qvm-run --nogui -q -u root -- "$UPDATEVM" "user=\$(qubesdb-read /default-user) && chown -R -- \"\$user:qubes\" '$dom0_updates_dir'" || exit 1 + + CMD="/usr/lib/qubes/qubes-download-dom0-updates-finish.sh" + qvm-run "${QVMRUN_OPTS[@]}" -- "$UPDATEVM" "$CMD" +else + if [ "$PROGRESS_REPORTING" == "1" ] && [ "$OLD_VERSION" == "1" ]; then + echo "$(hostname):out: Progress reporting requires updateVM based on a template with Qubes 4.3 packages." + fi + CMD="/usr/lib/qubes/qubes-download-dom0-updates.sh --doit --nogui" + + # We avoid using bash’s own facilities for this, as they produce $'\n'-style + # strings in certain cases. These are not portable, whereas the string produced + # by the following is. + for i in "${UPDATEVM_OPTS[@]}"; do CMD+=" '${i//\'/\'\\\'\'}'"; done + + if [[ -t 1 ]] && [[ -t 2 ]]; then + # Use ‘script’ to emulate a TTY, so that we get status bars and other + # progress output. Since stdout and stderr are both terminals, qvm-run + # will automatically sanitize them, but we explicitly tell it to anyway + # as a precaution. + # + # We MUST NOT use ‘exec script’ here. That causes ‘script’ to + # inherit the child processes of the shell. ‘script’ mishandles + # this and enters an infinite loop. + CMD="script --quiet --return --command '${CMD//\'/\'\\\'\'}' /dev/null" + fi + if [ "$PROGRESS_REPORTING" == "1" ]; then + qvm-run "${QVMRUN_OPTS[@]}" -- "$UPDATEVM" "$CMD" < /dev/null | sed "s/``^/$(hostname):out: /" + # "consume" the last empty line + echo "" + else + qvm-run "${QVMRUN_OPTS[@]}" -- "$UPDATEVM" "$CMD" < /dev/null + fi +fi RETCODE=$? +if [ "$PROGRESS_REPORTING" == "1" ] && [ "$OLD_VERSION" == "1" ]; then + echo "$(hostname) updating 50.0" >&2 + if [ "$RETCODE" -ne 0 ]; then + echo "$(hostname) done error" >&2 + exit $RETCODE + fi +fi if [[ "$REMOTE_ONLY" = '1' ]] || [ "$RETCODE" -ne 0 ]; then if [ "$CHECK_ONLY" = '1' ]; then if [ "$RETCODE" -eq 100 ]; then @@ -401,6 +482,9 @@ elif [ -f /var/lib/qubes/updates/repodata/repomd.xml ]; then # refresh packagekit metadata, GUI utilities use it pkcon refresh force $guiapp + elif [ "$PROGRESS_REPORTING" == 1 ]; then + # report progress to the user + qubes-vm-update --no-refresh --targets dom0 --force-update --log=DEBUG --just-print-progress --show-output else dnf check-update || if [ $? -eq 100 ]; then # Run dnf with options diff --git a/vmupdate/agent/entrypoint.py b/vmupdate/agent/entrypoint.py index 3f5a0df..cef2832 100755 --- a/vmupdate/agent/entrypoint.py +++ b/vmupdate/agent/entrypoint.py @@ -8,6 +8,7 @@ from source.utils import get_os_data from source.log_congfig import init_logs from source.common.exit_codes import EXIT +from source.common.package_manager import AgentType def main(args=None): @@ -21,18 +22,31 @@ def main(args=None): os_data = get_os_data() log.debug("Selecting package manager.") + agent_type = AgentType.VM + if os_data["id"] == "qubes": + agent_type = AgentType.DOM0 + if args.download_only: + agent_type = AgentType.UPDATE_VM pkg_mng = get_package_manager( - os_data, log, log_handler, log_level, args.no_progress) + os_data, log, log_handler, log_level, agent_type, args.no_progress) log.debug("Running upgrades.") return_code = pkg_mng.upgrade(refresh=not args.no_refresh, hard_fail=not args.force_upgrade, remove_obsolete=not args.leave_obsolete, - print_streams=args.show_output + print_streams=args.show_output, ) - log.debug("Notify dom0 about upgrades.") - os.system("/usr/lib/qubes/upgrades-status-notify") + if not pkg_mng.PROGRESS_REPORTING and not args.no_progress: + # even if progress reporting is unavailable we want info that update finished + if agent_type is AgentType.UPDATE_VM: + print(f"{55:.2f}", flush=True, file=sys.stderr) + else: + print(f"{100:.2f}", flush=True, file=sys.stderr) + + if agent_type is AgentType.VM: + log.debug("Notify dom0 about upgrades.") + os.system("/usr/lib/qubes/upgrades-status-notify") if not args.no_cleanup: return_code = max(pkg_mng.clean(), return_code) @@ -49,7 +63,7 @@ def parse_args(args): return args -def get_package_manager(os_data, log, log_handler, log_level, no_progress): +def get_package_manager(os_data, log, log_handler, log_level, agent_type, no_progress): """ Returns instance of `PackageManager`. @@ -59,57 +73,113 @@ def get_package_manager(os_data, log, log_handler, log_level, no_progress): requirements = {} # plugins MUST be applied before import anything from package managers. # in case of apt configuration is loaded on `import apt`. - for plugin in plugins.entrypoints: - plugin(os_data, log, requirements=requirements) - - if os_data["os_family"] == "Debian": - try: - from source.apt.apt_api import APT as PackageManager - except ImportError: - log.warning("Failed to load apt with progress bar. Using apt cli.") - # no progress reporting - no_progress = True - print(f"Progress reporting not supported.", flush=True) - - if no_progress: - from source.apt.apt_cli import APTCLI as PackageManager - elif os_data["os_family"] == "RedHat": - try: - version = int(os_data["release"].split(".")[0]) - except ValueError: - version = 99 # fedora changed its version - - loaded = False - if version >= 41: - try: - from source.dnf.dnf5_api import DNF as PackageManager - loaded = True - except ImportError: - log.warning("Failed to load dnf5.") - - if not loaded: - try: - from source.dnf.dnf_api import DNF as PackageManager - loaded = True - except ImportError: - log.warning( - "Failed to load dnf with progress bar. Using dnf cli.") - print(f"Progress reporting not supported.", flush=True) - - if no_progress or not loaded: - from source.dnf.dnf_cli import DNFCLI as PackageManager + if agent_type is not AgentType.UPDATE_VM: + for plugin in plugins.entrypoints: + plugin(os_data, log, requirements=requirements) + + if os_data["os_family"] == "RedHat" or agent_type is AgentType.UPDATE_VM: + PackageManager = import_rhel_package_manager(os_data, log, no_progress) + elif os_data["os_family"] == "Debian": + PackageManager = import_debian_package_manager(log, no_progress) elif os_data["os_family"] == "ArchLinux": from source.pacman.pacman_cli import PACMANCLI as PackageManager print(f"Progress reporting not supported.", flush=True) + elif os_data["os_family"] == "Qubes": + PackageManager = import_dom0_package_manager(os_data, log, no_progress) else: raise NotImplementedError( "Only Debian, RedHat and ArchLinux based OS is supported.") - pkg_mng = PackageManager(log_handler, log_level) + pkg_mng = PackageManager(log_handler, log_level, agent_type) pkg_mng.requirements = requirements return pkg_mng +def import_rhel_package_manager(os_data, log, no_progress): + """ + Import dnf package manager. + """ + dnf5_fedora_version = 41 + if os_data["os_family"] == "RedHat": + try: + version = int(os_data["release"].split(".")[0]) + except ValueError: + version = 99 # fedora changed its version + else: + version = dnf5_fedora_version # try to use whatever is available, starting from dnf5 + + loaded = False + if version >= dnf5_fedora_version: + try: + from source.dnf.dnf5_api import DNF5 as PackageManager + loaded = True + except ImportError: + log.warning("Failed to load dnf5.") + + if not loaded: + try: + from source.dnf.dnf_api import DNF as PackageManager + loaded = True + log.debug("Using dnf python API for progress reporting.") + except ImportError: + print(f"Progress reporting not supported.", flush=True) + + if no_progress or not loaded: + log.warning( + "Failed to load dnf with progress bar. Using dnf cli.") + from source.dnf.dnf_cli import DNFCLI as PackageManager + + return PackageManager + + +def import_debian_package_manager(log, no_progress): + """ + Import apt package manager. + """ + loaded = False + try: + from source.apt.apt_api import APT as PackageManager + loaded = True + except ImportError: + log.warning("Failed to load apt with progress bar. Using apt cli.") + print(f"Progress reporting not supported.", flush=True) + + if no_progress or not loaded: + from source.apt.apt_cli import APTCLI as PackageManager + + return PackageManager + + +def import_dom0_package_manager(os_data, log, no_progress): + """ + Import dnf package manager for dom0. + """ + major, minor = os_data["release"].split(".") + major, minor = int(major), int(minor) + loaded = False + if major >= 5 or (major == 4 and minor >= 3): + try: + from source.dnf.dnf5_api import DNF5 as PackageManager + loaded = True + except ImportError: + log.warning("Failed to load dnf5.") + + if not loaded: + try: + from source.dnf.dnf_api import DNF as PackageManager + loaded = True + log.debug("Using dnf python API for progress reporting.") + except ImportError: + print(f"Progress reporting not supported.", flush=True) + + if no_progress or not loaded: + log.warning( + "Failed to load dnf with progress bar. Using dnf cli.") + from source.dnf.dnf_cli import DNFCLI as PackageManager + + return PackageManager + + if __name__ == '__main__': try: sys.exit(main()) diff --git a/vmupdate/agent/source/apt/apt_api.py b/vmupdate/agent/source/apt/apt_api.py index 543ad31..5b52d8f 100644 --- a/vmupdate/agent/source/apt/apt_api.py +++ b/vmupdate/agent/source/apt/apt_api.py @@ -26,6 +26,7 @@ import apt.progress.base import apt_pkg +from source.common.package_manager import AgentType from source.common.process_result import ProcessResult from source.common.exit_codes import EXIT from source.common.progress_reporter import ProgressReporter, Progress @@ -34,8 +35,10 @@ class APT(APTCLI): - def __init__(self, log_handler, log_level): - super().__init__(log_handler, log_level) + PROGRESS_REPORTING = True + + def __init__(self, log_handler, log_level, agent_type: AgentType): + super().__init__(log_handler, log_level, agent_type) self.apt_cache = apt.Cache() update = FetchProgress( weight=4, log=self.log, refresh=True) # 4% of total time diff --git a/vmupdate/agent/source/apt/apt_cli.py b/vmupdate/agent/source/apt/apt_cli.py index 9779ad7..867f848 100644 --- a/vmupdate/agent/source/apt/apt_cli.py +++ b/vmupdate/agent/source/apt/apt_cli.py @@ -26,14 +26,18 @@ import contextlib from typing import List -from source.common.package_manager import PackageManager +from source.common.package_manager import PackageManager, AgentType from source.common.process_result import ProcessResult from source.common.exit_codes import EXIT class APTCLI(PackageManager): - def __init__(self, log_handler, log_level,): - super().__init__(log_handler, log_level,) + PROGRESS_REPORTING = False + + def __init__(self, log_handler, log_level, agent_type: AgentType): + super().__init__(log_handler, log_level, agent_type) + if self.type is AgentType.UPDATE_VM: + raise NotImplementedError("APT do not support update proxy VM.") self.package_manager: str = "apt-get" # to prevent a warning: `debconf: unable to initialize frontend: Dialog` diff --git a/vmupdate/agent/source/args.py b/vmupdate/agent/source/args.py index 5b0593c..611065e 100644 --- a/vmupdate/agent/source/args.py +++ b/vmupdate/agent/source/args.py @@ -41,6 +41,9 @@ class AgentArgs: ("--leave-obsolete",): { "action": 'store_true', "help": 'Do not remove updater and cache files from target qube'}, + ("--download-only",): { + "action": 'store_true', + "help": 'Only download packages'}, } EXCLUSIVE_OPTIONS_1 = { ("--show-output", "--verbose", "-v"): diff --git a/vmupdate/agent/source/common/package_manager.py b/vmupdate/agent/source/common/package_manager.py index 266a4ba..869b648 100644 --- a/vmupdate/agent/source/common/package_manager.py +++ b/vmupdate/agent/source/common/package_manager.py @@ -23,14 +23,21 @@ import logging import subprocess import sys +import enum from typing import Optional, Dict, List from .process_result import ProcessResult from .exit_codes import EXIT +class AgentType(enum.Enum): + VM = "Downloads and install updates in VM" + DOM0 = "Install downloaded updates in dom0" + UPDATE_VM = "Downloads updates for dom0" + + class PackageManager: """ main package manager class """ - def __init__(self, log_handler, log_level): + def __init__(self, log_handler, log_level, agent_type: AgentType): self.package_manager: Optional[str] = None self.log = logging.getLogger( f'vm-update.agent.{self.__class__.__name__}') @@ -38,13 +45,14 @@ def __init__(self, log_handler, log_level): self.log.addHandler(log_handler) self.log.propagate = False self.requirements: Optional[Dict[str, str]] = None + self.type = agent_type def upgrade( self, refresh: bool, hard_fail: bool, remove_obsolete: bool, - print_streams: bool = False + print_streams: bool = False, ): """ Upgrade packages using system package manager. @@ -105,9 +113,15 @@ def _upgrade( return result result_upgrade = self.upgrade_internal(remove_obsolete) - if result_upgrade: + if result_upgrade.code not in (EXIT.OK, EXIT.OK_NO_UPDATES): result_upgrade.code = EXIT.ERR_VM_UPDATE result += result_upgrade + if result: + return result + + if self.type == AgentType.UPDATE_VM: + # No package installation is required in UpdateVM, so changes are not checked. + return result new_pkg = self.get_packages() diff --git a/vmupdate/agent/source/dnf/dnf5_api.py b/vmupdate/agent/source/dnf/dnf5_api.py index d04040e..7d6e2d2 100644 --- a/vmupdate/agent/source/dnf/dnf5_api.py +++ b/vmupdate/agent/source/dnf/dnf5_api.py @@ -25,11 +25,12 @@ import libdnf5 from libdnf5.repo import DownloadCallbacks from libdnf5.rpm import TransactionCallbacks -from libdnf5.base import Base, Goal +from libdnf5.base import Goal from source.common.process_result import ProcessResult from source.common.exit_codes import EXIT from source.common.progress_reporter import ProgressReporter, Progress +from source.common.package_manager import AgentType from .dnf_cli import DNFCLI @@ -38,16 +39,32 @@ class TransactionError(RuntimeError): pass -class DNF(DNFCLI): - def __init__(self, log_handler, log_level): - super().__init__(log_handler, log_level) - self.base = Base() +class DNF5(DNFCLI): + PROGRESS_REPORTING = True + + def __init__(self, log_handler, log_level, agent_type: AgentType): + super().__init__(log_handler, log_level, agent_type) + + self.base = libdnf5.base.Base() + conf = self.base.get_config() + + if self.type == AgentType.UPDATE_VM: + conf.config_file_path = self.UPDATE_VM_INSTALLROOT + "/etc/dnf/dnf.conf" + conf.best = True + conf.plugins = False + conf.installroot = self.UPDATE_VM_INSTALLROOT + for opt in ('cachedir', 'logdir', 'persistdir'): + setattr(conf, opt, self.UPDATE_VM_INSTALLROOT + getattr(conf, opt)) + conf.reposdir = [self.UPDATE_VM_INSTALLROOT + "/etc/yum.repos.d"] + conf.excludepkgs = ["qubes-template-*"] self.base.load_config() + + # Create base object with the loaded config self.base.setup() self.config = self.base.get_config() update = FetchProgress(weight=0, log=self.log) # % of total time - fetch = FetchProgress(weight=50, log=self.log) # % of total time - upgrade = UpgradeProgress(weight=50, log=self.log) # % of total time + fetch = FetchProgress(weight=55, log=self.log) # % of total time + upgrade = UpgradeProgress(weight=45, log=self.log) # % of total time self.progress = ProgressReporter(update, fetch, upgrade) def refresh(self, hard_fail: bool) -> ProcessResult: @@ -85,6 +102,8 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: try: self.log.debug("Performing package upgrade...") goal = Goal(self.base) + if self.type == AgentType.UPDATE_VM: + goal.set_allow_erasing(True) goal.add_upgrade("*") transaction = goal.resolve() # fill empty `Command line` column in dnf history @@ -93,7 +112,7 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: if transaction.get_transaction_packages_count() == 0: self.log.info("No packages to upgrade, quitting.") return ProcessResult( - EXIT.OK, out="", + EXIT.OK_NO_UPDATES, out="", err="\n".join(transaction.get_resolve_logs_as_strings())) self.base.set_download_callbacks( @@ -106,8 +125,7 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: raise TransactionError( f"GPG signatures check failed: {problems}") - if result.code == EXIT.OK: - print("Updating packages.", flush=True) + if result.code == EXIT.OK and self.type is not AgentType.UPDATE_VM: self.log.debug("Committing upgrade...") transaction.set_callbacks( libdnf5.rpm.TransactionCallbacksUniquePtr( @@ -117,9 +135,9 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: raise TransactionError( transaction.transaction_result_to_string(tnx_result)) self.log.debug("Package upgrade successful.") - self.log.info("Notifying dom0 about installed applications") - subprocess.call(['/etc/qubes-rpc/qubes.PostInstall']) - print("Updated", flush=True) + if self.type is AgentType.VM: + self.log.info("Notifying dom0 about installed applications") + subprocess.call(['/etc/qubes-rpc/qubes.PostInstall']) except Exception as exc: self.log.error( "An error occurred while upgrading packages: %s", str(exc)) @@ -194,7 +212,10 @@ def end(self, user_cb_data: int, status: int, msg: str) -> int: :param msg: The error message in case of error. """ if status != 0: - print(msg, flush=True, file=self._stdout) + if isinstance(msg, bytes): + msg = msg.decode('ascii', errors='ignore') + if msg: + print(msg, flush=True, file=self._stdout) return DownloadCallbacks.end(self, user_cb_data, status, msg) def mirror_failure( @@ -208,6 +229,8 @@ def mirror_failure( :param url: Failed mirror URL. :param metadata: the type of metadata that is being downloaded """ + if isinstance(msg, bytes): + msg = msg.decode('ascii', errors='ignore') print(f"Fetching {metadata} failure " f"({self.package_names[user_cb_data]}) {msg}", flush=True, file=self._stdout) diff --git a/vmupdate/agent/source/dnf/dnf_api.py b/vmupdate/agent/source/dnf/dnf_api.py index 506090b..ed2971b 100644 --- a/vmupdate/agent/source/dnf/dnf_api.py +++ b/vmupdate/agent/source/dnf/dnf_api.py @@ -19,8 +19,11 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. +import os import subprocess import dnf +import dnf.conf +import dnf.rpm from dnf.yum.rpmtrans import TransactionDisplay from dnf.callback import DownloadProgress import dnf.transaction @@ -28,18 +31,54 @@ from source.common.process_result import ProcessResult from source.common.exit_codes import EXIT from source.common.progress_reporter import ProgressReporter, Progress +from source.common.package_manager import AgentType from .dnf_cli import DNFCLI class DNF(DNFCLI): - def __init__(self, log_handler, log_level): - super().__init__(log_handler, log_level) - self.base = dnf.Base() - self.base.conf.read() # load dnf.conf - update = FetchProgress(weight=0, log=self.log) # % of total time - fetch = FetchProgress(weight=50, log=self.log) # % of total time - upgrade = UpgradeProgress(weight=50, log=self.log) # % of total time + PROGRESS_REPORTING = True + + def __init__(self, log_handler, log_level, agent_type: AgentType): + super().__init__(log_handler, log_level, agent_type) + + if self.type == AgentType.UPDATE_VM: + dnfconf = self.UPDATE_VM_INSTALLROOT + "/etc/dnf/dnf.conf" + else: + dnfconf = None + conf = dnf.conf.Conf() + conf.read(filename=dnfconf) + + if self.type == AgentType.UPDATE_VM: + conf.best = True + conf.plugins = False + conf.installroot = self.UPDATE_VM_INSTALLROOT + for opt in ('cachedir', 'logdir', 'persistdir'): + conf.prepend_installroot(opt) + conf.reposdir = [self.UPDATE_VM_INSTALLROOT + "/etc/yum.repos.d"] + conf.excludepkgs = ["qubes-template-*"] + + # make sure log file exists + log_dir = self.UPDATE_VM_INSTALLROOT + "/var/log" + log_file = os.path.join(log_dir, "hawkey.log") + os.makedirs(log_dir, exist_ok=True) + if not os.path.exists(log_file): + with open(log_file, 'w'): + pass + + # Passing `conf` to `base` causes `releasever` not to be set + subst = conf.substitutions + if 'releasever' not in subst: + releasever = dnf.rpm.detect_releasever(conf.installroot) + subst['releasever'] = releasever + + self.base = dnf.Base(conf) + # Repositories serve as sources of information about packages. + self.base.read_all_repos() + + update = FetchProgress(weight=10, log=self.log, refresh=True) # % of total time + fetch = FetchProgress(weight=45, log=self.log) # % of total time + upgrade = UpgradeProgress(weight=45, log=self.log) # % of total time self.progress = ProgressReporter(update, fetch, upgrade) def refresh(self, hard_fail: bool) -> ProcessResult: @@ -49,16 +88,18 @@ def refresh(self, hard_fail: bool) -> ProcessResult: :param hard_fail: raise error if some repo is unavailable :return: (exit_code, stdout, stderr) """ - self.base.conf.skip_if_unavailable = int(not hard_fail) - result = ProcessResult() + self.base.conf.skip_if_unavailable = True try: self.log.debug("Refreshing available packages...") - # Repositories serve as sources of information about packages. - self.base.read_all_repos() + repos = [r for r in self.base.repos.iter_enabled()] + # we do not know the size of the repositories + self.progress.update_progress.start(len(repos), len(repos)) + for i, repo in enumerate(repos): + self.progress.update_progress.progress(repo.id, 1) + repo.load() + self.progress.update_progress.end(repo.id, 0, "") updated = self.base.update_cache() - # A sack is needed for querying. - self.base.fill_sack() if updated: self.log.debug("Cache refresh successful.") else: @@ -68,7 +109,6 @@ def refresh(self, hard_fail: bool) -> ProcessResult: self.log.error( "An error occurred while refreshing packages: %s", str(exc)) result += ProcessResult(EXIT.ERR_VM_REFRESH, out="", err=str(exc)) - return result def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: @@ -80,16 +120,19 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: result = ProcessResult() try: self.log.debug("Performing package upgrade...") + # A sack is needed for querying. + self.base.fill_sack() + self.base.upgrade_all() # fill empty `Command line` column in dnf history self.base.cmds = ["qubes-vm-update"] - self.base.resolve() + self.base.resolve(allow_erasing=self.type == AgentType.UPDATE_VM) trans = self.base.transaction if not trans: self.log.info("No packages to upgrade, quitting.") - return ProcessResult(EXIT.OK, out="", err="") + return ProcessResult(EXIT.OK_NO_UPDATES, out="", err="") self.base.download_packages( trans.install_set, @@ -97,13 +140,14 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult: ) result += sign_check(self.base, trans.install_set, self.log) - if result.code == EXIT.OK: + if result.code == EXIT.OK and self.type is not AgentType.UPDATE_VM: print("Updating packages.", flush=True) self.log.debug("Committing upgrade...") self.base.do_transaction(self.progress.upgrade_progress) self.log.debug("Package upgrade successful.") - self.log.info("Notifying dom0 about installed applications") - subprocess.call(['/etc/qubes-rpc/qubes.PostInstall']) + if self.type is AgentType.VM: + self.log.info("Notifying dom0 about installed applications") + subprocess.call(['/etc/qubes-rpc/qubes.PostInstall']) print("Updated", flush=True) except Exception as exc: self.log.error( @@ -143,10 +187,11 @@ def sign_check(base, packages, log) -> ProcessResult: class FetchProgress(DownloadProgress, Progress): - def __init__(self, weight: int, log): + def __init__(self, weight: int, log, refresh: bool = False): Progress.__init__(self, weight, log) self.bytes_to_fetch = None self.bytes_fetched = 0 + self.action = "refresh" if refresh else "fetch" self.package_bytes = {} def end(self, payload, status, msg): @@ -155,12 +200,19 @@ def end(self, payload, status, msg): :api, `status` is a constant denoting the type of outcome, `err_msg` is an error message in case the outcome was an error. """ - print(f"{payload}: Fetched", flush=True) + if status != 0: + if isinstance(msg, bytes): + msg = msg.decode('ascii', errors='ignore') + if msg: + print(msg, flush=True, file=self._stdout) + else: + print(f"{payload}: {self.action.capitalize()}ed", flush=True) def message(self, msg): if isinstance(msg, bytes): msg = msg.decode('ascii', errors='ignore') - print(msg, flush=True, file=self._stdout) + if msg: + print(msg, flush=True, file=self._stdout) def progress(self, payload, done): """Update the progress display. :api @@ -181,12 +233,15 @@ def start(self, total_files, total_size, total_drpms=0): `total_size` total size of all files. """ - self.log.info("Fetch started.") + self.log.info(f"{self.action.capitalize()} started.") self.bytes_to_fetch = total_size - print(f"Fetching {total_files} packages " - f"[{self._format_bytes(self.bytes_to_fetch)}]", - flush=True) - self.package_bytes = {} + if self.action == "refresh": + print("Refreshing available packages.", flush=True) + else: + print(f"Fetching {total_files} packages " + f"[{self._format_bytes(self.bytes_to_fetch)}]", + flush=True) + self.package_bytes = {} self.notify_callback(0) @@ -207,11 +262,12 @@ def progress(self, _package, action, ti_done, ti_total, ts_done, :param ts_done: number of actions processed in the whole transaction :param ts_total: total number of actions in the whole transaction """ + self.log.info(_package) fetch = 6 install = 7 if action not in (fetch, install): return - percent = ti_done / ti_total * ts_done / ts_total * 100 + percent = (ti_done / ti_total + ts_done - 1) / ts_total * 100 self.notify_callback(percent) def scriptout(self, msgs): diff --git a/vmupdate/agent/source/dnf/dnf_cli.py b/vmupdate/agent/source/dnf/dnf_cli.py index 80d530e..c3073cc 100644 --- a/vmupdate/agent/source/dnf/dnf_cli.py +++ b/vmupdate/agent/source/dnf/dnf_cli.py @@ -22,14 +22,17 @@ import shutil from typing import List -from source.common.package_manager import PackageManager +from source.common.package_manager import PackageManager, AgentType from source.common.process_result import ProcessResult from source.common.exit_codes import EXIT class DNFCLI(PackageManager): - def __init__(self, log_handler, log_level): - super().__init__(log_handler, log_level) + PROGRESS_REPORTING = False + UPDATE_VM_INSTALLROOT = "/var/lib/qubes/dom0-updates" + + def __init__(self, log_handler, log_level, agent_type: AgentType): + super().__init__(log_handler, log_level, agent_type) pck_mng_path = shutil.which('dnf') if pck_mng_path is not None: pck_mngr = 'dnf' @@ -54,12 +57,14 @@ def refresh(self, hard_fail: bool) -> ProcessResult: "-q", "check-update", f"--setopt=skip_if_unavailable={int(not hard_fail)}"] - result_check = self.run_cmd(cmd) - # ret_code == 100 is not an error - # It means there are packages to be updated - result_check.code = result_check.code if result_check.code != 100 else 0 - result += result_check - result.error_from_messages() + if self.type != AgentType.UPDATE_VM: + # In UpdateVM we use preconfigured repos + result_check = self.run_cmd(cmd) + # ret_code == 100 is not an error + # It means there are packages to be updated + result_check.code = result_check.code if result_check.code != 100 else 0 + result += result_check + result.error_from_messages() return result @@ -71,7 +76,11 @@ def expire_cache(self) -> ProcessResult: "-q", "clean", "expire-cache"] - result = self.run_cmd(cmd) + if self.type != AgentType.UPDATE_VM: + result = self.run_cmd(cmd) + else: + # In UpdateVM we use preconfigured repos + result = ProcessResult() return result def get_packages(self): @@ -101,10 +110,30 @@ def get_action(self, remove_obsolete) -> List[str]: """ Disable or enforce obsolete flag in dnf/yum. """ + result = ["-y"] + if self.type is AgentType.UPDATE_VM: + result.extend(["upgrade", + "--noplugins", + "--best", + "--allowerasing", + "--downloadonly", + "--installroot", self.UPDATE_VM_INSTALLROOT, + f"--setopt=cachedir={self.UPDATE_VM_INSTALLROOT}/var/cache/dnf", + f"--config={self.UPDATE_VM_INSTALLROOT}/etc/dnf/dnf.conf", + f"--setopt=reposdir={self.UPDATE_VM_INSTALLROOT}/etc/yum.repos.d", + "--exclude=qubes-template-*", "-y" + ]) + return result if remove_obsolete: - return ["-y", "--setopt=obsoletes=1", "upgrade"] - return ["-y", "--setopt=obsoletes=0", - "upgrade" if self.package_manager == "dnf" else "update"] + result.extend(["--setopt=obsoletes=1", "upgrade"]) + else: + result.append("--setopt=obsoletes=0") + if self.package_manager == "dnf": + result.append("upgrade") + else: + # yum + result.append("update") + return result def clean(self) -> int: """ diff --git a/vmupdate/agent/source/status.py b/vmupdate/agent/source/status.py index b302615..6c83016 100644 --- a/vmupdate/agent/source/status.py +++ b/vmupdate/agent/source/status.py @@ -59,3 +59,13 @@ def updating(qube, percent: float): @staticmethod def done(qube, status: FinalStatus): return StatusInfo(qube, Status.DONE, info=status) + + +class FormatedLine: + def __init__(self, qube_name, stream: str, message: str): + self.qname = qube_name + self.stream = stream + self.message = message + + def __str__(self): + return f"{self.qname}:{self.stream}: {self.message}" \ No newline at end of file diff --git a/vmupdate/agent/source/utils.py b/vmupdate/agent/source/utils.py index d486954..21678af 100644 --- a/vmupdate/agent/source/utils.py +++ b/vmupdate/agent/source/utils.py @@ -62,6 +62,10 @@ def get_os_data(logger: Optional = None) -> Dict[str, Any]: if 'rhel' in family or 'fedora' in family: data["os_family"] = 'RedHat' + if 'qubes' in family: + # We do not want to use 'RedHat' for dom0 since usually plugins do not apply to dom0 + data["os_family"] = 'Qubes' + if 'arch' in family: data["os_family"] = 'ArchLinux' diff --git a/vmupdate/qube_connection.py b/vmupdate/qube_connection.py index 01279cc..91d18b4 100644 --- a/vmupdate/qube_connection.py +++ b/vmupdate/qube_connection.py @@ -21,6 +21,7 @@ import os import shutil import signal +import subprocess import tempfile import concurrent.futures from os.path import join @@ -30,7 +31,7 @@ import qubesadmin from vmupdate.agent.source.args import AgentArgs from vmupdate.agent.source.log_congfig import LOGPATH, LOG_FILE -from vmupdate.agent.source.status import StatusInfo, FinalStatus +from vmupdate.agent.source.status import StatusInfo, FinalStatus, FormatedLine from vmupdate.agent.source.common.process_result import ProcessResult @@ -157,14 +158,9 @@ def run_entrypoint( :param agent_args: args for agent entrypoint :return: return code and output of the script """ - # make sure entrypoint is executable - command = ['chmod', 'u+x', entrypoint_path] - result = self._run_shell_command_in_qube(self.qube, command) - - # run entrypoint command = [QubeConnection.PYTHON_PATH, entrypoint_path, *AgentArgs.to_cli_args(agent_args)] - result += self._run_shell_command_in_qube( + result = self._run_shell_command_in_qube( self.qube, command, show=self.show_progress) return result @@ -215,11 +211,21 @@ def _run_command_and_wait_for_output( def _run_command_and_actively_report_progress( self, target, command: List[str] ) -> ProcessResult: - proc = target.run_service( - 'qubes.VMExec+' + qubesadmin.utils.encode_for_vmexec(command), - user='root', - preexec_fn=lambda: signal.signal(signal.SIGINT, signal.SIG_IGN) - ) + if self.qube.klass == "AdminVM": + proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif "--download-only" in command: + # for download-only commands, run with fakeroot + proc = target.run_service( + 'qubes.VMExec+' + qubesadmin.utils.encode_for_vmexec(["fakeroot"] + command), + user='user', + preexec_fn=lambda: signal.signal(signal.SIGINT, signal.SIG_IGN) + ) + else: + proc = target.run_service( + 'qubes.VMExec+' + qubesadmin.utils.encode_for_vmexec(command), + user='root', + preexec_fn=lambda: signal.signal(signal.SIGINT, signal.SIG_IGN) + ) self.logger.debug("Fetching agent process stdout/stderr.") with concurrent.futures.ThreadPoolExecutor() as executor: @@ -249,7 +255,7 @@ def _collect_stderr(self, proc) -> bytes: try: progress = float(line) except ValueError: - self._print('err', line) + self.status_notifier.put(FormatedLine(self.qube.name, 'err', line)) continue if progress == 100.: @@ -257,7 +263,7 @@ def _collect_stderr(self, proc) -> bytes: self.status_notifier.put( StatusInfo.updating(self.qube, progress)) else: - self._print('err', line) + self.status_notifier.put(FormatedLine(self.qube.name, 'err', line)) proc.stderr.close() self.logger.debug("Agent stderr closed.") @@ -270,12 +276,9 @@ def _collect_stdout(self, proc) -> bytes: line = ProcessResult.sanitize_output( untrusted_line, single=True) if line: - self._print('out', line) + self.status_notifier.put(FormatedLine(self.qube.name, 'out', line)) - proc.stderr.close() + proc.stdout.close() self.logger.debug("Agent stdout closed.") return b'' - - def _print(self, stream: str, line: str): - self.status_notifier.put(f"{self.qube.name}:{stream}: {line}") diff --git a/vmupdate/tests/test_vmupdate.py b/vmupdate/tests/test_vmupdate.py index 7cd8a36..4f55ef0 100644 --- a/vmupdate/tests/test_vmupdate.py +++ b/vmupdate/tests/test_vmupdate.py @@ -45,6 +45,14 @@ def test_no_options_do_nothing(_logger, _log_file, _chmod, _chown, test_qapp): assert main(args, test_qapp) == EXIT.OK_NO_UPDATES +class Subproc: + def __init__(self, returncode=0): + self.returncode = returncode + + def wait(self): + pass + + @patch('vmupdate.update_manager.TerminalMultiBar.print') @patch('os.chmod') @patch('os.chown') @@ -53,11 +61,13 @@ def test_no_options_do_nothing(_logger, _log_file, _chmod, _chown, test_qapp): @patch('vmupdate.update_manager.UpdateAgentManager') @patch('multiprocessing.Pool') @patch('multiprocessing.Manager') +@patch('subprocess.Popen') def test_preselection( - mp_manager, mp_pool, agent_mng, + dummy_subprocess, mp_manager, mp_pool, agent_mng, _logger, _log_file, _chmod, _chown, _print, test_qapp, test_manager, test_pool, test_agent, ): + dummy_subprocess.return_value = Subproc() mp_manager.return_value = test_manager mp_pool.return_value = test_pool @@ -173,12 +183,14 @@ def test_preselection( @patch('vmupdate.update_manager.UpdateAgentManager') @patch('multiprocessing.Pool') @patch('multiprocessing.Manager') +@patch('subprocess.Popen') def test_selection( - mp_manager, mp_pool, agent_mng, + dummy_subprocess, mp_manager, mp_pool, agent_mng, _logger, _log_file, _chmod, _chown, _print, test_qapp, test_manager, test_pool, test_agent, monkeypatch ): + dummy_subprocess.return_value = Subproc() mp_manager.return_value = test_manager mp_pool.return_value = test_pool @@ -213,7 +225,8 @@ def test_selection( else: feed = {vm.name: {'statuses': [FinalStatus.SUCCESS], 'retcode': EXIT.OK} - for vm in selected} + for vm in selected + if vm.klass != "AdminVM"} # dom0 is not updated via agent monkeypatch.setattr( vmupdate, "preselect_targets", lambda *_: selected) if feed: @@ -246,12 +259,14 @@ def test_selection( @patch('multiprocessing.Pool') @patch('multiprocessing.Manager') @patch('asyncio.run') +@patch('subprocess.Popen') def test_restarting( - arun, mp_manager, mp_pool, agent_mng, + dummy_subprocess, arun, mp_manager, mp_pool, agent_mng, _logger, _log_file, _chmod, _chown, _print, test_qapp, test_manager, test_pool, test_agent, monkeypatch ): + dummy_subprocess.return_value = Subproc() mp_manager.return_value = test_manager mp_pool.return_value = test_pool @@ -297,7 +312,8 @@ def test_restarting( monkeypatch.setattr(vmupdate, "get_targets", lambda *_: all) feed = {vm.name: {'statuses': [vm.update_result], 'retcode': None} # we don't care - for vm in all} + for vm in all + if vm.klass != "AdminVM"} # dom0 is not updated via agent unexpected = [] agent_mng.side_effect = test_agent(feed, unexpected) diff --git a/vmupdate/update_manager.py b/vmupdate/update_manager.py index e4977bc..52c0a33 100644 --- a/vmupdate/update_manager.py +++ b/vmupdate/update_manager.py @@ -32,7 +32,7 @@ from tqdm import tqdm -from .agent.source.status import StatusInfo, FinalStatus, Status +from .agent.source.status import StatusInfo, FinalStatus, Status, FormatedLine from .qube_connection import QubeConnection from vmupdate.agent.source.log_congfig import init_logs from vmupdate.agent.source.common.process_result import ProcessResult @@ -51,6 +51,7 @@ def __init__(self, qubes, args, log): self.quiet = args.quiet self.no_progress = args.no_progress self.just_print_progress = args.just_print_progress + self.download_only = args.download_only self.buffered = not args.just_print_progress and not args.no_progress self.buffer = "" self.cleanup = not args.no_cleanup @@ -67,7 +68,7 @@ def run(self, agent_args): return EXIT.OK, {} show_progress = not self.quiet and not self.no_progress - SimpleTerminalBar.reinit_class() + SimpleTerminalBar.reinit_class(self.download_only) progress_output = SimpleTerminalBar \ if self.just_print_progress else tqdm progress_bar = MultipleUpdateMultipleProgressBar( @@ -78,13 +79,19 @@ def run(self, agent_args): ) for qube in self.qubes: - progress_bar.add_bar(qube.name) + disp_name = agent_args.display_name \ + if agent_args.display_name is not None else qube.name + progress_bar.add_bar(disp_name) progress_bar.pool.apply_async( update_qube, (qube, agent_args, show_progress, progress_bar.status_notifier, progress_bar.termination), callback=self.collect_result, error_callback=print ) + if qube.klass == "AdminVM": + # progress of AdminVM is continuation of different process, + # so we want to skip 0 value at beginning + progress_bar.progress_bars[qube.name].progress = None progress_bar.pool.close() progress_bar.feeding() @@ -122,15 +129,15 @@ def collect_result(self, result_tuple: Tuple[str, ProcessResult]): if self.show_output: for line in result.out.split('\n'): - self.print(qube_name + ":out:", line) + self.print(FormatedLine(qube_name, "out", line)) for line in result.err.split('\n'): - self.print(qube_name + ":err:", line) + self.print(FormatedLine(qube_name, "err", line)) elif not self.quiet and self.no_progress: self.print(result.out) def print(self, *args): if self.buffered: - self.buffer += ' '.join(args) + '\n' + self.buffer += ' '.join(str(args)) + '\n' else: print(*args, file=sys.stdout, flush=True) @@ -146,6 +153,7 @@ def print(self): class SimpleTerminalBar: PARENT_MULTI_BAR = None + DOWNLOAD_ONLY = False def __init__(self, total, position, desc): assert position == len(SimpleTerminalBar.PARENT_MULTI_BAR.progresses) @@ -162,13 +170,19 @@ def __str__(self): FinalStatus.ERROR.value, FinalStatus.CANCELLED.value, FinalStatus.NO_UPDATES.value): + if SimpleTerminalBar.DOWNLOAD_ONLY: + return "" info = status.replace(" ", "_") status = "done" if status == Status.UPDATING.value: + if self.progress is None: + return "" info = self.progress return f"{name} {status} {info}" def update(self, progress): + if self.progress is None: + self.progress = 0 self.progress += progress SimpleTerminalBar.PARENT_MULTI_BAR.print() @@ -181,8 +195,9 @@ def close(self): pass @staticmethod - def reinit_class(): + def reinit_class(download_only = False): SimpleTerminalBar.PARENT_MULTI_BAR = TerminalMultiBar() + SimpleTerminalBar.DOWNLOAD_ONLY = download_only class MultipleUpdateMultipleProgressBar: @@ -291,18 +306,29 @@ def update_qube( :param termination: signal to gracefully terminate subprocess :return: """ + if agent_args.display_name is not None: + status_notifier = StatusNotifierWrapper(status_notifier, agent_args.display_name) + if termination.value: status_notifier.put(StatusInfo.done(qube, FinalStatus.CANCELLED)) return qube.name, ProcessResult(EXIT.SIGINT, "Canceled") - status_notifier.put(StatusInfo.updating(qube, 0)) try: - runner = UpdateAgentManager( - qube.app, - qube, - agent_args=agent_args, - show_progress=show_progress - ) + if qube.klass == "AdminVM": + # AdminVM update + runner = AdminVMAgentManager( + qube.app, + qube, + agent_args=agent_args, + show_progress=show_progress + ) + else: + runner = UpdateAgentManager( + qube.app, + qube, + agent_args=agent_args, + show_progress=show_progress + ) result = runner.run_agent( agent_args=agent_args, status_notifier=status_notifier, @@ -408,3 +434,70 @@ def _run_agent( self.log_handler.setFormatter(self.log_formatter) return result + +class StatusNotifierWrapper: + """ + Masks proxy VM with display name. + """ + def __init__(self, status_notifier, qube_name): + self.status_notifier = status_notifier + self.qube_name = qube_name + + def put(self, message): + if isinstance(message, (StatusInfo, FormatedLine)): + message.qname = self.qube_name + self.status_notifier.put(message) + + +class AdminVMAgentManager(UpdateAgentManager): + """ + Handle AdminVM updates. + """ + def __init__(self, app, qube, agent_args, show_progress): + super().__init__(app, qube, agent_args, show_progress) + + def run_agent( + self, agent_args, status_notifier, termination + ) -> ProcessResult: + """ + Download updates in UpdateVM and install them in AdminVM. + """ + status_notifier = StatusNotifierWrapper(status_notifier, "dom0") + result = self._run_agent( + agent_args, status_notifier, termination) + output = result.out.split("\n") + result.err.split("\n") + for line in output: + 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 == EXIT.OK else \ + f"ERROR (exit code {result.code}, details in {self.log_path})" + return result + + def _run_agent( + self, agent_args, status_notifier, termination + ) -> ProcessResult: + self.log.info('Running update agent for %s', self.qube.name) + this_dir = os.path.dirname(os.path.realpath(__file__)) + dest_agent = join(this_dir, UpdateAgentManager.ENTRYPOINT) + + with QubeConnection( + self.qube, + None, + False, + self.log, + self.show_progress, + status_notifier + ) as qconn: + if termination.value: + qconn.status = FinalStatus.CANCELLED + return ProcessResult(EXIT.SIGINT, "", "Cancelled") + + self.log.info( + "The agent is starting the task in qube: %s", self.qube.name) + result = qconn.run_entrypoint(dest_agent, agent_args) + if not result and qconn.status != FinalStatus.NO_UPDATES: + qconn.status = FinalStatus.SUCCESS + + return result + diff --git a/vmupdate/vmupdate.py b/vmupdate/vmupdate.py index e8efc20..b03b9e0 100644 --- a/vmupdate/vmupdate.py +++ b/vmupdate/vmupdate.py @@ -5,6 +5,7 @@ import argparse import asyncio import logging +import subprocess import sys import os import grp @@ -59,16 +60,36 @@ def main(args=None, app=qubesadmin.Qubes()): print("No qube selected for update") return EXIT.OK_NO_UPDATES if args.signal_no_updates else EXIT.OK + admin = [target for target in targets if target.klass == 'AdminVM'] independent = [target for target in targets if target.klass in ( 'TemplateVM', 'StandaloneVM')] derived = [target for target in targets if target.klass not in ( - 'TemplateVM', 'StandaloneVM')] + 'AdminVM', 'TemplateVM', 'StandaloneVM')] + + no_updates = True + ret_code_admin = EXIT.OK + if admin: + message = f"The admin VM ({admin[0].name}) will be updated." + else: + message = "The admin VM will not be updated." + if args.dry_run: + print(message) + elif admin: + log.debug(message) + if args.just_print_progress and args.no_refresh: + # internal usage just for installing ready updates, use carefully + ret_code_admin, admin_status = run_update(admin, args, log, "admin VM") + else: + # use qubes-dom0-update to update dom0 + ret_code_admin, admin_status = run_admin_update(admin[0], args, log) + no_updates = all(stat == FinalStatus.NO_UPDATES + for stat in admin_status.values()) # 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.values()) + for stat in templ_statuses.values()) and no_updates # then derived qubes (AppVMs...) ret_code_appvm, app_statuses = run_update(derived, args, log) no_updates = all(stat == FinalStatus.NO_UPDATES @@ -77,9 +98,11 @@ def main(args=None, app=qubesadmin.Qubes()): 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) + ret_code = max(ret_code_admin, ret_code_independent, ret_code_appvm, ret_code_restart) if ret_code == EXIT.OK and no_updates and args.signal_no_updates: return EXIT.OK_NO_UPDATES + if ret_code == EXIT.OK_NO_UPDATES and not args.signal_no_updates: + return EXIT.OK return ret_code @@ -153,6 +176,11 @@ def parse_args(args, app): help='DEFAULT. Target all updatable VMs except AdminVM. ' 'Use explicitly with "--targets" to include both.') + # for internal usage, e.g., download updates via proxy vm + parser.add_argument( + '--display-name', action='store', + help=argparse.SUPPRESS) + AgentArgs.add_arguments(parser) args = parser.parse_args(args) @@ -205,10 +233,7 @@ def preselect_targets(args, app) -> Set[qubesadmin.vm.QubesVM]: # remove skipped qubes and dom0 - not a target to_skip = args.skip.split(',') - if 'dom0' in targets and not args.quiet: - print("Skipping dom0. To update AdminVM use `qubes-dom0-update`") - targets = {vm for vm in targets - if vm.name != 'dom0' and vm.name not in to_skip} + targets = {vm for vm in targets if vm.name not in to_skip} # exclude vms with `skip-update` feature, but allow --targets to override it if not args.targets: @@ -265,20 +290,50 @@ def is_stale(vm, expiration_period): return False +def run_admin_update(admin_vm, args, log): + cmd = ["qubes-dom0-update", "-y"] + if args.quiet: + cmd.append('--quiet') + if args.just_print_progress: + cmd.append("--just-print-progress") + elif args.signal_no_updates: + # --just-print-progress checks it by default + proc = subprocess.Popen(["qubes-dom0-update", "--check-only"]) + proc.wait() + if proc.returncode == 0: + return proc.returncode, {admin_vm.name: FinalStatus.NO_UPDATES} + proc = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr) + proc.wait() + if proc.returncode == 0: + status = FinalStatus.SUCCESS + elif proc.returncode == 100: + status = FinalStatus.NO_UPDATES + else: + status = FinalStatus.ERROR + return proc.returncode, {admin_vm.name: status} + + def run_update( targets, args, log, qube_klass="qubes" ) -> Tuple[int, Dict[str, FinalStatus]]: - if not targets: - return EXIT.OK, {} - - message = f"Following {qube_klass} will be updated:" + \ - ",".join((target.name for target in targets)) + if targets: + message = f"Following {qube_klass} will be updated: " + \ + ", ".join((target.name for target in targets)) + else: + if qube_klass == "qubes": + message = "" # no need to inform about app VMs etc. + else: + message = f"No {qube_klass} will be updated." if args.dry_run: - print(message) + if message: + print(message) return EXIT.OK, {target.name: FinalStatus.SUCCESS for target in targets} else: log.debug(message) + if not targets: + return EXIT.OK, {} + runner = update_manager.UpdateManager(targets, args, log=log) ret_code, statuses = runner.run(agent_args=args) if ret_code: