# # The abstraction of the DNF base # # Copyright (C) 2020 Red Hat, Inc. # # This copyrighted material is made available to anyone wishing to use, # modify, copy, or redistribute it subject to the terms and conditions of # the GNU General Public License v.2, or (at your option) any later version. # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY expressed or implied, including the implied warranties of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program; if not, write to the # Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. Any Red Hat trademarks that are incorporated in the # source code or documentation are not subject to the GNU General Public # License and may only be used or replicated with the express permission of # Red Hat, Inc. # import multiprocessing import shutil import threading import traceback import dnf import dnf.exceptions import dnf.module.module_base import dnf.repo import dnf.subject import libdnf.conf from blivet.size import Size from pyanaconda.anaconda_loggers import get_module_logger from pyanaconda.core.configuration.anaconda import conf from pyanaconda.core.constants import DNF_DEFAULT_TIMEOUT, DNF_DEFAULT_RETRIES, URL_TYPE_BASEURL, \ URL_TYPE_MIRRORLIST, URL_TYPE_METALINK, DNF_DEFAULT_REPO_COST from pyanaconda.core.i18n import _ from pyanaconda.core.payload import ProxyString, ProxyStringError from pyanaconda.core.util import get_os_release_value from pyanaconda.modules.common.errors.installation import PayloadInstallationError from pyanaconda.modules.common.errors.payload import UnknownCompsEnvironmentError, \ UnknownCompsGroupError, UnknownRepositoryError from pyanaconda.modules.common.structures.comps import CompsEnvironmentData, CompsGroupData from pyanaconda.modules.common.structures.packages import PackagesConfigurationData from pyanaconda.modules.common.structures.payload import RepoConfigurationData from pyanaconda.modules.payloads.constants import DNF_REPO_DIRS from pyanaconda.modules.payloads.payload.dnf.download_progress import DownloadProgress from pyanaconda.modules.payloads.payload.dnf.transaction_progress import TransactionProgress, \ process_transaction_progress from pyanaconda.modules.payloads.payload.dnf.utils import get_product_release_version, \ calculate_hash, transaction_has_errors log = get_module_logger(__name__) DNF_CACHE_DIR = '/tmp/dnf.cache' DNF_PLUGINCONF_DIR = '/tmp/dnf.pluginconf' # Bonus to required free space which depends on block size and # rpm database size estimation. Every file could be aligned to # fragment size so 4KiB * number_of_files should be a worst case # scenario. 2KiB for RPM DB was acquired by testing. # # 4KiB = max default fragment size # 2KiB = RPM DB could be taken for a header file # 6KiB = 4KiB + 2KiB # DNF_EXTRA_SIZE_PER_FILE = Size("6 KiB") class DNFManagerError(Exception): """General error for the DNF manager.""" class MetadataError(DNFManagerError): """Metadata couldn't be loaded.""" class MissingSpecsError(DNFManagerError): """Some packages, groups or modules are missing.""" class BrokenSpecsError(DNFManagerError): """Some packages, groups or modules are broken.""" class InvalidSelectionError(DNFManagerError): """The software selection couldn't be resolved.""" class DNFManager(object): """The abstraction of the DNF base.""" def __init__(self): self.__base = None # Protect access to _base.repos to ensure that the dictionary is not # modified while another thread is attempting to iterate over it. The # lock only needs to be held during operations that change the number # of repos or that iterate over the repos. self._lock = threading.RLock() self._ignore_missing_packages = False self._ignore_broken_packages = False self._download_location = None self._md_hashes = {} self._enabled_system_repositories = [] @property def _base(self): """The DNF base.""" if self.__base is None: self.__base = self._create_base() return self.__base @classmethod def _create_base(cls): """Create a new DNF base.""" base = dnf.Base() base.conf.read() base.conf.cachedir = DNF_CACHE_DIR base.conf.pluginconfpath = DNF_PLUGINCONF_DIR base.conf.logdir = '/tmp/' # Set installer defaults base.conf.gpgcheck = False base.conf.skip_if_unavailable = False # Set the default release version. base.conf.releasever = get_product_release_version() # Load variables from the host (rhbz#1920735). base.conf.substitutions.update_from_etc("/") # Set the installation root. base.conf.installroot = conf.target.system_root base.conf.prepend_installroot('persistdir') # Set the platform id based on the /os/release present # in the installation environment. platform_id = get_os_release_value("PLATFORM_ID") if platform_id is not None: base.conf.module_platform_id = platform_id # Start with an empty comps so we can go ahead and use # the environment and group properties. Unset reposdir # to ensure dnf has nothing it can check automatically. base.conf.reposdir = [] base.read_comps(arch_filter=True) base.conf.reposdir = DNF_REPO_DIRS log.debug("The DNF base has been created.") return base def reset_base(self): """Reset the DNF base. * Close the current DNF base if any. * Reset all attributes of the DNF manager. * The new DNF base will be created on demand. """ base = self.__base self.__base = None if base is not None: base.close() self._ignore_missing_packages = False self._ignore_broken_packages = False self._download_location = None self._md_hashes = {} self._enabled_system_repositories = [] log.debug("The DNF base has been reset.") def configure_base(self, data: PackagesConfigurationData): """Configure the DNF base. :param data: a packages configuration data """ base = self._base base.conf.multilib_policy = data.multilib_policy if data.timeout != DNF_DEFAULT_TIMEOUT: base.conf.timeout = data.timeout if data.retries != DNF_DEFAULT_RETRIES: base.conf.retries = data.retries self._ignore_missing_packages = data.missing_ignored self._ignore_broken_packages = data.broken_ignored if self._ignore_broken_packages: log.warning( "\n***********************************************\n" "User has requested to skip broken packages. Using " "this option may result in an UNUSABLE system! " "\n***********************************************\n" ) # Two reasons to turn this off: # 1. Minimal installs don't want all the extras this brings in. # 2. Installs aren't reproducible due to weak deps. failing silently. base.conf.install_weak_deps = not data.weakdeps_excluded @property def default_environment(self): """Default environment. :return: an identifier of an environment or None """ environments = self.environments if conf.payload.default_environment in environments: return conf.payload.default_environment if environments: return environments[0] return None @property def environments(self): """Environments defined in comps.xml file. :return: a list of ids """ return [env.id for env in self._base.comps.environments] def _get_environment(self, environment_name): """Translate the given environment name to a DNF object. :param environment_name: an identifier of an environment :return: a DNF object or None """ if not environment_name: return None return self._base.comps.environment_by_pattern(environment_name) def resolve_environment(self, environment_name): """Translate the given environment name to a group ID. :param environment_name: an identifier of an environment :return: a string with the environment ID or None """ env = self._get_environment(environment_name) if not env: return None return env.id def get_environment_data(self, environment_name) -> CompsEnvironmentData: """Get the data of the specified environment. :param environment_name: an identifier of an environment :return: an instance of CompsEnvironmentData :raise: UnknownCompsEnvironmentError if no environment is found """ env = self._get_environment(environment_name) if not env: raise UnknownCompsEnvironmentError(environment_name) return self._get_environment_data(env) def _get_environment_data(self, env) -> CompsEnvironmentData: """Get the environment data. :param env: a DNF representation of the environment :return: an instance of CompsEnvironmentData """ data = CompsEnvironmentData() data.id = env.id or "" data.name = env.ui_name or "" data.description = env.ui_description or "" optional = {i.name for i in env.option_ids} default = {i.name for i in env.option_ids if i.default} for grp in self._base.comps.groups: if grp.id in optional: data.optional_groups.append(grp.id) if grp.visible: data.visible_groups.append(grp.id) if grp.id in default: data.default_groups.append(grp.id) return data @property def groups(self): """Groups defined in comps.xml file. :return: a list of IDs """ return [g.id for g in self._base.comps.groups] def _get_group(self, group_name): """Translate the given group name into a DNF object. :param group_name: an identifier of a group :return: a DNF object or None """ return self._base.comps.group_by_pattern(group_name) def resolve_group(self, group_name): """Translate the given group name into a group ID. :param group_name: an identifier of a group :return: a string with the group ID or None """ grp = self._get_group(group_name) if not grp: return None return grp.id def get_group_data(self, group_name) -> CompsGroupData: """Get the data of the specified group. :param group_name: an identifier of a group :return: an instance of CompsGroupData :raise: UnknownCompsGroupError if no group is found """ grp = self._get_group(group_name) if not grp: raise UnknownCompsGroupError(group_name) return self._get_group_data(grp) @staticmethod def _get_group_data(grp) -> CompsGroupData: """Get the group data. :param grp: a DNF representation of the group :return: an instance of CompsGroupData """ data = CompsGroupData() data.id = grp.id or "" data.name = grp.ui_name or "" data.description = grp.ui_description or "" return data def configure_proxy(self, url): """Configure the proxy of the DNF base. :param url: a proxy URL or None """ base = self._base # Reset the proxy configuration. base.conf.proxy = "" base.conf.proxy_username = "" base.conf.proxy_password = "" # Parse the given URL. proxy = self._parse_proxy(url) if not proxy: return # Set the proxy configuration. log.info("Using '%s' as a proxy.", url) base.conf.proxy = proxy.noauth_url base.conf.proxy_username = proxy.username or "" base.conf.proxy_password = proxy.password or "" def _parse_proxy(self, url): """Parse the given proxy URL. :param url: a string with the proxy URL :return: an instance of ProxyString or None """ if not url: return None try: return ProxyString(url) except ProxyStringError as e: log.error("Failed to parse the proxy '%s': %s", url, e) return None def dump_configuration(self): """Log the state of the DNF configuration.""" log.debug( "DNF configuration:" "\n%s" "\nsubstitutions = %s", self._base.conf.dump().strip(), self._base.conf.substitutions ) def substitute(self, text): """Replace variables with their values. Currently supports $releasever and $basearch. :param str text: a string to do replacement on :return str: a string with substituted variables """ if not text: return "" return libdnf.conf.ConfigParser.substitute( text, self._base.conf.substitutions ) def configure_substitution(self, release_version): """Set up the substitution variables. :param release_version: a string for $releasever """ if not release_version: return self._base.conf.releasever = release_version log.debug("The $releasever variable is set to '%s'.", release_version) def get_installation_size(self): """Calculate the installation size. :return: a space required by packages :rtype: an instance of Size """ packages_size = Size(0) files_number = 0 if self._base.transaction is None: return Size("3000 MiB") for tsi in self._base.transaction: # Space taken by all files installed by the packages. packages_size += tsi.pkg.installsize # Number of files installed on the system. files_number += len(tsi.pkg.files) # Calculate the files size depending on number of files. files_size = Size(files_number * DNF_EXTRA_SIZE_PER_FILE) # Get the total size. Add another 10% as safeguard. total_space = Size((packages_size + files_size) * 1.1) log.info("Total install size: %s", total_space) return total_space def get_download_size(self): """Calculate the download size. :return: a space required for packages :rtype: an instance of Size """ if self._base.transaction is None: return Size(0) download_size = Size(0) # Calculate the download size. for tsi in self._base.transaction: download_size += tsi.pkg.downloadsize # Get the total size. Reserve extra space. total_space = download_size + Size("150 MiB") log.info("Total download size: %s", total_space) return total_space def clear_cache(self): """Clear the DNF cache.""" self._enabled_system_repositories = [] shutil.rmtree(DNF_CACHE_DIR, ignore_errors=True) shutil.rmtree(DNF_PLUGINCONF_DIR, ignore_errors=True) self._base.reset(sack=True, repos=True, goal=True) log.debug("The DNF cache has been cleared.") def is_package_available(self, package_spec): """Is the specified package available for the installation? :param package_spec: a package spec :return: True if the package can be installed, otherwise False """ if not self._base.sack: log.warning("There is no metadata about packages!") return False subject = dnf.subject.Subject(package_spec) return bool(subject.get_best_query(self._base.sack)) def match_available_packages(self, pattern): """Find available packages that match the specified pattern. :param pattern: a pattern for package names :return: a list of matched package names """ if not self._base.sack: log.warning("There is no metadata about packages!") return [] packages = self._base.sack.query().available().filter(name__glob=pattern) return [p.name for p in packages] def enable_modules(self, module_specs): """Mark module streams for enabling. Mark module streams matching the module_specs list and also all required modular dependencies for enabling. For specs that do not specify the stream, the default stream is used. :param module_specs: a list of specs :raise MissingSpecsError: if there are missing specs :raise BrokenSpecsError: if there are broken specs """ log.debug("Enabling modules: %s", module_specs) try: module_base = dnf.module.module_base.ModuleBase(self._base) module_base.enable(module_specs) except dnf.exceptions.MarkingErrors as e: log.error("Failed to enable modules!\n%s", str(e)) self._handle_marking_errors(e) def disable_modules(self, module_specs): """Mark modules for disabling. Mark modules matching the module_specs list for disabling. Only the name part of the module specification is relevant. :param module_specs: a list of specs to disable :raise MissingSpecsError: if there are missing specs :raise BrokenSpecsError: if there are broken specs """ log.debug("Disabling modules: %s", module_specs) try: module_base = dnf.module.module_base.ModuleBase(self._base) module_base.disable(module_specs) except dnf.exceptions.MarkingErrors as e: log.error("Failed to disable modules!\n%s", str(e)) self._handle_marking_errors(e) def apply_specs(self, include_list, exclude_list): """Mark packages, groups and modules for installation. :param include_list: a list of specs for inclusion :param exclude_list: a list of specs for exclusion :raise MissingSpecsError: if there are missing specs :raise BrokenSpecsError: if there are broken specs """ log.info("Including specs: %s", include_list) log.info("Excluding specs: %s", exclude_list) try: self._base.install_specs( install=include_list, exclude=exclude_list, strict=not self._ignore_broken_packages ) except dnf.exceptions.MarkingErrors as e: log.error("Failed to apply specs!\n%s", str(e)) self._handle_marking_errors(e, self._ignore_missing_packages) def _handle_marking_errors(self, exception, ignore_missing_packages=False): """Handle the dnf.exceptions.MarkingErrors exception. :param exception: a exception :param ignore_missing_packages: can missing specs be ignored? :raise MissingSpecsError: if there are missing specs :raise BrokenSpecsError: if there are broken specs """ # There are only some missing specs. They can be ignored. if self._is_missing_specs_error(exception): if ignore_missing_packages: log.info("Ignoring missing packages, groups or modules.") return message = _("Some packages, groups or modules are missing.") raise MissingSpecsError(message + "\n\n" + str(exception).strip()) from None # There are some broken specs. Raise an exception. message = _("Some packages, groups or modules are broken.") raise BrokenSpecsError(message + "\n\n" + str(exception).strip()) from None def _is_missing_specs_error(self, exception): """Is it a missing specs error? :param exception: an exception :return: True or False """ return isinstance(exception, dnf.exceptions.MarkingErrors) \ and not exception.error_group_specs \ and not exception.error_pkg_specs \ and not exception.module_depsolv_errors def resolve_selection(self): """Resolve the software selection. :raise InvalidSelectionError: if the selection cannot be resolved """ log.debug("Resolving the software selection.",) try: self._base.resolve() except dnf.exceptions.DepsolveError as e: log.error("The software couldn't be resolved!\n%s", str(e)) message = _( "The following software marked for installation has errors.\n" "This is likely caused by an error with your installation source." ) raise InvalidSelectionError(message + "\n\n" + str(e).strip()) from None log.info("The software selection has been resolved (%d packages selected).", len(self._base.transaction)) def clear_selection(self): """Clear the software selection.""" self._base.reset(goal=True) log.debug("The software selection has been cleared.") @property def download_location(self): """The location for the package download.""" return self._download_location def set_download_location(self, path): """Set up the location for downloading the packages. :param path: a path to the package directory """ for repo in self._base.repos.iter_enabled(): repo.pkgdir = path self._download_location = path def download_packages(self, callback): """Download the packages. :param callback: a callback for progress reporting :raise PayloadInstallationError: if the download fails """ packages = self._base.transaction.install_set # pylint: disable=no-member progress = DownloadProgress(callback=callback) log.info("Downloading packages to %s.", self.download_location) try: self._base.download_packages(packages, progress) except dnf.exceptions.DownloadError as e: msg = "Failed to download the following packages: " + str(e) raise PayloadInstallationError(msg) from None def install_packages(self, callback, timeout=20): """Install the packages. Run the DNF transaction in a separate sub-process to isolate DNF and RPM from the installation process. See the bug 1614511. :param callback: a callback for progress reporting :param timeout: a time out of a failed process in seconds :raise PayloadInstallationError: if the installation fails """ queue = multiprocessing.Queue() display = TransactionProgress(queue) process = multiprocessing.Process( target=self._run_transaction, args=(self._base, display) ) # Start the transaction. log.debug("Starting the transaction process...") process.start() try: # Report the progress. process_transaction_progress(queue, callback) # Wait for the transaction to end. process.join() finally: # Kill the transaction after the timeout. process.join(timeout) process.kill() log.debug("The transaction process exited with %s.", process.exitcode) @staticmethod def _run_transaction(base, display): """Run the DNF transaction. Execute the DNF transaction and catch any errors. :param base: the DNF base :param display: the DNF progress-reporting object """ log.debug("Running the transaction...") try: base.do_transaction(display) if transaction_has_errors(base.transaction): display.error("The transaction process has ended with errors.") except BaseException as e: # pylint: disable=broad-except display.error("The transaction process has ended abruptly: {}\n{}".format( str(e), traceback.format_exc())) finally: log.debug("The transaction has ended.") base.close() # Always close this base. display.quit("DNF quit") @property def repositories(self): """Available repositories. :return: a list of IDs """ with self._lock: return [r.id for r in self._base.repos.values()] @property def enabled_repositories(self): """Enabled repositories. :return: a list of IDs """ with self._lock: return [r.id for r in self._base.repos.iter_enabled()] def get_matching_repositories(self, pattern): """Get a list of repositories that match the specified pattern. The pattern can contain Unix shell-style wildcards. See: https://docs.python.org/3/library/fnmatch.html :param pattern: a pattern for matching the repo IDs :return: a list of matching IDs """ with self._lock: return [r.id for r in self._base.repos.get_matching(pattern)] def _get_repository(self, repo_id): """Translate the given repository name to a DNF object. :param repo_id: an identifier of a repository :return: a DNF object :raise: UnknownRepositoryError if no repo is found """ repo = self._base.repos.get(repo_id) if not repo: raise UnknownRepositoryError(repo_id) return repo def add_repository(self, data: RepoConfigurationData): """Add a repository. If the repository already exists, replace it with a new one. :param RepoConfigurationData data: a repo configuration """ # Create a new repository. repo = self._create_repository(data) with self._lock: # Remove an existing repository. if repo.id in self._base.repos: self._base.repos.pop(repo.id) # Add the new repository. self._base.repos.add(repo) log.info("Added the '%s' repository: %s", repo.id, repo) def _create_repository(self, data: RepoConfigurationData): """Create a DNF repository. :param RepoConfigurationData data: a repo configuration return dnf.repo.Repo: a DNF repository """ repo = dnf.repo.Repo(data.name, self._base.conf) # Disable the repo if requested. if not data.enabled: repo.disable() # Set up the repo location. url = self.substitute(data.url) if data.type == URL_TYPE_BASEURL: repo.baseurl = [url] if data.type == URL_TYPE_MIRRORLIST: repo.mirrorlist = url if data.type == URL_TYPE_METALINK: repo.metalink = url # Set the proxy configuration. proxy = self._parse_proxy(data.proxy) if proxy: repo.proxy = proxy.noauth_url repo.proxy_username = proxy.username or "" repo.proxy_password = proxy.password or "" # Set the repo configuration. if data.cost != DNF_DEFAULT_REPO_COST: repo.cost = data.cost if data.included_packages: repo.includepkgs = data.included_packages if data.excluded_packages: repo.excludepkgs = data.excluded_packages # Set up the SSL configuration. repo.sslverify = conf.payload.verify_ssl and data.ssl_verification_enabled if data.ssl_configuration.ca_cert_path: repo.sslcacert = data.ssl_configuration.ca_cert_path if data.ssl_configuration.client_cert_path: repo.sslclientcert = data.ssl_configuration.client_cert_path if data.ssl_configuration.client_key_path: repo.sslclientkey = data.ssl_configuration.client_key_path return repo def generate_repo_file(self, data: RepoConfigurationData): """Generate a content of the .repo file. The content is generated only from the provided data. We don't check the configuration of the DNF objects. :param RepoConfigurationData data: a repo configuration return str: a content of a .repo file """ lines = [ "[{}]".format(data.name), "name = {}".format(data.name), ] if data.enabled: lines.append("enabled = 1") else: lines.append("enabled = 0") # Set up the repo location. if data.type == URL_TYPE_BASEURL: lines.append("baseurl = {}".format(data.url)) if data.type == URL_TYPE_MIRRORLIST: lines.append("mirrorlist = {}".format(data.url)) if data.type == URL_TYPE_METALINK: lines.append("metalink = {}".format(data.url)) if not data.ssl_verification_enabled: lines.append("sslverify = 0") # Set the proxy configuration. proxy = self._parse_proxy(data.proxy) if proxy: lines.append("proxy = {}".format(proxy.noauth_url)) if proxy and proxy.username: lines.append("proxy_username = {}".format(proxy.username)) if proxy and proxy.password: lines.append("proxy_password = {}".format(proxy.password)) # Set the repo configuration. if data.cost != DNF_DEFAULT_REPO_COST: lines.append("cost = {}".format(data.cost)) if data.included_packages: lines.append("includepkgs = {}".format(", ".join(data.included_packages))) if data.excluded_packages: lines.append("excludepkgs = {}".format(", ".join(data.excluded_packages))) return "\n".join(lines) def set_repository_enabled(self, repo_id, enabled): """Enable or disable the specified repository. :param repo_id: an identifier of a repository :param enabled: True to enable, False to disable :raise: UnknownRepositoryError if no repo is found """ repo = self._get_repository(repo_id) # Skip if the repository is already set to the right value. if repo.enabled == enabled: return if enabled: repo.enable() log.info("The '%s' repository is enabled.", repo_id) else: repo.disable() log.info("The '%s' repository is disabled.", repo_id) def read_system_repositories(self): """Read the system repositories. Read all repositories from the installation environment. Make a note of which are enabled, and then disable them all. Disabled system repositories can be restored later with restore_system_repositories. """ with self._lock: # Make sure that there are no repositories yet. Otherwise, # the code bellow will produce unexpected results. if self.repositories: raise RuntimeError("The DNF repo cache is not cleared.") log.debug("Read system repositories.") self._base.read_all_repos() # Remember enabled system repositories. self._enabled_system_repositories = list(self.enabled_repositories) log.debug("Disable system repositories.") self._base.repos.all().disable() def restore_system_repositories(self): """Restore the system repositories. Enable repositories from the installation environment that were disabled in read_system_repositories. """ log.debug("Restore system repositories.") for repo_id in self._enabled_system_repositories: try: self.set_repository_enabled(repo_id, True) except UnknownRepositoryError: log.debug("There is no '%s' repository to enable.", repo_id) def load_repository(self, repo_id): """Download repo metadata. If the repo is enabled, load its metadata to verify that the repo is valid. An invalid repo will be disabled. This method will by default not try to refresh already loaded data if called repeatedly. :param str repo_id: an identifier of a repository :raise: MetadataError if the metadata cannot be loaded """ log.debug("Load metadata for the '%s' repository.", repo_id) repo = self._get_repository(repo_id) url = repo.baseurl or repo.mirrorlist or repo.metalink if not repo.enabled: log.debug("Don't load metadata from a disabled repository.") return try: repo.load() except dnf.exceptions.RepoError as e: log.debug("Failed to load metadata from '%s': %s", url, str(e)) repo.disable() raise MetadataError(str(e)) from None log.info("Loaded metadata from '%s'.", url) def load_packages_metadata(self): """Load metadata about packages in available repositories. Load all enabled repositories and process their metadata. It will update the cache that provides information about available packages, modules, groups and environments. """ # Load all enabled repositories. # Set up the package sack. self._base.fill_sack( load_system_repo=False, load_available_repos=True, ) # Load the comps metadata. self._base.read_comps( arch_filter=True ) log.info("Loaded packages and group metadata.") def load_repomd_hashes(self): """Load a hash of the repomd.xml file for each enabled repository.""" self._md_hashes = self._get_repomd_hashes() def verify_repomd_hashes(self): """Verify a hash of the repomd.xml file for each enabled repository. This method tests if URL links from active repositories can be reached. It is useful when network settings are changed so that we can verify if repositories are still reachable. :return: True if files haven't changed, otherwise False """ return bool(self._md_hashes and self._md_hashes == self._get_repomd_hashes()) def _get_repomd_hashes(self): """Get a dictionary of repomd.xml hashes. :return: a dictionary of repo ids and repomd.xml hashes """ md_hashes = {} for repo in self._base.repos.iter_enabled(): content = self._get_repomd_content(repo) md_hash = calculate_hash(content) if content else None md_hashes[repo.id] = md_hash log.debug("Loaded repomd.xml hashes: %s", md_hashes) return md_hashes def _get_repomd_content(self, repo): """Get a content of a repomd.xml file. :param repo: a DNF repo :return: a content of the repomd.xml file """ for url in repo.baseurl: try: repomd_url = "{}/repodata/repomd.xml".format(url) with self._base.urlopen(repomd_url, repo=repo, mode="w+t") as f: return f.read() except OSError as e: log.debug("Can't download repomd.xml from: %s", str(e)) continue return ""