# # Copyright (C) 2021 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 configparser import copy import os import time from collections import namedtuple from functools import partial from productmd.treeinfo import TreeInfo from pyanaconda.modules.common.task import Task from requests import RequestException from pyanaconda.anaconda_loggers import get_module_logger from pyanaconda.core.configuration.anaconda import conf from pyanaconda.core.constants import URL_TYPE_BASEURL, NETWORK_CONNECTION_TIMEOUT, \ DEFAULT_REPOS, USER_AGENT, REPO_ORIGIN_TREEINFO from pyanaconda.core.path import join_paths from pyanaconda.core.payload import split_protocol, ProxyString, ProxyStringError from pyanaconda.core.util import requests_session, xprogressive_delay from pyanaconda.modules.common.structures.payload import RepoConfigurationData log = get_module_logger(__name__) __all__ = [ "TreeInfoMetadataError", "NoTreeInfoError", "InvalidTreeInfoError", "TreeInfoMetadata", "LoadTreeInfoMetadataResult", "LoadTreeInfoMetadataTask", ] def generate_treeinfo_repository(repo_data: RepoConfigurationData, repo_md): """Generate repositories from tree metadata of the specified repository. :param RepoConfigurationData repo_data: a repository with the .treeinfo file :param TreeInfoRepoMetadata repo_md: a metadata of a treeinfo repository :return RepoConfigurationData: a treeinfo repository """ repo = copy.deepcopy(repo_data) repo.origin = REPO_ORIGIN_TREEINFO repo.name = repo_md.name repo.type = URL_TYPE_BASEURL repo.url = repo_md.url repo.enabled = repo_md.enabled repo.installation_enabled = False return repo class TreeInfoMetadataError(Exception): """General error for the treeinfo metadata.""" pass class NoTreeInfoError(TreeInfoMetadataError): """There is no treeinfo metadata to use.""" pass class InvalidTreeInfoError(TreeInfoMetadataError): """The treeinfo metadata is invalid.""" pass class TreeInfoMetadata(object): """The representation of a .treeinfo file. The structure of the installation root can be similar to this: / - | - .treeinfo | - BaseRepo - | | - repodata | | - Packages | - AddonRepo - | - repodata | - Packages The .treeinfo file contains information where repositories are placed from the installation root. The provided URL of an installation source can be an installation tree root or a a subdirectory in the installation root. Both options are valid: * If the URL points to an installation root, we need to find paths to repositories in the .treeinfo file. * If URL points directly to a subdirectory, there is no .treeinfo file present. We will use the URL as a path to a repository. """ # Supported names of the treeinfo files. TREE_INFO_NAMES = [".treeinfo", "treeinfo"] # The number of download retries. MAX_TREEINFO_DOWNLOAD_RETRIES = 6 def __init__(self): """Create a new instance.""" self._release_version = "" self._repositories = [] def _reset(self): """Reset the metadata.""" self._release_version = "" self._repositories = [] @property def release_version(self): """Release version. :return: a release version as a lowercase string """ return self._release_version @property def repositories(self): """Repository metadata objects. :return: a list of TreeInfoRepoMetadata instances """ return self._repositories def load_file(self, path): """Loads installation tree metadata from the given path. :param str path: a path to the installation root :raise: NoTreeInfoError if there is no .treeinfo file """ self._reset() log.debug("Load treeinfo metadata for '%s'.", path) for name in self.TREE_INFO_NAMES: file_path = os.path.join(path, name) if os.access(file_path, os.R_OK): self._load_tree_info( root_url="file://" + path, file_path=file_path ) return raise NoTreeInfoError("No treeinfo metadata found.") def _load_tree_info(self, root_url, file_path=None, file_content=None): """Load the treeinfo metadata. :param root_url: a URL of the installation root :param file_path: a path to a treeinfo file or None :param file_content: a content of a treeinfo file or None :raise InvalidTreeInfoError: if the metadata is invalid """ try: # Load and validate the metadata. tree_info = TreeInfo() if file_content: tree_info.loads(file_content) else: tree_info.load(file_path) tree_info.validate() log.debug("Loaded treeinfo metadata:\n%s", tree_info.dumps()) # Load the release version. release_version = tree_info.release.version.lower() # Create repositories for variants and optional variants. # Child variants (like addons) will be ignored. repo_list = [] for name in tree_info.variants: log.debug("Processing the '%s' variant.", name) # Get the variant metadata. data = tree_info.variants[name] # Create the repo metadata. repo_md = TreeInfoRepoMetadata( repo_name=name, tree_info=data, root_url=root_url, ) repo_list.append(repo_md) except configparser.Error as e: log.debug("Failed to load treeinfo metadata: %s", e) raise InvalidTreeInfoError("Invalid metadata: {}".format(str(e))) from None # Update this treeinfo representation. self._repositories = repo_list self._release_version = release_version log.debug("The treeinfo metadata is loaded.") def load_data(self, data: RepoConfigurationData): """Loads installation tree metadata from the given data. param data: the repo configuration data :raise: NoTreeInfoError if there is no .treeinfo file """ self._reset() if data.type != URL_TYPE_BASEURL: raise NoTreeInfoError("Unsupported type of URL ({}).".format(data.type)) if not data.url: raise NoTreeInfoError("No URL specified.") # Download the metadata. log.debug("Load treeinfo metadata for '%s'.", data.url) with requests_session() as session: downloader = self._get_downloader(session, data) content = self._download_metadata(downloader, data.url) # Process the metadata. self._load_tree_info( root_url=data.url, file_content=content ) def _get_downloader(self, session, data): """Get a configured session.get method. :return: a partial function """ # Prepare the SSL configuration. ssl_enabled = conf.payload.verify_ssl and data.ssl_verification_enabled # ssl_verify can be: # - the path to a cert file # - True, to use the system's certificates # - False, to not verify ssl_verify = data.ssl_configuration.ca_cert_path or ssl_enabled # ssl_cert can be: # - a tuple of paths to a client cert file and a client key file # - None ssl_client_cert = data.ssl_configuration.client_cert_path or None ssl_client_key = data.ssl_configuration.client_key_path or None ssl_cert = (ssl_client_cert, ssl_client_key) if ssl_client_cert else None # Prepare the proxy configuration. proxy_url = data.proxy or None proxies = {} if proxy_url: try: proxy = ProxyString(proxy_url) proxies = { "http": proxy.url, "https": proxy.url, "ftp": proxy.url } except ProxyStringError as e: log.debug("Failed to parse the proxy '%s': %s", proxy_url, e) # Prepare headers. headers = {"user-agent": USER_AGENT} # Return a partial function. return partial( session.get, headers=headers, proxies=proxies, verify=ssl_verify, cert=ssl_cert, timeout=NETWORK_CONNECTION_TIMEOUT ) def _download_metadata(self, downloader, url): """Download metadata from the given URL.""" # How many times should we try the download? retry_max = self.MAX_TREEINFO_DOWNLOAD_RETRIES # Don't retry to download files that returned HTTP 404 code. not_found = set() # Retry treeinfo downloads with a progressively longer pause, # so NetworkManager have a chance setup a network and we have # full connectivity before trying to download things. (#1292613) xdelay = xprogressive_delay() for retry_count in range(0, retry_max): # Delay if we are retrying the download. if retry_count > 0: log.info("Retrying download (%d/%d)", retry_count, retry_max - 1) time.sleep(next(xdelay)) # Download the metadata file. for name in self.TREE_INFO_NAMES: file_url = "{}/{}".format(url, name) try: with downloader(file_url) as r: if r.status_code == 404: not_found.add(name) r.raise_for_status() return r.text except RequestException as e: log.debug("Failed to download '%s': %s", name, e) continue if not_found == set(self.TREE_INFO_NAMES): raise NoTreeInfoError("No treeinfo metadata found (404).") raise NoTreeInfoError("Couldn't download treeinfo metadata.") def verify_image_base_repo(self): """Verify the base repository of an ISO image. We only check whether the repodata directory of the base repo exists. That doesn't have to mean that the repo is valid. :return: True or False """ repo_md = self.get_base_repository() or self.get_root_repository() if not repo_md: log.debug("There is no usable repository available") return False if not repo_md.url.startswith("file://"): raise ValueError("Unexpected type of URL: {}".format(repo_md.url)) repo_path = repo_md.url.removeprefix("file://") data_path = os.path.join(repo_path, "repodata") if not os.access(data_path, os.R_OK): log.debug("There is no valid repository available.") return False return True def get_base_repository(self): """Return metadata of the base repository. :return: an instance of TreeInfoRepoMetadata or None """ for repo_md in self.repositories: if repo_md.name in DEFAULT_REPOS: return repo_md return None def get_root_repository(self): """Return metadata of the root repository. :return: an instance of TreeInfoRepoMetadata or None """ for repo_md in self.repositories: if repo_md.relative_path == ".": return repo_md return None class TreeInfoRepoMetadata(object): """Metadata repo object contains metadata about repository.""" def __init__(self, repo_name, tree_info, root_url): """Do not instantiate this class directly. :param repo_name: a name of the repository :param tree_info: a metadata of the repository :param root_url: a URL of the installation source """ self._name = repo_name self._type = tree_info.type self._relative_path = tree_info.paths.repository self._url = self._get_url( root_url=root_url, relative_path=self._relative_path ) @property def type(self): """Type of the repository.""" return self._type @property def name(self): """Name of the repository.""" return self._name @property def enabled(self): """Is the repository enabled? :return: True or False """ return self._type in conf.payload.enabled_repositories_from_treeinfo @property def relative_path(self): """Relative path of the repository. :return: a relative path """ return self._relative_path @property def url(self): """URL of the repository. :return: a URL """ return self._url @staticmethod def _get_url(root_url, relative_path): """Get the URL of the repository.""" if relative_path == ".": return root_url # Get the protocol. protocol, root_path = split_protocol(root_url) # Create the absolute path. absolute_path = join_paths(root_path, relative_path) # Normalize the URL to solve problems with a relative path. # This is especially useful for NFS (root/path/../new_path). return protocol + os.path.normpath(absolute_path) # The result of the LoadTreeInfoMetadataTask task. LoadTreeInfoMetadataResult = namedtuple( "LoadTreeInfoMetadataResult", [ "repository_data", "release_version", "treeinfo_repositories" ] ) class LoadTreeInfoMetadataTask(Task): """Task to process treeinfo metadata of an installation source.""" def __init__(self, data: RepoConfigurationData): """Create a task. :param RepoConfigurationData data: a repo configuration data """ super().__init__() self._repository_data = data @property def name(self): """The task name.""" return "Load treeinfo metadata" def run(self): """Run the task.""" log.debug("Reload treeinfo metadata.") try: return self._load_treeinfo_metadata() except NoTreeInfoError as e: log.debug("No treeinfo metadata to use: %s", str(e)) except TreeInfoMetadataError as e: log.warning("Couldn't use treeinfo metadata: %s", str(e)) return self._handle_no_treeinfo_metadata() def _load_treeinfo_metadata(self): """Load treeinfo metadata if available.""" # Load the treeinfo metadata. treeinfo_metadata = TreeInfoMetadata() treeinfo_metadata.load_data(self._repository_data) # Update the base repository. Use the base or root repository # from the treeinfo metadata if available. Otherwise, use the # original installation source. repository_md = treeinfo_metadata.get_base_repository() \ or treeinfo_metadata.get_root_repository() repository_data = self._generate_repository(repository_md) \ or self._repository_data # Generate the treeinfo repositories from the metadata. Skip # a repository that is used as a new base repository if any. treeinfo_repositories = [ self._generate_repository(m) for m in treeinfo_metadata.repositories if m is not repository_md ] # Get values of substitution variables. release_version = treeinfo_metadata.release_version or None # Return the results. return LoadTreeInfoMetadataResult( repository_data=repository_data, treeinfo_repositories=treeinfo_repositories, release_version=release_version, ) def _generate_repository(self, repo_md): """Generate a repository from q treeinfo metadata.""" if not repo_md: return None return generate_treeinfo_repository(self._repository_data, repo_md) def _handle_no_treeinfo_metadata(self): """The treeinfo metadata couldn't be loaded.""" return LoadTreeInfoMetadataResult( repository_data=None, treeinfo_repositories=[], release_version=None, )