From 198664695a8eb94e62347712f75dac8a38303d98 Mon Sep 17 00:00:00 2001 From: Yuriy Kohut Date: Fri, 15 May 2026 12:36:50 +0300 Subject: [PATCH] Update AlmaLinux ELevate and Vendors patch to upstream 03e67c0d42914b40cac1bff390bd27b9c7b91d04 (0.24.0-2) The package version 0.24.0-2.elevate.2 --- SOURCES/leapp-repository-0.24.0-elevate.patch | 8377 ++++++++++++++++- SPECS/leapp-repository.spec | 5 +- 2 files changed, 8358 insertions(+), 24 deletions(-) diff --git a/SOURCES/leapp-repository-0.24.0-elevate.patch b/SOURCES/leapp-repository-0.24.0-elevate.patch index ffcc01a..b5404d7 100644 --- a/SOURCES/leapp-repository-0.24.0-elevate.patch +++ b/SOURCES/leapp-repository-0.24.0-elevate.patch @@ -3515,6 +3515,132 @@ index 00000000..370758e6 + end + end +end +diff --git a/conftest.py b/conftest.py +index 5da5cc5f..f3d02e74 100644 +--- a/conftest.py ++++ b/conftest.py +@@ -1,5 +1,9 @@ + import logging + import os ++import shutil ++import tempfile ++ ++import pytest + + from leapp.repository.manager import RepositoryManager + from leapp.repository.scan import find_and_scan_repositories +@@ -12,40 +16,65 @@ logging.getLogger("parso").setLevel(logging.INFO) + + + def _load_and_add_repo(manager, repo_path): +- repo = find_and_scan_repositories( ++ manager_with_new_repos = find_and_scan_repositories( + repo_path, + include_locals=True + ) +- unloaded = set() +- loaded = {r.repo_id for r in manager.repos} +- if hasattr(repo, 'repos'): +- for repo in repo.repos: ++ ++ newly_discovered_repo_ids = set() ++ already_known_repoids = {r.repo_id for r in manager.repos} ++ if hasattr(manager_with_new_repos, 'repos'): ++ for repo in manager_with_new_repos.repos: + if not manager.repo_by_id(repo.repo_id): + manager.add_repo(repo) +- unloaded.add(repo.repo_id) ++ newly_discovered_repo_ids.add(repo.repo_id) + else: + manager.add_repo(repo) +- if not loaded: ++ if not already_known_repoids: + manager.load(skip_actors_discovery=True) + else: +- for repo_id in unloaded: ++ for repo_id in newly_discovered_repo_ids: + manager.repo_by_id(repo_id).load(skip_actors_discovery=True) + + ++def _cleanup_actor_context_from_session(collector): ++ is_actor_context_attached = hasattr(collector.session, "current_actor_path") ++ ++ if not is_actor_context_attached: ++ return # Nothing to clean ++ ++ try: ++ collector.session.current_actor_context.__exit__( ++ None, None, None ++ ) ++ delattr(collector.session, 'current_actor_path') ++ collector.session.current_actor_context = None ++ collector.session.current_actor = None ++ except AttributeError: ++ pass ++ ++ + def pytest_collectstart(collector): + if collector.nodeid: + current_repo_basedir = find_repository_basedir(str(collector.fspath)) + if not current_repo_basedir: + # This is not a repository + return ++ + if not hasattr(collector.session, "leapp_repository"): + collector.session.leapp_repository = RepositoryManager() + collector.session.repo_base_dir = current_repo_basedir + _load_and_add_repo(collector.session.leapp_repository, current_repo_basedir) + else: +- if not collector.session.leapp_repository.repo_by_id( +- get_repository_id(current_repo_basedir) +- ): ++ repo_id = get_repository_id(current_repo_basedir) ++ repo = collector.session.leapp_repository.repo_by_id(repo_id) ++ if not repo: ++ # We are about to load a new repository, which will append module loaders ++ # to sys.meta_path. Cleaning actor context will restore the meta_path used ++ # when the context was entered, so if we did the cleanup later we would ++ # undo appends to the meta_path. ++ _cleanup_actor_context_from_session(collector) ++ + _load_and_add_repo(collector.session.leapp_repository, current_repo_basedir) + + # we're forcing the actor context switch only when traversing new +@@ -96,3 +125,18 @@ def pytest_runtestloop(session): + ) + except AttributeError: + pass ++ ++ ++@pytest.fixture ++def leapp_tmpdir(): ++ """ ++ Create a temporary directory with 'leapptest-' prefix in /tmp. ++ ++ This fixture automatically creates and cleans up a temporary directory ++ that follows the project's naming convention for test directories. ++ ++ :returns: Path to the temporary directory ++ :rtype: str ++ """ ++ with tempfile.TemporaryDirectory(prefix='leapptest-', dir='/tmp') as tmpdir: ++ yield tmpdir +diff --git a/docs/source/libraries-and-api/deprecations-list.md b/docs/source/libraries-and-api/deprecations-list.md +index a074ebc1..301de558 100644 +--- a/docs/source/libraries-and-api/deprecations-list.md ++++ b/docs/source/libraries-and-api/deprecations-list.md +@@ -18,6 +18,11 @@ Only the versions in which a deprecation has been made are listed. + - **`LEAPP_NO_NETWORK_RENAMING`** - It becomes obsoleted by the solution based on `net.naming-scheme` which replaces the legacy solution based on created udev link files correcting NIC names during the upgrade. + - Models: + - **`RenamedInterfaces`** - Information provided in this message is not always complete and it's not used since the `net.naming-scheme` kernel command line argument is set during the upgrade. ++- Shared libraries ++ - **`leapp.libraries.common.dnfconfig`** - Moved to `leapp.libraries.common.dnflibs.dnfconfig`. Original library is deprecated. ++ - **`leapp.libraries.common.dnfplugin`** - Moved to `leapp.libraries.common.dnflibs.dnfplugin`. Original library is deprecated. ++ - **`leapp.libraries.common.module`** - Replaced by `leapp.libraries.common.dnflibs.dnfmodule` (renamed for clarity). Original library is deprecated. ++ + + ## v0.24.0 (till September 2026) + - Shared libraries diff --git a/etc/leapp/transaction/to_reinstall b/etc/leapp/transaction/to_reinstall new file mode 100644 index 00000000..c6694a8e @@ -3525,10 +3651,21 @@ index 00000000..c6694a8e +### Useful for packages that have identical version strings but contain binary changes between major OS versions +### Packages that aren't installed will be skipped diff --git a/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py b/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py -index 5b635a83..3de420ff 100644 +index 5b635a83..b2c960a4 100644 --- a/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py +++ b/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py -@@ -91,7 +91,7 @@ def figure_out_commands_needed_to_add_entry(kernel_path, initramfs_path, args_to +@@ -50,7 +50,9 @@ def collect_undesired_args(livemode_enabled): + args = dict(zip(('ro', 'rhgb', 'quiet'), itertools.repeat(None))) + args['rd.lvm.lv'] = _get_rdlvm_arg_values() + +- return set(args.items()) ++ undesired = set(args.items()) ++ undesired |= collect_set_of_kernel_args_from_msgs(UpgradeKernelCmdlineArgTasks, 'to_remove') ++ return undesired + + + def format_grubby_args_from_args_set(args_dict): +@@ -91,7 +93,7 @@ def figure_out_commands_needed_to_add_entry(kernel_path, initramfs_path, args_to '/usr/sbin/grubby', '--add-kernel', '{0}'.format(kernel_path), '--initrd', '{0}'.format(initramfs_path), @@ -3538,10 +3675,20 @@ index 5b635a83..3de420ff 100644 '--make-default', '--args', args_to_add_str diff --git a/repos/system_upgrade/common/actors/addupgradebootentry/tests/unit_test_addupgradebootentry.py b/repos/system_upgrade/common/actors/addupgradebootentry/tests/unit_test_addupgradebootentry.py -index 79c05a5d..96010c0c 100644 +index 79c05a5d..f5cd7808 100644 --- a/repos/system_upgrade/common/actors/addupgradebootentry/tests/unit_test_addupgradebootentry.py +++ b/repos/system_upgrade/common/actors/addupgradebootentry/tests/unit_test_addupgradebootentry.py -@@ -53,7 +53,7 @@ run_args_add = [ +@@ -18,7 +18,8 @@ from leapp.models import ( + LateTargetKernelCmdlineArgTasks, + LiveModeArtifacts, + LiveModeConfig, +- TargetKernelCmdlineArgTasks ++ TargetKernelCmdlineArgTasks, ++ UpgradeKernelCmdlineArgTasks + ) + + CUR_DIR = os.path.dirname(os.path.abspath(__file__)) +@@ -53,7 +54,7 @@ run_args_add = [ '/usr/sbin/grubby', '--add-kernel', '/abc', '--initrd', '/def', @@ -3550,6 +3697,56 @@ index 79c05a5d..96010c0c 100644 '--copy-default', '--make-default', '--args', +@@ -395,3 +396,26 @@ def test_modify_grubenv_to_have_separate_blsdir(monkeypatch, has_separate_boot): + monkeypatch.setattr(addupgradebootentry, 'run', run_mocked) + + addupgradebootentry.modify_our_grubenv_to_have_separate_blsdir(efi_info) ++ ++ ++def test_collect_undesired_args_includes_upgrade_to_remove(monkeypatch): ++ msgs = [ ++ UpgradeKernelCmdlineArgTasks(to_remove=[ ++ KernelCmdlineArg(key='rd.luks.uuid', value='luks-aaa'), ++ KernelCmdlineArg(key='rd.luks.uuid', value='luks-bbb'), ++ ]), ++ ] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ ++ undesired = addupgradebootentry.collect_undesired_args(livemode_enabled=False) ++ ++ assert ('rd.luks.uuid', 'luks-aaa') in undesired ++ assert ('rd.luks.uuid', 'luks-bbb') in undesired ++ ++ ++def test_collect_undesired_args_no_upgrade_to_remove(monkeypatch): ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[])) ++ ++ undesired = addupgradebootentry.collect_undesired_args(livemode_enabled=False) ++ ++ assert undesired == set() +diff --git a/repos/system_upgrade/common/actors/applytransactionworkarounds/actor.py b/repos/system_upgrade/common/actors/applytransactionworkarounds/actor.py +index f1514dd1..00fb4c4c 100644 +--- a/repos/system_upgrade/common/actors/applytransactionworkarounds/actor.py ++++ b/repos/system_upgrade/common/actors/applytransactionworkarounds/actor.py +@@ -1,5 +1,5 @@ + from leapp.actors import Actor +-from leapp.libraries.common import dnfplugin ++from leapp.libraries.common.dnflibs import dnfplugin + from leapp.models import DNFWorkaround + from leapp.tags import IPUWorkflowTag, PreparationPhaseTag + +diff --git a/repos/system_upgrade/common/actors/applytransactionworkarounds/tests/unit_test_applytransactionworkarounds.py b/repos/system_upgrade/common/actors/applytransactionworkarounds/tests/unit_test_applytransactionworkarounds.py +index 96b8094f..92d316b8 100644 +--- a/repos/system_upgrade/common/actors/applytransactionworkarounds/tests/unit_test_applytransactionworkarounds.py ++++ b/repos/system_upgrade/common/actors/applytransactionworkarounds/tests/unit_test_applytransactionworkarounds.py +@@ -1,6 +1,6 @@ + import os + +-from leapp.libraries.common.dnfplugin import api, apply_workarounds, mounting ++from leapp.libraries.common.dnflibs.dnfplugin import api, apply_workarounds, mounting + from leapp.libraries.common.testutils import CurrentActorMocked + from leapp.models import DNFWorkaround + diff --git a/repos/system_upgrade/common/actors/checkenabledvendorrepos/actor.py b/repos/system_upgrade/common/actors/checkenabledvendorrepos/actor.py new file mode 100644 index 00000000..52f5af9d @@ -3609,6 +3806,292 @@ index 00000000..52f5af9d + api.produce(ActiveVendorList(data=list(active_vendors))) + else: + self.log.info("No active vendors found, vendor list not generated") +diff --git a/repos/system_upgrade/common/actors/checkluks/actor.py b/repos/system_upgrade/common/actors/checkluks/actor.py +index 2ea16985..e382be98 100644 +--- a/repos/system_upgrade/common/actors/checkluks/actor.py ++++ b/repos/system_upgrade/common/actors/checkluks/actor.py +@@ -1,6 +1,15 @@ + from leapp.actors import Actor + from leapp.libraries.actor.checkluks import check_invalid_luks_devices +-from leapp.models import CephInfo, LuksDumps, StorageInfo, TargetUserSpaceUpgradeTasks, UpgradeInitramfsTasks ++from leapp.models import ( ++ CephInfo, ++ KernelCmdline, ++ LuksDumps, ++ StorageInfo, ++ TargetKernelCmdlineArgTasks, ++ TargetUserSpaceUpgradeTasks, ++ UpgradeInitramfsTasks, ++ UpgradeKernelCmdlineArgTasks ++) + from leapp.reporting import Report + from leapp.tags import ChecksPhaseTag, IPUWorkflowTag + +@@ -15,8 +24,14 @@ class CheckLuks(Actor): + """ + + name = 'check_luks' +- consumes = (CephInfo, LuksDumps, StorageInfo) +- produces = (Report, TargetUserSpaceUpgradeTasks, UpgradeInitramfsTasks) ++ consumes = (CephInfo, KernelCmdline, LuksDumps, StorageInfo) ++ produces = ( ++ Report, ++ TargetKernelCmdlineArgTasks, ++ TargetUserSpaceUpgradeTasks, ++ UpgradeInitramfsTasks, ++ UpgradeKernelCmdlineArgTasks ++ ) + tags = (ChecksPhaseTag, IPUWorkflowTag) + + def process(self): +diff --git a/repos/system_upgrade/common/actors/checkluks/libraries/checkluks.py b/repos/system_upgrade/common/actors/checkluks/libraries/checkluks.py +index f3e45b47..8db1db19 100644 +--- a/repos/system_upgrade/common/actors/checkluks/libraries/checkluks.py ++++ b/repos/system_upgrade/common/actors/checkluks/libraries/checkluks.py +@@ -6,9 +6,12 @@ from leapp.models import ( + CephInfo, + CopyFile, + DracutModule, ++ KernelCmdline, + LuksDumps, ++ TargetKernelCmdlineArgTasks, + TargetUserSpaceUpgradeTasks, +- UpgradeInitramfsTasks ++ UpgradeInitramfsTasks, ++ UpgradeKernelCmdlineArgTasks + ) + from leapp.reporting import create_report + +@@ -103,6 +106,41 @@ def report_inhibitor(luks1_partitions, no_tpm2_partitions): + ] + report_hints) + + ++def _emit_rdluks_undesired_for_upgrade_cmdline(): ++ """ ++ Identify rd.luks.uuid args on the source kernel cmdline and request their ++ removal from the upgrade boot entry. ++ ++ The upgrade initramfs uses clevis-tpm2 to unlock LUKS devices. Dracut's ++ rd.luks.uuid mechanism is not needed and can cause problems if left in. ++ This mechanism limits what devices will be unlocked during boot, i.e., only ++ devices named in the rd.luks.uuid args will be unlocked. However, we want ++ to unlock all LUKS devices (at least those in /etc/crypttab). Therefore, we ++ request that rd.luks.uuid args be removed from the upgrade kernel cmdline. ++ """ ++ cmdline = next(api.consume(KernelCmdline), None) ++ if not cmdline: ++ api.current_logger().debug('No KernelCmdline message received, nothing to do.') ++ return ++ ++ rdluks_args = [arg for arg in cmdline.parameters if arg.key == 'rd.luks.uuid'] ++ ++ if not rdluks_args: ++ api.current_logger().debug('No rd.luks.uuid args found on the source kernel cmdline.') ++ return ++ ++ api.current_logger().debug( ++ 'Requesting removal of rd.luks.uuid args from the upgrade kernel cmdline: %s', ++ ['{}={}'.format(a.key, a.value) for a in rdluks_args] ++ ) ++ api.produce(UpgradeKernelCmdlineArgTasks(to_remove=rdluks_args)) ++ ++ # When installing the target kernel RPM, the cmdline is copied from the booted system. ++ # As we remove the rd.luks.uuid args, we would accidentally remove them also from the ++ # target entry. Therefore, we add them back in here. ++ api.produce(TargetKernelCmdlineArgTasks(to_add=rdluks_args)) ++ ++ + def check_invalid_luks_devices(): + luks_dumps = next(api.consume(LuksDumps), None) + if not luks_dumps: +@@ -111,6 +149,7 @@ def check_invalid_luks_devices(): + + luks1_partitions = [] + no_tpm2_partitions = [] ++ ok_partitions = [] + ceph_vol = _get_ceph_volumes() + for luks_dump in luks_dumps.dumps: + # if the device is managed by ceph, don't inhibit +@@ -122,29 +161,32 @@ def check_invalid_luks_devices(): + luks1_partitions.append(luks_dump.device_name) + elif luks_dump.version == 2 and not _at_least_one_tpm_token(luks_dump): + no_tpm2_partitions.append(luks_dump.device_name) +- +- if luks1_partitions or no_tpm2_partitions: +- report_inhibitor(luks1_partitions, no_tpm2_partitions) + else: +- required_crypt_rpms = [ +- 'clevis', +- 'clevis-dracut', +- 'clevis-systemd', +- 'clevis-udisks2', +- 'clevis-luks', +- 'cryptsetup', +- 'tpm2-tss', +- 'tpm2-tools', +- 'tpm2-abrmd' +- ] +- api.produce(TargetUserSpaceUpgradeTasks( +- copy_files=[CopyFile(src="/etc/crypttab")], +- install_rpms=required_crypt_rpms) +- ) +- api.produce(UpgradeInitramfsTasks( +- include_files=['/etc/crypttab'], +- include_dracut_modules=[ +- DracutModule(name='clevis'), +- DracutModule(name='clevis-pin-tpm2') +- ]) +- ) ++ ok_partitions.append(luks_dump.device_name) ++ ++ if luks1_partitions or no_tpm2_partitions: ++ report_inhibitor(luks1_partitions, no_tpm2_partitions) ++ elif ok_partitions: ++ required_crypt_rpms = [ ++ 'clevis', ++ 'clevis-dracut', ++ 'clevis-systemd', ++ 'clevis-udisks2', ++ 'clevis-luks', ++ 'cryptsetup', ++ 'tpm2-tss', ++ 'tpm2-tools', ++ 'tpm2-abrmd' ++ ] ++ api.produce(TargetUserSpaceUpgradeTasks( ++ copy_files=[CopyFile(src="/etc/crypttab")], ++ install_rpms=required_crypt_rpms) ++ ) ++ api.produce(UpgradeInitramfsTasks( ++ include_files=['/etc/crypttab'], ++ include_dracut_modules=[ ++ DracutModule(name='clevis'), ++ DracutModule(name='clevis-pin-tpm2') ++ ]) ++ ) ++ _emit_rdluks_undesired_for_upgrade_cmdline() +diff --git a/repos/system_upgrade/common/actors/checkluks/tests/test_checkluks.py b/repos/system_upgrade/common/actors/checkluks/tests/test_checkluks.py +index 0147c4f8..0e13e09f 100644 +--- a/repos/system_upgrade/common/actors/checkluks/tests/test_checkluks.py ++++ b/repos/system_upgrade/common/actors/checkluks/tests/test_checkluks.py +@@ -1,13 +1,19 @@ ++from leapp.libraries.actor import checkluks + from leapp.libraries.common import distro +-from leapp.libraries.common.config import version ++from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked ++from leapp.libraries.stdlib import api + from leapp.models import ( + CephInfo, ++ KernelCmdline, ++ KernelCmdlineArg, + LsblkEntry, + LuksDump, + LuksDumps, + LuksToken, ++ TargetKernelCmdlineArgTasks, + TargetUserSpaceUpgradeTasks, +- UpgradeInitramfsTasks ++ UpgradeInitramfsTasks, ++ UpgradeKernelCmdlineArgTasks + ) + from leapp.reporting import Report + from leapp.snactor.fixture import current_actor_context +@@ -17,7 +23,7 @@ _REPORT_TITLE_UNSUITABLE = 'Detected LUKS devices unsuitable for in-place upgrad + + + def test_actor_with_luks1_notpm(monkeypatch, current_actor_context): +- monkeypatch.setattr(version, 'get_source_major_version', lambda: '8') ++ monkeypatch.setattr(checkluks, 'get_source_major_version', lambda: '8') + monkeypatch.setattr(distro, 'get_source_distro_id', lambda: 'rhel') + monkeypatch.setattr(distro, 'get_target_distro_id', lambda: 'rhel') + +@@ -41,7 +47,7 @@ def test_actor_with_luks1_notpm(monkeypatch, current_actor_context): + + + def test_actor_with_luks2_notpm(monkeypatch, current_actor_context): +- monkeypatch.setattr(version, 'get_source_major_version', lambda: '8') ++ monkeypatch.setattr(checkluks, 'get_source_major_version', lambda: '8') + luks_dump = LuksDump( + version=2, + uuid='27b57c75-9adf-4744-ab04-9eb99726a301', +@@ -62,7 +68,7 @@ def test_actor_with_luks2_notpm(monkeypatch, current_actor_context): + + + def test_actor_with_luks2_invalid_token(monkeypatch, current_actor_context): +- monkeypatch.setattr(version, 'get_source_major_version', lambda: '8') ++ monkeypatch.setattr(checkluks, 'get_source_major_version', lambda: '8') + luks_dump = LuksDump( + version=2, + uuid='dc1dbe37-6644-4094-9839-8fc5dcbec0c6', +@@ -84,7 +90,7 @@ def test_actor_with_luks2_invalid_token(monkeypatch, current_actor_context): + + + def test_actor_with_luks2_clevis_tpm_token(monkeypatch, current_actor_context): +- monkeypatch.setattr(version, 'get_source_major_version', lambda: '8') ++ monkeypatch.setattr(checkluks, 'get_source_major_version', lambda: '8') + luks_dump = LuksDump( + version=2, + uuid='83050bd9-61c6-4ff0-846f-bfd3ac9bfc67', +@@ -113,7 +119,7 @@ def test_actor_with_luks2_clevis_tpm_token(monkeypatch, current_actor_context): + + + def test_actor_with_luks2_ceph(monkeypatch, current_actor_context): +- monkeypatch.setattr(version, 'get_source_major_version', lambda: '8') ++ monkeypatch.setattr(checkluks, 'get_source_major_version', lambda: '8') + ceph_volume = ['sda'] + current_actor_context.feed(CephInfo(encrypted_volumes=ceph_volume)) + luks_dump = LuksDump( +@@ -143,3 +149,50 @@ LSBLK_ENTRY = LsblkEntry( + parent_name="", + parent_path="" + ) ++ ++ ++def test_rdluks_uuid_args_removed_from_upgrade_cmdline(monkeypatch): ++ cmdline = KernelCmdline(parameters=[ ++ KernelCmdlineArg(key='root', value='/dev/mapper/rhel-root'), ++ KernelCmdlineArg(key='rd.luks.uuid', value='luks-aaa-bbb'), ++ KernelCmdlineArg(key='rd.luks.uuid', value='luks-ccc-ddd'), ++ KernelCmdlineArg(key='ro', value=None), ++ ]) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[cmdline])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkluks._emit_rdluks_undesired_for_upgrade_cmdline() ++ ++ upgrade_msgs = [m for m in api.produce.model_instances if isinstance(m, UpgradeKernelCmdlineArgTasks)] ++ assert len(upgrade_msgs) == 1 ++ ++ removed_keys_values = {(arg.key, arg.value) for arg in upgrade_msgs[0].to_remove} ++ assert removed_keys_values == { ++ ('rd.luks.uuid', 'luks-aaa-bbb'), ++ ('rd.luks.uuid', 'luks-ccc-ddd'), ++ } ++ ++ target_msgs = [m for m in api.produce.model_instances if isinstance(m, TargetKernelCmdlineArgTasks)] ++ assert len(target_msgs) == 1 ++ ++ readded_keys_values = {(arg.key, arg.value) for arg in target_msgs[0].to_add} ++ assert readded_keys_values == { ++ ('rd.luks.uuid', 'luks-aaa-bbb'), ++ ('rd.luks.uuid', 'luks-ccc-ddd'), ++ } ++ ++ ++def test_no_rdluks_uuid_no_message(monkeypatch): ++ cmdline = KernelCmdline(parameters=[ ++ KernelCmdlineArg(key='root', value='/dev/mapper/rhel-root'), ++ KernelCmdlineArg(key='ro', value=None), ++ ]) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[cmdline])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkluks._emit_rdluks_undesired_for_upgrade_cmdline() ++ ++ upgrade_msgs = [m for m in api.produce.model_instances if isinstance(m, UpgradeKernelCmdlineArgTasks)] ++ assert not upgrade_msgs ++ target_msgs = [m for m in api.produce.model_instances if isinstance(m, TargetKernelCmdlineArgTasks)] ++ assert not target_msgs diff --git a/repos/system_upgrade/common/actors/checkmicroarchitecture/actor.py b/repos/system_upgrade/common/actors/checkmicroarchitecture/actor.py index bb342f2f..6c11a244 100644 --- a/repos/system_upgrade/common/actors/checkmicroarchitecture/actor.py @@ -3743,14 +4226,162 @@ index eeca8be0..949a731f 100644 checkmicroarchitecture.process() diff --git a/repos/system_upgrade/common/actors/commonleappdracutmodules/files/dracut/85sys-upgrade-redhat/do-upgrade.sh b/repos/system_upgrade/common/actors/commonleappdracutmodules/files/dracut/85sys-upgrade-redhat/do-upgrade.sh -index 683131ec..758e1dfa 100755 +index 683131ec..af67cab5 100755 --- a/repos/system_upgrade/common/actors/commonleappdracutmodules/files/dracut/85sys-upgrade-redhat/do-upgrade.sh +++ b/repos/system_upgrade/common/actors/commonleappdracutmodules/files/dracut/85sys-upgrade-redhat/do-upgrade.sh -@@ -390,4 +390,3 @@ getarg 'rd.break=leapp-logs' 'rd.upgrade.break=leapp-finish' && { +@@ -28,6 +28,7 @@ export RHEL_OS_MAJOR_RELEASE + export LEAPPBIN=/usr/bin/leapp + export LEAPPHOME=/root/tmp_leapp_py3 + export LEAPP3_BIN=$LEAPPHOME/leapp3 ++export LEAPP_FAILED_FLAG_FILE="/root/tmp_leapp_py3/.leapp_upgrade_failed" + + export NEWROOT=${NEWROOT:-"/sysroot"} + +@@ -46,7 +47,6 @@ fi + export NSPAWN_OPTS="$NSPAWN_OPTS --keep-unit --register=no --timezone=off --resolv-conf=off" + + +-export LEAPP_FAILED_FLAG_FILE="/root/tmp_leapp_py3/.leapp_upgrade_failed" + + # + # Temp for collecting and preparing tarball +@@ -236,7 +236,8 @@ do_upgrade() { + # NOTE: in case we would need to run leapp before pivot, we would need to + # specify where the root is, e.g. --root=/sysroot + # TODO: update: systemd-nspawn +- /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a; $LEAPPBIN upgrade --resume $args" ++ # shellcheck disable=SC2086 # The NSPAWN_OPTS and args variables are not quoted since they are supposed to expand into multiple arguments ++ /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a || : ; $LEAPPBIN upgrade --resume $args" + rv=$? + + # NOTE: flush the cached content to disk to ensure everything is written +@@ -248,10 +249,6 @@ do_upgrade() { + } + + if [ "$rv" -eq 0 ]; then +- # run leapp to proceed phases after the upgrade with Python3 +- #PY_LEAPP_PATH=/usr/lib/python2.7/site-packages/leapp/ +- #$NEWROOT/bin/systemd-nspawn $NSPAWN_OPTS -D $NEWROOT -E PYTHONPATH="${PYTHONPATH}:${PY_LEAPP_PATH}" /usr/bin/python3 $LEAPPBIN upgrade --resume $args +- + # on aarch64 systems during el8 to el9 upgrades the swap is broken due to change in page size (64K to 4k) + # adjust the page size before booting into the new system, as it is possible the swap is necessary for to boot + # `arch` command is not available in the dracut shell, using uname -m instead +@@ -272,7 +269,8 @@ do_upgrade() { + # all FSTAB partitions. As mount was working before, hopefully will + # work now as well. Later this should be probably modified as we will + # need to handle more stuff around storage at all. +- /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a; /usr/bin/python3 -B $LEAPP3_BIN upgrade --resume $args" ++ # shellcheck disable=SC2086 # The NSPAWN_OPTS and args variables are not quoted since they are supposed to expand into multiple arguments ++ /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a || : ; /usr/bin/python3 -B $LEAPP3_BIN upgrade --resume $args" + rv=$? + fi + +@@ -285,7 +283,7 @@ do_upgrade() { + + echo >&2 "Creating file $NEWROOT$LEAPP_FAILED_FLAG_FILE" + echo >&2 "Warning: Leapp upgrade failed and there is an issue blocking the upgrade." +- echo >&2 "Please file a support case with /var/log/leapp/leapp-upgrade.log attached" ++ echo >&2 "Please file a support case with /var/log/leapp/leapp-upgrade.log attached." + + "$NEWROOT/bin/touch" "$NEWROOT$LEAPP_FAILED_FLAG_FILE" + fi +@@ -328,7 +326,7 @@ save_journal() { + + # We need to run the actual saving of leapp-upgrade.log in a container and mount everything before, to be + # sure /var/log is mounted in case it is on a separate partition. +- local store_cmd="mount -a" ++ local store_cmd="mount -a || : " + local store_cmd="$store_cmd; cat /tmp-leapp-upgrade.log >> /var/log/leapp/leapp-upgrade.log" + + /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "$store_cmd" +@@ -378,7 +376,7 @@ mount -o "remount,rw" "$NEWROOT" + ) + result=$? + +-##### safe the data and remount $NEWROOT as it was previously mounted ##### ++##### save the data and remount $NEWROOT as it was previously mounted ##### + save_journal + + # NOTE: For debugging purposis. It's possible it will be changed in future. +@@ -390,4 +388,3 @@ getarg 'rd.break=leapp-logs' 'rd.upgrade.break=leapp-finish' && { sync mount -o "remount,$old_opts" "$NEWROOT" exit $result - +diff --git a/repos/system_upgrade/common/actors/createresumeservice/actor.py b/repos/system_upgrade/common/actors/createresumeservice/actor.py +index 8ac07a06..d7a0a5bc 100644 +--- a/repos/system_upgrade/common/actors/createresumeservice/actor.py ++++ b/repos/system_upgrade/common/actors/createresumeservice/actor.py +@@ -22,18 +22,21 @@ class CreateSystemdResumeService(Actor): + tags = (FinalizationPhaseTag, IPUWorkflowTag) + + def process(self): +- service_name = 'leapp_resume.service' + systemd_dir = '/etc/systemd/system' ++ service_name = 'leapp_resume.service' ++ target_name = 'multi-user.target' + + service_templ_fpath = self.get_file_path(service_name) + shutil.copyfile(service_templ_fpath, os.path.join(systemd_dir, service_name)) + +- service_path = '/etc/systemd/system/{}'.format(service_name) +- symlink_path = '/etc/systemd/system/default.target.wants/{}'.format(service_name) ++ target_wants_path = os.path.join(systemd_dir, '{}.wants'.format(target_name)) ++ ++ service_path = os.path.join(systemd_dir, service_name) ++ symlink_path = os.path.join(target_wants_path, service_name) + +- # in case nothing is enabled in the default target, the directory does not exist ++ # in case nothing is enabled in the target, the directory does not exist + try: +- os.mkdir(os.path.join(systemd_dir, 'default.target.wants')) ++ os.mkdir(target_wants_path) + except OSError: + pass + +diff --git a/repos/system_upgrade/common/actors/createresumeservice/files/leapp_resume.service b/repos/system_upgrade/common/actors/createresumeservice/files/leapp_resume.service +index 859237a1..749b4b84 100644 +--- a/repos/system_upgrade/common/actors/createresumeservice/files/leapp_resume.service ++++ b/repos/system_upgrade/common/actors/createresumeservice/files/leapp_resume.service +@@ -1,6 +1,6 @@ + [Unit] + Description=Temporary Leapp service which resumes execution after reboot +-After=default.target ++After=multi-user.target + DefaultDependencies=no + After=dbus.service + After=network-online.target +diff --git a/repos/system_upgrade/common/actors/createresumeservice/tests/test_createresumeservice.py b/repos/system_upgrade/common/actors/createresumeservice/tests/test_createresumeservice.py +index c1cefc37..f2590291 100644 +--- a/repos/system_upgrade/common/actors/createresumeservice/tests/test_createresumeservice.py ++++ b/repos/system_upgrade/common/actors/createresumeservice/tests/test_createresumeservice.py +@@ -1,21 +1,18 @@ + import os + +-import distro + import pytest + + + @pytest.mark.skipif(os.getuid() != 0, reason='User is not a root') +-@pytest.mark.skipif( +- distro.id() == 'fedora', +- reason='default.target.wants does not exists on Fedora distro', +-) + def test_create_resume_service(current_actor_context): +- + current_actor_context.run() + ++ systemd_dir = '/etc/systemd/system' + service_name = 'leapp_resume.service' +- service_path = '/etc/systemd/system/{}'.format(service_name) +- symlink_path = '/etc/systemd/system/default.target.wants/{}'.format(service_name) ++ target_name = 'multi-user.target' ++ ++ service_path = os.path.join(systemd_dir, service_name) ++ symlink_path = os.path.join(systemd_dir, '{}.wants'.format(target_name), service_name) + + try: + assert os.path.isfile(service_path) diff --git a/repos/system_upgrade/common/actors/distributionsignedrpmscanner/actor.py b/repos/system_upgrade/common/actors/distributionsignedrpmscanner/actor.py index 003f3fc5..9e7bbf4a 100644 --- a/repos/system_upgrade/common/actors/distributionsignedrpmscanner/actor.py @@ -3793,6 +4424,52 @@ index 003f3fc5..9e7bbf4a 100644 produces = (DistributionSignedRPM, InstalledUnsignedRPM, ThirdPartyRPM) tags = (IPUWorkflowTag, FactsPhaseTag) +diff --git a/repos/system_upgrade/common/actors/dnfdryrun/actor.py b/repos/system_upgrade/common/actors/dnfdryrun/actor.py +index bc3267b4..1f1d6bd1 100644 +--- a/repos/system_upgrade/common/actors/dnfdryrun/actor.py ++++ b/repos/system_upgrade/common/actors/dnfdryrun/actor.py +@@ -1,5 +1,5 @@ + from leapp.actors import Actor +-from leapp.libraries.common import dnfplugin ++from leapp.libraries.common.dnflibs import dnfplugin + from leapp.models import ( + BootContent, + DNFPluginTask, +diff --git a/repos/system_upgrade/common/actors/dnfpackagedownload/actor.py b/repos/system_upgrade/common/actors/dnfpackagedownload/actor.py +index b54f5627..796b21d4 100644 +--- a/repos/system_upgrade/common/actors/dnfpackagedownload/actor.py ++++ b/repos/system_upgrade/common/actors/dnfpackagedownload/actor.py +@@ -1,5 +1,5 @@ + from leapp.actors import Actor +-from leapp.libraries.common import dnfplugin ++from leapp.libraries.common.dnflibs import dnfplugin + from leapp.models import ( + DNFPluginTask, + DNFWorkaround, +diff --git a/repos/system_upgrade/common/actors/dnftransactioncheck/actor.py b/repos/system_upgrade/common/actors/dnftransactioncheck/actor.py +index b545d1ce..49f9bfe7 100644 +--- a/repos/system_upgrade/common/actors/dnftransactioncheck/actor.py ++++ b/repos/system_upgrade/common/actors/dnftransactioncheck/actor.py +@@ -1,5 +1,5 @@ + from leapp.actors import Actor +-from leapp.libraries.common import dnfplugin ++from leapp.libraries.common.dnflibs import dnfplugin + from leapp.models import ( + DNFPluginTask, + DNFWorkaround, +diff --git a/repos/system_upgrade/common/actors/dnfupgradetransaction/actor.py b/repos/system_upgrade/common/actors/dnfupgradetransaction/actor.py +index 2e069296..7bf8ab23 100644 +--- a/repos/system_upgrade/common/actors/dnfupgradetransaction/actor.py ++++ b/repos/system_upgrade/common/actors/dnfupgradetransaction/actor.py +@@ -1,7 +1,7 @@ + import shutil + + from leapp.actors import Actor +-from leapp.libraries.common import dnfplugin ++from leapp.libraries.common.dnflibs import dnfplugin + from leapp.libraries.stdlib import run + from leapp.models import ( + DNFPluginTask, diff --git a/repos/system_upgrade/common/actors/efibootorderfix/finalization/actor.py b/repos/system_upgrade/common/actors/efibootorderfix/finalization/actor.py index f42909f0..6383a56f 100644 --- a/repos/system_upgrade/common/actors/efibootorderfix/finalization/actor.py @@ -3955,6 +4632,506 @@ index aa735da3..fdb4c5ac 100644 + to_reinstall=list(to_reinstall), modules_to_reset=list(modules_to_reset.values()), modules_to_enable=list(modules_to_enable.values()))) +diff --git a/repos/system_upgrade/common/actors/getenabledmodules/actor.py b/repos/system_upgrade/common/actors/getenabledmodules/actor.py +index 71cefef6..38f458ef 100644 +--- a/repos/system_upgrade/common/actors/getenabledmodules/actor.py ++++ b/repos/system_upgrade/common/actors/getenabledmodules/actor.py +@@ -1,5 +1,5 @@ + from leapp.actors import Actor +-from leapp.libraries.common.module import get_enabled_modules ++from leapp.libraries.common.dnflibs.dnfmodule import get_enabled_modules + from leapp.models import EnabledModules, Module + from leapp.tags import FactsPhaseTag, IPUWorkflowTag + +diff --git a/repos/system_upgrade/common/actors/initramfs/mount_units_generator/libraries/mount_unit_generator.py b/repos/system_upgrade/common/actors/initramfs/mount_units_generator/libraries/mount_unit_generator.py +index e3070986..900132f2 100644 +--- a/repos/system_upgrade/common/actors/initramfs/mount_units_generator/libraries/mount_unit_generator.py ++++ b/repos/system_upgrade/common/actors/initramfs/mount_units_generator/libraries/mount_unit_generator.py +@@ -30,7 +30,7 @@ def run_systemd_fstab_generator(output_directory): + details = {'details': str(error)} + raise StopActorExecutionError( + 'Failed to generate mount units using systemd-fstab-generator', +- details ++ details=details + ) + + api.current_logger().debug( +@@ -110,6 +110,27 @@ def prefix_all_mount_units_with_sysroot(dir_containing_units): + api.current_logger().debug('Original mount unit {} removed.'.format(unit_file_path)) + + ++def is_unit_marked_with_nofail(unit_path: str) -> bool: ++ try: ++ with open(unit_path) as in_file: ++ lines = [line.strip() for line in in_file.readlines()] ++ except OSError: ++ api.current_logger().debug( ++ 'Could not read the unit {} to determine whether it is nofail.'.format(unit_path) ++ ) ++ return False # This way, the unit will end up in target's '.requires', so it is a safe choice ++ ++ for line in lines: ++ if line.startswith('Options'): ++ line_fragments = line.split('=', 1) ++ if len(line_fragments) <= 1: ++ continue # Should not happen ++ used_options = [opt.strip() for opt in line_fragments[1].split(',')] ++ if 'nofail' in used_options: ++ return True ++ return False ++ ++ + def _fix_symlinks_in_dir(dir_containing_mount_units, target_dir): + """ + Fix broken symlinks in given target_dir due to us modifying (renaming) the mount units. +@@ -137,11 +158,16 @@ def _fix_symlinks_in_dir(dir_containing_mount_units, target_dir): + os.mkdir(target_dir_path) + + api.current_logger().debug('Populating {} with new symlinks.'.format(target_dir)) ++ will_units_be_required = target_dir.endswith('.requires') + + for unit_file in os.listdir(dir_containing_mount_units): + if not unit_file.endswith('.mount'): + continue + ++ is_nofail = is_unit_marked_with_nofail(os.path.join(dir_containing_mount_units, unit_file)) ++ if is_nofail and will_units_be_required: ++ continue ++ + place_fastlink_at = os.path.join(target_dir_path, unit_file) + fastlink_points_to = os.path.join('../', unit_file) + try: +diff --git a/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/files/unit-nofail.mount b/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/files/unit-nofail.mount +new file mode 100644 +index 00000000..b87ec340 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/files/unit-nofail.mount +@@ -0,0 +1,15 @@ ++# Automatically generated by systemd-fstab-generator ++ ++[Unit] ++Documentation=man:fstab(5) man:systemd-fstab-generator(8) ++SourcePath=/etc/fstab ++Before=local-fs.target ++Requires=systemd-fsck@dev-disk-by\x2duuid-1ca63aee\x2d37b5\x2d42b7\x2db1a5\x2df752ba09e010.service ++After=systemd-fsck@dev-disk-by\x2duuid-1ca63aee\x2d37b5\x2d42b7\x2db1a5\x2df752ba09e010.service ++After=blockdev@dev-disk-by\x2duuid-1ca63aee\x2d37b5\x2d42b7\x2db1a5\x2df752ba09e010.target ++ ++[Mount] ++What=/dev/disk/by-uuid/1ca63aee-37b5-42b7-b1a5-f752ba09e010 ++Where=/boot ++Type=ext4 ++Options=nofail,noexec +diff --git a/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/files/unit.mount b/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/files/unit.mount +new file mode 100644 +index 00000000..ad1994e3 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/files/unit.mount +@@ -0,0 +1,14 @@ ++# Automatically generated by systemd-fstab-generator ++ ++[Unit] ++Documentation=man:fstab(5) man:systemd-fstab-generator(8) ++SourcePath=/etc/fstab ++Before=local-fs.target ++Requires=systemd-fsck@dev-disk-by\x2duuid-1ca63aee\x2d37b5\x2d42b7\x2db1a5\x2df752ba09e010.service ++After=systemd-fsck@dev-disk-by\x2duuid-1ca63aee\x2d37b5\x2d42b7\x2db1a5\x2df752ba09e010.service ++After=blockdev@dev-disk-by\x2duuid-1ca63aee\x2d37b5\x2d42b7\x2db1a5\x2df752ba09e010.target ++ ++[Mount] ++What=/dev/disk/by-uuid/1ca63aee-37b5-42b7-b1a5-f752ba09e010 ++Where=/boot ++Type=ext4 +diff --git a/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/test_mount_unit_generation.py b/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/test_mount_unit_generation.py +index eb90a75d..99f26ad1 100644 +--- a/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/test_mount_unit_generation.py ++++ b/repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/test_mount_unit_generation.py +@@ -326,3 +326,20 @@ def test_injection_of_sysroot_boot_bindmount_unit(monkeypatch, has_separate_boot + + if has_separate_boot: + assert was_copyfile_for_sysroot_boot_called ++ ++ ++TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'files') ++ ++ ++@pytest.mark.parametrize( ++ ('unit_filename', 'is_nofail'), ++ [ ++ ('unit.mount', False), ++ ('unit-nofail.mount', True), ++ ('non-existing-unit.mount', False), # This file does not exist in the files/ ++ ] ++) ++def test_is_unit_marked_with_nofail(unit_filename, is_nofail): ++ unit_path = os.path.join(TEST_DIR, unit_filename) ++ determined_no_fail = mount_unit_generator.is_unit_marked_with_nofail(unit_path) ++ assert determined_no_fail == is_nofail +diff --git a/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/libraries/targetinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/libraries/targetinitramfsgenerator.py +index edfb42ce..e3b9352e 100644 +--- a/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/libraries/targetinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/libraries/targetinitramfsgenerator.py +@@ -97,14 +97,16 @@ def _get_modules(): + + + def process(): ++ """ ++ Regenerate target system initramfs. ++ ++ The initramfs is regenerated unconditionally always as there might be new configs ++ necessary for boot which are written by us after the RPM transaction installs ++ the target kernel (and generates the target initramfs for the first time). ++ """ + files = _get_files() + modules = _get_modules() + +- if not files and not modules['kernel'] and not modules['dracut']: +- api.current_logger().debug( +- 'No additional files or modules required to add into the target initramfs.') +- return +- + target_kernel_info = next(api.consume(InstalledTargetKernelInfo), None) + if not target_kernel_info: + raise StopActorExecutionError( +diff --git a/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/tests/test_targetinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/tests/test_targetinitramfsgenerator.py +index 4df9a485..d21cdce8 100644 +--- a/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/tests/test_targetinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/tests/test_targetinitramfsgenerator.py +@@ -71,13 +71,12 @@ def gen_InitrdIncludes(files): + + def test_no_includes(monkeypatch): + run_mocked = RunMocked() +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[mk_kernel_info(KERNEL_VERSION)])) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + monkeypatch.setattr(targetinitramfsgenerator, 'run', run_mocked) + + targetinitramfsgenerator.process() +- assert NO_INCLUDE_MSG in api.current_logger.dbgmsg +- assert not run_mocked.called ++ assert run_mocked.called + + + TEST_CASES = [ +diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py +index 1a0dccd8..24f35847 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py +@@ -4,8 +4,9 @@ import shutil + from collections import namedtuple + + from leapp.exceptions import StopActorExecutionError +-from leapp.libraries.common import dnfplugin, mounting ++from leapp.libraries.common import mounting + from leapp.libraries.common.config.version import get_target_major_version ++from leapp.libraries.common.dnflibs import dnfplugin + from leapp.libraries.stdlib import api, CalledProcessError + from leapp.models import RequiredUpgradeInitramPackages # deprecated + from leapp.models import UpgradeDracutModule # deprecated +@@ -168,6 +169,14 @@ def _get_dracut_modules(): + + + def _install_initram_deps(packages): ++ """ ++ Install initramfs dependencies into the target userspace. ++ ++ :param packages: List of package names to install ++ ++ .. seealso:: ++ :func:`leapp.libraries.common.dnflibs.dnfplugin.install_initramdisk_requirements` ++ """ + used_repos = api.consume(UsedTargetRepositories) + target_userspace_info = next(api.consume(TargetUserSpaceInfo), None) + +diff --git a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh +index 51ebddb5..b6154573 100755 +--- a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh ++++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh +@@ -7,13 +7,13 @@ warn() { + + get_rhel_major_release() { + local os_version +- os_version=$(grep -o '^VERSION="[0-9][0-9]*\.' /etc/os-release | grep -o '[0-9]*') ++ os_version=$(grep -o '^VERSION="[0-9][0-9]*' /etc/os-release | grep -o '[0-9]*') + [ -z "$os_version" ] && { + # This should not happen as /etc/initrd-release is supposed to have API + # stability, but check is better than broken system. + warn "Cannot determine the major RHEL version." + warn "The upgrade environment cannot be setup reliably." +- echo "Content of the /etc/initrd-release:" ++ echo "Content of the /etc/os-release:" + cat /etc/os-release + exit 1 + } +@@ -26,6 +26,7 @@ export RHEL_OS_MAJOR_RELEASE + export LEAPPBIN=/usr/bin/leapp + export LEAPPHOME=/root/tmp_leapp_py3 + export LEAPP3_BIN=$LEAPPHOME/leapp3 ++export LEAPP_FAILED_FLAG_FILE="/root/tmp_leapp_py3/.leapp_upgrade_failed" + + # this was initially a dracut script, hence $NEWROOT. + # the rootfs is mounted on /run/upgrade when booted with dmsquash-live +@@ -48,155 +49,6 @@ fi + export NSPAWN_OPTS="$NSPAWN_OPTS --keep-unit --register=no --timezone=off --resolv-conf=off" + + +-export LEAPP_FAILED_FLAG_FILE="/root/tmp_leapp_py3/.leapp_upgrade_failed" +- +-# +-# Temp for collecting and preparing tarball +-# +-LEAPP_DEBUG_TMP="/tmp/leapp-debug-root" +- +-# +-# Number of times to emit all chunks +-# +-# To avoid spammy parts of console log, second and later emissions +-# take longer delay in-between. For example, with N being 3, +-# first emission is done immediately, second after 10s, and the +-# third one after 20s. +-# +-IBDMP_ITER=3 +- +-# +-# Size of one payload chunk +-# +-# IOW, amount of characters in a single chunk of the base64-encoded +-# payload. (By base64 standard, these characters are inherently ASCII, +-# so ie. they correspond to bytes.) +-# +-IBDMP_CHUNKSIZE=40 +- +-collect_and_dump_debug_data() { +- # +- # Collect various debug files and dump tarball using ibdmp +- # +- local tmp=$LEAPP_DEBUG_TMP +- local data=$tmp/data +- mkdir -p "$data" || { echo >&2 "fatal: cannot create leapp dump data dir: $data"; exit 4; } +- journalctl -amo verbose >"$data/journalctl.log" +- mkdir -p "$data/var/lib/leapp" +- mkdir -p "$data/var/log" +- cp -vr "$NEWROOT/var/lib/leapp/leapp.db" \ +- "$data/var/lib/leapp" +- cp -vr "$NEWROOT/var/log/leapp" \ +- "$data/var/log" +- tar -cJf "$tmp/data.tar.xz" "$data" +- ibdmp "$tmp/data.tar.xz" +- rm -r "$tmp" +-} +- +-want_inband_dump() { +- # +- # True if dump collection is needed given leapp exit status $1 and kernel option +- # +- local leapp_es=$1 +- local mode +- local kopt +- kopt=$(getarg 'rd.upgrade.inband') +- case $kopt in +- always|never|onerror) mode="$kopt" ;; +- "") mode="never" ;; +- *) warn "ignoring unknown value of rd.upgrade.inband (dump will be disabled): '$kopt'" +- return 2 ;; +- esac +- case $mode:$leapp_es in +- always:*) return 0 ;; +- never:*) return 1 ;; +- onerror:0) return 1 ;; +- onerror:*) return 0 ;; +- esac +-} +- +-ibdmp() { +- # +- # Dump tarball $1 in base64 to stdout +- # +- # Tarball is encoded in a way that: +- # +- # * final data can be printed to plain text terminal, +- # * tarball can be restored by scanning the saved +- # terminal output, +- # * corruptions caused by extra terminal noise +- # (extra lines, extra characters within lines, +- # line splits..) can be corrected. +- # +- # That is, +- # +- # 1. encode tarball using base64 +- # +- # 2. prepend line `chunks=CHUNKS,md5=MD5` where +- # MD5 is the MD5 digest of original tarball and +- # CHUNKS is number of upcoming Base64 chunks +- # +- # 3. decorate each chunk with prefix `N:` where +- # N is number of given chunk. +- # +- # 4. Finally print all lines (prepended "header" +- # line and all chunks) several times, where +- # every iteration should be prefixed by +- # `_ibdmp:I/TTL|` and suffixed by `|`. +- # where `I` is iteration number and `TTL` is +- # total iteration numbers. +- # +- # Decoder should look for strings like this: +- # +- # _ibdmp:I/J|CN:PAYLOAD| +- # +- # where I, J and CN are integers and PAYLOAD is a slice of a +- # base64 string. +- # +- # Here, I represents number of iteration, J total of iterations +- # ($IBDMP_ITER), and CN is number of given chunk within this +- # iteration. CN goes from 1 up to number of chunks (CHUNKS) +- # predicted by header. +- # +- # Each set corresponds to one dump of the tarball and error +- # correction is achieved by merging sets using these rules: +- # +- # 1. each set has to contain header (`chunks=CHUNKS, +- # md5=MD5`) prevalent header wins. +- # +- # 2. each set has to contain number of chunks +- # as per header +- # +- # 3. chunks are numbered so they can be compared across +- # sets; prevalent chunk wins. +- # +- # Finally the merged set of chunks is decoded as base64. +- # Resulting data has to match md5 sum or we're hosed. +- # +- local tarball=$1 +- local tmp=$LEAPP_DEBUG_TMP/ibdmp +- local md5 +- local i +- mkdir -p "$tmp" +- base64 -w "$IBDMP_CHUNKSIZE" "$tarball" > "$tmp/b64" +- md5=$(md5sum "$tarball" | sed 's/ .*//') +- chunks=$(wc -l <"$tmp/b64") +- ( +- set +x +- echo "chunks=$chunks,md5=$md5" +- cnum=1 +- while read -r chunk; do +- echo "$cnum:$chunk" +- ((cnum++)) +- done <"$tmp/b64" +- ) >"$tmp/report" +- i=0 +- while test "$i" -lt "$IBDMP_ITER"; do +- sleep "$((i * 10))" +- ((i++)) +- sed "s%^%_ibdmp:$i/$IBDMP_ITER|%; s%$%|%; " <"$tmp/report" +- done +-} + + do_upgrade() { + local args="" rv=0 +@@ -218,7 +70,7 @@ do_upgrade() { + + # NOTE: We disable shell-check since we want to word-break NSPAWN_OPTS + # shellcheck disable=SC2086 +- /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a; $LEAPPBIN upgrade --resume $args" ++ /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a || : ; $LEAPPBIN upgrade --resume $args" + rv=$? + + # NOTE: flush the cached content to disk to ensure everything is written +@@ -227,10 +79,6 @@ do_upgrade() { + ## TODO: implement "Break after LEAPP upgrade stop" + + if [ "$rv" -eq 0 ]; then +- # run leapp to proceed phases after the upgrade with Python3 +- #PY_LEAPP_PATH=/usr/lib/python2.7/site-packages/leapp/ +- #$NEWROOT/bin/systemd-nspawn $NSPAWN_OPTS -D $NEWROOT -E PYTHONPATH="${PYTHONPATH}:${PY_LEAPP_PATH}" /usr/bin/python3 $LEAPPBIN upgrade --resume $args +- + # on aarch64 systems during el8 to el9 upgrades the swap is broken due to change in page size (64K to 4k) + # adjust the page size before booting into the new system, as it is possible the swap is necessary for to boot + # `arch` command is not available in the dracut shell, using uname -m instead +@@ -255,7 +103,7 @@ do_upgrade() { + + # NOTE: We disable shell-check since we want to word-break NSPAWN_OPTS + # shellcheck disable=SC2086 +- /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a; /usr/bin/python3 -B $LEAPP3_BIN upgrade --resume $args" ++ /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a || : ; /usr/bin/python3 -B $LEAPP3_BIN upgrade --resume $args" + rv=$? + fi + +@@ -265,14 +113,14 @@ do_upgrade() { + local dirname + dirname="$("$NEWROOT/bin/dirname" "$NEWROOT$LEAPP_FAILED_FLAG_FILE")" + [ -d "$dirname" ] || mkdir "$dirname" ++ ++ echo >&2 "Creating file $NEWROOT$LEAPP_FAILED_FLAG_FILE" ++ echo >&2 "Warning: Leapp upgrade failed and there is an issue blocking the upgrade." ++ echo >&2 "Please file a support case with /var/log/leapp/leapp-upgrade.log attached." ++ + "$NEWROOT/bin/touch" "$NEWROOT$LEAPP_FAILED_FLAG_FILE" + fi + +- # Dump debug data in case something went wrong +- ##if want_inband_dump "$rv"; then +- ## collect_and_dump_debug_data +- ##fi +- + # NOTE: THIS SHOULD BE AGAIN PART OF LEAPP IDEALLY + ## backup old product id certificates + #chroot $NEWROOT /bin/sh -c 'mkdir /etc/pki/product_old; mv -f /etc/pki/product/*.pem /etc/pki/product_old/' +@@ -289,7 +137,7 @@ do_upgrade() { + + save_journal() { + # Q: would it be possible that journal will not be flushed completely yet? +- echo "writing logs to disk and rebooting" ++ echo "writing logs to disk" + + local logfile="/sysroot/tmp-leapp-upgrade.log" + +@@ -306,7 +154,7 @@ save_journal() { + + # We need to run the actual saving of leapp-upgrade.log in a container and mount everything before, to be + # sure /var/log is mounted in case it is on a separate partition. +- local store_cmd="mount -a" ++ local store_cmd="mount -a || : " + local store_cmd="$store_cmd; cat /tmp-leapp-upgrade.log >> /var/log/leapp/leapp-upgrade.log" + + # NOTE: We disable shell-check since we want to word-break NSPAWN_OPTS +@@ -333,10 +181,10 @@ awk '{print $1}' /proc/cmdline \ + # check if leapp previously failed in the initramfs, if it did return to the emergency shell + [ -f "$NEWROOT$LEAPP_FAILED_FLAG_FILE" ] && { + echo >&2 "Found file $NEWROOT$LEAPP_FAILED_FLAG_FILE" +- echo >&2 "Error: Leapp previously failed and cannot continue, returning back to emergency shell" +- echo >&2 "Please file a support case with $NEWROOT/var/log/leapp/leapp-upgrade.log attached" +- echo >&2 "To rerun the upgrade upon exiting the dracut shell remove the $NEWROOT$LEAPP_FAILED_FLAG_FILE file" +- exit 1 ++ echo >&2 "Warning: Leapp failed on a previous execution and something might be blocking the upgrade." ++ echo >&2 "Continuing with the upgrade anyway. Note that any subsequent error might be potentially misleading due to a previous failure." ++ echo >&2 "A log file will be generated at $NEWROOT/var/log/leapp/leapp-upgrade.log." ++ echo >&2 "In case of persisting failure, if possible, try to boot to the original system and file a support case with /var/log/leapp/leapp-upgrade.log attached." + } + + [ ! -x "$NEWROOT$LEAPPBIN" ] && { +@@ -348,7 +196,7 @@ awk '{print $1}' /proc/cmdline \ + ) + result=$? + +-##### safe the data ##### ++##### save the data ##### + save_journal + + # NOTE: flush the cached content to disk to ensure everything is written +@@ -365,7 +213,12 @@ sync + #Failed to talk to init daemon: Host is down + #""" + if [ "$result" == "0" ]; then +- [ -f "${NEWROOT}/.noreboot" ] || reboot ++ if [ -f "${NEWROOT}/.noreboot" ]; then ++ echo "Reboot suppressed by ${NEWROOT}/.noreboot" ++ else ++ echo "Rebooting..." ++ reboot ++ fi + else + echo >&2 "The upgrade container returned a non-zero exit code." + exit $result diff --git a/repos/system_upgrade/common/actors/missinggpgkeysinhibitor/libraries/missinggpgkey.py b/repos/system_upgrade/common/actors/missinggpgkeysinhibitor/libraries/missinggpgkey.py index 32e4527b..1e595e9a 100644 --- a/repos/system_upgrade/common/actors/missinggpgkeysinhibitor/libraries/missinggpgkey.py @@ -4000,6 +5177,169 @@ index 32e4527b..1e595e9a 100644 @suppress_deprecation(TMPTargetRepositoriesFacts) +diff --git a/repos/system_upgrade/common/actors/multipath/system_conf_patcher/libraries/system_config_patcher.py b/repos/system_upgrade/common/actors/multipath/system_conf_patcher/libraries/system_config_patcher.py +index 0d873322..0d1fd070 100644 +--- a/repos/system_upgrade/common/actors/multipath/system_conf_patcher/libraries/system_config_patcher.py ++++ b/repos/system_upgrade/common/actors/multipath/system_conf_patcher/libraries/system_config_patcher.py +@@ -1,3 +1,4 @@ ++import os + import shutil + + from leapp.libraries.stdlib import api +@@ -14,4 +15,8 @@ def patch_system_configs(): + ) + ) + ++ os.makedirs( ++ os.path.dirname(modified_config.target_path), ++ exist_ok=True ++ ) + shutil.copy(modified_config.updated_config_location, modified_config.target_path) +diff --git a/repos/system_upgrade/common/actors/multipath/target_uspace_configs/libraries/target_uspace_multipath_configs.py b/repos/system_upgrade/common/actors/multipath/target_uspace_configs/libraries/target_uspace_multipath_configs.py +index 72afc477..f294a15e 100644 +--- a/repos/system_upgrade/common/actors/multipath/target_uspace_configs/libraries/target_uspace_multipath_configs.py ++++ b/repos/system_upgrade/common/actors/multipath/target_uspace_configs/libraries/target_uspace_multipath_configs.py +@@ -22,7 +22,14 @@ def request_mpath_confs(multipath_info): + '/etc/multipath.conf': '/etc/multipath.conf' # default config + } + +- if os.path.exists(multipath_info.config_dir): ++ for conf_file in ( ++ multipath_info.bindings_file, ++ multipath_info.wwids_file, ++ multipath_info.prkeys_file ++ ): ++ if conf_file and os.path.exists(conf_file): ++ files_to_put_into_uspace[conf_file] = conf_file ++ if multipath_info.config_dir and os.path.exists(multipath_info.config_dir): + for filename in os.listdir(multipath_info.config_dir): + config_path = os.path.join(multipath_info.config_dir, filename) + if not config_path.endswith('.conf'): +diff --git a/repos/system_upgrade/common/actors/multipath/target_uspace_configs/tests/test_target_uspace_configs.py b/repos/system_upgrade/common/actors/multipath/target_uspace_configs/tests/test_target_uspace_configs.py +index ffb63322..ee199df4 100644 +--- a/repos/system_upgrade/common/actors/multipath/target_uspace_configs/tests/test_target_uspace_configs.py ++++ b/repos/system_upgrade/common/actors/multipath/target_uspace_configs/tests/test_target_uspace_configs.py +@@ -84,3 +84,120 @@ def test_production_conditions(monkeypatch, multipath_info, should_produce): + assert dracut_modules == ['multipath'] + else: + assert not produce_mock.called ++ ++ ++def test_file_locations_copied(monkeypatch): ++ """Test that bindings/wwids/prkeys files are copied to target userspace.""" ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ multipath_info = MultipathInfo( ++ is_configured=True, ++ config_dir='/etc/multipath/conf.d', ++ bindings_file='/etc/multipath/bindings', ++ wwids_file='/etc/multipath/wwids', ++ prkeys_file='/etc/multipath/prkeys', ++ ) ++ msgs = [multipath_info, MultipathConfigUpdatesInfo(updates=[])] ++ actor_mock = CurrentActorMocked(msgs=msgs) ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ existing_files = { ++ '/etc/multipath/conf.d', ++ '/etc/multipath/bindings', ++ '/etc/multipath/wwids', ++ '/etc/multipath/prkeys', ++ } ++ ++ def exists_mock(path): ++ return path in existing_files ++ ++ monkeypatch.setattr(os.path, 'exists', exists_mock) ++ monkeypatch.setattr(os, 'listdir', lambda path: []) ++ ++ actor_lib.process() ++ ++ _target_uspace_tasks = [ ++ msg for msg in produce_mock.model_instances if isinstance(msg, TargetUserSpaceUpgradeTasks) ++ ] ++ assert len(_target_uspace_tasks) == 1 ++ ++ copies = {(copy.src, copy.dst) for copy in _target_uspace_tasks[0].copy_files} ++ assert ('/etc/multipath/bindings', '/etc/multipath/bindings') in copies ++ assert ('/etc/multipath/wwids', '/etc/multipath/wwids') in copies ++ assert ('/etc/multipath/prkeys', '/etc/multipath/prkeys') in copies ++ ++ ++def test_file_locations_not_copied_when_missing(monkeypatch): ++ """Test that non-existent files are not copied.""" ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ multipath_info = MultipathInfo( ++ is_configured=True, ++ config_dir='/etc/multipath/conf.d', ++ bindings_file='/etc/multipath/bindings', ++ ) ++ msgs = [multipath_info, MultipathConfigUpdatesInfo(updates=[])] ++ actor_mock = CurrentActorMocked(msgs=msgs) ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ def exists_mock(path): ++ return path == '/etc/multipath/conf.d' ++ ++ monkeypatch.setattr(os.path, 'exists', exists_mock) ++ monkeypatch.setattr(os, 'listdir', lambda path: []) ++ ++ actor_lib.process() ++ ++ _target_uspace_tasks = [ ++ msg for msg in produce_mock.model_instances if isinstance(msg, TargetUserSpaceUpgradeTasks) ++ ] ++ assert len(_target_uspace_tasks) == 1 ++ ++ copies = {copy.src for copy in _target_uspace_tasks[0].copy_files} ++ # bindings_file doesn't exist on disk, so should not be copied ++ assert '/etc/multipath/bindings' not in copies ++ ++ ++def test_file_locations_overridden_by_updates(monkeypatch): ++ """Test that UpdatedMultipathConfig entries override file copy sources.""" ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ multipath_info = MultipathInfo( ++ is_configured=True, ++ config_dir='/etc/multipath/conf.d', ++ bindings_file='/etc/multipath/bindings', ++ ) ++ # The patcher says to move /tmp/bindings -> /etc/multipath/bindings ++ update = UpdatedMultipathConfig( ++ updated_config_location='/tmp/bindings', ++ target_path='/etc/multipath/bindings' ++ ) ++ msgs = [multipath_info, MultipathConfigUpdatesInfo(updates=[update])] ++ actor_mock = CurrentActorMocked(msgs=msgs) ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ existing_files = { ++ '/etc/multipath/conf.d', ++ '/etc/multipath/bindings', ++ } ++ ++ def exists_mock(path): ++ return path in existing_files ++ ++ monkeypatch.setattr(os.path, 'exists', exists_mock) ++ monkeypatch.setattr(os, 'listdir', lambda path: []) ++ ++ actor_lib.process() ++ ++ _target_uspace_tasks = [ ++ msg for msg in produce_mock.model_instances if isinstance(msg, TargetUserSpaceUpgradeTasks) ++ ] ++ assert len(_target_uspace_tasks) == 1 ++ ++ copies = {(copy.src, copy.dst) for copy in _target_uspace_tasks[0].copy_files} ++ # Original bindings file should be removed and replaced by the update source ++ assert ('/etc/multipath/bindings', '/etc/multipath/bindings') not in copies ++ assert ('/tmp/bindings', '/etc/multipath/bindings') in copies diff --git a/repos/system_upgrade/common/actors/peseventsscanner/actor.py b/repos/system_upgrade/common/actors/peseventsscanner/actor.py index f801f1a1..cb911471 100644 --- a/repos/system_upgrade/common/actors/peseventsscanner/actor.py @@ -4205,6 +5545,56 @@ index 6ba16ced..e7c37b24 100644 if rpm_tasks: + rpm_tasks.to_reinstall = sorted(pkgs_to_reinstall) api.produce(rpm_tasks) +diff --git a/repos/system_upgrade/common/actors/removeresumeservice/actor.py b/repos/system_upgrade/common/actors/removeresumeservice/actor.py +index 59935394..cf22ad07 100644 +--- a/repos/system_upgrade/common/actors/removeresumeservice/actor.py ++++ b/repos/system_upgrade/common/actors/removeresumeservice/actor.py +@@ -21,13 +21,16 @@ class RemoveSystemdResumeService(Actor): + tags = (FirstBootPhaseTag.After, IPUWorkflowTag) + + def process(self): ++ systemd_dir = '/etc/systemd/system' + service_name = 'leapp_resume.service' +- if os.path.isfile('/etc/systemd/system/{}'.format(service_name)): ++ target_name = 'multi-user.target' ++ ++ service_path = os.path.join(systemd_dir, service_name) ++ target_wants_path = os.path.join(systemd_dir, '{}.wants'.format(target_name), service_name) ++ ++ if os.path.isfile(service_path): + run(['systemctl', 'disable', service_name]) +- paths_to_unlink = [ +- '/etc/systemd/system/{}'.format(service_name), +- '/etc/systemd/system/default.target.wants/{}'.format(service_name), +- ] ++ paths_to_unlink = [service_path, target_wants_path] + for path in paths_to_unlink: + try: + os.unlink(path) +diff --git a/repos/system_upgrade/common/actors/removeupgradebootentry/libraries/removeupgradebootentry.py b/repos/system_upgrade/common/actors/removeupgradebootentry/libraries/removeupgradebootentry.py +index 7434e48c..71be42c0 100644 +--- a/repos/system_upgrade/common/actors/removeupgradebootentry/libraries/removeupgradebootentry.py ++++ b/repos/system_upgrade/common/actors/removeupgradebootentry/libraries/removeupgradebootentry.py +@@ -45,9 +45,16 @@ def remove_boot_entry(): + + # TODO: Move calling `mount -a` to a separate actor as it is not really related to removing the upgrade boot entry. + # It's worth to call it after removing the boot entry to avoid boot loop in case mounting fails. +- run([ +- '/bin/mount', '-a' +- ]) ++ try: ++ run(['/bin/mount', '-a']) ++ except CalledProcessError as err: ++ # Every mount that is not marked with 'nofail' should be mounted when this code is executed, so mount -a can ++ # fail only on corrupted nofail devices. We ignore errors here so that our mount -a behaves consistently ++ # with systemd's mount units marked with 'nofail' -- failures of such units will not prevent the system from ++ # booting. If a mount mounts a block dev with a corrupted FS, then the system could not have used the device, ++ # so it does not contain anything important for the upgrade. ++ api.current_logger().warning('Failed to execute `mount -a` with the following error: {}.'.format(err)) ++ pass + + + def get_upgrade_kernel_filepath(): diff --git a/repos/system_upgrade/common/actors/repositoriesmapping/libraries/repositoriesmapping.py b/repos/system_upgrade/common/actors/repositoriesmapping/libraries/repositoriesmapping.py index 503e66a3..4ec1d6e0 100644 --- a/repos/system_upgrade/common/actors/repositoriesmapping/libraries/repositoriesmapping.py @@ -4339,6 +5729,83 @@ index 503e66a3..4ec1d6e0 100644 def _inhibit_upgrade(msg): local_path = os.path.join('/etc/leapp/file', REPOMAP_FILE) hint = ( +diff --git a/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py b/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py +index 2a2ee03e..100fb32c 100644 +--- a/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py ++++ b/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py +@@ -1,8 +1,8 @@ + import warnings + + from leapp.exceptions import StopActorExecutionError +-from leapp.libraries.common import module as module_lib + from leapp.libraries.common import rpms ++from leapp.libraries.common.dnflibs import dnfmodule + from leapp.libraries.stdlib import api + from leapp.models import InstalledRPM, RPM + +@@ -99,8 +99,14 @@ def get_package_repository_data(): + def map_modular_rpms_to_modules(): + """ + Map modular packages to the module streams they come from. ++ ++ :returns: Mapping of RPM NEVRA tuples to (module_name, stream) tuples ++ :rtype: dict ++ ++ .. seealso:: ++ :func:`leapp.libraries.common.dnflibs.dnfmodule.get_modules` + """ +- modules = module_lib.get_modules() ++ modules = dnfmodule.get_modules() + # empty on RHEL 7 because of no modules + if not modules: + return {} +diff --git a/repos/system_upgrade/common/actors/rpmscanner/tests/test_rpmscanner.py b/repos/system_upgrade/common/actors/rpmscanner/tests/test_rpmscanner.py +index 151a1b2b..9955aff7 100644 +--- a/repos/system_upgrade/common/actors/rpmscanner/tests/test_rpmscanner.py ++++ b/repos/system_upgrade/common/actors/rpmscanner/tests/test_rpmscanner.py +@@ -4,8 +4,8 @@ import pytest + + from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import rpmscanner +-from leapp.libraries.common import module as module_lib + from leapp.libraries.common import rpms, testutils ++from leapp.libraries.common.dnflibs import dnfmodule + from leapp.libraries.stdlib import api + from leapp.models import InstalledRPM, RPM + from leapp.snactor.fixture import current_actor_context +@@ -103,20 +103,20 @@ MODULES = [ + + @pytest.mark.skipif(no_yum and no_dnf, reason='yum/dnf is unavailable') + def test_actor_execution(monkeypatch, current_actor_context): +- monkeypatch.setattr(rpmscanner.module_lib, 'get_modules', lambda: []) ++ monkeypatch.setattr(rpmscanner.dnfmodule, 'get_modules', lambda: []) + current_actor_context.run() + assert current_actor_context.consume(InstalledRPM) + assert current_actor_context.consume(InstalledRPM)[0].items + + + def test_map_modular_rpms_to_modules_empty(monkeypatch): +- monkeypatch.setattr(module_lib, 'get_modules', lambda: []) ++ monkeypatch.setattr(dnfmodule, 'get_modules', lambda: []) + mapping = rpmscanner.map_modular_rpms_to_modules() + assert not mapping + + + def test_map_modular_rpms_to_modules(monkeypatch): +- monkeypatch.setattr(module_lib, 'get_modules', lambda: MODULES) ++ monkeypatch.setattr(dnfmodule, 'get_modules', lambda: MODULES) + mapping = rpmscanner.map_modular_rpms_to_modules() + assert mapping[ + ('afterburn', '0', '4.2.0', '1.module_f31+6825+8330d585', 'x86_64') +@@ -154,7 +154,7 @@ PACKAGE_REPOS = { + + + def test_process(monkeypatch): +- monkeypatch.setattr(module_lib, 'get_modules', lambda: MODULES) ++ monkeypatch.setattr(dnfmodule, 'get_modules', lambda: MODULES) + monkeypatch.setattr(rpmscanner, 'get_package_repository_data', lambda: PACKAGE_REPOS) + monkeypatch.setattr(rpms, 'get_installed_rpms', lambda: INSTALLED_RPMS) + monkeypatch.setattr(api, 'produce', testutils.produce_mocked()) diff --git a/repos/system_upgrade/common/actors/rpmtransactionconfigtaskscollector/libraries/rpmtransactionconfigtaskscollector.py b/repos/system_upgrade/common/actors/rpmtransactionconfigtaskscollector/libraries/rpmtransactionconfigtaskscollector.py index 84895f83..62aefaf4 100644 --- a/repos/system_upgrade/common/actors/rpmtransactionconfigtaskscollector/libraries/rpmtransactionconfigtaskscollector.py @@ -4781,10 +6248,27 @@ index 59b12c87..85d4a09e 100644 def process(self): self.produce(systemfacts.get_sysctls_status()) diff --git a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py -index 2475aad4..9f07cb0f 100644 +index 2475aad4..03bc933b 100644 --- a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py +++ b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py -@@ -69,6 +69,7 @@ PROD_CERTS_FOLDER = 'prod-certs' +@@ -6,7 +6,7 @@ import shutil + from leapp import reporting + from leapp.exceptions import StopActorExecution, StopActorExecutionError + from leapp.libraries.actor import constants +-from leapp.libraries.common import distro, dnfplugin, mounting, overlaygen, repofileutils, rhsm, utils ++from leapp.libraries.common import distro, mounting, overlaygen, repofileutils, rhsm, utils + from leapp.libraries.common.config import ( + get_env, + get_product_type, +@@ -19,6 +19,7 @@ from leapp.libraries.common.config.version import ( + get_target_major_version, + get_target_version + ) ++from leapp.libraries.common.dnflibs import dnfplugin + from leapp.libraries.common.gpg import get_path_to_gpg_certs, is_nogpgcheck_set + from leapp.libraries.stdlib import api, CalledProcessError, config, run + from leapp.models import RequiredTargetUserspacePackages # deprecated +@@ -69,6 +70,7 @@ PROD_CERTS_FOLDER = 'prod-certs' PERSISTENT_PACKAGE_CACHE_DIR = '/var/lib/leapp/persistent_package_cache' DEDICATED_LEAPP_PART_URL = 'https://access.redhat.com/solutions/7011704' FMT_LIST_SEPARATOR = '\n - ' @@ -4792,7 +6276,7 @@ index 2475aad4..9f07cb0f 100644 def _check_deprecated_rhsm_skip(): -@@ -165,9 +166,10 @@ def _import_gpg_keys(context, install_root_dir, target_major_version): +@@ -165,9 +167,10 @@ def _import_gpg_keys(context, install_root_dir, target_major_version): # installation of initial packages try: # Import also any other keys provided by the customer in the same directory @@ -4806,7 +6290,7 @@ index 2475aad4..9f07cb0f 100644 except CalledProcessError as exc: raise StopActorExecutionError( message=( -@@ -215,6 +217,27 @@ def prepare_target_userspace(context, userspace_dir, enabled_repos, packages): +@@ -215,6 +218,27 @@ def prepare_target_userspace(context, userspace_dir, enabled_repos, packages): run(['rm', '-rf', userspace_dir]) _create_target_userspace_directories(userspace_dir) @@ -4834,7 +6318,7 @@ index 2475aad4..9f07cb0f 100644 target_major_version = get_target_major_version() install_root_dir = '/el{}target'.format(target_major_version) with mounting.BindMount(source=userspace_dir, target=os.path.join(context.base_dir, install_root_dir.lstrip('/'))): -@@ -648,9 +671,10 @@ def _prep_repository_access(context, target_userspace): +@@ -648,9 +672,10 @@ def _prep_repository_access(context, target_userspace): # NOTE(dkubek): context.call(['update-ca-trust']) seems to not be working. # I am not really sure why. The changes to files are not # being written to disk. @@ -4846,7 +6330,7 @@ index 2475aad4..9f07cb0f 100644 run(['rm', '-rf', os.path.join(target_etc, 'rhsm')]) context.copytree_from('/etc/rhsm', os.path.join(target_etc, 'rhsm')) -@@ -772,6 +796,70 @@ def _create_target_userspace_directories(target_userspace): +@@ -772,6 +797,70 @@ def _create_target_userspace_directories(target_userspace): ) @@ -4917,7 +6401,7 @@ index 2475aad4..9f07cb0f 100644 def _inhibit_on_duplicate_repos(repofiles): """ Inhibit the upgrade if any repoid is defined multiple times. -@@ -974,8 +1062,8 @@ def _get_distro_available_repoids(context, indata): +@@ -974,8 +1063,8 @@ def _get_distro_available_repoids(context, indata): provider has itw own rpm). On other: Repositories are provided in specific repofiles (e.g. centos.repo and centos-addons.repo on CS) @@ -4928,7 +6412,7 @@ index 2475aad4..9f07cb0f 100644 Conversions: Only custom repos - no distro repoids (all distros) :return: A set of repoids provided by distribution -@@ -987,10 +1075,14 @@ def _get_distro_available_repoids(context, indata): +@@ -987,10 +1076,14 @@ def _get_distro_available_repoids(context, indata): is_source_cs8 = ( get_source_distro_id() == "centos" and get_source_major_version() == '8' ) @@ -5457,18 +6941,2707 @@ index 1a5e3923..380ad241 100644 + return os.path.join(efi.EFI_MOUNTPOINT, "EFI", "centos") + return os.path.join(efi.EFI_MOUNTPOINT, "EFI", distro_id) +diff --git a/repos/system_upgrade/common/libraries/dnfconfig.py b/repos/system_upgrade/common/libraries/dnfconfig.py +index 9f1902b6..b0997836 100644 +--- a/repos/system_upgrade/common/libraries/dnfconfig.py ++++ b/repos/system_upgrade/common/libraries/dnfconfig.py +@@ -1,98 +1,20 @@ +-from leapp.exceptions import StopActorExecutionError +-from leapp.libraries.common.rpms import get_leapp_packages +-from leapp.libraries.stdlib import api, CalledProcessError ++""" ++DEPRECATED: This module has been moved to leapp.libraries.common.dnflibs.dnfconfig + ++This shim will be removed in a future version. Please update imports to: ++ from leapp.libraries.common.dnflibs import dnfconfig ++ # or ++ from leapp.libraries.common import dnflibs ++""" + +-def _strip_split(data, sep, maxsplit=-1): +- """ +- Just like str.split(), but remove ambient whitespaces from all items +- """ +- return [item.strip() for item in data.split(sep, maxsplit)] +- +- +-def _get_main_dump(context, disable_plugins): +- """ +- Return the dnf configuration dump of main options for the given context. +- +- Returns the list of lines after the line with "[main]" section +- """ +- +- cmd = ['dnf', 'config-manager', '--dump'] +- +- if disable_plugins: +- for plugin in disable_plugins: +- cmd += ['--disableplugin', plugin] +- +- try: +- data = context.call(cmd, split=True)['stdout'] +- except CalledProcessError as e: +- api.current_logger().error('Cannot obtain the dnf configuration') +- raise StopActorExecutionError( +- message='Cannot obtain data about the DNF configuration', +- details={'stdout': e.stdout, 'stderr': e.stderr} +- ) +- +- try: +- # return index of the first item in the main section +- main_start = data.index('[main]') + 1 +- except ValueError: +- raise StopActorExecutionError( +- message='Invalid DNF configuration data (missing [main])', +- details=data, +- ) +- +- output_data = {} +- for line in data[main_start:]: +- if not line.strip(): +- continue +- try: +- key, val = _strip_split(line, '=', 1) +- output_data[key] = val +- except ValueError: +- # This is not expected to happen, but call it a seatbelt in case +- # the dnf dump implementation will change and we will miss it +- # This is not such a hard error as the one above, as it means +- # some values could be incomplete, however we are still able +- # to continue. +- api.current_logger().warning( +- 'Cannot parse the dnf dump correctly, line: {}'.format(line)) +- pass +- +- return output_data +- +- +-def _get_excluded_pkgs(context, disable_plugins): +- """ +- Return the list of excluded packages for DNF in the given context. +- +- It shouldn't be used on the source system. It is expected this functions +- is called only in the target userspace container or on the target system. +- """ +- pkgs = _strip_split(_get_main_dump(context, disable_plugins).get('exclude', ''), ',') +- return [i for i in pkgs if i] +- +- +-def _set_excluded_pkgs(context, pkglist, disable_plugins): +- """ +- Configure DNF to exclude packages in the given list +- +- Raise the CalledProcessError on error. +- """ +- exclude = 'exclude={}'.format(','.join(pkglist)) +- cmd = ['dnf', 'config-manager', '--save', '--setopt', exclude] +- +- if disable_plugins: +- for plugin in disable_plugins: +- cmd += ['--disableplugin', plugin] +- +- try: +- context.call(cmd) +- except CalledProcessError: +- api.current_logger().error('Cannot set the dnf configuration') +- raise +- api.current_logger().debug('The DNF configuration has been updated to exclude leapp packages.') ++from leapp.libraries.common.dnflibs import dnfconfig as _dnfconfig ++from leapp.utils.deprecation import deprecated + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfconfig module. ' ++ 'Please update your imports to use the new location.' ++)) + def exclude_leapp_rpms(context, disable_plugins): + """ + Ensure the leapp RPMs are excluded from any DNF transaction. +@@ -104,5 +26,4 @@ def exclude_leapp_rpms(context, disable_plugins): + So user will have to drop these packages from the exclude after the + upgrade. + """ +- to_exclude = list(set(_get_excluded_pkgs(context, disable_plugins) + get_leapp_packages())) +- _set_excluded_pkgs(context, to_exclude, disable_plugins) ++ return _dnfconfig.exclude_leapp_rpms(context, disable_plugins) +diff --git a/repos/system_upgrade/common/libraries/dnflibs/__init__.py b/repos/system_upgrade/common/libraries/dnflibs/__init__.py +new file mode 100644 +index 00000000..787119a7 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/__init__.py +@@ -0,0 +1,83 @@ ++""" ++DNF-related libraries for the upgrade process. ++ ++This package consolidates DNF functionality previously scattered across: ++- leapp.libraries.common.dnfconfig -> dnflibs.dnfconfig ++- leapp.libraries.common.dnfplugin -> dnflibs.dnfplugin ++- leapp.libraries.common.module -> dnflibs.dnfmodule ++""" ++ ++import warnings ++ ++from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common.config.version import get_source_major_version ++ ++try: ++ import dnf ++except ImportError: ++ dnf = None ++ warnings.warn('Could not import the `dnf` python module.', ImportWarning) ++ ++ ++class DNFError(StopActorExecutionError): ++ """ ++ Generic exception inherited by all DNF errors raised in dnflibs libraries. ++ """ ++ ++ ++class DNFRepoError(DNFError): ++ """ ++ Used when DNF fails to load repositories. ++ """ ++ ++ ++def create_dnf_base(): ++ """ ++ Create properly initialized dnf.Base with filled sack. ++ ++ The proper initialisation of dnf.Base object is non-trivial and order of ++ operations matters - we made plenty of mistakes already before the function ++ got to this state, covering various setups and systems. So use it instead ++ of trying to do so manually. ++ ++ :returns: Initialized dnf.Base object with filled sack ++ :rtype: dnf.Base ++ :raises DNFRepoError: When a repository cannot be loaded ++ """ ++ # The DNF command reads /etc/dnf/vars/releasever, but the DNF library does not. It parses redhat-release ++ # package to retrieve system's major version which it then uses as $releasever. However, some systems might ++ # have repositories only for the exact system version (including the minor number). In a case when ++ # /etc/dnf/vars/releasever is present, read its contents so that we can access repositores on such systems. ++ conf = dnf.conf.Conf() ++ ++ # preload releasever from what we know, this will be our fallback ++ conf.substitutions['releasever'] = get_source_major_version() ++ ++ # load all substitutions from etc ++ conf.substitutions.update_from_etc('/') ++ ++ base = dnf.Base(conf=conf) ++ base.conf.read() ++ base.init_plugins() ++ base.read_all_repos() ++ ++ # configure plugins after the repositories are loaded ++ # e.g. the amazon-id plugin requires loaded repositories ++ # for the proper configuration. ++ base.configure_plugins() ++ ++ try: ++ base.fill_sack() ++ except dnf.exceptions.RepoError as e: ++ err_msg = str(e) ++ repoid = err_msg.split('repo:')[-1].strip() if 'repo:' in err_msg else 'unknown repo' ++ repoid = repoid.strip('"').strip("'").replace('\\"', '') ++ raise DNFRepoError( ++ message='DNF failed to load repositories: {}'.format(str(e)), ++ details={ ++ 'hint': 'Ensure the {} repository definition is correct or remove it ' ++ 'if the repository is not needed anymore.'.format(repoid) ++ } ++ ) ++ ++ return base +diff --git a/repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py b/repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py +new file mode 100644 +index 00000000..8872e592 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py +@@ -0,0 +1,138 @@ ++from leapp.libraries.common.dnflibs import DNFError ++from leapp.libraries.common.rpms import get_leapp_packages ++from leapp.libraries.stdlib import api, CalledProcessError ++ ++ ++class InvalidDNFConfig(DNFError): ++ """ ++ Raised when DNF configuration is invalid. ++ """ ++ ++ ++class CannotObtainDNFConfig(DNFError): ++ """ ++ Raised when cannot obtain DNF configuration. ++ """ ++ ++ ++class CannotUpdateDNFConfig(DNFError): ++ """ ++ Raised when the DNF configuration could not be updated. ++ """ ++ ++ ++def _strip_split(data, sep, maxsplit=-1): ++ """ ++ Just like str.split(), but remove ambient whitespaces from all items ++ """ ++ return [item.strip() for item in data.split(sep, maxsplit)] ++ ++ ++def _get_main_dump(context, disable_plugins): ++ """ ++ Return the dnf configuration dump of main options for the given context. ++ ++ :rtype: dict ++ """ ++ ++ cmd = ['dnf', 'config-manager', '--dump'] ++ ++ if disable_plugins: ++ for plugin in disable_plugins: ++ cmd += ['--disableplugin', plugin] ++ ++ try: ++ data = context.call(cmd, split=True)['stdout'] ++ except CalledProcessError as e: ++ api.current_logger().error('Cannot obtain the dnf configuration') ++ raise CannotObtainDNFConfig( ++ message='Cannot obtain data about the DNF configuration', ++ details={'stdout': e.stdout, 'stderr': e.stderr} ++ ) ++ ++ try: ++ # return index of the first item in the main section ++ main_start = data.index('[main]') + 1 ++ except ValueError: ++ raise InvalidDNFConfig( ++ message='Invalid DNF configuration data (missing [main])', ++ details=data, ++ ) ++ ++ output_data = {} ++ for line in data[main_start:]: ++ if not line.strip(): ++ continue ++ try: ++ key, val = _strip_split(line, '=', 1) ++ output_data[key] = val ++ except ValueError: ++ # This is not expected to happen, but call it a seatbelt in case ++ # the dnf dump implementation will change and we will miss it ++ # This is not such a hard error as the one above, as it means ++ # some values could be incomplete, however we are still able ++ # to continue. ++ api.current_logger().warning( ++ 'Cannot parse the dnf dump correctly, line: {}'.format(line)) ++ pass ++ ++ return output_data ++ ++ ++def _get_excluded_pkgs(context, disable_plugins): ++ """ ++ Return the list of excluded packages for DNF in the given context. ++ ++ It shouldn't be used on the source system. It is expected this functions ++ is called only in the target userspace container or on the target system. ++ """ ++ pkgs = _strip_split(_get_main_dump(context, disable_plugins).get('exclude', ''), ',') ++ return [i for i in pkgs if i] ++ ++ ++def _set_excluded_pkgs(context, pkglist, disable_plugins): ++ """ ++ Configure DNF to exclude packages in the given list ++ ++ :raises CannotUpdateDNFConfig: When an attempt to update DNF configuration fails ++ """ ++ exclude = 'exclude={}'.format(','.join(pkglist)) ++ cmd = ['dnf', 'config-manager', '--save', '--setopt', exclude] ++ ++ if disable_plugins: ++ for plugin in disable_plugins: ++ cmd += ['--disableplugin', plugin] ++ ++ try: ++ context.call(cmd) ++ except CalledProcessError as e: ++ api.current_logger().error('Cannot update the dnf configuration') ++ raise CannotUpdateDNFConfig( ++ message='Cannot update the DNF configuration to exclude RPMs of the upgrade tooling.', ++ details={'stdout': e.stdout, 'stderr': e.stderr} ++ ) ++ api.current_logger().debug('The DNF configuration has been updated to exclude leapp packages.') ++ ++ ++def exclude_leapp_rpms(context, disable_plugins): ++ """ ++ Ensure the leapp RPMs are excluded from any DNF transaction. ++ ++ This has to be called several times to ensure that our RPMs are not removed ++ or updated (replaced) during the IPU. The action should happen inside ++ - the target userspace container ++ - on the host system ++ So user will have to drop these packages from the exclude after the ++ upgrade. ++ ++ :param context: The execution context ++ :type context: mounting.IsolatedActions ++ :param disable_plugins: List of plugins supposed to be disabled during DNF execution ++ :type disable_plugins: list ++ :raises InvalidDNFConfig: When the discovered DNF configuratino is invalid. ++ E.g. the [main] section is missing. ++ :raises CannotObtainDNFConfig: The DNF call `dnf config-manager --dump` failed. ++ :raises CannotUpdateDNFConfig: The DNF call to update its configuration failed. ++ """ ++ to_exclude = list(set(_get_excluded_pkgs(context, disable_plugins) + get_leapp_packages())) ++ _set_excluded_pkgs(context, to_exclude, disable_plugins) +diff --git a/repos/system_upgrade/common/libraries/dnflibs/dnfmodule.py b/repos/system_upgrade/common/libraries/dnflibs/dnfmodule.py +new file mode 100644 +index 00000000..b274d917 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfmodule.py +@@ -0,0 +1,90 @@ ++import warnings ++ ++from leapp.libraries.common.dnflibs import create_dnf_base ++ ++try: ++ import dnf ++except ImportError: ++ dnf = None ++ warnings.warn('Could not import the `dnf` python module.', ImportWarning) ++ ++try: ++ import hawkey ++except ImportError: ++ hawkey = None ++ warnings.warn('Could not import the `hawkey` python module.', ImportWarning) ++ ++ ++def get_modules(base=None): ++ """ ++ Return info about all module streams as a list of libdnf.module.ModulePackage objects. ++ ++ The function return an empty list if the DNF python module is not present. ++ ++ :param base: If it is set, use it instead of creating a new one. ++ :type base: dnf.Base | None ++ ++ .. seealso:: ++ :func:`create_dnf_base` for exceptions raised when creating dnf.Base ++ """ ++ if not dnf: ++ return [] ++ if not base: ++ base = create_dnf_base() ++ ++ module_base = dnf.module.module_base.ModuleBase(base) ++ # this method is absent on RHEL 7, in which case there are no modules anyway ++ # note the method could be removed in future as well - so keep the check ++ if not hasattr(module_base, 'get_modules'): ++ return [] ++ return module_base.get_modules('*')[0] ++ ++ ++def get_enabled_modules(): ++ """ ++ Return currently enabled module streams as a list of libdnf.module.ModulePackage objects. ++ ++ The function return an empty list if the DNF python module is not present. ++ ++ :returns: Currently enabled module streams ++ :rtype: List[libdnf.module.ModulePackage] ++ ++ .. seealso:: ++ :func:`get_modules` for exceptions raised during module discovery. ++ :func:`create_dnf_base` for exceptions raised when creating dnf.Base ++ """ ++ if not dnf: ++ return [] ++ ++ base = create_dnf_base() ++ modules = get_modules(base) ++ ++ # if modules are not supported (RHEL 7), base.sack._moduleContainer won't exist ++ # luckily in that case modules are empty and the element won't even be accessed ++ return [m for m in modules if base.sack._moduleContainer.isEnabled(m)] ++ ++ ++def map_installed_rpms_to_modules(): ++ """ ++ Map installed modular packages to the module streams they come from. ++ ++ :returns: Mapping of RPM NVRA tuples to (module_name, stream) tuples ++ :rtype: dict ++ ++ .. seealso:: ++ :func:`get_modules` for exceptions raised during module discovery. ++ """ ++ modules = get_modules() ++ # empty on RHEL 7 because of no modules ++ if not modules: ++ return {} ++ # create a reverse mapping from the RPMS to module streams ++ # key: tuple of 4 strings representing a NVRA (name, version, release, arch) of an RPM ++ # value: tuple of 2 strings representing a module and its stream ++ rpm_streams = {} ++ for module in modules: ++ for rpm in module.getArtifacts(): ++ nevra = hawkey.split_nevra(rpm) ++ rpm_key = (nevra.name, nevra.version, nevra.release, nevra.arch) ++ rpm_streams[rpm_key] = (module.getName(), module.getStream()) ++ return rpm_streams +diff --git a/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +new file mode 100644 +index 00000000..bd9e4596 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +@@ -0,0 +1,682 @@ ++import contextlib ++import itertools ++import json ++import os ++import re ++import shutil ++ ++from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common import guards, mounting, overlaygen, rhsm, utils ++from leapp.libraries.common.config.version import get_target_major_version, get_target_version ++from leapp.libraries.common.dnflibs import dnfconfig, DNFError ++from leapp.libraries.common.gpg import is_nogpgcheck_set ++from leapp.libraries.stdlib import api, CalledProcessError, config ++from leapp.models import DNFWorkaround ++ ++DNF_PLUGIN_NAME = 'rhel_upgrade.py' ++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/' ++ ++_DEDICATED_URL = 'https://access.redhat.com/solutions/7011704' ++_DNF_PLUGIN_PATHS = { ++ '9': os.path.join('/lib/python3.9/site-packages/dnf-plugins', DNF_PLUGIN_NAME), ++ '10': os.path.join('/lib/python3.12/site-packages/dnf-plugins', DNF_PLUGIN_NAME), ++} ++ ++ ++class DNFPluginInstallError(DNFError): ++ """ ++ Raised when cannot install the rhel_upgrade DNF plugin. ++ """ ++ ++ ++class DNFExecutionError(DNFError): ++ """ ++ Raised when cannot execute DNF. ++ """ ++ ++ ++class DNFUpgradeTransactionError(DNFError): ++ """ ++ Raised when an error with the DNF upgrade transaction occurs. ++ ++ This covers all stages of the upgrade, from the initial calculation in ++ preupgrade phases to the final execution of the transaction in the ++ upgrade environment. ++ """ ++ ++ ++class RegisteredWorkaroundApplicationError(StopActorExecutionError): ++ """ ++ Raised when a registered workaround cannot be applied or the application failed. ++ """ ++ ++ ++def install(target_basedir): ++ """ ++ Installs the rhel_upgrade plugin to the DNF plugin under specified container path. ++ ++ :param target_basedir: The target userspace container base directory ++ :type target_basedir: str ++ :raises DNFPluginInstallError: When cannot install the DNF plugin. ++ :raises KeyError: When the installation DNF plugin path is not defined for ++ the target system (yet). ++ """ ++ # NOTE(pstodulk): Keeping KeyError unhandled as this can happen only ++ # for the new major upgrade path. ++ tgt_plugin_path = os.path.join( ++ target_basedir, ++ _DNF_PLUGIN_PATHS[get_target_major_version()].lstrip('/') ++ ) ++ try: ++ shutil.copy2(api.get_file_path(DNF_PLUGIN_NAME), tgt_plugin_path) ++ except EnvironmentError as e: ++ api.current_logger().debug('Failed to install DNF plugin', exc_info=True) ++ raise DNFPluginInstallError( ++ message='Failed to install DNF plugin. Error: {}'.format(str(e)) ++ ) ++ ++ ++def _rebuild_rpm_db(context, root=None): ++ """ ++ Convert rpmdb from BerkeleyDB to Sqlite ++ ++ :param context: Execution context ++ :type context: mounting.IsolatedActions ++ :param root: The system root dir for which the rpmdb should be rebuilt ++ :type root: str ++ """ ++ base_cmd = ['rpmdb', '--rebuilddb'] ++ cmd = base_cmd if not root else base_cmd + ['-r', root] ++ context.call(cmd) ++ ++ ++def build_plugin_data(target_repoids, debug, test, tasks, on_aws): ++ """ ++ Generates a dictionary with the DNF plugin data. ++ ++ :param target_repoids: Target repositories to use for the upgrade ++ :type target_repoids: list ++ :param debug: Whether debug data should be produced ++ :type debug: bool ++ :param test: Flag whether the test of the DNF transaction should be performed ++ :type test: bool ++ :param tasks: Explicit RPM transaction tasks for the DNF plugin ++ :type tasks: FilteredRpmTransactionTasks ++ :param on_aws: Whether running on AWS infrastructure ++ :type on_aws: bool ++ :returns: Dictionary with data for the DNF plugin ++ :rtype: dict ++ """ ++ # get list of repo IDs of target repositories that should be used for upgrade ++ data = { ++ 'pkgs_info': { ++ 'local_rpms': sorted(os.path.join('/installroot', pkg.lstrip('/')) for pkg in tasks.local_rpms), ++ 'to_install': sorted(tasks.to_install), ++ 'to_remove': sorted(tasks.to_remove), ++ 'to_upgrade': sorted(tasks.to_upgrade), ++ 'to_reinstall': sorted(tasks.to_reinstall), ++ 'modules_to_enable': sorted(['{}:{}'.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 is_nogpgcheck_set(), ++ '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. ++ ++ :param context: Execution context ++ :type context: mounting.IsolatedActions ++ :param target_repoids: Target repositories to use for the upgrade ++ :type target_repoids: list ++ :param debug: Whether debug data should be produced ++ :type debug: bool ++ :param test: Flag whether the test of the DNF transaction should be performed ++ :type test: bool ++ :param tasks: Explicit RPM transaction tasks for the DNF plugin ++ :type tasks: FilteredRpmTransactionTasks ++ :param on_aws: Whether running on AWS infrastructure ++ :type on_aws: bool ++ """ ++ 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. ++ ++ :param context: Execution context ++ :type context: mounting.IsolatedActions ++ """ ++ context.copy_from(DNF_PLUGIN_DATA_PATH, DNF_PLUGIN_DATA_LOG_PATH) ++ ++ ++def backup_debug_data(context): ++ """ ++ Performs the backup of DNF debug data ++ ++ :param context: Execution context ++ :type context: mounting.IsolatedActions ++ """ ++ 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(err, is_container=False): ++ NO_SPACE_STR = 'more space needed on the' ++ if NO_SPACE_STR not in err.stderr: ++ message = 'DNF execution failed with non zero exit code.' ++ # if there was a problem reaching repos and proxy is configured in DNF/YUM configs, the ++ # proxy is likely the problem. ++ # NOTE(mmatuska): We can't consistently detect there was a problem reaching some repos, ++ # because it isn't clear what are all the possible DNF error messages we can encounter, ++ # such as: "Failed to synchronize cache for repo ..." or "Errors during downloading ++ # metadata for # repository" or "No more mirrors to try - All mirrors were already tried ++ # without success" ++ # NOTE(mmatuska): We could check PkgManagerInfo to detect if proxy is indeed configured, ++ # however it would be pretty ugly to pass it all the way down here ++ proxy_hint = ( ++ "If there was a problem reaching remote content (see stderr output) and proxy is " ++ "configured in the YUM/DNF configuration file, the proxy configuration is likely " ++ "causing this error. " ++ "Make sure the proxy is properly configured in /etc/dnf/dnf.conf. " ++ "It's also possible the proxy settings in the DNF configuration file are " ++ "incompatible with the target system. A compatible configuration can be " ++ "placed in /etc/leapp/files/dnf.conf which, if present, it will be used during " ++ "some parts of the upgrade instead of original /etc/dnf/dnf.conf. " ++ "In such case the configuration will also be applied to the target system. " ++ "Note that /etc/dnf/dnf.conf needs to be still configured correctly " ++ "for your current system to pass the early phases of the upgrade process." ++ ) ++ details = {'STDOUT': err.stdout, 'STDERR': err.stderr, 'hint': proxy_hint} ++ raise DNFUpgradeTransactionError(message=message, details=details) ++ ++ # Disk Requirements: ++ # At least more space needed on the 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 DNFUpgradeTransactionError(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 ++ """ ++ ++ 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'} ++ ++ if 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 DNFExecutionError( ++ 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(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 ++ ++ Note this function consumes DNFWorkaround messages! An actor calling this ++ function must list the DNFWorkaround in the list of consumable messages. ++ ++ :param context: Execution context (defaults to NotIsolatedActions) ++ :type context: mounting.IsolatedActions | None ++ :raises RegisteredWorkaroundApplicationError: When a workaround script fails to execute. ++ """ ++ context = context or mounting.NotIsolatedActions(base_dir='/') ++ # FIXME(pstodulk): add check that actor is consuming DNFWorkaround, and raise ++ # new error if it is not. ++ 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 RegisteredWorkaroundApplicationError( ++ message=f'Failed to execute script to apply transaction workaround {workaround.display_name}.', ++ details={ ++ 'error message': str(e), ++ 'workaround name': workaround.display_name ++ } ++ ) ++ ++ ++def install_initramdisk_requirements(packages, target_userspace_info, used_repos): ++ """ ++ Performs the installation of packages into the initram disk ++ ++ :param packages: List of package names to install ++ :type packages: list[str] ++ :param target_userspace_info: Information about the target userspace ++ :type target_userspace_info: TargetUserSpaceInfo ++ :param used_repos: Target repositories to use for the installation ++ :type used_repos: Iterable[UsedTargetRepositories] ++ :raises DNFUpgradeTransactionError: When the DNF transaction fails (insufficient disk space, ++ package conflicts, or repository access issues). ++ ++ """ ++ mount_binds = ['/:/installroot'] ++ with _prepare_transaction(used_repos=used_repos, target_userspace_info=target_userspace_info, ++ binds=mount_binds) as (context, target_repoids, _unused): ++ if int(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 is_nogpgcheck_set(): ++ 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(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 ++ ++ :param target_userspace_info: Information about the target userspace ++ :type target_userspace_info: TargetUserSpaceInfo ++ :param storage_info: Storage and filesystem information ++ :type storage_info: StorageInfo ++ :param used_repos: Target repositories to use for the upgrade ++ :type used_repos: UsedTargetRepositories ++ :param tasks: Explicit RPM transaction tasks for the DNF plugin ++ :type tasks: FilteredRpmTransactionTasks ++ :param plugin_info: DNF plugin configuration ++ :type plugin_info: list[DNFPluginTask] ++ :param xfs_info: XFS filesystem information ++ :type xfs_info: XFSPresence ++ :raises DNFExecutionError: When the DNF command fails to execute. ++ :raises DNFUpgradeTransactionError: When the upgrade transaction encounters an error ++ (insufficient disk space, package conflicts, etc.). ++ ++ .. seealso:: ++ :func:`dnfconfig.exclude_leapp_rpms` - For DNF configuration exceptions ++ """ ++ ++ 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', ++ ] ++ ++ # 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 int(get_target_major_version()) >= 9: ++ _rebuild_rpm_db(context, root='/installroot') ++ _transaction( ++ context=context, stage=stage, 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): ++ # noqa: W0135; pylint: disable=bad-option-value,contextmanager-generator-missing-cleanup ++ # NOTE(pstodulk): the pylint check is not valid in this case - finally is covered ++ # implicitly ++ # noqa: W0135 ++ 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 ++ ++ :param target_userspace_info: Information about the target userspace ++ :type target_userspace_info: TargetUserSpaceInfo ++ :param storage_info: Storage and filesystem information ++ :type storage_info: StorageInfo ++ :param used_repos: Target repositories to use for the upgrade ++ :type used_repos: UsedTargetRepositories ++ :param tasks: Explicit RPM transaction tasks for the DNF plugin ++ :type tasks: FilteredRpmTransactionTasks ++ :param plugin_info: DNF plugin configuration ++ :type plugin_info: list[DNFPluginTask] ++ :param xfs_info: XFS filesystem information ++ :type xfs_info: XFSPresence ++ :param target_iso: Optional path to target ISO image ++ :type target_iso: TargetOSInstallationImage | None ++ :raises DNFExecutionError: When the DNF command fails to execute. ++ :raises DNFUpgradeTransactionError: When the transaction check encounters an error ++ (package dependency conflicts, repository issues, etc.). ++ ++ .. seealso:: ++ :func:`dnfconfig.exclude_leapp_rpms` - For DNF configuration exceptions ++ """ ++ ++ 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=stage, 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 ++ ++ :param target_userspace_info: Information about the target userspace ++ :type target_userspace_info: TargetUserSpaceInfo ++ :param storage_info: Storage and filesystem information ++ :type storage_info: StorageInfo ++ :param used_repos: Target repositories to use for the upgrade ++ :type used_repos: UsedTargetRepositories ++ :param tasks: Explicit RPM transaction tasks for the DNF plugin ++ :type tasks: FilteredRpmTransactionTasks ++ :param plugin_info: DNF plugin configuration ++ :type plugin_info: list[DNFPluginTask] ++ :param xfs_info: XFS filesystem information ++ :type xfs_info: XFSPresence ++ :param target_iso: Optional path to target ISO image ++ :type target_iso: TargetOSInstallationImage | None ++ :param on_aws: Whether running on AWS infrastructure ++ :type on_aws: bool ++ :raises DNFExecutionError: When the DNF command fails to execute. ++ :raises DNFUpgradeTransactionError: When the RPM download or transaction test fails ++ (insufficient disk space, network issues, repository problems). ++ ++ .. seealso:: ++ :func:`dnfconfig.exclude_leapp_rpms` - For DNF configuration exceptions ++ """ ++ ++ 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=stage, 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. ++ ++ :param target_userspace_info: Information about the target userspace ++ :type target_userspace_info: TargetUserSpaceInfo ++ :param storage_info: Storage and filesystem information ++ :type storage_info: StorageInfo ++ :param used_repos: Target repositories to use for the upgrade ++ :type used_repos: UsedTargetRepositories ++ :param tasks: Explicit RPM transaction tasks for the DNF plugin ++ :type tasks: FilteredRpmTransactionTasks ++ :param plugin_info: DNF plugin configuration ++ :type plugin_info: list[DNFPluginTask] ++ :param xfs_info: XFS filesystem information ++ :type xfs_info: XFSPresence ++ :param target_iso: Optional path to target ISO image ++ :type target_iso: TargetOSInstallationImage | None ++ :param on_aws: Whether running on AWS infrastructure ++ :type on_aws: bool ++ :raises DNFExecutionError: When the DNF command fails to execute. ++ :raises DNFUpgradeTransactionError: When the dry-run transaction test encounters an error ++ (package dependency conflicts, insufficient disk space). ++ """ ++ 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 ++ ) +diff --git a/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfconfig.py b/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfconfig.py +new file mode 100644 +index 00000000..8cfa75ba +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfconfig.py +@@ -0,0 +1,228 @@ ++import pytest ++ ++from leapp.libraries.common.dnflibs import dnfconfig ++from leapp.libraries.common.testutils import logger_mocked ++from leapp.libraries.stdlib import api, CalledProcessError ++ ++ ++class MockContext: ++ def __init__(self, stdout=None, should_raise=None): ++ self.stdout = stdout or [] ++ self.should_raise = should_raise ++ self.commands = [] ++ ++ @property ++ def last_cmd(self): ++ return self.commands[-1] if self.commands else None ++ ++ def call(self, cmd, split=False): ++ self.commands.append(cmd) ++ if self.should_raise: ++ raise self.should_raise ++ if split: ++ return {'stdout': self.stdout} ++ # TODO(pstodulk): this part is not used now, but if we want to make ++ # this in future more generic, we should update it properly ++ return None ++ ++ ++_SAMPLE_DNF_DUMP = [ ++ '[main]', ++ 'gpgcheck = 1', ++ 'installonly_limit = 3', ++ 'clean_requirements_on_remove = True', ++ 'exclude = pkg1,pkg2', ++] ++ ++ ++@pytest.mark.parametrize('data,sep,maxsplit,expected', [ ++ ('key = value', '=', -1, ['key', 'value']), ++ (' key = value ', '=', -1, ['key', 'value']), ++ ('a,b,c,d', ',', 2, ['a', 'b', 'c,d']), ++ ('pkg1, pkg2, pkg3', ',', -1, ['pkg1', 'pkg2', 'pkg3']), ++]) ++def test_strip_split(data, sep, maxsplit, expected): ++ result = dnfconfig._strip_split(data, sep, maxsplit) ++ assert result == expected ++ ++ ++def test_get_main_dump_success(monkeypatch): ++ context = MockContext(stdout=_SAMPLE_DNF_DUMP) ++ monkeypatch.setattr(api, 'current_logger', logger_mocked()) ++ ++ result = dnfconfig._get_main_dump(context, disable_plugins=None) ++ ++ assert 'gpgcheck' in result ++ assert result['gpgcheck'] == '1' ++ assert result['installonly_limit'] == '3' ++ assert result['exclude'] == 'pkg1,pkg2' ++ ++ ++def test_get_main_dump_with_disabled_plugins(): ++ context = MockContext(stdout=_SAMPLE_DNF_DUMP) ++ ++ dnfconfig._get_main_dump(context, disable_plugins=['plugin1', 'plugin2']) ++ ++ expected_cmd = [ ++ 'dnf', 'config-manager', '--dump', ++ '--disableplugin', 'plugin1', ++ '--disableplugin', 'plugin2' ++ ] ++ assert context.last_cmd == expected_cmd ++ ++ ++def test_get_main_dump_command_fails(monkeypatch): ++ err = CalledProcessError( ++ 'Command failed', ++ ['dnf', 'config-manager', '--dump'], ++ {'stdout': 'stdout output', 'stderr': 'stderr output', 'exit_code': 1} ++ ) ++ context = MockContext(should_raise=err) ++ ++ monkeypatch.setattr(api, 'current_logger', logger_mocked()) ++ ++ with pytest.raises(dnfconfig.CannotObtainDNFConfig) as exc_info: ++ dnfconfig._get_main_dump(context, disable_plugins=None) ++ ++ assert 'Cannot obtain data about the DNF configuration' in str(exc_info.value) ++ assert exc_info.value.details['stdout'] == 'stdout output' ++ assert exc_info.value.details['stderr'] == 'stderr output' ++ ++ ++def test_get_main_dump_missing_main_section(): ++ stdout = [ ++ '[repo1]', ++ 'name = Repository 1', ++ 'enabled = 1' ++ ] ++ context = MockContext(stdout=stdout) ++ ++ with pytest.raises(dnfconfig.InvalidDNFConfig) as exc_info: ++ dnfconfig._get_main_dump(context, disable_plugins=None) ++ ++ assert 'Invalid DNF configuration data (missing [main])' in str(exc_info.value) ++ ++ ++def test_get_main_dump_malformed_line(monkeypatch): ++ stdout = [ ++ '[main]', ++ 'gpgcheck = 1', ++ 'malformed line without equals', ++ 'exclude = pkg1' ++ ] ++ context = MockContext(stdout=stdout) ++ ++ mocked_logger = logger_mocked() ++ monkeypatch.setattr(api, 'current_logger', mocked_logger) ++ ++ result = dnfconfig._get_main_dump(context, disable_plugins=None) ++ ++ assert result['gpgcheck'] == '1' ++ assert result['exclude'] == 'pkg1' ++ assert len(mocked_logger.warnmsg) > 0 ++ assert 'malformed line without equals' in mocked_logger.warnmsg[0] ++ ++ ++@pytest.mark.parametrize('exclude_value,expected', [ ++ ('pkg1, pkg2, pkg3', ['pkg1', 'pkg2', 'pkg3']), ++ ('pkg1,pkg2, pkg3', ['pkg1', 'pkg2', 'pkg3']), ++ ('', []), ++ ('pkg1, , pkg2, ', ['pkg1', 'pkg2']), ++]) ++def test_get_excluded_pkgs(exclude_value, expected): ++ stdout = ['[main]', 'exclude = {}'.format(exclude_value)] if exclude_value else ['[main]', 'gpgcheck = 1'] ++ context = MockContext(stdout=stdout) ++ ++ result = dnfconfig._get_excluded_pkgs(context, disable_plugins=None) ++ ++ assert result == expected ++ ++ ++def test_set_excluded_pkgs_success(): ++ context = MockContext() ++ pkglist = ['pkg1', 'pkg2', 'pkg3'] ++ ++ dnfconfig._set_excluded_pkgs(context, pkglist, disable_plugins=None) ++ ++ expected_cmd = [ ++ 'dnf', 'config-manager', '--save', ++ '--setopt', 'exclude=pkg1,pkg2,pkg3' ++ ] ++ assert context.last_cmd == expected_cmd ++ ++ ++def test_set_excluded_pkgs_with_disabled_plugins(): ++ context = MockContext() ++ ++ dnfconfig._set_excluded_pkgs(context, ['pkg1'], disable_plugins=['plugin1', 'plugin2']) ++ ++ expected_cmd = [ ++ 'dnf', 'config-manager', '--save', ++ '--setopt', 'exclude=pkg1', ++ '--disableplugin', 'plugin1', ++ '--disableplugin', 'plugin2' ++ ] ++ assert context.last_cmd == expected_cmd ++ ++ ++def test_set_excluded_pkgs_fails(monkeypatch): ++ err = CalledProcessError( ++ 'Command failed', ++ ['dnf', 'config-manager', '--save'], ++ {'stdout': 'stdout output', 'stderr': 'stderr output', 'exit_code': 1} ++ ) ++ context = MockContext(should_raise=err) ++ ++ monkeypatch.setattr(api, 'current_logger', logger_mocked()) ++ ++ with pytest.raises(dnfconfig.CannotUpdateDNFConfig) as exc_info: ++ dnfconfig._set_excluded_pkgs(context, ['pkg1'], disable_plugins=None) ++ ++ assert 'Cannot update the DNF configuration' in str(exc_info.value) ++ assert exc_info.value.details['stdout'] == 'stdout output' ++ assert exc_info.value.details['stderr'] == 'stderr output' ++ ++ ++def test_exclude_leapp_rpms(monkeypatch): ++ """ ++ Test that exclude_leapp_rpms merges existing exclusions with leapp packages ++ """ ++ context = MockContext(stdout=[ ++ '[main]', ++ 'exclude = existing-pkg1, existing-pkg2' ++ ]) ++ monkeypatch.setattr(dnfconfig, 'get_leapp_packages', lambda: ['leapp', 'leapp-upgrade-el8toel9']) ++ ++ dnfconfig.exclude_leapp_rpms(context, disable_plugins=None) ++ ++ setopt_cmd = None ++ for cmd in context.commands: ++ if '--setopt' in cmd: ++ setopt_cmd = cmd ++ break ++ ++ assert setopt_cmd is not None ++ exclude_arg = set([arg for arg in setopt_cmd if arg.startswith('exclude=')][0].split('=')[1].split(',')) ++ expected_arg = set(['existing-pkg1', 'existing-pkg2', 'leapp', 'leapp-upgrade-el8toel9']) ++ assert expected_arg == exclude_arg ++ ++ ++def test_exclude_leapp_rpms_no_duplicates(monkeypatch): ++ context = MockContext(stdout=[ ++ '[main]', ++ 'exclude = leapp' ++ ]) ++ monkeypatch.setattr(dnfconfig, 'get_leapp_packages', lambda: ['leapp', 'leapp-upgrade-el8toel9']) ++ ++ dnfconfig.exclude_leapp_rpms(context, disable_plugins=None) ++ ++ setopt_cmd = None ++ for cmd in context.commands: ++ if '--setopt' in cmd: ++ setopt_cmd = cmd ++ break ++ ++ assert setopt_cmd is not None ++ exclude_arg = [arg for arg in setopt_cmd if arg.startswith('exclude=')][0] ++ packages = exclude_arg.replace('exclude=', '').split(',') ++ assert packages.count('leapp') == 1 +diff --git a/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnflibs.py b/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnflibs.py +new file mode 100644 +index 00000000..daf9c125 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnflibs.py +@@ -0,0 +1,118 @@ ++import pytest ++ ++from leapp.libraries.common import dnflibs ++from leapp.libraries.common.testutils import CurrentActorMocked ++from leapp.libraries.stdlib import api ++ ++ ++class MockSubstitutions(dict): ++ def update_from_etc(self, path): ++ pass ++ ++ ++class MockDNFConf: ++ def __init__(self): ++ self.substitutions = MockSubstitutions({'releasever': '8'}) ++ ++ def read(self): ++ pass ++ ++ ++class MockDNFBase: ++ def __init__(self, conf=None): ++ self.conf = conf or MockDNFConf() ++ self._fill_sack_called = False ++ ++ def read(self): ++ pass ++ ++ def init_plugins(self): ++ pass ++ ++ def read_all_repos(self): ++ pass ++ ++ def configure_plugins(self): ++ pass ++ ++ def fill_sack(self): ++ self._fill_sack_called = True ++ ++ ++class MockDNF: ++ """Mock dnf module""" ++ class conf: ++ Conf = MockDNFConf ++ ++ Base = MockDNFBase ++ ++ class exceptions: ++ RepoError = Exception ++ ++ ++def test_create_dnf_base_success(monkeypatch): ++ """ ++ Test successful creation and initialization of dnf.Base ++ """ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='8.10')) ++ monkeypatch.setattr(dnflibs, 'dnf', MockDNF) ++ ++ base = dnflibs.create_dnf_base() ++ ++ assert base is not None ++ assert isinstance(base, MockDNFBase) ++ assert base._fill_sack_called ++ ++ ++@pytest.mark.parametrize('error_message,expected_repoid', [ ++ ('Failed to download metadata for repo: test-repo', 'test-repo'), ++ ('Error with repo: "my-repo"', 'my-repo'), ++ ("Failed for repo: 'quoted-repo'", 'quoted-repo'), ++ ('Generic error without repo information', 'unknown repo'), ++]) ++def test_create_dnf_base_repo_error(monkeypatch, error_message, expected_repoid): ++ class RepoError(Exception): ++ pass ++ ++ class MockExceptions: ++ pass ++ ++ MockExceptions.RepoError = RepoError ++ ++ class FailingMockDNFBase: ++ _repo_error = RepoError ++ _error_message = error_message ++ ++ def __init__(self, conf=None): ++ self.conf = conf or MockDNFConf() ++ ++ def read(self): ++ pass ++ ++ def init_plugins(self): ++ pass ++ ++ def read_all_repos(self): ++ pass ++ ++ def configure_plugins(self): ++ pass ++ ++ def fill_sack(self): ++ raise self._repo_error(self._error_message) ++ ++ class FailingMockDNF: ++ class conf: ++ Conf = MockDNFConf ++ ++ Base = FailingMockDNFBase ++ exceptions = MockExceptions ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='8.10')) ++ monkeypatch.setattr(dnflibs, 'dnf', FailingMockDNF) ++ ++ with pytest.raises(dnflibs.DNFRepoError) as exc_info: ++ dnflibs.create_dnf_base() ++ ++ assert 'DNF failed to load repositories' in str(exc_info.value) ++ assert expected_repoid in str(exc_info.value.details['hint']) +diff --git a/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfmodule.py b/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfmodule.py +new file mode 100644 +index 00000000..82ce62c7 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfmodule.py +@@ -0,0 +1,270 @@ ++""" ++Trivial unit testing of dnfmodule library => rely on integration tests. ++ ++There is actually not a good coverage we could do for this library via ++unit-tests. These tests seem to have a low value as basically DNF & hawkey ++libraries are completely mocked - and there is not a good way to write unit-tests ++without this full mocking. The library is covered by CI tests. ++""" ++ ++from leapp.libraries.common.dnflibs import dnfmodule ++ ++ ++class MockModulePackage: ++ def __init__(self, name, stream, artifacts=None): ++ self._name = name ++ self._stream = stream ++ self._artifacts = artifacts or [] ++ ++ def getName(self): ++ return self._name ++ ++ def getStream(self): ++ return self._stream ++ ++ def getArtifacts(self): ++ return self._artifacts ++ ++ ++class MockNEVRA: ++ def __init__(self, name, version, release, arch): ++ self.name = name ++ self.version = version ++ self.release = release ++ self.arch = arch ++ ++ ++class MockModuleBase: ++ def __init__(self, base): ++ self.base = base ++ self._modules = [] ++ ++ def get_modules(self, pattern): ++ return [self._modules, None] ++ ++ ++class MockModuleContainer: ++ def __init__(self): ++ self._enabled = set() ++ ++ def isEnabled(self, module): ++ return module.getName() in self._enabled ++ ++ def enable(self, module_name): ++ self._enabled.add(module_name) ++ ++ ++class MockSack: ++ def __init__(self): ++ self._moduleContainer = MockModuleContainer() ++ ++ ++class MockDNFBase: ++ def __init__(self): ++ self.sack = MockSack() ++ ++ ++def _split_nevra(rpm_str): ++ """Simple NEVRA parser for testing""" ++ parts = rpm_str.rsplit('.', 1) ++ arch = parts[-1] if len(parts) > 1 else 'noarch' ++ rest = parts[0] if len(parts) > 1 else rpm_str ++ ++ parts = rest.rsplit('-', 2) ++ if len(parts) >= 3: ++ name, version, release = parts[0], parts[1], parts[2] ++ elif len(parts) == 2: ++ name, version, release = parts[0], parts[1], '0' ++ else: ++ name, version, release = parts[0], '0', '0' ++ ++ return MockNEVRA(name, version, release, arch) ++ ++ ++def test_get_modules_with_base(monkeypatch): ++ module1 = MockModulePackage('nodejs', '18') ++ module2 = MockModulePackage('postgresql', '15') ++ ++ base = MockDNFBase() ++ ++ class ModuleBaseWithModules(MockModuleBase): ++ def __init__(self, base): ++ super().__init__(base) ++ self._modules = [module1, module2] ++ ++ class MockDNF: ++ class module: ++ class module_base: ++ ModuleBase = ModuleBaseWithModules ++ ++ monkeypatch.setattr(dnfmodule, 'dnf', MockDNF) ++ ++ modules = dnfmodule.get_modules(base) ++ ++ assert len(modules) == 2 ++ assert modules[0].getName() == 'nodejs' ++ assert modules[1].getName() == 'postgresql' ++ ++ ++def test_get_modules_without_base(monkeypatch): ++ module1 = MockModulePackage('nodejs', '18') ++ ++ def mock_create_dnf_base(): ++ return MockDNFBase() ++ ++ class ModuleBaseWithModules(MockModuleBase): ++ def __init__(self, base): ++ super().__init__(base) ++ self._modules = [module1] ++ ++ class MockDNF: ++ class module: ++ class module_base: ++ ModuleBase = ModuleBaseWithModules ++ ++ monkeypatch.setattr(dnfmodule, 'create_dnf_base', mock_create_dnf_base) ++ monkeypatch.setattr(dnfmodule, 'dnf', MockDNF) ++ ++ modules = dnfmodule.get_modules() ++ ++ assert len(modules) == 1 ++ assert modules[0].getName() == 'nodejs' ++ ++ ++def test_get_modules_no_get_modules_method(monkeypatch): ++ """ ++ Test the compatibility for DNF versions without module stream functionality. ++ ++ This is a known case for RHEL 7, which we do not support in upstream anymore. ++ However, it's possible that future versions of DNF will drop module stream ++ functionality as well. So keeping this test for now, but the functionality ++ can be "broken" in different ways from the one we test at this moment. ++ """ ++ base = MockDNFBase() ++ ++ class LimitedModuleBase: ++ def __init__(self, base): ++ self.base = base ++ ++ class MockDNF: ++ class module: ++ class module_base: ++ ModuleBase = LimitedModuleBase ++ ++ monkeypatch.setattr(dnfmodule, 'dnf', MockDNF) ++ ++ modules = dnfmodule.get_modules(base) ++ ++ assert modules == [] ++ ++ ++def test_get_enabled_modules(monkeypatch): ++ module1 = MockModulePackage('nodejs', '18') ++ module2 = MockModulePackage('postgresql', '15') ++ module3 = MockModulePackage('ruby', '3.0') ++ ++ def mock_create_dnf_base(): ++ base = MockDNFBase() ++ base.sack._moduleContainer.enable('nodejs') ++ base.sack._moduleContainer.enable('ruby') ++ return base ++ ++ class ModuleBaseWithModules(MockModuleBase): ++ def __init__(self, base): ++ super().__init__(base) ++ self._modules = [module1, module2, module3] ++ ++ class MockDNF: ++ class module: ++ class module_base: ++ ModuleBase = ModuleBaseWithModules ++ ++ monkeypatch.setattr(dnfmodule, 'create_dnf_base', mock_create_dnf_base) ++ monkeypatch.setattr(dnfmodule, 'dnf', MockDNF) ++ ++ enabled = dnfmodule.get_enabled_modules() ++ ++ assert len(enabled) == 2 ++ enabled_names = [m.getName() for m in enabled] ++ assert 'nodejs' in enabled_names ++ assert 'ruby' in enabled_names ++ assert 'postgresql' not in enabled_names ++ ++ ++def test_get_enabled_modules_no_dnf(monkeypatch): ++ monkeypatch.setattr(dnfmodule, 'dnf', None) ++ ++ enabled = dnfmodule.get_enabled_modules() ++ ++ assert enabled == [] ++ ++ ++def test_map_installed_rpms_to_modules(monkeypatch): ++ module1 = MockModulePackage('nodejs', '18', artifacts=[ ++ 'nodejs-18.0.0-1.x86_64', ++ 'npm-8.0.0-1.x86_64' ++ ]) ++ module2 = MockModulePackage('postgresql', '15', artifacts=[ ++ 'postgresql-15.0-1.x86_64', ++ 'postgresql-server-15.0-1.x86_64' ++ ]) ++ ++ class ModuleBaseWithModules(MockModuleBase): ++ def __init__(self, base): ++ super().__init__(base) ++ self._modules = [module1, module2] ++ ++ class MockDNF: ++ class module: ++ class module_base: ++ ModuleBase = ModuleBaseWithModules ++ ++ class MockHawkey: ++ split_nevra = staticmethod(_split_nevra) ++ ++ def mock_create_dnf_base(): ++ return MockDNFBase() ++ ++ monkeypatch.setattr(dnfmodule, 'create_dnf_base', mock_create_dnf_base) ++ monkeypatch.setattr(dnfmodule, 'dnf', MockDNF) ++ monkeypatch.setattr(dnfmodule, 'hawkey', MockHawkey) ++ ++ rpm_map = dnfmodule.map_installed_rpms_to_modules() ++ ++ assert ('nodejs', '18.0.0', '1', 'x86_64') in rpm_map ++ assert rpm_map[('nodejs', '18.0.0', '1', 'x86_64')] == ('nodejs', '18') ++ ++ assert ('npm', '8.0.0', '1', 'x86_64') in rpm_map ++ assert rpm_map[('npm', '8.0.0', '1', 'x86_64')] == ('nodejs', '18') ++ ++ assert ('postgresql', '15.0', '1', 'x86_64') in rpm_map ++ assert rpm_map[('postgresql', '15.0', '1', 'x86_64')] == ('postgresql', '15') ++ ++ assert ('postgresql-server', '15.0', '1', 'x86_64') in rpm_map ++ assert rpm_map[('postgresql-server', '15.0', '1', 'x86_64')] == ('postgresql', '15') ++ ++ ++def test_map_installed_rpms_to_modules_empty(monkeypatch): ++ class EmptyModuleBase(MockModuleBase): ++ def __init__(self, base): ++ super().__init__(base) ++ self._modules = [] ++ ++ class MockDNF: ++ class module: ++ class module_base: ++ ModuleBase = EmptyModuleBase ++ ++ class MockHawkey: ++ split_nevra = staticmethod(_split_nevra) ++ ++ def mock_create_dnf_base(): ++ return MockDNFBase() ++ ++ monkeypatch.setattr(dnfmodule, 'create_dnf_base', mock_create_dnf_base) ++ monkeypatch.setattr(dnfmodule, 'dnf', MockDNF) ++ monkeypatch.setattr(dnfmodule, 'hawkey', MockHawkey) ++ ++ rpm_map = dnfmodule.map_installed_rpms_to_modules() ++ ++ assert rpm_map == {} +diff --git a/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfplugin.py b/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfplugin.py +new file mode 100644 +index 00000000..18908934 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfplugin.py +@@ -0,0 +1,294 @@ ++import os ++from unittest.mock import MagicMock ++ ++import pytest ++ ++from leapp.libraries.common.dnflibs import dnfplugin ++from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked ++from leapp.libraries.stdlib import api, CalledProcessError ++from leapp.models import DNFWorkaround, Module ++ ++ ++class MockContext: ++ def __init__(self, should_raise=None): ++ self.should_raise = should_raise ++ self.makedirs_calls = [] ++ self.open_calls = [] ++ self.copy_from_calls = [] ++ self.copytree_from_calls = [] ++ self.call_calls = [] ++ self._files = {} ++ ++ def makedirs(self, path, exists_ok=False): ++ self.makedirs_calls.append((path, exists_ok)) ++ ++ def open(self, path, mode): ++ self.open_calls.append((path, mode)) ++ mock_file = MagicMock() ++ self._files[path] = mock_file ++ return mock_file ++ ++ def copy_from(self, src, dst): ++ self.copy_from_calls.append((src, dst)) ++ ++ def copytree_from(self, src, dst): ++ self.copytree_from_calls.append((src, dst)) ++ if self.should_raise: ++ raise self.should_raise ++ ++ def call(self, cmd, **kwargs): ++ self.call_calls.append(cmd) ++ if self.should_raise: ++ raise self.should_raise ++ ++ ++class MockTasks: ++ def __init__(self): ++ self.local_rpms = ['/path/to/rpm1.rpm', '/path/to/rpm2.rpm'] ++ self.to_install = ['pkg1', 'pkg2'] ++ self.to_remove = ['old-pkg1', 'old-pkg2'] ++ self.to_upgrade = ['upgrade-pkg1', 'upgrade-pkg2'] ++ self.modules_to_enable = [] ++ self.modules_to_reset = [] ++ ++ ++@pytest.mark.parametrize('target_version', ['9', '10']) ++def test_install_success(monkeypatch, leapp_tmpdir, target_version): ++ target_basedir = leapp_tmpdir ++ ++ plugin_source = os.path.join(leapp_tmpdir, dnfplugin.DNF_PLUGIN_NAME) ++ target_plugin_dir = os.path.join( ++ target_basedir, ++ dnfplugin._DNF_PLUGIN_PATHS[target_version].lstrip('/') ++ ) ++ ++ def mocked_copy2(src, dst): ++ assert plugin_source == src ++ assert target_plugin_dir == dst ++ ++ def mocked_get_file_path(fpath): ++ assert fpath == dnfplugin.DNF_PLUGIN_NAME ++ return plugin_source ++ ++ monkeypatch.setattr(dnfplugin, 'get_target_major_version', lambda: target_version) ++ monkeypatch.setattr(api, 'get_file_path', mocked_get_file_path) ++ monkeypatch.setattr(dnfplugin.shutil, 'copy2', mocked_copy2) ++ ++ dnfplugin.install(target_basedir) ++ ++ ++def test_install_failure(monkeypatch, leapp_tmpdir): ++ target_basedir = leapp_tmpdir ++ ++ monkeypatch.setattr(dnfplugin, 'get_target_major_version', lambda: '9') ++ monkeypatch.setattr(api, 'get_file_path', lambda name: '/nonexistent/file') ++ monkeypatch.setattr(api, 'current_logger', logger_mocked()) ++ ++ with pytest.raises(dnfplugin.DNFPluginInstallError) as exc_info: ++ dnfplugin.install(target_basedir) ++ ++ assert 'Failed to install DNF plugin' in str(exc_info.value) ++ ++ ++def test_install_unsupported_version(monkeypatch, leapp_tmpdir): ++ target_basedir = leapp_tmpdir ++ ++ def mocked_copy2(dummy_src, dummy_dst): ++ assert False ++ ++ monkeypatch.setattr(dnfplugin, 'get_target_major_version', lambda: '99') ++ monkeypatch.setattr(dnfplugin.shutil, 'copy2', mocked_copy2) ++ ++ with pytest.raises(KeyError): ++ dnfplugin.install(target_basedir) ++ ++ ++@pytest.mark.parametrize('debug,test,on_aws', [ ++ (True, False, False), ++ (False, True, True), ++ (True, True, False), ++]) ++def test_build_plugin_data(monkeypatch, debug, test, on_aws): ++ tasks = MockTasks() ++ module1 = Module(name='nodejs', stream='18') ++ module2 = Module(name='postgresql', stream='15') ++ tasks.modules_to_enable = [module1, module2] ++ ++ target_repoids = ['rhel-9-baseos', 'rhel-9-appstream'] ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(dst_ver='9.3')) ++ monkeypatch.setattr(dnfplugin, 'is_nogpgcheck_set', lambda: False) ++ ++ data = dnfplugin.build_plugin_data(target_repoids, debug, test, tasks, on_aws) ++ ++ assert data['pkgs_info']['local_rpms'] == ['/installroot/path/to/rpm1.rpm', '/installroot/path/to/rpm2.rpm'] ++ assert data['pkgs_info']['to_install'] == ['pkg1', 'pkg2'] ++ assert data['pkgs_info']['to_remove'] == ['old-pkg1', 'old-pkg2'] ++ assert data['pkgs_info']['to_upgrade'] == ['upgrade-pkg1', 'upgrade-pkg2'] ++ assert 'nodejs:18' in data['pkgs_info']['modules_to_enable'] ++ assert 'postgresql:15' in data['pkgs_info']['modules_to_enable'] ++ ++ assert data['dnf_conf']['debugsolver'] is debug ++ assert data['dnf_conf']['enable_repos'] == target_repoids ++ assert data['dnf_conf']['platform_id'] == 'platform:el9' ++ assert data['dnf_conf']['releasever'] == '9.3' ++ assert data['dnf_conf']['test_flag'] is test ++ assert data['dnf_conf']['gpgcheck'] is True ++ ++ assert data['rhui']['aws']['on_aws'] is on_aws ++ ++ ++def test_build_plugin_data_with_nogpgcheck(monkeypatch): ++ tasks = MockTasks() ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(dst_ver='9.3')) ++ monkeypatch.setattr(dnfplugin, 'is_nogpgcheck_set', lambda: True) ++ ++ data = dnfplugin.build_plugin_data([], False, False, tasks, False) ++ ++ assert data['dnf_conf']['gpgcheck'] is False ++ ++ ++def test_create_config(monkeypatch): ++ context = MockContext() ++ tasks = MockTasks() ++ target_repoids = ['repo1', 'repo2'] ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(dst_ver='9.3')) ++ monkeypatch.setattr(dnfplugin, 'is_nogpgcheck_set', lambda: False) ++ ++ dnfplugin.create_config(context, target_repoids, debug=True, test=False, tasks=tasks, on_aws=False) ++ ++ assert len(context.makedirs_calls) == 1 ++ assert context.makedirs_calls[0][0] == os.path.dirname(dnfplugin.DNF_PLUGIN_DATA_PATH) ++ ++ assert len(context.open_calls) == 1 ++ assert context.open_calls[0][0] == dnfplugin.DNF_PLUGIN_DATA_PATH ++ assert context.open_calls[0][1] == 'w+' ++ ++ ++def test_backup_config(): ++ context = MockContext() ++ ++ dnfplugin.backup_config(context) ++ ++ assert len(context.copy_from_calls) == 1 ++ assert context.copy_from_calls[0] == (dnfplugin.DNF_PLUGIN_DATA_PATH, dnfplugin.DNF_PLUGIN_DATA_LOG_PATH) ++ ++ ++def test_backup_debug_data_with_debug(monkeypatch): ++ context = MockContext() ++ ++ monkeypatch.setattr(dnfplugin.config, 'is_debug', lambda: True) ++ ++ dnfplugin.backup_debug_data(context) ++ ++ assert len(context.copytree_from_calls) == 1 ++ assert context.copytree_from_calls[0][0] == '/debugdata' ++ ++ ++def test_backup_debug_data_without_debug(monkeypatch): ++ context = MockContext() ++ ++ monkeypatch.setattr(dnfplugin.config, 'is_debug', lambda: False) ++ ++ dnfplugin.backup_debug_data(context) ++ ++ assert len(context.copytree_from_calls) == 0 ++ ++ ++def test_backup_debug_data_oserror(monkeypatch): ++ context = MockContext(should_raise=OSError('Permission denied')) ++ ++ monkeypatch.setattr(dnfplugin.config, 'is_debug', lambda: True) ++ monkeypatch.setattr(api, 'current_logger', logger_mocked()) ++ ++ dnfplugin.backup_debug_data(context) ++ ++ ++@pytest.mark.parametrize('stderr,is_container,expected_in_message', [ ++ ('At least 500MB more space needed on the / filesystem.', True, '500MB'), ++ ('At least 500MB more space needed on the /usr filesystem.', False, '/usr'), ++ ('At least 200MB more space needed on the /var filesystem.', False, '/var'), ++]) ++def test_handle_transaction_err_msg_no_space(stderr, is_container, expected_in_message): ++ err = CalledProcessError( ++ 'dnf failed', ++ ['dnf', 'upgrade'], ++ { ++ 'stdout': 'stdout', ++ 'stderr': 'Error: Transaction test error:\n Disk Requirements:\n {}'.format(stderr), ++ 'exit_code': 1 ++ } ++ ) ++ ++ with pytest.raises(dnfplugin.DNFUpgradeTransactionError) as exc_info: ++ dnfplugin._handle_transaction_err_msg(err, is_container=is_container) ++ ++ assert 'not enough space' in str(exc_info.value).lower() ++ hint = str(exc_info.value.details.get('hint', '')) ++ disk_reqs = str(exc_info.value.details.get('Disk Requirements', '')) ++ assert expected_in_message in (hint + disk_reqs) ++ ++ ++def test_handle_transaction_err_msg_generic_error(): ++ err = CalledProcessError( ++ 'dnf failed', ++ ['dnf', 'upgrade'], ++ {'stdout': 'stdout output', 'stderr': 'Some other DNF error', 'exit_code': 1} ++ ) ++ ++ with pytest.raises(dnfplugin.DNFUpgradeTransactionError) as exc_info: ++ dnfplugin._handle_transaction_err_msg(err, is_container=False) ++ ++ assert 'DNF execution failed' in str(exc_info.value) ++ assert exc_info.value.details['STDOUT'] == 'stdout output' ++ assert exc_info.value.details['STDERR'] == 'Some other DNF error' ++ assert 'proxy' in exc_info.value.details['hint'].lower() ++ ++ ++def test_apply_workarounds_success(monkeypatch): ++ workaround1 = DNFWorkaround( ++ display_name='Workaround 1', ++ script_path='/path/to/script1.sh', ++ script_args=[] ++ ) ++ workaround2 = DNFWorkaround( ++ display_name='Workaround 2', ++ script_path='/path/to/script2.sh', ++ script_args=['arg1', 'arg2'] ++ ) ++ ++ context = MockContext() ++ ++ monkeypatch.setattr(api, 'consume', lambda model: [workaround1, workaround2]) ++ monkeypatch.setattr(api, 'show_message', lambda msg: None) ++ ++ dnfplugin.apply_workarounds(context) ++ ++ assert len(context.call_calls) == 2 ++ assert context.call_calls[0] == ['/bin/bash', '-c', '/path/to/script1.sh'] ++ assert context.call_calls[1] == ['/bin/bash', '-c', '/path/to/script2.sh arg1 arg2'] ++ ++ ++def test_apply_workarounds_script_fails(monkeypatch): ++ workaround = DNFWorkaround( ++ display_name='Failing Workaround', ++ script_path='/path/to/failing.sh', ++ script_args=[] ++ ) ++ ++ context = MockContext(should_raise=CalledProcessError( ++ 'Script failed', ++ ['/bin/bash', '-c', '/path/to/failing.sh'], ++ {'exit_code': 1, 'stdout': '', 'stderr': 'Script error'} ++ )) ++ ++ monkeypatch.setattr(api, 'consume', lambda model: [workaround]) ++ monkeypatch.setattr(api, 'show_message', lambda msg: None) ++ ++ with pytest.raises(dnfplugin.RegisteredWorkaroundApplicationError) as exc_info: ++ dnfplugin.apply_workarounds(context) ++ ++ assert 'Failed to execute script' in str(exc_info.value) ++ assert 'Failing Workaround' in exc_info.value.details['workaround name'] diff --git a/repos/system_upgrade/common/libraries/dnfplugin.py b/repos/system_upgrade/common/libraries/dnfplugin.py -index b91bcbe7..d2caf755 100644 +index b91bcbe7..19e4d4e2 100644 --- a/repos/system_upgrade/common/libraries/dnfplugin.py +++ b/repos/system_upgrade/common/libraries/dnfplugin.py -@@ -88,6 +88,7 @@ def build_plugin_data(target_repoids, debug, test, tasks, on_aws): - 'to_install': sorted(tasks.to_install), - 'to_remove': sorted(tasks.to_remove), - 'to_upgrade': sorted(tasks.to_upgrade), -+ 'to_reinstall': sorted(tasks.to_reinstall), - 'modules_to_enable': sorted(['{}:{}'.format(m.name, m.stream) for m in tasks.modules_to_enable]), - }, - 'dnf_conf': { +@@ -1,468 +1,117 @@ +-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.version import get_target_major_version, get_target_version +-from leapp.libraries.common.gpg import is_nogpgcheck_set +-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 = { +- "9": os.path.join('/lib/python3.9/site-packages/dnf-plugins', DNF_PLUGIN_NAME), +- "10": os.path.join('/lib/python3.12/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/' +- +- ++""" ++DEPRECATED: This module has been moved to leapp.libraries.common.dnflibs.dnfplugin ++ ++This shim will be removed in a future version. Please update imports to: ++ from leapp.libraries.common.dnflibs import dnfplugin ++ # or ++ from leapp.libraries.common import dnflibs ++""" ++ ++from leapp.libraries.common.dnflibs import dnfplugin as _dnfplugin ++from leapp.utils.deprecation import deprecated ++ ++# Re-export constants ++DNF_PLUGIN_NAME = _dnfplugin.DNF_PLUGIN_NAME ++DNF_PLUGIN_DATA_NAME = _dnfplugin.DNF_PLUGIN_DATA_NAME ++DNF_PLUGIN_DATA_PATH = _dnfplugin.DNF_PLUGIN_DATA_PATH ++DNF_PLUGIN_DATA_LOG_PATH = _dnfplugin.DNF_PLUGIN_DATA_LOG_PATH ++DNF_DEBUG_DATA_PATH = _dnfplugin.DNF_DEBUG_DATA_PATH ++ ++ ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + 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) ++ return _dnfplugin.install(target_basedir) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + 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': sorted(os.path.join('/installroot', pkg.lstrip('/')) for pkg in tasks.local_rpms), +- 'to_install': sorted(tasks.to_install), +- 'to_remove': sorted(tasks.to_remove), +- 'to_upgrade': sorted(tasks.to_upgrade), +- 'modules_to_enable': sorted(['{}:{}'.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 is_nogpgcheck_set(), +- '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 ++ return _dnfplugin.build_plugin_data(target_repoids, debug, test, tasks, on_aws) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + 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) ++ return _dnfplugin.create_config(context, target_repoids, debug, test, tasks, on_aws) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + 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) ++ return _dnfplugin.backup_config(context) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + 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 more space needed on the 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(err, is_container=False): +- NO_SPACE_STR = 'more space needed on the' +- if NO_SPACE_STR not in err.stderr: +- message = 'DNF execution failed with non zero exit code.' +- # if there was a problem reaching repos and proxy is configured in DNF/YUM configs, the +- # proxy is likely the problem. +- # NOTE(mmatuska): We can't consistently detect there was a problem reaching some repos, +- # because it isn't clear what are all the possible DNF error messages we can encounter, +- # such as: "Failed to synchronize cache for repo ..." or "Errors during downloading +- # metadata for # repository" or "No more mirrors to try - All mirrors were already tried +- # without success" +- # NOTE(mmatuska): We could check PkgManagerInfo to detect if proxy is indeed configured, +- # however it would be pretty ugly to pass it all the way down here +- proxy_hint = ( +- "If there was a problem reaching remote content (see stderr output) and proxy is " +- "configured in the YUM/DNF configuration file, the proxy configuration is likely " +- "causing this error. " +- "Make sure the proxy is properly configured in /etc/dnf/dnf.conf. " +- "It's also possible the proxy settings in the DNF configuration file are " +- "incompatible with the target system. A compatible configuration can be " +- "placed in /etc/leapp/files/dnf.conf which, if present, it will be used during " +- "some parts of the upgrade instead of original /etc/dnf/dnf.conf. " +- "In such case the configuration will also be applied to the target system. " +- "Note that /etc/dnf/dnf.conf needs to be still configured correctly " +- "for your current system to pass the early phases of the upgrade process." +- ) +- details = {'STDOUT': err.stdout, 'STDERR': err.stderr, 'hint': proxy_hint} +- raise StopActorExecutionError(message=message, details=details) +- +- # Disk Requirements: +- # At least more space needed on the 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'} +- +- if 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(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 ++ return _dnfplugin.backup_debug_data(context) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + 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)) +- ) ++ return _dnfplugin.apply_workarounds(context) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + def install_initramdisk_requirements(packages, target_userspace_info, used_repos): + """ + Performs the installation of packages into the initram disk + """ +- mount_binds = ['/:/installroot'] +- with _prepare_transaction(used_repos=used_repos, target_userspace_info=target_userspace_info, +- binds=mount_binds) as (context, target_repoids, _unused): +- if int(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 is_nogpgcheck_set(): +- 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(e, is_container=True) ++ return _dnfplugin.install_initramdisk_requirements(packages, target_userspace_info, used_repos) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + 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', +- ] +- +- # 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 int(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): +- # noqa: W0135; pylint: disable=bad-option-value,contextmanager-generator-missing-cleanup +- # NOTE(pstodulk): the pylint check is not valid in this case - finally is covered +- # implicitly +- # noqa: W0135 +- 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 ++ return _dnfplugin.perform_transaction_install( ++ target_userspace_info, storage_info, used_repos, tasks, plugin_info, xfs_info ++ ) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + def perform_transaction_check(target_userspace_info, + used_repos, + tasks, +@@ -473,26 +122,15 @@ def perform_transaction_check(target_userspace_info, + """ + 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 +- ) ++ return _dnfplugin.perform_transaction_check( ++ target_userspace_info, used_repos, tasks, xfs_info, storage_info, plugin_info, target_iso ++ ) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + def perform_rpm_download(target_userspace_info, + used_repos, + tasks, +@@ -504,29 +142,15 @@ def perform_rpm_download(target_userspace_info, + """ + 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 +- ) ++ return _dnfplugin.perform_rpm_download( ++ target_userspace_info, used_repos, tasks, xfs_info, storage_info, plugin_info, target_iso, on_aws ++ ) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfplugin module. ' ++ 'Please update your imports to use the new location.' ++)) + def perform_dry_run(target_userspace_info, + used_repos, + tasks, +@@ -538,13 +162,6 @@ def perform_dry_run(target_userspace_info, + """ + 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 +- ) ++ return _dnfplugin.perform_dry_run( ++ target_userspace_info, used_repos, tasks, xfs_info, storage_info, plugin_info, target_iso, on_aws ++ ) diff --git a/repos/system_upgrade/common/libraries/fetch.py b/repos/system_upgrade/common/libraries/fetch.py index baf2c4eb..44abe66b 100644 --- a/repos/system_upgrade/common/libraries/fetch.py @@ -5518,6 +9691,167 @@ index 4c5133e7..4f7b96d6 100644 def is_nogpgcheck_set(): +diff --git a/repos/system_upgrade/common/libraries/module.py b/repos/system_upgrade/common/libraries/module.py +index d85e4659..46b87a75 100644 +--- a/repos/system_upgrade/common/libraries/module.py ++++ b/repos/system_upgrade/common/libraries/module.py +@@ -1,105 +1,44 @@ +-import warnings ++""" ++DEPRECATED: This module has been moved to leapp.libraries.common.dnflibs.dnfmodule + +-from leapp.exceptions import StopActorExecutionError +-from leapp.libraries.common.config.version import get_source_major_version ++This shim will be removed in a future version. Please update imports to: ++ from leapp.libraries.common.dnflibs import dnfmodule ++ # or ++ from leapp.libraries.common import dnflibs ++""" + +-try: +- import dnf +-except ImportError: +- dnf = None +- warnings.warn('Could not import the `dnf` python module.', ImportWarning) +- +-try: +- import hawkey +-except ImportError: +- hawkey = None +- warnings.warn('Could not import the `hawkey` python module.', ImportWarning) +- +- +-def _create_or_get_dnf_base(base=None): +- if not base: +- # The DNF command reads /etc/yum/vars/releasever, but the DNF library does not. It parses redhat-release +- # package to retrieve system's major version which it then uses as $releasever. However, some systems might +- # have repositories only for the exact system version (including the minor number). In a case when +- # /etc/yum/vars/releasever is present, read its contents so that we can access repositores on such systems. +- conf = dnf.conf.Conf() +- +- # preload releasever from what we know, this will be our fallback +- conf.substitutions['releasever'] = get_source_major_version() +- +- # load all substitutions from etc +- conf.substitutions.update_from_etc('/') +- +- base = dnf.Base(conf=conf) +- base.conf.read() +- base.init_plugins() +- base.read_all_repos() +- # configure plugins after the repositories are loaded +- # e.g. the amazon-id plugin requires loaded repositories +- # for the proper configuration. +- base.configure_plugins() +- +- try: +- base.fill_sack() +- except dnf.exceptions.RepoError as e: +- err_msg = str(e) +- repoid = err_msg.split('repo:')[-1].strip() if 'repo:' in err_msg else 'unknown repo' +- repoid = repoid.strip('"').strip("'").replace('\\"', '') +- raise StopActorExecutionError( +- message='DNF failed to load repositories: {}'.format(str(e)), +- details={ +- 'hint': 'Ensure the {} repository definition is correct or remove it ' +- 'if the repository is not needed anymore.'.format(repoid) +- } +- ) +- return base ++from leapp.libraries.common.dnflibs import dnfmodule as _dnfmodule ++from leapp.utils.deprecation import deprecated + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfmodule module. ' ++ 'Please update your imports to use the new location.' ++)) + def get_modules(base=None): + """ + Return info about all module streams as a list of libdnf.module.ModulePackage objects. + """ +- if not dnf: +- return [] +- base = _create_or_get_dnf_base(base) +- +- module_base = dnf.module.module_base.ModuleBase(base) +- # this method is absent on RHEL 7, in which case there are no modules anyway +- if not hasattr(module_base, 'get_modules'): +- return [] +- return module_base.get_modules('*')[0] ++ return _dnfmodule.get_modules(base) + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfmodule module. ' ++ 'Please update your imports to use the new location.' ++)) + def get_enabled_modules(): + """ + Return currently enabled module streams as a list of libdnf.module.ModulePackage objects. + """ +- if not dnf: +- return [] +- +- base = _create_or_get_dnf_base() +- modules = get_modules(base) +- +- # if modules are not supported (RHEL 7), base.sack._moduleContainer won't exist +- # luckily in that case modules are empty and the element won't even be accessed +- return [m for m in modules if base.sack._moduleContainer.isEnabled(m)] ++ return _dnfmodule.get_enabled_modules() + + ++@deprecated(since='2026-03-10', message=( ++ 'This function has been moved to leapp.libraries.common.dnflibs.dnfmodule module. ' ++ 'Please update your imports to use the new location.' ++)) + def map_installed_rpms_to_modules(): + """ + Map installed modular packages to the module streams they come from. + """ +- modules = get_modules() +- # empty on RHEL 7 because of no modules +- if not modules: +- return {} +- # create a reverse mapping from the RPMS to module streams +- # key: tuple of 4 strings representing a NVRA (name, version, release, arch) of an RPM +- # value: tuple of 2 strings representing a module and its stream +- rpm_streams = {} +- for module in modules: +- for rpm in module.getArtifacts(): +- nevra = hawkey.split_nevra(rpm) +- rpm_key = (nevra.name, nevra.version, nevra.release, nevra.arch) +- rpm_streams[rpm_key] = (module.getName(), module.getStream()) +- return rpm_streams ++ return _dnfmodule.map_installed_rpms_to_modules() +diff --git a/repos/system_upgrade/common/libraries/multipathutil.py b/repos/system_upgrade/common/libraries/multipathutil.py +index cb5c3693..78ed5f13 100644 +--- a/repos/system_upgrade/common/libraries/multipathutil.py ++++ b/repos/system_upgrade/common/libraries/multipathutil.py +@@ -7,7 +7,8 @@ _sections = ('defaults', 'blacklist', 'blacklist_exceptions', 'devices', + 'overrides', 'multipaths') + + _subsections = {'blacklist': 'device', 'blacklist_exceptions': 'device', +- 'devices': 'device', 'multipaths': 'multipath'} ++ 'devices': 'device', 'multipaths': 'multipath', ++ 'overrides': 'protocol'} + + + def read_config(path): +diff --git a/repos/system_upgrade/common/libraries/repofileutils.py b/repos/system_upgrade/common/libraries/repofileutils.py +index d8e087c1..4518b091 100644 +--- a/repos/system_upgrade/common/libraries/repofileutils.py ++++ b/repos/system_upgrade/common/libraries/repofileutils.py +@@ -48,7 +48,7 @@ def parse_repofile(repofile): + """ + data = [] + with open(repofile, mode='r') as fp: +- cp = utils.parse_config(fp, strict=False) ++ cp = utils.parse_config(fp, strict=False, no_interpolation=True) + for repoid in cp.sections(): + try: + data.append(_parse_repository(repoid, dict(cp.items(repoid)))) diff --git a/repos/system_upgrade/common/libraries/repomaputils.py b/repos/system_upgrade/common/libraries/repomaputils.py new file mode 100644 index 00000000..40a6f001 @@ -5665,6 +9999,385 @@ index 00000000..40a6f001 + ) + + return combined_repomapping +diff --git a/repos/system_upgrade/common/libraries/tests/test_dnfplugin.py b/repos/system_upgrade/common/libraries/tests/test_dnfplugin.py +deleted file mode 100644 +index 1ca95945..00000000 +--- a/repos/system_upgrade/common/libraries/tests/test_dnfplugin.py ++++ /dev/null +@@ -1,193 +0,0 @@ +-from collections import namedtuple +- +-import pytest +- +-import leapp.models +-from leapp.libraries.common import dnfplugin +-from leapp.libraries.common.config.version import get_major_version +-from leapp.libraries.common.testutils import CurrentActorMocked +-from leapp.libraries.stdlib import api +-from leapp.models.fields import Boolean +-from leapp.topics import Topic +- +- +-class DATADnfPluginDataTopic(Topic): +- name = 'data_dnf_plugin_data' +- +- +-fields = leapp.models.fields +- +-TaskData = namedtuple('TaskData', 'expected initdata') +- +-TEST_INSTALL_PACKAGES = TaskData( +- expected=('install1', 'install2'), +- initdata=('install1', 'install2') +-) +-TEST_REMOVE_PACKAGES = TaskData( +- expected=('remove1', 'remove2'), +- initdata=('remove1', 'remove2'), +-) +-TEST_UPGRADE_PACKAGES = TaskData( +- expected=('upgrade1', 'upgrade2'), +- initdata=('upgrade1', 'upgrade2'), +-) +-TEST_ENABLE_MODULES = TaskData( +- expected=('enable1:stream1', 'enable2:stream2'), +- initdata=( +- leapp.models.Module(name='enable1', stream='stream1'), +- leapp.models.Module(name='enable2', stream='stream2'), +- ) +-) +- +- +-class DATADnfPluginDataPkgsInfo(leapp.models.Model): +- topic = DATADnfPluginDataTopic +- local_rpms = fields.List(fields.String()) +- to_install = fields.List(fields.StringEnum(choices=TEST_INSTALL_PACKAGES.expected)) +- to_remove = fields.List(fields.StringEnum(choices=TEST_REMOVE_PACKAGES.expected)) +- to_upgrade = fields.List(fields.StringEnum(choices=TEST_UPGRADE_PACKAGES.expected)) +- modules_to_enable = fields.List(fields.StringEnum(choices=TEST_ENABLE_MODULES.expected)) +- +- +-TEST_ENABLE_REPOS_CHOICES = ('enabled_repo', 'BASEOS', 'APPSTREAM') +- +- +-class BooleanEnum(fields.EnumMixin, Boolean): +- pass +- +- +-class DATADnfPluginDataDnfConf(leapp.models.Model): +- topic = DATADnfPluginDataTopic +- allow_erasing = BooleanEnum(choices=[True]) +- best = BooleanEnum(choices=[True]) +- debugsolver = fields.Boolean() +- disable_repos = BooleanEnum(choices=[True]) +- enable_repos = fields.List(fields.StringEnum(choices=TEST_ENABLE_REPOS_CHOICES)) +- gpgcheck = fields.Boolean() +- platform_id = fields.StringEnum(choices=['platform:el8', 'platform:el9']) +- releasever = fields.String() +- installroot = fields.StringEnum(choices=['/installroot']) +- test_flag = fields.Boolean() +- +- +-class DATADnfPluginDataRHUIAWS(leapp.models.Model): +- topic = DATADnfPluginDataTopic +- on_aws = fields.Boolean() +- region = fields.Nullable(fields.String()) +- +- +-class DATADnfPluginDataRHUI(leapp.models.Model): +- topic = DATADnfPluginDataTopic +- aws = fields.Model(DATADnfPluginDataRHUIAWS) +- +- +-class DATADnfPluginData(leapp.models.Model): +- topic = DATADnfPluginDataTopic +- pkgs_info = fields.Model(DATADnfPluginDataPkgsInfo) +- dnf_conf = fields.Model(DATADnfPluginDataDnfConf) +- rhui = fields.Model(DATADnfPluginDataRHUI) +- +- +-# Delete those models from leapp.models to 'unpolute' the module +-del leapp.models.DATADnfPluginDataPkgsInfo +-del leapp.models.DATADnfPluginDataDnfConf +-del leapp.models.DATADnfPluginDataRHUI +-del leapp.models.DATADnfPluginDataRHUIAWS +-del leapp.models.DATADnfPluginData +- +- +-_CONFIG_BUILD_TEST_DEFINITION = ( +- # Parameter, Input Data, Expected Fields with data +- ('debug', False, ('dnf_conf', 'debugsolver'), False), +- ('debug', True, ('dnf_conf', 'debugsolver'), True), +- ('target_repoids', TEST_ENABLE_REPOS_CHOICES, ('dnf_conf', 'enable_repos'), list(TEST_ENABLE_REPOS_CHOICES)), +- ('target_repoids', TEST_ENABLE_REPOS_CHOICES[0:1], +- ('dnf_conf', 'enable_repos'), list(TEST_ENABLE_REPOS_CHOICES[0:1])), +- ('target_repoids', TEST_ENABLE_REPOS_CHOICES[1:], +- ('dnf_conf', 'enable_repos'), list(TEST_ENABLE_REPOS_CHOICES[1:])), +- ('target_repoids', TEST_ENABLE_REPOS_CHOICES[2:], +- ('dnf_conf', 'enable_repos'), list(TEST_ENABLE_REPOS_CHOICES[2:])), +- ('test', False, ('dnf_conf', 'test_flag'), False), +- ('test', True, ('dnf_conf', 'test_flag'), True), +-) +- +- +-@pytest.mark.parametrize('used_target_version', ['8.4', '8.5', '9.0', '9.1']) +-@pytest.mark.parametrize('parameter,input_value,test_path,expected_value', _CONFIG_BUILD_TEST_DEFINITION) +-def test_build_plugin_data_variations( +- monkeypatch, +- used_target_version, +- parameter, +- input_value, +- test_path, +- expected_value, +-): +- used_target_major_version = get_major_version(used_target_version) +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(dst_ver=used_target_version)) +- inputs = { +- 'target_repoids': ['BASEOS', 'APPSTREAM'], +- 'debug': True, +- 'test': True, +- 'on_aws': False, +- 'tasks': leapp.models.FilteredRpmTransactionTasks( +- to_install=TEST_INSTALL_PACKAGES.initdata, +- to_remove=TEST_REMOVE_PACKAGES.initdata, +- to_upgrade=TEST_UPGRADE_PACKAGES.initdata, +- modules_to_enable=TEST_ENABLE_MODULES.initdata +- ) +- } +- inputs[parameter] = input_value +- created = DATADnfPluginData.create( +- dnfplugin.build_plugin_data( +- **inputs +- ) +- ) +- assert created.dnf_conf.platform_id == 'platform:el{}'.format(used_target_major_version) +- assert created.dnf_conf.releasever == used_target_version +- value = created +- for path in test_path: +- value = getattr(value, path) +- assert value == expected_value +- +- +-def test_build_plugin_data(monkeypatch): +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(dst_ver='8.4')) +- # Use leapp to validate format and data +- created = DATADnfPluginData.create( +- dnfplugin.build_plugin_data( +- target_repoids=['BASEOS', 'APPSTREAM'], +- debug=True, +- test=True, +- on_aws=False, +- tasks=leapp.models.FilteredRpmTransactionTasks( +- to_install=TEST_INSTALL_PACKAGES.initdata, +- to_remove=TEST_REMOVE_PACKAGES.initdata, +- to_upgrade=TEST_UPGRADE_PACKAGES.initdata, +- modules_to_enable=TEST_ENABLE_MODULES.initdata +- ) +- ) +- ) +- assert created.dnf_conf.debugsolver is True +- assert created.dnf_conf.test_flag is True +- assert created.rhui.aws.on_aws is False +- +- with pytest.raises(fields.ModelViolationError): +- DATADnfPluginData.create( +- dnfplugin.build_plugin_data( +- target_repoids=['BASEOS', 'APPSTREAM'], +- debug=True, +- test=True, +- on_aws=False, +- tasks=leapp.models.FilteredRpmTransactionTasks( +- to_install=TEST_INSTALL_PACKAGES.initdata, +- to_remove=TEST_REMOVE_PACKAGES.initdata, +- to_upgrade=TEST_UPGRADE_PACKAGES.initdata, +- # Enforcing the failure +- modules_to_enable=( +- leapp.models.Module( +- name='broken', stream=None +- ), +- ), +- ) +- ) +- ) +diff --git a/repos/system_upgrade/common/libraries/tests/test_multipathutil.py b/repos/system_upgrade/common/libraries/tests/test_multipathutil.py +index 3ddfcddf..852f3463 100644 +--- a/repos/system_upgrade/common/libraries/tests/test_multipathutil.py ++++ b/repos/system_upgrade/common/libraries/tests/test_multipathutil.py +@@ -38,7 +38,8 @@ def test_section_start(): + sections = ('defaults', 'blacklist', 'blacklist_exceptions', 'devices', + 'overrides', 'multipaths') + subsections = {'blacklist': 'device', 'blacklist_exceptions': 'device', +- 'devices': 'device', 'multipaths': 'multipath'} ++ 'devices': 'device', 'multipaths': 'multipath', ++ 'overrides': 'protocol'} + for section in sections: + data = lib.LineData(section + ' {', None, False) + assert data.type == data.TYPE_SECTION_START +diff --git a/repos/system_upgrade/common/libraries/tests/test_repofileutils.py b/repos/system_upgrade/common/libraries/tests/test_repofileutils.py +index d161e4bc..f9fb5623 100644 +--- a/repos/system_upgrade/common/libraries/tests/test_repofileutils.py ++++ b/repos/system_upgrade/common/libraries/tests/test_repofileutils.py +@@ -75,3 +75,23 @@ def test_parse_repofile(): + repos_duplicate = [repo for repo in repofile.data if repo.repoid == 'duplicate'] + assert len(repos_duplicate) == 1 # only one instance got through + assert repos_duplicate[0].name == 'Duplicate 2' # and it's the latter one ++ ++ ++def test_parse_repofile_preserves_url_encoded_characters(tmp_path): ++ """Percent signs from URL encoding must not be treated as config interpolation.""" ++ repo_path = tmp_path / 'urlencoded.repo' ++ expected_baseurl = ( ++ 'https://cdn.example.com/content/dist/rhel8/%24releasever/x86_64/os/' ++ '?token=foo%2Fbar%3Dbaz' ++ ) ++ repo_path.write_text( ++ '[urlencoded]\n' ++ 'name=URL-encoded baseurl\n' ++ 'baseurl={}\n' ++ 'enabled=0\n'.format(expected_baseurl), ++ encoding='utf-8', ++ ) ++ ++ repofile = repofileutils.parse_repofile(str(repo_path)) ++ repo = next(r for r in repofile.data if r.repoid == 'urlencoded') ++ assert repo.baseurl == expected_baseurl +diff --git a/repos/system_upgrade/common/libraries/tests/test_utils_parse_config.py b/repos/system_upgrade/common/libraries/tests/test_utils_parse_config.py +new file mode 100644 +index 00000000..64c9e735 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/tests/test_utils_parse_config.py +@@ -0,0 +1,30 @@ ++from configparser import InterpolationSyntaxError ++ ++import pytest ++ ++from leapp.libraries.common import utils ++ ++ ++def test_parse_config_no_interpolation_preserves_url_encoding(): ++ """Values with URL-encoded sequences must be read literally (no % interpolation).""" ++ cfg_text = ( ++ '[repo]\n' ++ 'name=test\n' ++ 'baseurl=https://example.com/path%20with%20spaces/repo%3Fquery=1\n' ++ ) ++ parser = utils.parse_config(cfg_text, strict=False, no_interpolation=True) ++ assert parser.get('repo', 'baseurl') == ( ++ 'https://example.com/path%20with%20spaces/repo%3Fquery=1' ++ ) ++ ++ ++def test_parse_config_interpolation_rejects_bare_percent_sequences(): ++ """Default ConfigParser treats '%' as interpolation; invalid sequences must raise.""" ++ cfg_text = ( ++ '[repo]\n' ++ 'name=test\n' ++ 'baseurl=https://example.com/path%20with%20spaces/repo\n' ++ ) ++ parser = utils.parse_config(cfg_text, strict=False, no_interpolation=False) ++ with pytest.raises(InterpolationSyntaxError): ++ parser.get('repo', 'baseurl') +diff --git a/repos/system_upgrade/common/libraries/testutils.py b/repos/system_upgrade/common/libraries/testutils.py +index 0a56d698..c9d6337a 100644 +--- a/repos/system_upgrade/common/libraries/testutils.py ++++ b/repos/system_upgrade/common/libraries/testutils.py +@@ -45,23 +45,23 @@ class logger_mocked: + self.warnmsg = [] + self.errmsg = [] + +- def debug(self, *args): ++ def debug(self, *args, **kwargs): + self.dbgmsg.extend(args) + +- def info(self, *args): ++ def info(self, *args, **kwargs): + self.infomsg.extend(args) + + @deprecated(since='2020-09-23', message=( + 'The logging.warn method has been deprecated since Python 3.3.' + 'Use the warning method instead.' + )) +- def warn(self, *args): ++ def warn(self, *args, **kwargs): + self.warnmsg.extend(args) + +- def warning(self, *args): ++ def warning(self, *args, **kwargs): + self.warnmsg.extend(args) + +- def error(self, *args): ++ def error(self, *args, **kwargs): + self.errmsg.extend(args) + + def __call__(self): +diff --git a/repos/system_upgrade/common/libraries/utils.py b/repos/system_upgrade/common/libraries/utils.py +index b7aa9c74..2c9aaee9 100644 +--- a/repos/system_upgrade/common/libraries/utils.py ++++ b/repos/system_upgrade/common/libraries/utils.py +@@ -1,8 +1,7 @@ + import functools + import os + import sys +- +-import six ++from configparser import ConfigParser, RawConfigParser + + from leapp.exceptions import StopActorExecutionError + from leapp.libraries.common import mounting +@@ -10,36 +9,35 @@ from leapp.libraries.stdlib import api, CalledProcessError, config, run, STDOUT + from leapp.utils.deprecation import deprecated + + +-def parse_config(cfg=None, strict=True): ++def parse_config(cfg=None, strict=True, no_interpolation=False): + """ +- Applies a workaround to parse a config file using py3 AND py2 ++ Parse a config input. ++ ++ When ``no_interpolation`` is True, ``RawConfigParser`` is used to read ++ values literally. This is useful for inputs that may contain URLs with ++ '%' characters for URL encoding (e.g. yum .repo baseurl). + +- ConfigParser has a new def to read strings/files in Py3, making +- the old ones (Py2) obsoletes, these function was created to use the +- ConfigParser on Py2 and Py3 ++ When set to False, the default ``ConfigParser`` is used, where '%' is treated ++ as interpolation syntax and must be escaped (%%) if used literally. + +- :type cfg: str ++ :param cfg: Config data as a string or a file-like object ++ :type cfg: str or file-like object ++ :param strict: Enable strict parsing (no duplicate sections/options) + :type strict: bool ++ :param no_interpolation: Disable interpolation and read values literally ++ :type no_interpolation: bool + """ +- if six.PY3: +- parser = six.moves.configparser.ConfigParser(strict=strict) # pylint: disable=unexpected-keyword-arg ++ if no_interpolation: ++ parser = RawConfigParser(strict=strict) + else: +- parser = six.moves.configparser.ConfigParser() ++ parser = ConfigParser(strict=strict) + + # we do not handle exception here, handle with it when these function is called +- if cfg and six.PY3: +- # Python 3 +- if isinstance(cfg, six.string_types): ++ if cfg: ++ if isinstance(cfg, str): + parser.read_string(cfg) + else: + parser.read_file(cfg) +- elif cfg: +- # Python 2 +- from cStringIO import StringIO # pylint: disable=import-outside-toplevel +- if isinstance(cfg, six.string_types): +- parser.readfp(StringIO(cfg)) # pylint: disable=deprecated-method +- else: +- parser.readfp(cfg) # pylint: disable=deprecated-method + return parser + + diff --git a/repos/system_upgrade/common/models/activevendorlist.py b/repos/system_upgrade/common/models/activevendorlist.py new file mode 100644 index 00000000..de4056fb @@ -5678,6 +10391,109 @@ index 00000000..de4056fb +class ActiveVendorList(Model): + topic = VendorTopic + data = fields.List(fields.String()) +diff --git a/repos/system_upgrade/common/models/kernelcmdlineargs.py b/repos/system_upgrade/common/models/kernelcmdlineargs.py +index fafd2853..2f163c34 100644 +--- a/repos/system_upgrade/common/models/kernelcmdlineargs.py ++++ b/repos/system_upgrade/common/models/kernelcmdlineargs.py +@@ -39,10 +39,14 @@ class LateTargetKernelCmdlineArgTasks(Model): + class UpgradeKernelCmdlineArgTasks(Model): + """ + Modifications of the upgrade kernel cmdline. ++ ++ The arguments in to_remove have precedence over argument in to_add. That is, if 'ARG' ++ is in to_remove, it is guaranteed to be removed (even if it is also in to_add). + """ + topic = SystemInfoTopic + + to_add = fields.List(fields.Model(KernelCmdlineArg), default=[]) ++ to_remove = fields.List(fields.Model(KernelCmdlineArg), default=[]) + + + class KernelCmdline(Model): +diff --git a/repos/system_upgrade/common/models/multipath.py b/repos/system_upgrade/common/models/multipath.py +index 1d1c53b5..aaa11801 100644 +--- a/repos/system_upgrade/common/models/multipath.py ++++ b/repos/system_upgrade/common/models/multipath.py +@@ -14,7 +14,32 @@ class MultipathInfo(Model): + """ + + config_dir = fields.Nullable(fields.String()) +- """ Value of config_dir in the defaults section. None if not set. """ ++ """ ++ The location of config_dir, if it should be copied to the same location ++ in the target userspace. None if it should be copied to a different ++ location (handled by upgrade-specific models). ++ """ ++ ++ bindings_file = fields.Nullable(fields.String()) ++ """ ++ The location of bindings_file, if it should be copied to the same location ++ in the target userspace. None if it should be copied to a different ++ location (handled by upgrade-specific models). ++ """ ++ ++ wwids_file = fields.Nullable(fields.String()) ++ """ ++ The location of wwids_file, if it should be copied to the same location ++ in the target userspace. None if it should be copied to a different ++ location (handled by upgrade-specific models). ++ """ ++ ++ prkeys_file = fields.Nullable(fields.String()) ++ """ ++ The location of prkeys_file, if it should be copied to the same location ++ in the target userspace. None if it should be copied to a different ++ location (handled by upgrade-specific models). ++ """ + + + class UpdatedMultipathConfig(Model): +@@ -34,45 +59,3 @@ class MultipathConfigUpdatesInfo(Model): + + updates = fields.List(fields.Model(UpdatedMultipathConfig), default=[]) + """ Collection of multipath config updates that must be performed during the upgrade. """ +- +- +-class MultipathConfig8to9(Model): +- """ +- Model information about multipath configuration file important for the 8>9 upgrade path. +- +- Note: This model is in the common repository due to the technical reasons +- (reusing parser code in a single actor), and it should not be emitted on +- non-8to9 upgrade paths. In the future, this model will likely be moved into +- el8toel9 repository. +- """ +- topic = SystemInfoTopic +- +- pathname = fields.String() +- """Config file path name""" +- +- config_dir = fields.Nullable(fields.String()) +- """Value of config_dir in the defaults section. None if not set""" +- +- enable_foreign_exists = fields.Boolean(default=False) +- """True if enable_foreign is set in the defaults section""" +- +- invalid_regexes_exist = fields.Boolean(default=False) +- """True if any regular expressions have the value of "*" """ +- +- allow_usb_exists = fields.Boolean(default=False) +- """True if allow_usb_devices is set in the defaults section.""" +- +- +-class MultipathConfFacts8to9(Model): +- """ +- Model representing information from multipath configuration files important for the 8>9 upgrade path. +- +- Note: This model is in the common repository due to the technical reasons +- (reusing parser code in a single actor), and it should not be emitted on +- non-8to9 upgrade paths. In the future, this model will likely be moved into +- el8toel9 repository. +- """ +- topic = SystemInfoTopic +- +- configs = fields.List(fields.Model(MultipathConfig8to9), default=[]) +- """List of multipath configuration files""" diff --git a/repos/system_upgrade/common/models/repositoriesmap.py b/repos/system_upgrade/common/models/repositoriesmap.py index 842cd807..fc740606 100644 --- a/repos/system_upgrade/common/models/repositoriesmap.py @@ -5759,3 +10575,3518 @@ index 00000000..014b7afb + +class VendorTopic(Topic): + name = 'vendor_topic' +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/actor.py b/repos/system_upgrade/el8toel9/actors/multipathconfread/actor.py +similarity index 68% +rename from repos/system_upgrade/common/actors/multipath/config_reader/actor.py +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/actor.py +index a7238a25..2e326614 100644 +--- a/repos/system_upgrade/common/actors/multipath/config_reader/actor.py ++++ b/repos/system_upgrade/el8toel9/actors/multipathconfread/actor.py +@@ -4,7 +4,7 @@ from leapp.models import DistributionSignedRPM, MultipathConfFacts8to9, Multipat + from leapp.tags import FactsPhaseTag, IPUWorkflowTag + + +-class MultipathConfRead(Actor): ++class MultipathConfRead8to9(Actor): + """ + Read multipath configuration files and extract the necessary information + +@@ -13,13 +13,11 @@ class MultipathConfRead(Actor): + - /etc/multipath/ - any files inside the directory + - /etc/xdrdevices.conf + +- Two kinds of messages are generated: +- - MultipathInfo - general information about multipath, version agnostic +- - upgrade-path-specific messages such as MultipathConfFacts8to9 (produced only +- when upgrading from 8 to 9) ++ Produces MultipathInfo with general information about multipath, and ++ MultipathConfFacts8to9 with details needed for the 8 to 9 upgrade. + """ + +- name = 'multipath_conf_read' ++ name = 'multipath_conf_read_8to9' + consumes = (DistributionSignedRPM,) + produces = (MultipathInfo, MultipathConfFacts8to9) + tags = (FactsPhaseTag, IPUWorkflowTag) +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/libraries/multipathconfread.py b/repos/system_upgrade/el8toel9/actors/multipathconfread/libraries/multipathconfread.py +similarity index 71% +rename from repos/system_upgrade/common/actors/multipath/config_reader/libraries/multipathconfread.py +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/libraries/multipathconfread.py +index e733500b..2c81ed52 100644 +--- a/repos/system_upgrade/common/actors/multipath/config_reader/libraries/multipathconfread.py ++++ b/repos/system_upgrade/el8toel9/actors/multipathconfread/libraries/multipathconfread.py +@@ -2,7 +2,6 @@ import errno + import os + + from leapp.libraries.common import multipathutil +-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 DistributionSignedRPM, MultipathConfFacts8to9, MultipathConfig8to9, MultipathInfo +@@ -10,8 +9,12 @@ from leapp.models import DistributionSignedRPM, MultipathConfFacts8to9, Multipat + _regexes = ('vendor', 'product', 'revision', 'product_blacklist', 'devnode', + 'wwid', 'property', 'protocol') + ++_DEFAULT_BINDINGS_FILE = '/etc/multipath/bindings' ++_DEFAULT_WWIDS_FILE = '/etc/multipath/wwids' ++_DEFAULT_PRKEYS_FILE = '/etc/multipath/prkeys' + +-def _parse_config(path): ++ ++def _parse_config(path, file_locs): + contents = multipathutil.read_config(path) + if contents is None: + return None +@@ -45,20 +48,22 @@ def _parse_config(path): + elif data.option == 'allow_usb_devices': + conf.allow_usb_exists = True + elif data.option == 'config_dir': +- conf.config_dir = data.value ++ conf.config_dir = os.path.normpath(data.value) ++ elif data.option in ('bindings_file', 'wwids_file', 'prkeys_file'): ++ file_locs[data.option] = os.path.normpath(data.value) + if data.option in _regexes and data.value == '*': + conf.invalid_regexes_exist = True + return conf + + +-def _parse_config_dir(config_dir): ++def _parse_config_dir(config_dir, file_locs): + res = [] + try: + for config_file in sorted(os.listdir(config_dir)): + path = os.path.join(config_dir, config_file) + if not path.endswith('.conf'): + continue +- conf = _parse_config(path) ++ conf = _parse_config(path, file_locs) + if conf: + res.append(conf) + except OSError as e: +@@ -83,11 +88,22 @@ def is_processable(): + return res + + ++def setup_default_file_locations(): ++ file_locs = { ++ "bindings_file": _DEFAULT_BINDINGS_FILE, ++ "wwids_file": _DEFAULT_WWIDS_FILE, ++ "prkeys_file": _DEFAULT_PRKEYS_FILE, ++ } ++ return file_locs ++ ++ + def scan_and_emit_multipath_info(default_config_path='/etc/multipath.conf'): + if not is_processable(): + return + +- primary_config = _parse_config(default_config_path) ++ file_locs = setup_default_file_locations() ++ ++ primary_config = _parse_config(default_config_path, file_locs) + if not primary_config: + api.current_logger().debug( + 'Primary multipath config /etc/multipath.conf is not present - multipath ' +@@ -101,12 +117,17 @@ def scan_and_emit_multipath_info(default_config_path='/etc/multipath.conf'): + is_configured=True, + config_dir=primary_config.config_dir or '/etc/multipath/conf.d' + ) +- api.produce(multipath_info) + +- # Handle upgrade-path-specific config actions +- if get_source_major_version() == '8': +- secondary_configs = _parse_config_dir(multipath_info.config_dir) +- all_configs = [primary_config] + secondary_configs ++ secondary_configs = _parse_config_dir( ++ multipath_info.config_dir, ++ file_locs ++ ) ++ ++ multipath_info.bindings_file = file_locs['bindings_file'] ++ multipath_info.wwids_file = file_locs['wwids_file'] ++ multipath_info.prkeys_file = file_locs['prkeys_file'] ++ api.produce(multipath_info) + +- config_facts_for_8to9 = MultipathConfFacts8to9(configs=all_configs) +- api.produce(config_facts_for_8to9) ++ all_configs = [primary_config] + secondary_configs ++ config_facts_for_8to9 = MultipathConfFacts8to9(configs=all_configs) ++ api.produce(config_facts_for_8to9) +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/all_the_things.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/all_the_things.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/all_the_things.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/all_the_things.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/allow_usb.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/allow_usb.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/allow_usb.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/allow_usb.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/complicated.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/complicated.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/complicated.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/complicated.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/conf1.d/empty.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/conf1.d/empty.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/conf1.d/empty.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/conf1.d/empty.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/conf1.d/nothing_important.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/conf1.d/nothing_important.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/conf1.d/nothing_important.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/conf1.d/nothing_important.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/conf2.d/all_true.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/conf2.d/all_true.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/conf2.d/all_true.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/conf2.d/all_true.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/conf3.d/README b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/conf3.d/README +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/conf3.d/README +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/conf3.d/README +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/converted_the_things.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/converted_the_things.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/converted_the_things.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/converted_the_things.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/default_rhel8.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/default_rhel8.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/default_rhel8.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/default_rhel8.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/empty.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/empty.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/empty.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/empty.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/empty_dir.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/empty_dir.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/empty_dir.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/empty_dir.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/missing_dir.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/missing_dir.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/missing_dir.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/missing_dir.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/no_defaults.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/no_defaults.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/no_defaults.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/no_defaults.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/no_foreign.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/no_foreign.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/no_foreign.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/no_foreign.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/not_set_dir.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/not_set_dir.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/not_set_dir.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/not_set_dir.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/set_in_dir.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/set_in_dir.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/set_in_dir.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/set_in_dir.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/files/two_defaults.conf b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/two_defaults.conf +similarity index 100% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/files/two_defaults.conf +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/files/two_defaults.conf +diff --git a/repos/system_upgrade/common/actors/multipath/config_reader/tests/test_multipath_conf_read_8to9.py b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/test_multipath_conf_read_8to9.py +similarity index 79% +rename from repos/system_upgrade/common/actors/multipath/config_reader/tests/test_multipath_conf_read_8to9.py +rename to repos/system_upgrade/el8toel9/actors/multipathconfread/tests/test_multipath_conf_read_8to9.py +index e593a857..cd8036ec 100644 +--- a/repos/system_upgrade/common/actors/multipath/config_reader/tests/test_multipath_conf_read_8to9.py ++++ b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/test_multipath_conf_read_8to9.py +@@ -78,9 +78,9 @@ two_defaults_conf = build_config( + os.path.join(TEST_DIR, 'two_defaults.conf'), None, True, False, False) + + +-def mock_parse_config(path): ++def mock_parse_config(path, file_locs): + """Convert config_dir into full pathname""" +- conf = multipathconfread._parse_config_orig(path) ++ conf = multipathconfread._parse_config_orig(path, file_locs) + if not conf: + return None + if conf.config_dir: +@@ -99,7 +99,9 @@ def test_parse_config(): + 'two_defaults.conf': two_defaults_conf, + 'empty.conf': empty_conf} + for config_name, expected_data in test_map.items(): +- config = multipathconfread._parse_config(os.path.join(TEST_DIR, config_name)) ++ file_locs = multipathconfread.setup_default_file_locations() ++ config = multipathconfread._parse_config( ++ os.path.join(TEST_DIR, config_name), file_locs) + assert config + assert_config(config, expected_data) + +@@ -133,6 +135,9 @@ def test_get_facts_missing_dir(monkeypatch, primary_config, expected_configs): + assert len(general_info) == 1 + assert general_info[0].is_configured + # general_info[0].config_dir is with the MultipathConfFacts8to9 messages below ++ assert general_info[0].bindings_file == '/etc/multipath/bindings' ++ assert general_info[0].wwids_file == '/etc/multipath/wwids' ++ assert general_info[0].prkeys_file == '/etc/multipath/prkeys' + + msgs = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathConfFacts8to9)] + assert len(msgs) == 1 +@@ -144,31 +149,32 @@ def test_get_facts_missing_dir(monkeypatch, primary_config, expected_configs): + assert_config(actual_config, expected_config) + + +-def test_only_general_info_is_produced_on_9to10(monkeypatch): +- default_config_path = '/etc/multipath.conf' +- +- def parse_config_mock(path): +- assert path == default_config_path +- return MultipathConfig8to9(pathname=path) +- +- monkeypatch.setattr(multipathconfread, '_parse_config', parse_config_mock) ++def test_file_locations_nondefault(monkeypatch): ++ """Check that non-default file locations are tracked correctly.""" + monkeypatch.setattr(multipathconfread, 'is_processable', lambda: True) + + produce_mock = produce_mocked() + monkeypatch.setattr(api, 'produce', produce_mock) + +- actor_mock = CurrentActorMocked(src_ver='9.6', dst_ver='10.0') ++ actor_mock = CurrentActorMocked(src_ver='8.10', dst_ver='9.6') + monkeypatch.setattr(api, 'current_actor', actor_mock) + +- multipathconfread.scan_and_emit_multipath_info(default_config_path) ++ # Create a test config with non-default file locations ++ file_locs = multipathconfread.setup_default_file_locations() ++ file_locs['bindings_file'] = '/tmp/bindings' ++ file_locs['wwids_file'] = '/tmp/wwids' + +- assert produce_mock.called ++ def mock_parse(path, fl): ++ fl.update(file_locs) ++ return MultipathConfig8to9(pathname=path) + +- general_info_msgs = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathInfo)] +- assert len(general_info_msgs) == 1 +- general_info = general_info_msgs[0] +- assert general_info.is_configured +- assert general_info.config_dir == '/etc/multipath/conf.d' ++ monkeypatch.setattr(multipathconfread, '_parse_config', mock_parse) ++ monkeypatch.setattr(multipathconfread, '_parse_config_dir', lambda d, fl: []) + +- msgs = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathConfFacts8to9)] +- assert not msgs ++ multipathconfread.scan_and_emit_multipath_info('/etc/multipath.conf') ++ ++ general_info = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathInfo)] ++ assert len(general_info) == 1 ++ assert general_info[0].bindings_file == '/tmp/bindings' ++ assert general_info[0].wwids_file == '/tmp/wwids' ++ assert general_info[0].prkeys_file == '/etc/multipath/prkeys' +diff --git a/repos/system_upgrade/el8toel9/actors/removeupgradeefientry/libraries/removeupgradeefientry.py b/repos/system_upgrade/el8toel9/actors/removeupgradeefientry/libraries/removeupgradeefientry.py +index 7e2972e5..0e2346f0 100644 +--- a/repos/system_upgrade/el8toel9/actors/removeupgradeefientry/libraries/removeupgradeefientry.py ++++ b/repos/system_upgrade/el8toel9/actors/removeupgradeefientry/libraries/removeupgradeefientry.py +@@ -55,7 +55,16 @@ def remove_upgrade_efi_entry(): + # TODO: Move calling `mount -a` to a separate actor as it is not really + # related to removing the upgrade boot entry. It's worth to call it after + # removing the boot entry to avoid boot loop in case mounting fails. +- run(['/bin/mount', '-a']) ++ try: ++ run(['/bin/mount', '-a']) ++ except CalledProcessError as err: ++ # Every mount that is not marked with 'nofail' should be mounted when this code is executed, so mount -a can ++ # fail only on corrupted nofail devices. We ignore errors here so that our mount -a behaves consistently ++ # with systemd's mount units marked with 'nofail' -- failures of such units will not prevent the system from ++ # booting. If a mount mounts a block dev with a corrupted FS, then the system could not have used the device, ++ # so it does not contain anything important for the upgrade. ++ api.current_logger().warning('Failed to execute `mount -a` with the following error: {}.'.format(err)) ++ pass + + + def _remove_upgrade_blsdir(bootloader_info): +diff --git a/repos/system_upgrade/el8toel9/models/multipath8to9.py b/repos/system_upgrade/el8toel9/models/multipath8to9.py +new file mode 100644 +index 00000000..a02f45db +--- /dev/null ++++ b/repos/system_upgrade/el8toel9/models/multipath8to9.py +@@ -0,0 +1,34 @@ ++from leapp.models import fields, Model ++from leapp.topics import SystemInfoTopic ++ ++ ++class MultipathConfig8to9(Model): ++ """ ++ Model information about multipath configuration file important for the 8>9 upgrade path. ++ """ ++ topic = SystemInfoTopic ++ ++ pathname = fields.String() ++ """Config file path name""" ++ ++ config_dir = fields.Nullable(fields.String()) ++ """Value of config_dir in the defaults section. None if not set""" ++ ++ enable_foreign_exists = fields.Boolean(default=False) ++ """True if enable_foreign is set in the defaults section""" ++ ++ invalid_regexes_exist = fields.Boolean(default=False) ++ """True if any regular expressions have the value of "*" """ ++ ++ allow_usb_exists = fields.Boolean(default=False) ++ """True if allow_usb_devices is set in the defaults section.""" ++ ++ ++class MultipathConfFacts8to9(Model): ++ """ ++ Model representing information from multipath configuration files important for the 8>9 upgrade path. ++ """ ++ topic = SystemInfoTopic ++ ++ configs = fields.List(fields.Model(MultipathConfig8to9), default=[]) ++ """List of multipath configuration files""" +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/actor.py b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/actor.py +new file mode 100644 +index 00000000..2b122fdd +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/actor.py +@@ -0,0 +1,25 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import multipathconfread ++from leapp.models import DistributionSignedRPM, MultipathConfFacts9to10, MultipathInfo ++from leapp.tags import FactsPhaseTag, IPUWorkflowTag ++ ++ ++class MultipathConfRead9to10(Actor): ++ """ ++ Read multipath configuration files and extract the necessary information ++ ++ Related files: ++ - /etc/multipath.conf ++ - /etc/multipath/ - any files inside the directory ++ ++ Produces MultipathInfo with general information about multipath, and ++ MultipathConfFacts9to10 with details needed for the 9 to 10 upgrade. ++ """ ++ ++ name = 'multipath_conf_read_9to10' ++ consumes = (DistributionSignedRPM,) ++ produces = (MultipathInfo, MultipathConfFacts9to10) ++ tags = (FactsPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ multipathconfread.scan_and_emit_multipath_info() +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py +new file mode 100644 +index 00000000..0f070c1a +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py +@@ -0,0 +1,165 @@ ++import errno ++import os ++ ++from leapp.libraries.common import mpathfiles, multipathutil ++from leapp.libraries.common.rpms import has_package ++from leapp.libraries.stdlib import api ++from leapp.models import DistributionSignedRPM, MultipathConfFacts9to10, MultipathConfig9to10, MultipathInfo ++ ++_DEFAULT_BINDINGS_FILE = '/etc/multipath/bindings' ++_DEFAULT_WWIDS_FILE = '/etc/multipath/wwids' ++_DEFAULT_PRKEYS_FILE = '/etc/multipath/prkeys' ++ ++ ++def _parse_config(path): ++ contents = multipathutil.read_config(path) ++ if contents is None: ++ return None ++ conf = MultipathConfig9to10(pathname=path) ++ section = None ++ in_subsection = False ++ for line in contents.split('\n'): ++ try: ++ data = multipathutil.LineData(line, section, in_subsection) ++ except ValueError: ++ continue ++ if data.type == data.TYPE_BLANK: ++ continue ++ if data.type == data.TYPE_SECTION_END: ++ if in_subsection: ++ in_subsection = False ++ elif section: ++ section = None ++ continue ++ if data.type == data.TYPE_SECTION_START: ++ if not section: ++ section = data.section ++ elif not in_subsection: ++ in_subsection = True ++ continue ++ if data.type != data.TYPE_OPTION: ++ continue ++ if section == 'defaults': ++ if data.option == 'config_dir': ++ conf.config_dir = data.value ++ elif data.option == 'bindings_file': ++ conf.bindings_file = data.value ++ elif data.option == 'wwids_file': ++ conf.wwids_file = data.value ++ elif data.option == 'prkeys_file': ++ conf.prkeys_file = data.value ++ if data.option == 'getuid_callout': ++ conf.has_getuid = True ++ return conf ++ ++ ++def _parse_config_dir(config_dir): ++ res = [] ++ try: ++ for config_file in sorted(os.listdir(config_dir)): ++ path = os.path.join(config_dir, config_file) ++ if not path.endswith('.conf'): ++ continue ++ conf = _parse_config(path) ++ if conf: ++ res.append(conf) ++ except OSError as e: ++ if e.errno == errno.ENOENT: ++ api.current_logger().debug( ++ 'Multipath conf directory "%s" doesn\'t exist', ++ config_dir ++ ) ++ else: ++ api.current_logger().warning( ++ 'Failed to read multipath config directory "%s": %s', ++ config_dir, ++ e ++ ) ++ return res ++ ++ ++def _check_socket_activation(): ++ return os.path.exists( ++ '/etc/systemd/system/sockets.target.wants/multipathd.socket' ++ ) ++ ++ ++def _check_dm_nvme_multipathing(): ++ if not os.path.isdir('/sys/module/nvme_core'): ++ return False ++ try: ++ with open('/sys/module/nvme_core/parameters/multipath', 'r') as f: ++ content = f.read().strip() ++ except IOError: ++ return False ++ return content == 'N' ++ ++ ++def is_processable(): ++ res = has_package(DistributionSignedRPM, 'device-mapper-multipath') ++ if not res: ++ api.current_logger().debug('device-mapper-multipath is not installed.') ++ return res ++ ++ ++def _add_file_locations(configs, mpath_info): ++ bindings_file, wwids_file, prkeys_file = mpathfiles.mpath_file_locations(configs) ++ ++ attrs_to_populate = [ ++ ('bindings_file', bindings_file, _DEFAULT_BINDINGS_FILE), ++ ('wwids_file', wwids_file, _DEFAULT_WWIDS_FILE), ++ ('prkeys_file', prkeys_file, _DEFAULT_PRKEYS_FILE), ++ ] ++ ++ # Set the attribute value only if it is not configured or equal to default. ++ # Setting mpath_info.X = Y would cause the config at Y to be copied into ++ # target_uspace/Y. However, we want to place Y at the default location due ++ # to 9>10 deprecations and there is no easy way to remove target_uspace/Y ++ # once created. ++ for mpath_info_attr, configured_value, default_value in attrs_to_populate: ++ if ( ++ configured_value is None or ++ os.path.normpath(configured_value) == default_value ++ ): ++ setattr(mpath_info, mpath_info_attr, default_value) ++ ++ ++def scan_and_emit_multipath_info(default_config_path='/etc/multipath.conf'): ++ if not is_processable(): ++ return ++ ++ primary_config = _parse_config(default_config_path) ++ if not primary_config: ++ api.current_logger().debug( ++ 'Primary multipath config /etc/multipath.conf is not present - multipath ' ++ 'is not used.' ++ ) ++ mpath_info = MultipathInfo(is_configured=False) ++ api.produce(mpath_info) ++ return ++ ++ multipath_info = MultipathInfo(is_configured=True) ++ # Do not set multipath_info.config_dir to a non-default directory, ++ # otherwise any files that exist there will be copied to that directory ++ # during the upgrade. Also don't set it to the default directory if you ++ # aren't reading config files from there. The directory may exist and have ++ # config files which are not used in the existing config, and should not ++ # be copied. ++ if ( ++ not primary_config.config_dir or ++ os.path.normpath(primary_config.config_dir) == '/etc/multipath/conf.d' ++ ): ++ multipath_info.config_dir = '/etc/multipath/conf.d' ++ ++ primary_config.has_socket_activation = _check_socket_activation() ++ primary_config.has_dm_nvme_multipathing = _check_dm_nvme_multipathing() ++ ++ secondary_configs = _parse_config_dir( ++ primary_config.config_dir or '/etc/multipath/conf.d' ++ ) ++ all_configs = [primary_config] + secondary_configs ++ _add_file_locations(all_configs, multipath_info) ++ api.produce(multipath_info) ++ ++ config_facts_for_9to10 = MultipathConfFacts9to10(configs=all_configs) ++ api.produce(config_facts_for_9to10) +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/all_options.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/all_options.conf +new file mode 100644 +index 00000000..cf72bf24 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/all_options.conf +@@ -0,0 +1,9 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++ config_dir "/etc/multipath/conf.d" ++ bindings_file "/etc/multipath/bindings" ++ wwids_file "/etc/multipath/wwids" ++ prkeys_file "/etc/multipath/prkeys" ++ getuid_callout "/lib/udev/scsi_id --whitelisted --device=/dev/%n" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/all_set_in_dir.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/all_set_in_dir.conf +new file mode 100644 +index 00000000..d9698c0a +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/all_set_in_dir.conf +@@ -0,0 +1,5 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++ config_dir "conf2.d" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/complicated.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/complicated.conf +new file mode 100644 +index 00000000..368e7d91 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/complicated.conf +@@ -0,0 +1,1136 @@ ++defaults { ++ verbosity 2 ++ polling_interval 5 ++ max_polling_interval 20 ++ reassign_maps "no" ++ multipath_dir "/lib64/multipath" ++ path_selector "service-time 0" ++ path_grouping_policy "failover" ++ uid_attribute "ID_SERIAL" ++ prio "const" ++ prio_args "" ++ features "0" ++ path_checker "tur" ++ alias_prefix "mpath" ++ failback "manual" ++ rr_min_io 1000 ++ rr_min_io_rq 1 ++ max_fds "max" ++ rr_weight "uniform" ++ queue_without_daemon "no" ++ allow_usb_devices "no" ++ flush_on_last_del "unused" ++ user_friendly_names "yes" ++ fast_io_fail_tmo 5 ++ bindings_file "/etc/multipath/bindings" ++ wwids_file "/etc/multipath/wwids" ++ prkeys_file "/etc/multipath/prkeys" ++ log_checker_err "always" ++ reservation_key "file" ++ all_tg_pt "no" ++ retain_attached_hw_handler "yes" ++ detect_prio "yes" ++ detect_checker "yes" ++ force_sync "yes" ++ strict_timing "no" ++ deferred_remove "no" ++ config_dir "/etc/multipath/conf.d" ++ delay_watch_checks "no" ++ delay_wait_checks "no" ++ san_path_err_threshold "no" ++ san_path_err_forget_rate "no" ++ san_path_err_recovery_time "no" ++ marginal_path_err_sample_time "no" ++ marginal_path_err_rate_threshold "no" ++ marginal_path_err_recheck_gap_time "no" ++ marginal_path_double_failed_time "no" ++ find_multipaths "on" ++ uxsock_timeout 4000 ++ retrigger_tries 0 ++ retrigger_delay 10 ++ missing_uev_wait_timeout 30 ++ skip_kpartx "no" ++ purge_disconnected "no" ++ disable_changed_wwids "ignored" ++ remove_retries 0 ++ ghost_delay "no" ++ auto_resize "never" ++ find_multipaths_timeout -10 ++ enable_foreign "NONE" ++ marginal_pathgroups "off" ++ recheck_wwid "no" ++} ++blacklist { ++ devnode ".*" ++ device { ++ vendor "SGI" ++ product "Universal Xport" ++ } ++ device { ++ vendor "^DGC" ++ product "LUNZ" ++ } ++ device { ++ vendor "EMC" ++ product "LUNZ" ++ } ++ device { ++ vendor "DELL" ++ product "Universal Xport" ++ } ++ device { ++ vendor "FUJITSU" ++ product "Universal Xport" ++ } ++ device { ++ vendor "IBM" ++ product "Universal Xport" ++ } ++ device { ++ vendor "IBM" ++ product "S/390" ++ } ++ device { ++ vendor "LENOVO" ++ product "Universal Xport" ++ } ++ device { ++ vendor "(NETAPP|LSI|ENGENIO)" ++ product "Universal Xport" ++ } ++ device { ++ vendor "STK" ++ product "Universal Xport" ++ } ++ device { ++ vendor "SUN" ++ product "Universal Xport" ++ } ++ wwid ".*" ++ device { ++ vendor "(Intel|INTEL)" ++ product "VTrak V-LUN" ++ } ++ device { ++ vendor "Promise" ++ product "VTrak V-LUN" ++ } ++ device { ++ vendor "Promise" ++ product "Vess V-LUN" ++ } ++ protocol ".*" ++} ++blacklist_exceptions { ++ devnode "^sd[a-z]" ++ wwid "^3" ++ protocol "scsi" ++} ++devices { ++ device { ++ vendor "NVME" ++ product ".*" ++ uid_attribute "ID_WWN" ++ path_checker "none" ++ retain_attached_hw_handler "no" ++ } ++ device { ++ vendor "APPLE" ++ product "Xserve RAID" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "3PARdata" ++ product "VV" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 18 ++ fast_io_fail_tmo 10 ++ dev_loss_tmo "infinity" ++ vpd_vendor "hp3par" ++ } ++ device { ++ vendor "NVME" ++ product "HPE Alletra" ++ path_grouping_policy "group_by_prio" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "DEC" ++ product "HSG80" ++ path_grouping_policy "group_by_prio" ++ path_checker "hp_sw" ++ hardware_handler "1 hp_sw" ++ prio "hp_sw" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "HP" ++ product "A6189A" ++ path_grouping_policy "multibus" ++ no_path_retry 12 ++ } ++ device { ++ vendor "(COMPAQ|HP)" ++ product "(MSA|HSV)1[01]0" ++ path_grouping_policy "group_by_prio" ++ path_checker "hp_sw" ++ hardware_handler "1 hp_sw" ++ prio "hp_sw" ++ no_path_retry 12 ++ } ++ device { ++ vendor "(COMPAQ|HP)" ++ product "MSA VOLUME" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 12 ++ } ++ device { ++ vendor "(COMPAQ|HP)" ++ product "(HSV1[01]1|HSV2[01]0|HSV3[046]0|HSV4[05]0)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 12 ++ } ++ device { ++ vendor "HP" ++ product "(MSA2[02]12fc|MSA2012i)" ++ path_grouping_policy "multibus" ++ no_path_retry 18 ++ } ++ device { ++ vendor "HP" ++ product "(MSA2012sa|MSA23(12|24)(fc|i|sa)|MSA2000s VOLUME)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 18 ++ } ++ device { ++ vendor "(HP|HPE)" ++ product "MSA [12]0[4567]0 (SAN|SAS|FC|iSCSI)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 18 ++ } ++ device { ++ vendor "HP" ++ product "(HSVX700|HSVX740)" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 12 ++ } ++ device { ++ vendor "HP" ++ product "LOGICAL VOLUME" ++ path_grouping_policy "multibus" ++ no_path_retry 12 ++ } ++ device { ++ vendor "HP" ++ product "(P2000 G3 FC|P2000G3 FC/iSCSI|P2000 G3 SAS|P2000 G3 iSCSI)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 18 ++ } ++ device { ++ vendor "LEFTHAND" ++ product "(P4000|iSCSIDisk|FCDISK)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 18 ++ } ++ device { ++ vendor "Nimble" ++ product "Server" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "SGI" ++ product "TP9100" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "SGI" ++ product "TP9[3457]00" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SGI" ++ product "IS" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SGI" ++ product "^DD[46]A-" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "DDN" ++ product "SAN DataDirector" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "DDN" ++ product "^EF3010" ++ path_grouping_policy "multibus" ++ no_path_retry 30 ++ } ++ device { ++ vendor "DDN" ++ product "^(EF3015|S2A|SFA)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "NEXENTA" ++ product "COMSTAR" ++ path_grouping_policy "group_by_serial" ++ no_path_retry 30 ++ } ++ device { ++ vendor "TEGILE" ++ product "(ZEBI-(FC|ISCSI)|INTELLIFLASH)" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 10 ++ } ++ device { ++ vendor "EMC" ++ product "SYMMETRIX" ++ path_grouping_policy "multibus" ++ no_path_retry 6 ++ } ++ device { ++ vendor "^DGC" ++ product "^(RAID|DISK|VRAID)" ++ product_blacklist "LUNZ" ++ path_grouping_policy "group_by_prio" ++ path_checker "emc_clariion" ++ hardware_handler "1 emc" ++ prio "emc" ++ failback "immediate" ++ no_path_retry 60 ++ detect_checker "no" ++ } ++ device { ++ vendor "EMC" ++ product "Invista" ++ product_blacklist "LUNZ" ++ path_grouping_policy "multibus" ++ no_path_retry 5 ++ } ++ device { ++ vendor "XtremIO" ++ product "XtremApp" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "COMPELNT" ++ product "Compellent Vol" ++ path_grouping_policy "group_by_prio" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "DELL" ++ product "^MD3" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "NVME" ++ product "^EMC PowerMax_" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "DellEMC" ++ product "PowerStore" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 3 ++ fast_io_fail_tmo 15 ++ } ++ device { ++ vendor "DellEMC" ++ product "^ME" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ } ++ device { ++ vendor "FSC" ++ product "CentricStor" ++ path_grouping_policy "group_by_serial" ++ } ++ device { ++ vendor "FUJITSU" ++ product "ETERNUS_DX(H|L|M|400|8000)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 10 ++ } ++ device { ++ vendor "(EUROLOGC|EuroLogc)" ++ product "FC2502" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "FUJITSU" ++ product "E[234]000" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 10 ++ } ++ device { ++ vendor "FUJITSU" ++ product "E[68]000" ++ path_grouping_policy "multibus" ++ no_path_retry 10 ++ } ++ device { ++ vendor "FUJITSU" ++ product "ETERNUS_AHB" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "(HITACHI|HP|HPE)" ++ product "^OPEN-" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "HITACHI" ++ product "^DF" ++ path_grouping_policy "group_by_prio" ++ prio "hds" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "HITACHI" ++ product "^DF600F" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "IBM" ++ product "ProFibre 4000R" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "IBM" ++ product "^1722-600" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^1724" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^1726" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^1742" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^1746" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^1813" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^1814" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^1815" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^1818" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^3526" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^(3542|3552)" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "IBM" ++ product "^2105" ++ path_grouping_policy "multibus" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "IBM" ++ product "^1750500" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "IBM" ++ product "^2107900" ++ path_grouping_policy "group_by_prio" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "IBM" ++ product "^2145" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "IBM" ++ product "S/390 DASD ECKD" ++ product_blacklist "S/390" ++ path_grouping_policy "multibus" ++ uid_attribute "ID_UID" ++ path_checker "directio" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "IBM" ++ product "S/390 DASD FBA" ++ product_blacklist "S/390" ++ path_grouping_policy "multibus" ++ uid_attribute "ID_UID" ++ path_checker "directio" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "IBM" ++ product "^IPR" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "IBM" ++ product "1820N00" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "(XIV|IBM)" ++ product "(NEXTRA|2810XIV)" ++ path_grouping_policy "group_by_prio" ++ failback 15 ++ no_path_retry "queue" ++ } ++ device { ++ vendor "(TMS|IBM)" ++ product "(RamSan|FlashSystem)" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "IBM" ++ product "^(DCS9900|2851)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "AIX" ++ product "VDASD" ++ path_grouping_policy "multibus" ++ no_path_retry 60 ++ } ++ device { ++ vendor "IBM" ++ product "3303[ ]+NVDISK" ++ no_path_retry 60 ++ } ++ device { ++ vendor "AIX" ++ product "NVDISK" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 60 ++ } ++ device { ++ vendor "LENOVO" ++ product "DE_Series" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "NETAPP" ++ product "LUN" ++ path_grouping_policy "group_by_prio" ++ features "2 pg_init_retries 50" ++ prio "ontap" ++ failback "immediate" ++ no_path_retry "queue" ++ flush_on_last_del "always" ++ dev_loss_tmo "infinity" ++ user_friendly_names "no" ++ } ++ device { ++ vendor "(NETAPP|LSI|ENGENIO)" ++ product "INF-01-00" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SolidFir" ++ product "SSD SAN" ++ path_grouping_policy "multibus" ++ no_path_retry 24 ++ } ++ device { ++ vendor "NVME" ++ product "^NetApp ONTAP Controller" ++ path_grouping_policy "multibus" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "NEC" ++ product "DISK ARRAY" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ } ++ device { ++ vendor "^Pillar" ++ product "^Axiom" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ } ++ device { ++ vendor "^Oracle" ++ product "^Oracle FS" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ } ++ device { ++ vendor "STK" ++ product "BladeCtlr" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "STK" ++ product "OPENstorage" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "STK" ++ product "FLEXLINE 380" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SUN" ++ product "StorEdge 3" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "SUN" ++ product "STK6580_6780" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SUN" ++ product "CSM[12]00_R" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SUN" ++ product "LCSM100_[IEFS]" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SUN" ++ product "SUN_6180" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SUN" ++ product "ArrayStorage" ++ product_blacklist "Universal Xport" ++ path_grouping_policy "group_by_prio" ++ path_checker "rdac" ++ features "2 pg_init_retries 50" ++ hardware_handler "1 rdac" ++ prio "rdac" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "SUN" ++ product "(Sun Storage|ZFS Storage|COMSTAR)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "PIVOT3" ++ product "RAIGE VOLUME" ++ path_grouping_policy "multibus" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "(NexGen|Pivot3)" ++ product "(TierStore|vSTAC)" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "(Intel|INTEL)" ++ product "Multi-Flex" ++ product_blacklist "VTrak V-LUN" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "(LIO-ORG|SUSE)" ++ product "RBD" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 12 ++ } ++ device { ++ vendor "DataCore" ++ product "SANmelody" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "DataCore" ++ product "Virtual Disk" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ } ++ device { ++ vendor "PURE" ++ product "FlashArray" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ fast_io_fail_tmo 10 ++ } ++ device { ++ vendor "HUAWEI" ++ product "XSG1" ++ path_grouping_policy "group_by_prio" ++ failback "immediate" ++ no_path_retry 15 ++ } ++ device { ++ vendor "KOVE" ++ product "XPD" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "NFINIDAT" ++ product "InfiniBox" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry "queue" ++ flush_on_last_del "always" ++ fast_io_fail_tmo 15 ++ dev_loss_tmo "infinity" ++ detect_prio "no" ++ } ++ device { ++ vendor "KMNRIO" ++ product "K2" ++ path_grouping_policy "multibus" ++ } ++ device { ++ vendor "NEXSAN" ++ product "NXS-B0" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 15 ++ } ++ device { ++ vendor "NEXSAN" ++ product "SATAB" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 15 ++ } ++ device { ++ vendor "Nexsan" ++ product "(NestOS|NST5000)" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "VIOLIN" ++ product "SAN ARRAY$" ++ path_grouping_policy "group_by_serial" ++ no_path_retry 30 ++ } ++ device { ++ vendor "VIOLIN" ++ product "SAN ARRAY ALUA" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "VIOLIN" ++ product "CONCERTO ARRAY" ++ path_grouping_policy "multibus" ++ no_path_retry 30 ++ } ++ device { ++ vendor "(XIOTECH|XIOtech)" ++ product "ISE" ++ path_grouping_policy "multibus" ++ no_path_retry 12 ++ } ++ device { ++ vendor "(XIOTECH|XIOtech)" ++ product "IGLU DISK" ++ path_grouping_policy "multibus" ++ no_path_retry 30 ++ } ++ device { ++ vendor "(XIOTECH|XIOtech)" ++ product "Magnitude" ++ path_grouping_policy "multibus" ++ no_path_retry 30 ++ } ++ device { ++ vendor "Vexata" ++ product "VX" ++ path_grouping_policy "multibus" ++ no_path_retry 30 ++ } ++ device { ++ vendor "Promise" ++ product "VTrak" ++ product_blacklist "VTrak V-LUN" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "Promise" ++ product "Vess" ++ product_blacklist "Vess V-LUN" ++ path_grouping_policy "group_by_prio" ++ hardware_handler "1 alua" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "^IFT" ++ product ".*" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "DotHill" ++ product "SANnet" ++ path_grouping_policy "multibus" ++ no_path_retry 30 ++ } ++ device { ++ vendor "DotHill" ++ product "R/Evo" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "DotHill" ++ product "^DH" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++ device { ++ vendor "AStor" ++ product "NeoSapphire" ++ path_grouping_policy "multibus" ++ no_path_retry 30 ++ } ++ device { ++ vendor "INSPUR" ++ product "MCS" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ } ++ device { ++ vendor "MacroSAN" ++ product "LU" ++ path_grouping_policy "group_by_prio" ++ prio "alua" ++ failback "immediate" ++ no_path_retry 30 ++ } ++} ++ ++overrides { ++ path_grouping_policy "multibus" ++ protocol { ++ type scsi:fcp ++ fast_io_fail_tmo 5 ++ } ++} ++ ++multipaths { ++ multipath { ++ wwid "333333330000007d0" ++ alias "test" ++ } ++ multipath { ++ wwid "33333333000001388" ++ alias "foo" ++ } ++} ++ +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf1.d/bindings_set.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf1.d/bindings_set.conf +new file mode 100644 +index 00000000..9b5120f9 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf1.d/bindings_set.conf +@@ -0,0 +1,3 @@ ++defaults { ++ bindings_file "/etc/multipath/bindings" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf1.d/empty.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf1.d/empty.conf +new file mode 100644 +index 00000000..8b137891 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf1.d/empty.conf +@@ -0,0 +1 @@ ++ +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf2.d/getuid_set.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf2.d/getuid_set.conf +new file mode 100644 +index 00000000..da0f9677 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf2.d/getuid_set.conf +@@ -0,0 +1,11 @@ ++defaults { ++ user_friendly_names yes ++} ++ ++devices { ++ device { ++ vendor "foo" ++ product "bar" ++ getuid_callout "/lib/udev/scsi_id --whitelisted --device=/dev/%n" ++ } ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf2.d/set_files.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf2.d/set_files.conf +new file mode 100644 +index 00000000..26c250b0 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf2.d/set_files.conf +@@ -0,0 +1,5 @@ ++defaults { ++ bindings_file "/etc/multipath/bindings" ++ wwids_file "/etc/multipath/wwids" ++ prkeys_file "/etc/multipath/prkeys" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf3.d/README b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf3.d/README +new file mode 100644 +index 00000000..d846e286 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf3.d/README +@@ -0,0 +1 @@ ++This directory is intentionally left without .conf files. +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/config_dir.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/config_dir.conf +new file mode 100644 +index 00000000..6b56d7f6 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/config_dir.conf +@@ -0,0 +1,5 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++ config_dir "conf1.d" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/default_rhel9.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/default_rhel9.conf +new file mode 100644 +index 00000000..c1076198 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/default_rhel9.conf +@@ -0,0 +1,21 @@ ++# device-mapper-multipath configuration file ++ ++# For a complete list of the default configuration values, run either: ++# # multipath -t ++# or ++# # multipathd show config ++ ++# For a list of configuration options with descriptions, see the ++# multipath.conf man page. ++ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++} ++ ++blacklist_exceptions { ++ property "(SCSI_IDENT_|ID_WWN)" ++} ++ ++blacklist { ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/empty.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/empty.conf +new file mode 100644 +index 00000000..8b137891 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/empty.conf +@@ -0,0 +1 @@ ++ +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/empty_dir.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/empty_dir.conf +new file mode 100644 +index 00000000..a163ed7e +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/empty_dir.conf +@@ -0,0 +1,5 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++ config_dir "conf3.d" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/extra_slash.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/extra_slash.conf +new file mode 100644 +index 00000000..ce7ea02a +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/extra_slash.conf +@@ -0,0 +1,5 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++ config_dir "/etc/multipath/conf.d/" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_defaults.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_defaults.conf +new file mode 100644 +index 00000000..326d677a +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_defaults.conf +@@ -0,0 +1,5 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++ getuid_callout "/lib/udev/scsi_id --whitelisted --device=/dev/%n" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_devices.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_devices.conf +new file mode 100644 +index 00000000..75abb4cb +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_devices.conf +@@ -0,0 +1,12 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++} ++ ++devices { ++ device { ++ vendor "foo" ++ product "bar" ++ getuid_callout "/lib/udev/scsi_id --whitelisted --device=/dev/%n" ++ } ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_overrides.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_overrides.conf +new file mode 100644 +index 00000000..8861a2d7 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_overrides.conf +@@ -0,0 +1,8 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++} ++ ++overrides { ++ getuid_callout "/lib/udev/scsi_id --whitelisted --device=/dev/%n" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/missing_dir.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/missing_dir.conf +new file mode 100644 +index 00000000..6a4bf49f +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/missing_dir.conf +@@ -0,0 +1,5 @@ ++defaults { ++ user_friendly_names yes ++ find_multipaths yes ++ config_dir "missing" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/no_defaults.conf b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/no_defaults.conf +new file mode 100644 +index 00000000..f8d54d09 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/no_defaults.conf +@@ -0,0 +1,7 @@ ++devices { ++ device { ++ vendor "foo" ++ product "bar" ++ getuid_callout "/lib/udev/scsi_id --whitelisted --device=/dev/%n" ++ } ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/test_multipath_conf_read_9to10.py b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/test_multipath_conf_read_9to10.py +new file mode 100644 +index 00000000..f8f039a0 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/test_multipath_conf_read_9to10.py +@@ -0,0 +1,382 @@ ++import os ++ ++import pytest ++ ++from leapp.libraries.actor import multipathconfread ++from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import MultipathConfFacts9to10, MultipathConfig9to10, MultipathInfo ++ ++TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'files') ++ ++ ++def build_config(pathname, config_dir=None, bindings_file=None, wwids_file=None, ++ prkeys_file=None, has_getuid=False): ++ return MultipathConfig9to10( ++ pathname=pathname, ++ config_dir=config_dir, ++ bindings_file=bindings_file, ++ wwids_file=wwids_file, ++ prkeys_file=prkeys_file, ++ has_getuid=has_getuid, ++ ) ++ ++ ++def assert_config(config, expected): ++ assert config.pathname == expected.pathname ++ assert config.config_dir == expected.config_dir ++ assert config.bindings_file == expected.bindings_file ++ assert config.wwids_file == expected.wwids_file ++ assert config.prkeys_file == expected.prkeys_file ++ assert config.has_getuid == expected.has_getuid ++ ++ ++default_rhel9_conf = build_config( ++ os.path.join(TEST_DIR, 'default_rhel9.conf')) ++ ++empty_conf = build_config( ++ os.path.join(TEST_DIR, 'empty.conf')) ++ ++getuid_defaults_conf = build_config( ++ os.path.join(TEST_DIR, 'getuid_defaults.conf'), has_getuid=True) ++ ++getuid_devices_conf = build_config( ++ os.path.join(TEST_DIR, 'getuid_devices.conf'), has_getuid=True) ++ ++getuid_overrides_conf = build_config( ++ os.path.join(TEST_DIR, 'getuid_overrides.conf'), has_getuid=True) ++ ++all_options_conf = build_config( ++ os.path.join(TEST_DIR, 'all_options.conf'), ++ config_dir='/etc/multipath/conf.d', ++ bindings_file='/etc/multipath/bindings', ++ wwids_file='/etc/multipath/wwids', ++ prkeys_file='/etc/multipath/prkeys', ++ has_getuid=True) ++ ++no_defaults_conf = build_config( ++ os.path.join(TEST_DIR, 'no_defaults.conf'), has_getuid=True) ++ ++complicated_conf = build_config( ++ os.path.join(TEST_DIR, 'complicated.conf'), ++ config_dir='/etc/multipath/conf.d', ++ bindings_file='/etc/multipath/bindings', ++ wwids_file='/etc/multipath/wwids', ++ prkeys_file='/etc/multipath/prkeys') ++ ++ ++def test_parse_config(): ++ test_map = {'default_rhel9.conf': default_rhel9_conf, ++ 'empty.conf': empty_conf, ++ 'getuid_defaults.conf': getuid_defaults_conf, ++ 'getuid_devices.conf': getuid_devices_conf, ++ 'getuid_overrides.conf': getuid_overrides_conf, ++ 'all_options.conf': all_options_conf, ++ 'no_defaults.conf': no_defaults_conf, ++ 'complicated.conf': complicated_conf} ++ for config_name, expected_data in test_map.items(): ++ config = multipathconfread._parse_config(os.path.join(TEST_DIR, config_name)) ++ assert config ++ assert_config(config, expected_data) ++ ++ ++extra_slash_conf = build_config( ++ os.path.join(TEST_DIR, 'extra_slash.conf'), ++ config_dir=os.path.join(TEST_DIR, '/etc/multipath/conf.d/')) ++ ++missing_dir_conf = build_config( ++ os.path.join(TEST_DIR, 'missing_dir.conf'), ++ config_dir=os.path.join(TEST_DIR, 'missing')) ++ ++empty_dir_conf = build_config( ++ os.path.join(TEST_DIR, 'empty_dir.conf'), ++ config_dir=os.path.join(TEST_DIR, 'conf3.d')) ++ ++config_dir_conf = build_config( ++ os.path.join(TEST_DIR, 'config_dir.conf'), ++ config_dir=os.path.join(TEST_DIR, 'conf1.d')) ++ ++bindings_set_conf = build_config( ++ os.path.join(TEST_DIR, 'conf1.d/bindings_set.conf'), ++ bindings_file='/etc/multipath/bindings') ++ ++empty1_conf = build_config( ++ os.path.join(TEST_DIR, 'conf1.d/empty.conf')) ++ ++all_set_in_dir_conf = build_config( ++ os.path.join(TEST_DIR, 'all_set_in_dir.conf'), ++ config_dir=os.path.join(TEST_DIR, 'conf2.d')) ++ ++getuid_set_conf = build_config( ++ os.path.join(TEST_DIR, 'conf2.d/getuid_set.conf'), ++ has_getuid=True) ++ ++set_files_conf = build_config( ++ os.path.join(TEST_DIR, 'conf2.d/set_files.conf'), ++ bindings_file='/etc/multipath/bindings', ++ wwids_file='/etc/multipath/wwids', ++ prkeys_file='/etc/multipath/prkeys') ++ ++ ++def mock_parse_config(path): ++ """Convert config_dir into full pathname""" ++ conf = multipathconfread._parse_config_orig(path) ++ if not conf: ++ return None ++ if conf.config_dir: ++ conf.config_dir = os.path.join(TEST_DIR, conf.config_dir) ++ return conf ++ ++ ++def mock_parse_config_dir(path): ++ assert os.path.normpath(path) == '/etc/multipath/conf.d' ++ return [] ++ ++ ++@pytest.mark.parametrize( ++ ('primary_config', 'expected_config'), ++ [ ++ ('all_options.conf', all_options_conf), ++ ('default_rhel9.conf', default_rhel9_conf), ++ ('extra_slash.conf', extra_slash_conf), ++ ] ++) ++def test_get_primary_facts_default_config_dir(monkeypatch, primary_config, expected_config): ++ monkeypatch.setattr(multipathconfread, 'is_processable', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_socket_activation', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_dm_nvme_multipathing', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_parse_config_dir', mock_parse_config_dir) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ actor_mock = CurrentActorMocked(src_ver='9.6', dst_ver='10.0') ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ config_to_use = os.path.join(TEST_DIR, primary_config) ++ multipathconfread.scan_and_emit_multipath_info(config_to_use) ++ ++ assert produce_mock.called ++ ++ general_info = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathInfo)] ++ assert len(general_info) == 1 ++ assert general_info[0].is_configured ++ assert general_info[0].config_dir == '/etc/multipath/conf.d' ++ assert general_info[0].bindings_file == '/etc/multipath/bindings' ++ assert general_info[0].wwids_file == '/etc/multipath/wwids' ++ assert general_info[0].prkeys_file == '/etc/multipath/prkeys' ++ ++ msgs = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathConfFacts9to10)] ++ assert len(msgs) == 1 ++ ++ actual_configs = msgs[0].configs ++ assert len(actual_configs) == 1 ++ ++ assert_config(actual_configs[0], expected_config) ++ ++ ++@pytest.mark.parametrize( ++ ('primary_config', 'expected_configs'), ++ [ ++ ('missing_dir.conf', [missing_dir_conf]), ++ ('empty_dir.conf', [empty_dir_conf]), ++ ('config_dir.conf', [config_dir_conf, bindings_set_conf, empty1_conf]), ++ ('all_set_in_dir.conf', [all_set_in_dir_conf, getuid_set_conf, set_files_conf]), ++ ] ++) ++def test_get_facts_with_config_dir(monkeypatch, primary_config, expected_configs): ++ monkeypatch.setattr(multipathconfread, '_parse_config_orig', multipathconfread._parse_config, raising=False) ++ monkeypatch.setattr(multipathconfread, '_parse_config', mock_parse_config) ++ monkeypatch.setattr(multipathconfread, 'is_processable', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_socket_activation', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_dm_nvme_multipathing', lambda: False) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ actor_mock = CurrentActorMocked(src_ver='9.6', dst_ver='10.0') ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ config_to_use = os.path.join(TEST_DIR, primary_config) ++ multipathconfread.scan_and_emit_multipath_info(config_to_use) ++ ++ assert produce_mock.called ++ ++ general_info = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathInfo)] ++ assert len(general_info) == 1 ++ assert general_info[0].is_configured ++ assert not general_info[0].config_dir ++ ++ msgs = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathConfFacts9to10)] ++ assert len(msgs) == 1 ++ ++ actual_configs = msgs[0].configs ++ assert len(actual_configs) == len(expected_configs) ++ ++ for actual_config, expected_config in zip(actual_configs, expected_configs): ++ assert_config(actual_config, expected_config) ++ ++ ++def test_file_locations_default(monkeypatch): ++ """Default or unset file locations should be set on MultipathInfo.""" ++ monkeypatch.setattr(multipathconfread, 'is_processable', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_socket_activation', lambda: False) ++ monkeypatch.setattr(multipathconfread, '_check_dm_nvme_multipathing', lambda: False) ++ monkeypatch.setattr(multipathconfread, '_parse_config_dir', mock_parse_config_dir) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ actor_mock = CurrentActorMocked(src_ver='9.6', dst_ver='10.0') ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ config_to_use = os.path.join(TEST_DIR, 'all_options.conf') ++ multipathconfread.scan_and_emit_multipath_info(config_to_use) ++ ++ general_info = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathInfo)] ++ assert len(general_info) == 1 ++ assert general_info[0].bindings_file == '/etc/multipath/bindings' ++ assert general_info[0].wwids_file == '/etc/multipath/wwids' ++ assert general_info[0].prkeys_file == '/etc/multipath/prkeys' ++ ++ ++def test_file_locations_nondefault(monkeypatch): ++ """Non-default file locations should NOT be set on MultipathInfo.""" ++ monkeypatch.setattr(multipathconfread, 'is_processable', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_socket_activation', lambda: False) ++ monkeypatch.setattr(multipathconfread, '_check_dm_nvme_multipathing', lambda: False) ++ monkeypatch.setattr(multipathconfread, '_parse_config_dir', mock_parse_config_dir) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ actor_mock = CurrentActorMocked(src_ver='9.6', dst_ver='10.0') ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ # Use a config that sets files to non-default paths ++ def mock_parse(path): ++ return MultipathConfig9to10( ++ pathname=path, ++ bindings_file='/tmp/bindings', ++ wwids_file='/tmp/wwids', ++ prkeys_file='/tmp/prkeys', ++ ) ++ ++ monkeypatch.setattr(multipathconfread, '_parse_config', mock_parse) ++ ++ multipathconfread.scan_and_emit_multipath_info('/etc/multipath.conf') ++ ++ general_info = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathInfo)] ++ assert len(general_info) == 1 ++ # Non-default file locations should not be set on MultipathInfo ++ assert general_info[0].bindings_file is None ++ assert general_info[0].wwids_file is None ++ assert general_info[0].prkeys_file is None ++ ++ ++def test_file_locations_secondary_overrides(monkeypatch): ++ """Last non-None value wins for file locations across configs.""" ++ monkeypatch.setattr(multipathconfread, 'is_processable', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_socket_activation', lambda: False) ++ monkeypatch.setattr(multipathconfread, '_check_dm_nvme_multipathing', lambda: False) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ actor_mock = CurrentActorMocked(src_ver='9.6', dst_ver='10.0') ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ primary = MultipathConfig9to10( ++ pathname='/etc/multipath.conf', ++ bindings_file='/tmp/bindings', ++ ) ++ ++ secondary = MultipathConfig9to10( ++ pathname='/etc/multipath/conf.d/secondary.conf', ++ bindings_file='/etc/multipath/bindings', ++ ) ++ ++ def mock_parse(path): ++ return primary ++ ++ def mock_parse_dir(config_dir): ++ return [secondary] ++ ++ monkeypatch.setattr(multipathconfread, '_parse_config', mock_parse) ++ monkeypatch.setattr(multipathconfread, '_parse_config_dir', mock_parse_dir) ++ ++ multipathconfread.scan_and_emit_multipath_info('/etc/multipath.conf') ++ ++ general_info = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathInfo)] ++ assert len(general_info) == 1 ++ # Secondary overrides primary to default, so it should be set ++ assert general_info[0].bindings_file == '/etc/multipath/bindings' ++ ++ ++def test_check_socket_activation(monkeypatch): ++ monkeypatch.setattr(os.path, 'exists', lambda path: True) ++ assert multipathconfread._check_socket_activation() is True ++ ++ monkeypatch.setattr(os.path, 'exists', lambda path: False) ++ assert multipathconfread._check_socket_activation() is False ++ ++ ++def test_check_dm_nvme_multipathing_no_module(monkeypatch): ++ monkeypatch.setattr(os.path, 'isdir', lambda path: False) ++ assert multipathconfread._check_dm_nvme_multipathing() is False ++ ++ ++def test_check_dm_nvme_multipathing_disabled(monkeypatch): ++ monkeypatch.setattr(os.path, 'isdir', lambda path: True) ++ monkeypatch.setattr(multipathconfread, 'open', ++ lambda path, mode='r': _mock_open('N'), raising=False) ++ assert multipathconfread._check_dm_nvme_multipathing() is True ++ ++ ++def test_check_dm_nvme_multipathing_enabled(monkeypatch): ++ monkeypatch.setattr(os.path, 'isdir', lambda path: True) ++ monkeypatch.setattr(multipathconfread, 'open', ++ lambda path, mode='r': _mock_open('Y'), raising=False) ++ assert multipathconfread._check_dm_nvme_multipathing() is False ++ ++ ++class _mock_open: ++ def __init__(self, content): ++ self._content = content ++ ++ def __enter__(self): ++ return self ++ ++ def __exit__(self, *args): ++ pass ++ ++ def read(self): ++ return self._content ++ ++ ++def test_system_fields_on_primary(monkeypatch): ++ monkeypatch.setattr(multipathconfread, 'is_processable', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_socket_activation', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_check_dm_nvme_multipathing', lambda: True) ++ monkeypatch.setattr(multipathconfread, '_parse_config_dir', lambda config_dir: []) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ actor_mock = CurrentActorMocked(src_ver='9.6', dst_ver='10.0') ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ config_to_use = os.path.join(TEST_DIR, 'default_rhel9.conf') ++ multipathconfread.scan_and_emit_multipath_info(config_to_use) ++ ++ assert produce_mock.called ++ ++ msgs = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathConfFacts9to10)] ++ assert len(msgs) == 1 ++ ++ configs = msgs[0].configs ++ assert len(configs) == 1 ++ ++ primary = configs[0] ++ assert primary.has_socket_activation is True ++ assert primary.has_dm_nvme_multipathing is True +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/actor.py b/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/actor.py +new file mode 100644 +index 00000000..20feb767 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/actor.py +@@ -0,0 +1,25 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import mpath_conf_check ++from leapp.models import MultipathConfFacts9to10 ++from leapp.reporting import Report ++from leapp.tags import ChecksPhaseTag, IPUWorkflowTag ++ ++ ++class MultipathConfCheck9to10(Actor): ++ """ ++ Checks if changes to the multipath configuration files are necessary ++ for upgrading to RHEL10, and reports the results. ++ """ ++ ++ name = 'multipath_conf_check_9to10' ++ consumes = (MultipathConfFacts9to10,) ++ produces = (Report,) ++ tags = (ChecksPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ facts = next(self.consume(MultipathConfFacts9to10), None) ++ if facts is None: ++ self.log.debug('Skipping execution. No MultipathConfFacts9to10 has ' ++ 'been produced') ++ return ++ mpath_conf_check.check_configs(facts) +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/libraries/mpath_conf_check.py b/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/libraries/mpath_conf_check.py +new file mode 100644 +index 00000000..8c4dc531 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/libraries/mpath_conf_check.py +@@ -0,0 +1,177 @@ ++import os ++ ++from leapp import reporting ++from leapp.libraries.common import mpathfiles ++from leapp.reporting import create_report ++ ++_DEFAULT_CONFIG_DIR = '/etc/multipath/conf.d' ++_DEFAULT_BINDINGS_FILE = '/etc/multipath/bindings' ++_DEFAULT_WWIDS_FILE = '/etc/multipath/wwids' ++_DEFAULT_PRKEYS_FILE = '/etc/multipath/prkeys' ++ ++ ++def _default_config_dir_has_conf_files(): ++ if not os.path.exists(_DEFAULT_CONFIG_DIR): ++ return False ++ for filename in os.listdir(_DEFAULT_CONFIG_DIR): ++ if filename.endswith('.conf'): ++ return True ++ return False ++ ++ ++def _report_config_dir(config_dir): ++ create_report([ ++ reporting.Title( ++ 'device-mapper-multipath custom config_dir is deprecated' ++ ), ++ reporting.Summary( ++ 'The multipath configuration option "config_dir" is set to ' ++ '"{cfg_dir}". In RHEL-10, this option is deprecated and unused. ' ++ 'The only valid configuration directory is "{def_cfg_dir}". Any ' ++ 'configuration files in "{cfg_dir}" will be moved to ' ++ '"{def_cfg_dir}".'.format( ++ cfg_dir=config_dir, def_cfg_dir=_DEFAULT_CONFIG_DIR)), ++ reporting.Severity(reporting.Severity.INFO), ++ reporting.Groups([reporting.Groups.SERVICES]), ++ reporting.RelatedResource('package', 'device-mapper-multipath') ++ ]) ++ ++ ++def _report_config_dir_conflict(config_dir): ++ create_report([ ++ reporting.Title( ++ 'device-mapper-multipath config_dir conflict' ++ ), ++ reporting.Summary( ++ 'The multipath configuration option "config_dir" is set to ' ++ '"{cfg_dir}". During the upgrade, configuration files from ' ++ '"{cfg_dir}" will be moved to "{def_cfg_dir}". However, ' ++ '"{def_cfg_dir}" already contains .conf files. These existing ' ++ 'files would be added to the multipath configuration after the ' ++ 'upgrade. Please remove or relocate the files in "{def_cfg_dir}" ' ++ 'before upgrading.'.format( ++ cfg_dir=config_dir, def_cfg_dir=_DEFAULT_CONFIG_DIR)), ++ reporting.Severity(reporting.Severity.HIGH), ++ reporting.Groups([reporting.Groups.INHIBITOR]), ++ reporting.RelatedResource('package', 'device-mapper-multipath') ++ ]) ++ ++ ++def _report_files(file_list): ++ details = ', '.join( ++ '{} (currently "{}") will be moved to "{}"'.format(name, current, default) ++ for name, current, default in file_list ++ ) ++ create_report([ ++ reporting.Title( ++ 'device-mapper-multipath configuration files will be moved' ++ ), ++ reporting.Summary( ++ 'The following multipath configuration file locations are ' ++ 'deprecated and unused in RHEL-10. The files will be moved ' ++ 'to their default locations: {}.'.format(details)), ++ reporting.Severity(reporting.Severity.INFO), ++ reporting.Groups([reporting.Groups.SERVICES]), ++ reporting.RelatedResource('package', 'device-mapper-multipath') ++ ]) ++ ++ ++def _report_socket_activation(): ++ create_report([ ++ reporting.Title( ++ 'device-mapper-multipath socket activation is disabled by default' ++ ), ++ reporting.Summary( ++ 'In RHEL-10, multipathd socket activation is disabled by ' ++ 'default. If you wish to re-enable it, uncomment ' ++ '"WantedBy=sockets.target" in ' ++ '/lib/systemd/system/multipathd.socket'), ++ reporting.Severity(reporting.Severity.INFO), ++ reporting.Groups([reporting.Groups.SERVICES]), ++ reporting.RelatedResource('package', 'device-mapper-multipath') ++ ]) ++ ++ ++def _report_dm_nvme_multipathing(): ++ create_report([ ++ reporting.Title( ++ 'device-mapper-multipath NVMe multipathing is no longer supported' ++ ), ++ reporting.Summary( ++ 'Only Native NVMe multipathing is supported in RHEL-10. Any ' ++ 'multipath NVMe devices will still work, but they will no ' ++ 'longer be managed by dm-multipath.'), ++ reporting.Severity(reporting.Severity.INFO), ++ reporting.Groups([reporting.Groups.SERVICES]), ++ reporting.RelatedResource('package', 'device-mapper-multipath') ++ ]) ++ ++ ++def _create_paths_str(paths): ++ if len(paths) < 2: ++ return paths[0] ++ return '{} and {}'.format(', '.join(paths[0:-1]), paths[-1]) ++ ++ ++def _report_getuid(paths): ++ paths_str = _create_paths_str(paths) ++ create_report([ ++ reporting.Title( ++ 'device-mapper-multipath configuration contains getuid_callout' ++ ), ++ reporting.Summary( ++ 'The "getuid_callout" option is no longer supported in ' ++ 'RHEL-10. It must be removed from the multipath ' ++ 'configuration before upgrading. The option was found in ' ++ '{}.'.format(paths_str)), ++ reporting.Severity(reporting.Severity.HIGH), ++ reporting.Groups([reporting.Groups.INHIBITOR]), ++ reporting.RelatedResource('package', 'device-mapper-multipath') ++ ]) ++ ++ ++def check_configs(facts): ++ if not facts.configs: ++ return ++ ++ primary = facts.configs[0] ++ ++ # config_dir: only valid in primary config ++ config_dir = primary.config_dir ++ if ( ++ config_dir is not None and ++ os.path.normpath(config_dir) != _DEFAULT_CONFIG_DIR ++ ): ++ _report_config_dir(config_dir) ++ if _default_config_dir_has_conf_files(): ++ _report_config_dir_conflict(config_dir) ++ ++ bindings_file, wwids_file, prkeys_file = mpathfiles.mpath_file_locations(facts.configs) ++ ++ file_list = [] ++ files_to_report = [ ++ ('bindings_file', bindings_file, _DEFAULT_BINDINGS_FILE), ++ ('wwids_file', wwids_file, _DEFAULT_WWIDS_FILE), ++ ('prkeys_file', prkeys_file, _DEFAULT_PRKEYS_FILE), ++ ] ++ ++ for file_name, configured_path, default_path in files_to_report: ++ if ( ++ configured_path is not None and ++ os.path.normpath(configured_path) != default_path ++ ): ++ file_list.append((file_name, configured_path, default_path)) ++ ++ if file_list: ++ _report_files(file_list) ++ ++ # socket activation and dm nvme multipathing: system-level, primary only ++ if primary.has_socket_activation: ++ _report_socket_activation() ++ if primary.has_dm_nvme_multipathing: ++ _report_dm_nvme_multipathing() ++ ++ # getuid: per-file, report if any config has it ++ getuid_paths = [conf.pathname for conf in facts.configs if conf.has_getuid] ++ if getuid_paths: ++ _report_getuid(getuid_paths) +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/tests/test_multipath_conf_check_9to10.py b/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/tests/test_multipath_conf_check_9to10.py +new file mode 100644 +index 00000000..bb03e5dd +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/tests/test_multipath_conf_check_9to10.py +@@ -0,0 +1,258 @@ ++from leapp.libraries.actor import mpath_conf_check ++from leapp.models import MultipathConfFacts9to10, MultipathConfig9to10 ++from leapp.reporting import Report ++ ++ ++def _assert_config_dir_report(report): ++ assert report['title'] == \ ++ 'device-mapper-multipath custom config_dir is deprecated' ++ assert report['severity'] == 'info' ++ ++ ++def _assert_config_dir_conflict_report(report): ++ assert report['title'] == \ ++ 'device-mapper-multipath config_dir conflict' ++ assert report['severity'] == 'high' ++ ++ ++def _assert_files_report(report): ++ assert report['title'] == \ ++ 'device-mapper-multipath configuration files will be moved' ++ assert report['severity'] == 'info' ++ ++ ++def _assert_socket_activation_report(report): ++ assert report['title'] == \ ++ 'device-mapper-multipath socket activation is disabled by default' ++ assert report['severity'] == 'info' ++ ++ ++def _assert_dm_nvme_report(report): ++ assert report['title'] == \ ++ 'device-mapper-multipath NVMe multipathing is no longer supported' ++ assert report['severity'] == 'info' ++ ++ ++def _assert_getuid_report(report, paths_str): ++ assert report['title'] == \ ++ 'device-mapper-multipath configuration contains getuid_callout' ++ assert report['severity'] == 'high' ++ assert paths_str in report['summary'] ++ ++ ++def _build_config(pathname, config_dir=None, bindings_file=None, ++ wwids_file=None, prkeys_file=None, ++ has_socket_activation=False, has_dm_nvme_multipathing=False, ++ has_getuid=False): ++ return MultipathConfig9to10( ++ pathname=pathname, ++ config_dir=config_dir, ++ bindings_file=bindings_file, ++ wwids_file=wwids_file, ++ prkeys_file=prkeys_file, ++ has_socket_activation=has_socket_activation, ++ has_dm_nvme_multipathing=has_dm_nvme_multipathing, ++ has_getuid=has_getuid, ++ ) ++ ++ ++def _build_facts(confs): ++ return MultipathConfFacts9to10(configs=confs) ++ ++ ++def test_no_issues(current_actor_context): ++ config = _build_config('no_issues.conf') ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = current_actor_context.consume(Report) ++ assert not reports ++ ++ ++def test_all_issues(current_actor_context, monkeypatch): ++ monkeypatch.setattr(mpath_conf_check, '_default_config_dir_has_conf_files', lambda: False) ++ config = _build_config( ++ 'all_issues.conf', ++ config_dir='/etc/multipath/foo.d', ++ bindings_file='/tmp/bindings', ++ has_socket_activation=True, ++ has_dm_nvme_multipathing=True, ++ has_getuid=True) ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 5 ++ _assert_config_dir_report(reports[0].report) ++ _assert_files_report(reports[1].report) ++ _assert_socket_activation_report(reports[2].report) ++ _assert_dm_nvme_report(reports[3].report) ++ _assert_getuid_report(reports[4].report, 'all_issues.conf') ++ ++ ++def test_config_dir_default(current_actor_context): ++ config = _build_config('default_dir.conf', ++ config_dir='/etc/multipath/conf.d') ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = current_actor_context.consume(Report) ++ assert not reports ++ ++ ++def test_config_dir_nondefault(current_actor_context, monkeypatch): ++ monkeypatch.setattr(mpath_conf_check, '_default_config_dir_has_conf_files', lambda: False) ++ config = _build_config('custom_dir.conf', ++ config_dir='/etc/multipath/foo.d') ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 1 ++ _assert_config_dir_report(reports[0].report) ++ ++ ++def test_files_nondefault(current_actor_context): ++ config = _build_config('custom_files.conf', ++ bindings_file='/tmp/bindings', ++ wwids_file='/tmp/wwids') ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 1 ++ _assert_files_report(reports[0].report) ++ assert '/tmp/bindings' in reports[0].report['summary'] ++ assert '/tmp/wwids' in reports[0].report['summary'] ++ ++ ++def test_files_overridden_by_secondary(current_actor_context): ++ primary = _build_config('primary.conf', ++ bindings_file='/tmp/bindings') ++ secondary = _build_config('secondary.conf', ++ bindings_file='/etc/multipath/bindings') ++ facts = _build_facts([primary, secondary]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = current_actor_context.consume(Report) ++ assert not reports ++ ++ ++def test_files_secondary_overrides_to_nondefault(current_actor_context): ++ primary = _build_config('primary.conf') ++ secondary = _build_config('secondary.conf', ++ bindings_file='/tmp/bindings') ++ facts = _build_facts([primary, secondary]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 1 ++ _assert_files_report(reports[0].report) ++ ++ ++def test_socket_activation(current_actor_context): ++ config = _build_config('socket.conf', has_socket_activation=True) ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 1 ++ _assert_socket_activation_report(reports[0].report) ++ ++ ++def test_no_socket_activation(current_actor_context): ++ config = _build_config('no_socket.conf', has_socket_activation=False) ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = current_actor_context.consume(Report) ++ assert not reports ++ ++ ++def test_dm_nvme(current_actor_context): ++ config = _build_config('nvme.conf', has_dm_nvme_multipathing=True) ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 1 ++ _assert_dm_nvme_report(reports[0].report) ++ ++ ++def test_getuid_inhibitor(current_actor_context): ++ config = _build_config('getuid.conf', has_getuid=True) ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 1 ++ _assert_getuid_report(reports[0].report, 'getuid.conf') ++ ++ ++def test_getuid_in_secondary(current_actor_context): ++ primary = _build_config('primary.conf') ++ secondary = _build_config('secondary.conf', has_getuid=True) ++ facts = _build_facts([primary, secondary]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 1 ++ _assert_getuid_report(reports[0].report, 'secondary.conf') ++ ++ ++def test_multiple_secondaries(current_actor_context): ++ # primary: non-default bindings, no getuid ++ primary = _build_config('primary.conf', ++ bindings_file='/tmp/bindings') ++ # second1: overrides bindings back to default, sets non-default wwids, ++ # has getuid ++ second1 = _build_config('second1.conf', ++ bindings_file='/etc/multipath/bindings', ++ wwids_file='/tmp/wwids', ++ has_getuid=True) ++ # second2: overrides wwids back to default, sets non-default prkeys, ++ # no getuid ++ second2 = _build_config('second2.conf', ++ wwids_file='/etc/multipath/wwids', ++ prkeys_file='/tmp/prkeys') ++ # second3: has getuid only ++ second3 = _build_config('second3.conf', has_getuid=True) ++ facts = _build_facts([primary, second1, second2, second3]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ # Expect: files report (only prkeys non-default) + getuid inhibitor ++ assert reports and len(reports) == 2 ++ _assert_files_report(reports[0].report) ++ # bindings was overridden to default, wwids was overridden to default, ++ # only prkeys should appear ++ assert '/tmp/prkeys' in reports[0].report['summary'] ++ assert '/tmp/bindings' not in reports[0].report['summary'] ++ assert '/tmp/wwids' not in reports[0].report['summary'] ++ # getuid found in second1 and second3 ++ _assert_getuid_report(reports[1].report, 'second1.conf and second3.conf') ++ ++ ++def test_config_dir_conflict_inhibitor(current_actor_context, monkeypatch): ++ monkeypatch.setattr(mpath_conf_check, '_default_config_dir_has_conf_files', lambda: True) ++ config = _build_config('custom_dir.conf', ++ config_dir='/etc/multipath/foo.d') ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 2 ++ _assert_config_dir_report(reports[0].report) ++ _assert_config_dir_conflict_report(reports[1].report) ++ ++ ++def test_config_dir_no_conflict(current_actor_context, monkeypatch): ++ monkeypatch.setattr(mpath_conf_check, '_default_config_dir_has_conf_files', lambda: False) ++ config = _build_config('custom_dir.conf', ++ config_dir='/etc/multipath/foo.d') ++ facts = _build_facts([config]) ++ current_actor_context.feed(facts) ++ current_actor_context.run() ++ reports = list(current_actor_context.consume(Report)) ++ assert reports and len(reports) == 1 ++ _assert_config_dir_report(reports[0].report) +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/actor.py b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/actor.py +new file mode 100644 +index 00000000..f72d2936 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/actor.py +@@ -0,0 +1,29 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import mpathconfupdate ++from leapp.models import MultipathConfFacts9to10, MultipathConfigUpdatesInfo ++from leapp.tags import IPUWorkflowTag, TargetTransactionChecksPhaseTag ++ ++ ++class MultipathUpgradeConfUpdate9to10(Actor): ++ """ ++ Modifies multipath configuration files for the RHEL-10 upgrade. ++ ++ Removes deprecated options (config_dir, bindings_file, wwids_file, ++ prkeys_file) from multipath configuration files. If config_dir is ++ set to a non-default directory, ensures all secondary configs are ++ moved to /etc/multipath/conf.d/. Creates entries to relocate ++ bindings, wwids, and prkeys files to their default RHEL-10 ++ locations if necessary. ++ """ ++ ++ name = 'multipath_upgrade_conf_update_9to10' ++ consumes = (MultipathConfFacts9to10,) ++ produces = (MultipathConfigUpdatesInfo,) ++ tags = (TargetTransactionChecksPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ facts = next(self.consume(MultipathConfFacts9to10), None) ++ if facts is None: ++ self.log.debug('Skipping execution. No MultipathConfFacts9to10 has been produced') ++ return ++ mpathconfupdate.update_configs(facts) +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/libraries/mpathconfupdate.py b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/libraries/mpathconfupdate.py +new file mode 100644 +index 00000000..d5618018 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/libraries/mpathconfupdate.py +@@ -0,0 +1,140 @@ ++import os ++import shutil ++ ++from leapp.libraries.common import mpathfiles, multipathutil ++from leapp.libraries.stdlib import api ++from leapp.models import MultipathConfigUpdatesInfo, UpdatedMultipathConfig ++ ++MODIFICATIONS_STORE_PATH = '/var/lib/leapp/proposed_modifications' ++ ++_DEFAULT_CONFIG_DIR = '/etc/multipath/conf.d' ++_DEFAULT_BINDINGS_FILE = '/etc/multipath/bindings' ++_DEFAULT_WWIDS_FILE = '/etc/multipath/wwids' ++_DEFAULT_PRKEYS_FILE = '/etc/multipath/prkeys' ++ ++_deprecated_options = ('config_dir', 'bindings_file', 'wwids_file', 'prkeys_file') ++ ++ ++def _update_config(config): ++ contents = multipathutil.read_config(config.pathname) ++ if contents is None: ++ return None ++ lines = contents.split('\n') ++ ++ section = None ++ in_subsection = False ++ updated_file = False ++ comment_lines = [] ++ for i, line in enumerate(lines): ++ try: ++ data = multipathutil.LineData(line, section, in_subsection) ++ except ValueError: ++ continue ++ if data.type == data.TYPE_SECTION_END: ++ if in_subsection: ++ in_subsection = False ++ elif section is not None: ++ section = None ++ elif data.type == data.TYPE_SECTION_START: ++ if section is None: ++ section = data.section ++ elif not in_subsection: ++ in_subsection = True ++ elif data.type == data.TYPE_OPTION: ++ if section == 'defaults' and data.option in _deprecated_options: ++ comment_lines.append(i) ++ updated_file = True ++ ++ if not updated_file: ++ return None ++ ++ for i in reversed(comment_lines): ++ lines[i] = '#{} # line commented out by leapp'.format(lines[i]) ++ ++ return '\n'.join(lines) ++ ++ ++def _get_file_locations(facts): ++ bindings_file, wwids_file, prkeys_file = mpathfiles.mpath_file_locations(facts.configs) ++ ++ file_updates = [] ++ files_to_move = [ ++ (bindings_file, _DEFAULT_BINDINGS_FILE), ++ (wwids_file, _DEFAULT_WWIDS_FILE), ++ (prkeys_file, _DEFAULT_PRKEYS_FILE), ++ ] ++ ++ for configured_path, default_path in files_to_move: ++ if configured_path is not None: ++ configured_path = os.path.normpath(configured_path) ++ if configured_path != default_path: ++ file_updates.append((configured_path, default_path)) ++ ++ return file_updates ++ ++ ++def prepare_destination_for_file(file_path): ++ dirname = os.path.dirname(file_path) ++ os.makedirs(dirname, exist_ok=True) ++ ++ ++def prepare_place_for_config_modifications(workspace_path=MODIFICATIONS_STORE_PATH): ++ if os.path.exists(workspace_path): ++ shutil.rmtree(workspace_path) ++ os.mkdir(workspace_path) ++ ++ ++def update_configs(facts): ++ if not facts.configs: ++ return ++ ++ config_updates = [] ++ prepare_place_for_config_modifications() ++ ++ primary = facts.configs[0] ++ non_default_config_dir = ( ++ primary.config_dir is not None ++ and os.path.normpath(primary.config_dir) != _DEFAULT_CONFIG_DIR ++ ) ++ ++ for idx, config in enumerate(facts.configs): ++ is_secondary = idx > 0 ++ ++ if is_secondary: ++ target_path = os.path.join( ++ _DEFAULT_CONFIG_DIR, os.path.basename(config.pathname) ++ ) ++ else: ++ target_path = config.pathname ++ ++ contents = _update_config(config) ++ ++ if contents is not None: ++ rootless_path = config.pathname.lstrip('/') ++ updated_config_location = os.path.join( ++ MODIFICATIONS_STORE_PATH, rootless_path ++ ) ++ api.current_logger().debug( ++ 'Instead of modifying {}, preparing modified config at {}'.format( ++ config.pathname, updated_config_location ++ ) ++ ) ++ prepare_destination_for_file(updated_config_location) ++ multipathutil.write_config(updated_config_location, contents) ++ config_updates.append(UpdatedMultipathConfig( ++ updated_config_location=updated_config_location, ++ target_path=target_path ++ )) ++ elif is_secondary and non_default_config_dir: ++ config_updates.append(UpdatedMultipathConfig( ++ updated_config_location=config.pathname, ++ target_path=target_path ++ )) ++ ++ for source_path, default_path in _get_file_locations(facts): ++ config_updates.append(UpdatedMultipathConfig( ++ updated_config_location=source_path, ++ target_path=default_path ++ )) ++ ++ api.produce(MultipathConfigUpdatesInfo(updates=config_updates)) +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/all_deprecated.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/all_deprecated.conf +new file mode 100644 +index 00000000..7489ed9e +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/all_deprecated.conf +@@ -0,0 +1,7 @@ ++defaults { ++ polling_interval 5 ++# config_dir "/etc/multipath/custom.d" # line commented out by leapp ++# bindings_file "/tmp/bindings" # line commented out by leapp ++# wwids_file "/tmp/wwids" # line commented out by leapp ++# prkeys_file "/tmp/prkeys" # line commented out by leapp ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/has_config_dir.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/has_config_dir.conf +new file mode 100644 +index 00000000..94c54ded +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/has_config_dir.conf +@@ -0,0 +1,4 @@ ++defaults { ++ polling_interval 5 ++# config_dir "/etc/multipath/custom.d" # line commented out by leapp ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/has_files.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/has_files.conf +new file mode 100644 +index 00000000..fb74a86e +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/has_files.conf +@@ -0,0 +1,6 @@ ++defaults { ++ polling_interval 5 ++# bindings_file "/tmp/bindings" # line commented out by leapp ++# wwids_file "/tmp/wwids" # line commented out by leapp ++# prkeys_file "/tmp/prkeys" # line commented out by leapp ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/secondary_with_deprecated.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/secondary_with_deprecated.conf +new file mode 100644 +index 00000000..68bfcfca +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/secondary_with_deprecated.conf +@@ -0,0 +1,9 @@ ++defaults { ++# bindings_file "/tmp/bindings" # line commented out by leapp ++} ++devices { ++ device { ++ vendor "VENDOR" ++ product "PRODUCT" ++ } ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/all_deprecated.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/all_deprecated.conf +new file mode 100644 +index 00000000..15e39d34 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/all_deprecated.conf +@@ -0,0 +1,7 @@ ++defaults { ++ polling_interval 5 ++ config_dir "/etc/multipath/custom.d" ++ bindings_file "/tmp/bindings" ++ wwids_file "/tmp/wwids" ++ prkeys_file "/tmp/prkeys" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/empty.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/empty.conf +new file mode 100644 +index 00000000..8b137891 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/empty.conf +@@ -0,0 +1 @@ ++ +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/has_config_dir.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/has_config_dir.conf +new file mode 100644 +index 00000000..7e8295ee +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/has_config_dir.conf +@@ -0,0 +1,4 @@ ++defaults { ++ polling_interval 5 ++ config_dir "/etc/multipath/custom.d" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/has_files.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/has_files.conf +new file mode 100644 +index 00000000..9404312b +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/has_files.conf +@@ -0,0 +1,6 @@ ++defaults { ++ polling_interval 5 ++ bindings_file "/tmp/bindings" ++ wwids_file "/tmp/wwids" ++ prkeys_file "/tmp/prkeys" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/no_defaults.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/no_defaults.conf +new file mode 100644 +index 00000000..dfc28f43 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/no_defaults.conf +@@ -0,0 +1,3 @@ ++blacklist { ++ devnode "^sd[a-z]" ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/no_deprecated.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/no_deprecated.conf +new file mode 100644 +index 00000000..8c0e1510 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/no_deprecated.conf +@@ -0,0 +1,3 @@ ++defaults { ++ polling_interval 5 ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/secondary_simple.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/secondary_simple.conf +new file mode 100644 +index 00000000..092e7cdc +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/secondary_simple.conf +@@ -0,0 +1,6 @@ ++devices { ++ device { ++ vendor "VENDOR" ++ product "PRODUCT" ++ } ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/secondary_with_deprecated.conf b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/secondary_with_deprecated.conf +new file mode 100644 +index 00000000..534c6f07 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/secondary_with_deprecated.conf +@@ -0,0 +1,9 @@ ++defaults { ++ bindings_file "/tmp/bindings" ++} ++devices { ++ device { ++ vendor "VENDOR" ++ product "PRODUCT" ++ } ++} +diff --git a/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/test_mpath_conf_update_9to10.py b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/test_mpath_conf_update_9to10.py +new file mode 100644 +index 00000000..ad6668b9 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/test_mpath_conf_update_9to10.py +@@ -0,0 +1,256 @@ ++import os ++ ++import pytest ++ ++from leapp.libraries.actor import mpathconfupdate ++from leapp.libraries.common import multipathutil ++from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import MultipathConfFacts9to10, MultipathConfig9to10 ++ ++BEFORE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'files/before') ++AFTER_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'files/after') ++ ++ ++def build_config(pathname, config_dir=None, bindings_file=None, ++ wwids_file=None, prkeys_file=None): ++ return MultipathConfig9to10( ++ pathname=pathname, ++ config_dir=config_dir, ++ bindings_file=bindings_file, ++ wwids_file=wwids_file, ++ prkeys_file=prkeys_file, ++ ) ++ ++ ++def build_facts(confs): ++ return MultipathConfFacts9to10(configs=confs) ++ ++ ++def mock_read_config(path): ++ return multipathutil.read_config_orig(os.path.join(BEFORE_DIR, path)) ++ ++ ++# Configs with no changes needed ++no_deprecated_conf = build_config('no_deprecated.conf') ++empty_conf = build_config('empty.conf') ++no_defaults_conf = build_config('no_defaults.conf') ++ ++# Configs with deprecated options ++has_config_dir_conf = build_config( ++ 'has_config_dir.conf', config_dir='/etc/multipath/custom.d') ++has_files_conf = build_config( ++ 'has_files.conf', bindings_file='/tmp/bindings', ++ wwids_file='/tmp/wwids', prkeys_file='/tmp/prkeys') ++all_deprecated_conf = build_config( ++ 'all_deprecated.conf', config_dir='/etc/multipath/custom.d', ++ bindings_file='/tmp/bindings', wwids_file='/tmp/wwids', ++ prkeys_file='/tmp/prkeys') ++ ++# Secondary configs ++secondary_simple_conf = build_config('secondary_simple.conf') ++secondary_with_deprecated_conf = build_config( ++ 'secondary_with_deprecated.conf', bindings_file='/tmp/bindings') ++ ++ ++@pytest.mark.parametrize( ++ 'config_facts', ++ [ ++ build_facts([no_deprecated_conf]), ++ build_facts([empty_conf]), ++ build_facts([no_defaults_conf]), ++ build_facts([has_config_dir_conf]), ++ build_facts([has_files_conf]), ++ build_facts([all_deprecated_conf]), ++ build_facts([has_config_dir_conf, secondary_simple_conf]), ++ build_facts([has_config_dir_conf, secondary_with_deprecated_conf]), ++ build_facts([no_deprecated_conf, secondary_simple_conf]), ++ build_facts([no_deprecated_conf, secondary_with_deprecated_conf]), ++ ] ++) ++def test_all_facts(monkeypatch, config_facts): ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ config_writes = {} ++ ++ def write_config_mock(location, contents): ++ config_writes[location] = contents ++ ++ monkeypatch.setattr(multipathutil, 'read_config_orig', multipathutil.read_config, raising=False) ++ monkeypatch.setattr(multipathutil, 'read_config', mock_read_config) ++ monkeypatch.setattr(multipathutil, 'write_config', write_config_mock) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_destination_for_file', lambda file_path: None) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_place_for_config_modifications', lambda: None) ++ ++ mpathconfupdate.update_configs(config_facts) ++ ++ config_updates = {} ++ for config_updates_msg in produce_mock.model_instances: ++ for update in config_updates_msg.updates: ++ config_updates[update.target_path] = update.updated_config_location ++ ++ primary = config_facts.configs[0] ++ non_default_config_dir = ( ++ primary.config_dir is not None ++ and os.path.normpath(primary.config_dir) != '/etc/multipath/conf.d' ++ ) ++ ++ for idx, config in enumerate(config_facts.configs): ++ is_secondary = idx > 0 ++ ++ if is_secondary: ++ target_path = os.path.join( ++ '/etc/multipath/conf.d', os.path.basename(config.pathname) ++ ) ++ else: ++ target_path = config.pathname ++ ++ expected_conf_location = os.path.join(AFTER_DIR, config.pathname) ++ ++ if target_path not in config_updates: ++ # No update for this config - verify no expected after file exists ++ # and it's not a secondary that should have been relocated ++ assert not os.path.exists(expected_conf_location) ++ assert not (is_secondary and non_default_config_dir) ++ continue ++ ++ updated_config_location = config_updates[target_path] ++ ++ if os.path.exists(expected_conf_location): ++ # Config was modified - check contents ++ assert updated_config_location in config_writes ++ actual_contents = config_writes[updated_config_location] ++ ++ updated_config_expected_location = os.path.join( ++ mpathconfupdate.MODIFICATIONS_STORE_PATH, ++ config.pathname.lstrip('/') ++ ) ++ assert updated_config_location == updated_config_expected_location ++ ++ expected_contents = multipathutil.read_config_orig(expected_conf_location) ++ assert actual_contents == expected_contents ++ else: ++ # Unmodified secondary relocated - source is original path ++ assert updated_config_location == config.pathname ++ ++ ++def test_file_relocation(monkeypatch): ++ """Check that non-default file locations produce UpdatedMultipathConfig entries.""" ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ monkeypatch.setattr(multipathutil, 'read_config_orig', multipathutil.read_config, raising=False) ++ monkeypatch.setattr(multipathutil, 'read_config', mock_read_config) ++ monkeypatch.setattr(multipathutil, 'write_config', lambda loc, contents: None) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_destination_for_file', lambda file_path: None) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_place_for_config_modifications', lambda: None) ++ ++ facts = build_facts([has_files_conf]) ++ mpathconfupdate.update_configs(facts) ++ ++ file_updates = {} ++ for config_updates_msg in produce_mock.model_instances: ++ for update in config_updates_msg.updates: ++ file_updates[update.target_path] = update.updated_config_location ++ ++ assert file_updates['/etc/multipath/bindings'] == '/tmp/bindings' ++ assert file_updates['/etc/multipath/wwids'] == '/tmp/wwids' ++ assert file_updates['/etc/multipath/prkeys'] == '/tmp/prkeys' ++ ++ ++def test_default_file_locations_no_relocation(monkeypatch): ++ """Check that default file locations don't produce relocation entries.""" ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ conf = build_config( ++ 'has_files.conf', ++ bindings_file='/etc/multipath/bindings', ++ wwids_file='/etc/multipath/wwids', ++ prkeys_file='/etc/multipath/prkeys', ++ ) ++ ++ monkeypatch.setattr(multipathutil, 'read_config_orig', multipathutil.read_config, raising=False) ++ monkeypatch.setattr(multipathutil, 'read_config', mock_read_config) ++ monkeypatch.setattr(multipathutil, 'write_config', lambda loc, contents: None) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_destination_for_file', lambda file_path: None) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_place_for_config_modifications', lambda: None) ++ ++ facts = build_facts([conf]) ++ mpathconfupdate.update_configs(facts) ++ ++ file_targets = set() ++ for config_updates_msg in produce_mock.model_instances: ++ for update in config_updates_msg.updates: ++ file_targets.add(update.target_path) ++ ++ # Config itself is modified (has deprecated options in before file), but no file relocations ++ assert '/etc/multipath/bindings' not in file_targets ++ assert '/etc/multipath/wwids' not in file_targets ++ assert '/etc/multipath/prkeys' not in file_targets ++ ++ ++def test_last_value_wins_for_files(monkeypatch): ++ """Check that the last non-None value wins for file locations.""" ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ primary = build_config( ++ 'no_deprecated.conf', bindings_file='/first/bindings') ++ secondary = build_config( ++ 'secondary_simple.conf', bindings_file='/second/bindings') ++ ++ monkeypatch.setattr(multipathutil, 'read_config_orig', multipathutil.read_config, raising=False) ++ monkeypatch.setattr(multipathutil, 'read_config', mock_read_config) ++ monkeypatch.setattr(multipathutil, 'write_config', lambda loc, contents: None) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_destination_for_file', lambda file_path: None) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_place_for_config_modifications', lambda: None) ++ ++ facts = build_facts([primary, secondary]) ++ mpathconfupdate.update_configs(facts) ++ ++ file_updates = {} ++ for config_updates_msg in produce_mock.model_instances: ++ for update in config_updates_msg.updates: ++ file_updates[update.target_path] = update.updated_config_location ++ ++ # Last value (/second/bindings) should win ++ assert file_updates['/etc/multipath/bindings'] == '/second/bindings' ++ ++ ++def test_proposed_config_updates_store(monkeypatch): ++ """Check whether configs are being stored in the expected path.""" ++ config = MultipathConfig9to10( ++ pathname='/etc/multipath.conf.d/xy.conf', ++ config_dir='', ++ ) ++ ++ produce_mock = produce_mocked() ++ monkeypatch.setattr(api, 'produce', produce_mock) ++ ++ monkeypatch.setattr(multipathutil, 'write_config', lambda loc, contents: None) ++ monkeypatch.setattr(mpathconfupdate, '_update_config', lambda *args: 'new config content') ++ monkeypatch.setattr(mpathconfupdate, 'prepare_destination_for_file', lambda file_path: None) ++ monkeypatch.setattr(mpathconfupdate, 'prepare_place_for_config_modifications', lambda: None) ++ ++ mpathconfupdate.update_configs(MultipathConfFacts9to10(configs=[config])) ++ ++ expected_updated_config_path = os.path.join( ++ mpathconfupdate.MODIFICATIONS_STORE_PATH, ++ 'etc/multipath.conf.d/xy.conf' ++ ) ++ found = False ++ for config_updates_msg in produce_mock.model_instances: ++ for update in config_updates_msg.updates: ++ if update.updated_config_location == expected_updated_config_path: ++ found = True ++ assert found +diff --git a/repos/system_upgrade/el9toel10/actors/pulseaudiocheck/scanpulseaudio/libraries/scanpulseaudio.py b/repos/system_upgrade/el9toel10/actors/pulseaudiocheck/scanpulseaudio/libraries/scanpulseaudio.py +index e5c2be53..28cde062 100644 +--- a/repos/system_upgrade/el9toel10/actors/pulseaudiocheck/scanpulseaudio/libraries/scanpulseaudio.py ++++ b/repos/system_upgrade/el9toel10/actors/pulseaudiocheck/scanpulseaudio/libraries/scanpulseaudio.py +@@ -2,6 +2,7 @@ import os + import pwd + + from leapp.libraries.common.rpms import check_file_modification ++from leapp.libraries.stdlib import api + from leapp.models import PulseAudioConfiguration + + # System-wide PulseAudio configuration directory +@@ -54,6 +55,9 @@ def _get_user_config_dirs(): + """ + found = [] + for user in pwd.getpwall(): ++ if not user.pw_dir: ++ api.current_logger().debug('User "{}" has no valid home entry, skipping.'.format(user.pw_name)) ++ continue + pulse_dir = os.path.join(user.pw_dir, _USER_CONFIG_SUBDIR) + if os.path.isdir(pulse_dir) and os.listdir(pulse_dir): + found.append(pulse_dir) +diff --git a/repos/system_upgrade/el9toel10/actors/pulseaudiocheck/scanpulseaudio/tests/test_scanpulseaudio.py b/repos/system_upgrade/el9toel10/actors/pulseaudiocheck/scanpulseaudio/tests/test_scanpulseaudio.py +index f499aafe..e6b63a4c 100644 +--- a/repos/system_upgrade/el9toel10/actors/pulseaudiocheck/scanpulseaudio/tests/test_scanpulseaudio.py ++++ b/repos/system_upgrade/el9toel10/actors/pulseaudiocheck/scanpulseaudio/tests/test_scanpulseaudio.py +@@ -1,5 +1,7 @@ + import os + ++import pytest ++ + from leapp.libraries.actor import scanpulseaudio + from leapp.libraries.actor.scanpulseaudio import _get_dropin_dirs_with_content, _get_user_config_dirs, scan_pulseaudio + +@@ -36,16 +38,24 @@ class TestGetUserConfigDirs: + monkeypatch.setattr(os.path, 'isdir', lambda _: False) + assert _get_user_config_dirs() == [] + +- def test_user_config_found(self, monkeypatch): ++ @pytest.mark.parametrize( ++ 'home_dir, expect', ++ [ ++ ('/home/testuser', ['/home/testuser/.config/pulse']), ++ ('', []), ++ ], ++ ) ++ def test_user_config_found(self, monkeypatch, home_dir, expect): + monkeypatch.setattr(os.path, 'isdir', lambda path: path == '/home/testuser/.config/pulse') + monkeypatch.setattr(os, 'listdir', lambda _: ['default.pa']) + + class FakeUser: +- pw_dir = '/home/testuser' ++ pw_name = 'testuser' ++ pw_dir = home_dir + +- monkeypatch.setattr(scanpulseaudio.pwd, 'getpwall', lambda: [FakeUser()]) ++ monkeypatch.setattr(scanpulseaudio.pwd, 'getpwall', lambda: [FakeUser]) + result = _get_user_config_dirs() +- assert result == ['/home/testuser/.config/pulse'] ++ assert result == expect + + + class TestScanPulseaudio: +diff --git a/repos/system_upgrade/el9toel10/libraries/mpathfiles.py b/repos/system_upgrade/el9toel10/libraries/mpathfiles.py +new file mode 100644 +index 00000000..ada276a3 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/libraries/mpathfiles.py +@@ -0,0 +1,22 @@ ++def mpath_file_locations(configs): ++ """ ++ Returns the configured location of the bindings_file, wwids_file, and ++ prkeys_file multipath files, handling cases where the location is set ++ multiple times (the last set value is used) or not set at all (None is ++ used). ++ ++ :param configs: The ordered list of all multipath config files data ++ :type configs: List[MultipathConfig9to10] ++ :return: The locations of bindings_file, wwids_file, and prkeys_file ++ :rtype: Tuple[Optional[str], Optional[str], Optional[str]] ++ ++ """ ++ ++ bindings_file = None ++ wwids_file = None ++ prkeys_file = None ++ for conf in configs: ++ bindings_file = conf.bindings_file or bindings_file ++ wwids_file = conf.wwids_file or wwids_file ++ prkeys_file = conf.prkeys_file or prkeys_file ++ return (bindings_file, wwids_file, prkeys_file) +diff --git a/repos/system_upgrade/el9toel10/models/multipath9to10.py b/repos/system_upgrade/el9toel10/models/multipath9to10.py +new file mode 100644 +index 00000000..19ef24cf +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/models/multipath9to10.py +@@ -0,0 +1,59 @@ ++from leapp.models import fields, Model ++from leapp.topics import SystemInfoTopic ++ ++ ++class MultipathConfig9to10(Model): ++ """ ++ Model information about multipath configuration file important for the 9>10 upgrade path. ++ """ ++ topic = SystemInfoTopic ++ ++ pathname = fields.String() ++ """Config file path name""" ++ ++ config_dir = fields.Nullable(fields.String()) ++ """ ++ Value of config_dir in the defaults section. None if not set. ++ Used both to track config lines that need commenting out and ++ to determine the actual directory location. ++ """ ++ ++ bindings_file = fields.Nullable(fields.String()) ++ """ ++ Value of bindings_file in the defaults section. None if not set. ++ Used both to track config lines that need commenting out and ++ to determine the actual file location for copying. ++ """ ++ ++ wwids_file = fields.Nullable(fields.String()) ++ """ ++ Value of wwids_file in the defaults section. None if not set. ++ Used both to track config lines that need commenting out and ++ to determine the actual file location for copying. ++ """ ++ ++ prkeys_file = fields.Nullable(fields.String()) ++ """ ++ Value of prkeys_file in the defaults section. None if not set. ++ Used both to track config lines that need commenting out and ++ to determine the actual file location for copying. ++ """ ++ ++ has_socket_activation = fields.Boolean(default=True) ++ """True if multipathd socket activation is enabled""" ++ ++ has_dm_nvme_multipathing = fields.Boolean(default=False) ++ """True if DM NVMe multipathing is enabled""" ++ ++ has_getuid = fields.Boolean(default=False) ++ """True if the getuid option is set anywhere in the multipath config""" ++ ++ ++class MultipathConfFacts9to10(Model): ++ """ ++ Model representing information from multipath configuration files important for the 9>10 upgrade path. ++ """ ++ topic = SystemInfoTopic ++ ++ configs = fields.List(fields.Model(MultipathConfig9to10), default=[]) ++ """List of multipath configuration files""" diff --git a/SPECS/leapp-repository.spec b/SPECS/leapp-repository.spec index 853aa0b..7ce102b 100644 --- a/SPECS/leapp-repository.spec +++ b/SPECS/leapp-repository.spec @@ -53,7 +53,7 @@ py2_byte_compile "%1" "%2"} Epoch: 1 Name: leapp-repository Version: 0.24.0 -Release: 2%{?dist}.elevate.1 +Release: 2%{?dist}.elevate.2 Summary: Repositories for leapp License: ASL 2.0 @@ -438,6 +438,9 @@ fi %changelog +* Fri May 15 2026 Yuriy Kohut - 0.24.0-2.elevate.2 +- ELevate vendors support for upstream 0.24.0-2 version (03e67c0d42914b40cac1bff390bd27b9c7b91d04) + * Thu May 14 2026 Yuriy Kohut - 0.24.0-2.elevate.1 - Rebase the ELevate Vendors patch based on the v0.24.0-2