429 lines
14 KiB
Python
429 lines
14 KiB
Python
|
#
|
||
|
# 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 fnmatch
|
||
|
import hashlib
|
||
|
import os
|
||
|
import rpm
|
||
|
from libdnf.transaction import TransactionItemState_ERROR
|
||
|
|
||
|
from blivet.size import Size
|
||
|
|
||
|
from pyanaconda.anaconda_loggers import get_module_logger
|
||
|
from pyanaconda.core.configuration.anaconda import conf
|
||
|
from pyanaconda.core.payload import parse_hdd_url
|
||
|
from pyanaconda.core.regexes import VERSION_DIGITS
|
||
|
from pyanaconda.core.util import execWithCapture
|
||
|
from pyanaconda.core.hw import is_lpae_available
|
||
|
from pyanaconda.core.path import join_paths
|
||
|
from pyanaconda.modules.common.constants.objects import DEVICE_TREE, DISK_SELECTION
|
||
|
from pyanaconda.modules.common.constants.services import STORAGE
|
||
|
from pyanaconda.modules.common.structures.packages import PackagesSelectionData
|
||
|
from pyanaconda.modules.payloads.constants import SourceType
|
||
|
from pyanaconda.core.product import get_product_name, get_product_version
|
||
|
from pyanaconda.modules.payloads.base.utils import sort_kernel_version_list
|
||
|
|
||
|
log = get_module_logger(__name__)
|
||
|
|
||
|
DNF_PACKAGE_CACHE_DIR_SUFFIX = 'dnf.package.cache'
|
||
|
|
||
|
|
||
|
def calculate_hash(data):
|
||
|
"""Calculate hash from the given data.
|
||
|
|
||
|
:return: a string with the hash
|
||
|
"""
|
||
|
m = hashlib.sha256()
|
||
|
m.update(data.encode('ascii', 'backslashreplace'))
|
||
|
return m.digest()
|
||
|
|
||
|
|
||
|
def get_kernel_package(dnf_manager, exclude_list):
|
||
|
"""Get an installable kernel package.
|
||
|
|
||
|
:param dnf_manager: a DNF manager
|
||
|
:param exclude_list: a list of excluded packages
|
||
|
:return: a package name or None
|
||
|
"""
|
||
|
if "kernel" in exclude_list:
|
||
|
return None
|
||
|
|
||
|
# Get the kernel packages.
|
||
|
kernels = ["kernel"]
|
||
|
|
||
|
# ARM systems use either the standard Multiplatform or LPAE platform.
|
||
|
if is_lpae_available():
|
||
|
kernels.insert(0, "kernel-lpae")
|
||
|
|
||
|
# Find an installable one.
|
||
|
for kernel_package in kernels:
|
||
|
if kernel_package in exclude_list:
|
||
|
continue
|
||
|
|
||
|
if not dnf_manager.is_package_available(kernel_package):
|
||
|
log.info("No such package: %s", kernel_package)
|
||
|
continue
|
||
|
|
||
|
return kernel_package
|
||
|
|
||
|
log.error("Failed to select a kernel from: %s", kernels)
|
||
|
return None
|
||
|
|
||
|
|
||
|
def get_product_release_version():
|
||
|
"""Get a release version of the product.
|
||
|
|
||
|
:return: a string with the release version
|
||
|
"""
|
||
|
try:
|
||
|
release_version = VERSION_DIGITS.match(get_product_version()).group(1)
|
||
|
except AttributeError:
|
||
|
release_version = "rawhide"
|
||
|
|
||
|
log.debug("Release version of %s is %s.", get_product_name(), release_version)
|
||
|
return release_version
|
||
|
|
||
|
|
||
|
def get_installation_specs(data: PackagesSelectionData, default_environment=None):
|
||
|
"""Get specifications of packages, groups and modules for installation.
|
||
|
|
||
|
:param data: a packages selection data
|
||
|
:param default_environment: a default environment to install
|
||
|
:return: a tuple of specification lists for inclusion and exclusion
|
||
|
"""
|
||
|
# Note about package/group/module spec formatting:
|
||
|
# - leading @ signifies a group or module
|
||
|
# - no leading @ means a package
|
||
|
include_list = []
|
||
|
exclude_list = []
|
||
|
|
||
|
# Handle the environment.
|
||
|
if data.default_environment_enabled and default_environment:
|
||
|
log.info("Selecting default environment '%s'.", default_environment)
|
||
|
include_list.append("@{}".format(default_environment))
|
||
|
elif data.environment:
|
||
|
include_list.append("@{}".format(data.environment))
|
||
|
|
||
|
# Handle the core group.
|
||
|
if not data.core_group_enabled:
|
||
|
log.info("Skipping @core group; system may not be complete.")
|
||
|
exclude_list.append("@core")
|
||
|
else:
|
||
|
include_list.append("@core")
|
||
|
|
||
|
# Handle groups.
|
||
|
for group_name in data.excluded_groups:
|
||
|
exclude_list.append("@{}".format(group_name))
|
||
|
|
||
|
for group_name in data.groups:
|
||
|
# Packages in groups can have different types
|
||
|
# and we provide an option to users to set
|
||
|
# which types are going to be installed.
|
||
|
if group_name in data.groups_package_types:
|
||
|
type_list = data.groups_package_types[group_name]
|
||
|
group_spec = "@{group_name}/{types}".format(
|
||
|
group_name=group_name,
|
||
|
types=",".join(type_list)
|
||
|
)
|
||
|
else:
|
||
|
# If group is a regular group this is equal to
|
||
|
# @group/mandatory,default,conditional (current
|
||
|
# content of the DNF GROUP_PACKAGE_TYPES constant).
|
||
|
group_spec = "@{}".format(group_name)
|
||
|
|
||
|
include_list.append(group_spec)
|
||
|
|
||
|
# Handle packages.
|
||
|
for pkg_name in data.excluded_packages:
|
||
|
exclude_list.append(pkg_name)
|
||
|
|
||
|
for pkg_name in data.packages:
|
||
|
include_list.append(pkg_name)
|
||
|
|
||
|
return include_list, exclude_list
|
||
|
|
||
|
|
||
|
def get_kernel_version_list():
|
||
|
"""Get a list of installed kernel versions.
|
||
|
|
||
|
:return: a list of kernel versions
|
||
|
"""
|
||
|
files = []
|
||
|
efi_dir = conf.bootloader.efi_dir
|
||
|
|
||
|
# Find all installed RPMs that provide 'kernel'.
|
||
|
ts = rpm.TransactionSet(conf.target.system_root)
|
||
|
mi = ts.dbMatch('providename', 'kernel')
|
||
|
|
||
|
for hdr in mi:
|
||
|
# Find all /boot/vmlinuz- files and strip off vmlinuz-.
|
||
|
files.extend((
|
||
|
f.split("/")[-1][8:] for f in hdr.filenames
|
||
|
if fnmatch.fnmatch(f, "/boot/vmlinuz-*") or
|
||
|
fnmatch.fnmatch(f, "/boot/efi/EFI/%s/vmlinuz-*" % efi_dir)
|
||
|
))
|
||
|
|
||
|
# Sort the kernel versions.
|
||
|
sort_kernel_version_list(files)
|
||
|
|
||
|
return files
|
||
|
|
||
|
|
||
|
def get_free_space_map(current=True, scheduled=False):
|
||
|
"""Get the available file system disk space.
|
||
|
|
||
|
:param bool current: use information about current mount points
|
||
|
:param bool scheduled: use information about scheduled mount points
|
||
|
:return: a dictionary of mount points and their available space
|
||
|
"""
|
||
|
mount_points = {}
|
||
|
|
||
|
if scheduled:
|
||
|
mount_points.update(_get_scheduled_free_space_map())
|
||
|
|
||
|
if current:
|
||
|
mount_points.update(_get_current_free_space_map())
|
||
|
|
||
|
return mount_points
|
||
|
|
||
|
|
||
|
def _get_current_free_space_map():
|
||
|
"""Get the available file system disk space of the current system.
|
||
|
|
||
|
:return: a dictionary of mount points and their available space
|
||
|
"""
|
||
|
mapping = {}
|
||
|
|
||
|
# Generate the dictionary of mount points and sizes.
|
||
|
output = execWithCapture('df', ['--output=target,avail'])
|
||
|
lines = output.rstrip().splitlines()
|
||
|
|
||
|
for line in lines:
|
||
|
key, val = line.rsplit(maxsplit=1)
|
||
|
|
||
|
if not key.startswith('/'):
|
||
|
continue
|
||
|
|
||
|
mapping[key] = Size(int(val) * 1024)
|
||
|
|
||
|
# Add /var/tmp/ if this is a directory or image installation.
|
||
|
if not conf.target.is_hardware:
|
||
|
var_tmp = os.statvfs("/var/tmp")
|
||
|
mapping["/var/tmp"] = Size(var_tmp.f_frsize * var_tmp.f_bfree)
|
||
|
|
||
|
return mapping
|
||
|
|
||
|
|
||
|
def _get_scheduled_free_space_map():
|
||
|
"""Get the available file system disk space of the scheduled system.
|
||
|
|
||
|
:return: a dictionary of mount points and their available space
|
||
|
"""
|
||
|
device_tree = STORAGE.get_proxy(DEVICE_TREE)
|
||
|
mount_points = {}
|
||
|
|
||
|
for mount_point in device_tree.GetMountPoints():
|
||
|
# we can ignore swap
|
||
|
if not mount_point.startswith('/'):
|
||
|
continue
|
||
|
|
||
|
free_space = Size(
|
||
|
device_tree.GetFileSystemFreeSpace([mount_point])
|
||
|
)
|
||
|
mount_point = os.path.normpath(
|
||
|
conf.target.system_root + mount_point
|
||
|
)
|
||
|
mount_points[mount_point] = free_space
|
||
|
|
||
|
return mount_points
|
||
|
|
||
|
|
||
|
def _pick_mount_points(mount_points, download_size, install_size):
|
||
|
"""Pick mount points for the package installation.
|
||
|
|
||
|
:return: a set of sufficient mount points
|
||
|
"""
|
||
|
suitable = {
|
||
|
'/var/tmp',
|
||
|
conf.target.system_root,
|
||
|
join_paths(conf.target.system_root, 'home'),
|
||
|
join_paths(conf.target.system_root, 'tmp'),
|
||
|
join_paths(conf.target.system_root, 'var'),
|
||
|
}
|
||
|
|
||
|
sufficient = set()
|
||
|
|
||
|
for mount_point, size in mount_points.items():
|
||
|
# Ignore mount points that are not suitable.
|
||
|
if mount_point not in suitable:
|
||
|
continue
|
||
|
|
||
|
if size >= (download_size + install_size):
|
||
|
log.debug("Considering %s (%s) for download and install.", mount_point, size)
|
||
|
sufficient.add(mount_point)
|
||
|
|
||
|
elif size >= download_size and not mount_point.startswith(conf.target.system_root):
|
||
|
log.debug("Considering %s (%s) for download.", mount_point, size)
|
||
|
sufficient.add(mount_point)
|
||
|
|
||
|
return sufficient
|
||
|
|
||
|
|
||
|
def _get_biggest_mount_point(mount_points, sufficient):
|
||
|
"""Get the biggest sufficient mount point.
|
||
|
|
||
|
:return: a mount point or None
|
||
|
"""
|
||
|
return max(sufficient, default=None, key=mount_points.get)
|
||
|
|
||
|
|
||
|
def pick_download_location(dnf_manager):
|
||
|
"""Pick the download location.
|
||
|
|
||
|
:param dnf_manager: the DNF manager
|
||
|
:return: a path to the download location
|
||
|
"""
|
||
|
download_size = dnf_manager.get_download_size()
|
||
|
install_size = dnf_manager.get_installation_size()
|
||
|
mount_points = get_free_space_map()
|
||
|
|
||
|
# Try to find mount points that are sufficient for download and install.
|
||
|
sufficient = _pick_mount_points(
|
||
|
mount_points,
|
||
|
download_size,
|
||
|
install_size
|
||
|
)
|
||
|
|
||
|
# Or find mount points that are sufficient only for download.
|
||
|
if not sufficient:
|
||
|
sufficient = _pick_mount_points(
|
||
|
mount_points,
|
||
|
download_size,
|
||
|
install_size=0
|
||
|
)
|
||
|
|
||
|
# Ignore the system root if there are other mount points.
|
||
|
if len(sufficient) > 1:
|
||
|
sufficient.discard(conf.target.system_root)
|
||
|
|
||
|
if not sufficient:
|
||
|
raise RuntimeError(
|
||
|
"Not enough disk space to download the "
|
||
|
"packages; size {}.".format(download_size)
|
||
|
)
|
||
|
|
||
|
# Choose the biggest sufficient mount point.
|
||
|
mount_point = _get_biggest_mount_point(mount_points, sufficient)
|
||
|
|
||
|
log.info("Mount point %s picked as download location", mount_point)
|
||
|
location = join_paths(mount_point, DNF_PACKAGE_CACHE_DIR_SUFFIX)
|
||
|
|
||
|
return location
|
||
|
|
||
|
|
||
|
def calculate_required_space(dnf_manager):
|
||
|
"""Calculate the space required for the installation.
|
||
|
|
||
|
:param DNFManager dnf_manager: the DNF manager
|
||
|
:return Size: the required space
|
||
|
"""
|
||
|
installation_size = dnf_manager.get_installation_size()
|
||
|
download_size = dnf_manager.get_download_size()
|
||
|
mount_points = get_free_space_map(scheduled=True)
|
||
|
|
||
|
# Find sufficient mount points.
|
||
|
sufficient = _pick_mount_points(
|
||
|
mount_points,
|
||
|
download_size,
|
||
|
installation_size
|
||
|
)
|
||
|
|
||
|
# Choose the biggest sufficient mount point.
|
||
|
mount_point = _get_biggest_mount_point(mount_points, sufficient)
|
||
|
|
||
|
if not mount_point or mount_point.startswith(conf.target.system_root):
|
||
|
log.debug("The install and download space is required.")
|
||
|
required_space = installation_size + download_size
|
||
|
else:
|
||
|
log.debug("Use the %s mount point for the %s download.", mount_point, download_size)
|
||
|
log.debug("Only the install space is required.")
|
||
|
required_space = installation_size
|
||
|
|
||
|
log.debug("The package installation requires %s.", required_space)
|
||
|
return required_space
|
||
|
|
||
|
|
||
|
def collect_installation_devices(sources, repositories):
|
||
|
"""Collect devices of installation sources.
|
||
|
|
||
|
:return: a list of device specifications
|
||
|
"""
|
||
|
devices = set()
|
||
|
|
||
|
configurations = [
|
||
|
s.configuration
|
||
|
for s in sources
|
||
|
if s.type == SourceType.HDD
|
||
|
]
|
||
|
|
||
|
for repository in configurations + repositories:
|
||
|
if repository.url.startswith("hd:"):
|
||
|
device, _path = parse_hdd_url(repository.url)
|
||
|
devices.add(device)
|
||
|
|
||
|
return devices
|
||
|
|
||
|
|
||
|
def protect_installation_devices(previous_devices, current_devices):
|
||
|
"""Protect installation devices.
|
||
|
|
||
|
:param previous_devices: a list of device specifications
|
||
|
:param current_devices: a list of device specifications
|
||
|
"""
|
||
|
# Nothing has changed.
|
||
|
if previous_devices == current_devices:
|
||
|
return
|
||
|
|
||
|
disk_selection_proxy = STORAGE.get_proxy(DISK_SELECTION)
|
||
|
protected_devices = disk_selection_proxy.ProtectedDevices
|
||
|
|
||
|
# Remove previous devices from the list.
|
||
|
for spec in previous_devices:
|
||
|
if spec in protected_devices:
|
||
|
protected_devices.remove(spec)
|
||
|
|
||
|
# Add current devices from the list.
|
||
|
for spec in sorted(current_devices):
|
||
|
if spec not in protected_devices:
|
||
|
protected_devices.append(spec)
|
||
|
|
||
|
disk_selection_proxy.ProtectedDevices = protected_devices
|
||
|
|
||
|
|
||
|
def transaction_has_errors(transaction):
|
||
|
"""Detect if finished DNF transaction has any errors.
|
||
|
|
||
|
:param transaction: the DNF transaction
|
||
|
:return: True if the transaction has any error, otherwise False
|
||
|
"""
|
||
|
has_errors = False
|
||
|
for tsi in transaction:
|
||
|
if tsi.state == TransactionItemState_ERROR:
|
||
|
log.error("The transaction contains item %s in error state.", tsi)
|
||
|
has_errors = True
|
||
|
return has_errors
|