From cc3fa6fb87f428b07350d0cbb3f838310e5595ad Mon Sep 17 00:00:00 2001 From: David Kubek Date: Tue, 20 Aug 2024 12:57:42 +0200 Subject: [PATCH 1/2] Resolve boot issues in hybrid azure during upgrades from RHEL 7 > 8 > 9. This commit addresses the issue where the `/boot/grub2/grub.cfg` file is overwritten during the upgrade process by an old RHEL7 configuration leftover on the system, causing the system to fail to boot. The problem occurs on hybrid Azure images, which support both UEFI and Legacy systems and have both `grub-pc` and `grub-efi` packages installed. It is caused by one of the scriplets in `grub-efi` which overwrites the old configuration. If old configuration is detected, this actor regenerates the grub configuration using `grub2-mkconfig -o /boot/grub2/grub.cfg` after installing rpms to ensure the correct boot configuration is in place. The fix is applied specifically to Azure hybrid cloud systems. JIRA: RHEL-38255 --- .../cloud/ensurevalidgrubcfghybrid/actor.py | 34 +++ .../libraries/ensurevalidgrubcfghybrid.py | 66 ++++++ .../tests/files/invalid_grub.cfg | 51 +++++ .../tests/files/valid_grub.cfg | 195 ++++++++++++++++++ .../tests/test_ensurevalidgrubcfghybrid.py | 124 +++++++++++ 5 files changed, 470 insertions(+) create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/libraries/ensurevalidgrubcfghybrid.py create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/files/invalid_grub.cfg create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/files/valid_grub.cfg create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/test_ensurevalidgrubcfghybrid.py diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py new file mode 100644 index 0000000000..68de0433fc --- /dev/null +++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py @@ -0,0 +1,34 @@ +from leapp.actors import Actor +from leapp.libraries.actor import ensurevalidgrubcfghybrid +from leapp.models import HybridImage +from leapp.tags import ApplicationsPhaseTag, IPUWorkflowTag + + +class EnsureValidGrubcfgHybrid(Actor): + """ + Resolve boot failures in Azure Gen1 VMs during upgrades from RHEL 7 to RHEL 8 to RHEL 9. + + This actor addresses the issue where the `/boot/grub2/grub.cfg` file is + overwritten during the upgrade process by an old RHEL7 configuration + leftover on the system, causing the system to fail to boot. + + The problem occurs on hybrid Azure images, which support both UEFI and + Legacy systems and have both `grub-pc` and `grub-efi` packages installed. + It is caused by one of the scriplets in `grub-efi` which overwrites the old + configuration. + + If old configuration is detected, this actor regenerates the grub + configuration using `grub2-mkconfig -o /boot/grub2/grub.cfg` after + installing rpms to ensure the correct boot configuration is in place. + + The fix is applied specifically to Azure hybrid cloud systems. + + """ + + name = 'ensure_valid_grubcfg_hybrid' + consumes = (HybridImage,) + produces = () + tags = (ApplicationsPhaseTag, IPUWorkflowTag) + + def process(self): + ensurevalidgrubcfghybrid.process() diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/libraries/ensurevalidgrubcfghybrid.py b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/libraries/ensurevalidgrubcfghybrid.py new file mode 100644 index 0000000000..127eccfc79 --- /dev/null +++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/libraries/ensurevalidgrubcfghybrid.py @@ -0,0 +1,66 @@ +import re + +from leapp.exceptions import StopActorExecutionError +from leapp.libraries.common.config.architecture import ARCH_ACCEPTED +from leapp.libraries.stdlib import api, CalledProcessError, run +from leapp.models import HybridImage + +GRUB_CFG_PATH = '/boot/grub2/grub.cfg' + +MATCH_ARCH = r'({})'.format('|'.join(ARCH_ACCEPTED)) +MATCH_RHEL7_KERNEL_VERSION = r"\d+\.\d+\.\d+-\d+(\.\d+)*\.el7\.{}".format(MATCH_ARCH) +MATCH_RHEL7_KERNEL_DEFINITION = r"vmlinuz-{}".format(MATCH_RHEL7_KERNEL_VERSION) + + +def process(): + if not _is_hybrid_image(): + api.current_logger().info('System is not a hybrid image. Skipping.') + return + + grubcfg = _read_grubcfg() + if _is_grubcfg_invalid(grubcfg): + _run_grub2_mkconfig() + + +def _is_hybrid_image(): + return next(api.consume(HybridImage), None) is not None + + +def _read_grubcfg(): + api.current_logger().debug('Reading {}:'.format(GRUB_CFG_PATH)) + with open(GRUB_CFG_PATH, 'r') as fin: + grubcfg = fin.read() + + api.current_logger().debug(grubcfg) + return grubcfg + + +def _is_grubcfg_invalid(grubcfg): + return _contains_rhel7_kernel_definition(grubcfg) + + +def _contains_rhel7_kernel_definition(grubcfg): + api.current_logger().debug("Looking for RHEL7 kernel version ...") + + match = re.search(MATCH_RHEL7_KERNEL_DEFINITION, grubcfg) + + api.current_logger().debug( + "Matched: {}".format(match.group() if match else "[NO MATCH]") + ) + + return match is not None + + +def _run_grub2_mkconfig(): + api.current_logger().info("Regenerating {}".format(GRUB_CFG_PATH)) + + try: + run([ + 'grub2-mkconfig', + '-o', + GRUB_CFG_PATH + ]) + except CalledProcessError as err: + msg = 'Could not regenerate {}: {}'.format(GRUB_CFG_PATH, str(err)) + api.current_logger().error(msg) + raise StopActorExecutionError(msg) diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/files/invalid_grub.cfg b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/files/invalid_grub.cfg new file mode 100644 index 0000000000..58f55c5370 --- /dev/null +++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/files/invalid_grub.cfg @@ -0,0 +1,51 @@ + +# Created by osbuild + +set timeout=10 + +# load the grubenv file +load_env + +# selection of the next boot entry +if [ "${next_entry}" ] ; then + set default="${next_entry}" + set next_entry= + save_env next_entry + set boot_once=true +else + set default="${saved_entry}" +fi + +if [ "${prev_saved_entry}" ]; then + set saved_entry="${prev_saved_entry}" + save_env saved_entry + set prev_saved_entry= + save_env prev_saved_entry + set boot_once=true +fi + +function savedefault { + if [ -z "${boot_once}" ]; then + saved_entry="${chosen}" + save_env saved_entry + fi +} + +serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1 +terminal_input serial console +terminal_output serial console + +menuentry 'Red Hat Enterprise Linux Server (3.10.0-1160.119.1.el7.x86_64) 7.9 (Maipo)' --class red --class gnu-linux --class gnu --class os --unrestricted --id 'gnulinux-3.10.0-1160.99.1.el7.x86_64-advanced-76a22bf4-f153-4541-b6c7-0332c0dfaeac' { + insmod all_video + set gfxpayload=keep + search --no-floppy --set=root --fs-uuid 61779359-8d11-49ba-bc9d-8d038ee4b108 + linuxefi /vmlinuz-3.10.0-1160.119.1.el7.x86_64 root=UUID=d3c9a2bd-7ffb-4113-9b8f-234c13b18274 ro crashkernel=auto console=tty1 console=ttyS0 earlyprintk=ttyS0 rootdelay=300 scsi_mod.use_blk_mq=y LANG=en_US.UTF-8 + initrdefi /initramfs-3.10.0-1160.119.1.el7.x86_64.img +} +menuentry 'Red Hat Enterprise Linux (3.10.0-1160.99.1.el7.x86_64) 7.9 (Maipo)' --class red --class gnu-linux --class gnu --class os --unrestricted --id 'gnulinux-3.10.0-1160.99.1.el7.x86_64-advanced-76a22bf4-f153-4541-b6c7-0332c0dfaeac' { + insmod all_video + set gfxpayload=keep + search --no-floppy --set=root --fs-uuid 61779359-8d11-49ba-bc9d-8d038ee4b108 + linuxefi /vmlinuz-3.10.0-1160.99.1.el7.x86_64 root=UUID=d3c9a2bd-7ffb-4113-9b8f-234c13b18274 ro crashkernel=auto console=tty1 console=ttyS0 earlyprintk=ttyS0 rootdelay=300 scsi_mod.use_blk_mq=y + initrdefi /initramfs-3.10.0-1160.99.1.el7.x86_64.img +} diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/files/valid_grub.cfg b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/files/valid_grub.cfg new file mode 100644 index 0000000000..8192665e43 --- /dev/null +++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/files/valid_grub.cfg @@ -0,0 +1,195 @@ +# +# DO NOT EDIT THIS FILE +# +# It is automatically generated by grub2-mkconfig using templates +# from /etc/grub.d and settings from /etc/default/grub +# + +### BEGIN /etc/grub.d/00_header ### +set pager=1 + +if [ -f ${config_directory}/grubenv ]; then + load_env -f ${config_directory}/grubenv +elif [ -s $prefix/grubenv ]; then + load_env +fi +if [ "${next_entry}" ] ; then + set default="${next_entry}" + set next_entry= + save_env next_entry + set boot_once=true +else + set default="${saved_entry}" +fi + +if [ x"${feature_menuentry_id}" = xy ]; then + menuentry_id_option="--id" +else + menuentry_id_option="" +fi + +export menuentry_id_option + +if [ "${prev_saved_entry}" ]; then + set saved_entry="${prev_saved_entry}" + save_env saved_entry + set prev_saved_entry= + save_env prev_saved_entry + set boot_once=true +fi + +function savedefault { + if [ -z "${boot_once}" ]; then + saved_entry="${chosen}" + save_env saved_entry + fi +} + +function load_video { + if [ x$feature_all_video_module = xy ]; then + insmod all_video + else + insmod efi_gop + insmod efi_uga + insmod ieee1275_fb + insmod vbe + insmod vga + insmod video_bochs + insmod video_cirrus + fi +} + +serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1 +terminal_input serial console +terminal_output serial console +if [ x$feature_timeout_style = xy ] ; then + set timeout_style=countdown + set timeout=10 +# Fallback hidden-timeout code in case the timeout_style feature is +# unavailable. +elif sleep --interruptible 10 ; then + set timeout=0 +fi +### END /etc/grub.d/00_header ### + +### BEGIN /etc/grub.d/00_tuned ### +set tuned_params="" +set tuned_initrd="" +### END /etc/grub.d/00_tuned ### + +### BEGIN /etc/grub.d/01_users ### +if [ -f ${prefix}/user.cfg ]; then + source ${prefix}/user.cfg + if [ -n "${GRUB2_PASSWORD}" ]; then + set superusers="root" + export superusers + password_pbkdf2 root ${GRUB2_PASSWORD} + fi +fi +### END /etc/grub.d/01_users ### + +### BEGIN /etc/grub.d/08_fallback_counting ### +insmod increment +# Check if boot_counter exists and boot_success=0 to activate this behaviour. +if [ -n "${boot_counter}" -a "${boot_success}" = "0" ]; then + # if countdown has ended, choose to boot rollback deployment, + # i.e. default=1 on OSTree-based systems. + if [ "${boot_counter}" = "0" -o "${boot_counter}" = "-1" ]; then + set default=1 + set boot_counter=-1 + # otherwise decrement boot_counter + else + decrement boot_counter + fi + save_env boot_counter +fi +### END /etc/grub.d/08_fallback_counting ### + +### BEGIN /etc/grub.d/10_linux ### +insmod part_gpt +insmod xfs +set root='hd0,gpt2' +if [ x$feature_platform_search_hint = xy ]; then + search --no-floppy --fs-uuid --set=root --hint-bios=hd0,gpt2 --hint-efi=hd0,gpt2 --hint-baremetal=ahci0,gpt2 61779359-8d11-49ba-bc9d-8d038ee4b108 +else + search --no-floppy --fs-uuid --set=root 61779359-8d11-49ba-bc9d-8d038ee4b108 +fi +insmod part_gpt +insmod xfs +set boot='hd0,gpt2' +if [ x$feature_platform_search_hint = xy ]; then + search --no-floppy --fs-uuid --set=boot --hint-bios=hd0,gpt2 --hint-efi=hd0,gpt2 --hint-baremetal=ahci0,gpt2 61779359-8d11-49ba-bc9d-8d038ee4b108 +else + search --no-floppy --fs-uuid --set=boot 61779359-8d11-49ba-bc9d-8d038ee4b108 +fi + +# This section was generated by a script. Do not modify the generated file - all changes +# will be lost the next time file is regenerated. Instead edit the BootLoaderSpec files. +# +# The blscfg command parses the BootLoaderSpec files stored in /boot/loader/entries and +# populates the boot menu. Please refer to the Boot Loader Specification documentation +# for the files format: https://www.freedesktop.org/wiki/Specifications/BootLoaderSpec/. + +# The kernelopts variable should be defined in the grubenv file. But to ensure that menu +# entries populated from BootLoaderSpec files that use this variable work correctly even +# without a grubenv file, define a fallback kernelopts variable if this has not been set. +# +# The kernelopts variable in the grubenv file can be modified using the grubby tool or by +# executing the grub2-mkconfig tool. For the latter, the values of the GRUB_CMDLINE_LINUX +# and GRUB_CMDLINE_LINUX_DEFAULT options from /etc/default/grub file are used to set both +# the kernelopts variable in the grubenv file and the fallback kernelopts variable. +if [ -z "${kernelopts}" ]; then + set kernelopts="root=/dev/mapper/rootvg-rootlv ro ro crashkernel=auto console=tty1 console=ttyS0 earlyprintk=ttyS0 rootdelay=300 scsi_mod.use_blk_mq=y " +fi + +insmod blscfg +blscfg +### END /etc/grub.d/10_linux ### + +### BEGIN /etc/grub.d/10_reset_boot_success ### +# Hiding the menu is ok if last boot was ok or if this is a first boot attempt to boot the entry +if [ "${boot_success}" = "1" -o "${boot_indeterminate}" = "1" ]; then + set menu_hide_ok=1 +else + set menu_hide_ok=0 +fi +# Reset boot_indeterminate after a successful boot +if [ "${boot_success}" = "1" ] ; then + set boot_indeterminate=0 +# Avoid boot_indeterminate causing the menu to be hidden more then once +elif [ "${boot_indeterminate}" = "1" ]; then + set boot_indeterminate=2 +fi +# Reset boot_success for current boot +set boot_success=0 +save_env boot_success boot_indeterminate +### END /etc/grub.d/10_reset_boot_success ### + +### BEGIN /etc/grub.d/12_menu_auto_hide ### +### END /etc/grub.d/12_menu_auto_hide ### + +### BEGIN /etc/grub.d/20_linux_xen ### +### END /etc/grub.d/20_linux_xen ### + +### BEGIN /etc/grub.d/20_ppc_terminfo ### +### END /etc/grub.d/20_ppc_terminfo ### + +### BEGIN /etc/grub.d/30_os-prober ### +### END /etc/grub.d/30_os-prober ### + +### BEGIN /etc/grub.d/30_uefi-firmware ### +### END /etc/grub.d/30_uefi-firmware ### + +### BEGIN /etc/grub.d/40_custom ### +# This file provides an easy way to add custom menu entries. Simply type the +# menu entries you want to add after this comment. Be careful not to change +# the 'exec tail' line above. +### END /etc/grub.d/40_custom ### + +### BEGIN /etc/grub.d/41_custom ### +if [ -f ${config_directory}/custom.cfg ]; then + source ${config_directory}/custom.cfg +elif [ -z "${config_directory}" -a -f $prefix/custom.cfg ]; then + source $prefix/custom.cfg; +fi +### END /etc/grub.d/41_custom ### diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/test_ensurevalidgrubcfghybrid.py b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/test_ensurevalidgrubcfghybrid.py new file mode 100644 index 0000000000..c0fb0a0dfe --- /dev/null +++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/test_ensurevalidgrubcfghybrid.py @@ -0,0 +1,124 @@ +import os + +import pytest + +from leapp.exceptions import StopActorExecutionError +from leapp.libraries.actor import ensurevalidgrubcfghybrid +from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked +from leapp.libraries.stdlib import api, CalledProcessError +from leapp.models import HybridImage + +CUR_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def raise_call_error(args=None): + raise CalledProcessError( + message='A Leapp Command Error occurred.', + command=args, + result={'signal': None, 'exit_code': 1, 'pid': 0, 'stdout': 'fake', 'stderr': 'fake'} + ) + + +class run_mocked(object): + def __init__(self, raise_err=False): + self.called = 0 + self.args = [] + self.raise_err = raise_err + + def __call__(self, *args): + self.called += 1 + self.args.append(args) + if self.raise_err: + raise_call_error(args) + + +def test_not_hybrid_image(monkeypatch): + """ + Skip when system is not a hybrid. + """ + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[])) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + monkeypatch.setattr(ensurevalidgrubcfghybrid, 'run', run_mocked(raise_err=False)) + + ensurevalidgrubcfghybrid.process() + + assert api.current_logger.infomsg[0].startswith('System is not a hybrid image') + assert ensurevalidgrubcfghybrid.run.called == 0 + + +@pytest.mark.parametrize("is_invalid", [True, False]) +def test_is_grubcfg_valid(monkeypatch, is_invalid): + + grubcfg_filename = ('invalid' if is_invalid else 'valid') + '_grub.cfg' + grubcfg_filepath = os.path.join(CUR_DIR, 'files', grubcfg_filename) + with open(grubcfg_filepath, 'r') as fin: + grubcfg = fin.read() + + assert ensurevalidgrubcfghybrid._is_grubcfg_invalid(grubcfg) == is_invalid + + +def test_valid_grubcfg(monkeypatch): + """ + Test valid configuration does not trigger grub2-mkconfig + """ + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImage()])) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + monkeypatch.setattr(ensurevalidgrubcfghybrid, 'run', run_mocked(raise_err=False)) + + grubcfg_filepath = os.path.join(CUR_DIR, 'files', 'valid_grub.cfg') + with open(grubcfg_filepath, 'r') as fin: + grubcfg = fin.read() + + monkeypatch.setattr(ensurevalidgrubcfghybrid, '_read_grubcfg', lambda: grubcfg) + + ensurevalidgrubcfghybrid.process() + + assert ensurevalidgrubcfghybrid.run.called == 0 + + +def test_invalid_grubcfg(monkeypatch): + """ + Test invalid configuration triggers grub2-mkconfig + """ + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImage()])) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + monkeypatch.setattr(ensurevalidgrubcfghybrid, 'run', run_mocked(raise_err=False)) + + grubcfg_filepath = os.path.join(CUR_DIR, 'files', 'invalid_grub.cfg') + with open(grubcfg_filepath, 'r') as fin: + grubcfg = fin.read() + + monkeypatch.setattr(ensurevalidgrubcfghybrid, '_read_grubcfg', lambda: grubcfg) + + ensurevalidgrubcfghybrid.process() + + assert ensurevalidgrubcfghybrid.run.called == 1 + assert any(msg.startswith('Regenerating') for msg in api.current_logger.infomsg) + + +def test_run_error(monkeypatch): + """ + Test invalid configuration triggers grub2-mkconfig + """ + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImage()])) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + monkeypatch.setattr(ensurevalidgrubcfghybrid, 'run', run_mocked(raise_err=True)) + + grubcfg_filepath = os.path.join(CUR_DIR, 'files', 'invalid_grub.cfg') + with open(grubcfg_filepath, 'r') as fin: + grubcfg = fin.read() + + monkeypatch.setattr(ensurevalidgrubcfghybrid, '_read_grubcfg', lambda: grubcfg) + + with pytest.raises(StopActorExecutionError): + ensurevalidgrubcfghybrid.process() + + assert ensurevalidgrubcfghybrid.run.called == 1 + assert any( + msg.startswith('Could not regenerate') + for msg in api.current_logger.err + ) From 39d83e3f9df2ddeb3cca5f3c9a5c23cda1fa908d Mon Sep 17 00:00:00 2001 From: David Kubek Date: Thu, 22 Aug 2024 11:54:37 +0200 Subject: [PATCH 2/2] Restructure hybrid image detection Previosly detection of Azure hybrid image was tightly coupled with process of converting grubenv symlink to a regular file. Since there exists other issues relating to hybrid images it is worth to separate these two concepts. This commit modifies the ScanHybridImage actor so that it produces a message whel WALinuxAgent is detected or we are booted in bios and ESP partition is mounted and we are running on Hyper-V (sign of a hybrid image). New CheckGrubenvToFile actor is responsible for detection of grubenv symlink on hybrid images and tasks ConvertGrubenvToFile that is later responsible for the actual conversion. --- .../actors/cloud/checkgrubenvtofile/actor.py | 34 +++++ .../libraries/checkgrubenvtofile.py | 44 +++++++ .../tests/test_checkgrubenvtofile.py | 35 +++++ .../actors/cloud/checkhybridimage/actor.py | 24 ---- .../libraries/checkhybridimage.py | 65 --------- .../tests/test_checkhybridimage.py | 82 ------------ .../cloud/convertgrubenvtofile/actor.py | 21 +++ .../libraries/convertgrubenvtofile.py} | 8 ++ .../tests/test_convertgrubenvtofile.py | 51 +++++++ .../actors/cloud/grubenvtofile/actor.py | 28 ---- .../grubenvtofile/tests/test_grubenvtofile.py | 43 ------ .../actors/cloud/scanhybridimage/actor.py | 19 +++ .../libraries/scanhybridimage.py | 102 ++++++++++++++ .../tests/test_scanhybridimage.py | 124 ++++++++++++++++++ repos/system_upgrade/common/models/grubenv.py | 11 +- .../common/models/hybridimage.py | 12 ++ .../cloud/checkvalidgrubcfghybrid/actor.py | 32 +++++ .../libraries/checkvalidgrubcfghybrid.py | 30 +++++ .../tests/test_checkvalidgrubcfghybrid.py | 25 ++++ .../cloud/ensurevalidgrubcfghybrid/actor.py | 18 +-- .../libraries/ensurevalidgrubcfghybrid.py | 4 +- .../tests/test_ensurevalidgrubcfghybrid.py | 8 +- 22 files changed, 555 insertions(+), 265 deletions(-) create mode 100644 repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/actor.py create mode 100644 repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/libraries/checkgrubenvtofile.py create mode 100644 repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/tests/test_checkgrubenvtofile.py delete mode 100644 repos/system_upgrade/common/actors/cloud/checkhybridimage/actor.py delete mode 100644 repos/system_upgrade/common/actors/cloud/checkhybridimage/libraries/checkhybridimage.py delete mode 100644 repos/system_upgrade/common/actors/cloud/checkhybridimage/tests/test_checkhybridimage.py create mode 100644 repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/actor.py rename repos/system_upgrade/common/actors/cloud/{grubenvtofile/libraries/grubenvtofile.py => convertgrubenvtofile/libraries/convertgrubenvtofile.py} (79%) create mode 100644 repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/tests/test_convertgrubenvtofile.py delete mode 100644 repos/system_upgrade/common/actors/cloud/grubenvtofile/actor.py delete mode 100644 repos/system_upgrade/common/actors/cloud/grubenvtofile/tests/test_grubenvtofile.py create mode 100644 repos/system_upgrade/common/actors/cloud/scanhybridimage/actor.py create mode 100644 repos/system_upgrade/common/actors/cloud/scanhybridimage/libraries/scanhybridimage.py create mode 100644 repos/system_upgrade/common/actors/cloud/scanhybridimage/tests/test_scanhybridimage.py create mode 100644 repos/system_upgrade/common/models/hybridimage.py create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/actor.py create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/libraries/checkvalidgrubcfghybrid.py create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/tests/test_checkvalidgrubcfghybrid.py diff --git a/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/actor.py b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/actor.py new file mode 100644 index 0000000000..62ff764432 --- /dev/null +++ b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/actor.py @@ -0,0 +1,34 @@ +from leapp.actors import Actor +from leapp.libraries.actor import checkgrubenvtofile +from leapp.models import ConvertGrubenvTask, FirmwareFacts, HybridImageAzure +from leapp.reporting import Report +from leapp.tags import ChecksPhaseTag, IPUWorkflowTag + + +class CheckGrubenvToFile(Actor): + """ + Check whether grubenv is a symlink on Azure hybrid images using BIOS. + + Azure images provided by Red Hat aim for hybrid (BIOS/EFI) functionality, + however, currently GRUB is not able to see the "grubenv" file if it is a + symlink to a different partition (default on EFI with grub2-efi pkg + installed) and fails on BIOS systems. + + These images have a default relative symlink to EFI partition even when + booted using BIOS and in such cases GRUB is not able to find "grubenv" and + fails to get the kernel cmdline options resulting in system failing to boot + after upgrade. + + The symlink needs to be converted to a normal file with the content of + grubenv on the EFI partition in case the system is using BIOS and running + on the Azure cloud. This action is reported in the preupgrade phase. + + """ + + name = 'check_grubenv_to_file' + consumes = (FirmwareFacts, HybridImageAzure,) + produces = (ConvertGrubenvTask, Report) + tags = (ChecksPhaseTag, IPUWorkflowTag) + + def process(self): + checkgrubenvtofile.process() diff --git a/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/libraries/checkgrubenvtofile.py b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/libraries/checkgrubenvtofile.py new file mode 100644 index 0000000000..a4c5ee1c09 --- /dev/null +++ b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/libraries/checkgrubenvtofile.py @@ -0,0 +1,44 @@ +from leapp import reporting +from leapp.libraries.stdlib import api +from leapp.models import ConvertGrubenvTask, FirmwareFacts, HybridImageAzure + + +def process(): + hybrid_image = next(api.consume(HybridImageAzure), None) + + if not hybrid_image: + return + + if not is_bios() or not hybrid_image.grubenv_is_symlink_to_efi: + return + + reporting.create_report([ + reporting.Title( + 'Azure hybrid (BIOS/EFI) image detected. "grubenv" symlink will be converted to a regular file' + ), + reporting.Summary( + 'Leapp detected the system is running on Azure cloud, booted using BIOS and ' + 'the "/boot/grub2/grubenv" file is a symlink to "../efi/EFI/redhat/grubenv". In case of such a ' + 'hybrid image scenario GRUB is not able to locate "grubenv" as it is a symlink to different ' + 'partition and fails to boot. If the system needs to be run in EFI mode later, please re-create ' + 'the relative symlink again.' + ), + reporting.Severity(reporting.Severity.HIGH), + reporting.Groups([ + reporting.Groups.PUBLIC_CLOUD, + reporting.Groups.BOOT + ]), + reporting.RelatedResource('file', '/boot/grub2/grubenv'), + reporting.RelatedResource('file', '/boot/efi/EFI/redhat/grubenv'), + ]) + + api.produce(ConvertGrubenvTask()) + + +def is_bios(): + """ + Check whether system is booted into BIOS + """ + + ff = next(api.consume(FirmwareFacts), None) + return ff and ff.firmware == 'bios' diff --git a/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/tests/test_checkgrubenvtofile.py b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/tests/test_checkgrubenvtofile.py new file mode 100644 index 0000000000..a5a203fdd5 --- /dev/null +++ b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/tests/test_checkgrubenvtofile.py @@ -0,0 +1,35 @@ +import pytest + +from leapp import reporting +from leapp.libraries.actor import checkgrubenvtofile +from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked +from leapp.libraries.stdlib import api +from leapp.models import FirmwareFacts, HybridImageAzure + +BIOS_FIRMWARE = FirmwareFacts(firmware='bios') +EFI_FIRMWARE = FirmwareFacts(firmware='efi') + + +@pytest.mark.parametrize('is_hybrid', [True, False]) +@pytest.mark.parametrize('is_bios', [True, False]) +@pytest.mark.parametrize('is_symlink', [True, False]) +def test_check_grubenv_to_file(monkeypatch, tmpdir, is_hybrid, is_bios, is_symlink): + + should_report = all([is_hybrid, is_bios, is_symlink]) + + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + + firmware = BIOS_FIRMWARE if is_bios else EFI_FIRMWARE + msgs = [firmware] + ([HybridImageAzure(grubenv_is_symlink_to_efi=is_symlink)] if is_hybrid else []) + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=msgs)) + monkeypatch.setattr(api, "produce", produce_mocked()) + + checkgrubenvtofile.process() + + if should_report: + assert reporting.create_report.called == 1 + assert 'hybrid' in reporting.create_report.report_fields['title'] + assert api.produce.called == 1 + else: + assert reporting.create_report.called == 0 + assert api.produce.called == 0 diff --git a/repos/system_upgrade/common/actors/cloud/checkhybridimage/actor.py b/repos/system_upgrade/common/actors/cloud/checkhybridimage/actor.py deleted file mode 100644 index 3cd2d8645f..0000000000 --- a/repos/system_upgrade/common/actors/cloud/checkhybridimage/actor.py +++ /dev/null @@ -1,24 +0,0 @@ -from leapp.actors import Actor -from leapp.libraries.actor.checkhybridimage import check_hybrid_image -from leapp.models import FirmwareFacts, HybridImage, InstalledRPM -from leapp.reporting import Report -from leapp.tags import ChecksPhaseTag, IPUWorkflowTag - - -class CheckHybridImage(Actor): - """ - Check if the system is using Azure hybrid image. - - These images have a default relative symlink to EFI - partition even when booted using BIOS and in such cases - GRUB is not able find "grubenv" to get the kernel cmdline - options and fails to boot after upgrade`. - """ - - name = 'checkhybridimage' - consumes = (InstalledRPM, FirmwareFacts) - produces = (HybridImage, Report) - tags = (ChecksPhaseTag, IPUWorkflowTag) - - def process(self): - check_hybrid_image() diff --git a/repos/system_upgrade/common/actors/cloud/checkhybridimage/libraries/checkhybridimage.py b/repos/system_upgrade/common/actors/cloud/checkhybridimage/libraries/checkhybridimage.py deleted file mode 100644 index a4eb6fa19a..0000000000 --- a/repos/system_upgrade/common/actors/cloud/checkhybridimage/libraries/checkhybridimage.py +++ /dev/null @@ -1,65 +0,0 @@ -import os - -from leapp import reporting -from leapp.libraries.common import rhui -from leapp.libraries.common.config.version import get_source_major_version -from leapp.libraries.common.rpms import has_package -from leapp.libraries.stdlib import api -from leapp.models import FirmwareFacts, HybridImage, InstalledRPM - -BIOS_PATH = '/boot/grub2/grubenv' -EFI_PATH = '/boot/efi/EFI/redhat/grubenv' - - -def is_grubenv_symlink_to_efi(): - """ - Check whether '/boot/grub2/grubenv' is a relative symlink to - '/boot/efi/EFI/redhat/grubenv'. - """ - return os.path.islink(BIOS_PATH) and os.path.realpath(BIOS_PATH) == os.path.realpath(EFI_PATH) - - -def is_azure_agent_installed(): - """Check whether 'WALinuxAgent' package is installed.""" - src_ver_major = get_source_major_version() - - family = rhui.RHUIFamily(rhui.RHUIProvider.AZURE) - azure_setups = rhui.RHUI_SETUPS.get(family, []) - - agent_pkg = None - for setup in azure_setups: - setup_major_ver = str(setup.os_version[0]) - if setup_major_ver == src_ver_major: - agent_pkg = setup.extra_info.get('agent_pkg') - break - - if not agent_pkg: - return False - - return has_package(InstalledRPM, agent_pkg) - - -def is_bios(): - """Check whether system is booted into BIOS""" - ff = next(api.consume(FirmwareFacts), None) - return ff and ff.firmware == 'bios' - - -def check_hybrid_image(): - """Check whether the system is using Azure hybrid image.""" - if all([is_grubenv_symlink_to_efi(), is_azure_agent_installed(), is_bios()]): - api.produce(HybridImage(detected=True)) - reporting.create_report([ - reporting.Title( - 'Azure hybrid (BIOS/EFI) image detected. "grubenv" symlink will be converted to a regular file' - ), - reporting.Summary( - 'Leapp detected the system is running on Azure cloud, booted using BIOS and ' - 'the "/boot/grub2/grubenv" file is a symlink to "../efi/EFI/redhat/grubenv". In case of such a ' - 'hybrid image scenario GRUB is not able to locate "grubenv" as it is a symlink to different ' - 'partition and fails to boot. If the system needs to be run in EFI mode later, please re-create ' - 'the relative symlink again.' - ), - reporting.Severity(reporting.Severity.HIGH), - reporting.Groups([reporting.Groups.PUBLIC_CLOUD]), - ]) diff --git a/repos/system_upgrade/common/actors/cloud/checkhybridimage/tests/test_checkhybridimage.py b/repos/system_upgrade/common/actors/cloud/checkhybridimage/tests/test_checkhybridimage.py deleted file mode 100644 index 16fbb44c37..0000000000 --- a/repos/system_upgrade/common/actors/cloud/checkhybridimage/tests/test_checkhybridimage.py +++ /dev/null @@ -1,82 +0,0 @@ -import pytest - -from leapp import reporting -from leapp.libraries.actor import checkhybridimage -from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked -from leapp.libraries.stdlib import api -from leapp.models import FirmwareFacts, InstalledRPM, RPM -from leapp.reporting import Report - -RH_PACKAGER = 'Red Hat, Inc. ' -WA_AGENT_RPM = RPM( - name='WALinuxAgent', version='0.1', release='1.sm01', epoch='1', packager=RH_PACKAGER, arch='noarch', - pgpsig='RSA/SHA256, Mon 01 Jan 1970 00:00:00 AM -03, Key ID 199e2f91fd431d51' -) -NO_AGENT_RPM = RPM( - name='NoAgent', version='0.1', release='1.sm01', epoch='1', packager=RH_PACKAGER, arch='noarch', - pgpsig='RSA/SHA256, Mon 01 Jan 1970 00:00:00 AM -03, Key ID 199e2f91fd431d51' -) - -INSTALLED_AGENT = InstalledRPM(items=[WA_AGENT_RPM]) -NOT_INSTALLED_AGENT = InstalledRPM(items=[NO_AGENT_RPM]) - -BIOS_FIRMWARE = FirmwareFacts(firmware='bios') -EFI_FIRMWARE = FirmwareFacts(firmware='efi') - -BIOS_PATH = '/boot/grub2/grubenv' -EFI_PATH = '/boot/efi/EFI/redhat/grubenv' - - -def test_hybrid_image(monkeypatch, tmpdir): - grubenv_efi = tmpdir.join('grubenv_efi') - grubenv_efi.write('grubenv') - - grubenv_boot = tmpdir.join('grubenv_boot') - grubenv_boot.mksymlinkto('grubenv_efi') - - monkeypatch.setattr(checkhybridimage, 'BIOS_PATH', grubenv_boot.strpath) - monkeypatch.setattr(checkhybridimage, 'EFI_PATH', grubenv_efi.strpath) - monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) - monkeypatch.setattr( - api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[BIOS_FIRMWARE, INSTALLED_AGENT]) - ) - monkeypatch.setattr(api, "produce", produce_mocked()) - - checkhybridimage.check_hybrid_image() - assert reporting.create_report.called == 1 - assert 'hybrid' in reporting.create_report.report_fields['title'] - assert api.produce.called == 1 - - -@pytest.mark.parametrize('is_symlink, realpath_match, is_bios, agent_installed', [ - (False, True, True, True), - (True, False, True, True), - (True, True, False, True), - (True, True, True, False), -]) -def test_no_hybrid_image(monkeypatch, is_symlink, realpath_match, is_bios, agent_installed, tmpdir): - grubenv_efi = tmpdir.join('grubenv_efi') - grubenv_efi.write('grubenv') - grubenv_efi_false = tmpdir.join('grubenv_efi_false') - grubenv_efi.write('nope') - grubenv_boot = tmpdir.join('grubenv_boot') - - grubenv_target = grubenv_efi if realpath_match else grubenv_efi_false - - if is_symlink: - grubenv_boot.mksymlinkto(grubenv_target) - - firmw = BIOS_FIRMWARE if is_bios else EFI_FIRMWARE - inst_rpms = INSTALLED_AGENT if agent_installed else NOT_INSTALLED_AGENT - - monkeypatch.setattr(checkhybridimage, 'BIOS_PATH', grubenv_boot.strpath) - monkeypatch.setattr(checkhybridimage, 'EFI_PATH', grubenv_efi.strpath) - monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) - monkeypatch.setattr( - api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[firmw, inst_rpms]) - ) - monkeypatch.setattr(api, "produce", produce_mocked()) - - checkhybridimage.check_hybrid_image() - assert not reporting.create_report.called - assert not api.produce.called diff --git a/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/actor.py b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/actor.py new file mode 100644 index 0000000000..68ef54bb9a --- /dev/null +++ b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/actor.py @@ -0,0 +1,21 @@ +from leapp.actors import Actor +from leapp.libraries.actor import convertgrubenvtofile +from leapp.models import ConvertGrubenvTask +from leapp.tags import FinalizationPhaseTag, IPUWorkflowTag + + +class ConvertGrubenvToFile(Actor): + """ + Convert "grubenv" symlink to a regular file on Azure hybrid images using BIOS. + + For more information see CheckGrubenvToFile actor. + + """ + + name = 'convert_grubenv_to_file' + consumes = (ConvertGrubenvTask,) + produces = () + tags = (FinalizationPhaseTag, IPUWorkflowTag) + + def process(self): + convertgrubenvtofile.process() diff --git a/repos/system_upgrade/common/actors/cloud/grubenvtofile/libraries/grubenvtofile.py b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/libraries/convertgrubenvtofile.py similarity index 79% rename from repos/system_upgrade/common/actors/cloud/grubenvtofile/libraries/grubenvtofile.py rename to repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/libraries/convertgrubenvtofile.py index 4d699ec3ff..1803c6c714 100644 --- a/repos/system_upgrade/common/actors/cloud/grubenvtofile/libraries/grubenvtofile.py +++ b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/libraries/convertgrubenvtofile.py @@ -1,9 +1,17 @@ from leapp.libraries.stdlib import api, CalledProcessError, run +from leapp.models import ConvertGrubenvTask BIOS_PATH = '/boot/grub2/grubenv' EFI_PATH = '/boot/efi/EFI/redhat/grubenv' +def process(): + convert_grubenv_task = next(api.consume(ConvertGrubenvTask), None) + + if convert_grubenv_task: + grubenv_to_file() + + def grubenv_to_file(): try: run(['unlink', BIOS_PATH]) diff --git a/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/tests/test_convertgrubenvtofile.py b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/tests/test_convertgrubenvtofile.py new file mode 100644 index 0000000000..c4534bd62e --- /dev/null +++ b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/tests/test_convertgrubenvtofile.py @@ -0,0 +1,51 @@ +import pytest + +from leapp.libraries.actor import convertgrubenvtofile +from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked +from leapp.libraries.stdlib import api, CalledProcessError +from leapp.models import ConvertGrubenvTask + + +def raise_call_error(args=None): + raise CalledProcessError( + message='A Leapp Command Error occurred.', + command=args, + result={'signal': None, 'exit_code': 1, 'pid': 0, 'stdout': 'fake', 'stderr': 'fake'} + ) + + +class run_mocked(object): + def __init__(self, raise_err=False): + self.called = 0 + self.args = [] + self.raise_err = raise_err + + def __call__(self, *args): + self.called += 1 + self.args.append(args) + if self.raise_err: + raise_call_error(args) + + +def test_grubenv_to_file(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[ConvertGrubenvTask()])) + monkeypatch.setattr(convertgrubenvtofile, 'run', run_mocked(raise_err=False)) + convertgrubenvtofile.process() + assert convertgrubenvtofile.run.called == 2 + + +def test_no_grubenv_to_file(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[])) + monkeypatch.setattr(convertgrubenvtofile, 'run', run_mocked(raise_err=False)) + convertgrubenvtofile.process() + assert convertgrubenvtofile.run.called == 0 + + +def test_fail_grubenv_to_file(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[ConvertGrubenvTask()])) + monkeypatch.setattr(convertgrubenvtofile, 'run', run_mocked(raise_err=True)) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + convertgrubenvtofile.grubenv_to_file() + + assert convertgrubenvtofile.run.called == 1 + assert api.current_logger.warnmsg[0].startswith('Could not unlink') diff --git a/repos/system_upgrade/common/actors/cloud/grubenvtofile/actor.py b/repos/system_upgrade/common/actors/cloud/grubenvtofile/actor.py deleted file mode 100644 index fc94219c8e..0000000000 --- a/repos/system_upgrade/common/actors/cloud/grubenvtofile/actor.py +++ /dev/null @@ -1,28 +0,0 @@ -from leapp.actors import Actor -from leapp.libraries.actor.grubenvtofile import grubenv_to_file -from leapp.models import HybridImage -from leapp.tags import FinalizationPhaseTag, IPUWorkflowTag - - -class GrubenvToFile(Actor): - """ - Convert "grubenv" symlink to a regular file on Azure hybrid images using BIOS. - - Azure images provided by Red Hat aim for hybrid (BIOS/EFI) functionality, - however, currently GRUB is not able to see the "grubenv" file if it is a symlink - to a different partition (default on EFI with grub2-efi pkg installed) and - fails on BIOS systems. This actor converts the symlink to the normal file - with the content of grubenv on the EFI partition in case the system is using BIOS - and running on the Azure cloud. This action is reported in the preupgrade phase. - """ - - name = 'grubenvtofile' - consumes = (HybridImage,) - produces = () - tags = (FinalizationPhaseTag, IPUWorkflowTag) - - def process(self): - grubenv_msg = next(self.consume(HybridImage), None) - - if grubenv_msg and grubenv_msg.detected: - grubenv_to_file() diff --git a/repos/system_upgrade/common/actors/cloud/grubenvtofile/tests/test_grubenvtofile.py b/repos/system_upgrade/common/actors/cloud/grubenvtofile/tests/test_grubenvtofile.py deleted file mode 100644 index 807f5efa37..0000000000 --- a/repos/system_upgrade/common/actors/cloud/grubenvtofile/tests/test_grubenvtofile.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - -from leapp.libraries.actor import grubenvtofile -from leapp.libraries.common.testutils import logger_mocked -from leapp.libraries.stdlib import api, CalledProcessError -from leapp.models import HybridImage - - -def raise_call_error(args=None): - raise CalledProcessError( - message='A Leapp Command Error occurred.', - command=args, - result={'signal': None, 'exit_code': 1, 'pid': 0, 'stdout': 'fake', 'stderr': 'fake'} - ) - - -class run_mocked(object): - def __init__(self, raise_err=False): - self.called = 0 - self.args = [] - self.raise_err = raise_err - - def __call__(self, *args): - self.called += 1 - self.args.append(args) - if self.raise_err: - raise_call_error(args) - - -def test_grubenv_to_file(monkeypatch): - monkeypatch.setattr(api, 'consume', lambda x: iter([HybridImage()])) - monkeypatch.setattr(grubenvtofile, 'run', run_mocked()) - grubenvtofile.grubenv_to_file() - assert grubenvtofile.run.called == 2 - - -def test_fail_grubenv_to_file(monkeypatch): - monkeypatch.setattr(api, 'consume', lambda x: iter([HybridImage()])) - monkeypatch.setattr(grubenvtofile, 'run', run_mocked(raise_err=True)) - monkeypatch.setattr(api, 'current_logger', logger_mocked()) - grubenvtofile.grubenv_to_file() - assert grubenvtofile.run.called == 1 - assert api.current_logger.warnmsg[0].startswith('Could not unlink') diff --git a/repos/system_upgrade/common/actors/cloud/scanhybridimage/actor.py b/repos/system_upgrade/common/actors/cloud/scanhybridimage/actor.py new file mode 100644 index 0000000000..b1848141d0 --- /dev/null +++ b/repos/system_upgrade/common/actors/cloud/scanhybridimage/actor.py @@ -0,0 +1,19 @@ +from leapp.actors import Actor +from leapp.libraries.actor.scanhybridimage import scan_hybrid_image +from leapp.models import FirmwareFacts, HybridImageAzure, InstalledRPM +from leapp.reporting import Report +from leapp.tags import FactsPhaseTag, IPUWorkflowTag + + +class ScanHybridImageAzure(Actor): + """ + Check if the system is using Azure hybrid image. + """ + + name = 'scan_hybrid_image_azure' + consumes = (InstalledRPM, FirmwareFacts) + produces = (HybridImageAzure, Report) + tags = (FactsPhaseTag, IPUWorkflowTag) + + def process(self): + scan_hybrid_image() diff --git a/repos/system_upgrade/common/actors/cloud/scanhybridimage/libraries/scanhybridimage.py b/repos/system_upgrade/common/actors/cloud/scanhybridimage/libraries/scanhybridimage.py new file mode 100644 index 0000000000..a37ab415b1 --- /dev/null +++ b/repos/system_upgrade/common/actors/cloud/scanhybridimage/libraries/scanhybridimage.py @@ -0,0 +1,102 @@ +import os + +from leapp.libraries.common import rhui +from leapp.libraries.common.config.version import get_source_major_version +from leapp.libraries.common.rpms import has_package +from leapp.libraries.stdlib import api, CalledProcessError, run +from leapp.models import FirmwareFacts, HybridImageAzure, InstalledRPM + +EFI_MOUNTPOINT = '/boot/efi/' +AZURE_HYPERVISOR_ID = 'microsoft' + +GRUBENV_BIOS_PATH = '/boot/grub2/grubenv' +GRUBENV_EFI_PATH = '/boot/efi/EFI/redhat/grubenv' + + +def scan_hybrid_image(): + """ + Check whether the system is using Azure hybrid image. + """ + + hybrid_image_condition_1 = is_azure_agent_installed() and is_bios() + hybrid_image_condition_2 = has_efi_partition() and is_bios() and is_running_on_azure_hypervisor() + + if any([hybrid_image_condition_1, hybrid_image_condition_2]): + api.produce( + HybridImageAzure( + grubenv_is_symlink_to_efi=is_grubenv_symlink_to_efi() + ) + ) + + +def is_azure_agent_installed(): + """ + Check whether 'WALinuxAgent' package is installed. + """ + + src_ver_major = get_source_major_version() + + family = rhui.RHUIFamily(rhui.RHUIProvider.AZURE) + azure_setups = rhui.RHUI_SETUPS.get(family, []) + + agent_pkg = None + for setup in azure_setups: + setup_major_ver = str(setup.os_version[0]) + if setup_major_ver == src_ver_major: + agent_pkg = setup.extra_info.get('agent_pkg') + break + + if not agent_pkg: + return False + + return has_package(InstalledRPM, agent_pkg) + + +def has_efi_partition(): + """ + Check whether ESP partition exists and is mounted. + """ + + return os.path.exists(EFI_MOUNTPOINT) and os.path.ismount(EFI_MOUNTPOINT) + + +def is_bios(): + """ + Check whether system is booted into BIOS + """ + + ff = next(api.consume(FirmwareFacts), None) + return ff and ff.firmware == 'bios' + + +def is_running_on_azure_hypervisor(): + """ + Check if system is running on Azure hypervisor (Hyper-V) + """ + + return detect_virt() == AZURE_HYPERVISOR_ID + + +def detect_virt(): + """ + Detect execution in a virtualized environment + """ + + try: + result = run(['systemd-detect-virt']) + except CalledProcessError as e: + api.current_logger().warning('Unable to detect virtualization environment! Error: {}'.format(e)) + return '' + + return result['stdout'] + + +def is_grubenv_symlink_to_efi(): + """ + Check whether '/boot/grub2/grubenv' is a relative symlink to '/boot/efi/EFI/redhat/grubenv'. + """ + + is_symlink = os.path.islink(GRUBENV_BIOS_PATH) + realpaths_match = os.path.realpath(GRUBENV_BIOS_PATH) == os.path.realpath(GRUBENV_EFI_PATH) + + return is_symlink and realpaths_match diff --git a/repos/system_upgrade/common/actors/cloud/scanhybridimage/tests/test_scanhybridimage.py b/repos/system_upgrade/common/actors/cloud/scanhybridimage/tests/test_scanhybridimage.py new file mode 100644 index 0000000000..a0f6fd4c85 --- /dev/null +++ b/repos/system_upgrade/common/actors/cloud/scanhybridimage/tests/test_scanhybridimage.py @@ -0,0 +1,124 @@ +import os + +import pytest + +from leapp import reporting +from leapp.libraries.actor import scanhybridimage +from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, logger_mocked, produce_mocked +from leapp.libraries.stdlib import api, CalledProcessError +from leapp.models import FirmwareFacts, HybridImageAzure, InstalledRPM, RPM + +RH_PACKAGER = 'Red Hat, Inc. ' +WA_AGENT_RPM = RPM( + name='WALinuxAgent', version='0.1', release='1.sm01', epoch='1', packager=RH_PACKAGER, arch='noarch', + pgpsig='RSA/SHA256, Mon 01 Jan 1970 00:00:00 AM -03, Key ID 199e2f91fd431d51' +) +NO_AGENT_RPM = RPM( + name='NoAgent', version='0.1', release='1.sm01', epoch='1', packager=RH_PACKAGER, arch='noarch', + pgpsig='RSA/SHA256, Mon 01 Jan 1970 00:00:00 AM -03, Key ID 199e2f91fd431d51' +) + +INSTALLED_AGENT = InstalledRPM(items=[WA_AGENT_RPM]) +NOT_INSTALLED_AGENT = InstalledRPM(items=[NO_AGENT_RPM]) + +BIOS_FIRMWARE = FirmwareFacts(firmware='bios') +EFI_FIRMWARE = FirmwareFacts(firmware='efi') + +BIOS_PATH = '/boot/grub2/grubenv' +EFI_PATH = '/boot/efi/EFI/redhat/grubenv' + + +def raise_call_error(args=None): + raise CalledProcessError( + message='A Leapp Command Error occurred.', + command=args, + result={'signal': None, 'exit_code': 1, 'pid': 0, 'stdout': 'fake', 'stderr': 'fake'} + ) + + +class run_mocked(object): + def __init__(self, hypervisor='', raise_err=False): + self.hypervisor = hypervisor + self.called = 0 + self.args = [] + self.raise_err = raise_err + + def __call__(self, *args): # pylint: disable=inconsistent-return-statements + self.called += 1 + self.args.append(args) + + if self.raise_err: + raise_call_error(args) + + if args[0] == ['systemd-detect-virt']: + return {'stdout': self.hypervisor} + + raise AttributeError("Unexpected command supplied!") + + +@pytest.mark.parametrize('hypervisor, expected', [('none', False), ('microsoft', True)]) +def test_is_running_on_azure_hypervisor(monkeypatch, hypervisor, expected): + monkeypatch.setattr(scanhybridimage, 'run', run_mocked(hypervisor)) + + assert scanhybridimage.is_running_on_azure_hypervisor() == expected + + +def test_is_running_on_azure_hypervisor_error(monkeypatch): + monkeypatch.setattr(scanhybridimage, 'run', run_mocked('microsoft', raise_err=True)) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + + result = scanhybridimage.is_running_on_azure_hypervisor() + + assert result is False + assert any('Unable to detect' in msg for msg in api.current_logger.warnmsg) + + +@pytest.mark.parametrize('is_symlink', [True, False]) +@pytest.mark.parametrize('realpath_match', [True, False]) +def test_is_grubenv_symlink_to_efi(monkeypatch, is_symlink, realpath_match): + grubenv_efi_false = '/other/grub/grubenv' + + monkeypatch.setattr(scanhybridimage, 'GRUBENV_BIOS_PATH', BIOS_PATH) + monkeypatch.setattr(scanhybridimage, 'GRUBENV_EFI_PATH', EFI_PATH) + + monkeypatch.setattr(os.path, 'islink', lambda path: is_symlink) + + def mocked_realpath(path): + if realpath_match: + return EFI_PATH + + return grubenv_efi_false if path == EFI_PATH else EFI_PATH + + monkeypatch.setattr(os.path, 'realpath', mocked_realpath) + + result = scanhybridimage.is_grubenv_symlink_to_efi() + + assert result == (is_symlink and realpath_match) + + +@pytest.mark.parametrize('is_bios', [True, False]) +@pytest.mark.parametrize('has_efi_partition', [True, False]) +@pytest.mark.parametrize('agent_installed', [True, False]) +@pytest.mark.parametrize('is_microsoft', [True, False]) +@pytest.mark.parametrize('is_symlink', [True, False]) +def test_hybrid_image(monkeypatch, tmpdir, is_bios, has_efi_partition, agent_installed, is_microsoft, is_symlink): + should_produce = (is_microsoft and is_bios and has_efi_partition) or (agent_installed and is_bios) + + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + msgs = [ + BIOS_FIRMWARE if is_bios else EFI_FIRMWARE, + INSTALLED_AGENT if agent_installed else NOT_INSTALLED_AGENT + ] + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + monkeypatch.setattr(scanhybridimage, 'has_efi_partition', lambda: has_efi_partition) + monkeypatch.setattr(scanhybridimage, 'is_running_on_azure_hypervisor', lambda: is_microsoft) + monkeypatch.setattr(scanhybridimage, 'is_grubenv_symlink_to_efi', lambda: is_symlink) + + scanhybridimage.scan_hybrid_image() + + if should_produce: + assert api.produce.called == 1 + assert HybridImageAzure(grubenv_is_symlink_to_efi=is_symlink) in api.produce.model_instances + else: + assert not api.produce.called diff --git a/repos/system_upgrade/common/models/grubenv.py b/repos/system_upgrade/common/models/grubenv.py index be541131e8..c7f339f101 100644 --- a/repos/system_upgrade/common/models/grubenv.py +++ b/repos/system_upgrade/common/models/grubenv.py @@ -1,12 +1,11 @@ -from leapp.models import fields, Model +from leapp.models import Model from leapp.topics import SystemFactsTopic -class HybridImage(Model): +class ConvertGrubenvTask(Model): """ - Model used for instructing Leapp to convert "grubenv" symlink - into a regular file in case of hybrid (BIOS/EFI) images using BIOS - on Azure. + Model used for instructing Leapp to convert "grubenv" symlink into a + regular file. """ + topic = SystemFactsTopic - detected = fields.Boolean(default=False) diff --git a/repos/system_upgrade/common/models/hybridimage.py b/repos/system_upgrade/common/models/hybridimage.py new file mode 100644 index 0000000000..6cf860efcc --- /dev/null +++ b/repos/system_upgrade/common/models/hybridimage.py @@ -0,0 +1,12 @@ +from leapp.models import fields, Model +from leapp.topics import SystemFactsTopic + + +class HybridImageAzure(Model): + """ + Model used to signify that the system is using a hybrid (BIOS/EFI) images + using BIOS on Azure. + """ + + topic = SystemFactsTopic + grubenv_is_symlink_to_efi = fields.Boolean(default=False) diff --git a/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/actor.py b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/actor.py new file mode 100644 index 0000000000..14668e42f7 --- /dev/null +++ b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/actor.py @@ -0,0 +1,32 @@ +from leapp.actors import Actor +from leapp.libraries.actor import checkvalidgrubcfghybrid +from leapp.models import FirmwareFacts, HybridImageAzure +from leapp.reporting import Report +from leapp.tags import ChecksPhaseTag, IPUWorkflowTag + + +class CheckValidGrubConfigHybrid(Actor): + """ + Check potential for boot failures in Azure Gen1 VMs due to invalid grubcfg + + This actor addresses the issue where the `/boot/grub2/grub.cfg` file is + overwritten during the upgrade process by an old RHEL7 configuration + leftover on the system, causing the system to fail to boot. + + The problem occurs on hybrid Azure images, which support both UEFI and + Legacy systems. The issue is caused by one of the scriplets in `grub-efi` + which overwrites during the upgrade current configuration in + `/boot/grub2/grub.cfg` by an old configuration from + `/boot/efi/EFI/redhat/grub.cfg`. + + The issue is detected specifically to Azure hybrid cloud systems. + + """ + + name = 'check_valid_grubcfg_hybrid' + consumes = (FirmwareFacts, HybridImageAzure,) + produces = (Report,) + tags = (ChecksPhaseTag, IPUWorkflowTag) + + def process(self): + checkvalidgrubcfghybrid.process() diff --git a/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/libraries/checkvalidgrubcfghybrid.py b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/libraries/checkvalidgrubcfghybrid.py new file mode 100644 index 0000000000..374772f5ba --- /dev/null +++ b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/libraries/checkvalidgrubcfghybrid.py @@ -0,0 +1,30 @@ +from leapp import reporting +from leapp.libraries.stdlib import api +from leapp.models import HybridImageAzure + + +def process(): + hybrid_image = next(api.consume(HybridImageAzure), None) + + if hybrid_image: + reporting.create_report([ + reporting.Title( + 'Azure hybrid (BIOS/EFI) image detected. The GRUB configuration might be regenerated.' + ), + reporting.Summary( + 'Leapp detected that the system is running on Azure cloud and is booted using BIOS. ' + 'While upgrading from older systems (i.e. RHEL 7) on such systems' + 'it is possible that the system might end up with invalid GRUB configuration, ' + 'as `/boot/grub2/grub.cfg` might be overwritten by an old configuration from ' + '`/boot/efi/EFI/redhat/grub.cfg`, which might cause the system to fail to boot. ' + + 'Please ensure that the system is able to boot with both of these ' + 'configurations. If an invalid configuration is detected during upgrade, ' + 'it will be regenerated automatically using `grub2-mkconfig.`' + ), + reporting.Severity(reporting.Severity.HIGH), + reporting.Groups([ + reporting.Groups.PUBLIC_CLOUD, + reporting.Groups.BOOT + ]), + ]) diff --git a/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/tests/test_checkvalidgrubcfghybrid.py b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/tests/test_checkvalidgrubcfghybrid.py new file mode 100644 index 0000000000..3fd9a53caa --- /dev/null +++ b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/tests/test_checkvalidgrubcfghybrid.py @@ -0,0 +1,25 @@ +import pytest + +from leapp import reporting +from leapp.libraries.actor import checkvalidgrubcfghybrid +from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked +from leapp.libraries.stdlib import api +from leapp.models import HybridImageAzure + + +@pytest.mark.parametrize('is_hybrid', [True, False]) +def test_check_invalid_grubcfg_hybrid(monkeypatch, is_hybrid): + + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + + msgs = [HybridImageAzure()] if is_hybrid else [] + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=msgs)) + monkeypatch.setattr(api, "produce", produce_mocked()) + + checkvalidgrubcfghybrid.process() + + if is_hybrid: + assert reporting.create_report.called == 1 + assert 'regenerated' in reporting.create_report.report_fields['title'] + else: + assert reporting.create_report.called == 0 diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py index 68de0433fc..a350c7a005 100644 --- a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py +++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py @@ -1,6 +1,6 @@ from leapp.actors import Actor from leapp.libraries.actor import ensurevalidgrubcfghybrid -from leapp.models import HybridImage +from leapp.models import HybridImageAzure from leapp.tags import ApplicationsPhaseTag, IPUWorkflowTag @@ -8,25 +8,21 @@ class EnsureValidGrubcfgHybrid(Actor): """ Resolve boot failures in Azure Gen1 VMs during upgrades from RHEL 7 to RHEL 8 to RHEL 9. - This actor addresses the issue where the `/boot/grub2/grub.cfg` file is - overwritten during the upgrade process by an old RHEL7 configuration - leftover on the system, causing the system to fail to boot. - - The problem occurs on hybrid Azure images, which support both UEFI and - Legacy systems and have both `grub-pc` and `grub-efi` packages installed. - It is caused by one of the scriplets in `grub-efi` which overwrites the old - configuration. - If old configuration is detected, this actor regenerates the grub configuration using `grub2-mkconfig -o /boot/grub2/grub.cfg` after installing rpms to ensure the correct boot configuration is in place. + Old configuration is detected by looking for a menuentry corresponding to a + kernel from RHEL 7 which should not be present on RHEL 8 systems. + The fix is applied specifically to Azure hybrid cloud systems. + See also CheckValidGrubConfigHybrid actor. + """ name = 'ensure_valid_grubcfg_hybrid' - consumes = (HybridImage,) + consumes = (HybridImageAzure,) produces = () tags = (ApplicationsPhaseTag, IPUWorkflowTag) diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/libraries/ensurevalidgrubcfghybrid.py b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/libraries/ensurevalidgrubcfghybrid.py index 127eccfc79..f94cf67b3b 100644 --- a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/libraries/ensurevalidgrubcfghybrid.py +++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/libraries/ensurevalidgrubcfghybrid.py @@ -3,7 +3,7 @@ from leapp.exceptions import StopActorExecutionError from leapp.libraries.common.config.architecture import ARCH_ACCEPTED from leapp.libraries.stdlib import api, CalledProcessError, run -from leapp.models import HybridImage +from leapp.models import HybridImageAzure GRUB_CFG_PATH = '/boot/grub2/grub.cfg' @@ -23,7 +23,7 @@ def process(): def _is_hybrid_image(): - return next(api.consume(HybridImage), None) is not None + return next(api.consume(HybridImageAzure), None) is not None def _read_grubcfg(): diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/test_ensurevalidgrubcfghybrid.py b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/test_ensurevalidgrubcfghybrid.py index c0fb0a0dfe..3ba46cb518 100644 --- a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/test_ensurevalidgrubcfghybrid.py +++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/tests/test_ensurevalidgrubcfghybrid.py @@ -6,7 +6,7 @@ from leapp.libraries.actor import ensurevalidgrubcfghybrid from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked from leapp.libraries.stdlib import api, CalledProcessError -from leapp.models import HybridImage +from leapp.models import HybridImageAzure CUR_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -63,7 +63,7 @@ def test_valid_grubcfg(monkeypatch): Test valid configuration does not trigger grub2-mkconfig """ - monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImage()])) + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImageAzure()])) monkeypatch.setattr(api, 'current_logger', logger_mocked()) monkeypatch.setattr(ensurevalidgrubcfghybrid, 'run', run_mocked(raise_err=False)) @@ -83,7 +83,7 @@ def test_invalid_grubcfg(monkeypatch): Test invalid configuration triggers grub2-mkconfig """ - monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImage()])) + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImageAzure()])) monkeypatch.setattr(api, 'current_logger', logger_mocked()) monkeypatch.setattr(ensurevalidgrubcfghybrid, 'run', run_mocked(raise_err=False)) @@ -104,7 +104,7 @@ def test_run_error(monkeypatch): Test invalid configuration triggers grub2-mkconfig """ - monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImage()])) + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[HybridImageAzure()])) monkeypatch.setattr(api, 'current_logger', logger_mocked()) monkeypatch.setattr(ensurevalidgrubcfghybrid, 'run', run_mocked(raise_err=True))