You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

540 lines
22 KiB

9 months ago
import contextlib
import itertools
import json
import os
import re
import shutil
from leapp.exceptions import StopActorExecutionError
from leapp.libraries.common import dnfconfig, guards, mounting, overlaygen, rhsm, utils
from leapp.libraries.common.config import get_env
from leapp.libraries.common.config.version import get_target_major_version, get_target_version
from leapp.libraries.stdlib import api, CalledProcessError, config
from leapp.models import DNFWorkaround
DNF_PLUGIN_NAME = 'rhel_upgrade.py'
_DEDICATED_URL = 'https://access.redhat.com/solutions/7011704'
class _DnfPluginPathStr(str):
_PATHS = {
"8": os.path.join('/lib/python3.6/site-packages/dnf-plugins', DNF_PLUGIN_NAME),
"9": os.path.join('/lib/python3.9/site-packages/dnf-plugins', DNF_PLUGIN_NAME),
}
def __init__(self): # noqa: W0231; pylint: disable=super-init-not-called
self.data = ""
def _feed(self):
major = get_target_major_version()
if major not in _DnfPluginPathStr._PATHS:
raise KeyError('{} is not a supported target version of RHEL'.format(major))
self.data = _DnfPluginPathStr._PATHS[major]
def __str__(self):
self._feed()
return str(self.data)
def __repr__(self):
self._feed()
return repr(self.data)
def lstrip(self, chars=None):
self._feed()
return self.data.lstrip(chars)
# Deprecated
DNF_PLUGIN_PATH = _DnfPluginPathStr()
DNF_PLUGIN_DATA_NAME = 'dnf-plugin-data.txt'
DNF_PLUGIN_DATA_PATH = os.path.join('/var/lib/leapp', DNF_PLUGIN_DATA_NAME)
DNF_PLUGIN_DATA_LOG_PATH = os.path.join('/var/log/leapp', DNF_PLUGIN_DATA_NAME)
DNF_DEBUG_DATA_PATH = '/var/log/leapp/dnf-debugdata/'
def install(target_basedir):
"""
Installs our plugin to the DNF plugins.
"""
try:
shutil.copy2(
api.get_file_path(DNF_PLUGIN_NAME),
os.path.join(target_basedir, DNF_PLUGIN_PATH.lstrip('/')))
except EnvironmentError as e:
api.current_logger().debug('Failed to install DNF plugin', exc_info=True)
raise StopActorExecutionError(
message='Failed to install DNF plugin. Error: {}'.format(str(e))
)
def _rebuild_rpm_db(context, root=None):
"""
Convert rpmdb from BerkeleyDB to Sqlite
"""
base_cmd = ['rpmdb', '--rebuilddb']
cmd = base_cmd if not root else base_cmd + ['-r', root]
context.call(cmd)
def _the_nogpgcheck_option_used():
return get_env('LEAPP_NOGPGCHECK', '0') == '1'
def build_plugin_data(target_repoids, debug, test, tasks, on_aws):
"""
Generates a dictionary with the DNF plugin data.
"""
# get list of repo IDs of target repositories that should be used for upgrade
data = {
'pkgs_info': {
'local_rpms': [os.path.join('/installroot', pkg.lstrip('/')) for pkg in tasks.local_rpms],
'to_install': tasks.to_install,
'to_remove': tasks.to_remove,
'to_upgrade': tasks.to_upgrade,
'modules_to_enable': ['{}:{}'.format(m.name, m.stream) for m in tasks.modules_to_enable],
},
'dnf_conf': {
'allow_erasing': True,
'best': True,
'debugsolver': debug,
'disable_repos': True,
'enable_repos': target_repoids,
'gpgcheck': not _the_nogpgcheck_option_used(),
'platform_id': 'platform:el{}'.format(get_target_major_version()),
'releasever': get_target_version(),
'installroot': '/installroot',
'test_flag': test
},
'rhui': {
'aws': {
'on_aws': on_aws,
'region': None,
}
}
}
return data
def create_config(context, target_repoids, debug, test, tasks, on_aws=False):
"""
Creates the configuration data file for our DNF plugin.
"""
context.makedirs(os.path.dirname(DNF_PLUGIN_DATA_PATH), exists_ok=True)
with context.open(DNF_PLUGIN_DATA_PATH, 'w+') as f:
config_data = build_plugin_data(
target_repoids=target_repoids, debug=debug, test=test, tasks=tasks, on_aws=on_aws
)
json.dump(config_data, f, sort_keys=True, indent=2)
def backup_config(context):
"""
Backs up the configuration data used for the plugin.
"""
context.copy_from(DNF_PLUGIN_DATA_PATH, DNF_PLUGIN_DATA_LOG_PATH)
def backup_debug_data(context):
"""
Performs the backup of DNF debug data
"""
if config.is_debug():
# The debugdata is a folder generated by dnf when using the --debugsolver dnf option. We switch on the
# debug_solver dnf config parameter in our rhel-upgrade dnf plugin when LEAPP_DEBUG env var set to 1.
try:
context.copytree_from('/debugdata', DNF_DEBUG_DATA_PATH)
except OSError as e:
api.current_logger().warning('Failed to copy debugdata. Message: {}'.format(str(e)), exc_info=True)
def _handle_transaction_err_msg_old(stage, xfs_info, err):
# NOTE(pstodulk): This is going to be removed in future!
message = 'DNF execution failed with non zero exit code.'
details = {'STDOUT': err.stdout, 'STDERR': err.stderr}
if 'more space needed on the' in err.stderr and stage != 'upgrade':
# Disk Requirements:
# At least <size> more space needed on the <path> filesystem.
#
article_section = 'Generic case'
if xfs_info.present and xfs_info.without_ftype:
article_section = 'XFS ftype=0 case'
message = ('There is not enough space on the file system hosting /var/lib/leapp directory '
'to extract the packages.')
details = {'hint': "Please follow the instructions in the '{}' section of the article at: "
"link: https://access.redhat.com/solutions/5057391".format(article_section)}
raise StopActorExecutionError(message=message, details=details)
def _handle_transaction_err_msg(stage, xfs_info, err, is_container=False):
# ignore the fallback when the error is related to the container issue
# e.g. installation of packages inside the container; so it's unrelated
# to the upgrade transactions.
if get_env('LEAPP_OVL_LEGACY', '0') == '1' and not is_container:
_handle_transaction_err_msg_old(stage, xfs_info, err)
return # not needed actually as the above function raises error, but for visibility
NO_SPACE_STR = 'more space needed on the'
message = 'DNF execution failed with non zero exit code.'
details = {'STDOUT': err.stdout, 'STDERR': err.stderr}
if NO_SPACE_STR not in err.stderr:
raise StopActorExecutionError(message=message, details=details)
# Disk Requirements:
# At least <size> more space needed on the <path> filesystem.
#
missing_space = [line.strip() for line in err.stderr.split('\n') if NO_SPACE_STR in line]
if is_container:
size_str = re.match(r'At least (.*) more space needed', missing_space[0]).group(1)
message = 'There is not enough space on the file system hosting /var/lib/leapp.'
hint = (
'Increase the free space on the filesystem hosting'
' /var/lib/leapp by {} at minimum. It is suggested to provide'
' reasonably more space to be able to perform all planned actions'
' (e.g. when 200MB is missing, add 1700MB or more).\n\n'
'It is also a good practice to create dedicated partition'
' for /var/lib/leapp when more space is needed, which can be'
' dropped after the system upgrade is fully completed'
' For more info, see: {}'
.format(size_str, _DEDICATED_URL)
)
# we do not want to confuse customers by the orig msg speaking about
# missing space on '/'. Skip the Disk Requirements section.
# The information is part of the hint.
details = {'hint': hint}
else:
message = 'There is not enough space on some file systems to perform the upgrade transaction.'
hint = (
'Increase the free space on listed filesystems. Presented values'
' are required minimum calculated by RPM and it is suggested to'
' provide reasonably more free space (e.g. when 200 MB is missing'
' on /usr, add 1200MB or more).'
)
details = {'hint': hint, 'Disk Requirements': '\n'.join(missing_space)}
raise StopActorExecutionError(message=message, details=details)
def _transaction(context, stage, target_repoids, tasks, plugin_info, xfs_info,
test=False, cmd_prefix=None, on_aws=False):
"""
Perform the actual DNF rpm download via our DNF plugin
"""
# we do not want
if stage not in ['dry-run', 'upgrade']:
create_config(
context=context,
target_repoids=target_repoids,
debug=config.is_debug(),
test=test, tasks=tasks,
on_aws=on_aws
)
backup_config(context=context)
# FIXME: rhsm
with guards.guarded_execution(guards.connection_guard(), guards.space_guard()):
cmd_prefix = cmd_prefix or []
common_params = []
if config.is_verbose():
common_params.append('-v')
if rhsm.skip_rhsm():
common_params += ['--disableplugin', 'subscription-manager']
if plugin_info:
for info in plugin_info:
if stage in info.disable_in:
common_params += ['--disableplugin', info.name]
env = {}
if get_target_major_version() == '9':
# allow handling new RHEL 9 syscalls by systemd-nspawn
env = {'SYSTEMD_SECCOMP': '0'}
# We need to reset modules twice, once before we check, and the second time before we actually perform
# the upgrade. Not more often as the modules will be reset already.
if stage in ('check', 'upgrade') and tasks.modules_to_reset:
# We shall only reset modules that are not going to be enabled
# This will make sure it is so
modules_to_reset = {(module.name, module.stream) for module in tasks.modules_to_reset}
modules_to_enable = {(module.name, module.stream) for module in tasks.modules_to_enable}
module_reset_list = [module[0] for module in modules_to_reset - modules_to_enable]
# Perform module reset
cmd = ['/usr/bin/dnf', 'module', 'reset', '--enabled', ] + module_reset_list
cmd += ['--disablerepo', '*', '-y', '--installroot', '/installroot']
try:
context.call(
cmd=cmd_prefix + cmd + common_params,
callback_raw=utils.logging_handler,
env=env
)
except (CalledProcessError, OSError):
api.current_logger().debug('Failed to reset modules via dnf with an error. Ignoring.',
exc_info=True)
cmd = [
'/usr/bin/dnf',
'rhel-upgrade',
stage,
DNF_PLUGIN_DATA_PATH
]
try:
context.call(
cmd=cmd_prefix + cmd + common_params,
callback_raw=utils.logging_handler,
env=env
)
except OSError as e:
api.current_logger().error('Could not call dnf command: Message: %s', str(e), exc_info=True)
raise StopActorExecutionError(
message='Failed to execute dnf. Reason: {}'.format(str(e))
)
except CalledProcessError as e:
api.current_logger().error('Cannot calculate, check, test, or perform the upgrade transaction.')
_handle_transaction_err_msg(stage, xfs_info, e, is_container=False)
finally:
if stage == 'check':
backup_debug_data(context=context)
@contextlib.contextmanager
def _prepare_transaction(used_repos, target_userspace_info, binds=()):
""" Creates the transaction environment needed for the target userspace DNF execution """
target_repoids = set()
for message in used_repos:
target_repoids.update([repo.repoid for repo in message.repos])
with mounting.NspawnActions(base_dir=target_userspace_info.path, binds=binds) as context:
yield context, list(target_repoids), target_userspace_info
def apply_workarounds(context=None):
"""
Apply registered workarounds in the given context environment
"""
context = context or mounting.NotIsolatedActions(base_dir='/')
for workaround in api.consume(DNFWorkaround):
try:
api.show_message('Applying transaction workaround - {}'.format(workaround.display_name))
if workaround.script_args:
cmd_str = '{script} {args}'.format(
script=workaround.script_path,
args=' '.join(workaround.script_args)
)
else:
cmd_str = workaround.script_path
context.call(['/bin/bash', '-c', cmd_str])
except (OSError, CalledProcessError) as e:
raise StopActorExecutionError(
message=('Failed to execute script to apply transaction workaround {display_name}.'
' Message: {error}'.format(error=str(e), display_name=workaround.display_name))
)
def install_initramdisk_requirements(packages, target_userspace_info, used_repos):
"""
Performs the installation of packages into the initram disk
"""
with _prepare_transaction(used_repos=used_repos,
target_userspace_info=target_userspace_info) as (context, target_repoids, _unused):
if get_target_major_version() == '9':
_rebuild_rpm_db(context)
repos_opt = [['--enablerepo', repo] for repo in target_repoids]
repos_opt = list(itertools.chain(*repos_opt))
cmd = [
'dnf',
'install',
'-y']
if _the_nogpgcheck_option_used():
cmd.append('--nogpgcheck')
cmd += [
'--setopt=module_platform_id=platform:el{}'.format(get_target_major_version()),
'--setopt=keepcache=1',
'--releasever', api.current_actor().configuration.version.target,
'--disablerepo', '*'
] + repos_opt + list(packages)
if config.is_verbose():
cmd.append('-v')
if rhsm.skip_rhsm():
cmd += ['--disableplugin', 'subscription-manager']
env = {}
if get_target_major_version() == '9':
# allow handling new RHEL 9 syscalls by systemd-nspawn
env = {'SYSTEMD_SECCOMP': '0'}
try:
context.call(cmd, env=env)
except CalledProcessError as e:
api.current_logger().error(
'Cannot install packages in the target container required to build the upgrade initramfs.'
)
_handle_transaction_err_msg('', None, e, is_container=True)
def perform_transaction_install(target_userspace_info, storage_info, used_repos, tasks, plugin_info, xfs_info):
"""
Performs the actual installation with the DNF rhel-upgrade plugin using the target userspace
"""
stage = 'upgrade'
# These bind mounts are performed by systemd-nspawn --bind parameters
bind_mounts = [
'/:/installroot',
'/dev:/installroot/dev',
'/proc:/installroot/proc',
'/run/udev:/installroot/run/udev',
]
if get_target_major_version() == '8':
bind_mounts.append('/sys:/installroot/sys')
else:
# the target major version is RHEL 9+
# we are bindmounting host's "/sys" to the intermediate "/hostsys"
# in the upgrade initramdisk to avoid cgroups tree layout clash
bind_mounts.append('/hostsys:/installroot/sys')
already_mounted = {entry.split(':')[0] for entry in bind_mounts}
for entry in storage_info.fstab:
mp = entry.fs_file
if not os.path.isdir(mp):
continue
if mp not in already_mounted:
bind_mounts.append('{}:{}'.format(mp, os.path.join('/installroot', mp.lstrip('/'))))
if os.path.ismount('/boot'):
bind_mounts.append('/boot:/installroot/boot')
if os.path.ismount('/boot/efi'):
bind_mounts.append('/boot/efi:/installroot/boot/efi')
with _prepare_transaction(used_repos=used_repos,
target_userspace_info=target_userspace_info,
binds=bind_mounts
) as (context, target_repoids, _unused):
# the below nsenter command is important as we need to enter sysvipc namespace on the host so we can
# communicate with udev
cmd_prefix = ['nsenter', '--ipc=/installroot/proc/1/ns/ipc']
disable_plugins = []
if plugin_info:
for info in plugin_info:
if stage in info.disable_in:
disable_plugins += [info.name]
# we have to ensure the leapp packages will stay untouched
# Note: this is the most probably duplicate action - it should be already
# set like that, however seatbelt is a good thing.
dnfconfig.exclude_leapp_rpms(context, disable_plugins)
if get_target_major_version() == '9':
_rebuild_rpm_db(context, root='/installroot')
_transaction(
context=context, stage='upgrade', target_repoids=target_repoids, plugin_info=plugin_info,
xfs_info=xfs_info, tasks=tasks, cmd_prefix=cmd_prefix
)
# we have to ensure the leapp packages will stay untouched even after the
# upgrade is fully finished (it cannot be done before the upgrade
# on the host as the config-manager plugin is available since rhel-8)
dnfconfig.exclude_leapp_rpms(mounting.NotIsolatedActions(base_dir='/'), disable_plugins=disable_plugins)
@contextlib.contextmanager
def _prepare_perform(used_repos, target_userspace_info, xfs_info, storage_info, target_iso=None):
reserve_space = overlaygen.get_recommended_leapp_free_space(target_userspace_info.path)
with _prepare_transaction(used_repos=used_repos,
target_userspace_info=target_userspace_info
) as (context, target_repoids, userspace_info):
with overlaygen.create_source_overlay(mounts_dir=userspace_info.mounts, scratch_dir=userspace_info.scratch,
xfs_info=xfs_info, storage_info=storage_info,
mount_target=os.path.join(context.base_dir, 'installroot'),
scratch_reserve=reserve_space) as overlay:
with mounting.mount_upgrade_iso_to_root_dir(target_userspace_info.path, target_iso):
yield context, overlay, target_repoids
def perform_transaction_check(target_userspace_info,
used_repos,
tasks,
xfs_info,
storage_info,
plugin_info,
target_iso=None):
"""
Perform DNF transaction check using our plugin
"""
stage = 'check'
with _prepare_perform(used_repos=used_repos, target_userspace_info=target_userspace_info, xfs_info=xfs_info,
storage_info=storage_info, target_iso=target_iso) as (context, overlay, target_repoids):
apply_workarounds(overlay.nspawn())
disable_plugins = []
if plugin_info:
for info in plugin_info:
if stage in info.disable_in:
disable_plugins += [info.name]
dnfconfig.exclude_leapp_rpms(context, disable_plugins)
_transaction(
context=context, stage='check', target_repoids=target_repoids, plugin_info=plugin_info, xfs_info=xfs_info,
tasks=tasks
)
def perform_rpm_download(target_userspace_info,
used_repos,
tasks,
xfs_info,
storage_info,
plugin_info,
target_iso=None,
on_aws=False):
"""
Perform RPM download including the transaction test using dnf with our plugin
"""
stage = 'download'
with _prepare_perform(used_repos=used_repos,
target_userspace_info=target_userspace_info,
xfs_info=xfs_info,
storage_info=storage_info,
target_iso=target_iso) as (context, overlay, target_repoids):
disable_plugins = []
if plugin_info:
for info in plugin_info:
if stage in info.disable_in:
disable_plugins += [info.name]
apply_workarounds(overlay.nspawn())
dnfconfig.exclude_leapp_rpms(context, disable_plugins)
_transaction(
context=context, stage='download', target_repoids=target_repoids, plugin_info=plugin_info, tasks=tasks,
test=True, on_aws=on_aws, xfs_info=xfs_info
)
def perform_dry_run(target_userspace_info,
used_repos,
tasks,
xfs_info,
storage_info,
plugin_info,
target_iso=None,
on_aws=False):
"""
Perform the dnf transaction test / dry-run using only cached data.
"""
with _prepare_perform(used_repos=used_repos,
target_userspace_info=target_userspace_info,
xfs_info=xfs_info,
storage_info=storage_info,
target_iso=target_iso) as (context, overlay, target_repoids):
apply_workarounds(overlay.nspawn())
_transaction(
context=context, stage='dry-run', target_repoids=target_repoids, plugin_info=plugin_info, tasks=tasks,
test=True, on_aws=on_aws, xfs_info=xfs_info
)