From f47cc147685289e1d43599a2bda82a76aad5969d Mon Sep 17 00:00:00 2001 From: Rajasi Rane Date: Mon, 15 Sep 2025 02:30:03 -0700 Subject: [PATCH 1/6] [AzL3Tdnf] Refactoring TDNF Package Manager separating Azure Linux specialiazations in it's own implementation --- .../src/bootstrap/ConfigurationFactory.py | 8 +- .../AzL3TdnfPackageManager.py | 575 ++++++++++++++++ .../package_managers/TdnfPackageManager.py | 545 ++------------- src/core/tests/Test_AzL3TdnfPackageManager.py | 628 ++++++++++++++++++ src/core/tests/Test_TdnfPackageManager.py | 600 +---------------- 5 files changed, 1253 insertions(+), 1103 deletions(-) create mode 100644 src/core/src/package_managers/AzL3TdnfPackageManager.py create mode 100644 src/core/tests/Test_AzL3TdnfPackageManager.py diff --git a/src/core/src/bootstrap/ConfigurationFactory.py b/src/core/src/bootstrap/ConfigurationFactory.py index ad308f31..30bf7db0 100644 --- a/src/core/src/bootstrap/ConfigurationFactory.py +++ b/src/core/src/bootstrap/ConfigurationFactory.py @@ -37,7 +37,7 @@ from core.src.local_loggers.CompositeLogger import CompositeLogger from core.src.package_managers.AptitudePackageManager import AptitudePackageManager -from core.src.package_managers.TdnfPackageManager import TdnfPackageManager +from core.src.package_managers.AzL3TdnfPackageManager import AzL3TdnfPackageManager from core.src.package_managers.YumPackageManager import YumPackageManager from core.src.package_managers.ZypperPackageManager import ZypperPackageManager @@ -70,17 +70,17 @@ def __init__(self, log_file_path, events_folder, telemetry_supported): self.configurations = { 'apt_prod_config': self.new_prod_configuration(Constants.APT, AptitudePackageManager), - 'tdnf_prod_config': self.new_prod_configuration(Constants.TDNF, TdnfPackageManager), + 'tdnf_prod_config': self.new_prod_configuration(Constants.TDNF, AzL3TdnfPackageManager), 'yum_prod_config': self.new_prod_configuration(Constants.YUM, YumPackageManager), 'zypper_prod_config': self.new_prod_configuration(Constants.ZYPPER, ZypperPackageManager), 'apt_dev_config': self.new_dev_configuration(Constants.APT, AptitudePackageManager), - 'tdnf_dev_config': self.new_dev_configuration(Constants.TDNF, TdnfPackageManager), + 'tdnf_dev_config': self.new_dev_configuration(Constants.TDNF, AzL3TdnfPackageManager), 'yum_dev_config': self.new_dev_configuration(Constants.YUM, YumPackageManager), 'zypper_dev_config': self.new_dev_configuration(Constants.ZYPPER, ZypperPackageManager), 'apt_test_config': self.new_test_configuration(Constants.APT, AptitudePackageManager), - 'tdnf_test_config': self.new_test_configuration(Constants.TDNF, TdnfPackageManager), + 'tdnf_test_config': self.new_test_configuration(Constants.TDNF, AzL3TdnfPackageManager), 'yum_test_config': self.new_test_configuration(Constants.YUM, YumPackageManager), 'zypper_test_config': self.new_test_configuration(Constants.ZYPPER, ZypperPackageManager) } diff --git a/src/core/src/package_managers/AzL3TdnfPackageManager.py b/src/core/src/package_managers/AzL3TdnfPackageManager.py new file mode 100644 index 00000000..d65b23a6 --- /dev/null +++ b/src/core/src/package_managers/AzL3TdnfPackageManager.py @@ -0,0 +1,575 @@ +# Copyright 2025 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.7+ + +"""AzL3TdnfPackageManager for Azure Linux""" +import json +import os +import re + +from core.src.core_logic.VersionComparator import VersionComparator +from core.src.package_managers.TdnfPackageManager import TdnfPackageManager +from core.src.bootstrap.Constants import Constants + + +class AzL3TdnfPackageManager(TdnfPackageManager): + """Implementation of Azure Linux package management operations""" + + def __init__(self, env_layer, execution_config, composite_logger, telemetry_writer, status_handler): + super(AzL3TdnfPackageManager, self).__init__(env_layer, execution_config, composite_logger, telemetry_writer, status_handler) + + # Support to get updates and their dependencies + self.tdnf_check = 'sudo tdnf -q list updates ' + + # Install update + self.install_security_updates_azgps_coordinated_cmd = 'sudo tdnf -y upgrade --skip-broken ' + + # Support to check for processes requiring restart + self.dnf_utils_prerequisite = 'sudo tdnf -y install dnf-utils' + self.needs_restarting_with_flag = 'sudo LANG=en_US.UTF8 needs-restarting -r' + + # auto OS updates + self.current_auto_os_update_service = None + self.os_patch_configuration_settings_file_path = '' + self.auto_update_service_enabled = False + self.auto_update_config_pattern_match_text = "" + self.download_updates_identifier_text = "" + self.apply_updates_identifier_text = "" + self.enable_on_reboot_identifier_text = "" + self.enable_on_reboot_check_cmd = '' + self.enable_on_reboot_cmd = '' + self.installation_state_identifier_text = "" + self.install_check_cmd = "" + self.apply_updates_enabled = "Enabled" + self.apply_updates_disabled = "Disabled" + self.apply_updates_unknown = "Unknown" + + # commands for DNF Automatic updates service + self.__init_constants_for_dnf_automatic() + + # Strict SDP specializations + self.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP = "3.5.8-3.azl3" # minimum version of tdnf required to support Strict SDP in Azure Linux + + # Miscellaneous + self.set_package_manager_setting(Constants.PKG_MGR_SETTING_IDENTITY, Constants.TDNF) + self.version_comparator = VersionComparator() + + # if an Auto Patching request comes in on an Azure Linux machine with Security and/or Critical classifications selected, we need to install all patches, since classifications aren't available in Azure Linux repository + installation_included_classifications = [] if execution_config.included_classifications_list is None else execution_config.included_classifications_list + if execution_config.health_store_id is not str() and execution_config.operation.lower() == Constants.INSTALLATION.lower() \ + and (env_layer.is_distro_azure_linux(str(env_layer.platform.linux_distribution()))) \ + and 'Critical' in installation_included_classifications and 'Security' in installation_included_classifications: + self.composite_logger.log_debug("Updating classifications list to install all patches for the Auto Patching request since classification based patching is not available on Azure Linux machines") + execution_config.included_classifications_list = [Constants.PackageClassification.CRITICAL, Constants.PackageClassification.SECURITY, Constants.PackageClassification.OTHER] + + # region Strict SDP using SnapshotTime + @staticmethod + def __generate_command_with_snapshotposixtime_if_specified(command_template, snapshot_posix_time=str()): + # type: (str, str) -> str + if snapshot_posix_time == str(): + return command_template.replace('', str()) + else: + return command_template.replace('', ('--snapshottime={0}'.format(str(snapshot_posix_time)))) + # endregion + + # region Get Available Updates + # region Classification-based (incl. All) update check + def get_all_updates(self, cached=False): + """Get all missing updates""" + self.composite_logger.log_verbose("[AzL3TDNF] Discovering all packages...") + if cached and not len(self.all_updates_cached) == 0: + self.composite_logger.log_debug("[AzL3TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(cached), len(self.all_updates_cached))) + return self.all_updates_cached, self.all_update_versions_cached # allows for high performance reuse in areas of the code explicitly aware of the cache + + out = self.invoke_package_manager(self.__generate_command_with_snapshotposixtime_if_specified(self.tdnf_check, self.max_patch_publish_date)) + self.all_updates_cached, self.all_update_versions_cached = self.extract_packages_and_versions(out) + self.composite_logger.log_debug("[AzL3TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(False), len(self.all_updates_cached))) + return self.all_updates_cached, self.all_update_versions_cached + + def get_security_updates(self): + """Get missing security updates. NOTE: Classification based categorization of patches is not available in Azure Linux as of now""" + self.composite_logger.log_verbose("[AzL3TDNF] Discovering all packages as 'security' packages, since TDNF does not support package classification...") + security_packages, security_package_versions = self.get_all_updates(cached=False) + self.composite_logger.log_debug("[AzL3TDNF] Discovered 'security' packages. [Count={0}]".format(len(security_packages))) + return security_packages, security_package_versions + + def get_other_updates(self): + """Get missing other updates.""" + self.composite_logger.log_verbose("[AzL3TDNF] Discovering 'other' packages...") + return [], [] + + def set_max_patch_publish_date(self, max_patch_publish_date=str()): + """Set the max patch publish date in POSIX time for strict SDP""" + self.composite_logger.log_debug("[AzL3TDNF] Setting max patch publish date. [MaxPatchPublishDate={0}]".format(str(max_patch_publish_date))) + self.max_patch_publish_date = str(self.env_layer.datetime.datetime_string_to_posix_time(max_patch_publish_date, '%Y%m%dT%H%M%SZ')) if max_patch_publish_date != str() else max_patch_publish_date + self.composite_logger.log_debug("[AzL3TDNF] Set max patch publish date. [MaxPatchPublishDatePosixTime={0}]".format(str(self.max_patch_publish_date))) + # endregion + + # endregion + + # region Install Updates + def install_updates_fail_safe(self, excluded_packages): + return + + def install_security_updates_azgps_coordinated(self): + """Install security updates in Azure Linux following strict SDP""" + command = self.__generate_command_with_snapshotposixtime_if_specified(self.install_security_updates_azgps_coordinated_cmd, self.max_patch_publish_date) + out, code = self.invoke_package_manager_advanced(command, raise_on_exception=False) + return code, out + + def try_meet_azgps_coordinated_requirements(self): + # type: () -> bool + """ Check if the system meets the requirements for Azure Linux strict safe deployment and attempt to update TDNF if necessary """ + self.composite_logger.log_debug("[AzL3TDNF] Checking if system meets Azure Linux security updates requirements...") + # Check if the system is Azure Linux 3.0 or beyond + if not self.env_layer.is_distro_azure_linux_3_or_beyond(): + self.composite_logger.log_error("[AzL3TDNF] The system does not meet minimum Azure Linux requirement of 3.0 or above for strict safe deployment. Defaulting to regular upgrades.") + self.set_max_patch_publish_date() # fall-back + return False + else: + if self.is_minimum_tdnf_version_for_strict_sdp_installed(): + self.composite_logger.log_debug("[AzL3TDNF] Minimum tdnf version for strict safe deployment is installed.") + return True + else: + if not self.try_tdnf_update_to_meet_strict_sdp_requirements(): + error_msg = "Failed to meet minimum TDNF version requirement for strict safe deployment. Defaulting to regular upgrades." + self.composite_logger.log_error(error_msg + "[Error={0}]".format(repr(error_msg))) + self.status_handler.add_error_to_status(error_msg) + self.set_max_patch_publish_date() # fall-back + return False + return True + + def is_minimum_tdnf_version_for_strict_sdp_installed(self): + # type: () -> bool + """Check if at least the minimum required version of TDNF is installed""" + self.composite_logger.log_debug("[AzL3TDNF] Checking if minimum TDNF version required for strict safe deployment is installed...") + tdnf_version = self.get_tdnf_version() + minimum_tdnf_version_for_strict_sdp = self.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP + distro_from_minimum_tdnf_version_for_strict_sdp = re.match(r".*-\d+\.([a-zA-Z0-9]+)$", minimum_tdnf_version_for_strict_sdp).group(1) + if tdnf_version is None: + self.composite_logger.log_error("[AzL3TDNF] Failed to get TDNF version. Cannot proceed with strict safe deployment. Defaulting to regular upgrades.") + return False + elif re.match(r".*-\d+\.([a-zA-Z0-9]+)$", tdnf_version).group(1) != distro_from_minimum_tdnf_version_for_strict_sdp: + self.composite_logger.log_warning("[AzL3TDNF] TDNF version installed is not from the same Azure Linux distribution as the minimum required version for strict SDP. [InstalledVersion={0}][MinimumRequiredVersion={1}]".format(tdnf_version, self.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP)) + return False + elif not self.version_comparator.compare_versions(tdnf_version, minimum_tdnf_version_for_strict_sdp) >= 0: + self.composite_logger.log_warning("[AzL3TDNF] TDNF version installed is less than the minimum required version for strict SDP. [InstalledVersion={0}][MinimumRequiredVersion={1}]".format(tdnf_version, self.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP)) + return False + return True + + def get_tdnf_version(self): + # type: () -> any + """Get the version of TDNF installed on the system""" + self.composite_logger.log_debug("[AzL3TDNF] Getting tdnf version...") + cmd = "rpm -q tdnf | sed -E 's/^tdnf-([0-9]+\\.[0-9]+\\.[0-9]+-[0-9]+\\.[a-zA-Z0-9]+).*/\\1/'" + code, output = self.env_layer.run_command_output(cmd, False, False) + if code == 0: + # Sample output: 3.5.8-3-azl3 + version = output.split()[0] if output else None + self.composite_logger.log_debug("[AzL3TDNF] TDNF version detected. [Version={0}]".format(version)) + return version + else: + self.composite_logger.log_error("[AzL3TDNF] Failed to get TDNF version. [Command={0}][Code={1}][Output={2}]".format(cmd, code, output)) + return None + + def try_tdnf_update_to_meet_strict_sdp_requirements(self): + # type: () -> bool + """Attempt to update TDNF to meet the minimum version required for strict SDP""" + self.composite_logger.log_debug("[AzL3TDNF] Attempting to update TDNF to meet strict safe deployment requirements...") + cmd = "sudo tdnf -y install tdnf-" + self.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP + code, output = self.env_layer.run_command_output(cmd, no_output=True, chk_err=False) + if code == 0: + self.composite_logger.log_debug("[AzL3TDNF] Successfully updated TDNF for Strict SDP. [Command={0}][Code={1}]".format(cmd, code)) + return True + else: + self.composite_logger.log_error("[AzL3TDNF] Failed to update TDNF for Strict SDP. [Command={0}][Code={1}][Output={2}]".format(cmd, code, output)) + return False + # endregion + + # region auto OS updates + def __init_constants_for_dnf_automatic(self): + self.dnf_automatic_configuration_file_path = '/etc/dnf/automatic.conf' + self.dnf_automatic_install_check_cmd = 'systemctl list-unit-files --type=service | grep dnf-automatic.service' # list-unit-files returns installed services, ref: https://www.freedesktop.org/software/systemd/man/systemctl.html#Unit%20File%20Commands + self.dnf_automatic_enable_on_reboot_check_cmd = 'systemctl is-enabled dnf-automatic.timer' + self.dnf_automatic_disable_on_reboot_cmd = 'systemctl disable dnf-automatic.timer' + self.dnf_automatic_enable_on_reboot_cmd = 'systemctl enable dnf-automatic.timer' + self.dnf_automatic_config_pattern_match_text = ' = (no|yes)' + self.dnf_automatic_download_updates_identifier_text = 'download_updates' + self.dnf_automatic_apply_updates_identifier_text = 'apply_updates' + self.dnf_automatic_enable_on_reboot_identifier_text = "enable_on_reboot" + self.dnf_automatic_installation_state_identifier_text = "installation_state" + self.dnf_auto_os_update_service = "dnf-automatic" + + def get_current_auto_os_patch_state(self): + """ Gets the current auto OS update patch state on the machine """ + self.composite_logger.log("[AzL3TDNF] Fetching the current automatic OS patch state on the machine...") + + current_auto_os_patch_state_for_dnf_automatic = self.__get_current_auto_os_patch_state_for_dnf_automatic() + + self.composite_logger.log("[AzL3TDNF] OS patch state per auto OS update service: [dnf-automatic={0}]".format(str(current_auto_os_patch_state_for_dnf_automatic))) + + if current_auto_os_patch_state_for_dnf_automatic == Constants.AutomaticOSPatchStates.ENABLED: + current_auto_os_patch_state = Constants.AutomaticOSPatchStates.ENABLED + elif current_auto_os_patch_state_for_dnf_automatic == Constants.AutomaticOSPatchStates.DISABLED: + current_auto_os_patch_state = Constants.AutomaticOSPatchStates.DISABLED + else: + current_auto_os_patch_state = Constants.AutomaticOSPatchStates.UNKNOWN + + self.composite_logger.log_debug("[AzL3TDNF] Overall Auto OS Patch State based on all auto OS update service states [OverallAutoOSPatchState={0}]".format(str(current_auto_os_patch_state))) + return current_auto_os_patch_state + + def __get_current_auto_os_patch_state_for_dnf_automatic(self): + """ Gets current auto OS update patch state for dnf-automatic """ + self.composite_logger.log_debug("[AzL3TDNF] Fetching current automatic OS patch state in dnf-automatic service. This includes checks on whether the service is installed, current auto patch enable state and whether it is set to enable on reboot") + self.__init_auto_update_for_dnf_automatic() + is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() + + apply_updates = self.__get_extension_standard_value_for_apply_updates(apply_updates_value) + + if apply_updates == self.apply_updates_enabled or enable_on_reboot_value: + return Constants.AutomaticOSPatchStates.ENABLED + # OS patch state is considered to be disabled: a) if it was successfully disabled or b) if the service is not installed + elif not is_service_installed or (apply_updates == self.apply_updates_disabled and not enable_on_reboot_value): + return Constants.AutomaticOSPatchStates.DISABLED + else: + return Constants.AutomaticOSPatchStates.UNKNOWN + + def __init_auto_update_for_dnf_automatic(self): + """ Initializes all generic auto OS update variables with the config values for dnf automatic service """ + self.os_patch_configuration_settings_file_path = self.dnf_automatic_configuration_file_path + self.download_updates_identifier_text = self.dnf_automatic_download_updates_identifier_text + self.apply_updates_identifier_text = self.dnf_automatic_apply_updates_identifier_text + self.enable_on_reboot_identifier_text = self.dnf_automatic_enable_on_reboot_identifier_text + self.installation_state_identifier_text = self.dnf_automatic_installation_state_identifier_text + self.auto_update_config_pattern_match_text = self.dnf_automatic_config_pattern_match_text + self.enable_on_reboot_check_cmd = self.dnf_automatic_enable_on_reboot_check_cmd + self.enable_on_reboot_cmd = self.dnf_automatic_enable_on_reboot_cmd + self.install_check_cmd = self.dnf_automatic_install_check_cmd + self.current_auto_os_update_service = self.dnf_auto_os_update_service + + def __get_current_auto_os_updates_setting_on_machine(self): + """ Gets all the update settings related to auto OS updates currently set on the machine """ + try: + download_updates_value = "" + apply_updates_value = "" + is_service_installed = False + enable_on_reboot_value = False + + # get install state + if not self.is_auto_update_service_installed(self.install_check_cmd): + return is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value + + is_service_installed = True + enable_on_reboot_value = self.is_service_set_to_enable_on_reboot(self.enable_on_reboot_check_cmd) + + self.composite_logger.log_debug("[AzL3TDNF] Checking if auto updates are currently enabled...") + image_default_patch_configuration = self.env_layer.file_system.read_with_retry(self.os_patch_configuration_settings_file_path, raise_if_not_found=False) + if image_default_patch_configuration is not None: + settings = image_default_patch_configuration.strip().split('\n') + for setting in settings: + match = re.search(self.download_updates_identifier_text + self.auto_update_config_pattern_match_text, str(setting)) + if match is not None: + download_updates_value = match.group(1) + + match = re.search(self.apply_updates_identifier_text + self.auto_update_config_pattern_match_text, str(setting)) + if match is not None: + apply_updates_value = match.group(1) + + if download_updates_value == "": + self.composite_logger.log_debug("[AzL3TDNF] Machine did not have any value set for [Setting={0}]".format(str(self.download_updates_identifier_text))) + else: + self.composite_logger.log_verbose("[AzL3TDNF] Current value set for [{0}={1}]".format(str(self.download_updates_identifier_text), str(download_updates_value))) + + if apply_updates_value == "": + self.composite_logger.log_debug("[AzL3TDNF] Machine did not have any value set for [Setting={0}]".format(str(self.apply_updates_identifier_text))) + else: + self.composite_logger.log_verbose("[AzL3TDNF] Current value set for [{0}={1}]".format(str(self.apply_updates_identifier_text), str(apply_updates_value))) + + return is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value + + except Exception as error: + raise Exception("[AzL3TDNF] Error occurred in fetching current auto OS update settings from the machine. [Exception={0}]".format(repr(error))) + + def is_auto_update_service_installed(self, install_check_cmd): + """ Checks if the auto update service is enable_on_reboot on the VM """ + code, out = self.env_layer.run_command_output(install_check_cmd, False, False) + self.composite_logger.log_debug("[AzL3TDNF] Checked if auto update service is installed. [Command={0}][Code={1}][Output={2}]".format(install_check_cmd, str(code), out)) + if len(out.strip()) > 0 and code == 0: + self.composite_logger.log_debug("[AzL3TDNF] > Auto OS update service is installed on the machine") + return True + else: + self.composite_logger.log_debug("[AzL3TDNF] > Auto OS update service is NOT installed on the machine") + return False + + def is_service_set_to_enable_on_reboot(self, command): + """ Checking if auto update is enable_on_reboot on the machine. An enable_on_reboot service will be activated (if currently inactive) on machine reboot """ + code, out = self.env_layer.run_command_output(command, False, False) + self.composite_logger.log_debug("[AzL3TDNF] Checked if auto update service is set to enable on reboot. [Code={0}][Out={1}]".format(str(code), out)) + if len(out.strip()) > 0 and code == 0 and 'enabled' in out: + self.composite_logger.log_debug("[AzL3TDNF] > Auto OS update service will enable on reboot") + return True + self.composite_logger.log_debug("[AzL3TDNF] > Auto OS update service will NOT enable on reboot") + return False + + def __get_extension_standard_value_for_apply_updates(self, apply_updates_value): + if apply_updates_value.lower() == 'yes' or apply_updates_value.lower() == 'true': + return self.apply_updates_enabled + elif apply_updates_value.lower() == 'no' or apply_updates_value.lower() == 'false': + return self.apply_updates_disabled + else: + return self.apply_updates_unknown + + def disable_auto_os_update(self): + """ Disables auto OS updates on the machine only if they are enabled and logs the default settings the machine comes with """ + try: + self.composite_logger.log_verbose("[AzL3TDNF] Disabling auto OS updates in all identified services...") + self.disable_auto_os_update_for_dnf_automatic() + self.composite_logger.log_debug("[AzL3TDNF] Successfully disabled auto OS updates") + + except Exception as error: + self.composite_logger.log_error("[AzL3TDNF] Could not disable auto OS updates. [Error={0}]".format(repr(error))) + raise + + def disable_auto_os_update_for_dnf_automatic(self): + """ Disables auto OS updates, using dnf-automatic service, and logs the default settings the machine comes with """ + self.composite_logger.log_verbose("[AzL3TDNF] Disabling auto OS updates using dnf-automatic") + self.__init_auto_update_for_dnf_automatic() + + self.backup_image_default_patch_configuration_if_not_exists() + + if not self.is_auto_update_service_installed(self.dnf_automatic_install_check_cmd): + self.composite_logger.log_debug("[AzL3TDNF] Cannot disable as dnf-automatic is not installed on the machine") + return + + self.composite_logger.log_verbose("[AzL3TDNF] Preemptively disabling auto OS updates using dnf-automatic") + self.update_os_patch_configuration_sub_setting(self.download_updates_identifier_text, "no", self.dnf_automatic_config_pattern_match_text) + self.update_os_patch_configuration_sub_setting(self.apply_updates_identifier_text, "no", self.dnf_automatic_config_pattern_match_text) + self.disable_auto_update_on_reboot(self.dnf_automatic_disable_on_reboot_cmd) + + self.composite_logger.log_debug("[AzL3TDNF] Successfully disabled auto OS updates using dnf-automatic") + + def disable_auto_update_on_reboot(self, command): + self.composite_logger.log_verbose("[AzL3TDNF] Disabling auto update on reboot. [Command={0}] ".format(command)) + code, out = self.env_layer.run_command_output(command, False, False) + + if code != 0: + self.composite_logger.log_error("[AzL3TDNF][ERROR] Error disabling auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) + error_msg = 'Unexpected return code (' + str(code) + ') on command: ' + command + self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.OPERATION_FAILED) + raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) + else: + self.composite_logger.log_debug("[AzL3TDNF] Disabled auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) + + def backup_image_default_patch_configuration_if_not_exists(self): + """ Records the default system settings for auto OS updates within patch extension artifacts for future reference. + We only log the default system settings a VM comes with, any subsequent updates will not be recorded""" + """ JSON format for backup file: + { + "dnf-automatic": { + "apply_updates": "yes/no/empty string", + "download_updates": "yes/no/empty string", + "enable_on_reboot": true/false, + "installation_state": true/false + } + } """ + try: + self.composite_logger.log_debug("[AzL3TDNF] Ensuring there is a backup of the default patch state for [AutoOSUpdateService={0}]".format(str(self.current_auto_os_update_service))) + image_default_patch_configuration_backup = self.__get_image_default_patch_configuration_backup() + + # verify if existing backup is valid if not, write to backup + is_backup_valid = self.is_image_default_patch_configuration_backup_valid(image_default_patch_configuration_backup) + if is_backup_valid: + self.composite_logger.log_debug("[AzL3TDNF] Since extension has a valid backup, no need to log the current settings again. [Default Auto OS update settings={0}] [File path={1}]" + .format(str(image_default_patch_configuration_backup), self.image_default_patch_configuration_backup_path)) + else: + self.composite_logger.log_debug("[AzL3TDNF] Since the backup is invalid, will add a new backup with the current auto OS update settings") + self.composite_logger.log_debug("[AzL3TDNF] Fetching current auto OS update settings for [AutoOSUpdateService={0}]".format(str(self.current_auto_os_update_service))) + is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() + + backup_image_default_patch_configuration_json_to_add = { + self.current_auto_os_update_service: { + self.download_updates_identifier_text: download_updates_value, + self.apply_updates_identifier_text: apply_updates_value, + self.enable_on_reboot_identifier_text: enable_on_reboot_value, + self.installation_state_identifier_text: is_service_installed + } + } + + image_default_patch_configuration_backup.update(backup_image_default_patch_configuration_json_to_add) + + self.composite_logger.log_debug("[AzL3TDNF] Logging default system configuration settings for auto OS updates. [Settings={0}] [Log file path={1}]" + .format(str(image_default_patch_configuration_backup), self.image_default_patch_configuration_backup_path)) + self.env_layer.file_system.write_with_retry(self.image_default_patch_configuration_backup_path, '{0}'.format(json.dumps(image_default_patch_configuration_backup)), mode='w+') + except Exception as error: + error_message = "[AzL3TDNF] Exception during fetching and logging default auto update settings on the machine. [Exception={0}]".format(repr(error)) + self.composite_logger.log_error(error_message) + self.status_handler.add_error_to_status(error_message, Constants.PatchOperationErrorCodes.DEFAULT_ERROR) + raise + + def is_image_default_patch_configuration_backup_valid(self, image_default_patch_configuration_backup): + """ Verifies if default auto update configurations, for a service under consideration, are saved in backup """ + return self.is_backup_valid_for_dnf_automatic(image_default_patch_configuration_backup) + + def is_backup_valid_for_dnf_automatic(self, image_default_patch_configuration_backup): + if self.dnf_auto_os_update_service in image_default_patch_configuration_backup \ + and self.dnf_automatic_download_updates_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ + and self.dnf_automatic_apply_updates_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ + and self.dnf_automatic_enable_on_reboot_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ + and self.dnf_automatic_installation_state_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service]: + self.composite_logger.log_debug("[AzL3TDNF] Extension has a valid backup for default dnf-automatic configuration settings") + return True + else: + self.composite_logger.log_debug("[AzL3TDNF] Extension does not have a valid backup for default dnf-automatic configuration settings") + return False + + def update_os_patch_configuration_sub_setting(self, patch_configuration_sub_setting, value="no", config_pattern_match_text=""): + """ Updates (or adds if it doesn't exist) the given patch_configuration_sub_setting with the given value in os_patch_configuration_settings_file """ + try: + # note: adding space between the patch_configuration_sub_setting and value since, we will have to do that if we have to add a patch_configuration_sub_setting that did not exist before + self.composite_logger.log_debug("[AzL3TDNF] Updating system configuration settings for auto OS updates. [Patch Configuration Sub Setting={0}] [Value={1}]".format(str(patch_configuration_sub_setting), value)) + os_patch_configuration_settings = self.env_layer.file_system.read_with_retry(self.os_patch_configuration_settings_file_path) + patch_configuration_sub_setting_to_update = patch_configuration_sub_setting + ' = ' + value + patch_configuration_sub_setting_found_in_file = False + updated_patch_configuration_sub_setting = "" + settings = os_patch_configuration_settings.strip().split('\n') + + # update value of existing setting + for i in range(len(settings)): + match = re.search(patch_configuration_sub_setting + config_pattern_match_text, settings[i]) + if match is not None: + settings[i] = patch_configuration_sub_setting_to_update + patch_configuration_sub_setting_found_in_file = True + updated_patch_configuration_sub_setting += settings[i] + "\n" + + # add setting to configuration file, since it doesn't exist + if not patch_configuration_sub_setting_found_in_file: + updated_patch_configuration_sub_setting += patch_configuration_sub_setting_to_update + "\n" + + self.env_layer.file_system.write_with_retry(self.os_patch_configuration_settings_file_path, '{0}'.format(updated_patch_configuration_sub_setting.lstrip()), mode='w+') + except Exception as error: + error_msg = "[AzL3TDNF] Error occurred while updating system configuration settings for auto OS updates. [Patch Configuration={0}] [Error={1}]".format(str(patch_configuration_sub_setting), repr(error)) + self.composite_logger.log_error(error_msg) + self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.DEFAULT_ERROR) + raise + + def revert_auto_os_update_to_system_default(self): + """ Reverts the auto OS update patch state on the machine to its system default value, if one exists in our backup file """ + # type () -> None + self.composite_logger.log("[AzL3TDNF] Reverting the current automatic OS patch state on the machine to its system default value before patchmode was set to 'AutomaticByPlatform'") + self.revert_auto_os_update_to_system_default_for_dnf_automatic() + self.composite_logger.log_debug("[AzL3TDNF] Successfully reverted auto OS updates to system default config") + + def revert_auto_os_update_to_system_default_for_dnf_automatic(self): + """ Reverts the auto OS update patch state on the machine to its system default value for given service, if applicable """ + # type () -> None + self.__init_auto_update_for_dnf_automatic() + self.composite_logger.log("[AzL3TDNF] Reverting the current automatic OS patch state on the machine to its system default value for [Service={0}]".format(str(self.current_auto_os_update_service))) + is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() + + if not is_service_installed: + self.composite_logger.log_debug("[AzL3TDNF] Machine default auto OS update service is not installed on the VM and hence no config to revert. [Service={0}]".format(str(self.current_auto_os_update_service))) + return + + self.composite_logger.log_debug("[AzL3TDNF] Logging current configuration settings for auto OS updates [Service={0}][Is_Service_Installed={1}][Machine_default_update_enable_on_reboot={2}][{3}={4}]][{5}={6}]" + .format(str(self.current_auto_os_update_service), str(is_service_installed), str(enable_on_reboot_value), str(self.download_updates_identifier_text), str(download_updates_value), str(self.apply_updates_identifier_text), str(apply_updates_value))) + + image_default_patch_configuration_backup = self.__get_image_default_patch_configuration_backup() + self.composite_logger.log_debug("[AzL3TDNF] Logging system default configuration settings for auto OS updates. [Settings={0}]".format(str(image_default_patch_configuration_backup))) + is_backup_valid = self.is_image_default_patch_configuration_backup_valid(image_default_patch_configuration_backup) + + if is_backup_valid: + download_updates_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.download_updates_identifier_text] + apply_updates_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.apply_updates_identifier_text] + enable_on_reboot_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.enable_on_reboot_identifier_text] + + self.update_os_patch_configuration_sub_setting(self.download_updates_identifier_text, download_updates_value_from_backup, self.auto_update_config_pattern_match_text) + self.update_os_patch_configuration_sub_setting(self.apply_updates_identifier_text, apply_updates_value_from_backup, self.auto_update_config_pattern_match_text) + if str(enable_on_reboot_value_from_backup).lower() == 'true': + self.enable_auto_update_on_reboot() + else: + self.composite_logger.log_debug("[AzL3TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service={0}]".format(str(self.current_auto_os_update_service))) + + def enable_auto_update_on_reboot(self): + """Enables machine default auto update on reboot""" + # type () -> None + command = self.enable_on_reboot_cmd + self.composite_logger.log_verbose("[AzL3TDNF] Enabling auto update on reboot. [Command={0}] ".format(command)) + code, out = self.env_layer.run_command_output(command, False, False) + + if code != 0: + self.composite_logger.log_error("[AzL3TDNF][ERROR] Error enabling auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) + error_msg = 'Unexpected return code (' + str(code) + ') on command: ' + command + self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.OPERATION_FAILED) + raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) + else: + self.composite_logger.log_debug("[AzL3TDNF] Enabled auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) + + def __get_image_default_patch_configuration_backup(self): + """ Get image_default_patch_configuration_backup file""" + image_default_patch_configuration_backup = {} + + # read existing backup since it also contains backup from other update services. We need to preserve any existing data within the backup file + if self.image_default_patch_configuration_backup_exists(): + try: + image_default_patch_configuration_backup = json.loads(self.env_layer.file_system.read_with_retry(self.image_default_patch_configuration_backup_path)) + except Exception as error: + self.composite_logger.log_error("[AzL3TDNF] Unable to read backup for default patch state. Will attempt to re-write. [Exception={0}]".format(repr(error))) + return image_default_patch_configuration_backup + # endregion + + # region Reboot Management + def is_reboot_pending(self): + """ Checks if there is a pending reboot on the machine. """ + try: + pending_file_exists = os.path.isfile(self.REBOOT_PENDING_FILE_PATH) + pending_processes_exist = self.do_processes_require_restart() + self.composite_logger.log_debug("[AzL3TDNF] > Reboot required debug flags (tdnf): " + str(pending_file_exists) + ", " + str(pending_processes_exist) + ".") + return pending_file_exists or pending_processes_exist + except Exception as error: + self.composite_logger.log_error('[AzL3TDNF] Error while checking for reboot pending (tdnf): ' + repr(error)) + return True # defaults for safety + + def do_processes_require_restart(self): + """Signals whether processes require a restart due to updates""" + self.composite_logger.log_verbose("[AzL3TDNF] Checking if process requires reboot") + # Checking using dnf-utils + code, out = self.env_layer.run_command_output(self.dnf_utils_prerequisite, False, False) # idempotent, doesn't install if already present + self.composite_logger.log_verbose("[AzL3TDNF] Idempotent dnf-utils existence check. [Code={0}][Out={1}]".format(str(code), out)) + + # Checking for restart for distros with -r flag + code, out = self.env_layer.run_command_output(self.needs_restarting_with_flag, False, False) + self.composite_logger.log_verbose("[AzL3TDNF] > Code: " + str(code) + ", Output: \n|\t" + "\n|\t".join(out.splitlines())) + if out.find("Reboot is required") < 0: + self.composite_logger.log_debug("[AzL3TDNF] > Reboot not detected to be required (L1).") + else: + self.composite_logger.log_debug("[AzL3TDNF] > Reboot is detected to be required (L1).") + return True + + return False + # endregion + + def set_security_esm_package_status(self, operation, packages): + """ Set the security-ESM classification for the esm packages. Only needed for apt. No-op for tdnf, yum and zypper.""" + pass + + def separate_out_esm_packages(self, packages, package_versions): + """Filter out packages from the list where the version matches the UA_ESM_REQUIRED string. + Only needed for apt. No-op for tdnf, yum and zypper""" + esm_packages = [] + esm_package_versions = [] + esm_packages_found = False + + return packages, package_versions, esm_packages, esm_package_versions, esm_packages_found + diff --git a/src/core/src/package_managers/TdnfPackageManager.py b/src/core/src/package_managers/TdnfPackageManager.py index 9c380548..2f95fa91 100644 --- a/src/core/src/package_managers/TdnfPackageManager.py +++ b/src/core/src/package_managers/TdnfPackageManager.py @@ -15,17 +15,16 @@ # Requires Python 2.7+ """TdnfPackageManager for Azure Linux""" -import json -import os import re +from abc import ABCMeta, abstractmethod from core.src.core_logic.VersionComparator import VersionComparator -from core.src.package_managers.PackageManager import PackageManager from core.src.bootstrap.Constants import Constants +from core.src.package_managers.PackageManager import PackageManager class TdnfPackageManager(PackageManager): - """Implementation of Azure Linux package management operations""" + """Implementation of Tdnf package management operations""" def __init__(self, env_layer, execution_config, composite_logger, telemetry_writer, status_handler): super(TdnfPackageManager, self).__init__(env_layer, execution_config, composite_logger, telemetry_writer, status_handler) @@ -34,59 +33,23 @@ def __init__(self, env_layer, execution_config, composite_logger, telemetry_writ self.cmd_repo_refresh = "sudo tdnf -q list updates" # Support to get updates and their dependencies - self.tdnf_check = 'sudo tdnf -q list updates ' self.single_package_check_versions = 'sudo tdnf list available ' self.single_package_check_installed = 'sudo tdnf list installed ' self.single_package_upgrade_simulation_cmd = 'sudo tdnf install --assumeno --skip-broken ' # Install update self.single_package_upgrade_cmd = 'sudo tdnf -y install --skip-broken ' - self.install_security_updates_azgps_coordinated_cmd = 'sudo tdnf -y upgrade --skip-broken ' # Package manager exit code(s) self.tdnf_exitcode_ok = 0 self.tdnf_exitcode_on_no_action_for_install_update = 8 self.commands_expecting_no_action_exitcode = [self.single_package_upgrade_simulation_cmd] - # Support to check for processes requiring restart - self.dnf_utils_prerequisite = 'sudo tdnf -y install dnf-utils' - self.needs_restarting_with_flag = 'sudo LANG=en_US.UTF8 needs-restarting -r' - - # auto OS updates - self.current_auto_os_update_service = None - self.os_patch_configuration_settings_file_path = '' - self.auto_update_service_enabled = False - self.auto_update_config_pattern_match_text = "" - self.download_updates_identifier_text = "" - self.apply_updates_identifier_text = "" - self.enable_on_reboot_identifier_text = "" - self.enable_on_reboot_check_cmd = '' - self.enable_on_reboot_cmd = '' - self.installation_state_identifier_text = "" - self.install_check_cmd = "" - self.apply_updates_enabled = "Enabled" - self.apply_updates_disabled = "Disabled" - self.apply_updates_unknown = "Unknown" - - # commands for DNF Automatic updates service - self.__init_constants_for_dnf_automatic() - - # AzLinux3 Package Manager. - self.azl3_tdnf_packagemanager = self.AzL3TdnfPackageManager() - # Miscellaneous self.set_package_manager_setting(Constants.PKG_MGR_SETTING_IDENTITY, Constants.TDNF) self.STR_TOTAL_DOWNLOAD_SIZE = "Total download size: " self.version_comparator = VersionComparator() - # if an Auto Patching request comes in on an Azure Linux machine with Security and/or Critical classifications selected, we need to install all patches, since classifications aren't available in Azure Linux repository - installation_included_classifications = [] if execution_config.included_classifications_list is None else execution_config.included_classifications_list - if execution_config.health_store_id is not str() and execution_config.operation.lower() == Constants.INSTALLATION.lower() \ - and (env_layer.is_distro_azure_linux(str(env_layer.platform.linux_distribution()))) \ - and 'Critical' in installation_included_classifications and 'Security' in installation_included_classifications: - self.composite_logger.log_debug("Updating classifications list to install all patches for the Auto Patching request since classification based patching is not available on Azure Linux machines") - execution_config.included_classifications_list = [Constants.PackageClassification.CRITICAL, Constants.PackageClassification.SECURITY, Constants.PackageClassification.OTHER] - self.package_install_expected_avg_time_in_seconds = 90 # Setting a default value of 90 seconds as the avg time to install a package using tdnf, might be changed later if needed. def refresh_repo(self): @@ -94,16 +57,6 @@ def refresh_repo(self): self.invoke_package_manager(self.cmd_clean_cache) self.invoke_package_manager(self.cmd_repo_refresh) - # region Strict SDP using SnapshotTime - @staticmethod - def __generate_command_with_snapshotposixtime_if_specified(command_template, snapshot_posix_time=str()): - # type: (str, str) -> str - if snapshot_posix_time == str(): - return command_template.replace('', str()) - else: - return command_template.replace('', ('--snapshottime={0}'.format(str(snapshot_posix_time)))) - # endregion - # region Get Available Updates def invoke_package_manager_advanced(self, command, raise_on_exception=True): """Get missing updates using the command input""" @@ -111,8 +64,8 @@ def invoke_package_manager_advanced(self, command, raise_on_exception=True): code, out = self.env_layer.run_command_output(command, False, False) if code is self.tdnf_exitcode_ok or \ - (any(command_expecting_no_action_exitcode in command for command_expecting_no_action_exitcode in self.commands_expecting_no_action_exitcode) and - code is self.tdnf_exitcode_on_no_action_for_install_update): + (any(command_expecting_no_action_exitcode in command for command_expecting_no_action_exitcode in self.commands_expecting_no_action_exitcode) and + code is self.tdnf_exitcode_on_no_action_for_install_update): self.composite_logger.log_debug('[TDNF] Invoked package manager. [Command={0}][Code={1}][Output={2}]'.format(command, str(code), str(out))) else: self.composite_logger.log_warning('[ERROR] Customer environment error. [Command={0}][Code={1}][Output={2}]'.format(command, str(code), str(out))) @@ -124,35 +77,23 @@ def invoke_package_manager_advanced(self, command, raise_on_exception=True): return out, code # region Classification-based (incl. All) update check + @abstractmethod def get_all_updates(self, cached=False): - """Get all missing updates""" - self.composite_logger.log_verbose("[TDNF] Discovering all packages...") - if cached and not len(self.all_updates_cached) == 0: - self.composite_logger.log_debug("[TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(cached), len(self.all_updates_cached))) - return self.all_updates_cached, self.all_update_versions_cached # allows for high performance reuse in areas of the code explicitly aware of the cache - - out = self.invoke_package_manager(self.__generate_command_with_snapshotposixtime_if_specified(self.tdnf_check, self.max_patch_publish_date)) - self.all_updates_cached, self.all_update_versions_cached = self.extract_packages_and_versions(out) - self.composite_logger.log_debug("[TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(False), len(self.all_updates_cached))) - return self.all_updates_cached, self.all_update_versions_cached + """Same behavior as get_available_updates, but higher performance with no filters""" + pass + return [], [] # only here to suppress a static syntax validation problem + @abstractmethod def get_security_updates(self): - """Get missing security updates. NOTE: Classification based categorization of patches is not available in TDNF as of now""" - self.composite_logger.log_verbose("[TDNF] Discovering all packages as 'security' packages, since TDNF does not support package classification...") - security_packages, security_package_versions = self.get_all_updates(cached=False) - self.composite_logger.log_debug("[TDNF] Discovered 'security' packages. [Count={0}]".format(len(security_packages))) - return security_packages, security_package_versions + pass + @abstractmethod def get_other_updates(self): - """Get missing other updates.""" - self.composite_logger.log_verbose("[TDNF] Discovering 'other' packages...") - return [], [] + pass + @abstractmethod def set_max_patch_publish_date(self, max_patch_publish_date=str()): - """Set the max patch publish date in POSIX time for strict SDP""" - self.composite_logger.log_debug("[TDNF] Setting max patch publish date. [MaxPatchPublishDate={0}]".format(str(max_patch_publish_date))) - self.max_patch_publish_date = str(self.env_layer.datetime.datetime_string_to_posix_time(max_patch_publish_date, '%Y%m%dT%H%M%SZ')) if max_patch_publish_date != str() else max_patch_publish_date - self.composite_logger.log_debug("[TDNF] Set max patch publish date. [MaxPatchPublishDatePosixTime={0}]".format(str(self.max_patch_publish_date))) + pass # endregion # region Output Parser(s) @@ -213,7 +154,7 @@ def __is_package(chunk): # endregion # endregion - # region Install Updates + # region Install Update def get_composite_package_identifier(self, package, package_version): package_without_arch, arch = self.get_product_name_and_arch(package) package_identifier = package_without_arch + '-' + str(package_version) @@ -221,82 +162,19 @@ def get_composite_package_identifier(self, package, package_version): package_identifier += arch return package_identifier + @abstractmethod def install_updates_fail_safe(self, excluded_packages): - return + pass + @abstractmethod def install_security_updates_azgps_coordinated(self): - """Install security updates in Azure Linux following strict SDP""" - command = self.__generate_command_with_snapshotposixtime_if_specified(self.install_security_updates_azgps_coordinated_cmd, self.max_patch_publish_date) - out, code = self.invoke_package_manager_advanced(command, raise_on_exception=False) - return code, out + pass + @abstractmethod def try_meet_azgps_coordinated_requirements(self): # type: () -> bool - """ Check if the system meets the requirements for Azure Linux strict safe deployment and attempt to update TDNF if necessary """ - self.composite_logger.log_debug("[TDNF] Checking if system meets Azure Linux security updates requirements...") - # Check if the system is Azure Linux 3.0 or beyond - if not self.env_layer.is_distro_azure_linux_3_or_beyond(): - self.composite_logger.log_error("[TDNF] The system does not meet minimum Azure Linux requirement of 3.0 or above for strict safe deployment. Defaulting to regular upgrades.") - self.set_max_patch_publish_date() # fall-back - return False - else: - if self.is_minimum_tdnf_version_for_strict_sdp_installed(): - self.composite_logger.log_debug("[TDNF] Minimum tdnf version for strict safe deployment is installed.") - return True - else: - if not self.try_tdnf_update_to_meet_strict_sdp_requirements(): - error_msg = "Failed to meet minimum TDNF version requirement for strict safe deployment. Defaulting to regular upgrades." - self.composite_logger.log_error(error_msg + "[Error={0}]".format(repr(error_msg))) - self.status_handler.add_error_to_status(error_msg) - self.set_max_patch_publish_date() # fall-back - return False - return True - - def is_minimum_tdnf_version_for_strict_sdp_installed(self): - # type: () -> bool - """Check if at least the minimum required version of TDNF is installed""" - self.composite_logger.log_debug("[TDNF] Checking if minimum TDNF version required for strict safe deployment is installed...") - tdnf_version = self.get_tdnf_version() - minimum_tdnf_version_for_strict_sdp = self.azl3_tdnf_packagemanager.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP - distro_from_minimum_tdnf_version_for_strict_sdp = re.match(r".*-\d+\.([a-zA-Z0-9]+)$", minimum_tdnf_version_for_strict_sdp).group(1) - if tdnf_version is None: - self.composite_logger.log_error("[TDNF] Failed to get TDNF version. Cannot proceed with strict safe deployment. Defaulting to regular upgrades.") - return False - elif re.match(r".*-\d+\.([a-zA-Z0-9]+)$", tdnf_version).group(1) != distro_from_minimum_tdnf_version_for_strict_sdp: - self.composite_logger.log_warning("[TDNF] TDNF version installed is not from the same Azure Linux distribution as the minimum required version for strict SDP. [InstalledVersion={0}][MinimumRequiredVersion={1}]".format(tdnf_version, self.azl3_tdnf_packagemanager.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP)) - return False - elif not self.version_comparator.compare_versions(tdnf_version, minimum_tdnf_version_for_strict_sdp) >= 0: - self.composite_logger.log_warning("[TDNF] TDNF version installed is less than the minimum required version for strict SDP. [InstalledVersion={0}][MinimumRequiredVersion={1}]".format(tdnf_version, self.azl3_tdnf_packagemanager.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP)) - return False - return True - - def get_tdnf_version(self): - # type: () -> any - """Get the version of TDNF installed on the system""" - self.composite_logger.log_debug("[TDNF] Getting tdnf version...") - cmd = "rpm -q tdnf | sed -E 's/^tdnf-([0-9]+\\.[0-9]+\\.[0-9]+-[0-9]+\\.[a-zA-Z0-9]+).*/\\1/'" - code, output = self.env_layer.run_command_output(cmd, False, False) - if code == 0: - # Sample output: 3.5.8-3-azl3 - version = output.split()[0] if output else None - self.composite_logger.log_debug("[TDNF] TDNF version detected. [Version={0}]".format(version)) - return version - else: - self.composite_logger.log_error("[TDNF] Failed to get TDNF version. [Command={0}][Code={1}][Output={2}]".format(cmd, code, output)) - return None - - def try_tdnf_update_to_meet_strict_sdp_requirements(self): - # type: () -> bool - """Attempt to update TDNF to meet the minimum version required for strict SDP""" - self.composite_logger.log_debug("[TDNF] Attempting to update TDNF to meet strict safe deployment requirements...") - cmd = "sudo tdnf -y install tdnf-" + self.azl3_tdnf_packagemanager.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP - code, output = self.env_layer.run_command_output(cmd, no_output=True, chk_err=False) - if code == 0: - self.composite_logger.log_debug("[TDNF] Successfully updated TDNF for Strict SDP. [Command={0}][Code={1}]".format(cmd, code)) - return True - else: - self.composite_logger.log_error("[TDNF] Failed to update TDNF for Strict SDP. [Command={0}][Code={1}][Output={2}]".format(cmd, code, output)) - return False + """ Returns true if the package manager meets the requirements for azgps coordinated security updates """ + return False # endregion # region Package Information @@ -409,7 +287,7 @@ def get_dependent_list(self, packages): cmd = self.single_package_upgrade_simulation_cmd + package_names output = self.invoke_package_manager(cmd) dependencies = self.extract_dependencies(output, packages) - self.composite_logger.log_verbose("[TDNF] Resolved dependencies. [Command={0}][Packages={1}][DependencyCount={2}]".format(str(cmd),str(packages), len(dependencies))) + self.composite_logger.log_verbose("[TDNF] Resolved dependencies. [Command={0}][Packages={1}][DependencyCount={2}]".format(str(cmd), str(packages), len(dependencies))) return dependencies def get_product_name(self, package_name): @@ -452,386 +330,49 @@ def get_package_size(self, output): # endregion # region auto OS updates - def __init_constants_for_dnf_automatic(self): - self.dnf_automatic_configuration_file_path = '/etc/dnf/automatic.conf' - self.dnf_automatic_install_check_cmd = 'systemctl list-unit-files --type=service | grep dnf-automatic.service' # list-unit-files returns installed services, ref: https://www.freedesktop.org/software/systemd/man/systemctl.html#Unit%20File%20Commands - self.dnf_automatic_enable_on_reboot_check_cmd = 'systemctl is-enabled dnf-automatic.timer' - self.dnf_automatic_disable_on_reboot_cmd = 'systemctl disable dnf-automatic.timer' - self.dnf_automatic_enable_on_reboot_cmd = 'systemctl enable dnf-automatic.timer' - self.dnf_automatic_config_pattern_match_text = ' = (no|yes)' - self.dnf_automatic_download_updates_identifier_text = 'download_updates' - self.dnf_automatic_apply_updates_identifier_text = 'apply_updates' - self.dnf_automatic_enable_on_reboot_identifier_text = "enable_on_reboot" - self.dnf_automatic_installation_state_identifier_text = "installation_state" - self.dnf_auto_os_update_service = "dnf-automatic" - + @abstractmethod def get_current_auto_os_patch_state(self): """ Gets the current auto OS update patch state on the machine """ - self.composite_logger.log("[TDNF] Fetching the current automatic OS patch state on the machine...") - - current_auto_os_patch_state_for_dnf_automatic = self.__get_current_auto_os_patch_state_for_dnf_automatic() - - self.composite_logger.log("[TDNF] OS patch state per auto OS update service: [dnf-automatic={0}]".format(str(current_auto_os_patch_state_for_dnf_automatic))) - - if current_auto_os_patch_state_for_dnf_automatic == Constants.AutomaticOSPatchStates.ENABLED: - current_auto_os_patch_state = Constants.AutomaticOSPatchStates.ENABLED - elif current_auto_os_patch_state_for_dnf_automatic == Constants.AutomaticOSPatchStates.DISABLED: - current_auto_os_patch_state = Constants.AutomaticOSPatchStates.DISABLED - else: - current_auto_os_patch_state = Constants.AutomaticOSPatchStates.UNKNOWN - - self.composite_logger.log_debug("[TDNF] Overall Auto OS Patch State based on all auto OS update service states [OverallAutoOSPatchState={0}]".format(str(current_auto_os_patch_state))) - return current_auto_os_patch_state - - def __get_current_auto_os_patch_state_for_dnf_automatic(self): - """ Gets current auto OS update patch state for dnf-automatic """ - self.composite_logger.log_debug("[TDNF] Fetching current automatic OS patch state in dnf-automatic service. This includes checks on whether the service is installed, current auto patch enable state and whether it is set to enable on reboot") - self.__init_auto_update_for_dnf_automatic() - is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() - - apply_updates = self.__get_extension_standard_value_for_apply_updates(apply_updates_value) - - if apply_updates == self.apply_updates_enabled or enable_on_reboot_value: - return Constants.AutomaticOSPatchStates.ENABLED - # OS patch state is considered to be disabled: a) if it was successfully disabled or b) if the service is not installed - elif not is_service_installed or (apply_updates == self.apply_updates_disabled and not enable_on_reboot_value): - return Constants.AutomaticOSPatchStates.DISABLED - else: - return Constants.AutomaticOSPatchStates.UNKNOWN - - def __init_auto_update_for_dnf_automatic(self): - """ Initializes all generic auto OS update variables with the config values for dnf automatic service """ - self.os_patch_configuration_settings_file_path = self.dnf_automatic_configuration_file_path - self.download_updates_identifier_text = self.dnf_automatic_download_updates_identifier_text - self.apply_updates_identifier_text = self.dnf_automatic_apply_updates_identifier_text - self.enable_on_reboot_identifier_text = self.dnf_automatic_enable_on_reboot_identifier_text - self.installation_state_identifier_text = self.dnf_automatic_installation_state_identifier_text - self.auto_update_config_pattern_match_text = self.dnf_automatic_config_pattern_match_text - self.enable_on_reboot_check_cmd = self.dnf_automatic_enable_on_reboot_check_cmd - self.enable_on_reboot_cmd = self.dnf_automatic_enable_on_reboot_cmd - self.install_check_cmd = self.dnf_automatic_install_check_cmd - self.current_auto_os_update_service = self.dnf_auto_os_update_service - - def __get_current_auto_os_updates_setting_on_machine(self): - """ Gets all the update settings related to auto OS updates currently set on the machine """ - try: - download_updates_value = "" - apply_updates_value = "" - is_service_installed = False - enable_on_reboot_value = False - - # get install state - if not self.is_auto_update_service_installed(self.install_check_cmd): - return is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value - - is_service_installed = True - enable_on_reboot_value = self.is_service_set_to_enable_on_reboot(self.enable_on_reboot_check_cmd) - - self.composite_logger.log_debug("[TDNF] Checking if auto updates are currently enabled...") - image_default_patch_configuration = self.env_layer.file_system.read_with_retry(self.os_patch_configuration_settings_file_path, raise_if_not_found=False) - if image_default_patch_configuration is not None: - settings = image_default_patch_configuration.strip().split('\n') - for setting in settings: - match = re.search(self.download_updates_identifier_text + self.auto_update_config_pattern_match_text, str(setting)) - if match is not None: - download_updates_value = match.group(1) - - match = re.search(self.apply_updates_identifier_text + self.auto_update_config_pattern_match_text, str(setting)) - if match is not None: - apply_updates_value = match.group(1) - - if download_updates_value == "": - self.composite_logger.log_debug("[TDNF] Machine did not have any value set for [Setting={0}]".format(str(self.download_updates_identifier_text))) - else: - self.composite_logger.log_verbose("[TDNF] Current value set for [{0}={1}]".format(str(self.download_updates_identifier_text), str(download_updates_value))) - - if apply_updates_value == "": - self.composite_logger.log_debug("[TDNF] Machine did not have any value set for [Setting={0}]".format(str(self.apply_updates_identifier_text))) - else: - self.composite_logger.log_verbose("[TDNF] Current value set for [{0}={1}]".format(str(self.apply_updates_identifier_text), str(apply_updates_value))) - - return is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value - - except Exception as error: - raise Exception("[TDNF] Error occurred in fetching current auto OS update settings from the machine. [Exception={0}]".format(repr(error))) - - def is_auto_update_service_installed(self, install_check_cmd): - """ Checks if the auto update service is enable_on_reboot on the VM """ - code, out = self.env_layer.run_command_output(install_check_cmd, False, False) - self.composite_logger.log_debug("[TDNF] Checked if auto update service is installed. [Command={0}][Code={1}][Output={2}]".format(install_check_cmd, str(code), out)) - if len(out.strip()) > 0 and code == 0: - self.composite_logger.log_debug("[TDNF] > Auto OS update service is installed on the machine") - return True - else: - self.composite_logger.log_debug("[TDNF] > Auto OS update service is NOT installed on the machine") - return False - - def is_service_set_to_enable_on_reboot(self, command): - """ Checking if auto update is enable_on_reboot on the machine. An enable_on_reboot service will be activated (if currently inactive) on machine reboot """ - code, out = self.env_layer.run_command_output(command, False, False) - self.composite_logger.log_debug("[TDNF] Checked if auto update service is set to enable on reboot. [Code={0}][Out={1}]".format(str(code), out)) - if len(out.strip()) > 0 and code == 0 and 'enabled' in out: - self.composite_logger.log_debug("[TDNF] > Auto OS update service will enable on reboot") - return True - self.composite_logger.log_debug("[TDNF] > Auto OS update service will NOT enable on reboot") - return False - - def __get_extension_standard_value_for_apply_updates(self, apply_updates_value): - if apply_updates_value.lower() == 'yes' or apply_updates_value.lower() == 'true': - return self.apply_updates_enabled - elif apply_updates_value.lower() == 'no' or apply_updates_value.lower() == 'false': - return self.apply_updates_disabled - else: - return self.apply_updates_unknown + pass + @abstractmethod def disable_auto_os_update(self): """ Disables auto OS updates on the machine only if they are enabled and logs the default settings the machine comes with """ - try: - self.composite_logger.log_verbose("[TDNF] Disabling auto OS updates in all identified services...") - self.disable_auto_os_update_for_dnf_automatic() - self.composite_logger.log_debug("[TDNF] Successfully disabled auto OS updates") - - except Exception as error: - self.composite_logger.log_error("[TDNF] Could not disable auto OS updates. [Error={0}]".format(repr(error))) - raise - - def disable_auto_os_update_for_dnf_automatic(self): - """ Disables auto OS updates, using dnf-automatic service, and logs the default settings the machine comes with """ - self.composite_logger.log_verbose("[TDNF] Disabling auto OS updates using dnf-automatic") - self.__init_auto_update_for_dnf_automatic() - - self.backup_image_default_patch_configuration_if_not_exists() - - if not self.is_auto_update_service_installed(self.dnf_automatic_install_check_cmd): - self.composite_logger.log_debug("[TDNF] Cannot disable as dnf-automatic is not installed on the machine") - return - - self.composite_logger.log_verbose("[TDNF] Preemptively disabling auto OS updates using dnf-automatic") - self.update_os_patch_configuration_sub_setting(self.download_updates_identifier_text, "no", self.dnf_automatic_config_pattern_match_text) - self.update_os_patch_configuration_sub_setting(self.apply_updates_identifier_text, "no", self.dnf_automatic_config_pattern_match_text) - self.disable_auto_update_on_reboot(self.dnf_automatic_disable_on_reboot_cmd) - - self.composite_logger.log_debug("[TDNF] Successfully disabled auto OS updates using dnf-automatic") - - def disable_auto_update_on_reboot(self, command): - self.composite_logger.log_verbose("[TDNF] Disabling auto update on reboot. [Command={0}] ".format(command)) - code, out = self.env_layer.run_command_output(command, False, False) - - if code != 0: - self.composite_logger.log_error("[TDNF][ERROR] Error disabling auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) - error_msg = 'Unexpected return code (' + str(code) + ') on command: ' + command - self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.OPERATION_FAILED) - raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) - else: - self.composite_logger.log_debug("[TDNF] Disabled auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) + pass + @abstractmethod def backup_image_default_patch_configuration_if_not_exists(self): """ Records the default system settings for auto OS updates within patch extension artifacts for future reference. We only log the default system settings a VM comes with, any subsequent updates will not be recorded""" - """ JSON format for backup file: - { - "dnf-automatic": { - "apply_updates": "yes/no/empty string", - "download_updates": "yes/no/empty string", - "enable_on_reboot": true/false, - "installation_state": true/false - } - } """ - try: - self.composite_logger.log_debug("[TDNF] Ensuring there is a backup of the default patch state for [AutoOSUpdateService={0}]".format(str(self.current_auto_os_update_service))) - image_default_patch_configuration_backup = self.__get_image_default_patch_configuration_backup() - - # verify if existing backup is valid if not, write to backup - is_backup_valid = self.is_image_default_patch_configuration_backup_valid(image_default_patch_configuration_backup) - if is_backup_valid: - self.composite_logger.log_debug("[TDNF] Since extension has a valid backup, no need to log the current settings again. [Default Auto OS update settings={0}] [File path={1}]" - .format(str(image_default_patch_configuration_backup), self.image_default_patch_configuration_backup_path)) - else: - self.composite_logger.log_debug("[TDNF] Since the backup is invalid, will add a new backup with the current auto OS update settings") - self.composite_logger.log_debug("[TDNF] Fetching current auto OS update settings for [AutoOSUpdateService={0}]".format(str(self.current_auto_os_update_service))) - is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() - - backup_image_default_patch_configuration_json_to_add = { - self.current_auto_os_update_service: { - self.download_updates_identifier_text: download_updates_value, - self.apply_updates_identifier_text: apply_updates_value, - self.enable_on_reboot_identifier_text: enable_on_reboot_value, - self.installation_state_identifier_text: is_service_installed - } - } - - image_default_patch_configuration_backup.update(backup_image_default_patch_configuration_json_to_add) - - self.composite_logger.log_debug("[TDNF] Logging default system configuration settings for auto OS updates. [Settings={0}] [Log file path={1}]" - .format(str(image_default_patch_configuration_backup), self.image_default_patch_configuration_backup_path)) - self.env_layer.file_system.write_with_retry(self.image_default_patch_configuration_backup_path, '{0}'.format(json.dumps(image_default_patch_configuration_backup)), mode='w+') - except Exception as error: - error_message = "[TDNF] Exception during fetching and logging default auto update settings on the machine. [Exception={0}]".format(repr(error)) - self.composite_logger.log_error(error_message) - self.status_handler.add_error_to_status(error_message, Constants.PatchOperationErrorCodes.DEFAULT_ERROR) - raise + pass + @abstractmethod def is_image_default_patch_configuration_backup_valid(self, image_default_patch_configuration_backup): - """ Verifies if default auto update configurations, for a service under consideration, are saved in backup """ - return self.is_backup_valid_for_dnf_automatic(image_default_patch_configuration_backup) - - def is_backup_valid_for_dnf_automatic(self, image_default_patch_configuration_backup): - if self.dnf_auto_os_update_service in image_default_patch_configuration_backup \ - and self.dnf_automatic_download_updates_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ - and self.dnf_automatic_apply_updates_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ - and self.dnf_automatic_enable_on_reboot_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ - and self.dnf_automatic_installation_state_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service]: - self.composite_logger.log_debug("[TDNF] Extension has a valid backup for default dnf-automatic configuration settings") - return True - else: - self.composite_logger.log_debug("[TDNF] Extension does not have a valid backup for default dnf-automatic configuration settings") - return False - - def update_os_patch_configuration_sub_setting(self, patch_configuration_sub_setting, value="no", config_pattern_match_text=""): - """ Updates (or adds if it doesn't exist) the given patch_configuration_sub_setting with the given value in os_patch_configuration_settings_file """ - try: - # note: adding space between the patch_configuration_sub_setting and value since, we will have to do that if we have to add a patch_configuration_sub_setting that did not exist before - self.composite_logger.log_debug("[TDNF] Updating system configuration settings for auto OS updates. [Patch Configuration Sub Setting={0}] [Value={1}]".format(str(patch_configuration_sub_setting), value)) - os_patch_configuration_settings = self.env_layer.file_system.read_with_retry(self.os_patch_configuration_settings_file_path) - patch_configuration_sub_setting_to_update = patch_configuration_sub_setting + ' = ' + value - patch_configuration_sub_setting_found_in_file = False - updated_patch_configuration_sub_setting = "" - settings = os_patch_configuration_settings.strip().split('\n') - - # update value of existing setting - for i in range(len(settings)): - match = re.search(patch_configuration_sub_setting + config_pattern_match_text, settings[i]) - if match is not None: - settings[i] = patch_configuration_sub_setting_to_update - patch_configuration_sub_setting_found_in_file = True - updated_patch_configuration_sub_setting += settings[i] + "\n" - - # add setting to configuration file, since it doesn't exist - if not patch_configuration_sub_setting_found_in_file: - updated_patch_configuration_sub_setting += patch_configuration_sub_setting_to_update + "\n" - - self.env_layer.file_system.write_with_retry(self.os_patch_configuration_settings_file_path, '{0}'.format(updated_patch_configuration_sub_setting.lstrip()), mode='w+') - except Exception as error: - error_msg = "[TDNF] Error occurred while updating system configuration settings for auto OS updates. [Patch Configuration={0}] [Error={1}]".format(str(patch_configuration_sub_setting), repr(error)) - self.composite_logger.log_error(error_msg) - self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.DEFAULT_ERROR) - raise - - def revert_auto_os_update_to_system_default(self): - """ Reverts the auto OS update patch state on the machine to its system default value, if one exists in our backup file """ - # type () -> None - self.composite_logger.log("[TDNF] Reverting the current automatic OS patch state on the machine to its system default value before patchmode was set to 'AutomaticByPlatform'") - self.revert_auto_os_update_to_system_default_for_dnf_automatic() - self.composite_logger.log_debug("[TDNF] Successfully reverted auto OS updates to system default config") - - def revert_auto_os_update_to_system_default_for_dnf_automatic(self): - """ Reverts the auto OS update patch state on the machine to its system default value for given service, if applicable """ - # type () -> None - self.__init_auto_update_for_dnf_automatic() - self.composite_logger.log("[TDNF] Reverting the current automatic OS patch state on the machine to its system default value for [Service={0}]".format(str(self.current_auto_os_update_service))) - is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() - - if not is_service_installed: - self.composite_logger.log_debug("[TDNF] Machine default auto OS update service is not installed on the VM and hence no config to revert. [Service={0}]".format(str(self.current_auto_os_update_service))) - return - - self.composite_logger.log_debug("[TDNF] Logging current configuration settings for auto OS updates [Service={0}][Is_Service_Installed={1}][Machine_default_update_enable_on_reboot={2}][{3}={4}]][{5}={6}]" - .format(str(self.current_auto_os_update_service), str(is_service_installed), str(enable_on_reboot_value), str(self.download_updates_identifier_text), str(download_updates_value), str(self.apply_updates_identifier_text), str(apply_updates_value))) - - image_default_patch_configuration_backup = self.__get_image_default_patch_configuration_backup() - self.composite_logger.log_debug("[TDNF] Logging system default configuration settings for auto OS updates. [Settings={0}]".format(str(image_default_patch_configuration_backup))) - is_backup_valid = self.is_image_default_patch_configuration_backup_valid(image_default_patch_configuration_backup) - - if is_backup_valid: - download_updates_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.download_updates_identifier_text] - apply_updates_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.apply_updates_identifier_text] - enable_on_reboot_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.enable_on_reboot_identifier_text] - - self.update_os_patch_configuration_sub_setting(self.download_updates_identifier_text, download_updates_value_from_backup, self.auto_update_config_pattern_match_text) - self.update_os_patch_configuration_sub_setting(self.apply_updates_identifier_text, apply_updates_value_from_backup, self.auto_update_config_pattern_match_text) - if str(enable_on_reboot_value_from_backup).lower() == 'true': - self.enable_auto_update_on_reboot() - else: - self.composite_logger.log_debug("[TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service={0}]".format(str(self.current_auto_os_update_service))) - - def enable_auto_update_on_reboot(self): - """Enables machine default auto update on reboot""" - # type () -> None - command = self.enable_on_reboot_cmd - self.composite_logger.log_verbose("[TDNF] Enabling auto update on reboot. [Command={0}] ".format(command)) - code, out = self.env_layer.run_command_output(command, False, False) + pass - if code != 0: - self.composite_logger.log_error("[TDNF][ERROR] Error enabling auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) - error_msg = 'Unexpected return code (' + str(code) + ') on command: ' + command - self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.OPERATION_FAILED) - raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) - else: - self.composite_logger.log_debug("[TDNF] Enabled auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) - - def __get_image_default_patch_configuration_backup(self): - """ Get image_default_patch_configuration_backup file""" - image_default_patch_configuration_backup = {} - - # read existing backup since it also contains backup from other update services. We need to preserve any existing data within the backup file - if self.image_default_patch_configuration_backup_exists(): - try: - image_default_patch_configuration_backup = json.loads(self.env_layer.file_system.read_with_retry(self.image_default_patch_configuration_backup_path)) - except Exception as error: - self.composite_logger.log_error("[TDNF] Unable to read backup for default patch state. Will attempt to re-write. [Exception={0}]".format(repr(error))) - return image_default_patch_configuration_backup + @abstractmethod + def update_os_patch_configuration_sub_setting(self, patch_configuration_sub_setting, value, patch_configuration_sub_setting_pattern_to_match): + pass # endregion - # region Reboot Management + @abstractmethod def is_reboot_pending(self): """ Checks if there is a pending reboot on the machine. """ - try: - pending_file_exists = os.path.isfile(self.REBOOT_PENDING_FILE_PATH) - pending_processes_exist = self.do_processes_require_restart() - self.composite_logger.log_debug("[TDNF] > Reboot required debug flags (tdnf): " + str(pending_file_exists) + ", " + str(pending_processes_exist) + ".") - return pending_file_exists or pending_processes_exist - except Exception as error: - self.composite_logger.log_error('[TDNF] Error while checking for reboot pending (tdnf): ' + repr(error)) - return True # defaults for safety + pass + @abstractmethod def do_processes_require_restart(self): - """Signals whether processes require a restart due to updates""" - self.composite_logger.log_verbose("[TDNF] Checking if process requires reboot") - # Checking using dnf-utils - code, out = self.env_layer.run_command_output(self.dnf_utils_prerequisite, False, False) # idempotent, doesn't install if already present - self.composite_logger.log_verbose("[TDNF] Idempotent dnf-utils existence check. [Code={0}][Out={1}]".format(str(code), out)) - - # Checking for restart for distros with -r flag - code, out = self.env_layer.run_command_output(self.needs_restarting_with_flag, False, False) - self.composite_logger.log_verbose("[TDNF] > Code: " + str(code) + ", Output: \n|\t" + "\n|\t".join(out.splitlines())) - if out.find("Reboot is required") < 0: - self.composite_logger.log_debug("[TDNF] > Reboot not detected to be required (L1).") - else: - self.composite_logger.log_debug("[TDNF] > Reboot is detected to be required (L1).") - return True - - return False - # endregion + """ Signals whether processes require a restart due to updates to files """ + pass + @abstractmethod def set_security_esm_package_status(self, operation, packages): - """ Set the security-ESM classification for the esm packages. Only needed for apt. No-op for tdnf, yum and zypper.""" pass + @abstractmethod def separate_out_esm_packages(self, packages, package_versions): - """Filter out packages from the list where the version matches the UA_ESM_REQUIRED string. - Only needed for apt. No-op for tdnf, yum and zypper""" - esm_packages = [] - esm_package_versions = [] - esm_packages_found = False - - return packages, package_versions, esm_packages, esm_package_versions, esm_packages_found + pass def get_package_install_expected_avg_time_in_seconds(self): return self.package_install_expected_avg_time_in_seconds - # region - AzLinux specializations - class AzL3TdnfPackageManager(object): - """AzLinux Package Manager class for TDNF package manager.""" - def __init__(self): - self.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP = "3.5.8-3.azl3" # minimum version of tdnf required to support Strict SDP in Azure Linux - diff --git a/src/core/tests/Test_AzL3TdnfPackageManager.py b/src/core/tests/Test_AzL3TdnfPackageManager.py new file mode 100644 index 00000000..ad8a7316 --- /dev/null +++ b/src/core/tests/Test_AzL3TdnfPackageManager.py @@ -0,0 +1,628 @@ +# Copyright 2025 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.7+ +import json +import os +import unittest +import sys +# Conditional import for StringIO +try: + from StringIO import StringIO # Python 2 +except ImportError: + from io import StringIO # Python 3 + +from core.src.bootstrap.Constants import Constants +from core.tests.library.LegacyEnvLayerExtensions import LegacyEnvLayerExtensions +from core.tests.library.ArgumentComposer import ArgumentComposer +from core.tests.library.RuntimeCompositor import RuntimeCompositor +from core.src.external_dependencies import distro + + +class TestAzL3TdnfPackageManager(unittest.TestCase): + def setUp(self): + self.runtime = RuntimeCompositor(ArgumentComposer().get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + def tearDown(self): + self.runtime.stop() + + # region Mocks + def mock_do_processes_require_restart_raise_exception(self): + raise Exception + + def mock_linux_distribution_to_return_azure_linux(self): + return ['Microsoft Azure Linux', '3.0', ''] + + def mock_linux_distribution_to_return_azure_linux_2(self): + return ['Common Base Linux Mariner', '2.0', ''] + + def mock_write_with_retry_raise_exception(self, file_path_or_handle, data, mode='a+'): + raise Exception + + def mock_run_command_output_return_tdnf_3(self, cmd, no_output=False, chk_err=True): + """ Mock for run_command_output to return tdnf 3 """ + return 0, "3.5.8-3\n" + + def mock_run_command_output_return_1(self, cmd, no_output=False, chk_err=True): + """ Mock for run_command_output to return None """ + return 1, "No output available\n" + + def mock_run_command_output_return_0(self, cmd, no_output=False, chk_err=True): + return 0, "Successfully executed command\n" + + def mock_get_tdnf_version_return_tdnf_3_5_8_3(self): + return "3.5.8-3.azl3" + + def mock_get_tdnf_version_return_tdnf_4_0(self): + return "4.0.0-1.azl3" + + def mock_get_tdnf_version_return_tdnf_2_5(self): + return "2.5.0-1.cm2" + + def mock_get_tdnf_version_return_tdnf_3_5_8_2(self): + return "3.5.8-2.azl3" + + def mock_get_tdnf_version_return_tdnf_3_5_8_6_cm2(self): + return "3.5.8-6.cm2" + + def mock_get_tdnf_version_return_None(self): + return None + + def mock_distro_os_release_attr_return_azure_linux_3(self, attribute): + return '3.0.0' + + def mock_distro_os_release_attr_return_azure_linux_2(self, attribute): + return '2.9.0' + # endregion + + # region Utility Functions + def __setup_config_and_invoke_revert_auto_os_to_system_default(self, package_manager, create_current_auto_os_config=True, create_backup_for_system_default_config=True, current_auto_os_update_config_value='', apply_updates_value="", + download_updates_value="", enable_on_reboot_value=False, installation_state_value=False, set_installation_state=True): + """ Sets up current auto OS update config, backup for system default config (if requested) and invoke revert to system default """ + # setup current auto OS update config + if create_current_auto_os_config: + self.__setup_current_auto_os_update_config(package_manager, current_auto_os_update_config_value) + + # setup backup for system default auto OS update config + if create_backup_for_system_default_config: + self.__setup_backup_for_system_default_OS_update_config(package_manager, apply_updates_value=apply_updates_value, download_updates_value=download_updates_value, enable_on_reboot_value=enable_on_reboot_value, + installation_state_value=installation_state_value, set_installation_state=set_installation_state) + + package_manager.revert_auto_os_update_to_system_default() + + def __setup_current_auto_os_update_config(self, package_manager, config_value='', config_file_name="automatic.conf"): + # setup current auto OS update config + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, config_file_name) + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, config_value) + + def __setup_backup_for_system_default_OS_update_config(self, package_manager, apply_updates_value="", download_updates_value="", enable_on_reboot_value=False, installation_state_value=False, set_installation_state=True): + # setup backup for system default auto OS update config + package_manager.image_default_patch_configuration_backup_path = os.path.join(self.runtime.execution_config.config_folder, Constants.IMAGE_DEFAULT_PATCH_CONFIGURATION_BACKUP_PATH) + backup_image_default_patch_configuration_json = { + "dnf-automatic": { + "apply_updates": apply_updates_value, + "download_updates": download_updates_value, + "enable_on_reboot": enable_on_reboot_value + } + } + if set_installation_state: + backup_image_default_patch_configuration_json["dnf-automatic"]["installation_state"] = installation_state_value + self.runtime.write_to_file(package_manager.image_default_patch_configuration_backup_path, '{0}'.format(json.dumps(backup_image_default_patch_configuration_json))) + + @staticmethod + def __capture_std_io(): + # arrange capture std IO + captured_output = StringIO() + original_stdout = sys.stdout + sys.stdout = captured_output + return captured_output, original_stdout + + def __assert_std_io(self, captured_output, expected_output=''): + output = captured_output.getvalue() + self.assertTrue(expected_output in output) + + def __assert_reverted_automatic_patch_configuration_settings(self, package_manager, config_exists=True, config_value_expected=''): + if config_exists: + reverted_dnf_automatic_patch_configuration_settings = self.runtime.env_layer.file_system.read_with_retry(package_manager.dnf_automatic_configuration_file_path) + self.assertTrue(reverted_dnf_automatic_patch_configuration_settings is not None) + self.assertTrue(config_value_expected in reverted_dnf_automatic_patch_configuration_settings) + else: + self.assertFalse(os.path.exists(package_manager.dnf_automatic_configuration_file_path)) + # endregion + + def test_do_processes_require_restart(self): + """Unit test for tdnf package manager""" + # Restart required + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager) + self.assertTrue(package_manager.is_reboot_pending()) + + # Restart not required + self.runtime.set_legacy_test_type('SadPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.assertFalse(package_manager.is_reboot_pending()) + + # Fake exception + self.runtime.set_legacy_test_type('SadPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + backup_do_processes_require_restart = package_manager.do_processes_require_restart + package_manager.do_processes_require_restart = self.mock_do_processes_require_restart_raise_exception + self.assertTrue(package_manager.is_reboot_pending()) # returns true because the safe default if a failure occurs is 'true' + package_manager.do_processes_require_restart = backup_do_processes_require_restart + + def test_all_classification_selected_for_auto_patching_request(self): + """Unit test for tdnf package manager for auto patching request where all classifications are selected since Azure Linux does not have classifications""" + backup_envlayer_platform_linux_distribution = LegacyEnvLayerExtensions.LegacyPlatform.linux_distribution + LegacyEnvLayerExtensions.LegacyPlatform.linux_distribution = self.mock_linux_distribution_to_return_azure_linux + + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.runtime.stop() + + argument_composer = ArgumentComposer() + argument_composer.classifications_to_include = [Constants.PackageClassification.SECURITY, Constants.PackageClassification.CRITICAL] + argument_composer.health_store_id = "pub_off_sku_2025.03.24" + argument_composer.operation = Constants.INSTALLATION + self.runtime = RuntimeCompositor(argument_composer.get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + execution_config = self.container.get('execution_config') + self.assertTrue(execution_config.included_classifications_list is not None) + self.assertTrue(execution_config.included_classifications_list == [Constants.PackageClassification.CRITICAL, Constants.PackageClassification.SECURITY, Constants.PackageClassification.OTHER]) + + LegacyEnvLayerExtensions.LegacyPlatform.linux_distribution = backup_envlayer_platform_linux_distribution + + def test_disable_auto_os_updates_with_uninstalled_services(self): + # no services are installed on the machine. expected o/p: function will complete successfully. Backup file will be created with default values, no auto OS update configuration settings will be updated as there are none + self.runtime.set_legacy_test_type('SadPath') + package_manager = self.container.get('package_manager') + package_manager.disable_auto_os_update() + self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) + image_default_patch_configuration_backup = json.loads(self.runtime.env_layer.file_system.read_with_retry(package_manager.image_default_patch_configuration_backup_path)) + self.assertTrue(image_default_patch_configuration_backup is not None) + + # validating backup for dnf-automatic + self.assertTrue(package_manager.dnf_auto_os_update_service in image_default_patch_configuration_backup) + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_download_updates_identifier_text], "") + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_apply_updates_identifier_text], "") + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_enable_on_reboot_identifier_text], False) + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_installation_state_identifier_text], False) + + def test_disable_auto_os_updates_with_installed_services(self): + # all services are installed and contain valid configurations. expected o/p All services will be disabled and backup file should reflect default settings for all + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + package_manager.disable_auto_os_update() + self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) + image_default_patch_configuration_backup = json.loads(self.runtime.env_layer.file_system.read_with_retry(package_manager.image_default_patch_configuration_backup_path)) + self.assertTrue(image_default_patch_configuration_backup is not None) + + # validating backup for dnf-automatic + self.assertTrue(package_manager.dnf_auto_os_update_service in image_default_patch_configuration_backup) + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_download_updates_identifier_text], "yes") + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_apply_updates_identifier_text], "yes") + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_enable_on_reboot_identifier_text], False) + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_installation_state_identifier_text], True) + + def test_disable_auto_os_update_failure(self): + # disable with non existing log file + package_manager = self.container.get('package_manager') + + self.assertRaises(Exception, package_manager.disable_auto_os_update) + self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) + + def test_update_image_default_patch_mode(self): + package_manager = self.container.get('package_manager') + package_manager.os_patch_configuration_settings_file_path = package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + + # disable apply_updates when enabled by default + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_apply_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) + dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) + self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) + self.assertTrue('apply_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) + self.assertTrue('download_updates = yes' in dnf_automatic_os_patch_configuration_settings_file_path_read) + + # disable download_updates when enabled by default + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.os_patch_configuration_settings_file_path, dnf_automatic_os_patch_configuration_settings) + package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_download_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) + dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) + self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) + self.assertTrue('apply_updates = yes' in dnf_automatic_os_patch_configuration_settings_file_path_read) + self.assertTrue('download_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) + + # disable apply_updates when default patch mode settings file is empty + dnf_automatic_os_patch_configuration_settings = '' + self.runtime.write_to_file(package_manager.os_patch_configuration_settings_file_path, dnf_automatic_os_patch_configuration_settings) + package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_apply_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) + dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) + self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) + self.assertTrue('download_updates' not in dnf_automatic_os_patch_configuration_settings_file_path_read) + self.assertTrue('apply_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) + + def test_update_image_default_patch_mode_raises_exception(self): + package_manager = self.container.get('package_manager') + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + self.runtime.env_layer.file_system.write_with_retry = self.mock_write_with_retry_raise_exception + self.assertRaises(Exception, package_manager.update_os_patch_configuration_sub_setting) + + def test_get_current_auto_os_patch_state_with_uninstalled_services(self): + # no services are installed on the machine. expected o/p: function will complete successfully, backup file is not created and function returns current_auto_os_patch_state as disabled + self.runtime.set_legacy_test_type('SadPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.DISABLED) + + def test_get_current_auto_os_patch_state_with_installed_services_and_state_disabled(self): + # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as disabled + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = no\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.DISABLED) + + def test_get_current_auto_os_patch_state_with_installed_services_and_state_enabled(self): + # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as enabled + + # with enable on reboot set to false + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.ENABLED) + + # with enable on reboot set to true + self.runtime.set_legacy_test_type('AnotherSadPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = no\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.ENABLED) + + def test_get_current_auto_os_patch_state_with_installed_services_and_state_unknown(self): + # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as unknown + + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = abc\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.UNKNOWN) + + def test_revert_auto_os_update_to_system_default(self): + revert_success_testcase = { + "legacy_type": 'HappyPath', + "stdio": { + "capture_output": False, + "expected_output": None + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": True, + "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": True, + "apply_updates_value": "yes", + "download_updates_value": "yes", + "enable_on_reboot_value": True, + "installation_state_value": True, + "set_installation_state": True + } + }, + "assertions": { + "config_value_expected": 'apply_updates = yes\ndownload_updates = yes\n', + "config_exists": True + } + } + + revert_success_with_dnf_not_installed_testcase = { + "legacy_type": 'SadPath', + "stdio": { + "capture_output": False, + "expected_output": None + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": False, + "current_auto_os_update_config_value": '' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": True, + "apply_updates_value": "", + "download_updates_value": "", + "enable_on_reboot_value": False, + "installation_state_value": False, + "set_installation_state": True + } + }, + "assertions": { + "config_value_expected": "", + "config_exists": False + } + } + + revert_success_with_dnf_installed_but_no_config_value_testcase = { + "legacy_type": 'RevertToImageDefault', + "stdio": { + "capture_output": False, + "expected_output": None + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": True, + "current_auto_os_update_config_value": 'test_value = yes\n' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": True, + "apply_updates_value": "", + "download_updates_value": "", + "enable_on_reboot_value": False, + "installation_state_value": False, + "set_installation_state": True + } + }, + "assertions": { + "config_value_expected": 'download_updates =\napply_updates = \n', + "config_exists": True + } + } + + revert_success_backup_config_does_not_exist_testcase = { + "legacy_type": 'RevertToImageDefault', + "stdio": { + "capture_output": True, + "expected_output": "[AzL3TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service=dnf-automatic]" + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": True, + "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": False, + "apply_updates_value": "", + "download_updates_value": "", + "enable_on_reboot_value": False, + "installation_state_value": False, + "set_installation_state": True + } + }, + "assertions": { + "config_value_expected": 'apply_updates = no\ndownload_updates = no\n', + "config_exists": True + } + } + + revert_success_default_backup_config_invalid_testcase = { + "legacy_type": 'RevertToImageDefault', + "stdio": { + "capture_output": True, + "expected_output": "[AzL3TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service=dnf-automatic]" + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": True, + "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": True, + "apply_updates_value": "yes", + "download_updates_value": "yes", + "enable_on_reboot_value": True, + "installation_state_value": False, + "set_installation_state": False + } + }, + "assertions": { + "config_value_expected": 'apply_updates = no\ndownload_updates = no\n', + "config_exists": True + } + } + + all_testcases = [revert_success_testcase, revert_success_with_dnf_not_installed_testcase, revert_success_with_dnf_installed_but_no_config_value_testcase, revert_success_backup_config_does_not_exist_testcase, revert_success_default_backup_config_invalid_testcase] + + for testcase in all_testcases: + self.tearDown() + self.setUp() + captured_output, original_stdout = None, None + if testcase["stdio"]["capture_output"]: + # arrange capture std IO + captured_output, original_stdout = self.__capture_std_io() + + self.runtime.set_legacy_test_type(testcase["legacy_type"]) + package_manager = self.container.get('package_manager') + + # setup current auto OS update config, backup for system default config and invoke revert to system default + self.__setup_config_and_invoke_revert_auto_os_to_system_default(package_manager, + create_current_auto_os_config=bool(testcase["config"]["current_auto_update_config"]["create_current_auto_os_config"]), + current_auto_os_update_config_value=testcase["config"]["current_auto_update_config"]["current_auto_os_update_config_value"], + create_backup_for_system_default_config=bool(testcase["config"]["backup_system_default_config"]["create_backup_for_system_default_config"]), + apply_updates_value=testcase["config"]["backup_system_default_config"]["apply_updates_value"], + download_updates_value=testcase["config"]["backup_system_default_config"]["download_updates_value"], + enable_on_reboot_value=bool(testcase["config"]["backup_system_default_config"]["enable_on_reboot_value"]), + installation_state_value=bool(testcase["config"]["backup_system_default_config"]["installation_state_value"]), + set_installation_state=bool(testcase["config"]["backup_system_default_config"]["set_installation_state"])) + + # assert + if testcase["stdio"]["capture_output"]: + # restore sys.stdout output + sys.stdout = original_stdout + self.__assert_std_io(captured_output=captured_output, expected_output=testcase["stdio"]["expected_output"]) + self.__assert_reverted_automatic_patch_configuration_settings(package_manager, config_exists=bool(testcase["assertions"]["config_exists"]), config_value_expected=testcase["assertions"]["config_value_expected"]) + + def test_set_max_patch_publish_date(self): + """Unit test for tdnf package manager set_max_patch_publish_date method""" + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + + input_output_table_for_successful_cases = [ + ["20240702T000000Z", "1719878400"], + ["", ""] + ] + for row in input_output_table_for_successful_cases: + package_manager.set_max_patch_publish_date(row[0]) + self.assertEqual(package_manager.max_patch_publish_date, row[1]) + + # posix time computation throws an exception if the date is not in the correct format + self.assertRaises(ValueError, package_manager.set_max_patch_publish_date, "2024-07-02T00:00:00Z") + + def test_get_tdnf_version(self): + """Unit test for tdnf package manager get_tdnf_version method""" + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.backup_run_command_output = self.runtime.env_layer.run_command_output + + test_input_output_table = [ + [self.mock_run_command_output_return_tdnf_3, "3.5.8-3"], + [self.mock_run_command_output_return_1, None], + ] + + for row in test_input_output_table: + self.runtime.env_layer.run_command_output = row[0] + version = package_manager.get_tdnf_version() + self.assertEqual(version, row[1]) + + self.runtime.env_layer.run_command_output = self.backup_run_command_output + + def test_is_mininum_tdnf_version_for_strict_sdp_installed(self): + """Unit test for tdnf package manager is_minimum_tdnf_version method""" + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + + self.backup_get_tdnf_version = package_manager.get_tdnf_version + + test_input_output_table = [ + [self.mock_get_tdnf_version_return_None, False], + [self.mock_get_tdnf_version_return_tdnf_2_5, False], + [self.mock_get_tdnf_version_return_tdnf_3_5_8_2, False], + [self.mock_get_tdnf_version_return_tdnf_3_5_8_6_cm2, False], + [self.mock_get_tdnf_version_return_tdnf_3_5_8_3, True], + [self.mock_get_tdnf_version_return_tdnf_4_0, True] + ] + + for row in test_input_output_table: + package_manager.get_tdnf_version = row[0] + result = package_manager.is_minimum_tdnf_version_for_strict_sdp_installed() + self.assertEqual(result, row[1]) + + package_manager.get_tdnf_version = self.backup_get_tdnf_version + + def test_try_tdnf_update_to_meet_strict_sdp_requirements(self): + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + + self.backup_run_command_output = self.runtime.env_layer.run_command_output + + input_output_table = [ + [self.mock_run_command_output_return_0, True], + [self.mock_run_command_output_return_1, False], + ] + + for row in input_output_table: + self.runtime.env_layer.run_command_output = row[0] + result = package_manager.try_tdnf_update_to_meet_strict_sdp_requirements() + self.assertEqual(result, row[1]) + + self.runtime.env_layer.run_command_output = self.backup_run_command_output + + def test_try_meet_azgps_coordinated_requirements(self): + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + + # backup methods + self.backup_linux_distribution = self.runtime.env_layer.platform.linux_distribution + self.backup_distro_os_release_attr = distro.os_release_attr + self.backup_get_tdnf_version = package_manager.get_tdnf_version + self.backup_run_command_output = self.runtime.env_layer.run_command_output + + """ test cases: + 1. Azure Linux 3 with tdnf version > 3.5.8-3 + 2. Azure Linux 3 with tdnf version = 3.5.8-3 + 3. Azure Linux 3 with tdnf version < 3.5.8-3, will be updated to 3.5.8-3 successfully + 4. Azure Linux 3 with tdnf version < 3.5.8-3, will not be updated to 3.5.8-3 + 5. Azure Linux 2""" + test_input_output_table = [ + [self.mock_linux_distribution_to_return_azure_linux, self.mock_distro_os_release_attr_return_azure_linux_3, self.mock_get_tdnf_version_return_tdnf_4_0, self.backup_run_command_output, True], + [self.mock_linux_distribution_to_return_azure_linux, self.mock_distro_os_release_attr_return_azure_linux_3, self.mock_get_tdnf_version_return_tdnf_3_5_8_3, self.backup_run_command_output, True], + [self.mock_linux_distribution_to_return_azure_linux, self.mock_distro_os_release_attr_return_azure_linux_3, self.mock_get_tdnf_version_return_tdnf_2_5, self.mock_run_command_output_return_0, True], + [self.mock_linux_distribution_to_return_azure_linux, self.mock_distro_os_release_attr_return_azure_linux_3, self.mock_get_tdnf_version_return_tdnf_2_5, self.mock_run_command_output_return_1, False], + [self.mock_linux_distribution_to_return_azure_linux_2, self.mock_distro_os_release_attr_return_azure_linux_2, self.backup_distro_os_release_attr, self.backup_run_command_output, False] + ] + + for row in test_input_output_table: + # set test case values + self.runtime.env_layer.platform.linux_distribution = row[0] + distro.os_release_attr = row[1] + package_manager.get_tdnf_version = row[2] + self.runtime.env_layer.run_command_output = row[3] + + # run test case + result = package_manager.try_meet_azgps_coordinated_requirements() + self.assertEqual(result, row[4]) + + # restore original methods + self.runtime.env_layer.platform.linux_distribution = self.backup_linux_distribution + distro.os_release_attr = self.backup_distro_os_release_attr + package_manager.get_tdnf_version = self.backup_get_tdnf_version + self.runtime.env_layer.run_command_output = self.backup_run_command_output + + +if __name__ == '__main__': + unittest.main() + diff --git a/src/core/tests/Test_TdnfPackageManager.py b/src/core/tests/Test_TdnfPackageManager.py index c05275de..354471bf 100644 --- a/src/core/tests/Test_TdnfPackageManager.py +++ b/src/core/tests/Test_TdnfPackageManager.py @@ -13,24 +13,14 @@ # limitations under the License. # # Requires Python 2.7+ -import json -import os import unittest -import sys -# Conditional import for StringIO -try: - from StringIO import StringIO # Python 2 -except ImportError: - from io import StringIO # Python 3 from core.src.bootstrap.Constants import Constants -from core.tests.library.LegacyEnvLayerExtensions import LegacyEnvLayerExtensions from core.tests.library.ArgumentComposer import ArgumentComposer from core.tests.library.RuntimeCompositor import RuntimeCompositor -from core.src.external_dependencies import distro -class TestTdnfPackageManager(unittest.TestCase): +class TestAzL3TdnfPackageManager(unittest.TestCase): def setUp(self): self.runtime = RuntimeCompositor(ArgumentComposer().get_composed_arguments(), True, Constants.TDNF) self.container = self.runtime.container @@ -38,132 +28,11 @@ def setUp(self): def tearDown(self): self.runtime.stop() - # region Mocks - def mock_do_processes_require_restart_raise_exception(self): - raise Exception - - def mock_linux_distribution_to_return_azure_linux(self): - return ['Microsoft Azure Linux', '3.0', ''] - - def mock_linux_distribution_to_return_azure_linux_2(self): - return ['Common Base Linux Mariner', '2.0', ''] - - def mock_write_with_retry_raise_exception(self, file_path_or_handle, data, mode='a+'): - raise Exception - - def mock_run_command_output_return_tdnf_3(self, cmd, no_output=False, chk_err=True): - """ Mock for run_command_output to return tdnf 3 """ - return 0, "3.5.8-3\n" - - def mock_run_command_output_return_1(self, cmd, no_output=False, chk_err=True): - """ Mock for run_command_output to return None """ - return 1, "No output available\n" - - def mock_run_command_output_return_0(self, cmd, no_output=False, chk_err=True): - return 0, "Successfully executed command\n" - - def mock_get_tdnf_version_return_tdnf_3_5_8_3(self): - return "3.5.8-3.azl3" - - def mock_get_tdnf_version_return_tdnf_4_0(self): - return "4.0.0-1.azl3" - - def mock_get_tdnf_version_return_tdnf_2_5(self): - return "2.5.0-1.cm2" - - def mock_get_tdnf_version_return_tdnf_3_5_8_2(self): - return "3.5.8-2.azl3" - - def mock_get_tdnf_version_return_tdnf_3_5_8_6_cm2(self): - return "3.5.8-6.cm2" - - def mock_get_tdnf_version_return_None(self): - return None - - def mock_distro_os_release_attr_return_azure_linux_3(self, attribute): - return '3.0.0' - - def mock_distro_os_release_attr_return_azure_linux_2(self, attribute): - return '2.9.0' - # endregion - - # region Utility Functions - def __setup_config_and_invoke_revert_auto_os_to_system_default(self, package_manager, create_current_auto_os_config=True, create_backup_for_system_default_config=True, current_auto_os_update_config_value='', apply_updates_value="", - download_updates_value="", enable_on_reboot_value=False, installation_state_value=False, set_installation_state=True): - """ Sets up current auto OS update config, backup for system default config (if requested) and invoke revert to system default """ - # setup current auto OS update config - if create_current_auto_os_config: - self.__setup_current_auto_os_update_config(package_manager, current_auto_os_update_config_value) - - # setup backup for system default auto OS update config - if create_backup_for_system_default_config: - self.__setup_backup_for_system_default_OS_update_config(package_manager, apply_updates_value=apply_updates_value, download_updates_value=download_updates_value, enable_on_reboot_value=enable_on_reboot_value, - installation_state_value=installation_state_value, set_installation_state=set_installation_state) - - package_manager.revert_auto_os_update_to_system_default() - - def __setup_current_auto_os_update_config(self, package_manager, config_value='', config_file_name="automatic.conf"): - # setup current auto OS update config - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, config_file_name) - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, config_value) - - def __setup_backup_for_system_default_OS_update_config(self, package_manager, apply_updates_value="", download_updates_value="", enable_on_reboot_value=False, installation_state_value=False, set_installation_state=True): - # setup backup for system default auto OS update config - package_manager.image_default_patch_configuration_backup_path = os.path.join(self.runtime.execution_config.config_folder, Constants.IMAGE_DEFAULT_PATCH_CONFIGURATION_BACKUP_PATH) - backup_image_default_patch_configuration_json = { - "dnf-automatic": { - "apply_updates": apply_updates_value, - "download_updates": download_updates_value, - "enable_on_reboot": enable_on_reboot_value - } - } - if set_installation_state: - backup_image_default_patch_configuration_json["dnf-automatic"]["installation_state"] = installation_state_value - self.runtime.write_to_file(package_manager.image_default_patch_configuration_backup_path, '{0}'.format(json.dumps(backup_image_default_patch_configuration_json))) - - @staticmethod - def __capture_std_io(): - # arrange capture std IO - captured_output = StringIO() - original_stdout = sys.stdout - sys.stdout = captured_output - return captured_output, original_stdout - - def __assert_std_io(self, captured_output, expected_output=''): - output = captured_output.getvalue() - self.assertTrue(expected_output in output) - - def __assert_reverted_automatic_patch_configuration_settings(self, package_manager, config_exists=True, config_value_expected=''): - if config_exists: - reverted_dnf_automatic_patch_configuration_settings = self.runtime.env_layer.file_system.read_with_retry(package_manager.dnf_automatic_configuration_file_path) - self.assertTrue(reverted_dnf_automatic_patch_configuration_settings is not None) - self.assertTrue(config_value_expected in reverted_dnf_automatic_patch_configuration_settings) - else: - self.assertFalse(os.path.exists(package_manager.dnf_automatic_configuration_file_path)) - # endregion - - def test_do_processes_require_restart(self): - """Unit test for tdnf package manager""" - # Restart required + def test_refresh_repo(self): self.runtime.set_legacy_test_type('HappyPath') package_manager = self.container.get('package_manager') - self.assertTrue(package_manager) - self.assertTrue(package_manager.is_reboot_pending()) - - # Restart not required - self.runtime.set_legacy_test_type('SadPath') - package_manager = self.container.get('package_manager') self.assertTrue(package_manager is not None) - self.assertFalse(package_manager.is_reboot_pending()) - - # Fake exception - self.runtime.set_legacy_test_type('SadPath') - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - backup_do_processes_require_restart = package_manager.do_processes_require_restart - package_manager.do_processes_require_restart = self.mock_do_processes_require_restart_raise_exception - self.assertTrue(package_manager.is_reboot_pending()) # returns true because the safe default if a failure occurs is 'true' - package_manager.do_processes_require_restart = backup_do_processes_require_restart + package_manager.refresh_repo_safely() def test_package_manager_no_updates(self): """Unit test for tdnf package manager with no updates""" @@ -545,469 +414,6 @@ def test_obsolete_packages_should_not_considered_in_available_updates(self): self.assertEqual(package_versions[4], '3.12.3-6.azl3') self.assertEqual(package_versions[5], '3.12.9-1.azl3') - def test_all_classification_selected_for_auto_patching_request(self): - """Unit test for tdnf package manager for auto patching request where all classifications are selected since Azure Linux does not have classifications""" - backup_envlayer_platform_linux_distribution = LegacyEnvLayerExtensions.LegacyPlatform.linux_distribution - LegacyEnvLayerExtensions.LegacyPlatform.linux_distribution = self.mock_linux_distribution_to_return_azure_linux - - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - self.runtime.stop() - - argument_composer = ArgumentComposer() - argument_composer.classifications_to_include = [Constants.PackageClassification.SECURITY, Constants.PackageClassification.CRITICAL] - argument_composer.health_store_id = "pub_off_sku_2025.03.24" - argument_composer.operation = Constants.INSTALLATION - self.runtime = RuntimeCompositor(argument_composer.get_composed_arguments(), True, Constants.TDNF) - self.container = self.runtime.container - - execution_config = self.container.get('execution_config') - self.assertTrue(execution_config.included_classifications_list is not None) - self.assertTrue(execution_config.included_classifications_list == [Constants.PackageClassification.CRITICAL, Constants.PackageClassification.SECURITY, Constants.PackageClassification.OTHER]) - - LegacyEnvLayerExtensions.LegacyPlatform.linux_distribution = backup_envlayer_platform_linux_distribution - - def test_refresh_repo(self): - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - package_manager.refresh_repo_safely() - - def test_disable_auto_os_updates_with_uninstalled_services(self): - # no services are installed on the machine. expected o/p: function will complete successfully. Backup file will be created with default values, no auto OS update configuration settings will be updated as there are none - self.runtime.set_legacy_test_type('SadPath') - package_manager = self.container.get('package_manager') - package_manager.disable_auto_os_update() - self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) - image_default_patch_configuration_backup = json.loads(self.runtime.env_layer.file_system.read_with_retry(package_manager.image_default_patch_configuration_backup_path)) - self.assertTrue(image_default_patch_configuration_backup is not None) - - # validating backup for dnf-automatic - self.assertTrue(package_manager.dnf_auto_os_update_service in image_default_patch_configuration_backup) - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_download_updates_identifier_text], "") - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_apply_updates_identifier_text], "") - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_enable_on_reboot_identifier_text], False) - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_installation_state_identifier_text], False) - - def test_disable_auto_os_updates_with_installed_services(self): - # all services are installed and contain valid configurations. expected o/p All services will be disabled and backup file should reflect default settings for all - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - package_manager.disable_auto_os_update() - self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) - image_default_patch_configuration_backup = json.loads(self.runtime.env_layer.file_system.read_with_retry(package_manager.image_default_patch_configuration_backup_path)) - self.assertTrue(image_default_patch_configuration_backup is not None) - - # validating backup for dnf-automatic - self.assertTrue(package_manager.dnf_auto_os_update_service in image_default_patch_configuration_backup) - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_download_updates_identifier_text], "yes") - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_apply_updates_identifier_text], "yes") - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_enable_on_reboot_identifier_text], False) - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_installation_state_identifier_text], True) - - def test_disable_auto_os_update_failure(self): - # disable with non existing log file - package_manager = self.container.get('package_manager') - - self.assertRaises(Exception, package_manager.disable_auto_os_update) - self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) - - def test_update_image_default_patch_mode(self): - package_manager = self.container.get('package_manager') - package_manager.os_patch_configuration_settings_file_path = package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - - # disable apply_updates when enabled by default - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_apply_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) - dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) - self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) - self.assertTrue('apply_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) - self.assertTrue('download_updates = yes' in dnf_automatic_os_patch_configuration_settings_file_path_read) - - # disable download_updates when enabled by default - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.os_patch_configuration_settings_file_path, dnf_automatic_os_patch_configuration_settings) - package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_download_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) - dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) - self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) - self.assertTrue('apply_updates = yes' in dnf_automatic_os_patch_configuration_settings_file_path_read) - self.assertTrue('download_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) - - # disable apply_updates when default patch mode settings file is empty - dnf_automatic_os_patch_configuration_settings = '' - self.runtime.write_to_file(package_manager.os_patch_configuration_settings_file_path, dnf_automatic_os_patch_configuration_settings) - package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_apply_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) - dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) - self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) - self.assertTrue('download_updates' not in dnf_automatic_os_patch_configuration_settings_file_path_read) - self.assertTrue('apply_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) - - def test_update_image_default_patch_mode_raises_exception(self): - package_manager = self.container.get('package_manager') - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - self.runtime.env_layer.file_system.write_with_retry = self.mock_write_with_retry_raise_exception - self.assertRaises(Exception, package_manager.update_os_patch_configuration_sub_setting) - - def test_get_current_auto_os_patch_state_with_uninstalled_services(self): - # no services are installed on the machine. expected o/p: function will complete successfully, backup file is not created and function returns current_auto_os_patch_state as disabled - self.runtime.set_legacy_test_type('SadPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.DISABLED) - - def test_get_current_auto_os_patch_state_with_installed_services_and_state_disabled(self): - # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as disabled - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = no\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.DISABLED) - - def test_get_current_auto_os_patch_state_with_installed_services_and_state_enabled(self): - # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as enabled - - # with enable on reboot set to false - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.ENABLED) - - # with enable on reboot set to true - self.runtime.set_legacy_test_type('AnotherSadPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = no\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.ENABLED) - - def test_get_current_auto_os_patch_state_with_installed_services_and_state_unknown(self): - # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as unknown - - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = abc\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.UNKNOWN) - - def test_revert_auto_os_update_to_system_default(self): - revert_success_testcase = { - "legacy_type": 'HappyPath', - "stdio": { - "capture_output": False, - "expected_output": None - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": True, - "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": True, - "apply_updates_value": "yes", - "download_updates_value": "yes", - "enable_on_reboot_value": True, - "installation_state_value": True, - "set_installation_state": True - } - }, - "assertions": { - "config_value_expected": 'apply_updates = yes\ndownload_updates = yes\n', - "config_exists": True - } - } - - revert_success_with_dnf_not_installed_testcase = { - "legacy_type": 'SadPath', - "stdio": { - "capture_output": False, - "expected_output": None - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": False, - "current_auto_os_update_config_value": '' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": True, - "apply_updates_value": "", - "download_updates_value": "", - "enable_on_reboot_value": False, - "installation_state_value": False, - "set_installation_state": True - } - }, - "assertions": { - "config_value_expected": "", - "config_exists": False - } - } - - revert_success_with_dnf_installed_but_no_config_value_testcase = { - "legacy_type": 'RevertToImageDefault', - "stdio": { - "capture_output": False, - "expected_output": None - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": True, - "current_auto_os_update_config_value": 'test_value = yes\n' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": True, - "apply_updates_value": "", - "download_updates_value": "", - "enable_on_reboot_value": False, - "installation_state_value": False, - "set_installation_state": True - } - }, - "assertions": { - "config_value_expected": 'download_updates =\napply_updates = \n', - "config_exists": True - } - } - - revert_success_backup_config_does_not_exist_testcase = { - "legacy_type": 'RevertToImageDefault', - "stdio": { - "capture_output": True, - "expected_output": "[TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service=dnf-automatic]" - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": True, - "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": False, - "apply_updates_value": "", - "download_updates_value": "", - "enable_on_reboot_value": False, - "installation_state_value": False, - "set_installation_state": True - } - }, - "assertions": { - "config_value_expected": 'apply_updates = no\ndownload_updates = no\n', - "config_exists": True - } - } - - revert_success_default_backup_config_invalid_testcase = { - "legacy_type": 'RevertToImageDefault', - "stdio": { - "capture_output": True, - "expected_output": "[TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service=dnf-automatic]" - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": True, - "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": True, - "apply_updates_value": "yes", - "download_updates_value": "yes", - "enable_on_reboot_value": True, - "installation_state_value": False, - "set_installation_state": False - } - }, - "assertions": { - "config_value_expected": 'apply_updates = no\ndownload_updates = no\n', - "config_exists": True - } - } - - all_testcases = [revert_success_testcase, revert_success_with_dnf_not_installed_testcase, revert_success_with_dnf_installed_but_no_config_value_testcase, revert_success_backup_config_does_not_exist_testcase, revert_success_default_backup_config_invalid_testcase] - - for testcase in all_testcases: - self.tearDown() - self.setUp() - captured_output, original_stdout = None, None - if testcase["stdio"]["capture_output"]: - # arrange capture std IO - captured_output, original_stdout = self.__capture_std_io() - - self.runtime.set_legacy_test_type(testcase["legacy_type"]) - package_manager = self.container.get('package_manager') - - # setup current auto OS update config, backup for system default config and invoke revert to system default - self.__setup_config_and_invoke_revert_auto_os_to_system_default(package_manager, - create_current_auto_os_config=bool(testcase["config"]["current_auto_update_config"]["create_current_auto_os_config"]), - current_auto_os_update_config_value=testcase["config"]["current_auto_update_config"]["current_auto_os_update_config_value"], - create_backup_for_system_default_config=bool(testcase["config"]["backup_system_default_config"]["create_backup_for_system_default_config"]), - apply_updates_value=testcase["config"]["backup_system_default_config"]["apply_updates_value"], - download_updates_value=testcase["config"]["backup_system_default_config"]["download_updates_value"], - enable_on_reboot_value=bool(testcase["config"]["backup_system_default_config"]["enable_on_reboot_value"]), - installation_state_value=bool(testcase["config"]["backup_system_default_config"]["installation_state_value"]), - set_installation_state=bool(testcase["config"]["backup_system_default_config"]["set_installation_state"])) - - # assert - if testcase["stdio"]["capture_output"]: - # restore sys.stdout output - sys.stdout = original_stdout - self.__assert_std_io(captured_output=captured_output, expected_output=testcase["stdio"]["expected_output"]) - self.__assert_reverted_automatic_patch_configuration_settings(package_manager, config_exists=bool(testcase["assertions"]["config_exists"]), config_value_expected=testcase["assertions"]["config_value_expected"]) - - def test_set_max_patch_publish_date(self): - """Unit test for tdnf package manager set_max_patch_publish_date method""" - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - - input_output_table_for_successful_cases = [ - ["20240702T000000Z", "1719878400"], - ["", ""] - ] - for row in input_output_table_for_successful_cases: - package_manager.set_max_patch_publish_date(row[0]) - self.assertEqual(package_manager.max_patch_publish_date, row[1]) - - # posix time computation throws an exception if the date is not in the correct format - self.assertRaises(ValueError, package_manager.set_max_patch_publish_date, "2024-07-02T00:00:00Z") - - def test_get_tdnf_version(self): - """Unit test for tdnf package manager get_tdnf_version method""" - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - self.backup_run_command_output = self.runtime.env_layer.run_command_output - - test_input_output_table = [ - [self.mock_run_command_output_return_tdnf_3, "3.5.8-3"], - [self.mock_run_command_output_return_1, None], - ] - - for row in test_input_output_table: - self.runtime.env_layer.run_command_output = row[0] - version = package_manager.get_tdnf_version() - self.assertEqual(version, row[1]) - - self.runtime.env_layer.run_command_output = self.backup_run_command_output - - def test_is_mininum_tdnf_version_for_strict_sdp_installed(self): - """Unit test for tdnf package manager is_minimum_tdnf_version method""" - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - - self.backup_get_tdnf_version = package_manager.get_tdnf_version - - test_input_output_table = [ - [self.mock_get_tdnf_version_return_None, False], - [self.mock_get_tdnf_version_return_tdnf_2_5, False], - [self.mock_get_tdnf_version_return_tdnf_3_5_8_2, False], - [self.mock_get_tdnf_version_return_tdnf_3_5_8_6_cm2, False], - [self.mock_get_tdnf_version_return_tdnf_3_5_8_3, True], - [self.mock_get_tdnf_version_return_tdnf_4_0, True] - ] - - for row in test_input_output_table: - package_manager.get_tdnf_version = row[0] - result = package_manager.is_minimum_tdnf_version_for_strict_sdp_installed() - self.assertEqual(result, row[1]) - - package_manager.get_tdnf_version = self.backup_get_tdnf_version - - def test_try_tdnf_update_to_meet_strict_sdp_requirements(self): - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - - self.backup_run_command_output = self.runtime.env_layer.run_command_output - - input_output_table = [ - [self.mock_run_command_output_return_0, True], - [self.mock_run_command_output_return_1, False], - ] - - for row in input_output_table: - self.runtime.env_layer.run_command_output = row[0] - result = package_manager.try_tdnf_update_to_meet_strict_sdp_requirements() - self.assertEqual(result, row[1]) - - self.runtime.env_layer.run_command_output = self.backup_run_command_output - - def test_try_meet_azgps_coordinated_requirements(self): - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - - # backup methods - self.backup_linux_distribution = self.runtime.env_layer.platform.linux_distribution - self.backup_distro_os_release_attr = distro.os_release_attr - self.backup_get_tdnf_version = package_manager.get_tdnf_version - self.backup_run_command_output = self.runtime.env_layer.run_command_output - - """ test cases: - 1. Azure Linux 3 with tdnf version > 3.5.8-3 - 2. Azure Linux 3 with tdnf version = 3.5.8-3 - 3. Azure Linux 3 with tdnf version < 3.5.8-3, will be updated to 3.5.8-3 successfully - 4. Azure Linux 3 with tdnf version < 3.5.8-3, will not be updated to 3.5.8-3 - 5. Azure Linux 2""" - test_input_output_table = [ - [self.mock_linux_distribution_to_return_azure_linux, self.mock_distro_os_release_attr_return_azure_linux_3, self.mock_get_tdnf_version_return_tdnf_4_0, self.backup_run_command_output, True], - [self.mock_linux_distribution_to_return_azure_linux, self.mock_distro_os_release_attr_return_azure_linux_3, self.mock_get_tdnf_version_return_tdnf_3_5_8_3, self.backup_run_command_output, True], - [self.mock_linux_distribution_to_return_azure_linux, self.mock_distro_os_release_attr_return_azure_linux_3, self.mock_get_tdnf_version_return_tdnf_2_5, self.mock_run_command_output_return_0, True], - [self.mock_linux_distribution_to_return_azure_linux, self.mock_distro_os_release_attr_return_azure_linux_3, self.mock_get_tdnf_version_return_tdnf_2_5, self.mock_run_command_output_return_1, False], - [self.mock_linux_distribution_to_return_azure_linux_2, self.mock_distro_os_release_attr_return_azure_linux_2, self.backup_distro_os_release_attr, self.backup_run_command_output, False] - ] - - for row in test_input_output_table: - # set test case values - self.runtime.env_layer.platform.linux_distribution = row[0] - distro.os_release_attr = row[1] - package_manager.get_tdnf_version = row[2] - self.runtime.env_layer.run_command_output = row[3] - - # run test case - result = package_manager.try_meet_azgps_coordinated_requirements() - self.assertEqual(result, row[4]) - - # restore original methods - self.runtime.env_layer.platform.linux_distribution = self.backup_linux_distribution - distro.os_release_attr = self.backup_distro_os_release_attr - package_manager.get_tdnf_version = self.backup_get_tdnf_version - self.runtime.env_layer.run_command_output = self.backup_run_command_output - if __name__ == '__main__': unittest.main() From c8360b793184c309199417815ac87659b5d81099 Mon Sep 17 00:00:00 2001 From: Rajasi Rane Date: Mon, 15 Sep 2025 11:18:00 -0700 Subject: [PATCH 2/6] [AzL3Tdnf] Extracting Tdnf generic code to TdnfPackageManager --- .../package_managers/TdnfPackageManager.py | 378 ++++++++++++++++ src/core/tests/Test_TdnfPackageManager.py | 420 ++++++++++++++++++ 2 files changed, 798 insertions(+) create mode 100644 src/core/src/package_managers/TdnfPackageManager.py create mode 100644 src/core/tests/Test_TdnfPackageManager.py diff --git a/src/core/src/package_managers/TdnfPackageManager.py b/src/core/src/package_managers/TdnfPackageManager.py new file mode 100644 index 00000000..2f95fa91 --- /dev/null +++ b/src/core/src/package_managers/TdnfPackageManager.py @@ -0,0 +1,378 @@ +# Copyright 2025 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.7+ + +"""TdnfPackageManager for Azure Linux""" +import re + +from abc import ABCMeta, abstractmethod +from core.src.core_logic.VersionComparator import VersionComparator +from core.src.bootstrap.Constants import Constants +from core.src.package_managers.PackageManager import PackageManager + + +class TdnfPackageManager(PackageManager): + """Implementation of Tdnf package management operations""" + + def __init__(self, env_layer, execution_config, composite_logger, telemetry_writer, status_handler): + super(TdnfPackageManager, self).__init__(env_layer, execution_config, composite_logger, telemetry_writer, status_handler) + # Repo refresh + self.cmd_clean_cache = "sudo tdnf clean expire-cache" + self.cmd_repo_refresh = "sudo tdnf -q list updates" + + # Support to get updates and their dependencies + self.single_package_check_versions = 'sudo tdnf list available ' + self.single_package_check_installed = 'sudo tdnf list installed ' + self.single_package_upgrade_simulation_cmd = 'sudo tdnf install --assumeno --skip-broken ' + + # Install update + self.single_package_upgrade_cmd = 'sudo tdnf -y install --skip-broken ' + + # Package manager exit code(s) + self.tdnf_exitcode_ok = 0 + self.tdnf_exitcode_on_no_action_for_install_update = 8 + self.commands_expecting_no_action_exitcode = [self.single_package_upgrade_simulation_cmd] + + # Miscellaneous + self.set_package_manager_setting(Constants.PKG_MGR_SETTING_IDENTITY, Constants.TDNF) + self.STR_TOTAL_DOWNLOAD_SIZE = "Total download size: " + self.version_comparator = VersionComparator() + + self.package_install_expected_avg_time_in_seconds = 90 # Setting a default value of 90 seconds as the avg time to install a package using tdnf, might be changed later if needed. + + def refresh_repo(self): + self.composite_logger.log("[TDNF] Refreshing local repo...") + self.invoke_package_manager(self.cmd_clean_cache) + self.invoke_package_manager(self.cmd_repo_refresh) + + # region Get Available Updates + def invoke_package_manager_advanced(self, command, raise_on_exception=True): + """Get missing updates using the command input""" + self.composite_logger.log_verbose("[TDNF] Invoking package manager. [Command={0}]".format(str(command))) + code, out = self.env_layer.run_command_output(command, False, False) + + if code is self.tdnf_exitcode_ok or \ + (any(command_expecting_no_action_exitcode in command for command_expecting_no_action_exitcode in self.commands_expecting_no_action_exitcode) and + code is self.tdnf_exitcode_on_no_action_for_install_update): + self.composite_logger.log_debug('[TDNF] Invoked package manager. [Command={0}][Code={1}][Output={2}]'.format(command, str(code), str(out))) + else: + self.composite_logger.log_warning('[ERROR] Customer environment error. [Command={0}][Code={1}][Output={2}]'.format(command, str(code), str(out))) + error_msg = "Customer environment error: Investigate and resolve unexpected return code ({0}) from package manager on command: {1}".format(str(code), command) + self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.PACKAGE_MANAGER_FAILURE) + if raise_on_exception: + raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) + + return out, code + + # region Classification-based (incl. All) update check + @abstractmethod + def get_all_updates(self, cached=False): + """Same behavior as get_available_updates, but higher performance with no filters""" + pass + return [], [] # only here to suppress a static syntax validation problem + + @abstractmethod + def get_security_updates(self): + pass + + @abstractmethod + def get_other_updates(self): + pass + + @abstractmethod + def set_max_patch_publish_date(self, max_patch_publish_date=str()): + pass + # endregion + + # region Output Parser(s) + def extract_packages_and_versions(self, output): + """Returns packages and versions from given output""" + packages, versions = self.extract_packages_and_versions_including_duplicates(output) + packages, versions = self.dedupe_update_packages_to_get_latest_versions(packages, versions) + return packages, versions + + def extract_packages_and_versions_including_duplicates(self, output): + """Returns packages and versions from given output""" + self.composite_logger.log_verbose("[TDNF] Extracting package and version data...") + packages, versions = [], [] + + lines = output.strip().split('\n') + + for line_index in range(0, len(lines)): + # Do not install Obsoleting Packages. The obsoleting packages list comes towards end in the output. + if lines[line_index].strip().startswith("Obsoleting"): + break + + line = re.split(r'\s+', lines[line_index].strip()) + + # If we run into a length of 3, we'll accept it and continue + if len(line) == 3 and self.__is_package(line[0]): + packages.append(self.get_product_name(line[0])) + versions.append(line[1]) + else: + self.composite_logger.log_verbose("[TDNF] > Inapplicable line (" + str(line_index) + "): " + lines[line_index]) + + return packages, versions + + def dedupe_update_packages_to_get_latest_versions(self, packages, package_versions): + """Remove duplicate packages and returns the latest/highest version of each package """ + deduped_packages = [] + deduped_package_versions = [] + + for index, package in enumerate(packages): + if package in deduped_packages: + deduped_package_version = deduped_package_versions[deduped_packages.index(package)] + duplicate_package_version = package_versions[index] + # use custom comparator output 0 (equal), -1 (deduped package version is the lower one), +1 (deduped package version is the greater one) + is_deduped_package_latest = self.version_comparator.compare_versions(deduped_package_version, duplicate_package_version) + if is_deduped_package_latest < 0: + deduped_package_versions[deduped_packages.index(package)] = duplicate_package_version + continue + + deduped_packages.append(package) + deduped_package_versions.append(package_versions[index]) + + return deduped_packages, deduped_package_versions + + @staticmethod + def __is_package(chunk): + # Using a list comprehension to determine if chunk is a package + package_extensions = Constants.SUPPORTED_PACKAGE_ARCH + return len([p for p in package_extensions if p in chunk]) == 1 + # endregion + # endregion + + # region Install Update + def get_composite_package_identifier(self, package, package_version): + package_without_arch, arch = self.get_product_name_and_arch(package) + package_identifier = package_without_arch + '-' + str(package_version) + if arch is not None: + package_identifier += arch + return package_identifier + + @abstractmethod + def install_updates_fail_safe(self, excluded_packages): + pass + + @abstractmethod + def install_security_updates_azgps_coordinated(self): + pass + + @abstractmethod + def try_meet_azgps_coordinated_requirements(self): + # type: () -> bool + """ Returns true if the package manager meets the requirements for azgps coordinated security updates """ + return False + # endregion + + # region Package Information + def get_all_available_versions_of_package(self, package_name): + """ Returns a list of all the available versions of a package """ + # Sample output format + # Loaded plugin: tdnfrepogpgcheck + # azurelinux-repos-shared.noarch 3.0-3.azl3 azurelinux-official-base + # azurelinux-repos-shared.noarch 3.0-4.azl3 azurelinux-official-base + cmd = self.single_package_check_versions.replace('', package_name) + output = self.invoke_package_manager(cmd) + packages, package_versions = self.extract_packages_and_versions_including_duplicates(output) + return package_versions + + def is_package_version_installed(self, package_name, package_version): + """ Returns true if the specific package version is installed """ + # Sample output format + # Loaded plugin: tdnfrepogpgcheck + # azurelinux-repos-shared.noarch 3.0-3.azl3 @System + self.composite_logger.log_verbose("[TDNF] Checking package install status. [PackageName={0}][PackageVersion={1}]".format(str(package_name), str(package_version))) + cmd = self.single_package_check_installed.replace('', package_name) + output = self.invoke_package_manager(cmd) + packages, package_versions = self.extract_packages_and_versions_including_duplicates(output) + + for index, package in enumerate(packages): + if package == package_name and (package_versions[index] == package_version): + self.composite_logger.log_debug("[TDNF] > Installed version match found. [PackageName={0}][PackageVersion={1}]".format(str(package_name), str(package_version))) + return True + else: + self.composite_logger.log_verbose("[TDNF] > Did not match: " + package + " (" + package_versions[index] + ")") + + # sometimes packages are removed entirely from the system during installation of other packages + # so let's check that the package is still needed before + self.composite_logger.log_debug("[TDNF] > Installed version match NOT found. [PackageName={0}][PackageVersion={1}]".format(str(package_name), str(package_version))) + return False + + def extract_dependencies(self, output, packages): + # Extracts dependent packages from output. + # sample output + # Loaded plugin: tdnfrepogpgcheck + # + # Upgrading: + # python3 x86_64 3.12.3-5.azl3 azurelinux-official-base 44.51k 36.89k + # python3-curses x86_64 3.12.3-5.azl3 azurelinux-official-base 165.62k 71.64k + # python3-libs x86_64 3.12.3-5.azl3 azurelinux-official-base 36.05M 10.52M + # + # Total installed size: 36.26M + # Total download size: 10.62M + # Error(1032) : Operation aborted. + dependencies = [] + package_arch_to_look_for = ["x86_64", "noarch", "i686", "aarch64"] # if this is changed, review Constants + + lines = output.strip().splitlines() + + for line_index in range(0, len(lines)): + line = re.split(r'\s+', lines[line_index].strip()) + dependent_package_name = "" + + if self.is_valid_update(line, package_arch_to_look_for): + dependent_package_name = self.get_product_name_with_arch(line, package_arch_to_look_for) + else: + self.composite_logger.log_verbose("[TDNF] > Inapplicable line: " + str(line)) + continue + + if len(dependent_package_name) != 0 and dependent_package_name not in packages and dependent_package_name not in dependencies: + self.composite_logger.log_verbose("[TDNF] > Dependency detected: " + dependent_package_name) + dependencies.append(dependent_package_name) + + return dependencies + + def add_arch_dependencies(self, package_manager, package, version, packages, package_versions, package_and_dependencies, package_and_dependency_versions): + """ + Add the packages with same name as that of input parameter package but with different architectures from packages list to the list package_and_dependencies. + Parameters: + package_manager (PackageManager): Package manager used. + package (string): Input package for which same package name but different architecture need to be added in the list package_and_dependencies. + version (string): version of the package. + packages (List of strings): List of all packages selected by user to install. + package_versions (List of strings): Versions of packages in packages list. + package_and_dependencies (List of strings): List of packages along with dependencies. This function adds packages with same name as input parameter package + but different architecture in this list. + package_and_dependency_versions (List of strings): Versions of packages in package_and_dependencies. + """ + package_name_without_arch = package_manager.get_product_name_without_arch(package) + for possible_arch_dependency, possible_arch_dependency_version in zip(packages, package_versions): + if package_manager.get_product_name_without_arch(possible_arch_dependency) == package_name_without_arch and possible_arch_dependency not in package_and_dependencies and possible_arch_dependency_version == version: + package_and_dependencies.append(possible_arch_dependency) + package_and_dependency_versions.append(possible_arch_dependency_version) + + def is_valid_update(self, package_details_in_output, package_arch_to_look_for): + # Verifies whether the line under consideration (i.e. package_details_in_output) contains relevant package details. + # package_details_in_output will be of the following format if it is valid + # Sample package details in TDNF: + # python3-libs x86_64 3.12.3-5.azl3 azurelinux-official-base 36.05M 10.52M + return len(package_details_in_output) == 6 and self.is_arch_in_package_details(package_details_in_output[1], package_arch_to_look_for) + + @staticmethod + def is_arch_in_package_details(package_detail, package_arch_to_look_for): + # Using a list comprehension to determine if chunk is a package + return len([p for p in package_arch_to_look_for if p in package_detail]) == 1 + + def get_dependent_list(self, packages): + """Returns dependent List for the list of packages""" + package_names = "" + for index, package in enumerate(packages): + if index != 0: + package_names += ' ' + package_names += package + + cmd = self.single_package_upgrade_simulation_cmd + package_names + output = self.invoke_package_manager(cmd) + dependencies = self.extract_dependencies(output, packages) + self.composite_logger.log_verbose("[TDNF] Resolved dependencies. [Command={0}][Packages={1}][DependencyCount={2}]".format(str(cmd), str(packages), len(dependencies))) + return dependencies + + def get_product_name(self, package_name): + """Retrieve package name """ + return package_name + + def get_product_name_and_arch(self, package_name): + """Splits out product name and architecture - if this is changed, modify in PackageFilter also""" + architectures = Constants.SUPPORTED_PACKAGE_ARCH + for arch in architectures: + if package_name.endswith(arch): + return package_name[:-len(arch)], arch + return package_name, None + + def get_product_name_without_arch(self, package_name): + """Retrieve product name only""" + product_name, arch = self.get_product_name_and_arch(package_name) + return product_name + + def get_product_arch(self, package_name): + """Retrieve product architecture only""" + product_name, arch = self.get_product_name_and_arch(package_name) + return arch + + def get_product_name_with_arch(self, package_detail, package_arch_to_look_for): + """Retrieve product name with arch separated by '.'. Note: This format is default in tdnf. Refer samples noted within func extract_dependencies() for more clarity""" + return package_detail[0] + "." + package_detail[1] if package_detail[1] in package_arch_to_look_for else package_detail[1] + + def get_package_size(self, output): + """Retrieve package size from installation output string""" + # Sample output line: + # Total download size: 15 M + if "Nothing to do" not in output: + lines = output.strip().split('\n') + for line in lines: + if line.find(self.STR_TOTAL_DOWNLOAD_SIZE) >= 0: + return line.replace(self.STR_TOTAL_DOWNLOAD_SIZE, "") + + return Constants.UNKNOWN_PACKAGE_SIZE + # endregion + + # region auto OS updates + @abstractmethod + def get_current_auto_os_patch_state(self): + """ Gets the current auto OS update patch state on the machine """ + pass + + @abstractmethod + def disable_auto_os_update(self): + """ Disables auto OS updates on the machine only if they are enabled and logs the default settings the machine comes with """ + pass + + @abstractmethod + def backup_image_default_patch_configuration_if_not_exists(self): + """ Records the default system settings for auto OS updates within patch extension artifacts for future reference. + We only log the default system settings a VM comes with, any subsequent updates will not be recorded""" + pass + + @abstractmethod + def is_image_default_patch_configuration_backup_valid(self, image_default_patch_configuration_backup): + pass + + @abstractmethod + def update_os_patch_configuration_sub_setting(self, patch_configuration_sub_setting, value, patch_configuration_sub_setting_pattern_to_match): + pass + # endregion + + @abstractmethod + def is_reboot_pending(self): + """ Checks if there is a pending reboot on the machine. """ + pass + + @abstractmethod + def do_processes_require_restart(self): + """ Signals whether processes require a restart due to updates to files """ + pass + + @abstractmethod + def set_security_esm_package_status(self, operation, packages): + pass + + @abstractmethod + def separate_out_esm_packages(self, packages, package_versions): + pass + + def get_package_install_expected_avg_time_in_seconds(self): + return self.package_install_expected_avg_time_in_seconds + diff --git a/src/core/tests/Test_TdnfPackageManager.py b/src/core/tests/Test_TdnfPackageManager.py new file mode 100644 index 00000000..354471bf --- /dev/null +++ b/src/core/tests/Test_TdnfPackageManager.py @@ -0,0 +1,420 @@ +# Copyright 2025 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.7+ +import unittest + +from core.src.bootstrap.Constants import Constants +from core.tests.library.ArgumentComposer import ArgumentComposer +from core.tests.library.RuntimeCompositor import RuntimeCompositor + + +class TestAzL3TdnfPackageManager(unittest.TestCase): + def setUp(self): + self.runtime = RuntimeCompositor(ArgumentComposer().get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + def tearDown(self): + self.runtime.stop() + + def test_refresh_repo(self): + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_manager.refresh_repo_safely() + + def test_package_manager_no_updates(self): + """Unit test for tdnf package manager with no updates""" + # Path change + self.runtime.set_legacy_test_type('SadPath') + + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + available_updates, package_versions = package_manager.get_available_updates(package_filter) + self.assertEqual(len(available_updates), 0) + self.assertEqual(len(package_versions), 0) + + def test_package_manager_unaligned_updates(self): + # Path change + self.runtime.set_legacy_test_type('UnalignedPath') + + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + try: + package_manager.get_available_updates(package_filter) + except Exception as exception: + self.assertTrue(str(exception)) + else: + self.assertFalse(1 != 2, 'Exception did not occur and test failed.') + + def test_package_manager(self): + """Unit test for tdnf package manager""" + self.runtime.set_legacy_test_type('HappyPath') + + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + # test for get_available_updates + available_updates, package_versions = package_manager.get_available_updates(package_filter) + self.assertTrue(available_updates is not None) + self.assertTrue(package_versions is not None) + self.assertEqual(9, len(available_updates)) + self.assertEqual(9, len(package_versions)) + self.assertEqual("azurelinux-release.noarch", available_updates[0]) + self.assertEqual("azurelinux-repos-ms-oss.noarch", available_updates[1]) + self.assertEqual("3.0-16.azl3", package_versions[0]) + self.assertEqual("3.0-3.azl3", package_versions[1]) + + # test for get_package_size when size is available + cmd = package_manager.single_package_upgrade_cmd + "curl" + code, out = self.runtime.env_layer.run_command_output(cmd, False, False) + size = package_manager.get_package_size(out) + self.assertEqual(size, "661.34k") + + # test for get_package_size when size is not available + cmd = package_manager.single_package_upgrade_cmd + "systemd" + code, out = self.runtime.env_layer.run_command_output(cmd, False, False) + size = package_manager.get_package_size(out) + self.assertEqual(size, Constants.UNKNOWN_PACKAGE_SIZE) + + # test for all available versions + package_versions = package_manager.get_all_available_versions_of_package("python3") + self.assertEqual(len(package_versions), 6) + self.assertEqual(package_versions[0], '3.12.3-1.azl3') + self.assertEqual(package_versions[1], '3.12.3-2.azl3') + self.assertEqual(package_versions[2], '3.12.3-4.azl3') + self.assertEqual(package_versions[3], '3.12.3-5.azl3') + self.assertEqual(package_versions[4], '3.12.3-6.azl3') + self.assertEqual(package_versions[5], '3.12.9-1.azl3') + + # test for get_dependent_list + dependent_list = package_manager.get_dependent_list(["hyperv-daemons.x86_64"]) + self.assertTrue(dependent_list is not None) + self.assertEqual(len(dependent_list), 4) + self.assertEqual(dependent_list[0], "hyperv-daemons-license.noarch") + self.assertEqual(dependent_list[1], "hypervvssd.x86_64") + self.assertEqual(dependent_list[2], "hypervkvpd.x86_64") + self.assertEqual(dependent_list[3], "hypervfcopyd.x86_64") + + # test install cmd + packages = ['kernel.x86_64', 'selinux-policy-targeted.noarch'] + package_versions = ['2.02.177-4.el7', '3.10.0-862.el7'] + cmd = package_manager.get_install_command('sudo tdnf -y install --skip-broken ', packages, package_versions) + self.assertEqual(cmd, 'sudo tdnf -y install --skip-broken kernel-2.02.177-4.el7.x86_64 selinux-policy-targeted-3.10.0-862.el7.noarch') + packages = ['kernel.x86_64'] + package_versions = ['2.02.177-4.el7'] + cmd = package_manager.get_install_command('sudo tdnf -y install --skip-broken ', packages, package_versions) + self.assertEqual(cmd, 'sudo tdnf -y install --skip-broken kernel-2.02.177-4.el7.x86_64') + packages = ['kernel.x86_64', 'kernel.i686'] + package_versions = ['2.02.177-4.el7', '2.02.177-4.el7'] + cmd = package_manager.get_install_command('sudo tdnf -y install --skip-broken ', packages, package_versions) + self.assertEqual(cmd, 'sudo tdnf -y install --skip-broken kernel-2.02.177-4.el7.x86_64 kernel-2.02.177-4.el7.i686') + + self.runtime.stop() + self.runtime = RuntimeCompositor(ArgumentComposer().get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + self.runtime.set_legacy_test_type('ExceptionPath') + + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + # test for get_available_updates + try: + package_manager.get_available_updates(package_filter) + except Exception as exception: + self.assertTrue(str(exception)) + else: + self.assertFalse(1 != 2, 'Exception did not occur and test failed.') + + # test for get_dependent_list + try: + package_manager.get_dependent_list(["man"]) + except Exception as exception: + self.assertTrue(str(exception)) + else: + self.assertFalse(1 != 2, 'Exception did not occur and test failed.') + + def test_install_package_success(self): + """Unit test for install package success""" + self.runtime.set_legacy_test_type('SuccessInstallPath') + + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + # test for successfully installing a package + self.assertEqual(package_manager.install_update_and_dependencies_and_get_status('hyperv-daemons-license.noarch', '6.6.78.1-1.azl3', simulate=True), Constants.INSTALLED) + + def test_install_package_failure(self): + """Unit test for install package failure""" + self.runtime.set_legacy_test_type('FailInstallPath') + + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + # test for unsuccessfully installing a package + self.assertEqual(package_manager.install_update_and_dependencies_and_get_status('hyperv-daemons-license.noarch', '6.6.78.1-1.azl3', simulate=True), Constants.FAILED) + + def test_get_product_name(self): + """Unit test for retrieving product Name""" + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + self.assertEqual(package_manager.get_product_name("bash.x86_64"), "bash.x86_64") + self.assertEqual(package_manager.get_product_name("firefox.x86_64"), "firefox.x86_64") + self.assertEqual(package_manager.get_product_name("test.noarch"), "test.noarch") + self.assertEqual(package_manager.get_product_name("noextension"), "noextension") + self.assertEqual(package_manager.get_product_name("noextension.ext"), "noextension.ext") + + def test_get_product_name_without_arch(self): + """Unit test for retrieving product Name""" + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + self.assertEqual(package_manager.get_product_name_without_arch("bash.x86_64"), "bash") + self.assertEqual(package_manager.get_product_name_without_arch("firefox.x86_64"), "firefox") + self.assertEqual(package_manager.get_product_name_without_arch("test.noarch"), "test") + self.assertEqual(package_manager.get_product_name_without_arch("noextension"), "noextension") + self.assertEqual(package_manager.get_product_name_without_arch("noextension.ext"), "noextension.ext") + + def test_get_product_arch(self): + """Unit test for retrieving product arch""" + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + self.assertEqual(package_manager.get_product_arch("bash.x86_64"), ".x86_64") + self.assertEqual(package_manager.get_product_arch("firefox.x86_64"), ".x86_64") + self.assertEqual(package_manager.get_product_arch("test.noarch"), ".noarch") + self.assertEqual(package_manager.get_product_arch("noextension"), None) + self.assertEqual(package_manager.get_product_arch("noextension.ext"), None) + + def test_inclusion_type_all(self): + """Unit test for tdnf package manager Classification = all and IncludedPackageNameMasks not specified.""" + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.runtime.stop() + + argument_composer = ArgumentComposer() + argument_composer.classifications_to_include = [Constants.PackageClassification.UNCLASSIFIED] + argument_composer.patches_to_exclude = ["ssh*", "test"] + self.runtime = RuntimeCompositor(argument_composer.get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + package_filter = self.container.get('package_filter') + + # test for get_available_updates + available_updates, package_versions = package_manager.get_available_updates(package_filter) + self.assertTrue(available_updates is not None) + self.assertTrue(package_versions is not None) + self.assertEqual(9, len(available_updates)) + self.assertEqual(9, len(package_versions)) + self.assertEqual("azurelinux-release.noarch", available_updates[0]) + self.assertEqual("3.0-16.azl3", package_versions[0]) + self.assertEqual("azurelinux-repos-ms-oss.noarch", available_updates[1]) + self.assertEqual("3.0-3.azl3", package_versions[1]) + self.assertEqual("libseccomp.x86_64", available_updates[2]) + self.assertEqual("2.5.4-1.azl3", package_versions[2]) + self.assertEqual("python3.x86_64", available_updates[3]) + self.assertEqual("3.12.3-6.azl3", package_versions[3]) + self.assertEqual("libxml2.x86_64", available_updates[4]) + self.assertEqual("2.11.5-1.azl3", package_versions[4]) + self.assertEqual("dracut.x86_64", available_updates[5]) + self.assertEqual("102-7.azl3", package_versions[5]) + self.assertEqual("hyperv-daemons-license.noarch", available_updates[6]) + self.assertEqual("6.6.78.1-1.azl3", package_versions[6]) + self.assertEqual("hypervvssd.x86_64", available_updates[7]) + self.assertEqual("6.6.78.1-1.azl3", package_versions[7]) + self.assertEqual("hypervkvpd.x86_64", available_updates[8]) + self.assertEqual("6.6.78.1-1.azl3", package_versions[8]) + + def test_inclusion_type_critical(self): + """Unit test for tdnf package manager with inclusion and Classification = Critical. Returns all packages since classifications are not available in Azure Linux, hence everything is considered as Critical.""" + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.runtime.stop() + + argument_composer = ArgumentComposer() + argument_composer.classifications_to_include = [Constants.PackageClassification.CRITICAL] + argument_composer.patches_to_exclude = ["ssh*", "test"] + argument_composer.patches_to_include = ["ssh", "tar*"] + self.runtime = RuntimeCompositor(argument_composer.get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + # test for get_available_updates + available_updates, package_versions = package_manager.get_available_updates(package_filter) + self.assertEqual(9, len(available_updates)) + self.assertEqual(9, len(package_versions)) + + def test_inclusion_type_other(self): + """Unit test for tdnf package manager with inclusion and Classification = Other. All packages are considered are 'Security' since TDNF does not have patch classification""" + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.runtime.stop() + + argument_composer = ArgumentComposer() + argument_composer.classifications_to_include = [Constants.PackageClassification.OTHER] + argument_composer.patches_to_include = ["ssh", "tcpdump"] + argument_composer.patches_to_exclude = ["ssh*", "test"] + self.runtime = RuntimeCompositor(argument_composer.get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + # test for get_available_updates + available_updates, package_versions = package_manager.get_available_updates(package_filter) + self.assertTrue(available_updates is not None) + self.assertTrue(package_versions is not None) + self.assertEqual(0, len(available_updates)) + self.assertEqual(0, len(package_versions)) + + def test_inclusion_only(self): + """Unit test for tdnf package manager with inclusion only and NotSelected Classifications""" + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.runtime.stop() + + argument_composer = ArgumentComposer() + argument_composer.classifications_to_include = [Constants.PackageClassification.UNCLASSIFIED] + argument_composer.patches_to_include = ["azurelinux-release.noarch", "lib*"] + argument_composer.patches_to_exclude = ["ssh*", "test"] + self.runtime = RuntimeCompositor(argument_composer.get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + # test for get_available_updates + available_updates, package_versions = package_manager.get_available_updates(package_filter) + self.assertTrue(available_updates is not None) + self.assertTrue(package_versions is not None) + self.assertEqual(3, len(available_updates)) + self.assertEqual(3, len(package_versions)) + self.assertEqual("azurelinux-release.noarch", available_updates[0]) + self.assertEqual("3.0-16.azl3", package_versions[0]) + self.assertEqual("libseccomp.x86_64", available_updates[1]) + self.assertEqual("2.5.4-1.azl3", package_versions[1]) + self.assertEqual("libxml2.x86_64", available_updates[2]) + self.assertEqual("2.11.5-1.azl3", package_versions[2]) + + def test_inclusion_dependency_only(self): + """Unit test for tdnf with test dependencies in Inclusion & NotSelected Classifications""" + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.runtime.stop() + + argument_composer = ArgumentComposer() + argument_composer.classifications_to_include = [Constants.PackageClassification.UNCLASSIFIED] + argument_composer.patches_to_include = ["ssh", "hypervvssd.x86_64"] + argument_composer.patches_to_exclude = ["ssh*", "test"] + self.runtime = RuntimeCompositor(argument_composer.get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + # test for get_available_updates + available_updates, package_versions = package_manager.get_available_updates(package_filter) + self.assertTrue(available_updates is not None) + self.assertTrue(package_versions is not None) + self.assertEqual(len(available_updates), 1) + self.assertEqual(len(package_versions), 1) + self.assertEqual(available_updates[0], "hypervvssd.x86_64") + self.assertEqual(package_versions[0], "6.6.78.1-1.azl3") + + def test_inclusion_notexist(self): + """Unit test for tdnf with Inclusion which does not exist & NotSelected Classifications""" + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.runtime.stop() + + argument_composer = ArgumentComposer() + argument_composer.classifications_to_include = [Constants.PackageClassification.UNCLASSIFIED] + argument_composer.patches_to_include = ["ssh"] + argument_composer.patches_to_exclude = ["ssh*", "test"] + self.runtime = RuntimeCompositor(argument_composer.get_composed_arguments(), True, Constants.TDNF) + self.container = self.runtime.container + + package_filter = self.container.get('package_filter') + self.assertTrue(package_filter is not None) + + # test for get_available_updates + available_updates, package_versions = package_manager.get_available_updates(package_filter) + self.assertTrue(available_updates is not None) + self.assertTrue(package_versions is not None) + self.assertEqual(len(available_updates), 0) + self.assertEqual(len(package_versions), 0) + + def test_dedupe_update_packages_to_get_latest_versions(self): + packages = [] + package_versions = [] + + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + deduped_packages, deduped_package_versions = package_manager.dedupe_update_packages_to_get_latest_versions(packages, package_versions) + self.assertTrue(deduped_packages == []) + self.assertTrue(deduped_package_versions == []) + + packages = ['python3.x86_64', 'dracut.x86_64', 'libxml2.x86_64', 'azurelinux-release.noarch', 'python3.noarch', 'python3.x86_64', 'python3.x86_64', 'hypervvssd.x86_64', 'python3.x86_64', 'python3.x86_64'] + package_versions = ['3.12.3-1.azl3', '102-7.azl3 ', '2.11.5-1.azl3', '3.0-16.azl3', '3.12.9-2.azl3', '3.12.9-1.azl3', '3.12.3-4.azl3', '6.6.78.1-1.azl3', '3.12.3-5.azl3', '3.12.3-5.azl3'] + deduped_packages, deduped_package_versions = package_manager.dedupe_update_packages_to_get_latest_versions(packages, package_versions) + self.assertTrue(deduped_packages is not None and deduped_packages is not []) + self.assertTrue(deduped_package_versions is not None and deduped_package_versions is not []) + self.assertTrue(len(deduped_packages) == 6) + self.assertTrue(deduped_packages[0] == 'python3.x86_64') + self.assertTrue(deduped_package_versions[0] == '3.12.9-1.azl3') + + def test_obsolete_packages_should_not_considered_in_available_updates(self): + self.runtime.set_legacy_test_type('ObsoletePackages') + package_manager = self.container.get('package_manager') + package_filter = self.container.get('package_filter') + + # test for all available versions + package_versions = package_manager.get_all_available_versions_of_package("python3") + self.assertEqual(len(package_versions), 6) + self.assertEqual(package_versions[0], '3.12.3-1.azl3') + self.assertEqual(package_versions[1], '3.12.3-2.azl3') + self.assertEqual(package_versions[2], '3.12.3-4.azl3') + self.assertEqual(package_versions[3], '3.12.3-5.azl3') + self.assertEqual(package_versions[4], '3.12.3-6.azl3') + self.assertEqual(package_versions[5], '3.12.9-1.azl3') + + +if __name__ == '__main__': + unittest.main() + From cdb8acfdb40668ec543e44978d69070c4bab1670 Mon Sep 17 00:00:00 2001 From: Rajasi Rane Date: Tue, 23 Sep 2025 04:27:23 -0700 Subject: [PATCH 3/6] [AzL3Tdnf] Refactor: Addressing PR feedback #1 --- .../AzL3TdnfPackageManager.py | 386 ---------------- .../package_managers/TdnfPackageManager.py | 383 +++++++++++++++- src/core/tests/Test_AzL3TdnfPackageManager.py | 403 ----------------- src/core/tests/Test_TdnfPackageManager.py | 416 ++++++++++++++++++ src/tools/Package-Core.py | 10 + 5 files changed, 793 insertions(+), 805 deletions(-) diff --git a/src/core/src/package_managers/AzL3TdnfPackageManager.py b/src/core/src/package_managers/AzL3TdnfPackageManager.py index d65b23a6..8a9377d8 100644 --- a/src/core/src/package_managers/AzL3TdnfPackageManager.py +++ b/src/core/src/package_managers/AzL3TdnfPackageManager.py @@ -16,7 +16,6 @@ """AzL3TdnfPackageManager for Azure Linux""" import json -import os import re from core.src.core_logic.VersionComparator import VersionComparator @@ -36,29 +35,6 @@ def __init__(self, env_layer, execution_config, composite_logger, telemetry_writ # Install update self.install_security_updates_azgps_coordinated_cmd = 'sudo tdnf -y upgrade --skip-broken ' - # Support to check for processes requiring restart - self.dnf_utils_prerequisite = 'sudo tdnf -y install dnf-utils' - self.needs_restarting_with_flag = 'sudo LANG=en_US.UTF8 needs-restarting -r' - - # auto OS updates - self.current_auto_os_update_service = None - self.os_patch_configuration_settings_file_path = '' - self.auto_update_service_enabled = False - self.auto_update_config_pattern_match_text = "" - self.download_updates_identifier_text = "" - self.apply_updates_identifier_text = "" - self.enable_on_reboot_identifier_text = "" - self.enable_on_reboot_check_cmd = '' - self.enable_on_reboot_cmd = '' - self.installation_state_identifier_text = "" - self.install_check_cmd = "" - self.apply_updates_enabled = "Enabled" - self.apply_updates_disabled = "Disabled" - self.apply_updates_unknown = "Unknown" - - # commands for DNF Automatic updates service - self.__init_constants_for_dnf_automatic() - # Strict SDP specializations self.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP = "3.5.8-3.azl3" # minimum version of tdnf required to support Strict SDP in Azure Linux @@ -198,368 +174,6 @@ def try_tdnf_update_to_meet_strict_sdp_requirements(self): return False # endregion - # region auto OS updates - def __init_constants_for_dnf_automatic(self): - self.dnf_automatic_configuration_file_path = '/etc/dnf/automatic.conf' - self.dnf_automatic_install_check_cmd = 'systemctl list-unit-files --type=service | grep dnf-automatic.service' # list-unit-files returns installed services, ref: https://www.freedesktop.org/software/systemd/man/systemctl.html#Unit%20File%20Commands - self.dnf_automatic_enable_on_reboot_check_cmd = 'systemctl is-enabled dnf-automatic.timer' - self.dnf_automatic_disable_on_reboot_cmd = 'systemctl disable dnf-automatic.timer' - self.dnf_automatic_enable_on_reboot_cmd = 'systemctl enable dnf-automatic.timer' - self.dnf_automatic_config_pattern_match_text = ' = (no|yes)' - self.dnf_automatic_download_updates_identifier_text = 'download_updates' - self.dnf_automatic_apply_updates_identifier_text = 'apply_updates' - self.dnf_automatic_enable_on_reboot_identifier_text = "enable_on_reboot" - self.dnf_automatic_installation_state_identifier_text = "installation_state" - self.dnf_auto_os_update_service = "dnf-automatic" - - def get_current_auto_os_patch_state(self): - """ Gets the current auto OS update patch state on the machine """ - self.composite_logger.log("[AzL3TDNF] Fetching the current automatic OS patch state on the machine...") - - current_auto_os_patch_state_for_dnf_automatic = self.__get_current_auto_os_patch_state_for_dnf_automatic() - - self.composite_logger.log("[AzL3TDNF] OS patch state per auto OS update service: [dnf-automatic={0}]".format(str(current_auto_os_patch_state_for_dnf_automatic))) - - if current_auto_os_patch_state_for_dnf_automatic == Constants.AutomaticOSPatchStates.ENABLED: - current_auto_os_patch_state = Constants.AutomaticOSPatchStates.ENABLED - elif current_auto_os_patch_state_for_dnf_automatic == Constants.AutomaticOSPatchStates.DISABLED: - current_auto_os_patch_state = Constants.AutomaticOSPatchStates.DISABLED - else: - current_auto_os_patch_state = Constants.AutomaticOSPatchStates.UNKNOWN - - self.composite_logger.log_debug("[AzL3TDNF] Overall Auto OS Patch State based on all auto OS update service states [OverallAutoOSPatchState={0}]".format(str(current_auto_os_patch_state))) - return current_auto_os_patch_state - - def __get_current_auto_os_patch_state_for_dnf_automatic(self): - """ Gets current auto OS update patch state for dnf-automatic """ - self.composite_logger.log_debug("[AzL3TDNF] Fetching current automatic OS patch state in dnf-automatic service. This includes checks on whether the service is installed, current auto patch enable state and whether it is set to enable on reboot") - self.__init_auto_update_for_dnf_automatic() - is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() - - apply_updates = self.__get_extension_standard_value_for_apply_updates(apply_updates_value) - - if apply_updates == self.apply_updates_enabled or enable_on_reboot_value: - return Constants.AutomaticOSPatchStates.ENABLED - # OS patch state is considered to be disabled: a) if it was successfully disabled or b) if the service is not installed - elif not is_service_installed or (apply_updates == self.apply_updates_disabled and not enable_on_reboot_value): - return Constants.AutomaticOSPatchStates.DISABLED - else: - return Constants.AutomaticOSPatchStates.UNKNOWN - - def __init_auto_update_for_dnf_automatic(self): - """ Initializes all generic auto OS update variables with the config values for dnf automatic service """ - self.os_patch_configuration_settings_file_path = self.dnf_automatic_configuration_file_path - self.download_updates_identifier_text = self.dnf_automatic_download_updates_identifier_text - self.apply_updates_identifier_text = self.dnf_automatic_apply_updates_identifier_text - self.enable_on_reboot_identifier_text = self.dnf_automatic_enable_on_reboot_identifier_text - self.installation_state_identifier_text = self.dnf_automatic_installation_state_identifier_text - self.auto_update_config_pattern_match_text = self.dnf_automatic_config_pattern_match_text - self.enable_on_reboot_check_cmd = self.dnf_automatic_enable_on_reboot_check_cmd - self.enable_on_reboot_cmd = self.dnf_automatic_enable_on_reboot_cmd - self.install_check_cmd = self.dnf_automatic_install_check_cmd - self.current_auto_os_update_service = self.dnf_auto_os_update_service - - def __get_current_auto_os_updates_setting_on_machine(self): - """ Gets all the update settings related to auto OS updates currently set on the machine """ - try: - download_updates_value = "" - apply_updates_value = "" - is_service_installed = False - enable_on_reboot_value = False - - # get install state - if not self.is_auto_update_service_installed(self.install_check_cmd): - return is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value - - is_service_installed = True - enable_on_reboot_value = self.is_service_set_to_enable_on_reboot(self.enable_on_reboot_check_cmd) - - self.composite_logger.log_debug("[AzL3TDNF] Checking if auto updates are currently enabled...") - image_default_patch_configuration = self.env_layer.file_system.read_with_retry(self.os_patch_configuration_settings_file_path, raise_if_not_found=False) - if image_default_patch_configuration is not None: - settings = image_default_patch_configuration.strip().split('\n') - for setting in settings: - match = re.search(self.download_updates_identifier_text + self.auto_update_config_pattern_match_text, str(setting)) - if match is not None: - download_updates_value = match.group(1) - - match = re.search(self.apply_updates_identifier_text + self.auto_update_config_pattern_match_text, str(setting)) - if match is not None: - apply_updates_value = match.group(1) - - if download_updates_value == "": - self.composite_logger.log_debug("[AzL3TDNF] Machine did not have any value set for [Setting={0}]".format(str(self.download_updates_identifier_text))) - else: - self.composite_logger.log_verbose("[AzL3TDNF] Current value set for [{0}={1}]".format(str(self.download_updates_identifier_text), str(download_updates_value))) - - if apply_updates_value == "": - self.composite_logger.log_debug("[AzL3TDNF] Machine did not have any value set for [Setting={0}]".format(str(self.apply_updates_identifier_text))) - else: - self.composite_logger.log_verbose("[AzL3TDNF] Current value set for [{0}={1}]".format(str(self.apply_updates_identifier_text), str(apply_updates_value))) - - return is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value - - except Exception as error: - raise Exception("[AzL3TDNF] Error occurred in fetching current auto OS update settings from the machine. [Exception={0}]".format(repr(error))) - - def is_auto_update_service_installed(self, install_check_cmd): - """ Checks if the auto update service is enable_on_reboot on the VM """ - code, out = self.env_layer.run_command_output(install_check_cmd, False, False) - self.composite_logger.log_debug("[AzL3TDNF] Checked if auto update service is installed. [Command={0}][Code={1}][Output={2}]".format(install_check_cmd, str(code), out)) - if len(out.strip()) > 0 and code == 0: - self.composite_logger.log_debug("[AzL3TDNF] > Auto OS update service is installed on the machine") - return True - else: - self.composite_logger.log_debug("[AzL3TDNF] > Auto OS update service is NOT installed on the machine") - return False - - def is_service_set_to_enable_on_reboot(self, command): - """ Checking if auto update is enable_on_reboot on the machine. An enable_on_reboot service will be activated (if currently inactive) on machine reboot """ - code, out = self.env_layer.run_command_output(command, False, False) - self.composite_logger.log_debug("[AzL3TDNF] Checked if auto update service is set to enable on reboot. [Code={0}][Out={1}]".format(str(code), out)) - if len(out.strip()) > 0 and code == 0 and 'enabled' in out: - self.composite_logger.log_debug("[AzL3TDNF] > Auto OS update service will enable on reboot") - return True - self.composite_logger.log_debug("[AzL3TDNF] > Auto OS update service will NOT enable on reboot") - return False - - def __get_extension_standard_value_for_apply_updates(self, apply_updates_value): - if apply_updates_value.lower() == 'yes' or apply_updates_value.lower() == 'true': - return self.apply_updates_enabled - elif apply_updates_value.lower() == 'no' or apply_updates_value.lower() == 'false': - return self.apply_updates_disabled - else: - return self.apply_updates_unknown - - def disable_auto_os_update(self): - """ Disables auto OS updates on the machine only if they are enabled and logs the default settings the machine comes with """ - try: - self.composite_logger.log_verbose("[AzL3TDNF] Disabling auto OS updates in all identified services...") - self.disable_auto_os_update_for_dnf_automatic() - self.composite_logger.log_debug("[AzL3TDNF] Successfully disabled auto OS updates") - - except Exception as error: - self.composite_logger.log_error("[AzL3TDNF] Could not disable auto OS updates. [Error={0}]".format(repr(error))) - raise - - def disable_auto_os_update_for_dnf_automatic(self): - """ Disables auto OS updates, using dnf-automatic service, and logs the default settings the machine comes with """ - self.composite_logger.log_verbose("[AzL3TDNF] Disabling auto OS updates using dnf-automatic") - self.__init_auto_update_for_dnf_automatic() - - self.backup_image_default_patch_configuration_if_not_exists() - - if not self.is_auto_update_service_installed(self.dnf_automatic_install_check_cmd): - self.composite_logger.log_debug("[AzL3TDNF] Cannot disable as dnf-automatic is not installed on the machine") - return - - self.composite_logger.log_verbose("[AzL3TDNF] Preemptively disabling auto OS updates using dnf-automatic") - self.update_os_patch_configuration_sub_setting(self.download_updates_identifier_text, "no", self.dnf_automatic_config_pattern_match_text) - self.update_os_patch_configuration_sub_setting(self.apply_updates_identifier_text, "no", self.dnf_automatic_config_pattern_match_text) - self.disable_auto_update_on_reboot(self.dnf_automatic_disable_on_reboot_cmd) - - self.composite_logger.log_debug("[AzL3TDNF] Successfully disabled auto OS updates using dnf-automatic") - - def disable_auto_update_on_reboot(self, command): - self.composite_logger.log_verbose("[AzL3TDNF] Disabling auto update on reboot. [Command={0}] ".format(command)) - code, out = self.env_layer.run_command_output(command, False, False) - - if code != 0: - self.composite_logger.log_error("[AzL3TDNF][ERROR] Error disabling auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) - error_msg = 'Unexpected return code (' + str(code) + ') on command: ' + command - self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.OPERATION_FAILED) - raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) - else: - self.composite_logger.log_debug("[AzL3TDNF] Disabled auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) - - def backup_image_default_patch_configuration_if_not_exists(self): - """ Records the default system settings for auto OS updates within patch extension artifacts for future reference. - We only log the default system settings a VM comes with, any subsequent updates will not be recorded""" - """ JSON format for backup file: - { - "dnf-automatic": { - "apply_updates": "yes/no/empty string", - "download_updates": "yes/no/empty string", - "enable_on_reboot": true/false, - "installation_state": true/false - } - } """ - try: - self.composite_logger.log_debug("[AzL3TDNF] Ensuring there is a backup of the default patch state for [AutoOSUpdateService={0}]".format(str(self.current_auto_os_update_service))) - image_default_patch_configuration_backup = self.__get_image_default_patch_configuration_backup() - - # verify if existing backup is valid if not, write to backup - is_backup_valid = self.is_image_default_patch_configuration_backup_valid(image_default_patch_configuration_backup) - if is_backup_valid: - self.composite_logger.log_debug("[AzL3TDNF] Since extension has a valid backup, no need to log the current settings again. [Default Auto OS update settings={0}] [File path={1}]" - .format(str(image_default_patch_configuration_backup), self.image_default_patch_configuration_backup_path)) - else: - self.composite_logger.log_debug("[AzL3TDNF] Since the backup is invalid, will add a new backup with the current auto OS update settings") - self.composite_logger.log_debug("[AzL3TDNF] Fetching current auto OS update settings for [AutoOSUpdateService={0}]".format(str(self.current_auto_os_update_service))) - is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() - - backup_image_default_patch_configuration_json_to_add = { - self.current_auto_os_update_service: { - self.download_updates_identifier_text: download_updates_value, - self.apply_updates_identifier_text: apply_updates_value, - self.enable_on_reboot_identifier_text: enable_on_reboot_value, - self.installation_state_identifier_text: is_service_installed - } - } - - image_default_patch_configuration_backup.update(backup_image_default_patch_configuration_json_to_add) - - self.composite_logger.log_debug("[AzL3TDNF] Logging default system configuration settings for auto OS updates. [Settings={0}] [Log file path={1}]" - .format(str(image_default_patch_configuration_backup), self.image_default_patch_configuration_backup_path)) - self.env_layer.file_system.write_with_retry(self.image_default_patch_configuration_backup_path, '{0}'.format(json.dumps(image_default_patch_configuration_backup)), mode='w+') - except Exception as error: - error_message = "[AzL3TDNF] Exception during fetching and logging default auto update settings on the machine. [Exception={0}]".format(repr(error)) - self.composite_logger.log_error(error_message) - self.status_handler.add_error_to_status(error_message, Constants.PatchOperationErrorCodes.DEFAULT_ERROR) - raise - - def is_image_default_patch_configuration_backup_valid(self, image_default_patch_configuration_backup): - """ Verifies if default auto update configurations, for a service under consideration, are saved in backup """ - return self.is_backup_valid_for_dnf_automatic(image_default_patch_configuration_backup) - - def is_backup_valid_for_dnf_automatic(self, image_default_patch_configuration_backup): - if self.dnf_auto_os_update_service in image_default_patch_configuration_backup \ - and self.dnf_automatic_download_updates_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ - and self.dnf_automatic_apply_updates_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ - and self.dnf_automatic_enable_on_reboot_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ - and self.dnf_automatic_installation_state_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service]: - self.composite_logger.log_debug("[AzL3TDNF] Extension has a valid backup for default dnf-automatic configuration settings") - return True - else: - self.composite_logger.log_debug("[AzL3TDNF] Extension does not have a valid backup for default dnf-automatic configuration settings") - return False - - def update_os_patch_configuration_sub_setting(self, patch_configuration_sub_setting, value="no", config_pattern_match_text=""): - """ Updates (or adds if it doesn't exist) the given patch_configuration_sub_setting with the given value in os_patch_configuration_settings_file """ - try: - # note: adding space between the patch_configuration_sub_setting and value since, we will have to do that if we have to add a patch_configuration_sub_setting that did not exist before - self.composite_logger.log_debug("[AzL3TDNF] Updating system configuration settings for auto OS updates. [Patch Configuration Sub Setting={0}] [Value={1}]".format(str(patch_configuration_sub_setting), value)) - os_patch_configuration_settings = self.env_layer.file_system.read_with_retry(self.os_patch_configuration_settings_file_path) - patch_configuration_sub_setting_to_update = patch_configuration_sub_setting + ' = ' + value - patch_configuration_sub_setting_found_in_file = False - updated_patch_configuration_sub_setting = "" - settings = os_patch_configuration_settings.strip().split('\n') - - # update value of existing setting - for i in range(len(settings)): - match = re.search(patch_configuration_sub_setting + config_pattern_match_text, settings[i]) - if match is not None: - settings[i] = patch_configuration_sub_setting_to_update - patch_configuration_sub_setting_found_in_file = True - updated_patch_configuration_sub_setting += settings[i] + "\n" - - # add setting to configuration file, since it doesn't exist - if not patch_configuration_sub_setting_found_in_file: - updated_patch_configuration_sub_setting += patch_configuration_sub_setting_to_update + "\n" - - self.env_layer.file_system.write_with_retry(self.os_patch_configuration_settings_file_path, '{0}'.format(updated_patch_configuration_sub_setting.lstrip()), mode='w+') - except Exception as error: - error_msg = "[AzL3TDNF] Error occurred while updating system configuration settings for auto OS updates. [Patch Configuration={0}] [Error={1}]".format(str(patch_configuration_sub_setting), repr(error)) - self.composite_logger.log_error(error_msg) - self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.DEFAULT_ERROR) - raise - - def revert_auto_os_update_to_system_default(self): - """ Reverts the auto OS update patch state on the machine to its system default value, if one exists in our backup file """ - # type () -> None - self.composite_logger.log("[AzL3TDNF] Reverting the current automatic OS patch state on the machine to its system default value before patchmode was set to 'AutomaticByPlatform'") - self.revert_auto_os_update_to_system_default_for_dnf_automatic() - self.composite_logger.log_debug("[AzL3TDNF] Successfully reverted auto OS updates to system default config") - - def revert_auto_os_update_to_system_default_for_dnf_automatic(self): - """ Reverts the auto OS update patch state on the machine to its system default value for given service, if applicable """ - # type () -> None - self.__init_auto_update_for_dnf_automatic() - self.composite_logger.log("[AzL3TDNF] Reverting the current automatic OS patch state on the machine to its system default value for [Service={0}]".format(str(self.current_auto_os_update_service))) - is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() - - if not is_service_installed: - self.composite_logger.log_debug("[AzL3TDNF] Machine default auto OS update service is not installed on the VM and hence no config to revert. [Service={0}]".format(str(self.current_auto_os_update_service))) - return - - self.composite_logger.log_debug("[AzL3TDNF] Logging current configuration settings for auto OS updates [Service={0}][Is_Service_Installed={1}][Machine_default_update_enable_on_reboot={2}][{3}={4}]][{5}={6}]" - .format(str(self.current_auto_os_update_service), str(is_service_installed), str(enable_on_reboot_value), str(self.download_updates_identifier_text), str(download_updates_value), str(self.apply_updates_identifier_text), str(apply_updates_value))) - - image_default_patch_configuration_backup = self.__get_image_default_patch_configuration_backup() - self.composite_logger.log_debug("[AzL3TDNF] Logging system default configuration settings for auto OS updates. [Settings={0}]".format(str(image_default_patch_configuration_backup))) - is_backup_valid = self.is_image_default_patch_configuration_backup_valid(image_default_patch_configuration_backup) - - if is_backup_valid: - download_updates_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.download_updates_identifier_text] - apply_updates_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.apply_updates_identifier_text] - enable_on_reboot_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.enable_on_reboot_identifier_text] - - self.update_os_patch_configuration_sub_setting(self.download_updates_identifier_text, download_updates_value_from_backup, self.auto_update_config_pattern_match_text) - self.update_os_patch_configuration_sub_setting(self.apply_updates_identifier_text, apply_updates_value_from_backup, self.auto_update_config_pattern_match_text) - if str(enable_on_reboot_value_from_backup).lower() == 'true': - self.enable_auto_update_on_reboot() - else: - self.composite_logger.log_debug("[AzL3TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service={0}]".format(str(self.current_auto_os_update_service))) - - def enable_auto_update_on_reboot(self): - """Enables machine default auto update on reboot""" - # type () -> None - command = self.enable_on_reboot_cmd - self.composite_logger.log_verbose("[AzL3TDNF] Enabling auto update on reboot. [Command={0}] ".format(command)) - code, out = self.env_layer.run_command_output(command, False, False) - - if code != 0: - self.composite_logger.log_error("[AzL3TDNF][ERROR] Error enabling auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) - error_msg = 'Unexpected return code (' + str(code) + ') on command: ' + command - self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.OPERATION_FAILED) - raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) - else: - self.composite_logger.log_debug("[AzL3TDNF] Enabled auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) - - def __get_image_default_patch_configuration_backup(self): - """ Get image_default_patch_configuration_backup file""" - image_default_patch_configuration_backup = {} - - # read existing backup since it also contains backup from other update services. We need to preserve any existing data within the backup file - if self.image_default_patch_configuration_backup_exists(): - try: - image_default_patch_configuration_backup = json.loads(self.env_layer.file_system.read_with_retry(self.image_default_patch_configuration_backup_path)) - except Exception as error: - self.composite_logger.log_error("[AzL3TDNF] Unable to read backup for default patch state. Will attempt to re-write. [Exception={0}]".format(repr(error))) - return image_default_patch_configuration_backup - # endregion - - # region Reboot Management - def is_reboot_pending(self): - """ Checks if there is a pending reboot on the machine. """ - try: - pending_file_exists = os.path.isfile(self.REBOOT_PENDING_FILE_PATH) - pending_processes_exist = self.do_processes_require_restart() - self.composite_logger.log_debug("[AzL3TDNF] > Reboot required debug flags (tdnf): " + str(pending_file_exists) + ", " + str(pending_processes_exist) + ".") - return pending_file_exists or pending_processes_exist - except Exception as error: - self.composite_logger.log_error('[AzL3TDNF] Error while checking for reboot pending (tdnf): ' + repr(error)) - return True # defaults for safety - - def do_processes_require_restart(self): - """Signals whether processes require a restart due to updates""" - self.composite_logger.log_verbose("[AzL3TDNF] Checking if process requires reboot") - # Checking using dnf-utils - code, out = self.env_layer.run_command_output(self.dnf_utils_prerequisite, False, False) # idempotent, doesn't install if already present - self.composite_logger.log_verbose("[AzL3TDNF] Idempotent dnf-utils existence check. [Code={0}][Out={1}]".format(str(code), out)) - - # Checking for restart for distros with -r flag - code, out = self.env_layer.run_command_output(self.needs_restarting_with_flag, False, False) - self.composite_logger.log_verbose("[AzL3TDNF] > Code: " + str(code) + ", Output: \n|\t" + "\n|\t".join(out.splitlines())) - if out.find("Reboot is required") < 0: - self.composite_logger.log_debug("[AzL3TDNF] > Reboot not detected to be required (L1).") - else: - self.composite_logger.log_debug("[AzL3TDNF] > Reboot is detected to be required (L1).") - return True - - return False - # endregion - def set_security_esm_package_status(self, operation, packages): """ Set the security-ESM classification for the esm packages. Only needed for apt. No-op for tdnf, yum and zypper.""" pass diff --git a/src/core/src/package_managers/TdnfPackageManager.py b/src/core/src/package_managers/TdnfPackageManager.py index 2f95fa91..52703af1 100644 --- a/src/core/src/package_managers/TdnfPackageManager.py +++ b/src/core/src/package_managers/TdnfPackageManager.py @@ -15,6 +15,8 @@ # Requires Python 2.7+ """TdnfPackageManager for Azure Linux""" +import json +import os import re from abc import ABCMeta, abstractmethod @@ -45,6 +47,29 @@ def __init__(self, env_layer, execution_config, composite_logger, telemetry_writ self.tdnf_exitcode_on_no_action_for_install_update = 8 self.commands_expecting_no_action_exitcode = [self.single_package_upgrade_simulation_cmd] + # Support to check for processes requiring restart + self.dnf_utils_prerequisite = 'sudo tdnf -y install dnf-utils' + self.needs_restarting_with_flag = 'sudo LANG=en_US.UTF8 needs-restarting -r' + + # auto OS updates + self.current_auto_os_update_service = None + self.os_patch_configuration_settings_file_path = '' + self.auto_update_service_enabled = False + self.auto_update_config_pattern_match_text = "" + self.download_updates_identifier_text = "" + self.apply_updates_identifier_text = "" + self.enable_on_reboot_identifier_text = "" + self.enable_on_reboot_check_cmd = '' + self.enable_on_reboot_cmd = '' + self.installation_state_identifier_text = "" + self.install_check_cmd = "" + self.apply_updates_enabled = "Enabled" + self.apply_updates_disabled = "Disabled" + self.apply_updates_unknown = "Unknown" + + # commands for DNF Automatic updates service + self.__init_constants_for_dnf_automatic() + # Miscellaneous self.set_package_manager_setting(Constants.PKG_MGR_SETTING_IDENTITY, Constants.TDNF) self.STR_TOTAL_DOWNLOAD_SIZE = "Total download size: " @@ -330,40 +355,366 @@ def get_package_size(self, output): # endregion # region auto OS updates - @abstractmethod + def __init_constants_for_dnf_automatic(self): + self.dnf_automatic_configuration_file_path = '/etc/dnf/automatic.conf' + self.dnf_automatic_install_check_cmd = 'systemctl list-unit-files --type=service | grep dnf-automatic.service' # list-unit-files returns installed services, ref: https://www.freedesktop.org/software/systemd/man/systemctl.html#Unit%20File%20Commands + self.dnf_automatic_enable_on_reboot_check_cmd = 'systemctl is-enabled dnf-automatic.timer' + self.dnf_automatic_disable_on_reboot_cmd = 'systemctl disable dnf-automatic.timer' + self.dnf_automatic_enable_on_reboot_cmd = 'systemctl enable dnf-automatic.timer' + self.dnf_automatic_config_pattern_match_text = ' = (no|yes)' + self.dnf_automatic_download_updates_identifier_text = 'download_updates' + self.dnf_automatic_apply_updates_identifier_text = 'apply_updates' + self.dnf_automatic_enable_on_reboot_identifier_text = "enable_on_reboot" + self.dnf_automatic_installation_state_identifier_text = "installation_state" + self.dnf_auto_os_update_service = "dnf-automatic" + def get_current_auto_os_patch_state(self): """ Gets the current auto OS update patch state on the machine """ - pass + self.composite_logger.log("[TDNF] Fetching the current automatic OS patch state on the machine...") + + current_auto_os_patch_state_for_dnf_automatic = self.__get_current_auto_os_patch_state_for_dnf_automatic() + + self.composite_logger.log("[TDNF] OS patch state per auto OS update service: [dnf-automatic={0}]".format(str(current_auto_os_patch_state_for_dnf_automatic))) + + if current_auto_os_patch_state_for_dnf_automatic == Constants.AutomaticOSPatchStates.ENABLED: + current_auto_os_patch_state = Constants.AutomaticOSPatchStates.ENABLED + elif current_auto_os_patch_state_for_dnf_automatic == Constants.AutomaticOSPatchStates.DISABLED: + current_auto_os_patch_state = Constants.AutomaticOSPatchStates.DISABLED + else: + current_auto_os_patch_state = Constants.AutomaticOSPatchStates.UNKNOWN + + self.composite_logger.log_debug("[TDNF] Overall Auto OS Patch State based on all auto OS update service states [OverallAutoOSPatchState={0}]".format(str(current_auto_os_patch_state))) + return current_auto_os_patch_state + + def __get_current_auto_os_patch_state_for_dnf_automatic(self): + """ Gets current auto OS update patch state for dnf-automatic """ + self.composite_logger.log_debug("[TDNF] Fetching current automatic OS patch state in dnf-automatic service. This includes checks on whether the service is installed, current auto patch enable state and whether it is set to enable on reboot") + self.__init_auto_update_for_dnf_automatic() + is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() + + apply_updates = self.__get_extension_standard_value_for_apply_updates(apply_updates_value) + + if apply_updates == self.apply_updates_enabled or enable_on_reboot_value: + return Constants.AutomaticOSPatchStates.ENABLED + # OS patch state is considered to be disabled: a) if it was successfully disabled or b) if the service is not installed + elif not is_service_installed or (apply_updates == self.apply_updates_disabled and not enable_on_reboot_value): + return Constants.AutomaticOSPatchStates.DISABLED + else: + return Constants.AutomaticOSPatchStates.UNKNOWN + + def __init_auto_update_for_dnf_automatic(self): + """ Initializes all generic auto OS update variables with the config values for dnf automatic service """ + self.os_patch_configuration_settings_file_path = self.dnf_automatic_configuration_file_path + self.download_updates_identifier_text = self.dnf_automatic_download_updates_identifier_text + self.apply_updates_identifier_text = self.dnf_automatic_apply_updates_identifier_text + self.enable_on_reboot_identifier_text = self.dnf_automatic_enable_on_reboot_identifier_text + self.installation_state_identifier_text = self.dnf_automatic_installation_state_identifier_text + self.auto_update_config_pattern_match_text = self.dnf_automatic_config_pattern_match_text + self.enable_on_reboot_check_cmd = self.dnf_automatic_enable_on_reboot_check_cmd + self.enable_on_reboot_cmd = self.dnf_automatic_enable_on_reboot_cmd + self.install_check_cmd = self.dnf_automatic_install_check_cmd + self.current_auto_os_update_service = self.dnf_auto_os_update_service + + def __get_current_auto_os_updates_setting_on_machine(self): + """ Gets all the update settings related to auto OS updates currently set on the machine """ + try: + download_updates_value = "" + apply_updates_value = "" + is_service_installed = False + enable_on_reboot_value = False + + # get install state + if not self.is_auto_update_service_installed(self.install_check_cmd): + return is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value + + is_service_installed = True + enable_on_reboot_value = self.is_service_set_to_enable_on_reboot(self.enable_on_reboot_check_cmd) + + self.composite_logger.log_debug("[TDNF] Checking if auto updates are currently enabled...") + image_default_patch_configuration = self.env_layer.file_system.read_with_retry(self.os_patch_configuration_settings_file_path, raise_if_not_found=False) + if image_default_patch_configuration is not None: + settings = image_default_patch_configuration.strip().split('\n') + for setting in settings: + match = re.search(self.download_updates_identifier_text + self.auto_update_config_pattern_match_text, str(setting)) + if match is not None: + download_updates_value = match.group(1) + + match = re.search(self.apply_updates_identifier_text + self.auto_update_config_pattern_match_text, str(setting)) + if match is not None: + apply_updates_value = match.group(1) + + if download_updates_value == "": + self.composite_logger.log_debug("[TDNF] Machine did not have any value set for [Setting={0}]".format(str(self.download_updates_identifier_text))) + else: + self.composite_logger.log_verbose("[TDNF] Current value set for [{0}={1}]".format(str(self.download_updates_identifier_text), str(download_updates_value))) + + if apply_updates_value == "": + self.composite_logger.log_debug("[TDNF] Machine did not have any value set for [Setting={0}]".format(str(self.apply_updates_identifier_text))) + else: + self.composite_logger.log_verbose("[TDNF] Current value set for [{0}={1}]".format(str(self.apply_updates_identifier_text), str(apply_updates_value))) + + return is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value + + except Exception as error: + raise Exception("[TDNF] Error occurred in fetching current auto OS update settings from the machine. [Exception={0}]".format(repr(error))) + + def is_auto_update_service_installed(self, install_check_cmd): + """ Checks if the auto update service is enable_on_reboot on the VM """ + code, out = self.env_layer.run_command_output(install_check_cmd, False, False) + self.composite_logger.log_debug("[TDNF] Checked if auto update service is installed. [Command={0}][Code={1}][Output={2}]".format(install_check_cmd, str(code), out)) + if len(out.strip()) > 0 and code == 0: + self.composite_logger.log_debug("[TDNF] > Auto OS update service is installed on the machine") + return True + else: + self.composite_logger.log_debug("[TDNF] > Auto OS update service is NOT installed on the machine") + return False + + def is_service_set_to_enable_on_reboot(self, command): + """ Checking if auto update is enable_on_reboot on the machine. An enable_on_reboot service will be activated (if currently inactive) on machine reboot """ + code, out = self.env_layer.run_command_output(command, False, False) + self.composite_logger.log_debug("[TDNF] Checked if auto update service is set to enable on reboot. [Code={0}][Out={1}]".format(str(code), out)) + if len(out.strip()) > 0 and code == 0 and 'enabled' in out: + self.composite_logger.log_debug("[TDNF] > Auto OS update service will enable on reboot") + return True + self.composite_logger.log_debug("[TDNF] > Auto OS update service will NOT enable on reboot") + return False + + def __get_extension_standard_value_for_apply_updates(self, apply_updates_value): + if apply_updates_value.lower() == 'yes' or apply_updates_value.lower() == 'true': + return self.apply_updates_enabled + elif apply_updates_value.lower() == 'no' or apply_updates_value.lower() == 'false': + return self.apply_updates_disabled + else: + return self.apply_updates_unknown - @abstractmethod def disable_auto_os_update(self): """ Disables auto OS updates on the machine only if they are enabled and logs the default settings the machine comes with """ - pass + try: + self.composite_logger.log_verbose("[TDNF] Disabling auto OS updates in all identified services...") + self.disable_auto_os_update_for_dnf_automatic() + self.composite_logger.log_debug("[TDNF] Successfully disabled auto OS updates") + + except Exception as error: + self.composite_logger.log_error("[TDNF] Could not disable auto OS updates. [Error={0}]".format(repr(error))) + raise + + def disable_auto_os_update_for_dnf_automatic(self): + """ Disables auto OS updates, using dnf-automatic service, and logs the default settings the machine comes with """ + self.composite_logger.log_verbose("[TDNF] Disabling auto OS updates using dnf-automatic") + self.__init_auto_update_for_dnf_automatic() + + self.backup_image_default_patch_configuration_if_not_exists() + + if not self.is_auto_update_service_installed(self.dnf_automatic_install_check_cmd): + self.composite_logger.log_debug("[TDNF] Cannot disable as dnf-automatic is not installed on the machine") + return + + self.composite_logger.log_verbose("[TDNF] Preemptively disabling auto OS updates using dnf-automatic") + self.update_os_patch_configuration_sub_setting(self.download_updates_identifier_text, "no", self.dnf_automatic_config_pattern_match_text) + self.update_os_patch_configuration_sub_setting(self.apply_updates_identifier_text, "no", self.dnf_automatic_config_pattern_match_text) + self.disable_auto_update_on_reboot(self.dnf_automatic_disable_on_reboot_cmd) + + self.composite_logger.log_debug("[TDNF] Successfully disabled auto OS updates using dnf-automatic") + + def disable_auto_update_on_reboot(self, command): + self.composite_logger.log_verbose("[TDNF] Disabling auto update on reboot. [Command={0}] ".format(command)) + code, out = self.env_layer.run_command_output(command, False, False) + + if code != 0: + self.composite_logger.log_error("[TDNF][ERROR] Error disabling auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) + error_msg = 'Unexpected return code (' + str(code) + ') on command: ' + command + self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.OPERATION_FAILED) + raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) + else: + self.composite_logger.log_debug("[TDNF] Disabled auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) - @abstractmethod def backup_image_default_patch_configuration_if_not_exists(self): """ Records the default system settings for auto OS updates within patch extension artifacts for future reference. We only log the default system settings a VM comes with, any subsequent updates will not be recorded""" - pass + """ JSON format for backup file: + { + "dnf-automatic": { + "apply_updates": "yes/no/empty string", + "download_updates": "yes/no/empty string", + "enable_on_reboot": true/false, + "installation_state": true/false + } + } """ + try: + self.composite_logger.log_debug("[TDNF] Ensuring there is a backup of the default patch state for [AutoOSUpdateService={0}]".format(str(self.current_auto_os_update_service))) + image_default_patch_configuration_backup = self.__get_image_default_patch_configuration_backup() + + # verify if existing backup is valid if not, write to backup + is_backup_valid = self.is_image_default_patch_configuration_backup_valid(image_default_patch_configuration_backup) + if is_backup_valid: + self.composite_logger.log_debug("[TDNF] Since extension has a valid backup, no need to log the current settings again. [Default Auto OS update settings={0}] [File path={1}]" + .format(str(image_default_patch_configuration_backup), self.image_default_patch_configuration_backup_path)) + else: + self.composite_logger.log_debug("[TDNF] Since the backup is invalid, will add a new backup with the current auto OS update settings") + self.composite_logger.log_debug("[TDNF] Fetching current auto OS update settings for [AutoOSUpdateService={0}]".format(str(self.current_auto_os_update_service))) + is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() + + backup_image_default_patch_configuration_json_to_add = { + self.current_auto_os_update_service: { + self.download_updates_identifier_text: download_updates_value, + self.apply_updates_identifier_text: apply_updates_value, + self.enable_on_reboot_identifier_text: enable_on_reboot_value, + self.installation_state_identifier_text: is_service_installed + } + } + + image_default_patch_configuration_backup.update(backup_image_default_patch_configuration_json_to_add) + + self.composite_logger.log_debug("[TDNF] Logging default system configuration settings for auto OS updates. [Settings={0}] [Log file path={1}]" + .format(str(image_default_patch_configuration_backup), self.image_default_patch_configuration_backup_path)) + self.env_layer.file_system.write_with_retry(self.image_default_patch_configuration_backup_path, '{0}'.format(json.dumps(image_default_patch_configuration_backup)), mode='w+') + except Exception as error: + error_message = "[TDNF] Exception during fetching and logging default auto update settings on the machine. [Exception={0}]".format(repr(error)) + self.composite_logger.log_error(error_message) + self.status_handler.add_error_to_status(error_message, Constants.PatchOperationErrorCodes.DEFAULT_ERROR) + raise - @abstractmethod def is_image_default_patch_configuration_backup_valid(self, image_default_patch_configuration_backup): - pass + """ Verifies if default auto update configurations, for a service under consideration, are saved in backup """ + return self.is_backup_valid_for_dnf_automatic(image_default_patch_configuration_backup) + + def is_backup_valid_for_dnf_automatic(self, image_default_patch_configuration_backup): + if self.dnf_auto_os_update_service in image_default_patch_configuration_backup \ + and self.dnf_automatic_download_updates_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ + and self.dnf_automatic_apply_updates_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ + and self.dnf_automatic_enable_on_reboot_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service] \ + and self.dnf_automatic_installation_state_identifier_text in image_default_patch_configuration_backup[self.dnf_auto_os_update_service]: + self.composite_logger.log_debug("[TDNF] Extension has a valid backup for default dnf-automatic configuration settings") + return True + else: + self.composite_logger.log_debug("[TDNF] Extension does not have a valid backup for default dnf-automatic configuration settings") + return False + + def update_os_patch_configuration_sub_setting(self, patch_configuration_sub_setting, value="no", config_pattern_match_text=""): + """ Updates (or adds if it doesn't exist) the given patch_configuration_sub_setting with the given value in os_patch_configuration_settings_file """ + try: + # note: adding space between the patch_configuration_sub_setting and value since, we will have to do that if we have to add a patch_configuration_sub_setting that did not exist before + self.composite_logger.log_debug("[TDNF] Updating system configuration settings for auto OS updates. [Patch Configuration Sub Setting={0}] [Value={1}]".format(str(patch_configuration_sub_setting), value)) + os_patch_configuration_settings = self.env_layer.file_system.read_with_retry(self.os_patch_configuration_settings_file_path) + patch_configuration_sub_setting_to_update = patch_configuration_sub_setting + ' = ' + value + patch_configuration_sub_setting_found_in_file = False + updated_patch_configuration_sub_setting = "" + settings = os_patch_configuration_settings.strip().split('\n') + + # update value of existing setting + for i in range(len(settings)): + match = re.search(patch_configuration_sub_setting + config_pattern_match_text, settings[i]) + if match is not None: + settings[i] = patch_configuration_sub_setting_to_update + patch_configuration_sub_setting_found_in_file = True + updated_patch_configuration_sub_setting += settings[i] + "\n" + + # add setting to configuration file, since it doesn't exist + if not patch_configuration_sub_setting_found_in_file: + updated_patch_configuration_sub_setting += patch_configuration_sub_setting_to_update + "\n" + + self.env_layer.file_system.write_with_retry(self.os_patch_configuration_settings_file_path, '{0}'.format(updated_patch_configuration_sub_setting.lstrip()), mode='w+') + except Exception as error: + error_msg = "[TDNF] Error occurred while updating system configuration settings for auto OS updates. [Patch Configuration={0}] [Error={1}]".format(str(patch_configuration_sub_setting), repr(error)) + self.composite_logger.log_error(error_msg) + self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.DEFAULT_ERROR) + raise + + def revert_auto_os_update_to_system_default(self): + """ Reverts the auto OS update patch state on the machine to its system default value, if one exists in our backup file """ + # type () -> None + self.composite_logger.log("[TDNF] Reverting the current automatic OS patch state on the machine to its system default value before patchmode was set to 'AutomaticByPlatform'") + self.revert_auto_os_update_to_system_default_for_dnf_automatic() + self.composite_logger.log_debug("[TDNF] Successfully reverted auto OS updates to system default config") + + def revert_auto_os_update_to_system_default_for_dnf_automatic(self): + """ Reverts the auto OS update patch state on the machine to its system default value for given service, if applicable """ + # type () -> None + self.__init_auto_update_for_dnf_automatic() + self.composite_logger.log("[TDNF] Reverting the current automatic OS patch state on the machine to its system default value for [Service={0}]".format(str(self.current_auto_os_update_service))) + is_service_installed, enable_on_reboot_value, download_updates_value, apply_updates_value = self.__get_current_auto_os_updates_setting_on_machine() + + if not is_service_installed: + self.composite_logger.log_debug("[TDNF] Machine default auto OS update service is not installed on the VM and hence no config to revert. [Service={0}]".format(str(self.current_auto_os_update_service))) + return + + self.composite_logger.log_debug("[TDNF] Logging current configuration settings for auto OS updates [Service={0}][Is_Service_Installed={1}][Machine_default_update_enable_on_reboot={2}][{3}={4}]][{5}={6}]" + .format(str(self.current_auto_os_update_service), str(is_service_installed), str(enable_on_reboot_value), str(self.download_updates_identifier_text), str(download_updates_value), str(self.apply_updates_identifier_text), str(apply_updates_value))) + + image_default_patch_configuration_backup = self.__get_image_default_patch_configuration_backup() + self.composite_logger.log_debug("[TDNF] Logging system default configuration settings for auto OS updates. [Settings={0}]".format(str(image_default_patch_configuration_backup))) + is_backup_valid = self.is_image_default_patch_configuration_backup_valid(image_default_patch_configuration_backup) + + if is_backup_valid: + download_updates_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.download_updates_identifier_text] + apply_updates_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.apply_updates_identifier_text] + enable_on_reboot_value_from_backup = image_default_patch_configuration_backup[self.current_auto_os_update_service][self.enable_on_reboot_identifier_text] + + self.update_os_patch_configuration_sub_setting(self.download_updates_identifier_text, download_updates_value_from_backup, self.auto_update_config_pattern_match_text) + self.update_os_patch_configuration_sub_setting(self.apply_updates_identifier_text, apply_updates_value_from_backup, self.auto_update_config_pattern_match_text) + if str(enable_on_reboot_value_from_backup).lower() == 'true': + self.enable_auto_update_on_reboot() + else: + self.composite_logger.log_debug("[TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service={0}]".format(str(self.current_auto_os_update_service))) - @abstractmethod - def update_os_patch_configuration_sub_setting(self, patch_configuration_sub_setting, value, patch_configuration_sub_setting_pattern_to_match): - pass + def enable_auto_update_on_reboot(self): + """Enables machine default auto update on reboot""" + # type () -> None + command = self.enable_on_reboot_cmd + self.composite_logger.log_verbose("[TDNF] Enabling auto update on reboot. [Command={0}] ".format(command)) + code, out = self.env_layer.run_command_output(command, False, False) + + if code != 0: + self.composite_logger.log_error("[TDNF][ERROR] Error enabling auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) + error_msg = 'Unexpected return code (' + str(code) + ') on command: ' + command + self.status_handler.add_error_to_status(error_msg, Constants.PatchOperationErrorCodes.OPERATION_FAILED) + raise Exception(error_msg, "[{0}]".format(Constants.ERROR_ADDED_TO_STATUS)) + else: + self.composite_logger.log_debug("[TDNF] Enabled auto update on reboot. [Command={0}][Code={1}][Output={2}]".format(command, str(code), out)) + + def __get_image_default_patch_configuration_backup(self): + """ Get image_default_patch_configuration_backup file""" + image_default_patch_configuration_backup = {} + + # read existing backup since it also contains backup from other update services. We need to preserve any existing data within the backup file + if self.image_default_patch_configuration_backup_exists(): + try: + image_default_patch_configuration_backup = json.loads(self.env_layer.file_system.read_with_retry(self.image_default_patch_configuration_backup_path)) + except Exception as error: + self.composite_logger.log_error("[TDNF] Unable to read backup for default patch state. Will attempt to re-write. [Exception={0}]".format(repr(error))) + return image_default_patch_configuration_backup # endregion - @abstractmethod + # region Reboot Management def is_reboot_pending(self): """ Checks if there is a pending reboot on the machine. """ - pass + try: + pending_file_exists = os.path.isfile(self.REBOOT_PENDING_FILE_PATH) + pending_processes_exist = self.do_processes_require_restart() + self.composite_logger.log_debug("[TDNF] > Reboot required debug flags (tdnf): " + str(pending_file_exists) + ", " + str(pending_processes_exist) + ".") + return pending_file_exists or pending_processes_exist + except Exception as error: + self.composite_logger.log_error('[TDNF] Error while checking for reboot pending (tdnf): ' + repr(error)) + return True # defaults for safety - @abstractmethod def do_processes_require_restart(self): - """ Signals whether processes require a restart due to updates to files """ - pass + """Signals whether processes require a restart due to updates""" + self.composite_logger.log_verbose("[TDNF] Checking if process requires reboot") + # Checking using dnf-utils + code, out = self.env_layer.run_command_output(self.dnf_utils_prerequisite, False, False) # idempotent, doesn't install if already present + self.composite_logger.log_verbose("[TDNF] Idempotent dnf-utils existence check. [Code={0}][Out={1}]".format(str(code), out)) + + # Checking for restart for distros with -r flag + code, out = self.env_layer.run_command_output(self.needs_restarting_with_flag, False, False) + self.composite_logger.log_verbose("[TDNF] > Code: " + str(code) + ", Output: \n|\t" + "\n|\t".join(out.splitlines())) + if out.find("Reboot is required") < 0: + self.composite_logger.log_debug("[TDNF] > Reboot not detected to be required (L1).") + else: + self.composite_logger.log_debug("[TDNF] > Reboot is detected to be required (L1).") + return True + + return False + # endregion @abstractmethod def set_security_esm_package_status(self, operation, packages): diff --git a/src/core/tests/Test_AzL3TdnfPackageManager.py b/src/core/tests/Test_AzL3TdnfPackageManager.py index ad8a7316..9e585039 100644 --- a/src/core/tests/Test_AzL3TdnfPackageManager.py +++ b/src/core/tests/Test_AzL3TdnfPackageManager.py @@ -16,7 +16,6 @@ import json import os import unittest -import sys # Conditional import for StringIO try: from StringIO import StringIO # Python 2 @@ -39,18 +38,12 @@ def tearDown(self): self.runtime.stop() # region Mocks - def mock_do_processes_require_restart_raise_exception(self): - raise Exception - def mock_linux_distribution_to_return_azure_linux(self): return ['Microsoft Azure Linux', '3.0', ''] def mock_linux_distribution_to_return_azure_linux_2(self): return ['Common Base Linux Mariner', '2.0', ''] - def mock_write_with_retry_raise_exception(self, file_path_or_handle, data, mode='a+'): - raise Exception - def mock_run_command_output_return_tdnf_3(self, cmd, no_output=False, chk_err=True): """ Mock for run_command_output to return tdnf 3 """ return 0, "3.5.8-3\n" @@ -87,84 +80,6 @@ def mock_distro_os_release_attr_return_azure_linux_2(self, attribute): return '2.9.0' # endregion - # region Utility Functions - def __setup_config_and_invoke_revert_auto_os_to_system_default(self, package_manager, create_current_auto_os_config=True, create_backup_for_system_default_config=True, current_auto_os_update_config_value='', apply_updates_value="", - download_updates_value="", enable_on_reboot_value=False, installation_state_value=False, set_installation_state=True): - """ Sets up current auto OS update config, backup for system default config (if requested) and invoke revert to system default """ - # setup current auto OS update config - if create_current_auto_os_config: - self.__setup_current_auto_os_update_config(package_manager, current_auto_os_update_config_value) - - # setup backup for system default auto OS update config - if create_backup_for_system_default_config: - self.__setup_backup_for_system_default_OS_update_config(package_manager, apply_updates_value=apply_updates_value, download_updates_value=download_updates_value, enable_on_reboot_value=enable_on_reboot_value, - installation_state_value=installation_state_value, set_installation_state=set_installation_state) - - package_manager.revert_auto_os_update_to_system_default() - - def __setup_current_auto_os_update_config(self, package_manager, config_value='', config_file_name="automatic.conf"): - # setup current auto OS update config - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, config_file_name) - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, config_value) - - def __setup_backup_for_system_default_OS_update_config(self, package_manager, apply_updates_value="", download_updates_value="", enable_on_reboot_value=False, installation_state_value=False, set_installation_state=True): - # setup backup for system default auto OS update config - package_manager.image_default_patch_configuration_backup_path = os.path.join(self.runtime.execution_config.config_folder, Constants.IMAGE_DEFAULT_PATCH_CONFIGURATION_BACKUP_PATH) - backup_image_default_patch_configuration_json = { - "dnf-automatic": { - "apply_updates": apply_updates_value, - "download_updates": download_updates_value, - "enable_on_reboot": enable_on_reboot_value - } - } - if set_installation_state: - backup_image_default_patch_configuration_json["dnf-automatic"]["installation_state"] = installation_state_value - self.runtime.write_to_file(package_manager.image_default_patch_configuration_backup_path, '{0}'.format(json.dumps(backup_image_default_patch_configuration_json))) - - @staticmethod - def __capture_std_io(): - # arrange capture std IO - captured_output = StringIO() - original_stdout = sys.stdout - sys.stdout = captured_output - return captured_output, original_stdout - - def __assert_std_io(self, captured_output, expected_output=''): - output = captured_output.getvalue() - self.assertTrue(expected_output in output) - - def __assert_reverted_automatic_patch_configuration_settings(self, package_manager, config_exists=True, config_value_expected=''): - if config_exists: - reverted_dnf_automatic_patch_configuration_settings = self.runtime.env_layer.file_system.read_with_retry(package_manager.dnf_automatic_configuration_file_path) - self.assertTrue(reverted_dnf_automatic_patch_configuration_settings is not None) - self.assertTrue(config_value_expected in reverted_dnf_automatic_patch_configuration_settings) - else: - self.assertFalse(os.path.exists(package_manager.dnf_automatic_configuration_file_path)) - # endregion - - def test_do_processes_require_restart(self): - """Unit test for tdnf package manager""" - # Restart required - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager) - self.assertTrue(package_manager.is_reboot_pending()) - - # Restart not required - self.runtime.set_legacy_test_type('SadPath') - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - self.assertFalse(package_manager.is_reboot_pending()) - - # Fake exception - self.runtime.set_legacy_test_type('SadPath') - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - backup_do_processes_require_restart = package_manager.do_processes_require_restart - package_manager.do_processes_require_restart = self.mock_do_processes_require_restart_raise_exception - self.assertTrue(package_manager.is_reboot_pending()) # returns true because the safe default if a failure occurs is 'true' - package_manager.do_processes_require_restart = backup_do_processes_require_restart - def test_all_classification_selected_for_auto_patching_request(self): """Unit test for tdnf package manager for auto patching request where all classifications are selected since Azure Linux does not have classifications""" backup_envlayer_platform_linux_distribution = LegacyEnvLayerExtensions.LegacyPlatform.linux_distribution @@ -188,324 +103,6 @@ def test_all_classification_selected_for_auto_patching_request(self): LegacyEnvLayerExtensions.LegacyPlatform.linux_distribution = backup_envlayer_platform_linux_distribution - def test_disable_auto_os_updates_with_uninstalled_services(self): - # no services are installed on the machine. expected o/p: function will complete successfully. Backup file will be created with default values, no auto OS update configuration settings will be updated as there are none - self.runtime.set_legacy_test_type('SadPath') - package_manager = self.container.get('package_manager') - package_manager.disable_auto_os_update() - self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) - image_default_patch_configuration_backup = json.loads(self.runtime.env_layer.file_system.read_with_retry(package_manager.image_default_patch_configuration_backup_path)) - self.assertTrue(image_default_patch_configuration_backup is not None) - - # validating backup for dnf-automatic - self.assertTrue(package_manager.dnf_auto_os_update_service in image_default_patch_configuration_backup) - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_download_updates_identifier_text], "") - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_apply_updates_identifier_text], "") - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_enable_on_reboot_identifier_text], False) - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_installation_state_identifier_text], False) - - def test_disable_auto_os_updates_with_installed_services(self): - # all services are installed and contain valid configurations. expected o/p All services will be disabled and backup file should reflect default settings for all - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - package_manager.disable_auto_os_update() - self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) - image_default_patch_configuration_backup = json.loads(self.runtime.env_layer.file_system.read_with_retry(package_manager.image_default_patch_configuration_backup_path)) - self.assertTrue(image_default_patch_configuration_backup is not None) - - # validating backup for dnf-automatic - self.assertTrue(package_manager.dnf_auto_os_update_service in image_default_patch_configuration_backup) - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_download_updates_identifier_text], "yes") - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_apply_updates_identifier_text], "yes") - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_enable_on_reboot_identifier_text], False) - self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_installation_state_identifier_text], True) - - def test_disable_auto_os_update_failure(self): - # disable with non existing log file - package_manager = self.container.get('package_manager') - - self.assertRaises(Exception, package_manager.disable_auto_os_update) - self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) - - def test_update_image_default_patch_mode(self): - package_manager = self.container.get('package_manager') - package_manager.os_patch_configuration_settings_file_path = package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - - # disable apply_updates when enabled by default - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_apply_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) - dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) - self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) - self.assertTrue('apply_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) - self.assertTrue('download_updates = yes' in dnf_automatic_os_patch_configuration_settings_file_path_read) - - # disable download_updates when enabled by default - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.os_patch_configuration_settings_file_path, dnf_automatic_os_patch_configuration_settings) - package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_download_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) - dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) - self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) - self.assertTrue('apply_updates = yes' in dnf_automatic_os_patch_configuration_settings_file_path_read) - self.assertTrue('download_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) - - # disable apply_updates when default patch mode settings file is empty - dnf_automatic_os_patch_configuration_settings = '' - self.runtime.write_to_file(package_manager.os_patch_configuration_settings_file_path, dnf_automatic_os_patch_configuration_settings) - package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_apply_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) - dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) - self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) - self.assertTrue('download_updates' not in dnf_automatic_os_patch_configuration_settings_file_path_read) - self.assertTrue('apply_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) - - def test_update_image_default_patch_mode_raises_exception(self): - package_manager = self.container.get('package_manager') - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - self.runtime.env_layer.file_system.write_with_retry = self.mock_write_with_retry_raise_exception - self.assertRaises(Exception, package_manager.update_os_patch_configuration_sub_setting) - - def test_get_current_auto_os_patch_state_with_uninstalled_services(self): - # no services are installed on the machine. expected o/p: function will complete successfully, backup file is not created and function returns current_auto_os_patch_state as disabled - self.runtime.set_legacy_test_type('SadPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.DISABLED) - - def test_get_current_auto_os_patch_state_with_installed_services_and_state_disabled(self): - # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as disabled - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = no\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.DISABLED) - - def test_get_current_auto_os_patch_state_with_installed_services_and_state_enabled(self): - # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as enabled - - # with enable on reboot set to false - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.ENABLED) - - # with enable on reboot set to true - self.runtime.set_legacy_test_type('AnotherSadPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = no\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.ENABLED) - - def test_get_current_auto_os_patch_state_with_installed_services_and_state_unknown(self): - # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as unknown - - self.runtime.set_legacy_test_type('HappyPath') - package_manager = self.container.get('package_manager') - package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state - - package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") - dnf_automatic_os_patch_configuration_settings = 'apply_updates = abc\ndownload_updates = yes\n' - self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) - - current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() - - self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) - self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.UNKNOWN) - - def test_revert_auto_os_update_to_system_default(self): - revert_success_testcase = { - "legacy_type": 'HappyPath', - "stdio": { - "capture_output": False, - "expected_output": None - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": True, - "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": True, - "apply_updates_value": "yes", - "download_updates_value": "yes", - "enable_on_reboot_value": True, - "installation_state_value": True, - "set_installation_state": True - } - }, - "assertions": { - "config_value_expected": 'apply_updates = yes\ndownload_updates = yes\n', - "config_exists": True - } - } - - revert_success_with_dnf_not_installed_testcase = { - "legacy_type": 'SadPath', - "stdio": { - "capture_output": False, - "expected_output": None - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": False, - "current_auto_os_update_config_value": '' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": True, - "apply_updates_value": "", - "download_updates_value": "", - "enable_on_reboot_value": False, - "installation_state_value": False, - "set_installation_state": True - } - }, - "assertions": { - "config_value_expected": "", - "config_exists": False - } - } - - revert_success_with_dnf_installed_but_no_config_value_testcase = { - "legacy_type": 'RevertToImageDefault', - "stdio": { - "capture_output": False, - "expected_output": None - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": True, - "current_auto_os_update_config_value": 'test_value = yes\n' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": True, - "apply_updates_value": "", - "download_updates_value": "", - "enable_on_reboot_value": False, - "installation_state_value": False, - "set_installation_state": True - } - }, - "assertions": { - "config_value_expected": 'download_updates =\napply_updates = \n', - "config_exists": True - } - } - - revert_success_backup_config_does_not_exist_testcase = { - "legacy_type": 'RevertToImageDefault', - "stdio": { - "capture_output": True, - "expected_output": "[AzL3TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service=dnf-automatic]" - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": True, - "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": False, - "apply_updates_value": "", - "download_updates_value": "", - "enable_on_reboot_value": False, - "installation_state_value": False, - "set_installation_state": True - } - }, - "assertions": { - "config_value_expected": 'apply_updates = no\ndownload_updates = no\n', - "config_exists": True - } - } - - revert_success_default_backup_config_invalid_testcase = { - "legacy_type": 'RevertToImageDefault', - "stdio": { - "capture_output": True, - "expected_output": "[AzL3TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service=dnf-automatic]" - }, - "config": { - "current_auto_update_config": { - "create_current_auto_os_config": True, - "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' - }, - "backup_system_default_config": { - "create_backup_for_system_default_config": True, - "apply_updates_value": "yes", - "download_updates_value": "yes", - "enable_on_reboot_value": True, - "installation_state_value": False, - "set_installation_state": False - } - }, - "assertions": { - "config_value_expected": 'apply_updates = no\ndownload_updates = no\n', - "config_exists": True - } - } - - all_testcases = [revert_success_testcase, revert_success_with_dnf_not_installed_testcase, revert_success_with_dnf_installed_but_no_config_value_testcase, revert_success_backup_config_does_not_exist_testcase, revert_success_default_backup_config_invalid_testcase] - - for testcase in all_testcases: - self.tearDown() - self.setUp() - captured_output, original_stdout = None, None - if testcase["stdio"]["capture_output"]: - # arrange capture std IO - captured_output, original_stdout = self.__capture_std_io() - - self.runtime.set_legacy_test_type(testcase["legacy_type"]) - package_manager = self.container.get('package_manager') - - # setup current auto OS update config, backup for system default config and invoke revert to system default - self.__setup_config_and_invoke_revert_auto_os_to_system_default(package_manager, - create_current_auto_os_config=bool(testcase["config"]["current_auto_update_config"]["create_current_auto_os_config"]), - current_auto_os_update_config_value=testcase["config"]["current_auto_update_config"]["current_auto_os_update_config_value"], - create_backup_for_system_default_config=bool(testcase["config"]["backup_system_default_config"]["create_backup_for_system_default_config"]), - apply_updates_value=testcase["config"]["backup_system_default_config"]["apply_updates_value"], - download_updates_value=testcase["config"]["backup_system_default_config"]["download_updates_value"], - enable_on_reboot_value=bool(testcase["config"]["backup_system_default_config"]["enable_on_reboot_value"]), - installation_state_value=bool(testcase["config"]["backup_system_default_config"]["installation_state_value"]), - set_installation_state=bool(testcase["config"]["backup_system_default_config"]["set_installation_state"])) - - # assert - if testcase["stdio"]["capture_output"]: - # restore sys.stdout output - sys.stdout = original_stdout - self.__assert_std_io(captured_output=captured_output, expected_output=testcase["stdio"]["expected_output"]) - self.__assert_reverted_automatic_patch_configuration_settings(package_manager, config_exists=bool(testcase["assertions"]["config_exists"]), config_value_expected=testcase["assertions"]["config_value_expected"]) - def test_set_max_patch_publish_date(self): """Unit test for tdnf package manager set_max_patch_publish_date method""" package_manager = self.container.get('package_manager') diff --git a/src/core/tests/Test_TdnfPackageManager.py b/src/core/tests/Test_TdnfPackageManager.py index 354471bf..e4aa76a7 100644 --- a/src/core/tests/Test_TdnfPackageManager.py +++ b/src/core/tests/Test_TdnfPackageManager.py @@ -13,7 +13,15 @@ # limitations under the License. # # Requires Python 2.7+ +import json +import os +import sys import unittest +# Conditional import for StringIO +try: + from StringIO import StringIO # Python 2 +except ImportError: + from io import StringIO # Python 3 from core.src.bootstrap.Constants import Constants from core.tests.library.ArgumentComposer import ArgumentComposer @@ -28,12 +36,420 @@ def setUp(self): def tearDown(self): self.runtime.stop() + # region Mocks + def mock_do_processes_require_restart_raise_exception(self): + raise Exception + + def mock_write_with_retry_raise_exception(self, file_path_or_handle, data, mode='a+'): + raise Exception + # endregion + + # region Utility Functions + def __setup_config_and_invoke_revert_auto_os_to_system_default(self, package_manager, create_current_auto_os_config=True, create_backup_for_system_default_config=True, current_auto_os_update_config_value='', apply_updates_value="", + download_updates_value="", enable_on_reboot_value=False, installation_state_value=False, set_installation_state=True): + """ Sets up current auto OS update config, backup for system default config (if requested) and invoke revert to system default """ + # setup current auto OS update config + if create_current_auto_os_config: + self.__setup_current_auto_os_update_config(package_manager, current_auto_os_update_config_value) + + # setup backup for system default auto OS update config + if create_backup_for_system_default_config: + self.__setup_backup_for_system_default_OS_update_config(package_manager, apply_updates_value=apply_updates_value, download_updates_value=download_updates_value, enable_on_reboot_value=enable_on_reboot_value, + installation_state_value=installation_state_value, set_installation_state=set_installation_state) + + package_manager.revert_auto_os_update_to_system_default() + + def __setup_current_auto_os_update_config(self, package_manager, config_value='', config_file_name="automatic.conf"): + # setup current auto OS update config + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, config_file_name) + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, config_value) + + def __setup_backup_for_system_default_OS_update_config(self, package_manager, apply_updates_value="", download_updates_value="", enable_on_reboot_value=False, installation_state_value=False, set_installation_state=True): + # setup backup for system default auto OS update config + package_manager.image_default_patch_configuration_backup_path = os.path.join(self.runtime.execution_config.config_folder, Constants.IMAGE_DEFAULT_PATCH_CONFIGURATION_BACKUP_PATH) + backup_image_default_patch_configuration_json = { + "dnf-automatic": { + "apply_updates": apply_updates_value, + "download_updates": download_updates_value, + "enable_on_reboot": enable_on_reboot_value + } + } + if set_installation_state: + backup_image_default_patch_configuration_json["dnf-automatic"]["installation_state"] = installation_state_value + self.runtime.write_to_file(package_manager.image_default_patch_configuration_backup_path, '{0}'.format(json.dumps(backup_image_default_patch_configuration_json))) + + @staticmethod + def __capture_std_io(): + # arrange capture std IO + captured_output = StringIO() + original_stdout = sys.stdout + sys.stdout = captured_output + return captured_output, original_stdout + + def __assert_std_io(self, captured_output, expected_output=''): + output = captured_output.getvalue() + self.assertTrue(expected_output in output) + + def __assert_reverted_automatic_patch_configuration_settings(self, package_manager, config_exists=True, config_value_expected=''): + if config_exists: + reverted_dnf_automatic_patch_configuration_settings = self.runtime.env_layer.file_system.read_with_retry(package_manager.dnf_automatic_configuration_file_path) + self.assertTrue(reverted_dnf_automatic_patch_configuration_settings is not None) + self.assertTrue(config_value_expected in reverted_dnf_automatic_patch_configuration_settings) + else: + self.assertFalse(os.path.exists(package_manager.dnf_automatic_configuration_file_path)) + # endregion + def test_refresh_repo(self): self.runtime.set_legacy_test_type('HappyPath') package_manager = self.container.get('package_manager') self.assertTrue(package_manager is not None) package_manager.refresh_repo_safely() + def test_do_processes_require_restart(self): + """Unit test for tdnf package manager""" + # Restart required + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager) + self.assertTrue(package_manager.is_reboot_pending()) + + # Restart not required + self.runtime.set_legacy_test_type('SadPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.assertFalse(package_manager.is_reboot_pending()) + + # Fake exception + self.runtime.set_legacy_test_type('SadPath') + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + backup_do_processes_require_restart = package_manager.do_processes_require_restart + package_manager.do_processes_require_restart = self.mock_do_processes_require_restart_raise_exception + self.assertTrue(package_manager.is_reboot_pending()) # returns true because the safe default if a failure occurs is 'true' + package_manager.do_processes_require_restart = backup_do_processes_require_restart + + def test_disable_auto_os_updates_with_uninstalled_services(self): + # no services are installed on the machine. expected o/p: function will complete successfully. Backup file will be created with default values, no auto OS update configuration settings will be updated as there are none + self.runtime.set_legacy_test_type('SadPath') + package_manager = self.container.get('package_manager') + package_manager.disable_auto_os_update() + self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) + image_default_patch_configuration_backup = json.loads(self.runtime.env_layer.file_system.read_with_retry(package_manager.image_default_patch_configuration_backup_path)) + self.assertTrue(image_default_patch_configuration_backup is not None) + + # validating backup for dnf-automatic + self.assertTrue(package_manager.dnf_auto_os_update_service in image_default_patch_configuration_backup) + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_download_updates_identifier_text], "") + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_apply_updates_identifier_text], "") + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_enable_on_reboot_identifier_text], False) + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_installation_state_identifier_text], False) + + def test_disable_auto_os_updates_with_installed_services(self): + # all services are installed and contain valid configurations. expected o/p All services will be disabled and backup file should reflect default settings for all + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + package_manager.disable_auto_os_update() + self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) + image_default_patch_configuration_backup = json.loads(self.runtime.env_layer.file_system.read_with_retry(package_manager.image_default_patch_configuration_backup_path)) + self.assertTrue(image_default_patch_configuration_backup is not None) + + # validating backup for dnf-automatic + self.assertTrue(package_manager.dnf_auto_os_update_service in image_default_patch_configuration_backup) + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_download_updates_identifier_text], "yes") + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_apply_updates_identifier_text], "yes") + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_enable_on_reboot_identifier_text], False) + self.assertEqual(image_default_patch_configuration_backup[package_manager.dnf_auto_os_update_service][package_manager.dnf_automatic_installation_state_identifier_text], True) + + def test_disable_auto_os_update_failure(self): + # disable with non existing log file + package_manager = self.container.get('package_manager') + + self.assertRaises(Exception, package_manager.disable_auto_os_update) + self.assertTrue(package_manager.image_default_patch_configuration_backup_exists()) + + def test_update_image_default_patch_mode(self): + package_manager = self.container.get('package_manager') + package_manager.os_patch_configuration_settings_file_path = package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + + # disable apply_updates when enabled by default + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_apply_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) + dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) + self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) + self.assertTrue('apply_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) + self.assertTrue('download_updates = yes' in dnf_automatic_os_patch_configuration_settings_file_path_read) + + # disable download_updates when enabled by default + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.os_patch_configuration_settings_file_path, dnf_automatic_os_patch_configuration_settings) + package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_download_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) + dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) + self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) + self.assertTrue('apply_updates = yes' in dnf_automatic_os_patch_configuration_settings_file_path_read) + self.assertTrue('download_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) + + # disable apply_updates when default patch mode settings file is empty + dnf_automatic_os_patch_configuration_settings = '' + self.runtime.write_to_file(package_manager.os_patch_configuration_settings_file_path, dnf_automatic_os_patch_configuration_settings) + package_manager.update_os_patch_configuration_sub_setting(package_manager.dnf_automatic_apply_updates_identifier_text, "no", package_manager.dnf_automatic_config_pattern_match_text) + dnf_automatic_os_patch_configuration_settings_file_path_read = self.runtime.env_layer.file_system.read_with_retry(package_manager.os_patch_configuration_settings_file_path) + self.assertTrue(dnf_automatic_os_patch_configuration_settings_file_path_read is not None) + self.assertTrue('download_updates' not in dnf_automatic_os_patch_configuration_settings_file_path_read) + self.assertTrue('apply_updates = no' in dnf_automatic_os_patch_configuration_settings_file_path_read) + + def test_update_image_default_patch_mode_raises_exception(self): + package_manager = self.container.get('package_manager') + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + backup_write_with_retry = self.runtime.env_layer.file_system.write_with_retry + self.runtime.env_layer.file_system.write_with_retry = self.mock_write_with_retry_raise_exception + self.assertRaises(Exception, package_manager.update_os_patch_configuration_sub_setting) + self.runtime.env_layer.file_system.write_with_retry = backup_write_with_retry + + def test_get_current_auto_os_patch_state_with_uninstalled_services(self): + # no services are installed on the machine. expected o/p: function will complete successfully, backup file is not created and function returns current_auto_os_patch_state as disabled + self.runtime.set_legacy_test_type('SadPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.DISABLED) + + def test_get_current_auto_os_patch_state_with_installed_services_and_state_disabled(self): + # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as disabled + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = no\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.DISABLED) + + def test_get_current_auto_os_patch_state_with_installed_services_and_state_enabled(self): + # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as enabled + + # with enable on reboot set to false + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = yes\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.ENABLED) + + # with enable on reboot set to true + self.runtime.set_legacy_test_type('AnotherSadPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = no\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.ENABLED) + + def test_get_current_auto_os_patch_state_with_installed_services_and_state_unknown(self): + # dnf-automatic is installed on the machine. expected o/p: function will complete successfully, backup file is NOT created and function returns current_auto_os_patch_state as unknown + + self.runtime.set_legacy_test_type('HappyPath') + package_manager = self.container.get('package_manager') + package_manager.get_current_auto_os_patch_state = self.runtime.backup_get_current_auto_os_patch_state + + package_manager.dnf_automatic_configuration_file_path = os.path.join(self.runtime.execution_config.config_folder, "automatic.conf") + dnf_automatic_os_patch_configuration_settings = 'apply_updates = abc\ndownload_updates = yes\n' + self.runtime.write_to_file(package_manager.dnf_automatic_configuration_file_path, dnf_automatic_os_patch_configuration_settings) + + current_auto_os_patch_state = package_manager.get_current_auto_os_patch_state() + + self.assertFalse(package_manager.image_default_patch_configuration_backup_exists()) + self.assertEqual(current_auto_os_patch_state, Constants.AutomaticOSPatchStates.UNKNOWN) + + def test_revert_auto_os_update_to_system_default(self): + revert_success_testcase = { + "legacy_type": 'HappyPath', + "stdio": { + "capture_output": False, + "expected_output": None + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": True, + "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": True, + "apply_updates_value": "yes", + "download_updates_value": "yes", + "enable_on_reboot_value": True, + "installation_state_value": True, + "set_installation_state": True + } + }, + "assertions": { + "config_value_expected": 'apply_updates = yes\ndownload_updates = yes\n', + "config_exists": True + } + } + + revert_success_with_dnf_not_installed_testcase = { + "legacy_type": 'SadPath', + "stdio": { + "capture_output": False, + "expected_output": None + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": False, + "current_auto_os_update_config_value": '' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": True, + "apply_updates_value": "", + "download_updates_value": "", + "enable_on_reboot_value": False, + "installation_state_value": False, + "set_installation_state": True + } + }, + "assertions": { + "config_value_expected": "", + "config_exists": False + } + } + + revert_success_with_dnf_installed_but_no_config_value_testcase = { + "legacy_type": 'RevertToImageDefault', + "stdio": { + "capture_output": False, + "expected_output": None + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": True, + "current_auto_os_update_config_value": 'test_value = yes\n' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": True, + "apply_updates_value": "", + "download_updates_value": "", + "enable_on_reboot_value": False, + "installation_state_value": False, + "set_installation_state": True + } + }, + "assertions": { + "config_value_expected": 'download_updates =\napply_updates = \n', + "config_exists": True + } + } + + revert_success_backup_config_does_not_exist_testcase = { + "legacy_type": 'RevertToImageDefault', + "stdio": { + "capture_output": True, + "expected_output": "[TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service=dnf-automatic]" + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": True, + "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": False, + "apply_updates_value": "", + "download_updates_value": "", + "enable_on_reboot_value": False, + "installation_state_value": False, + "set_installation_state": True + } + }, + "assertions": { + "config_value_expected": 'apply_updates = no\ndownload_updates = no\n', + "config_exists": True + } + } + + revert_success_default_backup_config_invalid_testcase = { + "legacy_type": 'RevertToImageDefault', + "stdio": { + "capture_output": True, + "expected_output": "[TDNF] Since the backup is invalid or does not exist for current service, we won't be able to revert auto OS patch settings to their system default value. [Service=dnf-automatic]" + }, + "config": { + "current_auto_update_config": { + "create_current_auto_os_config": True, + "current_auto_os_update_config_value": 'apply_updates = no\ndownload_updates = no\n' + }, + "backup_system_default_config": { + "create_backup_for_system_default_config": True, + "apply_updates_value": "yes", + "download_updates_value": "yes", + "enable_on_reboot_value": True, + "installation_state_value": False, + "set_installation_state": False + } + }, + "assertions": { + "config_value_expected": 'apply_updates = no\ndownload_updates = no\n', + "config_exists": True + } + } + + all_testcases = [revert_success_testcase, revert_success_with_dnf_not_installed_testcase, revert_success_with_dnf_installed_but_no_config_value_testcase, revert_success_backup_config_does_not_exist_testcase, revert_success_default_backup_config_invalid_testcase] + + for testcase in all_testcases: + self.tearDown() + self.setUp() + captured_output, original_stdout = None, None + if testcase["stdio"]["capture_output"]: + # arrange capture std IO + captured_output, original_stdout = self.__capture_std_io() + + self.runtime.set_legacy_test_type(testcase["legacy_type"]) + package_manager = self.container.get('package_manager') + + # setup current auto OS update config, backup for system default config and invoke revert to system default + self.__setup_config_and_invoke_revert_auto_os_to_system_default(package_manager, + create_current_auto_os_config=bool(testcase["config"]["current_auto_update_config"]["create_current_auto_os_config"]), + current_auto_os_update_config_value=testcase["config"]["current_auto_update_config"]["current_auto_os_update_config_value"], + create_backup_for_system_default_config=bool(testcase["config"]["backup_system_default_config"]["create_backup_for_system_default_config"]), + apply_updates_value=testcase["config"]["backup_system_default_config"]["apply_updates_value"], + download_updates_value=testcase["config"]["backup_system_default_config"]["download_updates_value"], + enable_on_reboot_value=bool(testcase["config"]["backup_system_default_config"]["enable_on_reboot_value"]), + installation_state_value=bool(testcase["config"]["backup_system_default_config"]["installation_state_value"]), + set_installation_state=bool(testcase["config"]["backup_system_default_config"]["set_installation_state"])) + + # assert + if testcase["stdio"]["capture_output"]: + # restore sys.stdout output + sys.stdout = original_stdout + self.__assert_std_io(captured_output=captured_output, expected_output=testcase["stdio"]["expected_output"]) + self.__assert_reverted_automatic_patch_configuration_settings(package_manager, config_exists=bool(testcase["assertions"]["config_exists"]), config_value_expected=testcase["assertions"]["config_value_expected"]) + + def test_package_manager_no_updates(self): """Unit test for tdnf package manager with no updates""" # Path change diff --git a/src/tools/Package-Core.py b/src/tools/Package-Core.py index bae1a033..afbcc01f 100644 --- a/src/tools/Package-Core.py +++ b/src/tools/Package-Core.py @@ -130,6 +130,16 @@ def generate_compiled_script(source_code_path, merged_file_full_path, merged_fil continue elif os.path.basename(file_path) in ('PackageManager.py', 'Constants.py', 'LifecycleManager.py', 'SystemctlManager.py'): modules_to_be_merged.insert(0, file_path) + elif os.path.basename(file_path) == 'TdnfPackageManager.py': + # Insert before `AzL3PackageManager.py`; fallback to append. + inserted = False + for i, p in enumerate(modules_to_be_merged): + if os.path.basename(p) == 'AzL3TdnfPackageManager.py': + modules_to_be_merged.insert(i, file_path) + inserted = True + break + if not inserted: + modules_to_be_merged.append(file_path) else: if len(modules_to_be_merged) > 0 and '__main__.py' in modules_to_be_merged[-1]: modules_to_be_merged.insert(-1, file_path) From 262254b8513cc448ab4375cc38029f1b5902b76a Mon Sep 17 00:00:00 2001 From: Rajasi Rane Date: Fri, 26 Sep 2025 02:41:38 -0700 Subject: [PATCH 4/6] [AzL3Tdnf] Refactor: Addressing PR feedback #2 --- .../AzL3TdnfPackageManager.py | 28 ------------------- .../package_managers/TdnfPackageManager.py | 28 ++++++++++++++++--- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/core/src/package_managers/AzL3TdnfPackageManager.py b/src/core/src/package_managers/AzL3TdnfPackageManager.py index 8a9377d8..9bf5b204 100644 --- a/src/core/src/package_managers/AzL3TdnfPackageManager.py +++ b/src/core/src/package_managers/AzL3TdnfPackageManager.py @@ -145,21 +145,6 @@ def is_minimum_tdnf_version_for_strict_sdp_installed(self): return False return True - def get_tdnf_version(self): - # type: () -> any - """Get the version of TDNF installed on the system""" - self.composite_logger.log_debug("[AzL3TDNF] Getting tdnf version...") - cmd = "rpm -q tdnf | sed -E 's/^tdnf-([0-9]+\\.[0-9]+\\.[0-9]+-[0-9]+\\.[a-zA-Z0-9]+).*/\\1/'" - code, output = self.env_layer.run_command_output(cmd, False, False) - if code == 0: - # Sample output: 3.5.8-3-azl3 - version = output.split()[0] if output else None - self.composite_logger.log_debug("[AzL3TDNF] TDNF version detected. [Version={0}]".format(version)) - return version - else: - self.composite_logger.log_error("[AzL3TDNF] Failed to get TDNF version. [Command={0}][Code={1}][Output={2}]".format(cmd, code, output)) - return None - def try_tdnf_update_to_meet_strict_sdp_requirements(self): # type: () -> bool """Attempt to update TDNF to meet the minimum version required for strict SDP""" @@ -174,16 +159,3 @@ def try_tdnf_update_to_meet_strict_sdp_requirements(self): return False # endregion - def set_security_esm_package_status(self, operation, packages): - """ Set the security-ESM classification for the esm packages. Only needed for apt. No-op for tdnf, yum and zypper.""" - pass - - def separate_out_esm_packages(self, packages, package_versions): - """Filter out packages from the list where the version matches the UA_ESM_REQUIRED string. - Only needed for apt. No-op for tdnf, yum and zypper""" - esm_packages = [] - esm_package_versions = [] - esm_packages_found = False - - return packages, package_versions, esm_packages, esm_package_versions, esm_packages_found - diff --git a/src/core/src/package_managers/TdnfPackageManager.py b/src/core/src/package_managers/TdnfPackageManager.py index 52703af1..f1b851aa 100644 --- a/src/core/src/package_managers/TdnfPackageManager.py +++ b/src/core/src/package_managers/TdnfPackageManager.py @@ -19,7 +19,7 @@ import os import re -from abc import ABCMeta, abstractmethod +from abc import abstractmethod from core.src.core_logic.VersionComparator import VersionComparator from core.src.bootstrap.Constants import Constants from core.src.package_managers.PackageManager import PackageManager @@ -716,13 +716,33 @@ def do_processes_require_restart(self): return False # endregion - @abstractmethod + def get_tdnf_version(self): + # type: () -> any + """Get the version of TDNF installed on the system""" + self.composite_logger.log_debug("[TDNF] Getting tdnf version...") + cmd = "rpm -q tdnf | sed -E 's/^tdnf-([0-9]+\\.[0-9]+\\.[0-9]+-[0-9]+\\.[a-zA-Z0-9]+).*/\\1/'" + code, output = self.env_layer.run_command_output(cmd, False, False) + if code == 0: + # Sample output: 3.5.8-3-azl3 + version = output.split()[0] if output else None + self.composite_logger.log_debug("[TDNF] TDNF version detected. [Version={0}]".format(version)) + return version + else: + self.composite_logger.log_error("[TDNF] Failed to get TDNF version. [Command={0}][Code={1}][Output={2}]".format(cmd, code, output)) + return None + def set_security_esm_package_status(self, operation, packages): + """ Set the security-ESM classification for the esm packages. Only needed for apt. No-op for tdnf, yum and zypper.""" pass - @abstractmethod def separate_out_esm_packages(self, packages, package_versions): - pass + """Filter out packages from the list where the version matches the UA_ESM_REQUIRED string. + Only needed for apt. No-op for tdnf, yum and zypper""" + esm_packages = [] + esm_package_versions = [] + esm_packages_found = False + + return packages, package_versions, esm_packages, esm_package_versions, esm_packages_found def get_package_install_expected_avg_time_in_seconds(self): return self.package_install_expected_avg_time_in_seconds From 5776569dca39d15dbf30d7543d3f0a6efe46cd85 Mon Sep 17 00:00:00 2001 From: Rajasi Rane Date: Thu, 2 Oct 2025 08:01:30 -0700 Subject: [PATCH 5/6] [AzL3Tdnf] Refactor: Addressing PR feedback #3.1 --- .../AzL3TdnfPackageManager.py | 35 ++++------------- .../src/package_managers/PackageManager.py | 3 +- .../package_managers/TdnfPackageManager.py | 39 +++++++++++++------ src/core/tests/Test_AzL3TdnfPackageManager.py | 22 ----------- src/core/tests/Test_TdnfPackageManager.py | 27 ++++++++++++- 5 files changed, 62 insertions(+), 64 deletions(-) diff --git a/src/core/src/package_managers/AzL3TdnfPackageManager.py b/src/core/src/package_managers/AzL3TdnfPackageManager.py index 9bf5b204..a86bcbfe 100644 --- a/src/core/src/package_managers/AzL3TdnfPackageManager.py +++ b/src/core/src/package_managers/AzL3TdnfPackageManager.py @@ -29,11 +29,8 @@ class AzL3TdnfPackageManager(TdnfPackageManager): def __init__(self, env_layer, execution_config, composite_logger, telemetry_writer, status_handler): super(AzL3TdnfPackageManager, self).__init__(env_layer, execution_config, composite_logger, telemetry_writer, status_handler) - # Support to get updates and their dependencies - self.tdnf_check = 'sudo tdnf -q list updates ' - # Install update - self.install_security_updates_azgps_coordinated_cmd = 'sudo tdnf -y upgrade --skip-broken ' + self.install_security_updates_azgps_coordinated_cmd = 'sudo tdnf -y upgrade --skip-broken' # Strict SDP specializations self.TDNF_MINIMUM_VERSION_FOR_STRICT_SDP = "3.5.8-3.azl3" # minimum version of tdnf required to support Strict SDP in Azure Linux @@ -51,14 +48,11 @@ def __init__(self, env_layer, execution_config, composite_logger, telemetry_writ execution_config.included_classifications_list = [Constants.PackageClassification.CRITICAL, Constants.PackageClassification.SECURITY, Constants.PackageClassification.OTHER] # region Strict SDP using SnapshotTime - @staticmethod - def __generate_command_with_snapshotposixtime_if_specified(command_template, snapshot_posix_time=str()): - # type: (str, str) -> str - if snapshot_posix_time == str(): - return command_template.replace('', str()) + def _Base__add_additional_parameters_as_required_to_cmd(self, cmd): + if self.max_patch_publish_date == str(): + return cmd else: - return command_template.replace('', ('--snapshottime={0}'.format(str(snapshot_posix_time)))) - # endregion + return cmd + ' --snapshottime={0}'.format(str(self.max_patch_publish_date)) # region Get Available Updates # region Classification-based (incl. All) update check @@ -69,28 +63,15 @@ def get_all_updates(self, cached=False): self.composite_logger.log_debug("[AzL3TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(cached), len(self.all_updates_cached))) return self.all_updates_cached, self.all_update_versions_cached # allows for high performance reuse in areas of the code explicitly aware of the cache - out = self.invoke_package_manager(self.__generate_command_with_snapshotposixtime_if_specified(self.tdnf_check, self.max_patch_publish_date)) + out = self.invoke_package_manager(self._Base__add_additional_parameters_as_required_to_cmd(self.tdnf_check)) self.all_updates_cached, self.all_update_versions_cached = self.extract_packages_and_versions(out) self.composite_logger.log_debug("[AzL3TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(False), len(self.all_updates_cached))) return self.all_updates_cached, self.all_update_versions_cached - def get_security_updates(self): - """Get missing security updates. NOTE: Classification based categorization of patches is not available in Azure Linux as of now""" - self.composite_logger.log_verbose("[AzL3TDNF] Discovering all packages as 'security' packages, since TDNF does not support package classification...") - security_packages, security_package_versions = self.get_all_updates(cached=False) - self.composite_logger.log_debug("[AzL3TDNF] Discovered 'security' packages. [Count={0}]".format(len(security_packages))) - return security_packages, security_package_versions - - def get_other_updates(self): - """Get missing other updates.""" - self.composite_logger.log_verbose("[AzL3TDNF] Discovering 'other' packages...") - return [], [] - def set_max_patch_publish_date(self, max_patch_publish_date=str()): """Set the max patch publish date in POSIX time for strict SDP""" - self.composite_logger.log_debug("[AzL3TDNF] Setting max patch publish date. [MaxPatchPublishDate={0}]".format(str(max_patch_publish_date))) self.max_patch_publish_date = str(self.env_layer.datetime.datetime_string_to_posix_time(max_patch_publish_date, '%Y%m%dT%H%M%SZ')) if max_patch_publish_date != str() else max_patch_publish_date - self.composite_logger.log_debug("[AzL3TDNF] Set max patch publish date. [MaxPatchPublishDatePosixTime={0}]".format(str(self.max_patch_publish_date))) + self.composite_logger.log_debug("[AzL3TDNF] Set max patch publish date in posix time for Strict SDP. [MaxPatchPublishDate={0}][MaxPatchPublishDatePosixTime={1}]".format(str(max_patch_publish_date), str(self.max_patch_publish_date))) # endregion # endregion @@ -101,7 +82,7 @@ def install_updates_fail_safe(self, excluded_packages): def install_security_updates_azgps_coordinated(self): """Install security updates in Azure Linux following strict SDP""" - command = self.__generate_command_with_snapshotposixtime_if_specified(self.install_security_updates_azgps_coordinated_cmd, self.max_patch_publish_date) + command = self._Base__add_additional_parameters_as_required_to_cmd(self.install_security_updates_azgps_coordinated_cmd) out, code = self.invoke_package_manager_advanced(command, raise_on_exception=False) return code, out diff --git a/src/core/src/package_managers/PackageManager.py b/src/core/src/package_managers/PackageManager.py index d2bb57d3..ee177c25 100644 --- a/src/core/src/package_managers/PackageManager.py +++ b/src/core/src/package_managers/PackageManager.py @@ -349,9 +349,8 @@ def install_security_updates_azgps_coordinated(self): @abstractmethod def try_meet_azgps_coordinated_requirements(self): - # type: () -> bool """ Returns true if the package manager meets the requirements for azgps coordinated security updates """ - return False + pass # endregion # region Package Information diff --git a/src/core/src/package_managers/TdnfPackageManager.py b/src/core/src/package_managers/TdnfPackageManager.py index f1b851aa..44ef8c5f 100644 --- a/src/core/src/package_managers/TdnfPackageManager.py +++ b/src/core/src/package_managers/TdnfPackageManager.py @@ -19,7 +19,7 @@ import os import re -from abc import abstractmethod +from abc import ABCMeta, abstractmethod from core.src.core_logic.VersionComparator import VersionComparator from core.src.bootstrap.Constants import Constants from core.src.package_managers.PackageManager import PackageManager @@ -35,6 +35,7 @@ def __init__(self, env_layer, execution_config, composite_logger, telemetry_writ self.cmd_repo_refresh = "sudo tdnf -q list updates" # Support to get updates and their dependencies + self.tdnf_check = 'sudo tdnf -q list updates' self.single_package_check_versions = 'sudo tdnf list available ' self.single_package_check_installed = 'sudo tdnf list installed ' self.single_package_upgrade_simulation_cmd = 'sudo tdnf install --assumeno --skip-broken ' @@ -77,6 +78,8 @@ def __init__(self, env_layer, execution_config, composite_logger, telemetry_writ self.package_install_expected_avg_time_in_seconds = 90 # Setting a default value of 90 seconds as the avg time to install a package using tdnf, might be changed later if needed. + __metaclass__ = ABCMeta # For Python 3.0+, it changes to class Abstract(metaclass=ABCMeta) + def refresh_repo(self): self.composite_logger.log("[TDNF] Refreshing local repo...") self.invoke_package_manager(self.cmd_clean_cache) @@ -102,23 +105,36 @@ def invoke_package_manager_advanced(self, command, raise_on_exception=True): return out, code # region Classification-based (incl. All) update check - @abstractmethod def get_all_updates(self, cached=False): - """Same behavior as get_available_updates, but higher performance with no filters""" - pass - return [], [] # only here to suppress a static syntax validation problem + """Get all missing updates""" + self.composite_logger.log_verbose("[TDNF] Discovering all packages...") + if cached and not len(self.all_updates_cached) == 0: + self.composite_logger.log_debug("[TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(cached), len(self.all_updates_cached))) + return self.all_updates_cached, self.all_update_versions_cached # allows for high performance reuse in areas of the code explicitly aware of the cache + + out = self.invoke_package_manager(self.__add_additional_parameters_as_required_to_cmd(self.tdnf_check)) + self.all_updates_cached, self.all_update_versions_cached = self.extract_packages_and_versions(out) + self.composite_logger.log_debug("[TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(False), len(self.all_updates_cached))) + return self.all_updates_cached, self.all_update_versions_cached - @abstractmethod def get_security_updates(self): - pass + """Get missing security updates. NOTE: Classification based categorization of patches is not available in Azure Linux as of now""" + self.composite_logger.log_verbose("[TDNF] Discovering all packages as 'security' packages, since TDNF does not support package classification...") + security_packages, security_package_versions = self.get_all_updates(cached=False) + self.composite_logger.log_debug("[TDNF] Discovered 'security' packages. [Count={0}]".format(len(security_packages))) + return security_packages, security_package_versions - @abstractmethod def get_other_updates(self): - pass + """Get missing other updates.""" + self.composite_logger.log_verbose("[TDNF] Discovering 'other' packages...") + return [], [] - @abstractmethod def set_max_patch_publish_date(self, max_patch_publish_date=str()): - pass + self.composite_logger.log_debug("[TDNF] Setting max patch publish date. [Date={0}]".format(str())) + self.max_patch_publish_date = str() + + def __add_additional_parameters_as_required_to_cmd(self, cmd): + return cmd # endregion # region Output Parser(s) @@ -195,7 +211,6 @@ def install_updates_fail_safe(self, excluded_packages): def install_security_updates_azgps_coordinated(self): pass - @abstractmethod def try_meet_azgps_coordinated_requirements(self): # type: () -> bool """ Returns true if the package manager meets the requirements for azgps coordinated security updates """ diff --git a/src/core/tests/Test_AzL3TdnfPackageManager.py b/src/core/tests/Test_AzL3TdnfPackageManager.py index 9e585039..e0faea2c 100644 --- a/src/core/tests/Test_AzL3TdnfPackageManager.py +++ b/src/core/tests/Test_AzL3TdnfPackageManager.py @@ -44,10 +44,6 @@ def mock_linux_distribution_to_return_azure_linux(self): def mock_linux_distribution_to_return_azure_linux_2(self): return ['Common Base Linux Mariner', '2.0', ''] - def mock_run_command_output_return_tdnf_3(self, cmd, no_output=False, chk_err=True): - """ Mock for run_command_output to return tdnf 3 """ - return 0, "3.5.8-3\n" - def mock_run_command_output_return_1(self, cmd, no_output=False, chk_err=True): """ Mock for run_command_output to return None """ return 1, "No output available\n" @@ -119,24 +115,6 @@ def test_set_max_patch_publish_date(self): # posix time computation throws an exception if the date is not in the correct format self.assertRaises(ValueError, package_manager.set_max_patch_publish_date, "2024-07-02T00:00:00Z") - def test_get_tdnf_version(self): - """Unit test for tdnf package manager get_tdnf_version method""" - package_manager = self.container.get('package_manager') - self.assertTrue(package_manager is not None) - self.backup_run_command_output = self.runtime.env_layer.run_command_output - - test_input_output_table = [ - [self.mock_run_command_output_return_tdnf_3, "3.5.8-3"], - [self.mock_run_command_output_return_1, None], - ] - - for row in test_input_output_table: - self.runtime.env_layer.run_command_output = row[0] - version = package_manager.get_tdnf_version() - self.assertEqual(version, row[1]) - - self.runtime.env_layer.run_command_output = self.backup_run_command_output - def test_is_mininum_tdnf_version_for_strict_sdp_installed(self): """Unit test for tdnf package manager is_minimum_tdnf_version method""" package_manager = self.container.get('package_manager') diff --git a/src/core/tests/Test_TdnfPackageManager.py b/src/core/tests/Test_TdnfPackageManager.py index e4aa76a7..c897e679 100644 --- a/src/core/tests/Test_TdnfPackageManager.py +++ b/src/core/tests/Test_TdnfPackageManager.py @@ -28,7 +28,7 @@ from core.tests.library.RuntimeCompositor import RuntimeCompositor -class TestAzL3TdnfPackageManager(unittest.TestCase): +class TestTdnfPackageManager(unittest.TestCase): def setUp(self): self.runtime = RuntimeCompositor(ArgumentComposer().get_composed_arguments(), True, Constants.TDNF) self.container = self.runtime.container @@ -42,6 +42,14 @@ def mock_do_processes_require_restart_raise_exception(self): def mock_write_with_retry_raise_exception(self, file_path_or_handle, data, mode='a+'): raise Exception + + def mock_run_command_output_return_tdnf_3(self, cmd, no_output=False, chk_err=True): + """ Mock for run_command_output to return tdnf 3 """ + return 0, "3.5.8-3\n" + + def mock_run_command_output_return_1(self, cmd, no_output=False, chk_err=True): + """ Mock for run_command_output to return None """ + return 1, "No output available\n" # endregion # region Utility Functions @@ -449,6 +457,23 @@ def test_revert_auto_os_update_to_system_default(self): self.__assert_std_io(captured_output=captured_output, expected_output=testcase["stdio"]["expected_output"]) self.__assert_reverted_automatic_patch_configuration_settings(package_manager, config_exists=bool(testcase["assertions"]["config_exists"]), config_value_expected=testcase["assertions"]["config_value_expected"]) + def test_get_tdnf_version(self): + """Unit test for tdnf package manager get_tdnf_version method""" + package_manager = self.container.get('package_manager') + self.assertTrue(package_manager is not None) + self.backup_run_command_output = self.runtime.env_layer.run_command_output + + test_input_output_table = [ + [self.mock_run_command_output_return_tdnf_3, "3.5.8-3"], + [self.mock_run_command_output_return_1, None], + ] + + for row in test_input_output_table: + self.runtime.env_layer.run_command_output = row[0] + version = package_manager.get_tdnf_version() + self.assertEqual(version, row[1]) + + self.runtime.env_layer.run_command_output = self.backup_run_command_output def test_package_manager_no_updates(self): """Unit test for tdnf package manager with no updates""" From 1c8339071551b0e04d261bdcb44b82f3f66a45a7 Mon Sep 17 00:00:00 2001 From: Rajasi Rane Date: Thu, 2 Oct 2025 12:12:05 -0700 Subject: [PATCH 6/6] [AzL3Tdnf] Refactor: Addressing PR feedback #3.2 --- .../AzL3TdnfPackageManager.py | 22 +------------------ .../package_managers/TdnfPackageManager.py | 13 ++++++----- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/core/src/package_managers/AzL3TdnfPackageManager.py b/src/core/src/package_managers/AzL3TdnfPackageManager.py index a86bcbfe..0a9cface 100644 --- a/src/core/src/package_managers/AzL3TdnfPackageManager.py +++ b/src/core/src/package_managers/AzL3TdnfPackageManager.py @@ -47,33 +47,13 @@ def __init__(self, env_layer, execution_config, composite_logger, telemetry_writ self.composite_logger.log_debug("Updating classifications list to install all patches for the Auto Patching request since classification based patching is not available on Azure Linux machines") execution_config.included_classifications_list = [Constants.PackageClassification.CRITICAL, Constants.PackageClassification.SECURITY, Constants.PackageClassification.OTHER] - # region Strict SDP using SnapshotTime - def _Base__add_additional_parameters_as_required_to_cmd(self, cmd): - if self.max_patch_publish_date == str(): - return cmd - else: - return cmd + ' --snapshottime={0}'.format(str(self.max_patch_publish_date)) - # region Get Available Updates # region Classification-based (incl. All) update check - def get_all_updates(self, cached=False): - """Get all missing updates""" - self.composite_logger.log_verbose("[AzL3TDNF] Discovering all packages...") - if cached and not len(self.all_updates_cached) == 0: - self.composite_logger.log_debug("[AzL3TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(cached), len(self.all_updates_cached))) - return self.all_updates_cached, self.all_update_versions_cached # allows for high performance reuse in areas of the code explicitly aware of the cache - - out = self.invoke_package_manager(self._Base__add_additional_parameters_as_required_to_cmd(self.tdnf_check)) - self.all_updates_cached, self.all_update_versions_cached = self.extract_packages_and_versions(out) - self.composite_logger.log_debug("[AzL3TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(False), len(self.all_updates_cached))) - return self.all_updates_cached, self.all_update_versions_cached - def set_max_patch_publish_date(self, max_patch_publish_date=str()): """Set the max patch publish date in POSIX time for strict SDP""" self.max_patch_publish_date = str(self.env_layer.datetime.datetime_string_to_posix_time(max_patch_publish_date, '%Y%m%dT%H%M%SZ')) if max_patch_publish_date != str() else max_patch_publish_date self.composite_logger.log_debug("[AzL3TDNF] Set max patch publish date in posix time for Strict SDP. [MaxPatchPublishDate={0}][MaxPatchPublishDatePosixTime={1}]".format(str(max_patch_publish_date), str(self.max_patch_publish_date))) # endregion - # endregion # region Install Updates @@ -82,7 +62,7 @@ def install_updates_fail_safe(self, excluded_packages): def install_security_updates_azgps_coordinated(self): """Install security updates in Azure Linux following strict SDP""" - command = self._Base__add_additional_parameters_as_required_to_cmd(self.install_security_updates_azgps_coordinated_cmd) + command = self.add_additional_parameters_as_required_to_cmd(self.install_security_updates_azgps_coordinated_cmd) out, code = self.invoke_package_manager_advanced(command, raise_on_exception=False) return code, out diff --git a/src/core/src/package_managers/TdnfPackageManager.py b/src/core/src/package_managers/TdnfPackageManager.py index 44ef8c5f..f19c43d2 100644 --- a/src/core/src/package_managers/TdnfPackageManager.py +++ b/src/core/src/package_managers/TdnfPackageManager.py @@ -112,7 +112,7 @@ def get_all_updates(self, cached=False): self.composite_logger.log_debug("[TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(cached), len(self.all_updates_cached))) return self.all_updates_cached, self.all_update_versions_cached # allows for high performance reuse in areas of the code explicitly aware of the cache - out = self.invoke_package_manager(self.__add_additional_parameters_as_required_to_cmd(self.tdnf_check)) + out = self.invoke_package_manager(self.add_additional_parameters_as_required_to_cmd(self.tdnf_check)) self.all_updates_cached, self.all_update_versions_cached = self.extract_packages_and_versions(out) self.composite_logger.log_debug("[TDNF] Get all updates : [Cached={0}][PackagesCount={1}]]".format(str(False), len(self.all_updates_cached))) return self.all_updates_cached, self.all_update_versions_cached @@ -129,12 +129,15 @@ def get_other_updates(self): self.composite_logger.log_verbose("[TDNF] Discovering 'other' packages...") return [], [] + @abstractmethod def set_max_patch_publish_date(self, max_patch_publish_date=str()): - self.composite_logger.log_debug("[TDNF] Setting max patch publish date. [Date={0}]".format(str())) - self.max_patch_publish_date = str() + pass - def __add_additional_parameters_as_required_to_cmd(self, cmd): - return cmd + def add_additional_parameters_as_required_to_cmd(self, cmd): + if self.max_patch_publish_date == str(): + return cmd + else: + return cmd + ' --snapshottime={0}'.format(str(self.max_patch_publish_date)) # endregion # region Output Parser(s)