diff --git a/repos/system_upgrade/common/actors/checkgrubcore/actor.py b/repos/system_upgrade/common/actors/checkgrubcore/actor.py index 6aa9979746..ae9e53ef1d 100644 --- a/repos/system_upgrade/common/actors/checkgrubcore/actor.py +++ b/repos/system_upgrade/common/actors/checkgrubcore/actor.py @@ -32,7 +32,7 @@ def process(self): grub_info = next(self.consume(GrubInfo), None) if not grub_info: raise StopActorExecutionError('Actor did not receive any GrubInfo message.') - if grub_info.orig_device_name: + if grub_info.orig_devices: create_report([ reporting.Title( 'GRUB2 core will be automatically updated during the upgrade' @@ -45,8 +45,9 @@ def process(self): create_report([ reporting.Title('Leapp could not identify where GRUB2 core is located'), reporting.Summary( - 'We assumed GRUB2 core is located on the same device as /boot, however Leapp could not ' - 'detect GRUB2 on the device. GRUB2 core needs to be updated maually on legacy (BIOS) systems. ' + 'We assumed GRUB2 core is located on the same device(s) as /boot, ' + 'however Leapp could not detect GRUB2 on the device(s). ' + 'GRUB2 core needs to be updated maually on legacy (BIOS) systems. ' ), reporting.Severity(reporting.Severity.HIGH), reporting.Groups([reporting.Groups.BOOT]), diff --git a/repos/system_upgrade/common/actors/checkgrubcore/tests/test_checkgrubcore.py b/repos/system_upgrade/common/actors/checkgrubcore/tests/test_checkgrubcore.py index fe15b65b2b..b834f9fe9e 100644 --- a/repos/system_upgrade/common/actors/checkgrubcore/tests/test_checkgrubcore.py +++ b/repos/system_upgrade/common/actors/checkgrubcore/tests/test_checkgrubcore.py @@ -1,18 +1,17 @@ -import pytest - -from leapp.exceptions import StopActorExecutionError from leapp.libraries.common.config import mock_configs from leapp.models import FirmwareFacts, GrubInfo from leapp.reporting import Report NO_GRUB = 'Leapp could not identify where GRUB2 core is located' +GRUB = 'GRUB2 core will be automatically updated during the upgrade' def test_actor_update_grub(current_actor_context): current_actor_context.feed(FirmwareFacts(firmware='bios')) - current_actor_context.feed(GrubInfo(orig_device_name='/dev/vda')) + current_actor_context.feed(GrubInfo(orig_devices=['/dev/vda', '/dev/vdb'])) current_actor_context.run(config_model=mock_configs.CONFIG) assert current_actor_context.consume(Report) + assert current_actor_context.consume(Report)[0].report['title'].startswith(GRUB) def test_actor_no_grub_device(current_actor_context): @@ -31,6 +30,6 @@ def test_actor_with_efi(current_actor_context): def test_s390x(current_actor_context): current_actor_context.feed(FirmwareFacts(firmware='bios')) - current_actor_context.feed(GrubInfo(orig_device_name='/dev/vda')) + current_actor_context.feed(GrubInfo(orig_devices=['/dev/vda', '/dev/vdb'])) current_actor_context.run(config_model=mock_configs.CONFIG_S390X) assert not current_actor_context.consume(Report) diff --git a/repos/system_upgrade/common/actors/scangrubdevice/actor.py b/repos/system_upgrade/common/actors/scangrubdevice/actor.py index a12739e105..cde5bcd8d1 100644 --- a/repos/system_upgrade/common/actors/scangrubdevice/actor.py +++ b/repos/system_upgrade/common/actors/scangrubdevice/actor.py @@ -7,7 +7,7 @@ class ScanGrubDeviceName(Actor): """ - Find the name of the block device where GRUB is located + Find the name of the block devices where GRUB is located """ name = 'scan_grub_device_name' @@ -19,8 +19,5 @@ def process(self): if architecture.matches_architecture(architecture.ARCH_S390X): return - device_name = grub.get_grub_device() - if device_name: - self.produce(GrubInfo(orig_device_name=device_name)) - else: - self.produce(GrubInfo()) + devices = grub.get_grub_devices() + self.produce(GrubInfo(orig_devices=devices)) diff --git a/repos/system_upgrade/common/actors/scangrubdevice/tests/test_scangrubdevice.py b/repos/system_upgrade/common/actors/scangrubdevice/tests/test_scangrubdevice.py new file mode 100644 index 0000000000..126e6b262b --- /dev/null +++ b/repos/system_upgrade/common/actors/scangrubdevice/tests/test_scangrubdevice.py @@ -0,0 +1,20 @@ +from leapp.libraries.common import grub +from leapp.libraries.common.config import mock_configs +from leapp.models import GrubInfo + + +def _get_grub_devices_mocked(): + return ['/dev/vda', '/dev/vdb'] + + +def test_actor_scan_grub_device(current_actor_context, monkeypatch): + monkeypatch.setattr(grub, 'get_grub_devices', _get_grub_devices_mocked) + current_actor_context.run(config_model=mock_configs.CONFIG) + info = current_actor_context.consume(GrubInfo) + assert info and info[0].orig_devices == ['/dev/vda', '/dev/vdb'] + + +def test_actor_scan_grub_device_s390x(current_actor_context, monkeypatch): + monkeypatch.setattr(grub, 'get_grub_devices', _get_grub_devices_mocked) + current_actor_context.run(config_model=mock_configs.CONFIG_S390X) + assert not current_actor_context.consume(GrubInfo) diff --git a/repos/system_upgrade/common/actors/updategrubcore/actor.py b/repos/system_upgrade/common/actors/updategrubcore/actor.py index 4545bad6e2..ac9aa82995 100644 --- a/repos/system_upgrade/common/actors/updategrubcore/actor.py +++ b/repos/system_upgrade/common/actors/updategrubcore/actor.py @@ -21,8 +21,8 @@ class UpdateGrubCore(Actor): def process(self): ff = next(self.consume(FirmwareFacts), None) if ff and ff.firmware == 'bios': - grub_dev = grub.get_grub_device() - if grub_dev: - update_grub_core(grub_dev) + grub_devs = grub.get_grub_devices() + if grub_devs: + update_grub_core(grub_devs) else: - api.current_logger().warning('Leapp could not detect GRUB on {}'.format(grub_dev)) + api.current_logger().warning('Leapp could not detect GRUB devices') diff --git a/repos/system_upgrade/common/actors/updategrubcore/libraries/updategrubcore.py b/repos/system_upgrade/common/actors/updategrubcore/libraries/updategrubcore.py index 22ee337260..35050c7798 100644 --- a/repos/system_upgrade/common/actors/updategrubcore/libraries/updategrubcore.py +++ b/repos/system_upgrade/common/actors/updategrubcore/libraries/updategrubcore.py @@ -1,35 +1,42 @@ from leapp import reporting -from leapp.exceptions import StopActorExecution from leapp.libraries.stdlib import api, CalledProcessError, config, run -def update_grub_core(grub_dev): +def update_grub_core(grub_devs): """ Update GRUB core after upgrade from RHEL7 to RHEL8 On legacy systems, GRUB core does not get automatically updated when GRUB packages are updated. """ - cmd = ['grub2-install', grub_dev] - if config.is_debug(): - cmd += ['-v'] - try: - run(cmd) - except CalledProcessError as err: - reporting.create_report([ - reporting.Title('GRUB core update failed'), - reporting.Summary(str(err)), - reporting.Groups([reporting.Groups.BOOT]), - reporting.Severity(reporting.Severity.HIGH), - reporting.Remediation( - hint='Please run "grub2-install " manually after upgrade' - ) - ]) - api.current_logger().warning('GRUB core update on {} failed'.format(grub_dev)) - raise StopActorExecution() + + successful = [] + failed = [] + for dev in grub_devs: + cmd = ['grub2-install', dev] + if config.is_debug(): + cmd += ['-v'] + try: + run(cmd) + except CalledProcessError as err: + api.current_logger().warning('GRUB core update on {} failed: {}'.format(dev, err)) + failed.append(dev) + + successful.append(dev) + + reporting.create_report([ + reporting.Title('GRUB core update failed'), + reporting.Summary('Leapp failed to update GRUB on {}'.format(', '.join(failed))), + reporting.Groups([reporting.Groups.BOOT]), + reporting.Severity(reporting.Severity.HIGH), + reporting.Remediation( + hint='Please run "grub2-install " manually after upgrade' + ) + ]) + reporting.create_report([ reporting.Title('GRUB core successfully updated'), - reporting.Summary('GRUB core on {} was successfully updated'.format(grub_dev)), + reporting.Summary('GRUB core on {} was successfully updated'.format(', '.join(successful))), reporting.Groups([reporting.Groups.BOOT]), reporting.Severity(reporting.Severity.INFO) ]) diff --git a/repos/system_upgrade/common/actors/updategrubcore/tests/test_updategrubcore.py b/repos/system_upgrade/common/actors/updategrubcore/tests/test_updategrubcore.py index e65807a203..7fc9edc502 100644 --- a/repos/system_upgrade/common/actors/updategrubcore/tests/test_updategrubcore.py +++ b/repos/system_upgrade/common/actors/updategrubcore/tests/test_updategrubcore.py @@ -1,7 +1,6 @@ import pytest from leapp import reporting -from leapp.exceptions import StopActorExecution from leapp.libraries.actor import updategrubcore from leapp.libraries.common import testutils from leapp.libraries.stdlib import api, CalledProcessError @@ -32,21 +31,24 @@ def __call__(self, *args): raise_call_error(args) -def test_update_grub(monkeypatch): +@pytest.mark.parametrize('devices', [['/dev/vda'], ['/dev/vda', '/dev/vdb']]) +def test_update_grub(monkeypatch, devices): monkeypatch.setattr(reporting, "create_report", testutils.create_report_mocked()) monkeypatch.setattr(updategrubcore, 'run', run_mocked()) - updategrubcore.update_grub_core('/dev/vda') + updategrubcore.update_grub_core(devices) assert reporting.create_report.called - assert UPDATE_OK_TITLE == reporting.create_report.report_fields['title'] + assert UPDATE_OK_TITLE == reporting.create_report.reports[1]['title'] + assert all(dev in reporting.create_report.reports[1]['summary'] for dev in devices) -def test_update_grub_failed(monkeypatch): +@pytest.mark.parametrize('devices', [['/dev/vda'], ['/dev/vda', '/dev/vdb']]) +def test_update_grub_failed(monkeypatch, devices): monkeypatch.setattr(reporting, "create_report", testutils.create_report_mocked()) monkeypatch.setattr(updategrubcore, 'run', run_mocked(raise_err=True)) - with pytest.raises(StopActorExecution): - updategrubcore.update_grub_core('/dev/vda') + updategrubcore.update_grub_core(devices) assert reporting.create_report.called - assert UPDATE_FAILED_TITLE == reporting.create_report.report_fields['title'] + assert UPDATE_FAILED_TITLE == reporting.create_report.reports[0]['title'] + assert all(dev in reporting.create_report.reports[0]['summary'] for dev in devices) def test_update_grub_negative(current_actor_context): diff --git a/repos/system_upgrade/common/libraries/grub.py b/repos/system_upgrade/common/libraries/grub.py index f6b00f65e8..11492994f9 100644 --- a/repos/system_upgrade/common/libraries/grub.py +++ b/repos/system_upgrade/common/libraries/grub.py @@ -1,7 +1,9 @@ import os from leapp.exceptions import StopActorExecution +from leapp.libraries.common import mdraid from leapp.libraries.stdlib import api, CalledProcessError, run +from leapp.utils.deprecation import deprecated def has_grub(blk_dev): @@ -59,6 +61,32 @@ def get_boot_partition(): return boot_partition +def get_grub_devices(): + """ + Get block devices where GRUB is located. We assume GRUB is on the same device + as /boot partition is. In case that device is an md (Multiple Device) device, all + of the component devices of such a device are considered. + + :return: Devices where GRUB is located + :rtype: list + """ + boot_device = get_boot_partition() + devices = [] + if mdraid.is_mdraid_dev(boot_device): + component_devs = mdraid.get_component_devices(boot_device) + blk_devs = [blk_dev_from_partition(dev) for dev in component_devs] + # remove duplicates as there might be raid on partitions on the same drive + # even if that's very unusual + devices = list(set(blk_devs)) + else: + devices.append(blk_dev_from_partition(boot_device)) + + have_grub = [dev for dev in devices if has_grub(dev)] + api.current_logger().info('GRUB is installed on {}'.format(",".join(have_grub))) + return have_grub + + +@deprecated(since='2023-06-23', message='This function has been replaced by get_grub_devices') def get_grub_device(): """ Get block device where GRUB is located. We assume GRUB is on the same device diff --git a/repos/system_upgrade/common/libraries/mdraid.py b/repos/system_upgrade/common/libraries/mdraid.py new file mode 100644 index 0000000000..c4346f1da7 --- /dev/null +++ b/repos/system_upgrade/common/libraries/mdraid.py @@ -0,0 +1,41 @@ +from leapp.exceptions import StopActorExecution +from leapp.libraries.stdlib import api, CalledProcessError, run + + +def is_mdraid_dev(dev): + """ + Check if a given device is an md (Multiple Device) device + + :raises: StopActorExecution in case of error + """ + try: + result = run(['mdadm', '--query', dev]) + except CalledProcessError as err: + api.current_logger().warning( + 'Could not check if device is an md device: {}'.format(err) + ) + raise StopActorExecution() + return '--detail' in result['stdout'] + + +def get_component_devices(raid_dev): + """ + Get list of component devices in an md (Multiple Device) array + + :return: The list of component devices or None in case of error + """ + try: + # using both --verbose and --brief for medium verbosity + result = run(['mdadm', '--detail', '--verbose', '--brief', raid_dev]) + except CalledProcessError as err: + api.current_logger().warning( + 'Could not get md array component devices: {}'.format(err) + ) + return None + # example output: + # ARRAY /dev/md0 level=raid1 num-devices=2 metadata=1.2 name=localhost.localdomain:0 UUID=c4acea6e:d56e1598:91822e3f:fb26832c # noqa: E501; pylint: disable=line-too-long + # devices=/dev/vda1,/dev/vdb1 + if 'does not appear to be an md device' in result['stdout']: + raise ValueError("Expected md device, but got: {}".format(raid_dev)) + + return result['stdout'].rsplit('=', 2)[-1].strip().split(',') diff --git a/repos/system_upgrade/common/libraries/tests/test_grub.py b/repos/system_upgrade/common/libraries/tests/test_grub.py index ba086854b0..a47fd4e272 100644 --- a/repos/system_upgrade/common/libraries/tests/test_grub.py +++ b/repos/system_upgrade/common/libraries/tests/test_grub.py @@ -3,7 +3,7 @@ import pytest from leapp.exceptions import StopActorExecution -from leapp.libraries.common import grub +from leapp.libraries.common import grub, mdraid from leapp.libraries.common.testutils import logger_mocked from leapp.libraries.stdlib import api, CalledProcessError from leapp.models import DefaultGrub, DefaultGrubInfo @@ -11,6 +11,9 @@ BOOT_PARTITION = '/dev/vda1' BOOT_DEVICE = '/dev/vda' +MD_BOOT_DEVICE = '/dev/md0' +MD_BOOT_DEVICES_WITH_GRUB = ['/dev/sda', '/dev/sdb'] + VALID_DD = b'GRUB GeomHard DiskRead Error' INVALID_DD = b'Nothing to see here!' @@ -27,10 +30,11 @@ def raise_call_error(args=None): class RunMocked(object): - def __init__(self, raise_err=False): + def __init__(self, raise_err=False, boot_on_raid=False): self.called = 0 self.args = None self.raise_err = raise_err + self.boot_on_raid = boot_on_raid def __call__(self, args, encoding=None): self.called += 1 @@ -39,18 +43,22 @@ def __call__(self, args, encoding=None): raise_call_error(args) if self.args == ['grub2-probe', '--target=device', '/boot']: - stdout = BOOT_PARTITION + stdout = MD_BOOT_DEVICE if self.boot_on_raid else BOOT_PARTITION elif self.args == ['lsblk', '-spnlo', 'name', BOOT_PARTITION]: stdout = BOOT_DEVICE + elif self.args[:-1] == ['lsblk', '-spnlo', 'name']: + stdout = self.args[-1][:-1] return {'stdout': stdout} def open_mocked(fn, flags): - return open( - os.path.join(CUR_DIR, 'grub_valid') if fn == BOOT_DEVICE else os.path.join(CUR_DIR, 'grub_invalid'), 'r' - ) + if fn == BOOT_DEVICE or fn in MD_BOOT_DEVICES_WITH_GRUB: + path = os.path.join(CUR_DIR, 'grub_valid') + else: + path = os.path.join(CUR_DIR, 'grub_invalid') + return open(path, 'r') def open_invalid(fn, flags): @@ -122,3 +130,54 @@ def test_is_blscfg_library(monkeypatch, enabled): assert result else: assert not result + + +def is_mdraid_dev_mocked(dev): + return dev == '/dev/md0' + + +def test_get_grub_devices_one_device(monkeypatch): + run_mocked = RunMocked() + monkeypatch.setattr(grub, 'run', run_mocked) + monkeypatch.setattr(os, 'open', open_mocked) + monkeypatch.setattr(os, 'read', read_mocked) + monkeypatch.setattr(os, 'close', close_mocked) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + monkeypatch.setattr(mdraid, 'is_mdraid_dev', is_mdraid_dev_mocked) + + result = grub.get_grub_devices() + assert grub.run.called == 2 + assert [BOOT_DEVICE] == result + assert not api.current_logger.warnmsg + assert 'GRUB is installed on {}'.format(",".join(result)) in api.current_logger.infomsg + + +@pytest.mark.parametrize( + ',component_devs,expected', + [ + (['/dev/sda1', '/dev/sdb1'], MD_BOOT_DEVICES_WITH_GRUB), + (['/dev/sda1', '/dev/sdb1', '/dev/sdc1', '/dev/sdd1'], MD_BOOT_DEVICES_WITH_GRUB), + (['/dev/sda2', '/dev/sdc1'], ['/dev/sda']), + (['/dev/sdd3', '/dev/sdb2'], ['/dev/sdb']), + ] +) +def test_get_grub_devices_raid_device(monkeypatch, component_devs, expected): + run_mocked = RunMocked(boot_on_raid=True) + monkeypatch.setattr(grub, 'run', run_mocked) + monkeypatch.setattr(os, 'open', open_mocked) + monkeypatch.setattr(os, 'read', read_mocked) + monkeypatch.setattr(os, 'close', close_mocked) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + monkeypatch.setattr(mdraid, 'is_mdraid_dev', is_mdraid_dev_mocked) + + def get_component_devices_mocked(raid_dev): + assert raid_dev == MD_BOOT_DEVICE + return component_devs + + monkeypatch.setattr(mdraid, 'get_component_devices', get_component_devices_mocked) + + result = grub.get_grub_devices() + assert grub.run.called == 1 + len(component_devs) # grub2-probe + Nx lsblk + assert sorted(expected) == sorted(result) + assert not api.current_logger.warnmsg + assert 'GRUB is installed on {}'.format(",".join(result)) in api.current_logger.infomsg diff --git a/repos/system_upgrade/common/libraries/tests/test_mdraid.py b/repos/system_upgrade/common/libraries/tests/test_mdraid.py new file mode 100644 index 0000000000..fe19d85600 --- /dev/null +++ b/repos/system_upgrade/common/libraries/tests/test_mdraid.py @@ -0,0 +1,78 @@ +import os + +import pytest + +from leapp.libraries.common import mdraid +from leapp.libraries.common.testutils import logger_mocked +from leapp.libraries.stdlib import api, CalledProcessError + +MD_DEVICE = '/dev/md0' +NOT_MD_DEVICE = '/dev/sda' + +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 RunMocked(object): + + def __init__(self, raise_err=False): + self.called = 0 + self.args = None + self.raise_err = raise_err + + def __call__(self, args, encoding=None): + self.called += 1 + self.args = args + if self.raise_err: + raise_call_error(args) + + if self.args == ['mdadm', '--query', MD_DEVICE]: + stdout = '/dev/md0: 1022.00MiB raid1 2 devices, 0 spares. Use mdadm --detail for more detail.' + elif self.args == ['mdadm', '--query', NOT_MD_DEVICE]: + stdout = '/dev/sda: is not an md array' + + elif self.args == ['mdadm', '--detail', '--verbose', '--brief', MD_DEVICE]: + stdout = 'ARRAY /dev/md0 level=raid1 num-devices=2 metadata=1.2 name=localhost.localdomain:0 UUID=c4acea6e:d56e1598:91822e3f:fb26832c\n devices=/dev/sda1,/dev/sdb1' # noqa: E501; pylint: disable=line-too-long + elif self.args == ['mdadm', '--detail', '--verbose', '--brief', NOT_MD_DEVICE]: + stdout = 'mdadm: /dev/sda does not appear to be an md device' + + return {'stdout': stdout} + + +@pytest.mark.parametrize('dev,expected', [(MD_DEVICE, True), (NOT_MD_DEVICE, False)]) +def test_is_mdraid_dev(monkeypatch, dev, expected): + run_mocked = RunMocked() + monkeypatch.setattr(mdraid, 'run', run_mocked) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + + result = mdraid.is_mdraid_dev(dev) + assert mdraid.run.called == 1 + assert expected == result + assert not api.current_logger.warnmsg + + +def test_get_component_devices_ok(monkeypatch): + run_mocked = RunMocked() + monkeypatch.setattr(mdraid, 'run', run_mocked) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + + result = mdraid.get_component_devices(MD_DEVICE) + assert mdraid.run.called == 1 + assert ['/dev/sda1', '/dev/sdb1'] == result + assert not api.current_logger.warnmsg + + +def test_get_component_devices_not_md_device(monkeypatch): + run_mocked = RunMocked() + monkeypatch.setattr(mdraid, 'run', run_mocked) + + with pytest.raises(ValueError): + mdraid.get_component_devices(NOT_MD_DEVICE) + assert mdraid.run.called == 1 diff --git a/repos/system_upgrade/common/models/grubinfo.py b/repos/system_upgrade/common/models/grubinfo.py index 952d01c12c..f89770b4aa 100644 --- a/repos/system_upgrade/common/models/grubinfo.py +++ b/repos/system_upgrade/common/models/grubinfo.py @@ -8,6 +8,8 @@ class GrubInfo(Model): """ topic = SystemFactsTopic + # NOTE: @deprecated is not supported on fields + # @deprecated(since='2023-06-23', message='This field has been replaced by orig_devices') orig_device_name = fields.Nullable(fields.String()) """ Original name of the block device where Grub is located. @@ -17,3 +19,13 @@ class GrubInfo(Model): it's recommended to use `leapp.libraries.common.grub.get_grub_device()` anywhere else. """ + + orig_devices = fields.List(fields.String(), default=[]) + """ + Original names of the block devices where Grub is located. + + The names are persistent during the boot of the system so it's safe to be used during + preupgrade phases. However the names could be different after the reboot, so + it's recommended to use `leapp.libraries.common.grub.get_grub_devices()` everywhere + else. + """