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