anaconda/anaconda-40.22.3.13/pyanaconda/modules/payloads/payload/dnf/tree_info.py
2024-11-14 21:39:56 -08:00

538 lines
17 KiB
Python

#
# 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,
)