From b31dbfa161c67d9093e3df177d4ea45cfc34f700 Mon Sep 17 00:00:00 2001 From: Matej Matuska Date: Thu, 25 Jun 2026 10:52:04 +0200 Subject: [PATCH] IPU 8.10 -> 9.9: CTC2 candidate 1 - Requires leapp-framework 6.6+ - Initial implementation of upgrades on systems with configured Software RAID - Handle multipath device identification in upgrade environment - Fix upgrade incorrectly resuming during SELinux relabeling - Fix upgrades on systems with multiple LUKS devices - Fix upgrades for systems with /boot/efi on a Software RAID - Fix mount failures for FSTAB entries with the `nofail` option specified - Fix AVC errors triggered by registration to insight-client during the upgrade - Install the correct kernel for the source system kernel page size on ARM systems - Inhibit the upgrade when network devices configured with ifcfg files could depend on NM - Introduce rhui.obsolete_gpg_keys configuration option for the removal of obsoleted RPM GPG keys of RHUI Cloud providers - LiveMode: Fix upgrade getting stuck when the `.leapp_upgrade_failed` file exists - LiveMode: Fix system version determination in the upgrade environment on CentOS Stream - Fix target system initramfs containing outdated configuration files in some circumstances - Ensure SELinux is set to permissive mode during the upgrade even when enforcing=1 is set on the kernel cmdline - Fix source and target distribution names in /etc/migration-results - Fix upgrades with the RealTime kernel on CentOS Stream - Detect misconfigured kernel & systemd API mountpoints in FSTAB - Detect misconfigured /var/run on the source system - Unify behaviour of upgrades on systems with enabled CRB repositories and explicitly required CRB repositories by user during the upgrade - Drop the inconsistent report about the use of CRB (unsupported by Red Hat) repositories - Fix upgrade crashing when dnf repofiles contain URL encoded characters - Resolves: RHEL-3289, RHEL-17842, RHEL-36249, RHEL-56040, RHEL-56176, RHEL-60060, RHEL-76846, RHEL-104384, RHEL-145136, RHEL-148631, RHEL-162192, RHEL-185508 --- ...sable-ConfigParser-interpolation-whe.patch | 72 + ...ncy-and-simplify-config-parsing-to-P.patch | 157 ++ ...gnore-failures-when-callling-mount-a.patch | 102 + ...ator-do-not-make-nofail-mounts-requi.patch | 142 + ...vice-use-multi-user.target-instead-o.patch | 133 + ...do-upgrade.sh-to-reflect-upstream-ch.patch | 334 +++ ...-do-upgrade.sh-output-when-.noreboot.patch | 43 + ...ndle-the-overrides-protocol-subsecti.patch | 47 + ...onfig_reader-Actor-and-8to9-Models-t.patch | 324 +++ ...ltipathConfig9to10-and-MultipathConf.patch | 83 + ...ltipathConfRead9to10-config_reader-a.patch | 1897 +++++++++++++ ...ltipathConfCheck9to10-mpath_conf_che.patch | 522 ++++ ...ltipathUpgradeConfUpdate9to10-mpath_.patch | 659 +++++ ...ndings-wwids-prkeys-file-copying-to-.patch | 567 ++++ ...athfiles-library-and-use-it-in-el9to.patch | 227 ++ ...-upgrade_cmdline-add-to_remove-field.patch | 96 + 0061-checkluks-emit-messages-only-once.patch | 81 + ...-rd.luks.uuid-from-the-upgrade-entry.patch | 256 ++ ...t-actor-context-when-switching-repos.patch | 114 + ...stutils-logger_mocked-accepts-kwargs.patch | 52 + ...pp_tmpdir-fixture-for-standardized-t.patch | 52 + ...e-DNF-libraries-into-dnflibs-package.patch | 1744 ++++++++++++ ...e-imports-to-use-new-dnflibs-modules.patch | 256 ++ ...dnflibs-introduce-DNFError-exception.patch | 35 + ...n-Drop-dead-code-_handle_transaction.patch | 48 + ...ugin-Drop-deprecated-DNF_PLUGIN_PATH.patch | 111 + ...n-Introduce-new-DNF-exceptions-and-t.patch | 903 ++++++ ...e-Move-init-of-dnf.Base-to-dnflibs-a.patch | 637 +++++ ...g-Raise-proper-exceptions-and-add-te.patch | 363 +++ ...ent-DNF-related-exception-propagatio.patch | 57 + ...fix-for-users-with-None-home-directo.patch | 87 + ...amfs-always-regenerate-initramfs-153.patch | 68 + ...-harness-with-AGENTS.md-and-task-ski.patch | 393 +++ ...deprecations-warn-about-ifcfg-device.patch | 230 ++ ...-1-from-upgrade-and-target-kernel-bo.patch | 439 +++ ..._path.py-crash-on-invalid-repo-paths.patch | 71 + ...use-distro-aware-OS-names-for-source.patch | 119 + ...vice-use-install_exec_t-SELinux-cont.patch | 58 + ...config-for-setting-rpm-gpg-keys-to-r.patch | 380 +++ ...r-invalid-fstab-entries-on-pseudo-fi.patch | 274 ++ ...ve-deprecated-is_rhel_realtime-funct.patch | 81 + ...el-Fix-rt-kernel-detection-on-centos.patch | 142 + ...d-YAML-frontmatter-to-skills-for-Cla.patch | 78 + ...r-incorrect-var-run-symlink-configur.patch | 249 ++ ...nk-Log-warning-when-var-run-is-missi.patch | 34 + ...ps-actor-to-scan-installed-DNF-comps.patch | 419 +++ ...-Fix-the-upgrade-of-Graphical-Server.patch | 165 ++ 0092-Bump-leapp-framework.patch | 26 + ...m-include-configuration-in-initramfs.patch | 491 ++++ ...tramfs_gen-write-rd.md.uuids-into-cm.patch | 235 ++ ...Update-actions-checkout-action-to-v7.patch | 53 + ...lues-for-rhui-jobs-follow-up-on-9to1.patch | 115 + ...-and-refactor-of-repo-related-actors.patch | 2493 +++++++++++++++++ ...tresolver-Init-message-consumption-c.patch | 751 +++++ ...tresolver-RepositoriesBlacklisted-us.patch | 309 ++ ...tresolver-repositoriesblacklist-rede.patch | 816 ++++++ ...mapping-scan_repositories-load_repos.patch | 288 ++ ...tresolver-operate-with-RepoMapDataHa.patch | 615 ++++ ...tresolver-setuptargetrepos-minor-upd.patch | 262 ++ ...tresolver-pes_events_scanner-refacto.patch | 849 ++++++ ...argetcontentresolver-Make-TODO-notes.patch | 52 + ...pport-for-kernels-with-64k-page-size.patch | 1560 +++++++++++ ...ckage-identification-to-use-lib-modu.patch | 363 +++ ...l9-checkkernelarm-Install-64k-kernel.patch | 187 ++ leapp-repository.spec | 158 +- 65 files changed, 23022 insertions(+), 2 deletions(-) create mode 100644 0045-repofileutils-disable-ConfigParser-interpolation-whe.patch create mode 100644 0046-Drop-six-dependency-and-simplify-config-parsing-to-P.patch create mode 100644 0047-mounting-ignore-failures-when-callling-mount-a.patch create mode 100644 0048-mount_unit_generator-do-not-make-nofail-mounts-requi.patch create mode 100644 0049-leapp_resume.service-use-multi-user.target-instead-o.patch create mode 100644 0050-LiveMode-update-do-upgrade.sh-to-reflect-upstream-ch.patch create mode 100644 0051-LiveMode-improve-do-upgrade.sh-output-when-.noreboot.patch create mode 100644 0052-multipathutil-handle-the-overrides-protocol-subsecti.patch create mode 100644 0053-multipath-move-config_reader-Actor-and-8to9-Models-t.patch create mode 100644 0054-multipath-Add-MultipathConfig9to10-and-MultipathConf.patch create mode 100644 0055-multipath-Add-MultipathConfRead9to10-config_reader-a.patch create mode 100644 0056-multipath-Add-MultipathConfCheck9to10-mpath_conf_che.patch create mode 100644 0057-multipath-Add-MultipathUpgradeConfUpdate9to10-mpath_.patch create mode 100644 0058-multipath-Add-bindings-wwids-prkeys-file-copying-to-.patch create mode 100644 0059-multipath-Add-mpathfiles-library-and-use-it-in-el9to.patch create mode 100644 0060-model-upgrade_cmdline-add-to_remove-field.patch create mode 100644 0061-checkluks-emit-messages-only-once.patch create mode 100644 0062-checkluks-remove-rd.luks.uuid-from-the-upgrade-entry.patch create mode 100644 0063-conftest-exit-actor-context-when-switching-repos.patch create mode 100644 0064-testutils-logger_mocked-accepts-kwargs.patch create mode 100644 0065-conftest-Add-leapp_tmpdir-fixture-for-standardized-t.patch create mode 100644 0066-Reorganize-DNF-libraries-into-dnflibs-package.patch create mode 100644 0067-Update-imports-to-use-new-dnflibs-modules.patch create mode 100644 0068-dnflibs-introduce-DNFError-exception.patch create mode 100644 0069-dnflibs.dnfplugin-Drop-dead-code-_handle_transaction.patch create mode 100644 0070-dnflibs.dnfplugin-Drop-deprecated-DNF_PLUGIN_PATH.patch create mode 100644 0071-dnflibs.dnfplugin-Introduce-new-DNF-exceptions-and-t.patch create mode 100644 0072-dnflibs-dnfmodule-Move-init-of-dnf.Base-to-dnflibs-a.patch create mode 100644 0073-dnflibs.dnfconfig-Raise-proper-exceptions-and-add-te.patch create mode 100644 0074-DOCSTRINGS-Document-DNF-related-exception-propagatio.patch create mode 100644 0075-pulseaudiocheck-fix-for-users-with-None-home-directo.patch create mode 100644 0076-fix-target_initramfs-always-regenerate-initramfs-153.patch create mode 100644 0077-Add-AI-assistant-harness-with-AGENTS.md-and-task-ski.patch create mode 100644 0078-el8toel9-networkdeprecations-warn-about-ifcfg-device.patch create mode 100644 0079-Remove-enforcing-1-from-upgrade-and-target-kernel-bo.patch create mode 100644 0080-Fix-utils-actor_path.py-crash-on-invalid-repo-paths.patch create mode 100644 0081-fix-breadcrumbs-use-distro-aware-OS-names-for-source.patch create mode 100644 0082-leapp_resume.service-use-install_exec_t-SELinux-cont.patch create mode 100644 0083-config-rhui-Add-config-for-setting-rpm-gpg-keys-to-r.patch create mode 100644 0084-Add-inhibitor-for-invalid-fstab-entries-on-pseudo-fi.patch create mode 100644 0085-lib-version-Remove-deprecated-is_rhel_realtime-funct.patch create mode 100644 0086-lib-kernel-Fix-rt-kernel-detection-on-centos.patch create mode 100644 0087-Add-CLAUDE.md-and-YAML-frontmatter-to-skills-for-Cla.patch create mode 100644 0088-Add-inhibitor-for-incorrect-var-run-symlink-configur.patch create mode 100644 0089-checkvarrunsymlink-Log-warning-when-var-run-is-missi.patch create mode 100644 0090-Add-scandnfcomps-actor-to-scan-installed-DNF-comps.patch create mode 100644 0091-IPU-9-10-Fix-the-upgrade-of-Graphical-Server.patch create mode 100644 0092-Bump-leapp-framework.patch create mode 100644 0093-raid-mdadm-include-configuration-in-initramfs.patch create mode 100644 0094-feat-upgrade_initramfs_gen-write-rd.md.uuids-into-cm.patch create mode 100644 0095-Update-actions-checkout-action-to-v7.patch create mode 100644 0096-Update-distro-values-for-rhui-jobs-follow-up-on-9to1.patch create mode 100644 0097-1-9-Merge-and-refactor-of-repo-related-actors.patch create mode 100644 0098-2-9-targetcontentresolver-Init-message-consumption-c.patch create mode 100644 0099-3-9-targetcontentresolver-RepositoriesBlacklisted-us.patch create mode 100644 0100-4-9-targetcontentresolver-repositoriesblacklist-rede.patch create mode 100644 0101-5-9-repositoriesmapping-scan_repositories-load_repos.patch create mode 100644 0102-6-9-targetcontentresolver-operate-with-RepoMapDataHa.patch create mode 100644 0103-7-9-targetcontentresolver-setuptargetrepos-minor-upd.patch create mode 100644 0104-8-9-targetcontentresolver-pes_events_scanner-refacto.patch create mode 100644 0105-9-9-targetcontentresolver-Make-TODO-notes.patch create mode 100644 0106-fix-support-for-kernels-with-64k-page-size.patch create mode 100644 0107-rework-kernel-package-identification-to-use-lib-modu.patch create mode 100644 0108-el8toel9-checkkernelarm-Install-64k-kernel.patch diff --git a/0045-repofileutils-disable-ConfigParser-interpolation-whe.patch b/0045-repofileutils-disable-ConfigParser-interpolation-whe.patch new file mode 100644 index 0000000..3035503 --- /dev/null +++ b/0045-repofileutils-disable-ConfigParser-interpolation-whe.patch @@ -0,0 +1,72 @@ +From 97bdaa4d90bdbb951de9d920036d93c08579bf2e Mon Sep 17 00:00:00 2001 +From: Pradeep Jagtap +Date: Fri, 17 Apr 2026 16:56:28 +0530 +Subject: [PATCH 045/108] repofileutils: disable ConfigParser interpolation + when parsing .repo files + +This occurs when parsing repository definitions containing percent-encoded +values (e.g. '%252B'in baseurls). Python's ConfigParser enables +interpolation by default and treats '%' as a special token, which causes +valid URLs to fail during parsing. + +Disable interpolation by using parse_config(..., no_interpolation=True) +so that repository values are handled literally, consistent with DNF/YUM +behavior. + +Jira-ref: RHEL-162328 +--- + .../common/libraries/repofileutils.py | 2 +- + repos/system_upgrade/common/libraries/utils.py | 14 ++++++++++++-- + 2 files changed, 13 insertions(+), 3 deletions(-) + +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/utils.py b/repos/system_upgrade/common/libraries/utils.py +index b7aa9c74..bd5337c5 100644 +--- a/repos/system_upgrade/common/libraries/utils.py ++++ b/repos/system_upgrade/common/libraries/utils.py +@@ -10,7 +10,7 @@ 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 + +@@ -18,10 +18,20 @@ def parse_config(cfg=None, strict=True): + the old ones (Py2) obsoletes, these function was created to use the + ConfigParser on Py2 and Py3 + ++ When no_interpolation is True, values are read literally. Use this for ++ inputs that may contain percent-encoded URLs (e.g. yum .repo baseurl), ++ since default ConfigParser treats ``%`` as interpolation syntax. ++ + :type cfg: str + :type strict: bool ++ :type no_interpolation: bool + """ +- if six.PY3: ++ if no_interpolation: ++ if six.PY3: ++ parser = six.moves.configparser.RawConfigParser(strict=strict) # pylint: disable=unexpected-keyword-arg ++ else: ++ parser = six.moves.configparser.RawConfigParser() ++ elif six.PY3: + parser = six.moves.configparser.ConfigParser(strict=strict) # pylint: disable=unexpected-keyword-arg + else: + parser = six.moves.configparser.ConfigParser() +-- +2.54.0 + diff --git a/0046-Drop-six-dependency-and-simplify-config-parsing-to-P.patch b/0046-Drop-six-dependency-and-simplify-config-parsing-to-P.patch new file mode 100644 index 0000000..35958e9 --- /dev/null +++ b/0046-Drop-six-dependency-and-simplify-config-parsing-to-P.patch @@ -0,0 +1,157 @@ +From 4ccbb21ba2fec74400ad846e41248a0606fd256e Mon Sep 17 00:00:00 2001 +From: Pradeep Jagtap +Date: Mon, 20 Apr 2026 14:15:50 +0530 +Subject: [PATCH 046/108] Drop six dependency and simplify config parsing to + Python 3 native configparser + +- Replaced six.moves.configparser with standard configparser +- Removed Python 2 compatibility code paths +- Simplified string/file handling using read_string and read_file +- Updated parse_config implementation for Python 3 only +--- + .../libraries/tests/test_repofileutils.py | 20 +++++++++ + .../tests/test_utils_parse_config.py | 30 +++++++++++++ + .../system_upgrade/common/libraries/utils.py | 42 +++++++------------ + 3 files changed, 65 insertions(+), 27 deletions(-) + create mode 100644 repos/system_upgrade/common/libraries/tests/test_utils_parse_config.py + +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/utils.py b/repos/system_upgrade/common/libraries/utils.py +index bd5337c5..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 +@@ -12,44 +11,33 @@ from leapp.utils.deprecation import deprecated + + 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. + +- 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 ``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). + +- When no_interpolation is True, values are read literally. Use this for +- inputs that may contain percent-encoded URLs (e.g. yum .repo baseurl), +- since default ConfigParser treats ``%`` as interpolation syntax. ++ 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 no_interpolation: +- if six.PY3: +- parser = six.moves.configparser.RawConfigParser(strict=strict) # pylint: disable=unexpected-keyword-arg +- else: +- parser = six.moves.configparser.RawConfigParser() +- elif six.PY3: +- parser = six.moves.configparser.ConfigParser(strict=strict) # pylint: disable=unexpected-keyword-arg ++ 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 + + +-- +2.54.0 + diff --git a/0047-mounting-ignore-failures-when-callling-mount-a.patch b/0047-mounting-ignore-failures-when-callling-mount-a.patch new file mode 100644 index 0000000..2ff0382 --- /dev/null +++ b/0047-mounting-ignore-failures-when-callling-mount-a.patch @@ -0,0 +1,102 @@ +From 966e7854ec63a977656743b53b1b2811f255a006 Mon Sep 17 00:00:00 2001 +From: Michal Hecko +Date: Thu, 16 Apr 2026 17:57:17 +0200 +Subject: [PATCH 047/108] mounting: ignore failures when callling mount -a + +The `mount -a` calls are implemented just as a safeguard. However, the +semantics of `nofail` as seen by `mount` are different as the ones seen +by `systemd` in that they can result in a non-zero exit code e.g. if the +block device does not contain a valid filesystem. In contrast, systemd +threads `nofail` so that the unit does not fail (even if the filesystem +is broken). This patch adds error handling that ignores non-zero exit +codes. + +Jira-ref: RHEL-56176 +--- + .../files/dracut/85sys-upgrade-redhat/do-upgrade.sh | 8 +++++--- + .../libraries/removeupgradebootentry.py | 13 ++++++++++--- + .../libraries/removeupgradeefientry.py | 11 ++++++++++- + 3 files changed, 25 insertions(+), 7 deletions(-) + +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..41eb1625 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 +@@ -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 +@@ -272,7 +273,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 + +@@ -328,7 +330,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" +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/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): +-- +2.54.0 + diff --git a/0048-mount_unit_generator-do-not-make-nofail-mounts-requi.patch b/0048-mount_unit_generator-do-not-make-nofail-mounts-requi.patch new file mode 100644 index 0000000..1342ba2 --- /dev/null +++ b/0048-mount_unit_generator-do-not-make-nofail-mounts-requi.patch @@ -0,0 +1,142 @@ +From 417ef75d311af2506c4ba568c43698d949479169 Mon Sep 17 00:00:00 2001 +From: Michal Hecko +Date: Tue, 21 Apr 2026 11:30:19 +0200 +Subject: [PATCH 048/108] mount_unit_generator: do not make nofail mounts + required + +--- + .../libraries/mount_unit_generator.py | 28 ++++++++++++++++++- + .../tests/files/unit-nofail.mount | 15 ++++++++++ + .../tests/files/unit.mount | 14 ++++++++++ + .../tests/test_mount_unit_generation.py | 17 +++++++++++ + 4 files changed, 73 insertions(+), 1 deletion(-) + create mode 100644 repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/files/unit-nofail.mount + create mode 100644 repos/system_upgrade/common/actors/initramfs/mount_units_generator/tests/files/unit.mount + +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 +-- +2.54.0 + diff --git a/0049-leapp_resume.service-use-multi-user.target-instead-o.patch b/0049-leapp_resume.service-use-multi-user.target-instead-o.patch new file mode 100644 index 0000000..a87b568 --- /dev/null +++ b/0049-leapp_resume.service-use-multi-user.target-instead-o.patch @@ -0,0 +1,133 @@ +From d41629259c916a1205eccbafa3b03884cff5949f Mon Sep 17 00:00:00 2001 +From: Peter Mocary +Date: Mon, 27 Apr 2026 09:03:57 +0200 +Subject: [PATCH 049/108] leapp_resume.service: use multi-user.target instead + of default.target + +When selinux autorelabel is triggered, the selinux-autorelabel-generator +links default.target to selinux-autorelabel.target. During boot, +the relabel is performed and then the system is rebooted, resulting in +an intermediate reboot before the system fully starts. + +Since leapp_resume.service was linked into default.target.wants/ and +ordered After=default.target, it was started during this intermediate +autorelabel boot. When the relabel finished and the reboot was +triggered, leapp_resume.service was terminated, preventing post-upgrade +tasks from completing. The service was then rerun on the next boot. + +This patch makes leapp_resume.service rely on multi-user.target instead +of the default.target which is not reached during the autorelabel boot, +ensuring leapp_resume.service only runs on the actual first boot into +the upgraded OS. + +Jira: RHEL-60060 +--- + .../common/actors/createresumeservice/actor.py | 13 ++++++++----- + .../createresumeservice/files/leapp_resume.service | 2 +- + .../tests/test_createresumeservice.py | 13 +++++-------- + .../common/actors/removeresumeservice/actor.py | 13 ++++++++----- + 4 files changed, 22 insertions(+), 19 deletions(-) + +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)) + +- # in case nothing is enabled in the default target, the directory does not exist ++ 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 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/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) +-- +2.54.0 + diff --git a/0050-LiveMode-update-do-upgrade.sh-to-reflect-upstream-ch.patch b/0050-LiveMode-update-do-upgrade.sh-to-reflect-upstream-ch.patch new file mode 100644 index 0000000..5c5b8cf --- /dev/null +++ b/0050-LiveMode-update-do-upgrade.sh-to-reflect-upstream-ch.patch @@ -0,0 +1,334 @@ +From 985cc58bb3942d5061b92c84bcfe93788e80bca6 Mon Sep 17 00:00:00 2001 +From: Peter Mocary +Date: Wed, 22 Apr 2026 14:01:23 +0200 +Subject: [PATCH 050/108] LiveMode: update do-upgrade.sh to reflect upstream + changes + +- treat the existence of .leapp_upgrade_failed file the same as in the default solution. Reflects changes from 1f8b8f3. +- updates the regex used to extract OS major version so that it is able to extract version without a minor version. Reflects changes from 64ec2ec. +- ignore failures when calling mount -a, makes mount treat nofail option same as systemd. Reflects changes from 966e785. + +Jira: RHEL-104384, RHEL-56040 +--- + .../dracut/85sys-upgrade-redhat/do-upgrade.sh | 10 +- + .../files/do-upgrade.sh | 184 ++---------------- + 2 files changed, 19 insertions(+), 175 deletions(-) + +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 41eb1625..7d470459 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 +@@ -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 +@@ -249,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 +@@ -287,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 +@@ -380,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. +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..6f2e37ef 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/' +@@ -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 +-- +2.54.0 + diff --git a/0051-LiveMode-improve-do-upgrade.sh-output-when-.noreboot.patch b/0051-LiveMode-improve-do-upgrade.sh-output-when-.noreboot.patch new file mode 100644 index 0000000..16d49c4 --- /dev/null +++ b/0051-LiveMode-improve-do-upgrade.sh-output-when-.noreboot.patch @@ -0,0 +1,43 @@ +From 72ec8a691678f5097f6fc9290c5ae1a6b030dac9 Mon Sep 17 00:00:00 2001 +From: Peter Mocary +Date: Thu, 30 Apr 2026 11:53:26 +0200 +Subject: [PATCH 051/108] LiveMode: improve do-upgrade.sh output when .noreboot + file exists + +The output of do-upgrade.sh suggested that a reboot is going to happen +even though it wasn't as .noreboot file stopped it. This patch makes +sure the output is not misleading users. +--- + .../modify_userspace_for_livemode/files/do-upgrade.sh | 9 +++++++-- + 1 file changed, 7 insertions(+), 2 deletions(-) + +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 6f2e37ef..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 +@@ -137,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" + +@@ -213,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 +-- +2.54.0 + diff --git a/0052-multipathutil-handle-the-overrides-protocol-subsecti.patch b/0052-multipathutil-handle-the-overrides-protocol-subsecti.patch new file mode 100644 index 0000000..9ae2dba --- /dev/null +++ b/0052-multipathutil-handle-the-overrides-protocol-subsecti.patch @@ -0,0 +1,47 @@ +From a9aa8a8037a1705a9f967d5f62f6b2db428bf8fc Mon Sep 17 00:00:00 2001 +From: Benjamin Marzinski +Date: Mon, 16 Mar 2026 19:05:54 -0400 +Subject: [PATCH 052/108] multipathutil: handle the overrides:protocol + subsection + +Starting in rhel-8, multipath.conf supports a protocol subection of the +overrides configuration section. Accept this when parsing the config, +and add a test for it. + +Jira: RHEL-151509 +--- + repos/system_upgrade/common/libraries/multipathutil.py | 3 ++- + .../common/libraries/tests/test_multipathutil.py | 3 ++- + 2 files changed, 4 insertions(+), 2 deletions(-) + +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/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 +-- +2.54.0 + diff --git a/0053-multipath-move-config_reader-Actor-and-8to9-Models-t.patch b/0053-multipath-move-config_reader-Actor-and-8to9-Models-t.patch new file mode 100644 index 0000000..5dc0747 --- /dev/null +++ b/0053-multipath-move-config_reader-Actor-and-8to9-Models-t.patch @@ -0,0 +1,324 @@ +From 71431ef1404f2eb817a37a9574e9fbcf55d6d367 Mon Sep 17 00:00:00 2001 +From: Benjamin Marzinski +Date: Tue, 17 Mar 2026 13:53:47 -0400 +Subject: [PATCH 053/108] multipath: move config_reader Actor and 8to9 Models + to el8toel9 repo + +The config_reader actor is mostly decidated to checking for issues that +can happen during an el8 to el9 upgrade and produces +MultipathConfFacts8to9 messages. The MultipathConfig8to9 and +MultipathConfFacts8to9 models are exclusively for el8 to el9 upgrades. +All of these should live in the el8toel9 repo. + +Jira: RHEL-151509 +--- + .../system_upgrade/common/models/multipath.py | 42 ------------------- + .../actors/multipathconfread}/actor.py | 10 ++--- + .../libraries/multipathconfread.py | 11 ++--- + .../tests/files/all_the_things.conf | 0 + .../tests/files/allow_usb.conf | 0 + .../tests/files/complicated.conf | 0 + .../tests/files/conf1.d/empty.conf | 0 + .../files/conf1.d/nothing_important.conf | 0 + .../tests/files/conf2.d/all_true.conf | 0 + .../tests/files/conf3.d/README | 0 + .../tests/files/converted_the_things.conf | 0 + .../tests/files/default_rhel8.conf | 0 + .../multipathconfread}/tests/files/empty.conf | 0 + .../tests/files/empty_dir.conf | 0 + .../tests/files/missing_dir.conf | 0 + .../tests/files/no_defaults.conf | 0 + .../tests/files/no_foreign.conf | 0 + .../tests/files/not_set_dir.conf | 0 + .../tests/files/set_in_dir.conf | 0 + .../tests/files/two_defaults.conf | 0 + .../tests/test_multipath_conf_read_8to9.py | 30 ------------- + .../el8toel9/models/multipath8to9.py | 34 +++++++++++++++ + 22 files changed, 42 insertions(+), 85 deletions(-) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/actor.py (68%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/libraries/multipathconfread.py (88%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/all_the_things.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/allow_usb.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/complicated.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/conf1.d/empty.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/conf1.d/nothing_important.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/conf2.d/all_true.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/conf3.d/README (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/converted_the_things.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/default_rhel8.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/empty.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/empty_dir.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/missing_dir.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/no_defaults.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/no_foreign.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/not_set_dir.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/set_in_dir.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/files/two_defaults.conf (100%) + rename repos/system_upgrade/{common/actors/multipath/config_reader => el8toel9/actors/multipathconfread}/tests/test_multipath_conf_read_8to9.py (83%) + create mode 100644 repos/system_upgrade/el8toel9/models/multipath8to9.py + +diff --git a/repos/system_upgrade/common/models/multipath.py b/repos/system_upgrade/common/models/multipath.py +index 1d1c53b5..9ccaa290 100644 +--- a/repos/system_upgrade/common/models/multipath.py ++++ b/repos/system_upgrade/common/models/multipath.py +@@ -34,45 +34,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/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 88% +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..f2f39293 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 +@@ -103,10 +102,8 @@ def scan_and_emit_multipath_info(default_config_path='/etc/multipath.conf'): + ) + 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) ++ all_configs = [primary_config] + secondary_configs + +- config_facts_for_8to9 = MultipathConfFacts8to9(configs=all_configs) +- api.produce(config_facts_for_8to9) ++ 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 83% +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..2da74927 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 +@@ -142,33 +142,3 @@ def test_get_facts_missing_dir(monkeypatch, primary_config, expected_configs): + + for actual_config, expected_config in zip(actual_configs, 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) +- 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') +- monkeypatch.setattr(api, 'current_actor', actor_mock) +- +- multipathconfread.scan_and_emit_multipath_info(default_config_path) +- +- assert produce_mock.called +- +- 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' +- +- msgs = [msg for msg in produce_mock.model_instances if isinstance(msg, MultipathConfFacts8to9)] +- assert not msgs +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""" +-- +2.54.0 + diff --git a/0054-multipath-Add-MultipathConfig9to10-and-MultipathConf.patch b/0054-multipath-Add-MultipathConfig9to10-and-MultipathConf.patch new file mode 100644 index 0000000..3768bed --- /dev/null +++ b/0054-multipath-Add-MultipathConfig9to10-and-MultipathConf.patch @@ -0,0 +1,83 @@ +From b1bf3ce6175e007f304443830a49b641fe0aa96f Mon Sep 17 00:00:00 2001 +From: Benjamin Marzinski +Date: Wed, 18 Mar 2026 16:38:43 -0400 +Subject: [PATCH 054/108] multipath: Add MultipathConfig9to10 and + MultipathConfFacts9to10 + +Add el9toel10 models to track the necessary configuration changes for +the upgrade from RHEL-9 to RHEL-10 + +Jira: RHEL-151509 +--- + .../el9toel10/models/multipath9to10.py | 59 +++++++++++++++++++ + 1 file changed, 59 insertions(+) + create mode 100644 repos/system_upgrade/el9toel10/models/multipath9to10.py + +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""" +-- +2.54.0 + diff --git a/0055-multipath-Add-MultipathConfRead9to10-config_reader-a.patch b/0055-multipath-Add-MultipathConfRead9to10-config_reader-a.patch new file mode 100644 index 0000000..c3f6066 --- /dev/null +++ b/0055-multipath-Add-MultipathConfRead9to10-config_reader-a.patch @@ -0,0 +1,1897 @@ +From 7ff626243366a4ffaaf972f12e8a4d7708c06040 Mon Sep 17 00:00:00 2001 +From: Benjamin Marzinski +Date: Wed, 18 Mar 2026 20:51:05 -0400 +Subject: [PATCH 055/108] multipath: Add MultipathConfRead9to10 config_reader + actor + +Add the el9toel10 config_reader actor that scans multipath configuration +and produces MultipathInfo and MultipathConfFacts9to10 messages. The actor +detects getuid_callout usage, socket activation, DM NVMe multipathing, +and tracks config_dir/bindings_file/wwids_file/prkeys_file settings. + +Also keep target_uspace_configs from automatically copying the old +config_dir, if it isn't in the default location in an el9toel10 upgrade. +the old config_dir will always be copied in an el8toel9 upgrade. + +Jira: RHEL-151509 + +Co-Authored-By: Claude Opus 4.6 +--- + .../target_uspace_multipath_configs.py | 2 +- + .../actors/multipath/config_reader/actor.py | 25 + + .../libraries/multipathconfread.py | 138 ++ + .../tests/files/all_options.conf | 9 + + .../tests/files/all_set_in_dir.conf | 5 + + .../tests/files/complicated.conf | 1136 +++++++++++++++++ + .../tests/files/conf1.d/bindings_set.conf | 3 + + .../tests/files/conf1.d/empty.conf | 1 + + .../tests/files/conf2.d/getuid_set.conf | 11 + + .../tests/files/conf2.d/set_files.conf | 5 + + .../config_reader/tests/files/conf3.d/README | 1 + + .../config_reader/tests/files/config_dir.conf | 5 + + .../tests/files/default_rhel9.conf | 21 + + .../config_reader/tests/files/empty.conf | 1 + + .../config_reader/tests/files/empty_dir.conf | 5 + + .../tests/files/extra_slash.conf | 5 + + .../tests/files/getuid_defaults.conf | 5 + + .../tests/files/getuid_devices.conf | 12 + + .../tests/files/getuid_overrides.conf | 8 + + .../tests/files/missing_dir.conf | 5 + + .../tests/files/no_defaults.conf | 7 + + .../tests/test_multipath_conf_read_9to10.py | 283 ++++ + 22 files changed, 1692 insertions(+), 1 deletion(-) + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/actor.py + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/all_options.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/all_set_in_dir.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/complicated.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf1.d/bindings_set.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf1.d/empty.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf2.d/getuid_set.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf2.d/set_files.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/conf3.d/README + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/config_dir.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/default_rhel9.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/empty.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/empty_dir.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/extra_slash.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_defaults.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_devices.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/getuid_overrides.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/missing_dir.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/files/no_defaults.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/test_multipath_conf_read_9to10.py + +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..bc685fa2 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,7 @@ def request_mpath_confs(multipath_info): + '/etc/multipath.conf': '/etc/multipath.conf' # default config + } + +- if os.path.exists(multipath_info.config_dir): ++ 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/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..50c87d86 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py +@@ -0,0 +1,138 @@ ++import errno ++import os ++ ++from leapp.libraries.common import multipathutil ++from leapp.libraries.common.rpms import has_package ++from leapp.libraries.stdlib import api ++from leapp.models import DistributionSignedRPM, MultipathConfFacts9to10, MultipathConfig9to10, MultipathInfo ++ ++ ++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 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' ++ api.produce(multipath_info) ++ ++ 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 ++ ++ 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..cb90fcc3 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/test_multipath_conf_read_9to10.py +@@ -0,0 +1,283 @@ ++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' ++ ++ 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_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 +-- +2.54.0 + diff --git a/0056-multipath-Add-MultipathConfCheck9to10-mpath_conf_che.patch b/0056-multipath-Add-MultipathConfCheck9to10-mpath_conf_che.patch new file mode 100644 index 0000000..887f2e0 --- /dev/null +++ b/0056-multipath-Add-MultipathConfCheck9to10-mpath_conf_che.patch @@ -0,0 +1,522 @@ +From 29de17c95fe77ebd4da83f55d12ef294ecd29544 Mon Sep 17 00:00:00 2001 +From: Benjamin Marzinski +Date: Tue, 24 Mar 2026 17:31:26 -0400 +Subject: [PATCH 056/108] multipath: Add MultipathConfCheck9to10 + mpath_conf_check actor + +Add the el9toel10 mpath_conf_check actor that consumes +MultipathConfFacts9to10 and reports on multipath configuration changes +needed for the RHEL 9 to RHEL 10 upgrade: deprecated config_dir, +deprecated file paths (bindings/wwids/prkeys), socket activation +disabled by default, unsupported DM NVMe multipathing, and +getuid_callout as an upgrade inhibitor. + +Also, if config_dir is not /etc/multipath/conf.d, the upgrade will be +inhibited if /etc/multipath/conf.d already exists and has config files +in it. Otherwise they would become part of the new config, after the +upgrade. + +Jira: RHEL-151509 + +Co-Authored-By: Claude Opus 4.6 +--- + .../multipath/mpath_conf_check/actor.py | 25 ++ + .../libraries/mpath_conf_check.py | 188 +++++++++++++ + .../tests/test_multipath_conf_check_9to10.py | 258 ++++++++++++++++++ + 3 files changed, 471 insertions(+) + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/actor.py + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/libraries/mpath_conf_check.py + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/tests/test_multipath_conf_check_9to10.py + +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..49aefc5a +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_conf_check/libraries/mpath_conf_check.py +@@ -0,0 +1,188 @@ ++import os ++ ++from leapp import reporting ++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: last non-None value wins ++ bindings_file = None ++ wwids_file = None ++ prkeys_file = None ++ for conf in facts.configs: ++ if conf.bindings_file is not None: ++ bindings_file = conf.bindings_file ++ if conf.wwids_file is not None: ++ wwids_file = conf.wwids_file ++ if conf.prkeys_file is not None: ++ prkeys_file = conf.prkeys_file ++ ++ file_list = [] ++ if ( ++ bindings_file is not None and ++ os.path.normpath(bindings_file) != _DEFAULT_BINDINGS_FILE ++ ): ++ file_list.append(('bindings_file', bindings_file, _DEFAULT_BINDINGS_FILE)) ++ if ( ++ wwids_file is not None and ++ os.path.normpath(wwids_file) != _DEFAULT_WWIDS_FILE ++ ): ++ file_list.append(('wwids_file', wwids_file, _DEFAULT_WWIDS_FILE)) ++ if ( ++ prkeys_file is not None and ++ os.path.normpath(prkeys_file) != _DEFAULT_PRKEYS_FILE ++ ): ++ file_list.append(('prkeys_file', prkeys_file, _DEFAULT_PRKEYS_FILE)) ++ 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) +-- +2.54.0 + diff --git a/0057-multipath-Add-MultipathUpgradeConfUpdate9to10-mpath_.patch b/0057-multipath-Add-MultipathUpgradeConfUpdate9to10-mpath_.patch new file mode 100644 index 0000000..c311a71 --- /dev/null +++ b/0057-multipath-Add-MultipathUpgradeConfUpdate9to10-mpath_.patch @@ -0,0 +1,659 @@ +From beffa9b5decef2639af93adf014ca63071900e3e Mon Sep 17 00:00:00 2001 +From: Benjamin Marzinski +Date: Mon, 30 Mar 2026 18:39:57 -0400 +Subject: [PATCH 057/108] multipath: Add MultipathUpgradeConfUpdate9to10 + mpath_upgrade_conf_patcher actor + +Removes deprecated defaults section options (config_dir, bindings_file, +wwids_file, prkeys_file) from multipath configuration files during the +RHEL 9 to RHEL 10 upgrade. Relocates secondary configs to +/etc/multipath/conf.d/ when config_dir is non-default, and creates +entries to move bindings, wwids, and prkeys files to their default +RHEL-10 locations. + +Also make multipath_system_config_patcher create directories if they +don't already exist when copying files, to deal with the case where +config files need to be copied to /etc/multipath/conf.d, but it doesn't +exist. + +Jira: RHEL-151509 + +Co-Authored-By: Claude Opus 4.6 +--- + .../libraries/system_config_patcher.py | 5 + + .../mpath_upgrade_conf_patcher/actor.py | 29 ++ + .../libraries/mpathconfupdate.py | 143 ++++++++++ + .../tests/files/after/all_deprecated.conf | 7 + + .../tests/files/after/has_config_dir.conf | 4 + + .../tests/files/after/has_files.conf | 6 + + .../after/secondary_with_deprecated.conf | 9 + + .../tests/files/before/all_deprecated.conf | 7 + + .../tests/files/before/empty.conf | 1 + + .../tests/files/before/has_config_dir.conf | 4 + + .../tests/files/before/has_files.conf | 6 + + .../tests/files/before/no_defaults.conf | 3 + + .../tests/files/before/no_deprecated.conf | 3 + + .../tests/files/before/secondary_simple.conf | 6 + + .../before/secondary_with_deprecated.conf | 9 + + .../tests/test_mpath_conf_update_9to10.py | 256 ++++++++++++++++++ + 16 files changed, 498 insertions(+) + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/actor.py + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/libraries/mpathconfupdate.py + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/all_deprecated.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/has_config_dir.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/has_files.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/after/secondary_with_deprecated.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/all_deprecated.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/empty.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/has_config_dir.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/has_files.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/no_defaults.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/no_deprecated.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/secondary_simple.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/files/before/secondary_with_deprecated.conf + create mode 100644 repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/tests/test_mpath_conf_update_9to10.py + +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/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..a2e3bafc +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/multipath/mpath_upgrade_conf_patcher/libraries/mpathconfupdate.py +@@ -0,0 +1,143 @@ ++import os ++import shutil ++ ++from leapp.libraries.common import 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 = None ++ wwids_file = None ++ prkeys_file = None ++ for conf in facts.configs: ++ if conf.bindings_file is not None: ++ bindings_file = os.path.normpath(conf.bindings_file) ++ if conf.wwids_file is not None: ++ wwids_file = os.path.normpath(conf.wwids_file) ++ if conf.prkeys_file is not None: ++ prkeys_file = os.path.normpath(conf.prkeys_file) ++ ++ file_updates = [] ++ if bindings_file is not None and bindings_file != _DEFAULT_BINDINGS_FILE: ++ file_updates.append((bindings_file, _DEFAULT_BINDINGS_FILE)) ++ if wwids_file is not None and wwids_file != _DEFAULT_WWIDS_FILE: ++ file_updates.append((wwids_file, _DEFAULT_WWIDS_FILE)) ++ if prkeys_file is not None and prkeys_file != _DEFAULT_PRKEYS_FILE: ++ file_updates.append((prkeys_file, _DEFAULT_PRKEYS_FILE)) ++ 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 +-- +2.54.0 + diff --git a/0058-multipath-Add-bindings-wwids-prkeys-file-copying-to-.patch b/0058-multipath-Add-bindings-wwids-prkeys-file-copying-to-.patch new file mode 100644 index 0000000..358005d --- /dev/null +++ b/0058-multipath-Add-bindings-wwids-prkeys-file-copying-to-.patch @@ -0,0 +1,567 @@ +From 3c45b90ec3a053bae88f6bf829317c36bb914b71 Mon Sep 17 00:00:00 2001 +From: Benjamin Marzinski +Date: Wed, 1 Apr 2026 14:24:42 -0400 +Subject: [PATCH 058/108] multipath: Add bindings/wwids/prkeys file copying to + target userspace + +The request_multipath_conf_in_target_userspace actor was not copying +bindings, wwids, and prkeys files into the target userspace. Add +bindings_file, wwids_file, and prkeys_file fields to MultipathInfo so +the config reader actors can communicate the file locations to the +target_uspace_configs actor. + +For el8toel9, MultipathInfo always gets all three file fields set to +whatever the effective location is (default or configured), so the +files are always copied at their current location. + +For el9toel10, MultipathInfo file fields are only set when the +effective value is at the default location (or unset). Non-default +file locations are not set on MultipathInfo, since the patcher actor +handles relocating them to the default location via +UpdatedMultipathConfig entries. + +Also normalize config_dir paths in the el8toel9 config reader with +os.path.normpath() for consistency with el9toel10. + +Jira: RHEL-151509 + +Tests-Authored-By: Claude Opus 4.6 +--- + .../target_uspace_multipath_configs.py | 7 ++ + .../tests/test_target_uspace_configs.py | 117 ++++++++++++++++++ + .../system_upgrade/common/models/multipath.py | 27 +++- + .../libraries/multipathconfread.py | 38 ++++-- + .../tests/test_multipath_conf_read_8to9.py | 42 ++++++- + .../libraries/multipathconfread.py | 36 +++++- + .../tests/test_multipath_conf_read_9to10.py | 99 +++++++++++++++ + 7 files changed, 354 insertions(+), 12 deletions(-) + +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 bc685fa2..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,6 +22,13 @@ def request_mpath_confs(multipath_info): + '/etc/multipath.conf': '/etc/multipath.conf' # default config + } + ++ 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) +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/models/multipath.py b/repos/system_upgrade/common/models/multipath.py +index 9ccaa290..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): +diff --git a/repos/system_upgrade/el8toel9/actors/multipathconfread/libraries/multipathconfread.py b/repos/system_upgrade/el8toel9/actors/multipathconfread/libraries/multipathconfread.py +index f2f39293..2c81ed52 100644 +--- a/repos/system_upgrade/el8toel9/actors/multipathconfread/libraries/multipathconfread.py ++++ b/repos/system_upgrade/el8toel9/actors/multipathconfread/libraries/multipathconfread.py +@@ -9,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 +@@ -44,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: +@@ -82,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 ' +@@ -100,10 +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' + ) ++ ++ 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) + +- secondary_configs = _parse_config_dir(multipath_info.config_dir) + 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/el8toel9/actors/multipathconfread/tests/test_multipath_conf_read_8to9.py b/repos/system_upgrade/el8toel9/actors/multipathconfread/tests/test_multipath_conf_read_8to9.py +index 2da74927..cd8036ec 100644 +--- a/repos/system_upgrade/el8toel9/actors/multipathconfread/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 +@@ -142,3 +147,34 @@ def test_get_facts_missing_dir(monkeypatch, primary_config, expected_configs): + + for actual_config, expected_config in zip(actual_configs, expected_configs): + assert_config(actual_config, expected_config) ++ ++ ++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='8.10', dst_ver='9.6') ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ # 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' ++ ++ def mock_parse(path, fl): ++ fl.update(file_locs) ++ return MultipathConfig8to9(pathname=path) ++ ++ monkeypatch.setattr(multipathconfread, '_parse_config', mock_parse) ++ monkeypatch.setattr(multipathconfread, '_parse_config_dir', lambda d, fl: []) ++ ++ 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/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py +index 50c87d86..7d85bdbc 100644 +--- a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py +@@ -6,6 +6,10 @@ 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) +@@ -98,6 +102,35 @@ def is_processable(): + return res + + ++def _add_file_locations(configs, mpath_info): ++ bindings_file = None ++ wwids_file = None ++ prkeys_file = None ++ for conf in configs: ++ if conf.bindings_file is not None: ++ bindings_file = conf.bindings_file ++ if conf.wwids_file is not None: ++ wwids_file = conf.wwids_file ++ if conf.prkeys_file is not None: ++ prkeys_file = conf.prkeys_file ++ ++ if ( ++ bindings_file is None or ++ os.path.normpath(bindings_file) == _DEFAULT_BINDINGS_FILE ++ ): ++ mpath_info.bindings_file = _DEFAULT_BINDINGS_FILE ++ if ( ++ wwids_file is None or ++ os.path.normpath(wwids_file) == _DEFAULT_WWIDS_FILE ++ ): ++ mpath_info.wwids_file = _DEFAULT_WWIDS_FILE ++ if ( ++ prkeys_file is None or ++ os.path.normpath(prkeys_file) == _DEFAULT_PRKEYS_FILE ++ ): ++ mpath_info.prkeys_file = _DEFAULT_PRKEYS_FILE ++ ++ + def scan_and_emit_multipath_info(default_config_path='/etc/multipath.conf'): + if not is_processable(): + return +@@ -124,7 +157,6 @@ def scan_and_emit_multipath_info(default_config_path='/etc/multipath.conf'): + os.path.normpath(primary_config.config_dir) == '/etc/multipath/conf.d' + ): + multipath_info.config_dir = '/etc/multipath/conf.d' +- api.produce(multipath_info) + + primary_config.has_socket_activation = _check_socket_activation() + primary_config.has_dm_nvme_multipathing = _check_dm_nvme_multipathing() +@@ -133,6 +165,8 @@ def scan_and_emit_multipath_info(default_config_path='/etc/multipath.conf'): + 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/test_multipath_conf_read_9to10.py b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/tests/test_multipath_conf_read_9to10.py +index cb90fcc3..f8f039a0 100644 +--- 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 +@@ -162,6 +162,9 @@ def test_get_primary_facts_default_config_dir(monkeypatch, primary_config, expec + 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 +@@ -214,6 +217,102 @@ def test_get_facts_with_config_dir(monkeypatch, primary_config, 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 +-- +2.54.0 + diff --git a/0059-multipath-Add-mpathfiles-library-and-use-it-in-el9to.patch b/0059-multipath-Add-mpathfiles-library-and-use-it-in-el9to.patch new file mode 100644 index 0000000..e5fad95 --- /dev/null +++ b/0059-multipath-Add-mpathfiles-library-and-use-it-in-el9to.patch @@ -0,0 +1,227 @@ +From 6fc76ee600c896b92f06cb65d9f1bbc774e4c9f8 Mon Sep 17 00:00:00 2001 +From: Benjamin Marzinski +Date: Wed, 15 Apr 2026 02:30:37 -0400 +Subject: [PATCH 059/108] multipath: Add mpathfiles library and use it in + el9toel10 actors + +All of the el9toel10 actors need to read through all the configs to +determine the value of bindings_file, wwids_file, and prkeys_file for +the overall configuration. Split this code out into a library function +and call it from all of the actors. Also move the code to use these +files into a loop, since the same action needs to be done on all three +files. +--- + .../libraries/multipathconfread.py | 45 ++++++++----------- + .../libraries/mpath_conf_check.py | 41 +++++++---------- + .../libraries/mpathconfupdate.py | 31 ++++++------- + .../el9toel10/libraries/mpathfiles.py | 22 +++++++++ + 4 files changed, 70 insertions(+), 69 deletions(-) + create mode 100644 repos/system_upgrade/el9toel10/libraries/mpathfiles.py + +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 +index 7d85bdbc..0f070c1a 100644 +--- a/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py ++++ b/repos/system_upgrade/el9toel10/actors/multipath/config_reader/libraries/multipathconfread.py +@@ -1,7 +1,7 @@ + import errno + import os + +-from leapp.libraries.common import multipathutil ++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 +@@ -103,32 +103,25 @@ def is_processable(): + + + def _add_file_locations(configs, mpath_info): +- bindings_file = None +- wwids_file = None +- prkeys_file = None +- for conf in configs: +- if conf.bindings_file is not None: +- bindings_file = conf.bindings_file +- if conf.wwids_file is not None: +- wwids_file = conf.wwids_file +- if conf.prkeys_file is not None: +- prkeys_file = conf.prkeys_file ++ bindings_file, wwids_file, prkeys_file = mpathfiles.mpath_file_locations(configs) + +- if ( +- bindings_file is None or +- os.path.normpath(bindings_file) == _DEFAULT_BINDINGS_FILE +- ): +- mpath_info.bindings_file = _DEFAULT_BINDINGS_FILE +- if ( +- wwids_file is None or +- os.path.normpath(wwids_file) == _DEFAULT_WWIDS_FILE +- ): +- mpath_info.wwids_file = _DEFAULT_WWIDS_FILE +- if ( +- prkeys_file is None or +- os.path.normpath(prkeys_file) == _DEFAULT_PRKEYS_FILE +- ): +- mpath_info.prkeys_file = _DEFAULT_PRKEYS_FILE ++ 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'): +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 +index 49aefc5a..8c4dc531 100644 +--- 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 +@@ -1,6 +1,7 @@ + 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' +@@ -145,34 +146,22 @@ def check_configs(facts): + if _default_config_dir_has_conf_files(): + _report_config_dir_conflict(config_dir) + +- # bindings_file, wwids_file, prkeys_file: last non-None value wins +- bindings_file = None +- wwids_file = None +- prkeys_file = None +- for conf in facts.configs: +- if conf.bindings_file is not None: +- bindings_file = conf.bindings_file +- if conf.wwids_file is not None: +- wwids_file = conf.wwids_file +- if conf.prkeys_file is not None: +- prkeys_file = conf.prkeys_file ++ bindings_file, wwids_file, prkeys_file = mpathfiles.mpath_file_locations(facts.configs) + + file_list = [] +- if ( +- bindings_file is not None and +- os.path.normpath(bindings_file) != _DEFAULT_BINDINGS_FILE +- ): +- file_list.append(('bindings_file', bindings_file, _DEFAULT_BINDINGS_FILE)) +- if ( +- wwids_file is not None and +- os.path.normpath(wwids_file) != _DEFAULT_WWIDS_FILE +- ): +- file_list.append(('wwids_file', wwids_file, _DEFAULT_WWIDS_FILE)) +- if ( +- prkeys_file is not None and +- os.path.normpath(prkeys_file) != _DEFAULT_PRKEYS_FILE +- ): +- file_list.append(('prkeys_file', prkeys_file, _DEFAULT_PRKEYS_FILE)) ++ 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) + +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 +index a2e3bafc..d5618018 100644 +--- 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 +@@ -1,7 +1,7 @@ + import os + import shutil + +-from leapp.libraries.common import multipathutil ++from leapp.libraries.common import mpathfiles, multipathutil + from leapp.libraries.stdlib import api + from leapp.models import MultipathConfigUpdatesInfo, UpdatedMultipathConfig + +@@ -55,24 +55,21 @@ def _update_config(config): + + + def _get_file_locations(facts): +- bindings_file = None +- wwids_file = None +- prkeys_file = None +- for conf in facts.configs: +- if conf.bindings_file is not None: +- bindings_file = os.path.normpath(conf.bindings_file) +- if conf.wwids_file is not None: +- wwids_file = os.path.normpath(conf.wwids_file) +- if conf.prkeys_file is not None: +- prkeys_file = os.path.normpath(conf.prkeys_file) ++ bindings_file, wwids_file, prkeys_file = mpathfiles.mpath_file_locations(facts.configs) + + file_updates = [] +- if bindings_file is not None and bindings_file != _DEFAULT_BINDINGS_FILE: +- file_updates.append((bindings_file, _DEFAULT_BINDINGS_FILE)) +- if wwids_file is not None and wwids_file != _DEFAULT_WWIDS_FILE: +- file_updates.append((wwids_file, _DEFAULT_WWIDS_FILE)) +- if prkeys_file is not None and prkeys_file != _DEFAULT_PRKEYS_FILE: +- file_updates.append((prkeys_file, _DEFAULT_PRKEYS_FILE)) ++ 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 + + +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) +-- +2.54.0 + diff --git a/0060-model-upgrade_cmdline-add-to_remove-field.patch b/0060-model-upgrade_cmdline-add-to_remove-field.patch new file mode 100644 index 0000000..a8f37a8 --- /dev/null +++ b/0060-model-upgrade_cmdline-add-to_remove-field.patch @@ -0,0 +1,96 @@ +From ecb06c6fd4ad365ecfe0808a616cd3b3b9cf18d0 Mon Sep 17 00:00:00 2001 +From: Michal Hecko +Date: Wed, 29 Apr 2026 21:05:52 +0200 +Subject: [PATCH 060/108] model(upgrade_cmdline): add to_remove field + +Extend the UpgradeKernelCmdlineArgTasks model to have a `to_remove` +field that list kernel cmdline arguments that should be removed from the +upgrade boot entry. Args listed in the field have precedence over +arguments listed in to_add, i.e., if 'X' is in both `to_remove` and +`to_add`, it is guaranteed to be removed. + +Jira-ref: RHEL-145136 +--- + .../libraries/addupgradebootentry.py | 4 ++- + .../tests/unit_test_addupgradebootentry.py | 26 ++++++++++++++++++- + .../common/models/kernelcmdlineargs.py | 4 +++ + 3 files changed, 32 insertions(+), 2 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py b/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py +index 5b635a83..8fb2e404 100644 +--- a/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py ++++ b/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py +@@ -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): +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..09632c0c 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 +@@ -18,7 +18,8 @@ from leapp.models import ( + LateTargetKernelCmdlineArgTasks, + LiveModeArtifacts, + LiveModeConfig, +- TargetKernelCmdlineArgTasks ++ TargetKernelCmdlineArgTasks, ++ UpgradeKernelCmdlineArgTasks + ) + + CUR_DIR = os.path.dirname(os.path.abspath(__file__)) +@@ -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/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): +-- +2.54.0 + diff --git a/0061-checkluks-emit-messages-only-once.patch b/0061-checkluks-emit-messages-only-once.patch new file mode 100644 index 0000000..715534e --- /dev/null +++ b/0061-checkluks-emit-messages-only-once.patch @@ -0,0 +1,81 @@ +From bb81f96120a8c8b2ecba4bb9db7ecb86da3867fb Mon Sep 17 00:00:00 2001 +From: Michal Hecko +Date: Thu, 7 May 2026 10:39:12 +0200 +Subject: [PATCH 061/108] checkluks: emit messages only once + +Currently, the checkluks actor emits messages for target userspace and +initramfs in a loop, i.e., a single message is emitted for every OK luks +partition. This patch changes the behavior, so that messages are emitted +only once, if there are any OK partitions present. +--- + .../actors/checkluks/libraries/checkluks.py | 50 +++++++++++-------- + 1 file changed, 28 insertions(+), 22 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/checkluks/libraries/checkluks.py b/repos/system_upgrade/common/actors/checkluks/libraries/checkluks.py +index f3e45b47..cdf86d08 100644 +--- a/repos/system_upgrade/common/actors/checkluks/libraries/checkluks.py ++++ b/repos/system_upgrade/common/actors/checkluks/libraries/checkluks.py +@@ -111,6 +111,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 +@@ -126,25 +127,30 @@ def check_invalid_luks_devices(): + 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') ++ ]) ++ ) +-- +2.54.0 + diff --git a/0062-checkluks-remove-rd.luks.uuid-from-the-upgrade-entry.patch b/0062-checkluks-remove-rd.luks.uuid-from-the-upgrade-entry.patch new file mode 100644 index 0000000..60e6b7d --- /dev/null +++ b/0062-checkluks-remove-rd.luks.uuid-from-the-upgrade-entry.patch @@ -0,0 +1,256 @@ +From 5e4cba2a334b7ff2f3413f0d67f316b86a945fe5 Mon Sep 17 00:00:00 2001 +From: Michal Hecko +Date: Thu, 7 May 2026 10:41:20 +0200 +Subject: [PATCH 062/108] checkluks: remove rd.luks.uuid from the upgrade entry + +Request removal of the rd.luks.uuid cmdline args from the upgrade boot +entry. These args filter which devices in /etc/cryptab can be unlocked +during early stages of the boot process. As we want to mount more +devices (essentially everything in /etc/cryptab), presence of these args +is undesired. + +Jira-ref: RHEL-145136 +--- + .../common/actors/checkluks/actor.py | 21 +++++- + .../actors/checkluks/libraries/checkluks.py | 44 ++++++++++-- + .../actors/checkluks/tests/test_checkluks.py | 67 +++++++++++++++++-- + 3 files changed, 118 insertions(+), 14 deletions(-) + +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 cdf86d08..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: +@@ -123,9 +161,6 @@ 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: + ok_partitions.append(luks_dump.device_name) + +@@ -154,3 +189,4 @@ def check_invalid_luks_devices(): + 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 +-- +2.54.0 + diff --git a/0063-conftest-exit-actor-context-when-switching-repos.patch b/0063-conftest-exit-actor-context-when-switching-repos.patch new file mode 100644 index 0000000..18f82e6 --- /dev/null +++ b/0063-conftest-exit-actor-context-when-switching-repos.patch @@ -0,0 +1,114 @@ +From 7d0bf3eed770e3cbaf48bfacbc1c11943f3a63c8 Mon Sep 17 00:00:00 2001 +From: Michal Hecko +Date: Mon, 11 May 2026 18:28:32 +0200 +Subject: [PATCH 063/108] conftest: exit actor context when switching repos + +Prior to this patch, the current_actor_context fixture was always exited +when executing a new actor. Exiting the fixture causes sys.meta_path to +be restored to a state when the fixture was first entered. Therefore, if +we are running tests across multiple repositories, say common and el9toel10, +then when we are executing the first actor from el9toel10 we execute +__exit__ of the last executed actor from the common repository. So the +following would happen: +1) Finish running the last actor named 'X' from the common repository. +2) Load el9toel10 repository, appending to sys.meta_path loaders for + leapp.libraries.common, etc. +3) Discovery of the tests of the first actor from el9toel10. +4) Detect that we are executing a different actor than 'X', so call + cleanup, which restores the state of sys.meta_path. Therefore, this + effectively removes all loaders for el9toel10 repository from + sys.meta_path. +5) Execute tests for the first actor from el9toel10. + +This patch ensures that we call current_actor_context.__exit__ whenever +we are about to start loading a new repository, preventing us from +unwanted removal of the loaders in sys.meta_path (added by the loaded +repository). +--- + conftest.py | 47 ++++++++++++++++++++++++++++++++++++----------- + 1 file changed, 36 insertions(+), 11 deletions(-) + +diff --git a/conftest.py b/conftest.py +index 5da5cc5f..542e22cb 100644 +--- a/conftest.py ++++ b/conftest.py +@@ -12,40 +12,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 +-- +2.54.0 + diff --git a/0064-testutils-logger_mocked-accepts-kwargs.patch b/0064-testutils-logger_mocked-accepts-kwargs.patch new file mode 100644 index 0000000..dd1d3e9 --- /dev/null +++ b/0064-testutils-logger_mocked-accepts-kwargs.patch @@ -0,0 +1,52 @@ +From 8b5840dd94efca6a30ec1b1ba5ac75bb015e4dac Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Mon, 4 May 2026 14:41:41 +0200 +Subject: [PATCH 064/108] testutils: logger_mocked: accepts kwargs + +For logger calls specifying kw like: exc_info, stack_info, ... +methods in logger_mocked were broken. Allow to specify **kwargs so +the mocking is allowed. If any kw is specified, it's ignored in the +mocked call at this moment as we do not have use for it in tests. + +Assisted-by: Claude Sonnet 4.5 +Signed-off-by: Petr Stodulka +--- + repos/system_upgrade/common/libraries/testutils.py | 10 +++++----- + 1 file changed, 5 insertions(+), 5 deletions(-) + +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): +-- +2.54.0 + diff --git a/0065-conftest-Add-leapp_tmpdir-fixture-for-standardized-t.patch b/0065-conftest-Add-leapp_tmpdir-fixture-for-standardized-t.patch new file mode 100644 index 0000000..5f57bc8 --- /dev/null +++ b/0065-conftest-Add-leapp_tmpdir-fixture-for-standardized-t.patch @@ -0,0 +1,52 @@ +From d3e20f73aca107a48ae7ee5734bd6a8542dfca96 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Mon, 4 May 2026 15:10:04 +0000 +Subject: [PATCH 065/108] conftest: Add leapp_tmpdir fixture for standardized + temp directories + +Introduces a project-wide pytest fixture that ensures all tests create +temporary directories with the 'leapptest-' prefix in /tmp, maintaining +consistency across the test suite and simplifying cleanup. + +Co-Authored-By: Claude Sonnet 4.5 +Signed-off-by: Petr Stodulka +--- + conftest.py | 19 +++++++++++++++++++ + 1 file changed, 19 insertions(+) + +diff --git a/conftest.py b/conftest.py +index 542e22cb..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 +@@ -121,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 +-- +2.54.0 + diff --git a/0066-Reorganize-DNF-libraries-into-dnflibs-package.patch b/0066-Reorganize-DNF-libraries-into-dnflibs-package.patch new file mode 100644 index 0000000..5fe6fba --- /dev/null +++ b/0066-Reorganize-DNF-libraries-into-dnflibs-package.patch @@ -0,0 +1,1744 @@ +From 9e165750360adf579d9b80d8cb1bf3518a74c445 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Tue, 10 Mar 2026 10:45:41 +0000 +Subject: [PATCH 066/108] Reorganize DNF libraries into dnflibs package + +Consolidate DNF-related libraries (dnfconfig, dnfplugin, module) into a +new dnflibs package for better organization. The old module locations +remain as deprecated shims using @deprecated decorator, ensuring full +backward compatibility while encouraging migration to the new structure. + +I plan additional changes in future and I consider the original +structure problematic, like: +* I want to expose some of private functions, but there is no good + place where to put them - and keeping them in original places is + not good. +* The module.py library has kind of ambiguous name and it was not + clear what to expect from it without reading the content. + +With this change, all actions related to DNF are nicely closured. + +Note that unit-tests are going to be added in a following commits. + +Changes: +- Create repos/system_upgrade/common/libraries/dnflibs/ package +- Move dnfconfig.py to dnflibs/dnfconfig.py +- Move dnfplugin.py to dnflibs/dnfplugin.py +- Move module.py to dnflibs/dnfmodule.py (renamed) +- Mark original libraries and their content as deprecated +- Update the documentation to reflect deprecated functionality + +Assisted-by: Claude Sonnet 4.5 +Signed-off-by: Petr Stodulka +--- + .../libraries-and-api/deprecations-list.md | 6 + + .../common/libraries/dnfconfig.py | 107 +--- + .../common/libraries/dnflibs/__init__.py | 8 + + .../common/libraries/dnflibs/dnfconfig.py | 108 ++++ + .../common/libraries/dnflibs/dnfmodule.py | 105 ++++ + .../common/libraries/dnflibs/dnfplugin.py | 551 ++++++++++++++++++ + .../common/libraries/dnfplugin.py | 542 +++-------------- + .../system_upgrade/common/libraries/module.py | 109 +--- + 8 files changed, 896 insertions(+), 640 deletions(-) + create mode 100644 repos/system_upgrade/common/libraries/dnflibs/__init__.py + create mode 100644 repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py + create mode 100644 repos/system_upgrade/common/libraries/dnflibs/dnfmodule.py + create mode 100644 repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py + +diff --git a/docs/source/libraries-and-api/deprecations-list.md b/docs/source/libraries-and-api/deprecations-list.md +index a074ebc1..aa20f874 100644 +--- a/docs/source/libraries-and-api/deprecations-list.md ++++ b/docs/source/libraries-and-api/deprecations-list.md +@@ -18,6 +18,12 @@ 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 ++ - DNF-related libraries have been reorganized into the `leapp.libraries.common.dnflibs` package. The following modules have been moved: ++ - **`leapp.libraries.common.dnfconfig`** - Moved to `leapp.libraries.common.dnflibs.dnfconfig`. All public functions (`exclude_leapp_rpms`) are deprecated in the old location. ++ - **`leapp.libraries.common.dnfplugin`** - Moved to `leapp.libraries.common.dnflibs.dnfplugin`. All public functions (`install`, `build_plugin_data`, `create_config`, `backup_config`, `backup_debug_data`, `apply_workarounds`, `install_initramdisk_requirements`, `perform_transaction_install`, `perform_transaction_check`, `perform_rpm_download`, `perform_dry_run`) and constants (`DNF_PLUGIN_NAME`, `DNF_PLUGIN_PATH`, `DNF_PLUGIN_DATA_NAME`, `DNF_PLUGIN_DATA_PATH`, `DNF_PLUGIN_DATA_LOG_PATH`, `DNF_DEBUG_DATA_PATH`) are available in the new location. ++ - **`leapp.libraries.common.module`** - Moved to `leapp.libraries.common.dnflibs.dnfmodule` (renamed for clarity). All public functions (`get_modules`, `get_enabled_modules`, `map_installed_rpms_to_modules`) are deprecated in the old location. ++ + + ## v0.24.0 (till September 2026) + - Shared libraries +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..d94e4b4c +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/__init__.py +@@ -0,0 +1,8 @@ ++""" ++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 ++""" +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..9f1902b6 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py +@@ -0,0 +1,108 @@ ++from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common.rpms import get_leapp_packages ++from leapp.libraries.stdlib import api, CalledProcessError ++ ++ ++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.') ++ ++ ++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. ++ """ ++ 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..d85e4659 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfmodule.py +@@ -0,0 +1,105 @@ ++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) ++ ++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 ++ ++ ++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] ++ ++ ++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)] ++ ++ ++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 +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..32407283 +--- /dev/null ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +@@ -0,0 +1,551 @@ ++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 ++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/' ++ ++ ++def install(target_basedir): ++ """ ++ Installs our plugin to the DNF plugins. ++ """ ++ try: ++ shutil.copy2( ++ api.get_file_path(DNF_PLUGIN_NAME), ++ os.path.join(target_basedir, DNF_PLUGIN_PATH.lstrip('/'))) ++ except EnvironmentError as e: ++ api.current_logger().debug('Failed to install DNF plugin', exc_info=True) ++ raise StopActorExecutionError( ++ message='Failed to install DNF plugin. Error: {}'.format(str(e)) ++ ) ++ ++ ++def _rebuild_rpm_db(context, root=None): ++ """ ++ Convert rpmdb from BerkeleyDB to Sqlite ++ """ ++ base_cmd = ['rpmdb', '--rebuilddb'] ++ cmd = base_cmd if not root else base_cmd + ['-r', root] ++ context.call(cmd) ++ ++ ++def 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 ++ ++ ++def create_config(context, target_repoids, debug, test, tasks, on_aws=False): ++ """ ++ Creates the configuration data file for our DNF plugin. ++ """ ++ context.makedirs(os.path.dirname(DNF_PLUGIN_DATA_PATH), exists_ok=True) ++ with context.open(DNF_PLUGIN_DATA_PATH, 'w+') as f: ++ config_data = build_plugin_data( ++ target_repoids=target_repoids, debug=debug, test=test, tasks=tasks, on_aws=on_aws ++ ) ++ json.dump(config_data, f, sort_keys=True, indent=2) ++ ++ ++def backup_config(context): ++ """ ++ Backs up the configuration data used for the plugin. ++ """ ++ context.copy_from(DNF_PLUGIN_DATA_PATH, DNF_PLUGIN_DATA_LOG_PATH) ++ ++ ++def backup_debug_data(context): ++ """ ++ Performs the backup of DNF debug data ++ """ ++ if config.is_debug(): ++ # The debugdata is a folder generated by dnf when using the --debugsolver dnf option. We switch on the ++ # debug_solver dnf config parameter in our rhel-upgrade dnf plugin when LEAPP_DEBUG env var set to 1. ++ try: ++ context.copytree_from('/debugdata', DNF_DEBUG_DATA_PATH) ++ except OSError as e: ++ api.current_logger().warning('Failed to copy debugdata. Message: {}'.format(str(e)), exc_info=True) ++ ++ ++def _handle_transaction_err_msg_old(stage, xfs_info, err): ++ # NOTE(pstodulk): This is going to be removed in future! ++ message = 'DNF execution failed with non zero exit code.' ++ details = {'STDOUT': err.stdout, 'STDERR': err.stderr} ++ ++ if 'more space needed on the' in err.stderr and stage != 'upgrade': ++ # Disk Requirements: ++ # At least 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 ++ ++ ++def apply_workarounds(context=None): ++ """ ++ Apply registered workarounds in the given context environment ++ """ ++ context = context or mounting.NotIsolatedActions(base_dir='/') ++ for workaround in api.consume(DNFWorkaround): ++ try: ++ api.show_message('Applying transaction workaround - {}'.format(workaround.display_name)) ++ if workaround.script_args: ++ cmd_str = '{script} {args}'.format( ++ script=workaround.script_path, ++ args=' '.join(workaround.script_args) ++ ) ++ else: ++ cmd_str = workaround.script_path ++ context.call(['/bin/bash', '-c', cmd_str]) ++ except (OSError, CalledProcessError) as e: ++ raise StopActorExecutionError( ++ message=('Failed to execute script to apply transaction workaround {display_name}.' ++ ' Message: {error}'.format(error=str(e), display_name=workaround.display_name)) ++ ) ++ ++ ++def install_initramdisk_requirements(packages, target_userspace_info, used_repos): ++ """ ++ Performs the installation of packages into the initram disk ++ """ ++ 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 ++ """ ++ ++ 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 ++ ++ ++def perform_transaction_check(target_userspace_info, ++ used_repos, ++ tasks, ++ xfs_info, ++ storage_info, ++ plugin_info, ++ target_iso=None): ++ """ ++ Perform DNF transaction check using our plugin ++ """ ++ ++ stage = 'check' ++ ++ with _prepare_perform(used_repos=used_repos, target_userspace_info=target_userspace_info, xfs_info=xfs_info, ++ storage_info=storage_info, target_iso=target_iso) as (context, overlay, target_repoids): ++ apply_workarounds(overlay.nspawn()) ++ ++ disable_plugins = [] ++ if plugin_info: ++ for info in plugin_info: ++ if stage in info.disable_in: ++ disable_plugins += [info.name] ++ ++ dnfconfig.exclude_leapp_rpms(context, disable_plugins) ++ _transaction( ++ context=context, stage='check', target_repoids=target_repoids, plugin_info=plugin_info, xfs_info=xfs_info, ++ tasks=tasks ++ ) ++ ++ ++def perform_rpm_download(target_userspace_info, ++ used_repos, ++ tasks, ++ xfs_info, ++ storage_info, ++ plugin_info, ++ target_iso=None, ++ on_aws=False): ++ """ ++ Perform RPM download including the transaction test using dnf with our plugin ++ """ ++ ++ stage = 'download' ++ ++ with _prepare_perform(used_repos=used_repos, ++ target_userspace_info=target_userspace_info, ++ xfs_info=xfs_info, ++ storage_info=storage_info, ++ target_iso=target_iso) as (context, overlay, target_repoids): ++ ++ disable_plugins = [] ++ if plugin_info: ++ for info in plugin_info: ++ if stage in info.disable_in: ++ disable_plugins += [info.name] ++ ++ apply_workarounds(overlay.nspawn()) ++ dnfconfig.exclude_leapp_rpms(context, disable_plugins) ++ _transaction( ++ context=context, stage='download', target_repoids=target_repoids, plugin_info=plugin_info, tasks=tasks, ++ test=True, on_aws=on_aws, xfs_info=xfs_info ++ ) ++ ++ ++def perform_dry_run(target_userspace_info, ++ used_repos, ++ tasks, ++ xfs_info, ++ storage_info, ++ plugin_info, ++ target_iso=None, ++ on_aws=False): ++ """ ++ Perform the dnf transaction test / dry-run using only cached data. ++ """ ++ with _prepare_perform(used_repos=used_repos, ++ target_userspace_info=target_userspace_info, ++ xfs_info=xfs_info, ++ storage_info=storage_info, ++ target_iso=target_iso) as (context, overlay, target_repoids): ++ apply_workarounds(overlay.nspawn()) ++ _transaction( ++ context=context, stage='dry-run', target_repoids=target_repoids, plugin_info=plugin_info, tasks=tasks, ++ test=True, on_aws=on_aws, xfs_info=xfs_info ++ ) +diff --git a/repos/system_upgrade/common/libraries/dnfplugin.py b/repos/system_upgrade/common/libraries/dnfplugin.py +index b91bcbe7..713cc34b 100644 +--- a/repos/system_upgrade/common/libraries/dnfplugin.py ++++ b/repos/system_upgrade/common/libraries/dnfplugin.py +@@ -1,468 +1,118 @@ +-import contextlib +-import itertools +-import json +-import os +-import re +-import shutil ++""" ++DEPRECATED: This module has been moved to leapp.libraries.common.dnflibs.dnfplugin + +-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 ++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 ++""" + +-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/' ++from leapp.libraries.common.dnflibs import dnfplugin as _dnfplugin ++from leapp.utils.deprecation import deprecated ++ ++# Re-export constants (not deprecated) ++DNF_PLUGIN_NAME = _dnfplugin.DNF_PLUGIN_NAME ++DNF_PLUGIN_PATH = _dnfplugin.DNF_PLUGIN_PATH ++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 +123,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 +143,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 +163,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/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() +-- +2.54.0 + diff --git a/0067-Update-imports-to-use-new-dnflibs-modules.patch b/0067-Update-imports-to-use-new-dnflibs-modules.patch new file mode 100644 index 0000000..ef0147b --- /dev/null +++ b/0067-Update-imports-to-use-new-dnflibs-modules.patch @@ -0,0 +1,256 @@ +From f60f6af718e6d8e1459022374be836b2850add03 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Tue, 10 Mar 2026 10:49:38 +0000 +Subject: [PATCH 067/108] Update imports to use new dnflibs modules + +Update all actors and tests to import DNF libraries from the new +dnflibs package location instead of the deprecated root paths. + +Changes: +- Update dnfplugin imports to use dnflibs.dnfplugin +- Update module imports to use dnflibs.dnfmodule +- Affects 9 actors and 3 test files + +This completes the migration to the new library structure, ensuring +all internal code uses the new import paths while maintaining backward +compatibility through deprecation shims. + +Assisted-by: Claude Sonnet 4.5 +Signed-off-by: Petr Stodulka +--- + docs/source/libraries-and-api/deprecations-list.md | 7 +++---- + .../common/actors/applytransactionworkarounds/actor.py | 2 +- + .../tests/unit_test_applytransactionworkarounds.py | 2 +- + repos/system_upgrade/common/actors/dnfdryrun/actor.py | 2 +- + .../common/actors/dnfpackagedownload/actor.py | 2 +- + .../common/actors/dnftransactioncheck/actor.py | 2 +- + .../common/actors/dnfupgradetransaction/actor.py | 2 +- + .../common/actors/getenabledmodules/actor.py | 2 +- + .../libraries/upgradeinitramfsgenerator.py | 3 ++- + .../common/actors/rpmscanner/libraries/rpmscanner.py | 4 ++-- + .../common/actors/rpmscanner/tests/test_rpmscanner.py | 10 +++++----- + .../targetuserspacecreator/libraries/userspacegen.py | 3 ++- + .../common/libraries/tests/test_dnfplugin.py | 2 +- + 13 files changed, 22 insertions(+), 21 deletions(-) + +diff --git a/docs/source/libraries-and-api/deprecations-list.md b/docs/source/libraries-and-api/deprecations-list.md +index aa20f874..301de558 100644 +--- a/docs/source/libraries-and-api/deprecations-list.md ++++ b/docs/source/libraries-and-api/deprecations-list.md +@@ -19,10 +19,9 @@ Only the versions in which a deprecation has been made are listed. + - 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 +- - DNF-related libraries have been reorganized into the `leapp.libraries.common.dnflibs` package. The following modules have been moved: +- - **`leapp.libraries.common.dnfconfig`** - Moved to `leapp.libraries.common.dnflibs.dnfconfig`. All public functions (`exclude_leapp_rpms`) are deprecated in the old location. +- - **`leapp.libraries.common.dnfplugin`** - Moved to `leapp.libraries.common.dnflibs.dnfplugin`. All public functions (`install`, `build_plugin_data`, `create_config`, `backup_config`, `backup_debug_data`, `apply_workarounds`, `install_initramdisk_requirements`, `perform_transaction_install`, `perform_transaction_check`, `perform_rpm_download`, `perform_dry_run`) and constants (`DNF_PLUGIN_NAME`, `DNF_PLUGIN_PATH`, `DNF_PLUGIN_DATA_NAME`, `DNF_PLUGIN_DATA_PATH`, `DNF_PLUGIN_DATA_LOG_PATH`, `DNF_DEBUG_DATA_PATH`) are available in the new location. +- - **`leapp.libraries.common.module`** - Moved to `leapp.libraries.common.dnflibs.dnfmodule` (renamed for clarity). All public functions (`get_modules`, `get_enabled_modules`, `map_installed_rpms_to_modules`) are deprecated in the old location. ++ - **`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) +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/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/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/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py +index 1a0dccd8..aec1debf 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 +diff --git a/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py b/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py +index 2a2ee03e..ebf17e19 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 + +@@ -100,7 +100,7 @@ def map_modular_rpms_to_modules(): + """ + Map modular packages to the module streams they come from. + """ +- 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/targetuserspacecreator/libraries/userspacegen.py b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py +index 2475aad4..a776deac 100644 +--- a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py ++++ b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py +@@ -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 +diff --git a/repos/system_upgrade/common/libraries/tests/test_dnfplugin.py b/repos/system_upgrade/common/libraries/tests/test_dnfplugin.py +index 1ca95945..2f220785 100644 +--- a/repos/system_upgrade/common/libraries/tests/test_dnfplugin.py ++++ b/repos/system_upgrade/common/libraries/tests/test_dnfplugin.py +@@ -3,8 +3,8 @@ 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.dnflibs import dnfplugin + from leapp.libraries.common.testutils import CurrentActorMocked + from leapp.libraries.stdlib import api + from leapp.models.fields import Boolean +-- +2.54.0 + diff --git a/0068-dnflibs-introduce-DNFError-exception.patch b/0068-dnflibs-introduce-DNFError-exception.patch new file mode 100644 index 0000000..4e2c836 --- /dev/null +++ b/0068-dnflibs-introduce-DNFError-exception.patch @@ -0,0 +1,35 @@ +From 1b6beff15bf6e5c23feb74ab5bc16ea4c526597b Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Mon, 4 May 2026 14:46:05 +0200 +Subject: [PATCH 068/108] dnflibs: introduce DNFError exception + +This is generic exception used for all DNF related errors raised +in dnflibs libraries. IOW, all exceptions introduced in these libraries +are based on DNFError, so if wanted, it's possible to catch a DNFError +exception in calling functions instead of listing each possible DNF +exception separately. + +Signed-off-by: Petr Stodulka +--- + repos/system_upgrade/common/libraries/dnflibs/__init__.py | 8 ++++++++ + 1 file changed, 8 insertions(+) + +diff --git a/repos/system_upgrade/common/libraries/dnflibs/__init__.py b/repos/system_upgrade/common/libraries/dnflibs/__init__.py +index d94e4b4c..1aef3295 100644 +--- a/repos/system_upgrade/common/libraries/dnflibs/__init__.py ++++ b/repos/system_upgrade/common/libraries/dnflibs/__init__.py +@@ -6,3 +6,11 @@ This package consolidates DNF functionality previously scattered across: + - leapp.libraries.common.dnfplugin -> dnflibs.dnfplugin + - leapp.libraries.common.module -> dnflibs.dnfmodule + """ ++ ++from leapp.exceptions import StopActorExecutionError ++ ++ ++class DNFError(StopActorExecutionError): ++ """ ++ Generic exception inherited by all DNF errors raised in dnflibs libraries. ++ """ +-- +2.54.0 + diff --git a/0069-dnflibs.dnfplugin-Drop-dead-code-_handle_transaction.patch b/0069-dnflibs.dnfplugin-Drop-dead-code-_handle_transaction.patch new file mode 100644 index 0000000..3621c9a --- /dev/null +++ b/0069-dnflibs.dnfplugin-Drop-dead-code-_handle_transaction.patch @@ -0,0 +1,48 @@ +From 7d576c6d427d748e43c71e9ee031f556810fbb36 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Tue, 10 Mar 2026 16:11:47 +0100 +Subject: [PATCH 069/108] dnflibs.dnfplugin: Drop dead code: + _handle_transaction_err_msg_old + +This function is not used anywhere anymore. Let's just remove it. + +Signed-off-by: Petr Stodulka +--- + .../common/libraries/dnflibs/dnfplugin.py | 21 ------------------- + 1 file changed, 21 deletions(-) + +diff --git a/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +index 32407283..b0e10054 100644 +--- a/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +@@ -145,27 +145,6 @@ def backup_debug_data(context): + 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: +-- +2.54.0 + diff --git a/0070-dnflibs.dnfplugin-Drop-deprecated-DNF_PLUGIN_PATH.patch b/0070-dnflibs.dnfplugin-Drop-deprecated-DNF_PLUGIN_PATH.patch new file mode 100644 index 0000000..01f7483 --- /dev/null +++ b/0070-dnflibs.dnfplugin-Drop-deprecated-DNF_PLUGIN_PATH.patch @@ -0,0 +1,111 @@ +From e84fe5ebffc05e5f44d431ff770c0910131a6343 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Tue, 10 Mar 2026 18:24:46 +0100 +Subject: [PATCH 070/108] dnflibs.dnfplugin: Drop deprecated DNF_PLUGIN_PATH + +This has been deprecated for a long time - dropping it finally to +clean the code a little bit after the moving to a new path. + +Signed-off-by: Petr Stodulka +--- + .../common/libraries/dnflibs/dnfplugin.py | 57 +++++++------------ + .../common/libraries/dnfplugin.py | 3 +- + 2 files changed, 21 insertions(+), 39 deletions(-) + +diff --git a/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +index b0e10054..43179138 100644 +--- a/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +@@ -14,54 +14,37 @@ 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/' + ++_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), ++} ++ ++ ++ ++ ++ ++ ++ ++ + + def install(target_basedir): + """ + Installs our plugin to the DNF plugins. + """ ++ # 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), +- os.path.join(target_basedir, DNF_PLUGIN_PATH.lstrip('/'))) ++ 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 StopActorExecutionError( +diff --git a/repos/system_upgrade/common/libraries/dnfplugin.py b/repos/system_upgrade/common/libraries/dnfplugin.py +index 713cc34b..19e4d4e2 100644 +--- a/repos/system_upgrade/common/libraries/dnfplugin.py ++++ b/repos/system_upgrade/common/libraries/dnfplugin.py +@@ -10,9 +10,8 @@ This shim will be removed in a future version. Please update imports to: + from leapp.libraries.common.dnflibs import dnfplugin as _dnfplugin + from leapp.utils.deprecation import deprecated + +-# Re-export constants (not deprecated) ++# Re-export constants + DNF_PLUGIN_NAME = _dnfplugin.DNF_PLUGIN_NAME +-DNF_PLUGIN_PATH = _dnfplugin.DNF_PLUGIN_PATH + 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 +-- +2.54.0 + diff --git a/0071-dnflibs.dnfplugin-Introduce-new-DNF-exceptions-and-t.patch b/0071-dnflibs.dnfplugin-Introduce-new-DNF-exceptions-and-t.patch new file mode 100644 index 0000000..a4fc91e --- /dev/null +++ b/0071-dnflibs.dnfplugin-Introduce-new-DNF-exceptions-and-t.patch @@ -0,0 +1,903 @@ +From e5f867a809db5ae9e671cb4a5c02eb2b88f3bf39 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Tue, 10 Mar 2026 18:27:18 +0100 +Subject: [PATCH 071/108] dnflibs.dnfplugin: Introduce new DNF exceptions and + tests + +Originally the library raised usually StopActorExecutionError exception +in case of issues. But as we agreed recently, we want to start to +raise rather proper exceptions in libraries and handle this better +in callers (actors). + +For the comfort (and sake of safety) the newly created exception +classes are based on the DNFError class, which is based on StopActorExecutionError, +so these exceptions will be properly handled also if they are not caught +correctly in actors. Also, in case of this library we kind of know +that we are the only callers of these functions as they are fundamental +for the upgrade process and majority of them can be executed just once +per the upgrade process. + +Docstrings in public functions have been properly updated to reflect +the change. This includes also docstrings of other public functions +in the library. + +List of new exceptions: + * DNFPluginInstallError + * DNFExecutionError + * DNFUpgradeTransactionError + * RegisteredWorkaroundApplicationError + +Assisted-by: Claude Sonnet 4.5 +Signed-off-by: Petr Stodulka +--- + .../common/libraries/dnflibs/dnfplugin.py | 194 +++++++++++- + .../libraries/dnflibs/tests/test_dnfplugin.py | 294 ++++++++++++++++++ + .../common/libraries/tests/test_dnfplugin.py | 193 ------------ + 3 files changed, 475 insertions(+), 206 deletions(-) + create mode 100644 repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfplugin.py + delete mode 100644 repos/system_upgrade/common/libraries/tests/test_dnfplugin.py + +diff --git a/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +index 43179138..cb810a2a 100644 +--- a/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfplugin.py +@@ -8,7 +8,7 @@ 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 ++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 +@@ -26,16 +26,43 @@ _DNF_PLUGIN_PATHS = { + } + + ++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 our plugin to the DNF plugins. ++ 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. +@@ -47,7 +74,7 @@ def install(target_basedir): + 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 StopActorExecutionError( ++ raise DNFPluginInstallError( + message='Failed to install DNF plugin. Error: {}'.format(str(e)) + ) + +@@ -55,6 +82,11 @@ def install(target_basedir): + 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] +@@ -64,6 +96,19 @@ def _rebuild_rpm_db(context, root=None): + 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 = { +@@ -99,6 +144,19 @@ def build_plugin_data(target_repoids, debug, test, tasks, on_aws): + 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: +@@ -111,6 +169,9 @@ def create_config(context, target_repoids, debug, test, tasks, on_aws=False): + 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) + +@@ -118,6 +179,9 @@ def backup_config(context): + 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 +@@ -155,7 +219,7 @@ def _handle_transaction_err_msg(err, is_container=False): + "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) ++ raise DNFUpgradeTransactionError(message=message, details=details) + + # Disk Requirements: + # At least more space needed on the filesystem. +@@ -189,7 +253,7 @@ def _handle_transaction_err_msg(err, is_container=False): + ) + details = {'hint': hint, 'Disk Requirements': '\n'.join(missing_space)} + +- raise StopActorExecutionError(message=message, details=details) ++ raise DNFUpgradeTransactionError(message=message, details=details) + + + def _transaction(context, stage, target_repoids, tasks, plugin_info, xfs_info, +@@ -198,7 +262,6 @@ def _transaction(context, stage, target_repoids, tasks, plugin_info, xfs_info, + 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, +@@ -259,7 +322,7 @@ def _transaction(context, stage, target_repoids, tasks, plugin_info, xfs_info, + ) + except OSError as e: + api.current_logger().error('Could not call dnf command: Message: %s', str(e), exc_info=True) +- raise StopActorExecutionError( ++ raise DNFExecutionError( + message='Failed to execute dnf. Reason: {}'.format(str(e)) + ) + except CalledProcessError as e: +@@ -283,8 +346,17 @@ def _prepare_transaction(used_repos, target_userspace_info, binds=()): + 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)) +@@ -297,15 +369,28 @@ def apply_workarounds(context=None): + 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)) ++ 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, +@@ -346,6 +431,25 @@ def install_initramdisk_requirements(packages, target_userspace_info, used_repos + 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' +@@ -398,7 +502,7 @@ def perform_transaction_install(target_userspace_info, storage_info, used_repos, + 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, ++ context=context, stage=stage, target_repoids=target_repoids, plugin_info=plugin_info, + xfs_info=xfs_info, tasks=tasks, cmd_prefix=cmd_prefix + ) + +@@ -435,6 +539,27 @@ def perform_transaction_check(target_userspace_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' +@@ -451,7 +576,7 @@ def perform_transaction_check(target_userspace_info, + + dnfconfig.exclude_leapp_rpms(context, disable_plugins) + _transaction( +- context=context, stage='check', target_repoids=target_repoids, plugin_info=plugin_info, xfs_info=xfs_info, ++ context=context, stage=stage, target_repoids=target_repoids, plugin_info=plugin_info, xfs_info=xfs_info, + tasks=tasks + ) + +@@ -466,6 +591,29 @@ def perform_rpm_download(target_userspace_info, + 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' +@@ -485,7 +633,7 @@ def perform_rpm_download(target_userspace_info, + 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, ++ context=context, stage=stage, target_repoids=target_repoids, plugin_info=plugin_info, tasks=tasks, + test=True, on_aws=on_aws, xfs_info=xfs_info + ) + +@@ -500,6 +648,26 @@ def perform_dry_run(target_userspace_info, + 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, +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/tests/test_dnfplugin.py b/repos/system_upgrade/common/libraries/tests/test_dnfplugin.py +deleted file mode 100644 +index 2f220785..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.config.version import get_major_version +-from leapp.libraries.common.dnflibs import dnfplugin +-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 +- ), +- ), +- ) +- ) +- ) +-- +2.54.0 + diff --git a/0072-dnflibs-dnfmodule-Move-init-of-dnf.Base-to-dnflibs-a.patch b/0072-dnflibs-dnfmodule-Move-init-of-dnf.Base-to-dnflibs-a.patch new file mode 100644 index 0000000..6fe464b --- /dev/null +++ b/0072-dnflibs-dnfmodule-Move-init-of-dnf.Base-to-dnflibs-a.patch @@ -0,0 +1,637 @@ +From a48283cb02eeca31847b5501698d259979efc78b Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Wed, 11 Mar 2026 18:42:48 +0100 +Subject: [PATCH 072/108] dnflibs/dnfmodule: Move init of dnf.Base to dnflibs + and add tests + +Introduce `create__dnf_base()` function in the `dnflibs` package. +The function replaces original private function +`dnflibs.dnfmodule._create_or_get_dnf_base`. + +The correct creation and initialisation of the dnf.Base object is +non-trivial. To cover the initialisation correctly for various setups +(including also systems using RHUI which requires additional transparent +DNF plugin) we make this function public so it can be used in actors. + +The sack in dnf.Base object is filled and prepared for use. +The function can raise `DNFRepoError` exception, based on +`DNFError`, if a repository cannot be loaded properly, +providing additional details about the problem. + +NOTE that included tests provides just trivial coverate as there +is not a good way to cover this library by unit-tests without full +mock of DNF and hawkey libraries - if we want to keep test independent +on systems where they are executed. Keeping the valid testing on CI +pipelines with integration tests. + +Assisted-by: Claude Sonnet 4.5 +Signed-off-by: Petr Stodulka +--- + .../common/libraries/dnflibs/__init__.py | 67 +++++ + .../common/libraries/dnflibs/dnfmodule.py | 71 ++--- + .../libraries/dnflibs/tests/test_dnflibs.py | 118 ++++++++ + .../libraries/dnflibs/tests/test_dnfmodule.py | 270 ++++++++++++++++++ + 4 files changed, 483 insertions(+), 43 deletions(-) + create mode 100644 repos/system_upgrade/common/libraries/dnflibs/tests/test_dnflibs.py + create mode 100644 repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfmodule.py + +diff --git a/repos/system_upgrade/common/libraries/dnflibs/__init__.py b/repos/system_upgrade/common/libraries/dnflibs/__init__.py +index 1aef3295..787119a7 100644 +--- a/repos/system_upgrade/common/libraries/dnflibs/__init__.py ++++ b/repos/system_upgrade/common/libraries/dnflibs/__init__.py +@@ -7,10 +7,77 @@ This package consolidates DNF functionality previously scattered across: + - 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/dnfmodule.py b/repos/system_upgrade/common/libraries/dnflibs/dnfmodule.py +index d85e4659..b274d917 100644 +--- a/repos/system_upgrade/common/libraries/dnflibs/dnfmodule.py ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfmodule.py +@@ -1,7 +1,6 @@ + import warnings + +-from leapp.exceptions import StopActorExecutionError +-from leapp.libraries.common.config.version import get_source_major_version ++from leapp.libraries.common.dnflibs import create_dnf_base + + try: + import dnf +@@ -16,55 +15,26 @@ except ImportError: + 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 +- +- + 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 [] +- base = _create_or_get_dnf_base(base) ++ 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] +@@ -73,11 +43,20 @@ def get_modules(base=None): + 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_or_get_dnf_base() ++ base = create_dnf_base() + modules = get_modules(base) + + # if modules are not supported (RHEL 7), base.sack._moduleContainer won't exist +@@ -88,6 +67,12 @@ def get_enabled_modules(): + 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 +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 == {} +-- +2.54.0 + diff --git a/0073-dnflibs.dnfconfig-Raise-proper-exceptions-and-add-te.patch b/0073-dnflibs.dnfconfig-Raise-proper-exceptions-and-add-te.patch new file mode 100644 index 0000000..2782327 --- /dev/null +++ b/0073-dnflibs.dnfconfig-Raise-proper-exceptions-and-add-te.patch @@ -0,0 +1,363 @@ +From bcc445b165cfd63ae659f699209fff10e6aa8c42 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Fri, 24 Apr 2026 15:52:07 +0200 +Subject: [PATCH 073/108] dnflibs.dnfconfig: Raise proper exceptions and add + tests + +Originally the library raised StopActorExecutionError or +CalledProcessError exceptions in case of problems. This change +introduces new exceptions to make clear what problem actually happens. +New exceptions are based on DNFError (which is based on +StopActorExecutionError, to ensure that errors are caught and handled +properly). + +List of new exceptions: + * InvalidDNFConfig + * CannotObtainDNFConfig + * CannotUpdateDNFConfig (replacing CalledProcessError) + +Assisted-by: Claude Sonnet 4.5 +Signed-off-by: Petr Stodulka +--- + .../common/libraries/dnflibs/dnfconfig.py | 46 +++- + .../libraries/dnflibs/tests/test_dnfconfig.py | 228 ++++++++++++++++++ + 2 files changed, 266 insertions(+), 8 deletions(-) + create mode 100644 repos/system_upgrade/common/libraries/dnflibs/tests/test_dnfconfig.py + +diff --git a/repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py b/repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py +index 9f1902b6..8872e592 100644 +--- a/repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py ++++ b/repos/system_upgrade/common/libraries/dnflibs/dnfconfig.py +@@ -1,8 +1,26 @@ +-from leapp.exceptions import StopActorExecutionError ++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 +@@ -14,7 +32,7 @@ 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 ++ :rtype: dict + """ + + cmd = ['dnf', 'config-manager', '--dump'] +@@ -27,7 +45,7 @@ def _get_main_dump(context, disable_plugins): + data = context.call(cmd, split=True)['stdout'] + except CalledProcessError as e: + api.current_logger().error('Cannot obtain the dnf configuration') +- raise StopActorExecutionError( ++ raise CannotObtainDNFConfig( + message='Cannot obtain data about the DNF configuration', + details={'stdout': e.stdout, 'stderr': e.stderr} + ) +@@ -36,7 +54,7 @@ def _get_main_dump(context, disable_plugins): + # return index of the first item in the main section + main_start = data.index('[main]') + 1 + except ValueError: +- raise StopActorExecutionError( ++ raise InvalidDNFConfig( + message='Invalid DNF configuration data (missing [main])', + details=data, + ) +@@ -76,7 +94,7 @@ def _set_excluded_pkgs(context, pkglist, disable_plugins): + """ + Configure DNF to exclude packages in the given list + +- Raise the CalledProcessError on error. ++ :raises CannotUpdateDNFConfig: When an attempt to update DNF configuration fails + """ + exclude = 'exclude={}'.format(','.join(pkglist)) + cmd = ['dnf', 'config-manager', '--save', '--setopt', exclude] +@@ -87,9 +105,12 @@ def _set_excluded_pkgs(context, pkglist, disable_plugins): + + try: + context.call(cmd) +- except CalledProcessError: +- api.current_logger().error('Cannot set the dnf configuration') +- raise ++ 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.') + + +@@ -103,6 +124,15 @@ def exclude_leapp_rpms(context, disable_plugins): + - 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/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 +-- +2.54.0 + diff --git a/0074-DOCSTRINGS-Document-DNF-related-exception-propagatio.patch b/0074-DOCSTRINGS-Document-DNF-related-exception-propagatio.patch new file mode 100644 index 0000000..03c706d --- /dev/null +++ b/0074-DOCSTRINGS-Document-DNF-related-exception-propagatio.patch @@ -0,0 +1,57 @@ +From cf1a6cf5d890573a888a067196bc69fec3b23d4a Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Mon, 27 Apr 2026 20:45:31 +0000 +Subject: [PATCH 074/108] DOCSTRINGS: Document DNF related exception + propagation + +Update docstrings in actors that use dnflibs libraries to make +make clear what exceptions can be potentially raised: + * rpmscanner + * upgradeinitramfsgenerator + +Assisted-by: Claude Sonnet 4.5 +Signed-off-by: Petr Stodulka +--- + .../libraries/upgradeinitramfsgenerator.py | 8 ++++++++ + .../common/actors/rpmscanner/libraries/rpmscanner.py | 6 ++++++ + 2 files changed, 14 insertions(+) + +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 aec1debf..24f35847 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py +@@ -169,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/rpmscanner/libraries/rpmscanner.py b/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py +index ebf17e19..100fb32c 100644 +--- a/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py ++++ b/repos/system_upgrade/common/actors/rpmscanner/libraries/rpmscanner.py +@@ -99,6 +99,12 @@ 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 = dnfmodule.get_modules() + # empty on RHEL 7 because of no modules +-- +2.54.0 + diff --git a/0075-pulseaudiocheck-fix-for-users-with-None-home-directo.patch b/0075-pulseaudiocheck-fix-for-users-with-None-home-directo.patch new file mode 100644 index 0000000..41c1667 --- /dev/null +++ b/0075-pulseaudiocheck-fix-for-users-with-None-home-directo.patch @@ -0,0 +1,87 @@ +From 545f15a053b6dc588f41a5326964a9423d4a7857 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Jozef=20Ba=C4=8D=C3=ADk?= +Date: Mon, 11 May 2026 12:21:31 +0200 +Subject: [PATCH 075/108] pulseaudiocheck: fix for users with "None" home + directory + +If the user has single entry in /etc/passwd without any colons, the reported +pw_dir is NoneType. This makes the os.path.join() crash as NoneType is +unexpected. +While technically, entry without the colons is considered invalid and not POSIX +compliant, it has been seen that some customers are using such entries and leapp +is already taking this possibility into account. +This commit modifies the behaviour of the scanpulseaudio actor, so such entries +are logged and skipped. + +Co-authored-by: Matej Matuska +--- + .../scanpulseaudio/libraries/scanpulseaudio.py | 4 ++++ + .../tests/test_scanpulseaudio.py | 18 ++++++++++++++---- + 2 files changed, 18 insertions(+), 4 deletions(-) + +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: +-- +2.54.0 + diff --git a/0076-fix-target_initramfs-always-regenerate-initramfs-153.patch b/0076-fix-target_initramfs-always-regenerate-initramfs-153.patch new file mode 100644 index 0000000..c79c69a --- /dev/null +++ b/0076-fix-target_initramfs-always-regenerate-initramfs-153.patch @@ -0,0 +1,68 @@ +From 03e67c0d42914b40cac1bff390bd27b9c7b91d04 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Michal=20He=C4=8Dko?= +Date: Wed, 13 May 2026 11:51:48 +0200 +Subject: [PATCH 076/108] fix(target_initramfs): always regenerate initramfs + (#1536) + +Remove conditions that would skip target initramfs regeneration if there +are no dracut or kernel modules to be included. Instead, regenerate the +target initramfs always, as there might be new configs written by us +after the upgrade RPM transaction installs the target kernel and +generates a corresponding initramfs. The initramfs generated during RPM +transaction, therefore lacks or contains invalid config files. + +Jira: RHEL-151509 +--- + .../libraries/targetinitramfsgenerator.py | 12 +++++++----- + .../tests/test_targetinitramfsgenerator.py | 5 ++--- + 2 files changed, 9 insertions(+), 8 deletions(-) + +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 = [ +-- +2.54.0 + diff --git a/0077-Add-AI-assistant-harness-with-AGENTS.md-and-task-ski.patch b/0077-Add-AI-assistant-harness-with-AGENTS.md-and-task-ski.patch new file mode 100644 index 0000000..6d5722d --- /dev/null +++ b/0077-Add-AI-assistant-harness-with-AGENTS.md-and-task-ski.patch @@ -0,0 +1,393 @@ +From af876b2deb797606089ed309c551b674e2ba0345 Mon Sep 17 00:00:00 2001 +From: karolinku +Date: Mon, 11 May 2026 09:56:27 +0200 +Subject: [PATCH 077/108] Add AI assistant harness with AGENTS.md and task + skills + +Introduce an AI assistant harness to guide agents and human +developers when working with coding tools with this repo. +AGENTS.md serves as a compact always-loaded entrypoint with project +boundaries, architecture, phases, general rules and a definition of +done. Detailed procedure guidance is split into skill files to +minimize token consumption. +Skills added: +- leapp-test-runner: container-based testing, linting, failure triage +- leapp-unit-tests: test patterns, testutils API, mocking conventions +- leapp-actor-dev: actor structure, phase selection, placement rules +--- + AGENTS.md | 81 ++++++++++++++++++++++++++ + skills/leapp-actor-dev/SKILL.md | 95 +++++++++++++++++++++++++++++++ + skills/leapp-test-runner/SKILL.md | 68 ++++++++++++++++++++++ + skills/leapp-unit-tests/SKILL.md | 94 ++++++++++++++++++++++++++++++ + 4 files changed, 338 insertions(+) + create mode 100644 AGENTS.md + create mode 100644 skills/leapp-actor-dev/SKILL.md + create mode 100644 skills/leapp-test-runner/SKILL.md + create mode 100644 skills/leapp-unit-tests/SKILL.md + +diff --git a/AGENTS.md b/AGENTS.md +new file mode 100644 +index 00000000..5de224bc +--- /dev/null ++++ b/AGENTS.md +@@ -0,0 +1,81 @@ ++# AGENTS.md ++ ++## Project objective ++ ++[Leapp-repository](https://github.com/oamg/leapp-repository) contains the actors, models, and libraries for RHEL in-place upgrades (IPU). It runs on top of the [Leapp framework](https://github.com/oamg/leapp), which provides the actor runtime, workflow execution, messaging, and core CLI behavior. It's important to notice, that framework-level mechanics belong to leapp, while upgrade-path logic belong to leapp-repository. ++ ++## Architecture ++ ++Leapp IPU content is actor-based and message-driven: ++- Actors exchange typed messages (`consumes` / `produces`) through workflow phases. ++- Messages only flow forward: a producer must run before its consumer. Within the same phase, the framework resolves ordering automatically. ++- Prefer scanner/checker design: collect facts in one actor, evaluate in another. ++- Keep actor `process()` thin; place implementation logic in actor libraries for testability. ++ ++### Phase workflow (always apply) ++- `FactsCollectionPhase`: collect system facts and produce messages ++- `ChecksPhase`: consume previously collected facts, report/inhibit compatibility issues ++- If modification is required, do it in later phases (`ApplicationsPhase` or `ThirdPartyApplicationsPhase`). ++- `FinalPhase`: to perform actions after the upgraded system is booted. ++ ++For full phase details if ambiguous : ++- [Phases of the Upgrade Workflow](https://leapp-repository.readthedocs.io/latest/upgrade-architecture-and-workflow/phases-overview.html#phases-overview) ++ ++### Repository Layout ++ ++``` ++├── system_upgrade/ ++ ├── common/ # common repository/content used and accessible for all system upgrades ++ │ ├── actors// ++ │ │ ├── actor.py # Actor class definition ++ │ │ ├── libraries/ # Actor-private logic ++ │ │ └── tests/ # Actor tests (unit_test_*.py, test_*.py) ++ │ ├── models/ # Data models (consumed/produced by actors) ++ │ ├── topics/ # Message topics ++ │ └── libraries/ # Shared libraries (importable via leapp.libraries.common) ++ ├── el8toel9/ # content specific only for RHEL 8 -> 9 upgrades ++ └── el9toel10/ # content specific only for RHEL 9 -> 10 upgrades ++``` ++ ++ ++## General rules (always apply) ++ ++- Follow leapp specific guidelines: docs/source/contributing/coding-guidelines.md and project conventions first. ++- Follow Python coding guidelines: https://leapp.readthedocs.io/en/stable/contributing.html ++- Analyze and plan before introducing code. ++- Ask, don't assume. Be verbose when user input is unclear or can be interpreted in many ways. ++- Explain your decisions briefly; expand detail only when requested. ++- If multiple approaches or solution exists, pick simpler one. ++- Be ready to undo your changes. ++- Before introducing new code, check if it's not already solved in codebase. Avoid code duplication. ++- Try to write as simple code as possible. New code should easy to read and understand even for non-experienced developers. ++- Don't change code, which is not directly involved in given task. Don't try to improve unrelated code. ++- Don't perform git commit, git push, or create PRs without explicit user request. ++ ++ ++ ++## Common Commands ++ ++Quick reference (details in skills below): ++ ++```bash ++make lint # pylint + flake8 + isort ++TEST_CONTAINER=el9 make test_container # full CI-equivalent test ++ACTOR= make dev_test_no_lint # single-actor fast test ++``` ++ ++## Skills ++ ++Load the relevant skill for the task at hand: ++ ++- **Run tests / lint** → `skills/leapp-test-runner/` ++- **Write or fix unit tests** → `skills/leapp-unit-tests/` ++- **Develop or modify actors** → `skills/leapp-actor-dev/` ++ ++ ++## Definition of Done ++ ++- Scope is clear and limited to the requested change. ++- New or changed code is covered by unit tests. ++- Relevant checks were run (`make lint` and/or targeted tests) and results are reported. ++- Risks, assumptions, and follow-up work are explicitly called out +diff --git a/skills/leapp-actor-dev/SKILL.md b/skills/leapp-actor-dev/SKILL.md +new file mode 100644 +index 00000000..124f3e99 +--- /dev/null ++++ b/skills/leapp-actor-dev/SKILL.md +@@ -0,0 +1,95 @@ ++# Developing Leapp Actors ++ ++## Actor structure ++ ++Every actor follows layout: ++ ++``` ++repos/system_upgrade//actors// ++├── actor.py # Actor class (thin wrapper) ++├── libraries/ ++│ └── .py # Implementation logic ++└── tests/ ++ └── test_.py ++``` ++ ++Keep `actor.py` minimal — delegate the real implementation to the library for testability: ++ ++```python ++from leapp.actors import Actor ++from leapp.libraries.actor import ++from leapp.models import , ++from leapp.tags import , IPUWorkflowTag ++ ++ ++class MyActor(Actor): ++ """Brief description of what this actor does.""" ++ ++ name = '' ++ consumes = (,) ++ produces = (,) ++ tags = (IPUWorkflowTag, ) ++ ++ def process(self): ++ .process() ++``` ++ ++## Phase selection ++ ++| Intent | Phase tag | Rules | ++|--------|-----------|-------| ++| Collect system facts | `FactsPhaseTag` | Produce messages only, no decisions | ++| Check compatibility / inhibit | `ChecksPhaseTag` | Consume facts, no direct system interaction | ++| Modify application config | `ApplicationsPhaseTag` | Only after RPM upgrade is complete | ++| Modify third-party config | `ThirdPartyApplicationsPhaseTag` | Same as above, for non-Red Hat apps | ++ ++### Common multi-actor patterns ++ ++**Inhibiting an incompatible system** (2 actors): ++1. Scanner in `FactsCollectionPhase` — produces a facts message. ++2. Checker in `ChecksPhase` — consumes facts, creates report / inhibits. ++ ++**Modifying incompatible config** (3 actors): ++1. Scanner in `FactsCollectionPhase` — collects info. ++2. Checker in `ChecksPhase` — decides and reports planned change. ++3. Modifier in `ApplicationsPhase` — performs the change. ++ ++## Placement rules ++ ++- Reusable across upgrade paths → `repos/system_upgrade/common/` ++- Specific to one path → `repos/system_upgrade/el8toel9/` or `el9toel10/` ++- Same rule applies to models and shared libraries. ++ ++ ++## Before writing new code ++ ++- Search existing actors/models/libraries — reuse before creating. ++- Check if a model already provides the message you need (search model class names in `repos/`). ++- Discover `repos/system_upgrade/common/libraries/` for shared utilities. ++- Prefer using stdlib functions over shell commands. ++- If introduce envars, use LEAPP and LEAPP_DEVEL. ++- Write unit-testable code (see `skills/leapp-unit-tests/`). ++ ++ ++## Key constraints ++ ++- Avoid running code on a module level to avoid slow downs during this load phase ++- Never use `subprocess` — use `leapp.libraries.stdlib.run()` instead. ++- Never interact with the system during `ChecksPhase`. ++- Do not introduce new dependencies, unless it's strongly justified. ++ ++ ++## Python compatibility ++ ++| Repository | Required Python versions | ++|------------|--------------------------| ++| `common` | 3.6, 3.9, 3.12 | ++| `el8toel9` | 3.6, 3.9 | ++| `el9toel10`| 3.9, 3.12 | ++ ++## Reference ++ ++- [How to write an Actor for Leapp Upgrade](https://leapp-repository.readthedocs.io/latest/tutorials/howto-first-actor-upgrade.html) ++- [Phases of the Upgrade Workflow](https://leapp-repository.readthedocs.io/latest/upgrade-architecture-and-workflow/phases-overview.html) ++- [Coding guidelines](https://leapp-repository.readthedocs.io/latest/contributing/coding-guidelines.html) ++- [Best Practices for actor development](https://leapp.readthedocs.io/en/stable/best-practices.html) +diff --git a/skills/leapp-test-runner/SKILL.md b/skills/leapp-test-runner/SKILL.md +new file mode 100644 +index 00000000..579aeb5d +--- /dev/null ++++ b/skills/leapp-test-runner/SKILL.md +@@ -0,0 +1,68 @@ ++# Running Tests and Linters ++ ++All non-container commands (`make lint`, `make test_no_lint`, `make fast_lint`, `make dev_test_no_lint`) require a local virtualenv and `REPOSITORIES` envar (e.g. `REPOSITORIES=common,el9toel10`). Create the virtualenv with `make install-deps` (RHEL/CentOS) or `make install-deps-fedora` (Fedora) — the Makefile handles activation internally, don't source the venv manually. Set `PYTHON_VENV=pythonX.X` to choose the Python version (default: `python3.6`). Container commands are self-contained and recommended as the default. ++ ++## Container-based testing (CI-equivalent, no venv needed) ++ ++Three container options, each matching a target Python version: ++ ++| Container | Python | Used for | ++|-----------|--------|----------| ++| `el8` | 3.6 | `common`, `el8toel9` | ++| `el9` | 3.9 | `common`, `el8toel9`, `el9toel10` | ++| `f42` | 3.13 | lint-only (latest tooling) | ++ ++Pick container(s) based on which repositories your change touches: ++- `common` actors: test on `el8` **and** `el9` (both Python versions must pass). ++- `el8toel9` only: `el8` is sufficient, `el9` is a bonus. ++- `el9toel10` only: `el9`. ++ ++```bash ++# Full test (lint + unit tests) in container — recommended ++TEST_CONTAINER=el9 make test_container ++ ++# Tests only, skip lint — faster iteration ++TEST_CONTAINER=el9 make test_container_no_lint ++ ++# All containers at once ++make test_container_all ++``` ++ ++## Single-actor testing (fastest feedback, requires venv) ++ ++Use during development when iterating on one actor: ++ ++```bash ++ACTOR=checkmemory make dev_test_no_lint ++``` ++ ++## Linting ++ ++```bash ++# Lint in container (recommended, no venv needed) ++make lint_container ++ ++# Full lint locally (requires venv): pylint + flake8 + isort ++make lint ++ ++# Lint only local changes relative to main branch (requires venv) ++make fast_lint ++ ++# Auto-fix isort violations locally (requires venv) ++make lint_fix ++``` ++ ++## Choosing what to run ++ ++| Situation | Command | ++|-----------|---------| ++| Quick check during development | `ACTOR= make dev_test_no_lint` | ++| Before pushing | `TEST_CONTAINER=elX make test_container` | ++| Full CI confidence | `make test_container_all` | ++| Only lint issues | `make fast_lint` or `TEST_CONTAINER=elX make lint_container` | ++ ++## Interpreting failures ++ ++- **pylint / flake8**: fix the reported file and line. ++- **isort**: run `make lint_fix` to auto-sort imports, then verify. ++- **pytest**: read the traceback; check if the test uses `monkeypatch.setattr` correctly and if mocked models match current schema. +\ No newline at end of file +diff --git a/skills/leapp-unit-tests/SKILL.md b/skills/leapp-unit-tests/SKILL.md +new file mode 100644 +index 00000000..894d4271 +--- /dev/null ++++ b/skills/leapp-unit-tests/SKILL.md +@@ -0,0 +1,94 @@ ++# Writing Unit Tests ++ ++ ++## File placement ++ ++### Tests for actors ++ ++Tests live in the tests directory under an actor: ++ ++``` ++repos/system_upgrade//actors// ++├── actor.py ++├── libraries/.py ++└── tests/test_.py # <-- tests go here ++``` ++ ++### Tests for shared libraries ++ ++Tests for shared libraries are in the `tests` sub-directory next to the shared library, like: ++ ++``` ++repos/system_upgrade// ++├── .py ++├── tests/test_.py # <-- tests for libA go here ++├── /.py ++└── /tests/test_.py # <-- tests for libB go here ++``` ++ ++## Key testing utilities ++ ++As mocked objects, use functions and classes present in `leapp.libraries.common.testutils` - especially: ++ ++| Utility | Purpose | ++| ------------------------- | ---------------------------------------------------------- | ++| `CurrentActorMocked(...)` | Mock `api.current_actor` with configuration, version, etc. | ++| `produce_mocked()` | Capture `api.produce(...)` calls for assertions | ++| `create_report_mocked()` | Capture `reporting.create_report(...)` calls | ++| `logger_mocked()` | Capture log output | ++ ++ ++## Standard test pattern ++ ++Test the **library**, not the actor class directly: ++ ++```python ++from leapp.libraries.actor import ++from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import , ++ ++ ++def test_(monkeypatch): ++ # 1. Mock current_actor context ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ ++ # 2. Mock consumed messages ++ monkeypatch.setattr(api, 'consume', lambda x: iter([(...)])) ++ ++ # 3. Mock produce ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ # 4. Call library function ++ .process() ++ ++ # 5. Assert on produced messages ++ assert api.produce.called ++ assert isinstance(api.produce.model_instances[0], ) ++``` ++ ++## Rules ++ ++ ++- Test the library, not `actor.py` — the `process()` method should be a thin wrapper. ++- Pass consumed messages via `CurrentActorMocked(msgs=[...])`. ++- Mock file I/O instead of creating temp files when possible. Use the `leapp_tmpdir` fixture when tempfiles are needed. ++- Use `monkeypatch.setattr` over `unittest.mock.patch` — monkeypatch is the project convention. ++- Cover at least: happy path, edge/error cases, and inhibitor conditions if applicable. ++- If you intentionally skip a test scenario, add a comment explaining why. ++- Use consistent string quoting within a file. ++- Use pytest.mark.parametrize where possible. ++- Never use type() when creating mock classes. ++- Use module-level constants with "_" prefix. ++ ++- Name test functions clearly after the behavior: `test_inhibits_when_fips_enabled` not `test_fips_1`. ++- Look at existing tests in `repos/system_upgrade/common/actors/` for project conventions before writing new ones. ++ ++## Running tests ++ ++See `skills/leapp-test-runner/` for full commands. Quick single-actor run: ++ ++```bash ++ACTOR= make dev_test_no_lint ++``` ++ +-- +2.54.0 + diff --git a/0078-el8toel9-networkdeprecations-warn-about-ifcfg-device.patch b/0078-el8toel9-networkdeprecations-warn-about-ifcfg-device.patch new file mode 100644 index 0000000..7f63997 --- /dev/null +++ b/0078-el8toel9-networkdeprecations-warn-about-ifcfg-device.patch @@ -0,0 +1,230 @@ +From 3268f9a5cc41755bb2fecdbeb81f73dd788f74b4 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=8D=C3=B1igo=20Huguet?= +Date: Thu, 23 Apr 2026 09:25:39 +0200 +Subject: [PATCH 078/108] el8toel9: networkdeprecations: warn about ifcfg + devices not controlled by NM + +Network devices currently configured via ifcfg have to be configured by +NetworkManager in EL9. If NetworkManager is disabled, connectivity will +be lost. Generate a report with HIGH severity in that case. + +If devices are configured with NM_CONTROLLED=no, then ignore it, as they +are being explicitly excluded from NetworkManager control. + +Assisted-by: Claude 4.6 Opus + +Jira: https://redhat.atlassian.net/browse/RHEL-133966 +--- + .../actors/networkdeprecations/actor.py | 4 +- + .../libraries/networkdeprecations.py | 53 +++++++++++- + .../tests/unit_test_networkdeprecations.py | 82 ++++++++++++++++++- + 3 files changed, 135 insertions(+), 4 deletions(-) + +diff --git a/repos/system_upgrade/el8toel9/actors/networkdeprecations/actor.py b/repos/system_upgrade/el8toel9/actors/networkdeprecations/actor.py +index 3074a3c7..8515ec69 100644 +--- a/repos/system_upgrade/el8toel9/actors/networkdeprecations/actor.py ++++ b/repos/system_upgrade/el8toel9/actors/networkdeprecations/actor.py +@@ -1,6 +1,6 @@ + from leapp.actors import Actor + from leapp.libraries.actor import networkdeprecations +-from leapp.models import IfCfg, NetworkManagerConnection, Report ++from leapp.models import IfCfg, NetworkManagerConnection, Report, SystemdServicesInfoSource + from leapp.tags import ChecksPhaseTag, IPUWorkflowTag + + +@@ -16,7 +16,7 @@ class CheckNetworkDeprecations(Actor): + """ + + name = "network_deprecations" +- consumes = (IfCfg, NetworkManagerConnection,) ++ consumes = (IfCfg, NetworkManagerConnection, SystemdServicesInfoSource,) + produces = (Report,) + tags = (ChecksPhaseTag, IPUWorkflowTag,) + +diff --git a/repos/system_upgrade/el8toel9/actors/networkdeprecations/libraries/networkdeprecations.py b/repos/system_upgrade/el8toel9/actors/networkdeprecations/libraries/networkdeprecations.py +index 92dfc51d..d4f2106b 100644 +--- a/repos/system_upgrade/el8toel9/actors/networkdeprecations/libraries/networkdeprecations.py ++++ b/repos/system_upgrade/el8toel9/actors/networkdeprecations/libraries/networkdeprecations.py +@@ -1,12 +1,13 @@ + from leapp import reporting + from leapp.libraries.stdlib import api +-from leapp.models import IfCfg, NetworkManagerConnection ++from leapp.models import IfCfg, NetworkManagerConnection, SystemdServicesInfoSource + + FMT_LIST_SEPARATOR = '\n - ' + + + def process(): + wep_files = [] ++ nm_controlled_files = [] + + # Scan NetworkManager native keyfile connections + for nmconn in api.consume(NetworkManagerConnection): +@@ -26,6 +27,8 @@ def process(): + if ifcfg.secrets is not None: + props = props + ifcfg.secrets + ++ nm_controlled = True ++ + for prop in props: + name = prop.name + value = prop.value +@@ -40,6 +43,14 @@ def process(): + wep_files.append(ifcfg.filename) + continue + ++ # NM_CONTROLLED ++ if name == 'NM_CONTROLLED' and value.lower() in ("no", "false", "f", "n", "0"): ++ nm_controlled = False ++ continue ++ ++ if nm_controlled: ++ nm_controlled_files.append(ifcfg.filename) ++ + if wep_files: + title = 'Wireless networks using unsupported WEP encryption detected' + summary = ('The Wired Equivalent Privacy (WEP) algorithm used for' +@@ -65,3 +76,43 @@ def process(): + reporting.RelatedResource('file', fname) + for fname in wep_files + ]) ++ ++ if nm_controlled_files and _is_nm_service_disabled(): ++ title = 'ifcfg files expect NetworkManager but the service is disabled' ++ summary = ( ++ 'NetworkManager is disabled on the system, but there are ifcfg' ++ ' configuration files that do not set NM_CONTROLLED=no. After the' ++ ' upgrade, legacy network scripts will no longer be available and' ++ ' NetworkManager will be the only way to manage networking. These' ++ ' connections will not be active unless NetworkManager is enabled.' ++ ' Files with the problematic configuration:{}' ++ ).format( ++ ''.join(['{}{}'.format(FMT_LIST_SEPARATOR, f) for f in nm_controlled_files]) ++ ) ++ remediation = ( ++ 'Either enable the NetworkManager service before upgrading, or add' ++ ' NM_CONTROLLED=no to the ifcfg files that should not be managed' ++ ' by NetworkManager.' ++ ) ++ reporting.create_report([ ++ reporting.Title(title), ++ reporting.Summary(summary), ++ reporting.Remediation(hint=remediation), ++ reporting.Severity(reporting.Severity.HIGH), ++ reporting.Groups([reporting.Groups.NETWORK, reporting.Groups.SERVICES]), ++ reporting.Groups([reporting.Groups.INHIBITOR]), ++ reporting.RelatedResource('package', 'NetworkManager'), ++ ] + [ ++ reporting.RelatedResource('file', fname) ++ for fname in nm_controlled_files ++ ]) ++ ++ ++def _is_nm_service_disabled(): ++ services_info = next(api.consume(SystemdServicesInfoSource), None) ++ if not services_info: ++ return True ++ for svc in services_info.service_files: ++ if svc.name == 'NetworkManager.service': ++ return svc.state == 'disabled' ++ return True +diff --git a/repos/system_upgrade/el8toel9/actors/networkdeprecations/tests/unit_test_networkdeprecations.py b/repos/system_upgrade/el8toel9/actors/networkdeprecations/tests/unit_test_networkdeprecations.py +index 659ab993..e9fa69ac 100644 +--- a/repos/system_upgrade/el8toel9/actors/networkdeprecations/tests/unit_test_networkdeprecations.py ++++ b/repos/system_upgrade/el8toel9/actors/networkdeprecations/tests/unit_test_networkdeprecations.py +@@ -3,7 +3,9 @@ from leapp.models import ( + IfCfgProperty, + NetworkManagerConnection, + NetworkManagerConnectionProperty, +- NetworkManagerConnectionSetting ++ NetworkManagerConnectionSetting, ++ SystemdServiceFile, ++ SystemdServicesInfoSource + ) + from leapp.reporting import Report + from leapp.utils.report import is_inhibitor +@@ -122,3 +124,81 @@ def test_ifcfg_dynamic_wep(current_actor_context): + current_actor_context.run() + report_fields = current_actor_context.consume(Report)[0].report + assert is_inhibitor(report_fields) ++ ++ ++def _nm_service_info(state): ++ return SystemdServicesInfoSource(service_files=[ ++ SystemdServiceFile(name='NetworkManager.service', state=state), ++ ]) ++ ++ ++def test_nm_disabled_ifcfg_no_nm_controlled(current_actor_context): ++ """ ++ Report if NM is disabled and ifcfg files don't have NM_CONTROLLED=no. ++ """ ++ ++ current_actor_context.feed(_nm_service_info('disabled')) ++ current_actor_context.feed(IfCfg(filename='/NM/ifcfg-eth0', properties=( ++ IfCfgProperty(name='TYPE', value='Ethernet'), ++ ))) ++ current_actor_context.run() ++ reports = current_actor_context.consume(Report) ++ assert len(reports) == 1 ++ assert 'ifcfg files expect NetworkManager' in reports[0].report['title'] ++ ++ ++def test_nm_disabled_ifcfg_nm_controlled_no(current_actor_context): ++ """ ++ No report if NM is disabled but all ifcfg files have NM_CONTROLLED=no. ++ """ ++ ++ current_actor_context.feed(_nm_service_info('disabled')) ++ current_actor_context.feed(IfCfg(filename='/NM/ifcfg-eth0', properties=( ++ IfCfgProperty(name='TYPE', value='Ethernet'), ++ IfCfgProperty(name='NM_CONTROLLED', value='no'), ++ ))) ++ current_actor_context.run() ++ assert not current_actor_context.consume(Report) ++ ++ ++def test_nm_enabled_ifcfg_no_nm_controlled(current_actor_context): ++ """ ++ No report if NM is enabled, even if ifcfg files lack NM_CONTROLLED=no. ++ """ ++ ++ current_actor_context.feed(_nm_service_info('enabled')) ++ current_actor_context.feed(IfCfg(filename='/NM/ifcfg-eth0', properties=( ++ IfCfgProperty(name='TYPE', value='Ethernet'), ++ ))) ++ current_actor_context.run() ++ assert not current_actor_context.consume(Report) ++ ++ ++def test_nm_disabled_mixed_ifcfg(current_actor_context): ++ """ ++ Report only the ifcfg files that lack NM_CONTROLLED=no when NM is disabled. ++ """ ++ ++ current_actor_context.feed(_nm_service_info('disabled')) ++ current_actor_context.feed(IfCfg(filename='/NM/ifcfg-eth0', properties=( ++ IfCfgProperty(name='TYPE', value='Ethernet'), ++ ))) ++ current_actor_context.feed(IfCfg(filename='/NM/ifcfg-eth1', properties=( ++ IfCfgProperty(name='TYPE', value='Ethernet'), ++ IfCfgProperty(name='NM_CONTROLLED', value='no'), ++ ))) ++ current_actor_context.run() ++ reports = current_actor_context.consume(Report) ++ assert len(reports) == 1 ++ assert '/NM/ifcfg-eth0' in reports[0].report['summary'] ++ assert '/NM/ifcfg-eth1' not in reports[0].report['summary'] ++ ++ ++def test_nm_disabled_no_ifcfg(current_actor_context): ++ """ ++ No report if NM is disabled but there are no ifcfg files at all. ++ """ ++ ++ current_actor_context.feed(_nm_service_info('disabled')) ++ current_actor_context.run() ++ assert not current_actor_context.consume(Report) +-- +2.54.0 + diff --git a/0079-Remove-enforcing-1-from-upgrade-and-target-kernel-bo.patch b/0079-Remove-enforcing-1-from-upgrade-and-target-kernel-bo.patch new file mode 100644 index 0000000..69ca395 --- /dev/null +++ b/0079-Remove-enforcing-1-from-upgrade-and-target-kernel-bo.patch @@ -0,0 +1,439 @@ +From 924276d11589cefba16901a30d7269062a06eb3b Mon Sep 17 00:00:00 2001 +From: Pradeep Jagtap +Date: Mon, 4 May 2026 14:07:53 +0530 +Subject: [PATCH 079/108] Remove enforcing=1 from upgrade and target kernel + boot entries during IPU + +Detect enforcing=1 on the running kernel (KernelCmdline) or in any bootloader +entry. Bootloader detection is collected by systemfacts via `grubby --info ALL` +and exposed as SELinuxFacts.enforcing_via_any_cmdline. + +When enforcing=1 is present, checkselinux (Checks phase) emits +UpgradeKernelCmdlineArgTasks to remove enforcing=1 from the interim upgrade +boot entry, and TargetKernelCmdlineArgTasks to remove it from the target kernel +entry and update the default kernel command line during finalization. + +An INFO report with remediation guidance to restore enforcing=1 after upgrade is also +produced. Original boot entries for existing kernels are not modified. + +JIRA: RHEL-162192 +--- + .../common/actors/checkselinux/actor.py | 15 +++- + .../checkselinux/libraries/checkselinux.py | 80 ++++++++++++++++++- + .../checkselinux/tests/test_checkselinux.py | 68 ++++++++++++++-- + .../systemfacts/libraries/systemfacts.py | 20 +++++ + .../tests/test_systemfacts_selinux.py | 61 +++++++++++++- + .../common/models/selinuxfacts.py | 4 + + 6 files changed, 234 insertions(+), 14 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/checkselinux/actor.py b/repos/system_upgrade/common/actors/checkselinux/actor.py +index 4e8fa7df..d618b5d5 100644 +--- a/repos/system_upgrade/common/actors/checkselinux/actor.py ++++ b/repos/system_upgrade/common/actors/checkselinux/actor.py +@@ -1,6 +1,15 @@ + from leapp.actors import Actor + from leapp.libraries.actor import checkselinux +-from leapp.models import KernelCmdlineArg, Report, SELinuxFacts, SelinuxPermissiveDecision, SelinuxRelabelDecision ++from leapp.models import ( ++ KernelCmdline, ++ KernelCmdlineArg, ++ Report, ++ SELinuxFacts, ++ SelinuxPermissiveDecision, ++ SelinuxRelabelDecision, ++ TargetKernelCmdlineArgTasks, ++ UpgradeKernelCmdlineArgTasks ++) + from leapp.tags import ChecksPhaseTag, IPUWorkflowTag + + +@@ -13,12 +22,14 @@ class CheckSelinux(Actor): + """ + + name = 'check_se_linux' +- consumes = (SELinuxFacts,) ++ consumes = (SELinuxFacts, KernelCmdline) + produces = ( + KernelCmdlineArg, + Report, + SelinuxPermissiveDecision, + SelinuxRelabelDecision, ++ TargetKernelCmdlineArgTasks, ++ UpgradeKernelCmdlineArgTasks, + ) + tags = (ChecksPhaseTag, IPUWorkflowTag) + +diff --git a/repos/system_upgrade/common/actors/checkselinux/libraries/checkselinux.py b/repos/system_upgrade/common/actors/checkselinux/libraries/checkselinux.py +index dbd79adf..4f892777 100644 +--- a/repos/system_upgrade/common/actors/checkselinux/libraries/checkselinux.py ++++ b/repos/system_upgrade/common/actors/checkselinux/libraries/checkselinux.py +@@ -1,11 +1,85 @@ + from leapp import reporting ++from leapp.libraries.common.config import architecture + from leapp.libraries.common.config.version import get_target_major_version + from leapp.libraries.common.distro import DISTRO_REPORT_NAMES + from leapp.libraries.stdlib import api +-from leapp.models import KernelCmdlineArg, SELinuxFacts, SelinuxPermissiveDecision, SelinuxRelabelDecision ++from leapp.models import ( ++ KernelCmdline, ++ KernelCmdlineArg, ++ SELinuxFacts, ++ SelinuxPermissiveDecision, ++ SelinuxRelabelDecision, ++ TargetKernelCmdlineArgTasks, ++ UpgradeKernelCmdlineArgTasks ++) + + DOC_URL = 'https://red.ht/rhel9-disabling-selinux' + ++_ENFORCING_ONE_ARG = KernelCmdlineArg(key='enforcing', value='1') ++ ++ ++def _proc_cmdline_has_enforcing_one(kernel_cmdline): ++ """ ++ Checks if the kernel command line contains enforcing=1. ++ """ ++ if not kernel_cmdline: ++ return False ++ for param in kernel_cmdline.parameters: ++ if param.key == 'enforcing' and param.value == '1': ++ return True ++ return False ++ ++ ++def _request_removal_of_enforcing_one(): ++ """ ++ Schedule stripping of enforcing=1 from the upgrade and target boot entries. ++ ++ The upgrade workflow runs with SELinux permissive, but if enforcing=1 is present on ++ the running kernel or in any bootloader entry it can override that. We produce: ++ ++ * ``UpgradeKernelCmdlineArgTasks`` so the interim upgrade boot entry drops enforcing=1 ++ (consumed by ``addupgradebootentry``). ++ * ``TargetKernelCmdlineArgTasks`` so the target kernel entry and the default kernel ++ command line configuration are updated during finalization (consumed by ++ ``kernelcmdlineconfig``), ensuring that future kernels also omit enforcing=1. ++ ++ Original boot entries for existing (non-target) kernels are left untouched. ++ ++ ``SELinuxFacts.enforcing_via_any_cmdline`` is populated earlier by the ++ ``systemfacts`` actor. ++ """ ++ arch = api.current_actor().configuration.architecture ++ arch_msg = ' Then make sure to run zipl to update the boot menu.' if arch == architecture.ARCH_S390X else '' ++ doc_url = 'https://red.ht/rhel-{}-configure-kernel-cmdline'.format(get_target_major_version()) ++ hint = ( ++ 'After the upgrade, add the `enforcing=1` kernel command line argument back if it is wanted.' ++ ' Run "grubby --update-kernel=ALL --args=enforcing=1" to enable enforcing on all boot entries' ++ f' and set it as default.{arch_msg}' ++ ' Follow the documentation in the attached link for more details.' ++ ) ++ ++ api.produce(UpgradeKernelCmdlineArgTasks(to_remove=[_ENFORCING_ONE_ARG])) ++ api.produce(TargetKernelCmdlineArgTasks(to_remove=[_ENFORCING_ONE_ARG])) ++ reporting.create_report([ ++ reporting.Title('Kernel boot parameter enforcing=1 will be removed'), ++ reporting.Summary( ++ 'The kernel parameter enforcing=1 forces SELinux enforcing mode at boot and overrides ' ++ 'the permissive configuration used during the upgrade. Leapp will remove enforcing=1 from ' ++ 'the upgrade and target kernel boot entries and update the default kernel command line ' ++ 'configuration so it does not persist after the upgrade. Existing boot entries for other ' ++ 'kernels will not be modified.' ++ ), ++ reporting.Remediation(hint=hint), ++ reporting.Severity(reporting.Severity.INFO), ++ reporting.Groups([ ++ reporting.Groups.SELINUX, ++ reporting.Groups.BOOT, ++ reporting.Groups.KERNEL, ++ reporting.Groups.POST, ++ ]), ++ reporting.ExternalLink(url=doc_url, title='Configuring kernel command line parameters'), ++ ]) ++ + + def process(): + facts = next(api.consume(SELinuxFacts), None) +@@ -49,6 +123,10 @@ def process(): + ]) + return + ++ kernel_cmdline = next(api.consume(KernelCmdline), None) ++ if _proc_cmdline_has_enforcing_one(kernel_cmdline) or facts.enforcing_via_any_cmdline: ++ _request_removal_of_enforcing_one() ++ + if conf_status in ('enforcing', 'permissive'): + api.produce(SelinuxRelabelDecision(set_relabel=True)) + reporting.create_report([ +diff --git a/repos/system_upgrade/common/actors/checkselinux/tests/test_checkselinux.py b/repos/system_upgrade/common/actors/checkselinux/tests/test_checkselinux.py +index 2a42b3ef..a0cd2ec9 100644 +--- a/repos/system_upgrade/common/actors/checkselinux/tests/test_checkselinux.py ++++ b/repos/system_upgrade/common/actors/checkselinux/tests/test_checkselinux.py +@@ -4,11 +4,24 @@ from leapp import reporting + from leapp.libraries.actor import checkselinux + from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked + from leapp.libraries.stdlib import api +-from leapp.models import Report, SELinuxFacts, SelinuxPermissiveDecision, SelinuxRelabelDecision +-from leapp.snactor.fixture import current_actor_context ++from leapp.models import ( ++ KernelCmdline, ++ KernelCmdlineArg, ++ SELinuxFacts, ++ SelinuxPermissiveDecision, ++ SelinuxRelabelDecision, ++ TargetKernelCmdlineArgTasks, ++ UpgradeKernelCmdlineArgTasks ++) + + +-def create_selinuxfacts(static_mode, enabled, policy='targeted', mls_enabled=True): ++def create_selinuxfacts( ++ static_mode, ++ enabled, ++ policy='targeted', ++ mls_enabled=True, ++ enforcing_via_any_cmdline=False, ++): + runtime_mode = static_mode if static_mode != 'disabled' else None + + return SELinuxFacts( +@@ -16,7 +29,8 @@ def create_selinuxfacts(static_mode, enabled, policy='targeted', mls_enabled=Tru + static_mode=static_mode, + enabled=enabled, + policy=policy, +- mls_enabled=mls_enabled ++ mls_enabled=mls_enabled, ++ enforcing_via_any_cmdline=enforcing_via_any_cmdline, + ) + + +@@ -25,7 +39,7 @@ def test_actor_schedule_relabelling(monkeypatch, mode): + + fact = create_selinuxfacts(static_mode=mode, enabled=True) + +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[fact])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[fact, KernelCmdline(parameters=[])])) + monkeypatch.setattr(api, 'produce', produce_mocked()) + monkeypatch.setattr(reporting, "create_report", create_report_mocked()) + +@@ -38,7 +52,7 @@ def test_actor_schedule_relabelling(monkeypatch, mode): + def test_actor_set_permissive(monkeypatch): + relabel = create_selinuxfacts(static_mode='enforcing', enabled=True) + +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[relabel])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[relabel, KernelCmdline(parameters=[])])) + monkeypatch.setattr(api, 'produce', produce_mocked()) + monkeypatch.setattr(reporting, "create_report", create_report_mocked()) + +@@ -56,7 +70,9 @@ def test_actor_selinux_disabled(monkeypatch, el8_to_el9): + target_ver = '8' if not el8_to_el9 else '9' + + monkeypatch.setattr(checkselinux, 'get_target_major_version', lambda: target_ver) +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[disabled])) ++ monkeypatch.setattr( ++ api, 'current_actor', CurrentActorMocked(msgs=[disabled, KernelCmdline(parameters=[])]) ++ ) + monkeypatch.setattr(api, 'produce', produce_mocked()) + monkeypatch.setattr(reporting, "create_report", create_report_mocked()) + +@@ -67,3 +83,41 @@ def test_actor_selinux_disabled(monkeypatch, el8_to_el9): + else: + assert not api.produce.model_instances + assert reporting.create_report.called ++ ++ ++def test_enforcing_one_in_proc_cmdline_schedules_removal(monkeypatch): ++ fact = create_selinuxfacts(static_mode='permissive', enabled=True) ++ kcmd = KernelCmdline(parameters=[KernelCmdlineArg(key='enforcing', value='1')]) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[fact, kcmd])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) ++ ++ checkselinux.process() ++ ++ upgrade_produced = [m for m in api.produce.model_instances if isinstance(m, UpgradeKernelCmdlineArgTasks)] ++ assert upgrade_produced ++ assert upgrade_produced[0].to_remove == [KernelCmdlineArg(key='enforcing', value='1')] ++ ++ target_produced = [m for m in api.produce.model_instances if isinstance(m, TargetKernelCmdlineArgTasks)] ++ assert target_produced ++ assert target_produced[0].to_remove == [KernelCmdlineArg(key='enforcing', value='1')] ++ ++ ++def test_enforcing_one_only_in_bootloader_schedules_removal(monkeypatch): ++ fact = create_selinuxfacts( ++ static_mode='permissive', enabled=True, enforcing_via_any_cmdline=True ++ ) ++ kcmd = KernelCmdline(parameters=[KernelCmdlineArg(key='ro')]) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[fact, kcmd])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) ++ ++ checkselinux.process() ++ ++ upgrade_produced = [m for m in api.produce.model_instances if isinstance(m, UpgradeKernelCmdlineArgTasks)] ++ assert upgrade_produced ++ assert upgrade_produced[0].to_remove == [KernelCmdlineArg(key='enforcing', value='1')] ++ ++ target_produced = [m for m in api.produce.model_instances if isinstance(m, TargetKernelCmdlineArgTasks)] ++ assert target_produced ++ assert target_produced[0].to_remove == [KernelCmdlineArg(key='enforcing', value='1')] +diff --git a/repos/system_upgrade/common/actors/systemfacts/libraries/systemfacts.py b/repos/system_upgrade/common/actors/systemfacts/libraries/systemfacts.py +index ba7bdb82..578f96cb 100644 +--- a/repos/system_upgrade/common/actors/systemfacts/libraries/systemfacts.py ++++ b/repos/system_upgrade/common/actors/systemfacts/libraries/systemfacts.py +@@ -230,6 +230,24 @@ def get_repositories_status(): + }) + + ++def _bootloader_entries_contain_enforcing_one(): ++ """ ++ True if grubby reports enforcing=1 in any boot loader entry's kernel arguments. ++ """ ++ try: ++ out = run(['/usr/sbin/grubby', '--info', 'ALL'], split=False)['stdout'] ++ except (OSError, CalledProcessError): ++ api.current_logger().debug( ++ 'grubby --info ALL failed; assuming no bootloader enforcing=1', exc_info=True ++ ) ++ return False ++ ++ for line in out.splitlines(): ++ if line.startswith('args=') and 'enforcing=1' in line[len('args='):].strip('"').split(): ++ return True ++ return False ++ ++ + def get_selinux_status(): + """ Get SELinux status information """ + # will be None if something went wrong or contain SELinuxFacts otherwise +@@ -259,6 +277,8 @@ def get_selinux_status(): + outdata['static_mode'] = 'disabled' + outdata['policy'] = 'targeted' + ++ outdata['enforcing_via_any_cmdline'] = _bootloader_entries_contain_enforcing_one() ++ + res = SELinuxFacts(**outdata) + return res + +diff --git a/repos/system_upgrade/common/actors/systemfacts/tests/test_systemfacts_selinux.py b/repos/system_upgrade/common/actors/systemfacts/tests/test_systemfacts_selinux.py +index d36900bd..85a3ff3d 100644 +--- a/repos/system_upgrade/common/actors/systemfacts/tests/test_systemfacts_selinux.py ++++ b/repos/system_upgrade/common/actors/systemfacts/tests/test_systemfacts_selinux.py +@@ -2,6 +2,7 @@ import warnings + + import pytest + ++from leapp.libraries.actor import systemfacts + from leapp.libraries.actor.systemfacts import get_selinux_status + from leapp.models import SELinuxFacts + +@@ -20,6 +21,23 @@ reason_to_skip_msg = "Selinux is not available" + # FIXME: create valid tests... + + ++def _run_result(stdout): ++ return {'stdout': stdout, 'stderr': '', 'signal': None, 'exit_code': 0, 'pid': 1} ++ ++ ++@pytest.fixture(autouse=True) ++def stub_grubby_info_all_without_enforcing(monkeypatch): ++ """ ++ Avoid invoking real grubby from get_selinux_status(); default: no enforcing=1 in boot entries. ++ """ ++ ++ def mocked_run(cmd, split=False, **dummy_kwargs): ++ assert cmd == ['/usr/sbin/grubby', '--info', 'ALL'], f"Unexpected grubby cmd: {cmd}" ++ return _run_result('index=0\nkernel="/boot/vmlinuz-1"\nargs="ro rhgb quiet"\nroot="UUID=xxx"\n') ++ ++ monkeypatch.setattr(systemfacts, 'run', mocked_run) ++ ++ + @pytest.mark.skipif(no_selinux, reason=reason_to_skip_msg) + def test_selinux_enabled_enforcing(monkeypatch): + """ +@@ -34,7 +52,8 @@ def test_selinux_enabled_enforcing(monkeypatch): + 'mls_enabled': True, + 'enabled': True, + 'runtime_mode': 'enforcing', +- 'static_mode': 'enforcing'} ++ 'static_mode': 'enforcing', ++ 'enforcing_via_any_cmdline': False} + assert SELinuxFacts(**expected_data) == get_selinux_status() + + +@@ -52,7 +71,8 @@ def test_selinux_enabled_permissive(monkeypatch): + 'mls_enabled': True, + 'enabled': True, + 'runtime_mode': 'permissive', +- 'static_mode': 'permissive'} ++ 'static_mode': 'permissive', ++ 'enforcing_via_any_cmdline': False} + assert SELinuxFacts(**expected_data) == get_selinux_status() + + +@@ -70,7 +90,8 @@ def test_selinux_disabled(monkeypatch): + 'mls_enabled': False, + 'enabled': False, + 'runtime_mode': 'permissive', +- 'static_mode': 'permissive'} ++ 'static_mode': 'permissive', ++ 'enforcing_via_any_cmdline': False} + assert SELinuxFacts(**expected_data) == get_selinux_status() + + +@@ -93,6 +114,38 @@ def test_selinux_disabled_no_config_file(monkeypatch): + 'mls_enabled': False, + 'enabled': False, + 'runtime_mode': 'permissive', +- 'static_mode': 'disabled'} ++ 'static_mode': 'disabled', ++ 'enforcing_via_any_cmdline': False} + + assert SELinuxFacts(**expected_data) == get_selinux_status() ++ ++ ++@pytest.mark.skipif(no_selinux, reason=reason_to_skip_msg) ++@pytest.mark.parametrize('grubby_stdout,expected', [ ++ ('index=0\nargs="ro enforcing=1"\n', True), ++ ('index=0\nargs="ro enforcing=1 rhgb quiet"\n', True), ++ ('index=0\nargs="enforcing=1"\n', True), ++ ('index=0\nargs="ro enforcing=1"\nindex=1\nargs="ro rhgb quiet"\n', True), ++ ('index=0\nargs="ro rhgb quiet"\nindex=1\nargs="ro enforcing=1"\n', True), ++ ('index=0\nargs="ro enforcing=0"\n', False), ++ ('index=0\nargs="ro rhgb quiet"\n', False), ++ ('index=0\nargs="ro fooenforcing=1"\n', False), ++ ('index=0\nargs="ro enforcing=1bar"\n', False), ++ ('index=0\nargs="ro"\n', False), ++ ('index=0\nargs=""\n', False), ++]) ++def test_bootloader_enforcing_one_detected(monkeypatch, grubby_stdout, expected): ++ monkeypatch.setattr(selinux, 'is_selinux_mls_enabled', lambda: 1) ++ monkeypatch.setattr(selinux, 'security_getenforce', lambda: 1) ++ monkeypatch.setattr(selinux, 'selinux_getenforcemode', lambda: [0, 1]) ++ monkeypatch.setattr(selinux, 'is_selinux_enabled', lambda: 1) ++ monkeypatch.setattr(selinux, 'selinux_getpolicytype', lambda: [0, 'targeted']) ++ ++ def mocked_run(cmd, split=False, **dummy_kwargs): ++ assert cmd == ['/usr/sbin/grubby', '--info', 'ALL'], f"Unexpected grubby cmd: {cmd}" ++ return _run_result(grubby_stdout) ++ ++ monkeypatch.setattr(systemfacts, 'run', mocked_run) ++ ++ fact = get_selinux_status() ++ assert fact.enforcing_via_any_cmdline is expected +diff --git a/repos/system_upgrade/common/models/selinuxfacts.py b/repos/system_upgrade/common/models/selinuxfacts.py +index 12b0cf8a..9376e4b1 100644 +--- a/repos/system_upgrade/common/models/selinuxfacts.py ++++ b/repos/system_upgrade/common/models/selinuxfacts.py +@@ -10,3 +10,7 @@ class SELinuxFacts(Model): + enabled = fields.Boolean() + policy = fields.StringEnum(['targeted', 'minimum', 'mls']) + mls_enabled = fields.Boolean() ++ enforcing_via_any_cmdline = fields.Boolean(default=False) ++ """ ++ True when any bootloader entries have set enforcing=1. ++ """ +-- +2.54.0 + diff --git a/0080-Fix-utils-actor_path.py-crash-on-invalid-repo-paths.patch b/0080-Fix-utils-actor_path.py-crash-on-invalid-repo-paths.patch new file mode 100644 index 0000000..24878cb --- /dev/null +++ b/0080-Fix-utils-actor_path.py-crash-on-invalid-repo-paths.patch @@ -0,0 +1,71 @@ +From fdc5e80760f08bbef095713d7d592758f551d6b8 Mon Sep 17 00:00:00 2001 +From: Matej Matuska +Date: Thu, 21 May 2026 13:23:02 +0200 +Subject: [PATCH 080/108] Fix utils/actor_path.py crash on invalid repo paths + +When a scan of a path which does not contain a valid leapp repository is +attempted in utils/actor.py, it crashes. For example, run with +REPOSITORIES=common,invalid: + +$ ACTOR=mysql_check REPOSITORIES=common,invalid make test +ERROR: Unknown error: 'name' +ERROR: Possibly you need newer version of the leapp framework +ERROR: e.g.: rm -rf .tut && make install-deps-fedora +Traceback (most recent call last): + File "utils/actor_path.py", line 50, in + main() + File "utils/actor_path.py", line 33, in main + manager.add_repo(scan_repo(os.path.join(SYSUPG_REPO, repo))) + File "/home/user/leapp-repository/tut/lib/python3.6/site-packages/leapp/repository/scan.py", line 70, in scan_repo + return scan(Repository(path), path) + File "/home/user/leapp-repository/tut/lib/python3.6/site-packages/leapp/repository/__init__.py", line 40, in __init__ + self.name = get_repository_name(directory) + File "/home/user/leapp-repository/tut/lib/python3.6/site-packages/leapp/utils/repository.py", line 96, in get_repository_name + return get_repository_metadata(path)['name'] +KeyError: 'name' + +With this patch, there is an error with suggestion: +$ ACTOR=mysql_check REPOSITORIES=common,invalid make test +ERROR: Failed to scan repository at repos/system_upgrade/invalid, make sure there is a valid leapp repository at the path. +. tut/bin/activate; \ +echo "--- Linting ... ---" && \ +SEARCH_PATH="" && \ +echo "Using search path '${SEARCH_PATH}'" && \ +echo "--- Running pylint ---" && \ +bash -c "[[ ! -z '${SEARCH_PATH}' ]] && find ${SEARCH_PATH} -name '*.py' | sort -u | xargs pylint -j0 " && \ +echo "--- Running flake8 ---" && \ +bash -c "[[ ! -z '${SEARCH_PATH}' ]] && flake8 ${SEARCH_PATH} " +--- Linting ... --- +Using search path '' +--- Running pylint --- +make: *** [Makefile:343: lint] Error 1 +--- + utils/actor_path.py | 12 +++++++++++- + 1 file changed, 11 insertions(+), 1 deletion(-) + +diff --git a/utils/actor_path.py b/utils/actor_path.py +index 3c61ce79..68b70da6 100755 +--- a/utils/actor_path.py ++++ b/utils/actor_path.py +@@ -30,7 +30,17 @@ def main(): + # the scanning and resolving done below + manager = RepositoryManager() + for repo in repos: +- manager.add_repo(scan_repo(os.path.join(SYSUPG_REPO, repo))) ++ repo_path = os.path.join(SYSUPG_REPO, repo) ++ try: ++ repo_obj = scan_repo(repo_path) ++ except KeyError: # this is not a nice API, should handle this better in the framework ++ sys.stderr.write( ++ "ERROR: Failed to scan repository at {}, make sure there" ++ " is a valid leapp repository at the path.\n".format(repo_path) ++ ) ++ return ++ ++ manager.add_repo(repo_obj) + _resolve_repository_links(manager=manager, include_locals=True) + manager.load() + else: +-- +2.54.0 + diff --git a/0081-fix-breadcrumbs-use-distro-aware-OS-names-for-source.patch b/0081-fix-breadcrumbs-use-distro-aware-OS-names-for-source.patch new file mode 100644 index 0000000..d9b942e --- /dev/null +++ b/0081-fix-breadcrumbs-use-distro-aware-OS-names-for-source.patch @@ -0,0 +1,119 @@ +From 1797c35db27e5af2d54598a05ded4fd55aed3765 Mon Sep 17 00:00:00 2001 +From: Pradeep Jagtap +Date: Thu, 14 May 2026 13:41:34 +0530 +Subject: [PATCH 081/108] fix(breadcrumbs): use distro-aware OS names for + source and target + +The original code hardcoded "Red Hat Enterprise Linux" for both +source_os and target_os in /etc/migration-results breadcrumbs. +With multiple distros now supported (CentOS Stream, AlmaLinux, etc.), +this produced incorrect records for non-RHEL upgrades. + +- Add DISTRO_NAMES mapping in command_utils.py for CLI-layer + use, with a sync note referencing distro_id_to_pretty_name() in + the repo library (which cannot be imported from the CLI layer). + +- Replace inline "Red Hat Enterprise Linux" formatting with a single + _get_os_name(ipu_cfg, direction) helper that resolves the display + name from IPUConfig distro/version metadata. + +- Return 'N/A' when distro or version data is missing instead of + silently assuming RHEL. + +JIRA: RHEL-156580 +--- + commands/command_utils.py | 14 ++++++++++++ + commands/upgrade/breadcrumbs.py | 22 +++++++++++++++---- + .../system_upgrade/common/libraries/distro.py | 4 ++++ + 3 files changed, 36 insertions(+), 4 deletions(-) + +diff --git a/commands/command_utils.py b/commands/command_utils.py +index 735144f8..20ae1b48 100644 +--- a/commands/command_utils.py ++++ b/commands/command_utils.py +@@ -39,6 +39,20 @@ class DistroIDs(str, Enum): + ALMALINUX = 'almalinux' + + ++DISTRO_NAMES = { ++ DistroIDs.RHEL: 'Red Hat Enterprise Linux', ++ DistroIDs.CENTOS: 'CentOS Stream', ++ DistroIDs.ALMALINUX: 'AlmaLinux', ++} ++""" ++Maps distro IDs to their user-facing display names (matching NAME in /etc/os-release). ++ ++NOTE: keep in sync with distro_id_to_pretty_name() in ++repos/system_upgrade/common/libraries/distro.py the repo library ++is not importable from the CLI layer. ++""" ++ ++ + _DISTRO_VERSION_FORMATS = { + DistroIDs.RHEL: VersionFormats.MAJOR_MINOR, + DistroIDs.CENTOS: VersionFormats.MAJOR_ONLY, +diff --git a/commands/upgrade/breadcrumbs.py b/commands/upgrade/breadcrumbs.py +index 95a551c3..f5ca92e9 100644 +--- a/commands/upgrade/breadcrumbs.py ++++ b/commands/upgrade/breadcrumbs.py +@@ -6,6 +6,7 @@ from functools import wraps + from itertools import chain + + from leapp import FULL_VERSION ++from leapp.cli.commands.command_utils import DISTRO_NAMES + from leapp.libraries.stdlib.call import _call + from leapp.utils.audit import get_messages + +@@ -15,6 +16,20 @@ except ImportError: + JSONDecodeError = ValueError + + ++def _get_os_name(ipu_cfg, direction): ++ """ ++ Determine the OS display name from IPUConfig data. ++ ++ :param direction: 'source' or 'target' ++ """ ++ distro = (ipu_cfg.get('distro') or {}).get(direction, '') ++ version = (ipu_cfg.get('version') or {}).get(direction, '') ++ if not distro: ++ return 'N/A' ++ distro_name = DISTRO_NAMES.get(distro, 'unknown') ++ return '{} {}'.format(distro_name, version or 'N/A') ++ ++ + def runs_in_container(): + """ + Check if the current process is running inside a container +@@ -95,10 +110,9 @@ class _BreadCrumbs: + self._crumbs['run_id'] = os.environ.get('LEAPP_EXECUTION_ID', 'N/A') + self._crumbs['leapp_file_changes'].extend(self._verify_leapp_pkgs()) + messages = get_messages(('IPUConfig',), self._crumbs['run_id']) +- versions = json.loads((messages or [{}])[0].get('message', {}).get( +- 'data', '{}')).get('version', {'target': 'N/A', 'source': 'N/A'}) +- self._crumbs['target_os'] = 'Red Hat Enterprise Linux {target}'.format(**versions) +- self._crumbs['source_os'] = 'Red Hat Enterprise Linux {source}'.format(**versions) ++ ipu_cfg = json.loads((messages or [{}])[0].get('message', {}).get('data', '{}')) ++ self._crumbs['source_os'] = _get_os_name(ipu_cfg, 'source') ++ self._crumbs['target_os'] = _get_os_name(ipu_cfg, 'target') + self._crumbs['activity_ended'] = datetime.datetime.utcnow().isoformat() + 'Z' + self._crumbs['env'] = {k: v for k, v in os.environ.items() if k.startswith('LEAPP_')} + try: +diff --git a/repos/system_upgrade/common/libraries/distro.py b/repos/system_upgrade/common/libraries/distro.py +index 1a5e3923..78ac79ca 100644 +--- a/repos/system_upgrade/common/libraries/distro.py ++++ b/repos/system_upgrade/common/libraries/distro.py +@@ -227,6 +227,10 @@ def distro_id_to_pretty_name(distro_id): + Get pretty name for the given distro id. + + The pretty name is what is found in the NAME field of /etc/os-release. ++ ++ NOTE: a parallel mapping exists in commands/command_utils.py ++ (DISTRO_NAMES) for use in the CLI layer which cannot import ++ repo libraries. Keep both in sync when adding a new distro. + """ + return { + "rhel": "Red Hat Enterprise Linux", +-- +2.54.0 + diff --git a/0082-leapp_resume.service-use-install_exec_t-SELinux-cont.patch b/0082-leapp_resume.service-use-install_exec_t-SELinux-cont.patch new file mode 100644 index 0000000..b89b2d7 --- /dev/null +++ b/0082-leapp_resume.service-use-install_exec_t-SELinux-cont.patch @@ -0,0 +1,58 @@ +From 09846327822b5f3ac6f021daed866990bde5f92b Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Fri, 27 Mar 2026 18:12:03 +0100 +Subject: [PATCH 082/108] leapp_resume.service: use install_exec_t SELinux + context + +The original fix to deal with AVC during execution of leapp when +booting to the upgraded system appeared to leave still a space to +generate new AVC messages during some operations. From our side, +the only way how we could be sure that we do not hit any additional +error on top of what root user would see, is to set an unconfined +context. + +However, after discussion with SELinux guys, we decided to not do that +yet. It can be actually valuable when discovering potential problems +in applications - and there is no harm to the upgrade process as +the system is in the permissive mode. + +Currently dealing with the RH Insights client, it has been +discovered that bug is in new SELinux policies for the client and not +in leapp-repository. But after the discussion, we are going to switch +the SELinux type context to `install_exec_t` +which should fit better to the upgrade process than the original one. +We will monitor the situation around AVC messages and make possible +further changes based on the feedback. + +Jira: RHEL-161684 +Signed-off-by: Petr Stodulka +--- + .../createresumeservice/files/leapp_resume.service | 13 ++++++++++--- + 1 file changed, 10 insertions(+), 3 deletions(-) + +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 749b4b84..e31b5eb5 100644 +--- a/repos/system_upgrade/common/actors/createresumeservice/files/leapp_resume.service ++++ b/repos/system_upgrade/common/actors/createresumeservice/files/leapp_resume.service +@@ -8,8 +8,15 @@ Wants=network-online.target + + [Service] + Type=oneshot +-# FIXME: this is temporary workaround for Python3 +-ExecStart=/bin/sh -c "/root/tmp_leapp_py3/leapp3 upgrade --resume" ++# NOTE(pstodulk): After discussion with the SELinux team, we try to stay ++# out of unconfined mode for now. ++#SELinuxContext=unconfined_u:unconfined_r:unconfined_t:s0 ++ ++# NOTE(pstodulk): Correction of the selinux type context. This one should fit to leapp ++# Ignore the exit code as this cmd is expected to fail if selinux is disabled ++ExecStartPre=-chcon -t install_exec_t /root/tmp_leapp_py3/leapp3 ++ExecStart=/root/tmp_leapp_py3/leapp3 upgrade --resume + StandardOutput=journal+console +-# FIXME: this shouldn't be needed, but Satellite upgrade runs installer, and that's slow ++ ++# NOTE(pstodulk): some actions can take a lot of time (e.g. satellite installer) + TimeoutStartSec=infinity +-- +2.54.0 + diff --git a/0083-config-rhui-Add-config-for-setting-rpm-gpg-keys-to-r.patch b/0083-config-rhui-Add-config-for-setting-rpm-gpg-keys-to-r.patch new file mode 100644 index 0000000..d8ce989 --- /dev/null +++ b/0083-config-rhui-Add-config-for-setting-rpm-gpg-keys-to-r.patch @@ -0,0 +1,380 @@ +From b70ff330e9c6b9e570f24c63b0a28760c5e900b6 Mon Sep 17 00:00:00 2001 +From: Matej Matuska +Date: Tue, 12 May 2026 19:11:26 +0200 +Subject: [PATCH 083/108] config(rhui): Add config for setting rpm gpg keys to + remove + +Add a config option obsolete_gpg_keys to allow users to specify obsolete +rpm gpg keys to be removed during the upgrade. The removeobsoletegpgkeys +then checks the config and produces the appropriate DNFWorkaround +messages to remove them, similar to distro keys. + +This can be prefilled in the config shipped by leapp-rhui-* packages to +remove RPM GPG keys for cloud vendor keys, such as those which are used +to sign the RHUI client rpm. + +Jira: RHEL-121979 +--- + .../actors/removeobsoletegpgkeys/actor.py | 5 + + .../libraries/removeobsoleterpmgpgkeys.py | 56 +++++++- + .../tests/test_removeobsoleterpmgpgkeys.py | 130 ++++++++++++++++-- + repos/system_upgrade/common/configs/rhui.py | 27 +++- + 4 files changed, 204 insertions(+), 14 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/removeobsoletegpgkeys/actor.py b/repos/system_upgrade/common/actors/removeobsoletegpgkeys/actor.py +index 58b15a84..f3694776 100644 +--- a/repos/system_upgrade/common/actors/removeobsoletegpgkeys/actor.py ++++ b/repos/system_upgrade/common/actors/removeobsoletegpgkeys/actor.py +@@ -1,4 +1,5 @@ + from leapp.actors import Actor ++from leapp.configs.common.rhui import all_rhui_cfg + from leapp.libraries.actor import removeobsoleterpmgpgkeys + from leapp.models import DNFWorkaround, InstalledRPM + from leapp.tags import FactsPhaseTag, IPUWorkflowTag +@@ -17,10 +18,14 @@ class RemoveObsoleteGpgKeys(Actor): + - If converting, the obsolete keys are all of the keys provided by the + vendor of the source distribution. + ++ Additionally, if RHUI configuration is active (use_config=True), GPG keys ++ specified in the RHUI obsolete_gpg_keys configuration will also be removed. ++ + A DNFWorkaround is registered to actually remove the keys. + """ + + name = "remove_obsolete_gpg_keys" ++ config_schemas = all_rhui_cfg + consumes = (InstalledRPM,) + produces = (DNFWorkaround,) + tags = (FactsPhaseTag, IPUWorkflowTag) +diff --git a/repos/system_upgrade/common/actors/removeobsoletegpgkeys/libraries/removeobsoleterpmgpgkeys.py b/repos/system_upgrade/common/actors/removeobsoletegpgkeys/libraries/removeobsoleterpmgpgkeys.py +index 7d047395..1db6a2ed 100644 +--- a/repos/system_upgrade/common/actors/removeobsoletegpgkeys/libraries/removeobsoleterpmgpgkeys.py ++++ b/repos/system_upgrade/common/actors/removeobsoletegpgkeys/libraries/removeobsoleterpmgpgkeys.py +@@ -1,5 +1,8 @@ + import itertools ++import re + ++from leapp.configs.common.rhui import RHUI_CONFIG_SECTION, RhuiObsoleteGpgKeys, RhuiUseConfig ++from leapp.exceptions import StopActorExecutionError + from leapp.libraries.common.config import get_source_distro_id, get_target_distro_id + from leapp.libraries.common.config.version import get_target_major_version + from leapp.libraries.common.distro import get_distribution_data +@@ -7,6 +10,15 @@ from leapp.libraries.common.rpms import has_package + from leapp.libraries.stdlib import api + from leapp.models import DNFWorkaround, InstalledRPM + ++_GPG_PUBKEY_NVR_FORMAT = re.compile(r"^gpg-pubkey-[0-9a-f]{8}-[0-9a-f]{8}$") ++ ++ ++def _is_valid_pubkey_nvr(name): ++ """ ++ Validate if a string is a valid gpg key RPM NVR ++ """ ++ return _GPG_PUBKEY_NVR_FORMAT.match(name) is not None ++ + + def _is_key_installed(key): + """ +@@ -50,17 +62,51 @@ def _get_source_distro_keys(): + ] + + ++def _get_rhui_configured_keys(): ++ """ ++ Get obsolete cloud vendor keys specified in RHUI configuration. ++ ++ Returns keys only when RHUI use_config is True, ensuring this is opt-in behavior. ++ Only returns keys that are actually installed on the system. ++ """ ++ rhui_config = api.current_actor().config[RHUI_CONFIG_SECTION] ++ ++ # Only apply RHUI keys when the leapp RHUI configuration is set ++ if not rhui_config[RhuiUseConfig.name]: ++ api.current_logger().debug("RHUI config disabled, ignoring obsolete RPM GPG keys") ++ return [] ++ ++ configured_keys = rhui_config[RhuiObsoleteGpgKeys.name] ++ ++ for key in configured_keys: ++ if not _is_valid_pubkey_nvr(key): ++ raise StopActorExecutionError( ++ "Provided RHUI config contains invalid value for setting {}".format( ++ RhuiObsoleteGpgKeys.name ++ ), ++ details={ ++ "details": "Entry {} is not a in valid RPM GPG name".format(key), ++ "hint": ( ++ "The expected format is: gpg-pubkey--," ++ " for example: gpg-pubkey-d4082792-5b32db75" ++ ), ++ }, ++ ) ++ ++ return [key for key in configured_keys if _is_key_installed(key)] ++ ++ + def register_dnfworkaround(keys): + api.produce( + DNFWorkaround( + display_name="remove obsolete RPM GPG keys from RPM DB", + script_path=api.current_actor().get_common_tool_path("removerpmgpgkeys"), +- script_args=keys, ++ script_args=list(keys), + ) + ) + + +-def process(): ++def _get_all_obsolete_keys(): + if get_source_distro_id() == get_target_distro_id(): + # only upgrading - remove keys obsoleted in previous versions + keys = _get_obsolete_keys() +@@ -68,5 +114,11 @@ def process(): + # also converting - we need to remove all keys from the source distro + keys = _get_source_distro_keys() + ++ keys.extend(_get_rhui_configured_keys()) ++ return set(keys) ++ ++ ++def process(): ++ keys = _get_all_obsolete_keys() + if keys: + register_dnfworkaround(keys) +diff --git a/repos/system_upgrade/common/actors/removeobsoletegpgkeys/tests/test_removeobsoleterpmgpgkeys.py b/repos/system_upgrade/common/actors/removeobsoletegpgkeys/tests/test_removeobsoleterpmgpgkeys.py +index 8b9b842b..ff6b77ba 100644 +--- a/repos/system_upgrade/common/actors/removeobsoletegpgkeys/tests/test_removeobsoleterpmgpgkeys.py ++++ b/repos/system_upgrade/common/actors/removeobsoletegpgkeys/tests/test_removeobsoleterpmgpgkeys.py +@@ -3,10 +3,12 @@ import unittest.mock as mock + + import pytest + ++from leapp.configs.common.rhui import RhuiObsoleteGpgKeys, RhuiUseConfig ++from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import removeobsoleterpmgpgkeys + from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked + from leapp.libraries.stdlib import api +-from leapp.models import InstalledRPM, RPM ++from leapp.models import DNFWorkaround, InstalledRPM, RPM + + _CUR_DIR = os.path.dirname(os.path.abspath(__file__)) + +@@ -15,6 +17,40 @@ def common_folder_path_mocked(folder): + return os.path.join(_CUR_DIR, "../../../files/", folder) + + ++@pytest.mark.parametrize( ++ "key, is_valid", ++ [ ++ # too short ++ ("gpg-pubkey-10-10", False), ++ ("gpg-pubkey-888-abc", False), ++ ("gpg-d4082792-5b32db75j", False), ++ # too long ver ++ ("gpg-pubkey-1234456789-b32db75j8", False), ++ # end bound ++ ("gpg-pubkey-12345678-123456789", False), ++ # start bound ++ ("aaagpg-pubkey-12345678-12345678", False), ++ # invalid format ++ ("gpg-12345678-12345678", False), ++ ("pubkey-12345678-12345678", False), ++ ("gpg-pubkey-5b32db75j", False), ++ ("gpg-pubkey-d40827925b32db75j", False), ++ # non hex ++ ("gpg-pubkey-abcdefgh-aaaaaaaa", False), ++ ("gpg-pubkey-12345678-hhhhhhhh", False), ++ # uppercase ++ ("gpg-pubkey-DEADBEEF-12345678", False), ++ ("gpg-pubkey-12345678-DEADBEEF", False), ++ # Ok ++ ("gpg-pubkey-12345678-deadbeef", True), ++ ("gpg-pubkey-d4082792-5b32db75", True), ++ ("gpg-pubkey-2fa658e0-45700c69", True), ++ ], ++) ++def test_is_valid_pubkey_nvr(key, is_valid): ++ assert removeobsoleterpmgpgkeys._is_valid_pubkey_nvr(key) == is_valid ++ ++ + def test_is_key_installed(monkeypatch): + installed_rpms = InstalledRPM( + items=[ +@@ -170,27 +206,47 @@ def test_get_source_distro_keys(monkeypatch, distro, expected): + ) + def test_workaround_should_register(monkeypatch, keys, should_register): + monkeypatch.setattr( +- removeobsoleterpmgpgkeys, "_get_obsolete_keys", lambda: keys ++ removeobsoleterpmgpgkeys, "_get_all_obsolete_keys", lambda: keys + ) + monkeypatch.setattr(api, "produce", produce_mocked()) + monkeypatch.setattr(api, "current_actor", CurrentActorMocked()) + + removeobsoleterpmgpgkeys.process() + assert api.produce.called == should_register ++ if should_register: ++ inst = api.produce.model_instances[0] ++ assert isinstance(inst, DNFWorkaround) ++ assert inst.script_args == keys + + +-def test_process(monkeypatch): ++@pytest.mark.parametrize( ++ "obsolete_keys, src_distro_keys, rhui_keys", ++ [ ++ ( ++ ["gpg-pubkey-12345678-abcdefgh"], ++ ["gpg-pubkey-87654321-hgfedcba"], ++ ["gpg-pubkey-12344321-abecedaa"], ++ ), ++ # test if the keys are deduplicated ++ ( ++ ["gpg-pubkey-12345678-abcdefgh"], ++ ["gpg-pubkey-12345678-abcdefgh"], ++ ["gpg-pubkey-12345678-abcdefgh"], ++ ), ++ ], ++) ++def test_process(monkeypatch, obsolete_keys, src_distro_keys, rhui_keys): + """ + Test that the correct path is taken depending on whether also converting + """ +- obsolete = ["gpg-pubkey-12345678-abcdefgh"] +- source_distro = ["gpg-pubkey-87654321-hgfedcba"] +- + monkeypatch.setattr( +- removeobsoleterpmgpgkeys, "_get_obsolete_keys", lambda: obsolete ++ removeobsoleterpmgpgkeys, "_get_obsolete_keys", lambda: obsolete_keys + ) + monkeypatch.setattr( +- removeobsoleterpmgpgkeys, "_get_source_distro_keys", lambda: source_distro, ++ removeobsoleterpmgpgkeys, "_get_source_distro_keys", lambda: src_distro_keys, ++ ) ++ monkeypatch.setattr( ++ removeobsoleterpmgpgkeys, "_get_rhui_configured_keys", lambda: rhui_keys + ) + + # upgrade only path +@@ -202,7 +258,7 @@ def test_process(monkeypatch): + ): + removeobsoleterpmgpgkeys.process() + removeobsoleterpmgpgkeys.register_dnfworkaround.assert_called_once_with( +- obsolete ++ set(obsolete_keys + rhui_keys) + ) + + # upgrade + conversion paths +@@ -214,7 +270,7 @@ def test_process(monkeypatch): + ): + removeobsoleterpmgpgkeys.process() + removeobsoleterpmgpgkeys.register_dnfworkaround.assert_called_once_with( +- source_distro ++ set(src_distro_keys + rhui_keys) + ) + + monkeypatch.setattr( +@@ -225,5 +281,57 @@ def test_process(monkeypatch): + ): + removeobsoleterpmgpgkeys.process() + removeobsoleterpmgpgkeys.register_dnfworkaround.assert_called_once_with( +- source_distro ++ set(src_distro_keys + rhui_keys) + ) ++ ++ ++@pytest.mark.parametrize( ++ "use_config, configured_keys, expected_in_result", ++ [ ++ # RHUI config enabled with keys ++ (True, ["gpg-pubkey-aaaaaaaa-11111111", "gpg-pubkey-bbbbbbbb-22222222"], True), ++ # RHUI config disabled (keys should NOT be included) ++ (False, ["gpg-pubkey-12345678-abcdefgh"], False), ++ # Empty key list ++ (True, [], False), ++ ] ++) ++def test_get_rhui_configured_keys(monkeypatch, use_config, configured_keys, expected_in_result): ++ """Test that RHUI configured keys are returned only when use_config=True""" ++ rhui_config = {} ++ if use_config is not None: ++ rhui_config[RhuiUseConfig.name] = use_config ++ if configured_keys is not None: ++ rhui_config[RhuiObsoleteGpgKeys.name] = configured_keys ++ ++ all_config = {'rhui': rhui_config} ++ ++ monkeypatch.setattr( ++ api, "current_actor", CurrentActorMocked(config=all_config) ++ ) ++ monkeypatch.setattr( ++ removeobsoleterpmgpgkeys, "_is_key_installed", lambda key: key in configured_keys ++ ) ++ ++ result = removeobsoleterpmgpgkeys._get_rhui_configured_keys() ++ ++ if expected_in_result: ++ assert set(result) == set(configured_keys) ++ else: ++ assert result == [] ++ ++ ++def test_get_rhui_configured_keys_raises_on_invalid(monkeypatch): ++ """Test that RHUI configured keys are returned only when use_config=True""" ++ rhui_config = { ++ RhuiUseConfig.name: True, ++ RhuiObsoleteGpgKeys.name: ["gpg-pubkey-10-10"], ++ } ++ all_config = {'rhui': rhui_config} ++ ++ monkeypatch.setattr( ++ api, "current_actor", CurrentActorMocked(config=all_config) ++ ) ++ ++ with pytest.raises(StopActorExecutionError): ++ removeobsoleterpmgpgkeys._get_rhui_configured_keys() +diff --git a/repos/system_upgrade/common/configs/rhui.py b/repos/system_upgrade/common/configs/rhui.py +index ade9bab9..4972effe 100644 +--- a/repos/system_upgrade/common/configs/rhui.py ++++ b/repos/system_upgrade/common/configs/rhui.py +@@ -116,6 +116,30 @@ class RhuiTargetRepositoriesToUse(Config): + default = list() + + ++class RhuiObsoleteGpgKeys(Config): ++ section = RHUI_CONFIG_SECTION ++ name = "obsolete_gpg_keys" ++ type_ = fields.List(fields.String()) ++ description = """ ++ List of obsolete cloud vendor GPG keys to remove from the RPM database during the upgrade. ++ ++ These are GPG keys used by cloud providers to sign RHUI client packages. These are NOT ++ Red Hat distribution keys. ++ ++ Each key should be specified in the format of an NVR of the RPM metapackage for the given key after ++ it's imported to rpmdb: ++ gpg-pubkey-- ++ For example: "gpg-pubkey-d4082792-5b32db75" ++ ++ These keys will be removed in addition to any keys marked as obsolete in the ++ distribution-specific configuration. Only keys that are actually installed ++ will be removed. ++ ++ Note: This setting only takes effect when use_config is set to True. ++ """ ++ default = list() ++ ++ + all_rhui_cfg = ( + RhuiTargetPkgs, + RhuiUpgradeFiles, +@@ -123,5 +147,6 @@ all_rhui_cfg = ( + RhuiCloudProvider, + RhuiCloudVariant, + RhuiSourcePkgs, +- RhuiUseConfig ++ RhuiUseConfig, ++ RhuiObsoleteGpgKeys + ) +-- +2.54.0 + diff --git a/0084-Add-inhibitor-for-invalid-fstab-entries-on-pseudo-fi.patch b/0084-Add-inhibitor-for-invalid-fstab-entries-on-pseudo-fi.patch new file mode 100644 index 0000000..cfbd499 --- /dev/null +++ b/0084-Add-inhibitor-for-invalid-fstab-entries-on-pseudo-fi.patch @@ -0,0 +1,274 @@ +From 078a619c24ed1250f0d5f13c96412a051884a289 Mon Sep 17 00:00:00 2001 +From: Pradeep Jagtap +Date: Wed, 20 May 2026 15:45:13 +0530 +Subject: [PATCH 084/108] Add inhibitor for invalid fstab entries on + pseudo-filesystem mountpoints + +Detect /etc/fstab entries that incorrectly define pseudo-filesystem +mountpoints (/proc, /sys, /dev/shm, /run, etc.) with block devices or +wrong filesystem types. Such configurations have been invalid since +RHEL 7 but are typically ignored during normal boot. During the upgrade +the system applies these entries as configured, leading to failures. + +Use an allowlist of valid virtual filesystem sources and types so +entries referencing UUID=, LABEL=, PARTUUID=, or /dev/* paths are +correctly detected and inhibited. + +JIRA: RHEL-17842 +--- + .../actors/checkfstabapifsoverride/actor.py | 26 ++++ + .../libraries/checkfstabapifsoverride.py | 80 ++++++++++++ + .../tests/test_checkfstabapifsoverride.py | 121 ++++++++++++++++++ + 3 files changed, 227 insertions(+) + create mode 100644 repos/system_upgrade/common/actors/checkfstabapifsoverride/actor.py + create mode 100644 repos/system_upgrade/common/actors/checkfstabapifsoverride/libraries/checkfstabapifsoverride.py + create mode 100644 repos/system_upgrade/common/actors/checkfstabapifsoverride/tests/test_checkfstabapifsoverride.py + +diff --git a/repos/system_upgrade/common/actors/checkfstabapifsoverride/actor.py b/repos/system_upgrade/common/actors/checkfstabapifsoverride/actor.py +new file mode 100644 +index 00000000..5747f0fb +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkfstabapifsoverride/actor.py +@@ -0,0 +1,26 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor.checkfstabapifsoverride import check_fstab_api_fs_override ++from leapp.models import StorageInfo ++from leapp.reporting import Report ++from leapp.tags import ChecksPhaseTag, IPUWorkflowTag ++ ++ ++class CheckFstabApiFsOverride(Actor): ++ """ ++ Inhibit upgrade if /etc/fstab contains invalid entries for pseudo-filesystem mountpoints. ++ ++ Paths like /proc, /sys, /dev/shm, /run are managed by the kernel and ++ systemd and have specific requirements for their fstab entries. Defining ++ them with block devices (via /dev/*, UUID=, LABEL=, etc.) or wrong ++ filesystem types has been invalid since RHEL 7, but such configurations ++ are typically ignored during normal boot. During the upgrade, these entries ++ are applied as configured, which may lead to failures. This actor ensures ++ users correct these entries before proceeding. ++ """ ++ name = 'check_fstab_api_fs_override' ++ consumes = (StorageInfo,) ++ produces = (Report,) ++ tags = (ChecksPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ check_fstab_api_fs_override() +diff --git a/repos/system_upgrade/common/actors/checkfstabapifsoverride/libraries/checkfstabapifsoverride.py b/repos/system_upgrade/common/actors/checkfstabapifsoverride/libraries/checkfstabapifsoverride.py +new file mode 100644 +index 00000000..8fce90fd +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkfstabapifsoverride/libraries/checkfstabapifsoverride.py +@@ -0,0 +1,80 @@ ++from leapp import reporting ++from leapp.libraries.stdlib import api, format_list ++from leapp.models import StorageInfo ++ ++API_FS_EXPECTED_TYPES = { ++ '/dev/shm': {'tmpfs'}, ++ '/dev/pts': {'devpts'}, ++ '/dev/mqueue': {'mqueue'}, ++ '/dev/hugepages': {'hugetlbfs'}, ++ '/proc': {'proc'}, ++ '/sys': {'sysfs'}, ++ '/sys/fs/cgroup': {'cgroup', 'cgroup2', 'tmpfs'}, ++ '/sys/fs/selinux': {'selinuxfs'}, ++ '/sys/kernel/debug': {'debugfs'}, ++ '/sys/kernel/security': {'securityfs'}, ++ '/run': {'tmpfs'}, ++ '/run/lock': {'tmpfs'}, ++} ++ ++ ++def check_fstab_api_fs_override(): ++ """ ++ Check fstab entries targeting pseudo-filesystem mountpoints for invalid sources or types. ++ ++ An entry is flagged if its mount point is a known pseudo-filesystem path and ++ either the filesystem type or the source device does not match the expected ++ virtual filesystem set. The 'none' pseudo-source is also accepted. ++ """ ++ storage_info = next(api.consume(StorageInfo), None) ++ if not storage_info: ++ return ++ ++ bad_entries = [] ++ for entry in storage_info.fstab: ++ mount_point = entry.fs_file ++ if mount_point != '/': ++ mount_point = mount_point.rstrip('/') ++ ++ expected = API_FS_EXPECTED_TYPES.get(mount_point) ++ if not expected: ++ continue ++ ++ if entry.fs_vfstype not in expected or entry.fs_spec not in (expected | {'none'}): ++ bad_entries.append(entry) ++ ++ if not bad_entries: ++ return ++ ++ entries_text = format_list( ++ [e.fs_file for e in bad_entries], ++ ) ++ ++ reporting.create_report([ ++ reporting.Title( ++ 'Detected invalid entries in /etc/fstab for pseudo-filesystem mountpoints' ++ ), ++ reporting.Summary( ++ 'Detected /etc/fstab entries that incorrectly define pseudo-filesystem ' ++ 'mountpoints. These mountpoints (e.g. /proc, /sys, /dev/shm, /run) are ' ++ 'managed by the kernel and systemd, and their fstab entries must use the ' ++ 'correct virtual filesystem type and source. Defining them with a block ' ++ 'device (via /dev/*, UUID=, LABEL=, etc.) or a wrong filesystem type is ' ++ 'invalid. Such configurations were common on RHEL 6 and earlier and are ' ++ 'typically ignored during normal boot. However, during the upgrade the ' ++ 'system applies fstab entries as configured, which may lead to failures.\n' ++ 'Problematic entries: {}'.format(entries_text) ++ ), ++ reporting.Remediation( ++ hint=( ++ 'Remove or correct the problematic entries in /etc/fstab. If an entry ' ++ 'is required (e.g. to set specific mount options for compliance), ensure ' ++ 'both the source and the filesystem type match the expected virtual ' ++ "filesystem (e.g. 'tmpfs /dev/shm tmpfs defaults 0 0').\n" ++ 'After editing, run "mount -a" to verify.' ++ ) ++ ), ++ reporting.RelatedResource('file', '/etc/fstab'), ++ reporting.Severity(reporting.Severity.HIGH), ++ reporting.Groups([reporting.Groups.FILESYSTEM, reporting.Groups.INHIBITOR]), ++ ]) +diff --git a/repos/system_upgrade/common/actors/checkfstabapifsoverride/tests/test_checkfstabapifsoverride.py b/repos/system_upgrade/common/actors/checkfstabapifsoverride/tests/test_checkfstabapifsoverride.py +new file mode 100644 +index 00000000..1616021f +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkfstabapifsoverride/tests/test_checkfstabapifsoverride.py +@@ -0,0 +1,121 @@ ++import pytest ++ ++from leapp import reporting ++from leapp.libraries.actor.checkfstabapifsoverride import check_fstab_api_fs_override ++from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked ++from leapp.libraries.stdlib import api ++from leapp.models import FstabEntry, StorageInfo ++ ++ ++def _fstab(fs_spec, fs_file, fs_vfstype, fs_mntops='defaults'): ++ return FstabEntry( ++ fs_spec=fs_spec, fs_file=fs_file, fs_vfstype=fs_vfstype, ++ fs_mntops=fs_mntops, fs_freq='0', fs_passno='0', ++ ) ++ ++ ++# --- Entries that SHOULD inhibit (invalid API fs overrides) --- ++_SHM_LVM = _fstab('/dev/mapper/vg00-shm', '/dev/shm', 'xfs') ++_SHM_TMPFS_LABEL = _fstab('LABEL=mydata', '/dev/shm', 'tmpfs') ++_SHM_LVM_TRAILING = _fstab('/dev/mapper/vg00-shm', '/dev/shm/', 'xfs') ++_PROC_EXT4 = _fstab('/dev/sda5', '/proc', 'ext4') ++_SYS_LVM = _fstab('/dev/mapper/vg00-sys', '/sys', 'xfs') ++_SYS_LVM_SYSFS = _fstab('/dev/mapper/vg00-sys', '/sys', 'sysfs') ++_SYS_UUID = _fstab('UUID=023e51f5-b590-4de5-ab21-cf33ecasasas', '/sys', 'xfs') ++_SYS_UUID_SYSFS = _fstab('UUID=023e51f5-b590-4de5-ab21-cf33ecasasas', '/sys', 'sysfs') ++_RUN_PARTUUID = _fstab('PARTUUID=abcd-1234', '/run', 'ext4') ++_RUN_UUID = _fstab('UUID=aabbccdd-1234-5678-9012-aabbccddeeff', '/run', 'ext4') ++_SHM_UUID = _fstab('UUID=12345678-abcd-ef01-2345-6789abcdef01', '/dev/shm', 'xfs') ++_PROC_BIND = _fstab('/some/dir', '/proc', 'none', fs_mntops='bind') ++ ++# --- Entries that should NOT inhibit --- ++_SHM_TMPFS = _fstab('tmpfs', '/dev/shm', 'tmpfs') ++_PROC_PROC = _fstab('proc', '/proc', 'proc') ++_SYS_SYSFS = _fstab('sysfs', '/sys', 'sysfs') ++_PROC_NONE = _fstab('none', '/proc', 'proc') ++_RUN_TMPFS = _fstab('tmpfs', '/run', 'tmpfs') ++_HOME_EXT4 = _fstab('/dev/mapper/fedora-home', '/home', 'ext4') ++_ROOT_XFS = _fstab('/dev/mapper/fedora-root', '/', 'xfs') ++_VAR_UUID = _fstab('UUID=aabbccdd-1122-3344-5566-778899aabbcc', '/var', 'xfs') ++_OPT_LABEL = _fstab('LABEL=optdata', '/opt', 'ext4') ++_DATA_PARTUUID = _fstab('PARTUUID=abcd-5678', '/data', 'xfs') ++_BOOT_UUID = _fstab('UUID=01234567-89ab-cdef-0123-456789abcdef', '/boot', 'ext2') ++_MNT_LABEL = _fstab('LABEL=backup', '/mnt/backup', 'ext4') ++_SRV_LVM = _fstab('/dev/mapper/vg00-srv', '/srv', 'xfs') ++_HOME_BIND = _fstab('/exports/home', '/home', 'none', fs_mntops='bind') ++_MNT_BIND = _fstab('/data/shared', '/mnt/shared', 'none', fs_mntops='bind') ++ ++ ++@pytest.mark.parametrize( ++ ('fstab_entries', 'should_inhibit'), ++ [ ++ # Invalid overrides of pseudo-filesystem mountpoints ++ ([_SHM_LVM], True), ++ ([_PROC_EXT4], True), ++ ([_SHM_LVM_TRAILING], True), ++ ([_SYS_LVM], True), ++ ([_SYS_LVM_SYSFS], True), ++ ([_SYS_UUID], True), ++ ([_SYS_UUID_SYSFS], True), ++ ([_SHM_TMPFS_LABEL], True), ++ ([_RUN_PARTUUID], True), ++ ([_RUN_UUID], True), ++ ([_SHM_UUID], True), ++ ([_PROC_BIND], True), ++ # Valid pseudo-filesystem entries ++ ([_SHM_TMPFS, _PROC_PROC, _SYS_SYSFS], False), ++ ([_PROC_NONE], False), ++ ([_RUN_TMPFS], False), ++ # Normal mounts (not pseudo-fs paths) with various source types ++ ([_HOME_EXT4, _ROOT_XFS], False), ++ ([_VAR_UUID], False), ++ ([_OPT_LABEL], False), ++ ([_DATA_PARTUUID], False), ++ ([_BOOT_UUID], False), ++ ([_MNT_LABEL], False), ++ ([_SRV_LVM], False), ++ # Bind mounts on normal paths ++ ([_HOME_BIND], False), ++ ([_MNT_BIND], False), ++ # Mixed valid entries ++ ([_HOME_EXT4, _VAR_UUID, _BOOT_UUID, _SHM_TMPFS], False), ++ # Empty fstab ++ ([], False), ++ ], ++) ++def test_check_fstab_api_fs_override(monkeypatch, fstab_entries, should_inhibit): ++ created_reports = create_report_mocked() ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ msgs=[StorageInfo(fstab=fstab_entries)] ++ )) ++ monkeypatch.setattr(reporting, 'create_report', created_reports) ++ ++ check_fstab_api_fs_override() ++ ++ assert bool(created_reports.called) == should_inhibit ++ ++ ++def test_report_lists_only_problematic_entries(monkeypatch): ++ created_reports = create_report_mocked() ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ msgs=[StorageInfo(fstab=[_SHM_LVM, _PROC_EXT4, _HOME_EXT4])] ++ )) ++ monkeypatch.setattr(reporting, 'create_report', created_reports) ++ ++ check_fstab_api_fs_override() ++ ++ assert created_reports.called == 1 ++ summary = created_reports.reports[0]['summary'] ++ assert '/dev/shm' in summary ++ assert '/proc' in summary ++ assert '/home' not in summary ++ ++ ++def test_no_storage_info(monkeypatch): ++ created_reports = create_report_mocked() ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ monkeypatch.setattr(reporting, 'create_report', created_reports) ++ ++ check_fstab_api_fs_override() ++ ++ assert not created_reports.called +-- +2.54.0 + diff --git a/0085-lib-version-Remove-deprecated-is_rhel_realtime-funct.patch b/0085-lib-version-Remove-deprecated-is_rhel_realtime-funct.patch new file mode 100644 index 0000000..efe6dd8 --- /dev/null +++ b/0085-lib-version-Remove-deprecated-is_rhel_realtime-funct.patch @@ -0,0 +1,81 @@ +From 83a3b58313331ff906e6a5823d23d4ac83779699 Mon Sep 17 00:00:00 2001 +From: Matej Matuska +Date: Fri, 29 May 2026 13:32:34 +0200 +Subject: [PATCH 085/108] lib/version: Remove deprecated is_rhel_realtime + function + +The function is deprecated long enough and also importing version lib in +kernel lib would cause unnecessarty circular imports. +--- + .../libraries/config/tests/test_version.py | 13 ----------- + .../common/libraries/config/version.py | 23 ------------------- + 2 files changed, 36 deletions(-) + +diff --git a/repos/system_upgrade/common/libraries/config/tests/test_version.py b/repos/system_upgrade/common/libraries/config/tests/test_version.py +index f36dbc5f..c7054893 100644 +--- a/repos/system_upgrade/common/libraries/config/tests/test_version.py ++++ b/repos/system_upgrade/common/libraries/config/tests/test_version.py +@@ -132,19 +132,6 @@ def test_is_supported_version(monkeypatch, result, src_ver, is_saphana): + assert version.is_supported_version() == result + + +-@pytest.mark.parametrize('result,kernel,release_id', [ +- (False, '3.10.0-790.el7.x86_64', 'rhel'), +- (False, '3.10.0-790.el7.x86_64', 'fedora'), +- (False, '3.10.0-790.35.2.rt666.1133.el7.x86_64', 'fedora'), +- (True, '3.10.0-790.35.2.rt666.1133.el7.x86_64', 'rhel'), +-]) +-@suppress_deprecation(version.is_rhel_realtime) +-def test_is_rhel_realtime(monkeypatch, result, kernel, release_id): +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='7.9', kernel=kernel, +- release_id=release_id)) +- assert version.is_rhel_realtime() == result +- +- + @pytest.mark.parametrize('result,sys_version', [ + ('7', '7.1.0'), + ('7', '7.9'), +diff --git a/repos/system_upgrade/common/libraries/config/version.py b/repos/system_upgrade/common/libraries/config/version.py +index 5c1e30c6..8eb02ef1 100644 +--- a/repos/system_upgrade/common/libraries/config/version.py ++++ b/repos/system_upgrade/common/libraries/config/version.py +@@ -3,7 +3,6 @@ import re + + import six + +-from leapp.libraries.common import kernel as kernel_lib + from leapp.libraries.stdlib import api + from leapp.utils.deprecation import deprecated + +@@ -335,28 +334,6 @@ def is_rhel_alt(): + return conf.os_release.release_id == 'rhel' and conf.kernel[0] == '4' + + +-@deprecated(since='2023-08-15', message='This information is now provided by KernelInfo message.') +-def is_rhel_realtime(): +- """ +- Check whether the original system is RHEL Real Time. +- +- Currently the check is based on the release of the original booted kernel. +- In case of RHEL, we are sure the release contains the ".rt" string and +- non-realtime kernels don't. Let's use this minimalistic check for now. +- In future, we could detect whether the system is preemptive or not based +- on properties of the kernel (e.g. uname -a tells that information). +- +- :return: `True` if the orig system is RHEL RT and `False` otherwise. +- :rtype: bool +- """ +- conf = api.current_actor().configuration +- if conf.os_release.release_id != 'rhel': +- return False +- +- kernel_type = kernel_lib.determine_kernel_type_from_uname(get_source_version(), conf.kernel) +- return kernel_type == kernel_lib.KernelType.REALTIME +- +- + def is_supported_version(): + """ + Verify if the current system version is supported for the upgrade. +-- +2.54.0 + diff --git a/0086-lib-kernel-Fix-rt-kernel-detection-on-centos.patch b/0086-lib-kernel-Fix-rt-kernel-detection-on-centos.patch new file mode 100644 index 0000000..0b48fae --- /dev/null +++ b/0086-lib-kernel-Fix-rt-kernel-detection-on-centos.patch @@ -0,0 +1,142 @@ +From 68e286f5d36fc76ce06e8feff2fe418c96f3a9f5 Mon Sep 17 00:00:00 2001 +From: Matej Matuska +Date: Fri, 29 May 2026 13:33:23 +0200 +Subject: [PATCH 086/108] lib/kernel: Fix rt kernel detection on centos + +The kernel detection function determine_kernel_type_from_uname() didn't +take major-only versions as used for CentOS Stream into account and +therefore it misidentified rt kernels as "ordinary" on CentOS Stream +systems. + +The function is modified to use matches_version() from the version lib +which already handles the versions comparison for CentOS Stream as. + +Jira: RHEL-161560 +--- + .../common/libraries/config/version.py | 13 ++++++ + .../system_upgrade/common/libraries/kernel.py | 10 ++--- + .../common/libraries/tests/test_kernel_lib.py | 43 ++++++++++++++----- + 3 files changed, 49 insertions(+), 17 deletions(-) + +diff --git a/repos/system_upgrade/common/libraries/config/version.py b/repos/system_upgrade/common/libraries/config/version.py +index 8eb02ef1..faba3e68 100644 +--- a/repos/system_upgrade/common/libraries/config/version.py ++++ b/repos/system_upgrade/common/libraries/config/version.py +@@ -171,6 +171,19 @@ def matches_version(match_list, detected): + """ + Check if the `detected` version meets the criteria specified in `match_list`. + ++ For CentOS Stream (which natively uses only major versions), the match list ++ and the detected version are automatically adjusted to ensure intuitive evaluation: ++ ++ - If `match_list` contains only major versions, the comparison is restricted ++ to the major version component: ++ version.matches_version(['> 8', '<= 9'], '9.5') # True ++ ++ - If `match_list` contains major.minor versions but `detected` is a major-only ++ version, the function resolves the input to its defined "virtual version" ++ before evaluation: ++ # e.g., if the virtual version for '9' is defined as '9.5' ++ version.matches_version(['> 8.10', '<= 9.7'], '9') # True ++ + :param match_list: specification of versions to check against + :type match_list: list or tuple of strings in one of the two following forms: + ``['>'|'>='|'<'|'<='] .`` form, where elements are ANDed, +diff --git a/repos/system_upgrade/common/libraries/kernel.py b/repos/system_upgrade/common/libraries/kernel.py +index fc8c1167..0a160e7f 100644 +--- a/repos/system_upgrade/common/libraries/kernel.py ++++ b/repos/system_upgrade/common/libraries/kernel.py +@@ -1,6 +1,7 @@ + from collections import namedtuple + + from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common.config.version import matches_version + from leapp.libraries.stdlib import api, CalledProcessError, run + + KernelPkgInfo = namedtuple('KernelPkgInfo', ('name', 'version', 'release', 'arch', 'nevra')) +@@ -14,6 +15,8 @@ class KernelType: + REALTIME = 'realtime' + + ++# TODO: rename rhel_version to something distro agnostic in the new function ++# when this is deprecated + def determine_kernel_type_from_uname(rhel_version, kernel_uname_r): + """ + Determine kernel type from given kernel release (uname-r). +@@ -23,12 +26,7 @@ def determine_kernel_type_from_uname(rhel_version, kernel_uname_r): + :returns: Kernel type based on a given uname_r + :rtype: KernelType + """ +- version_fragments = rhel_version.split('.') +- major_ver = version_fragments[0] +- minor_ver = version_fragments[1] if len(version_fragments) > 1 else '0' +- rhel_version = (major_ver, minor_ver) +- +- if rhel_version <= ('9', '2'): ++ if matches_version(['<= 9.2'], rhel_version): + uname_r_infixes = { + '.rt': KernelType.REALTIME + } +diff --git a/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py b/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py +index a6696a3c..1687d0dc 100644 +--- a/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py ++++ b/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py +@@ -5,23 +5,44 @@ import pytest + from leapp.exceptions import StopActorExecutionError + from leapp.libraries.common import kernel as kernel_lib + from leapp.libraries.common.kernel import KernelType ++from leapp.libraries.common.testutils import CurrentActorMocked ++from leapp.libraries.stdlib import api + + + @pytest.mark.parametrize( +- ('rhel_version', 'uname_r', 'expected_kernel_type'), ++ ('version', 'uname_r', 'expected_kernel_type'), + ( +- ('7.9', '3.10.0-1160.el7.x86_64', KernelType.ORDINARY), +- ('7.9', '3.10.0-1160.rt56.1131.el7.x86_64', KernelType.REALTIME), +- ('8.7', '4.18.0-425.3.1.el8.x86_64', KernelType.ORDINARY), +- ('8.7', '4.18.0-425.3.1.rt7.213.el8.x86_64', KernelType.REALTIME), +- ('9.2', '5.14.0-284.11.1.el9_2.x86_64', KernelType.ORDINARY), +- ('9.2', '5.14.0-284.11.1.rt14.296.el9_2.x86_64', KernelType.REALTIME), +- ('9.3', '5.14.0-354.el9.x86_64', KernelType.ORDINARY), +- ('9.3', '5.14.0-354.el9.x86_64+rt', KernelType.REALTIME), ++ # (version, virtual_version) ++ (('7.9', None), '3.10.0-1160.el7.x86_64', KernelType.ORDINARY), ++ (('7.9', None), '3.10.0-1160.rt56.1131.el7.x86_64', KernelType.REALTIME), ++ (('8.7', None), '4.18.0-425.3.1.el8.x86_64', KernelType.ORDINARY), ++ (('8.7', None), '4.18.0-425.3.1.rt7.213.el8.x86_64', KernelType.REALTIME), ++ (('9.2', None), '5.14.0-284.11.1.el9_2.x86_64', KernelType.ORDINARY), ++ (('9.2', None), '5.14.0-284.11.1.rt14.296.el9_2.x86_64', KernelType.REALTIME), ++ (('9.3', None), '5.14.0-354.el9.x86_64', KernelType.ORDINARY), ++ (('9.3', None), '5.14.0-354.el9.x86_64+rt', KernelType.REALTIME), ++ # centos ++ (('8', '8.7'), '4.18.0-425.3.1.el8.x86_64', KernelType.ORDINARY), ++ (('8', '8.7'), '4.18.0-425.3.1.rt7.213.el8.x86_64', KernelType.REALTIME), ++ (('9', '9.2'), '5.14.0-284.11.1.el9_2.x86_64', KernelType.ORDINARY), ++ (('9', '9.2'), '5.14.0-284.11.1.rt14.296.el9_2.x86_64', KernelType.REALTIME), ++ (('9', '9.3'), '5.14.0-354.el9.x86_64', KernelType.ORDINARY), ++ (('9', '9.3'), '5.14.0-354.el9.x86_64+rt', KernelType.REALTIME), + ) + ) +-def test_determine_kernel_type_from_uname(rhel_version, uname_r, expected_kernel_type): +- kernel_type = kernel_lib.determine_kernel_type_from_uname(rhel_version, uname_r) ++def test_determine_kernel_type_from_uname(monkeypatch, version, uname_r, expected_kernel_type): ++ real_ver, virtual_ver = version ++ # needed to for lookups of virtual versions in matches_version ++ actor_mock = CurrentActorMocked( ++ release_id='centos' if '.' not in real_ver else 'rhel', ++ src_ver=real_ver, ++ dst_ver="irrelevant", ++ virtual_source_version=virtual_ver or real_ver, ++ virtual_target_version="irrelevant", ++ ) ++ monkeypatch.setattr(api, 'current_actor', actor_mock) ++ ++ kernel_type = kernel_lib.determine_kernel_type_from_uname(real_ver, uname_r) + assert kernel_type == expected_kernel_type + + +-- +2.54.0 + diff --git a/0087-Add-CLAUDE.md-and-YAML-frontmatter-to-skills-for-Cla.patch b/0087-Add-CLAUDE.md-and-YAML-frontmatter-to-skills-for-Cla.patch new file mode 100644 index 0000000..202a64d --- /dev/null +++ b/0087-Add-CLAUDE.md-and-YAML-frontmatter-to-skills-for-Cla.patch @@ -0,0 +1,78 @@ +From 1093f58a66a6221519714332d09126c76c5712c1 Mon Sep 17 00:00:00 2001 +From: karolinku +Date: Thu, 28 May 2026 17:11:17 +0200 +Subject: [PATCH 087/108] Add CLAUDE.md and YAML frontmatter to skills for + Claude Code compatibility + +CLAUDE.md imports AGENTS.md so Claude Code uses the same instructions +as other agents without duplicating content. YAML frontmatter (name, +description) is added to each SKILL.md to satisfy Claude Code's skill +metadata requirements. Add symlink ../skills -> .claude/skills to +enable Claude's auto-discovery of skills. +--- + .claude/skills | 1 + + CLAUDE.md | 1 + + skills/leapp-actor-dev/SKILL.md | 5 +++++ + skills/leapp-test-runner/SKILL.md | 5 +++++ + skills/leapp-unit-tests/SKILL.md | 5 +++++ + 5 files changed, 17 insertions(+) + create mode 120000 .claude/skills + create mode 100644 CLAUDE.md + +diff --git a/.claude/skills b/.claude/skills +new file mode 120000 +index 00000000..42c5394a +--- /dev/null ++++ b/.claude/skills +@@ -0,0 +1 @@ ++../skills +\ No newline at end of file +diff --git a/CLAUDE.md b/CLAUDE.md +new file mode 100644 +index 00000000..141137e7 +--- /dev/null ++++ b/CLAUDE.md +@@ -0,0 +1 @@ ++@import AGENTS.md +diff --git a/skills/leapp-actor-dev/SKILL.md b/skills/leapp-actor-dev/SKILL.md +index 124f3e99..5bfdc97a 100644 +--- a/skills/leapp-actor-dev/SKILL.md ++++ b/skills/leapp-actor-dev/SKILL.md +@@ -1,3 +1,8 @@ ++--- ++name: leapp-actor-dev ++description: Develop or modify leapp-repository actors. Covers actor structure, phase selection, placement rules, and key constraints. ++--- ++ + # Developing Leapp Actors + + ## Actor structure +diff --git a/skills/leapp-test-runner/SKILL.md b/skills/leapp-test-runner/SKILL.md +index 579aeb5d..18640e3f 100644 +--- a/skills/leapp-test-runner/SKILL.md ++++ b/skills/leapp-test-runner/SKILL.md +@@ -1,3 +1,8 @@ ++--- ++name: leapp-test-runner ++description: Run tests and linters for leapp-repository. Covers container-based CI testing, single-actor fast tests, and lint commands. ++--- ++ + # Running Tests and Linters + + All non-container commands (`make lint`, `make test_no_lint`, `make fast_lint`, `make dev_test_no_lint`) require a local virtualenv and `REPOSITORIES` envar (e.g. `REPOSITORIES=common,el9toel10`). Create the virtualenv with `make install-deps` (RHEL/CentOS) or `make install-deps-fedora` (Fedora) — the Makefile handles activation internally, don't source the venv manually. Set `PYTHON_VENV=pythonX.X` to choose the Python version (default: `python3.6`). Container commands are self-contained and recommended as the default. +diff --git a/skills/leapp-unit-tests/SKILL.md b/skills/leapp-unit-tests/SKILL.md +index 894d4271..e5189f31 100644 +--- a/skills/leapp-unit-tests/SKILL.md ++++ b/skills/leapp-unit-tests/SKILL.md +@@ -1,3 +1,8 @@ ++--- ++name: leapp-unit-tests ++description: Write or fix unit tests for leapp-repository actors and shared libraries. Covers test placement, mocking patterns, and project conventions. ++--- ++ + # Writing Unit Tests + + +-- +2.54.0 + diff --git a/0088-Add-inhibitor-for-incorrect-var-run-symlink-configur.patch b/0088-Add-inhibitor-for-incorrect-var-run-symlink-configur.patch new file mode 100644 index 0000000..360ca8e --- /dev/null +++ b/0088-Add-inhibitor-for-incorrect-var-run-symlink-configur.patch @@ -0,0 +1,249 @@ +From 342e14b4e05524fd0925aeba1f3294b3cbd8efcf Mon Sep 17 00:00:00 2001 +From: Pradeep Jagtap +Date: Mon, 1 Jun 2026 16:38:46 +0530 +Subject: [PATCH 088/108] Add inhibitor for incorrect /var/run symlink + configuration + +In modern Linux systems that use systemd, /run is a tmpfs filesystem +managed by systemd and /var/run must be a symbolic link pointing to +../run for compatibility. Inhibit the upgrade if /var/run is not +configured as expected, as this can cause boot failures, service +startup failures, or login issues. + +Extended the FileInfo model with a real_path field and add /var/run to +the scansourcefiles tracked files list. The ChecksPhase actor consumes +the TrackedFilesInfoSource message to detect this unsupported +configuration and inhibits the upgrade. + +JIRA: RHEL-76846 +--- + .../common/actors/checkvarrunsymlink/actor.py | 25 ++++++++++ + .../libraries/checkvarrunsymlink.py | 49 +++++++++++++++++++ + .../tests/test_checkvarrunsymlink.py | 41 ++++++++++++++++ + .../libraries/scansourcefiles.py | 5 +- + .../tests/unit_test_scansourcefiles.py | 23 +++++---- + .../common/models/trackedfiles.py | 5 ++ + 6 files changed, 138 insertions(+), 10 deletions(-) + create mode 100644 repos/system_upgrade/common/actors/checkvarrunsymlink/actor.py + create mode 100644 repos/system_upgrade/common/actors/checkvarrunsymlink/libraries/checkvarrunsymlink.py + create mode 100644 repos/system_upgrade/common/actors/checkvarrunsymlink/tests/test_checkvarrunsymlink.py + +diff --git a/repos/system_upgrade/common/actors/checkvarrunsymlink/actor.py b/repos/system_upgrade/common/actors/checkvarrunsymlink/actor.py +new file mode 100644 +index 00000000..512ed1a8 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkvarrunsymlink/actor.py +@@ -0,0 +1,25 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import checkvarrunsymlink ++from leapp.models import TrackedFilesInfoSource ++from leapp.reporting import Report ++from leapp.tags import ChecksPhaseTag, IPUWorkflowTag ++ ++ ++class CheckVarRunSymlink(Actor): ++ """ ++ Check that /var/run is a symlink pointing to /run. ++ ++ In modern Linux systems that use systemd, /run is a tmpfs filesystem ++ managed by systemd and /var/run must be a symbolic link pointing to ++ ../run for compatibility. Inhibit the upgrade if /var/run is not ++ configured as expected, as this can cause boot failures, service ++ startup failures, or login issues. ++ """ ++ ++ name = 'check_var_run_symlink' ++ consumes = (TrackedFilesInfoSource,) ++ produces = (Report,) ++ tags = (ChecksPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ checkvarrunsymlink.process() +diff --git a/repos/system_upgrade/common/actors/checkvarrunsymlink/libraries/checkvarrunsymlink.py b/repos/system_upgrade/common/actors/checkvarrunsymlink/libraries/checkvarrunsymlink.py +new file mode 100644 +index 00000000..e2b198c1 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkvarrunsymlink/libraries/checkvarrunsymlink.py +@@ -0,0 +1,49 @@ ++from leapp import reporting ++from leapp.libraries.stdlib import api ++from leapp.models import TrackedFilesInfoSource ++ ++VAR_RUN_PATH = '/var/run' ++ ++ ++def _get_real_path(tracked_files): ++ for finfo in tracked_files.files: ++ if finfo.path == VAR_RUN_PATH: ++ return finfo.real_path ++ return None ++ ++ ++def process(): ++ tracked_files = next(api.consume(TrackedFilesInfoSource), None) ++ if not tracked_files: ++ api.current_logger().warning( ++ 'The TrackedFilesInfoSource message is missing. Skipping the check of /var/run symlink.' ++ ) ++ return ++ ++ real_path = _get_real_path(tracked_files) ++ if real_path is None or real_path == '/run': ++ return ++ ++ reporting.create_report([ ++ reporting.Title('/var/run is not correctly configured'), ++ reporting.Summary( ++ 'In modern Linux systems that use systemd, /run is a tmpfs filesystem' ++ ' managed by systemd and /var/run must be a symbolic link pointing to' ++ ' ../run for compatibility. The current system does not have /var/run' ++ ' configured as expected. This can cause boot failures, service startup' ++ ' failures, or login issues both on the current system and after an' ++ ' in-place upgrade.' ++ ), ++ reporting.Remediation( ++ hint=( ++ 'Back up current /var/run directory if needed and replace it by' ++ ' symbolic link pointing to "../run".' ++ ), ++ commands=[ ++ ['bash', '-c', 'mv /var/run /tmp/var_run.bak && ln -s ../run /var/run'], ++ ] ++ ), ++ reporting.Severity(reporting.Severity.HIGH), ++ reporting.Groups([reporting.Groups.FILESYSTEM, reporting.Groups.INHIBITOR]), ++ reporting.RelatedResource('directory', VAR_RUN_PATH), ++ ]) +diff --git a/repos/system_upgrade/common/actors/checkvarrunsymlink/tests/test_checkvarrunsymlink.py b/repos/system_upgrade/common/actors/checkvarrunsymlink/tests/test_checkvarrunsymlink.py +new file mode 100644 +index 00000000..2df218e8 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkvarrunsymlink/tests/test_checkvarrunsymlink.py +@@ -0,0 +1,41 @@ ++import pytest ++ ++from leapp import reporting ++from leapp.libraries.actor import checkvarrunsymlink ++from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked ++from leapp.libraries.stdlib import api ++from leapp.models import FileInfo, TrackedFilesInfoSource ++from leapp.utils.report import is_inhibitor ++ ++ ++def _msg_varrun(real_path): ++ return TrackedFilesInfoSource(files=[ ++ FileInfo(path='/var/run', exists=True, is_modified=False, real_path=real_path) ++ ]) ++ ++ ++@pytest.mark.parametrize('real_path, should_inhibit', [ ++ ('/run', False), ++ ('/var/run', True), ++ ('/tmp/foo', True), ++]) ++def test_check_var_run_symlink(monkeypatch, real_path, should_inhibit): ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ msgs=[_msg_varrun(real_path)] ++ )) ++ ++ checkvarrunsymlink.process() ++ ++ assert bool(reporting.create_report.called) == should_inhibit ++ if should_inhibit: ++ assert is_inhibitor(reporting.create_report.report_fields) ++ ++ ++def test_check_var_run_missing_message(monkeypatch): ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[])) ++ ++ checkvarrunsymlink.process() ++ ++ assert not reporting.create_report.called +diff --git a/repos/system_upgrade/common/actors/scansourcefiles/libraries/scansourcefiles.py b/repos/system_upgrade/common/actors/scansourcefiles/libraries/scansourcefiles.py +index 16c0e8aa..7c3ddbe3 100644 +--- a/repos/system_upgrade/common/actors/scansourcefiles/libraries/scansourcefiles.py ++++ b/repos/system_upgrade/common/actors/scansourcefiles/libraries/scansourcefiles.py +@@ -10,6 +10,7 @@ from leapp.models import FileInfo, TrackedFilesInfoSource + TRACKED_FILES = { + 'common': [ + '/etc/pki/tls/openssl.cnf', ++ '/var/run', + ], + '8': [ + ], +@@ -56,10 +57,12 @@ def is_modified(input_file): + + + def scan_file(input_file): ++ exists = os.path.exists(input_file) + data = { + 'path': input_file, +- 'exists': os.path.exists(input_file), ++ 'exists': exists, + 'rpm_name': _get_rpm_name(input_file), ++ 'real_path': os.path.realpath(input_file) if exists else '', + } + + if data['rpm_name']: +diff --git a/repos/system_upgrade/common/actors/scansourcefiles/tests/unit_test_scansourcefiles.py b/repos/system_upgrade/common/actors/scansourcefiles/tests/unit_test_scansourcefiles.py +index 40ae2408..ed262126 100644 +--- a/repos/system_upgrade/common/actors/scansourcefiles/tests/unit_test_scansourcefiles.py ++++ b/repos/system_upgrade/common/actors/scansourcefiles/tests/unit_test_scansourcefiles.py +@@ -66,22 +66,27 @@ def test_get_rpm_name_error(monkeypatch): + + + @pytest.mark.parametrize( +- ('input_file', 'exists', 'rpm_name', 'is_modified'), ++ ('input_file', 'exists', 'rpm_name', 'is_modified', 'real_path'), + ( +- ('/not_existing_file', False, '', False), +- ('/not_existing_file_rpm_owned', False, 'rpm', False), +- ('/not_existing_file_rpm_owned_modified', False, 'rpm', True), +- ('/existing_file_not_modified', True, '', False), +- ('/existing_file_owned_by_rpm_not_modified', True, 'rpm', False), +- ('/existing_file_owned_by_rpm_modified', True, 'rpm', True), ++ ('/not_existing_file', False, '', False, ''), ++ ('/not_existing_file_rpm_owned', False, 'rpm', False, ''), ++ ('/not_existing_file_rpm_owned_modified', False, 'rpm', True, ''), ++ ('/existing_file_not_modified', True, '', False, '/existing_file_not_modified'), ++ ('/existing_file_owned_by_rpm_not_modified', True, 'rpm', False, '/existing_file_owned_by_rpm_not_modified'), ++ ('/existing_file_owned_by_rpm_modified', True, 'rpm', True, '/existing_file_owned_by_rpm_modified'), ++ ('/existing_symlink', True, '', False, '/target'), + ) + ) +-def test_scan_file(monkeypatch, input_file, exists, rpm_name, is_modified): ++def test_scan_file(monkeypatch, input_file, exists, rpm_name, is_modified, real_path): + monkeypatch.setattr(scansourcefiles, 'is_modified', lambda _: is_modified) + monkeypatch.setattr(scansourcefiles, '_get_rpm_name', lambda _: rpm_name) + monkeypatch.setattr(os.path, 'exists', lambda _: exists) ++ monkeypatch.setattr(os.path, 'realpath', lambda _: real_path) + +- expected_model_output = FileInfo(path=input_file, exists=exists, rpm_name=rpm_name, is_modified=is_modified) ++ expected_model_output = FileInfo( ++ path=input_file, exists=exists, rpm_name=rpm_name, ++ is_modified=is_modified, real_path=real_path ++ ) + assert scansourcefiles.scan_file(input_file) == expected_model_output + + +diff --git a/repos/system_upgrade/common/models/trackedfiles.py b/repos/system_upgrade/common/models/trackedfiles.py +index f7c2c809..a3f6e9f5 100644 +--- a/repos/system_upgrade/common/models/trackedfiles.py ++++ b/repos/system_upgrade/common/models/trackedfiles.py +@@ -40,6 +40,11 @@ class FileInfo(Model): + In such a case the value is always false. + """ + ++ real_path = fields.String(default="") ++ """ ++ The resolved (real) path of the file. Empty string if path does not exist. ++ """ ++ + + class TrackedFilesInfoSource(Model): + """ +-- +2.54.0 + diff --git a/0089-checkvarrunsymlink-Log-warning-when-var-run-is-missi.patch b/0089-checkvarrunsymlink-Log-warning-when-var-run-is-missi.patch new file mode 100644 index 0000000..c6bb18e --- /dev/null +++ b/0089-checkvarrunsymlink-Log-warning-when-var-run-is-missi.patch @@ -0,0 +1,34 @@ +From c5ef9301838854790d01c2cca086e80f8a781018 Mon Sep 17 00:00:00 2001 +From: Pradeep Jagtap +Date: Wed, 10 Jun 2026 11:42:53 +0530 +Subject: [PATCH 089/108] checkvarrunsymlink: Log warning when /var/run is + missing from tracked files + +Address review feedback from #1546: emit a warning when /var/run +is unexpectedly absent from TrackedFilesInfoSource instead of +silently skipping the check. +--- + .../checkvarrunsymlink/libraries/checkvarrunsymlink.py | 7 ++++++- + 1 file changed, 6 insertions(+), 1 deletion(-) + +diff --git a/repos/system_upgrade/common/actors/checkvarrunsymlink/libraries/checkvarrunsymlink.py b/repos/system_upgrade/common/actors/checkvarrunsymlink/libraries/checkvarrunsymlink.py +index e2b198c1..4e6f030e 100644 +--- a/repos/system_upgrade/common/actors/checkvarrunsymlink/libraries/checkvarrunsymlink.py ++++ b/repos/system_upgrade/common/actors/checkvarrunsymlink/libraries/checkvarrunsymlink.py +@@ -21,7 +21,12 @@ def process(): + return + + real_path = _get_real_path(tracked_files) +- if real_path is None or real_path == '/run': ++ if real_path is None: ++ api.current_logger().warning( ++ '/var/run not found in TrackedFilesInfoSource. Skipping the symlink check.' ++ ) ++ return ++ if real_path == '/run': + return + + reporting.create_report([ +-- +2.54.0 + diff --git a/0090-Add-scandnfcomps-actor-to-scan-installed-DNF-comps.patch b/0090-Add-scandnfcomps-actor-to-scan-installed-DNF-comps.patch new file mode 100644 index 0000000..c1c612e --- /dev/null +++ b/0090-Add-scandnfcomps-actor-to-scan-installed-DNF-comps.patch @@ -0,0 +1,419 @@ +From a9e5a3e2343654504accb11f5d1305cacce22a90 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Tue, 9 Jun 2026 10:46:56 +0000 +Subject: [PATCH 090/108] Add scandnfcomps actor to scan installed DNF comps + +The actor scan installed DNF environments and groups (DNF comps) +which are represented by new InstalledDNFComps model. + +The scan is performed using initialized dnf.Base to load all +available groups and envs and then it checks what is installed based +on the history. + +About each group and environment, only ID and and (pretty) name +are collected. Skipping the scan of other data as we do not have a +use for that at this moment. + +Unit-tests and part of docstrings are assisted by AI. All the other +code had to be manually updated unfortunately. + +Assisted-By: Claude Opus 4.6 +Signed-off-by: Petr Stodulka +--- + .../common/actors/scandnfcomps/actor.py | 22 +++ + .../scandnfcomps/libraries/scandnfcomps.py | 87 +++++++++ + .../scandnfcomps/tests/test_scandnfcomps.py | 175 ++++++++++++++++++ + .../system_upgrade/common/models/dnfcomps.py | 76 ++++++++ + 4 files changed, 360 insertions(+) + create mode 100644 repos/system_upgrade/common/actors/scandnfcomps/actor.py + create mode 100644 repos/system_upgrade/common/actors/scandnfcomps/libraries/scandnfcomps.py + create mode 100644 repos/system_upgrade/common/actors/scandnfcomps/tests/test_scandnfcomps.py + create mode 100644 repos/system_upgrade/common/models/dnfcomps.py + +diff --git a/repos/system_upgrade/common/actors/scandnfcomps/actor.py b/repos/system_upgrade/common/actors/scandnfcomps/actor.py +new file mode 100644 +index 00000000..ddebfce0 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/scandnfcomps/actor.py +@@ -0,0 +1,22 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import scandnfcomps ++from leapp.models import InstalledDNFComps ++from leapp.tags import FactsPhaseTag, IPUWorkflowTag ++ ++ ++class ScanDNFComps(Actor): ++ """ ++ Scan installed DNF comps (environments and groups). ++ ++ Collects information about DNF package environments and groups that are ++ currently installed on the source system and produce InstalledDNFComps ++ message. ++ """ ++ ++ name = 'scan_dnf_comps' ++ consumes = () ++ produces = (InstalledDNFComps,) ++ tags = (FactsPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ scandnfcomps.process() +diff --git a/repos/system_upgrade/common/actors/scandnfcomps/libraries/scandnfcomps.py b/repos/system_upgrade/common/actors/scandnfcomps/libraries/scandnfcomps.py +new file mode 100644 +index 00000000..31b07bc0 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/scandnfcomps/libraries/scandnfcomps.py +@@ -0,0 +1,87 @@ ++import warnings ++ ++from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common.dnflibs import create_dnf_base, DNFRepoError ++from leapp.libraries.stdlib import api ++from leapp.models import DNFEnvironment, DNFGroup, InstalledDNFComps ++ ++try: ++ import dnf ++except ImportError: ++ # NOTE(pstodulk): To stay consistent with other DNF related actors, but ++ # I guess we can get rid of it nowadays. ++ dnf = None ++ warnings.warn('Could not import the `dnf` python module.', ImportWarning) ++ ++ ++def _get_installed_groups(base): ++ """ ++ Query installed DNF comps groups via the DNF base object. ++ ++ Iterates over all available comps groups and filters to those recorded ++ as installed in the DNF history. ++ ++ :param base: Initialized dnf.Base object with filled sack ++ :type base: dnf.Base ++ :returns: List of DNFGroup model instances sorted by group id ++ :rtype: List[DNFGroup] ++ """ ++ groups = [] ++ for grp in base.comps.groups_iter(): ++ if not base.history.group.get(grp.id): ++ # not installed ++ continue ++ groups.append(DNFGroup( ++ id=grp.id, ++ name=grp.ui_name, ++ )) ++ return sorted(groups, key=lambda x: x.id) ++ ++ ++def _get_installed_environments(base): ++ """ ++ Query installed DNF comps environments via the DNF base object. ++ ++ Iterates over all available comps environments and filters to those ++ recorded as installed in the DNF history. ++ ++ :param base: Initialized dnf.Base object with filled sack ++ :type base: dnf.Base ++ :returns: List of DNFEnvironment model instances sorted by environment id ++ :rtype: List[DNFEnvironment] ++ """ ++ environments = [] ++ for env in base.comps.environments_iter(): ++ if not base.history.env.get(env.id): ++ continue ++ environments.append(DNFEnvironment( ++ id=env.id, ++ name=env.ui_name, ++ )) ++ return sorted(environments, key=lambda x: x.id) ++ ++ ++def process(): ++ """ ++ Scan installed DNF comps and produce InstalledDNFComps message. ++ ++ .. seealso:: ++ :func:`create_dnf_base` for exceptions raised when creating dnf.Base ++ """ ++ if not dnf: ++ api.current_logger().debug('DNF is not available, skipping DNF comps scan.') ++ return ++ ++ try: ++ base = create_dnf_base() ++ except DNFRepoError as e: ++ e.details['details'] = e.message ++ raise StopActorExecutionError( ++ message='Cannot obtain information about DNF Groups and Environments', ++ details=e.details ++ ) ++ ++ api.produce(InstalledDNFComps( ++ environments=_get_installed_environments(base), ++ groups=_get_installed_groups(base), ++ )) +diff --git a/repos/system_upgrade/common/actors/scandnfcomps/tests/test_scandnfcomps.py b/repos/system_upgrade/common/actors/scandnfcomps/tests/test_scandnfcomps.py +new file mode 100644 +index 00000000..9641c918 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/scandnfcomps/tests/test_scandnfcomps.py +@@ -0,0 +1,175 @@ ++import pytest ++ ++from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.actor import scandnfcomps ++from leapp.libraries.common.dnflibs import DNFRepoError ++from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked, produce_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import DNFEnvironment, DNFGroup, InstalledDNFComps ++ ++ ++class MockCompsGroup(): ++ def __init__(self, gid, ui_name): ++ self.id = gid ++ self.ui_name = ui_name ++ ++ ++class MockCompsEnvironment(): ++ def __init__(self, env_id, ui_name): ++ self.id = env_id ++ self.ui_name = ui_name ++ ++ ++class MockComps(): ++ def __init__(self, groups=None, environments=None): ++ self._groups = groups or [] ++ self._environments = environments or [] ++ ++ def groups_iter(self): ++ return iter(self._groups) ++ ++ def environments_iter(self): ++ return iter(self._environments) ++ ++ ++class MockHistory(): ++ ++ class _Lookup(): ++ """ ++ Simulates base.history.group or base.history.env ++ ++ group & env have have same "API" that we use, so no need to create ++ a different class for each one. ++ """ ++ ++ def __init__(self, installed_ids): ++ self._installed = set(installed_ids) ++ ++ def get(self, item_id): ++ return item_id in self._installed ++ ++ def __init__(self, installed_group_ids=None, installed_env_ids=None): ++ self.group = MockHistory._Lookup(installed_group_ids or []) ++ self.env = MockHistory._Lookup(installed_env_ids or []) ++ ++ ++class MockDNFBase(): ++ def __init__(self, comps=None, history=None): ++ self.comps = comps or MockComps() ++ self.history = history or MockHistory() ++ ++ ++def test_process_with_groups_and_environments(monkeypatch): ++ comps = MockComps( ++ groups=[ ++ MockCompsGroup('core', 'Core'), ++ MockCompsGroup('base', 'Base'), ++ ], ++ environments=[ ++ MockCompsEnvironment('minimal-environment', 'Minimal Install'), ++ ], ++ ) ++ history = MockHistory( ++ installed_group_ids=['core', 'base'], ++ installed_env_ids=['minimal-environment'], ++ ) ++ base = MockDNFBase(comps=comps, history=history) ++ ++ mocked_producer = produce_mocked() ++ monkeypatch.setattr(scandnfcomps, 'create_dnf_base', lambda: base) ++ monkeypatch.setattr(scandnfcomps, 'dnf', True) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ monkeypatch.setattr(api, 'produce', mocked_producer) ++ ++ scandnfcomps.process() ++ ++ assert mocked_producer.called == 1 ++ msg = mocked_producer.model_instances[0] ++ assert isinstance(msg, InstalledDNFComps) ++ assert len(msg.groups) == 2 ++ assert msg.groups[0].id == 'base' ++ assert msg.groups[0].name == 'Base' ++ assert msg.groups[1].id == 'core' ++ assert msg.groups[1].name == 'Core' ++ assert len(msg.environments) == 1 ++ assert msg.environments[0].id == 'minimal-environment' ++ assert msg.environments[0].name == 'Minimal Install' ++ ++ ++def test_process_filters_not_installed(monkeypatch): ++ """Only groups/environments recorded as installed in history are returned.""" ++ comps = MockComps( ++ groups=[ ++ MockCompsGroup('core', 'Core'), ++ MockCompsGroup('base', 'Base'), ++ MockCompsGroup('extra', 'Extra'), ++ ], ++ environments=[ ++ MockCompsEnvironment('minimal-environment', 'Minimal Install'), ++ MockCompsEnvironment('server-product-environment', 'Server'), ++ ], ++ ) ++ history = MockHistory( ++ installed_group_ids=['core'], ++ installed_env_ids=['server-product-environment'], ++ ) ++ base = MockDNFBase(comps=comps, history=history) ++ ++ mocked_producer = produce_mocked() ++ monkeypatch.setattr(scandnfcomps, 'create_dnf_base', lambda: base) ++ monkeypatch.setattr(scandnfcomps, 'dnf', True) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ monkeypatch.setattr(api, 'produce', mocked_producer) ++ ++ scandnfcomps.process() ++ ++ assert mocked_producer.called == 1 ++ msg = mocked_producer.model_instances[0] ++ assert len(msg.groups) == 1 ++ assert msg.groups[0].id == 'core' ++ assert len(msg.environments) == 1 ++ assert msg.environments[0].id == 'server-product-environment' ++ ++ ++def test_process_empty_comps(monkeypatch): ++ base = MockDNFBase() ++ ++ mocked_producer = produce_mocked() ++ monkeypatch.setattr(scandnfcomps, 'create_dnf_base', lambda: base) ++ monkeypatch.setattr(scandnfcomps, 'dnf', True) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ monkeypatch.setattr(api, 'produce', mocked_producer) ++ ++ scandnfcomps.process() ++ ++ assert mocked_producer.called == 1 ++ msg = mocked_producer.model_instances[0] ++ assert isinstance(msg, InstalledDNFComps) ++ assert msg.groups == [] ++ assert msg.environments == [] ++ ++ ++def test_process_no_dnf(monkeypatch): ++ mocked_producer = produce_mocked() ++ monkeypatch.setattr(scandnfcomps, 'dnf', None) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ monkeypatch.setattr(api, 'produce', mocked_producer) ++ ++ scandnfcomps.process() ++ assert mocked_producer.called == 0 ++ ++ ++def test_process_dnf_repo_error(monkeypatch): ++ def raise_repo_error(): ++ raise DNFRepoError( ++ message='DNF failed to load repositories: repo error', ++ details={'hint': 'Ensure the myrepo repository definition is correct.'} ++ ) ++ ++ monkeypatch.setattr(scandnfcomps, 'create_dnf_base', raise_repo_error) ++ monkeypatch.setattr(scandnfcomps, 'dnf', True) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ with pytest.raises(StopActorExecutionError, match='Cannot obtain information about DNF Groups and Environments'): ++ scandnfcomps.process() +diff --git a/repos/system_upgrade/common/models/dnfcomps.py b/repos/system_upgrade/common/models/dnfcomps.py +new file mode 100644 +index 00000000..896f832d +--- /dev/null ++++ b/repos/system_upgrade/common/models/dnfcomps.py +@@ -0,0 +1,76 @@ ++from leapp.models import fields, Model ++from leapp.topics import SystemFactsTopic ++ ++ ++class DNFGroup(Model): ++ """ ++ Provide information about a single installed DNF comps group. ++ ++ This model is not expected to be produced or consumed by any actors as ++ a standalone message. It is always part of InstalledDNFComps message. ++ """ ++ ++ topic = SystemFactsTopic ++ ++ id = fields.String() ++ """ ++ The DNF comps group identifier (e.g. "core", "base"). ++ """ ++ ++ name = fields.String() ++ """ ++ Human-readable display name of the group. ++ """ ++ ++ # NOTE(pstodulk): possible extension if needed in future, but not implemented now: ++ # (( to list installed packages, split by various groups )) ++ # mandatory_packages = fields.List(fields.String, default=[]) ++ # default_packages = fields.List(fields.String, default=[]) ++ # conditional_packages = fields.List(fields.String, default=[]) ++ # optional_packages = fields.List(fields.String, default=[]) ++ ++ ++class DNFEnvironment(Model): ++ """ ++ Provide information about a single installed DNF comps environment. ++ ++ This model is not expected to be produced or consumed by any actors as ++ a standalone message. It is always part of InstalledDNFComps message. ++ """ ++ ++ topic = SystemFactsTopic ++ ++ id = fields.String() ++ """ ++ The DNF comps environment identifier (e.g. "minimal-environment"). ++ """ ++ ++ name = fields.String() ++ """ ++ Human-readable display name of the environment. ++ """ ++ ++ # NOTE(pstodulk): possible extension if needed in future, but not implemented now: ++ # mandatory_groups = fields.List(fields.String(), default=[]) ++ # optional_groups = fields.List(fields.String(), default=[]) ++ ++ ++class InstalledDNFComps(Model): ++ """ ++ Provide information about installed DNF comps on the source system. ++ ++ Contains lists of installed DNF package environments and groups as ++ tracked by the DNF history database. ++ """ ++ ++ topic = SystemFactsTopic ++ ++ environments = fields.List(fields.Model(DNFEnvironment), default=[]) ++ """ ++ List of installed DNF comps environments. ++ """ ++ ++ groups = fields.List(fields.Model(DNFGroup), default=[]) ++ """ ++ List of installed DNF comps groups. ++ """ +-- +2.54.0 + diff --git a/0091-IPU-9-10-Fix-the-upgrade-of-Graphical-Server.patch b/0091-IPU-9-10-Fix-the-upgrade-of-Graphical-Server.patch new file mode 100644 index 0000000..597f787 --- /dev/null +++ b/0091-IPU-9-10-Fix-the-upgrade-of-Graphical-Server.patch @@ -0,0 +1,165 @@ +From 63ed8d0297197fab585f5c268638b20c07ea428e Mon Sep 17 00:00:00 2001 +From: Tomas Popela +Date: Mon, 9 Mar 2026 10:58:44 +0100 +Subject: [PATCH 091/108] IPU 9 -> 10: Fix the upgrade of Graphical Server + +Prior to RHEL 10 we didn't have a way to enforce some of the desktop +environment changes to be only Server specific (such as disabling +the power button handling or disabling automatic suspendand) hence +we had to enable everything for everyone. + +In RHEL 10 we've added a new subpackage of gnome-settings-daemon, the +gnome-settings-daemon-server-defaults which is only preinstalled on +"Server with GUI" product and on nothing else. But this only works for +clean installations as the comps groups are not synced when doing the +upgrades with Leapp. To fix that we've created this Leapp actor which +will detect if the graphical-server-environment comps group was +installed and only in the case it will preinstall the server-defaults +subpackage. + +Assisted-By: Claude Opus 4.6 +Co-Authored-by: Petr Stodulka + +Jira: RHEL-148697 +--- + .../actors/gnomesettingsdaemoncheck/actor.py | 22 ++++++++ + .../libraries/gnomesettingsdaemoncheck.py | 36 +++++++++++++ + .../tests/test_gnomesettingsdaemoncheck.py | 54 +++++++++++++++++++ + 3 files changed, 112 insertions(+) + create mode 100644 repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/actor.py + create mode 100644 repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/libraries/gnomesettingsdaemoncheck.py + create mode 100644 repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/tests/test_gnomesettingsdaemoncheck.py + +diff --git a/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/actor.py b/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/actor.py +new file mode 100644 +index 00000000..3056c242 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/actor.py +@@ -0,0 +1,22 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import gnomesettingsdaemoncheck ++from leapp.models import InstalledDNFComps, RpmTransactionTasks ++from leapp.tags import ChecksPhaseTag, IPUWorkflowTag ++ ++ ++class GnomeSettingsDaemonCheck(Actor): ++ """ ++ Install gnome-settings-daemon-server-defaults on graphical server upgrades. ++ ++ If the graphical-server-environment comps environment group was installed ++ on the source RHEL 9 system, schedules the installation of the ++ gnome-settings-daemon-server-defaults package during the upgrade to RHEL 10. ++ """ ++ ++ name = 'gnome_settings_daemon_check' ++ consumes = (InstalledDNFComps,) ++ produces = (RpmTransactionTasks,) ++ tags = (ChecksPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ gnomesettingsdaemoncheck.process() +diff --git a/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/libraries/gnomesettingsdaemoncheck.py b/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/libraries/gnomesettingsdaemoncheck.py +new file mode 100644 +index 00000000..6d2ff111 +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/libraries/gnomesettingsdaemoncheck.py +@@ -0,0 +1,36 @@ ++from leapp.libraries.stdlib import api ++from leapp.models import InstalledDNFComps, RpmTransactionTasks ++ ++GSD_SERVER_DEFAULTS_PKG = 'gnome-settings-daemon-server-defaults' ++GRAPHICAL_SERVER_ENV = 'graphical-server-environment' ++ ++ ++def _is_graphical_server_environment_installed(installed_comps): ++ """ ++ Return True if the `GRAPHICAL_SERVER_ENV` comps environment group ++ is installed on the source system, False otherwise. ++ """ ++ return any(env.id == GRAPHICAL_SERVER_ENV for env in installed_comps.environments) ++ ++ ++def process(): ++ installed_comps = next(api.consume(InstalledDNFComps), None) ++ if not installed_comps: ++ api.current_logger().warning( ++ 'Missing information about installed DNF comps: No InstalledDNFComps message.' ++ ) ++ return ++ ++ if not _is_graphical_server_environment_installed(installed_comps): ++ api.current_logger().debug( ++ 'The {} comps environment is not installed; skipping.' ++ .format(GRAPHICAL_SERVER_ENV) ++ ) ++ return ++ ++ api.current_logger().info( ++ 'The {} comps environment is installed.' ++ ' Scheduling installation of {} for the upgrade.' ++ .format(GRAPHICAL_SERVER_ENV, GSD_SERVER_DEFAULTS_PKG) ++ ) ++ api.produce(RpmTransactionTasks(to_install=[GSD_SERVER_DEFAULTS_PKG])) +diff --git a/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/tests/test_gnomesettingsdaemoncheck.py b/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/tests/test_gnomesettingsdaemoncheck.py +new file mode 100644 +index 00000000..53681afd +--- /dev/null ++++ b/repos/system_upgrade/el9toel10/actors/gnomesettingsdaemoncheck/tests/test_gnomesettingsdaemoncheck.py +@@ -0,0 +1,54 @@ ++import pytest ++ ++from leapp.libraries.actor import gnomesettingsdaemoncheck ++from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import DNFEnvironment, InstalledDNFComps, RpmTransactionTasks ++ ++ ++def _installed_comps(env_ids=None): ++ envs = [DNFEnvironment(id=eid, name=eid) for eid in (env_ids or [])] ++ return InstalledDNFComps(environments=envs) ++ ++ ++@pytest.mark.parametrize('env_ids,expected', [ ++ ([gnomesettingsdaemoncheck.GRAPHICAL_SERVER_ENV], True), ++ ([gnomesettingsdaemoncheck.GRAPHICAL_SERVER_ENV, 'minimal-environment'], True), ++ (['minimal-environment'], False), ++ ([], False), ++]) ++def test_is_graphical_server_environment_installed(env_ids, expected): ++ comps = _installed_comps(env_ids) ++ assert gnomesettingsdaemoncheck._is_graphical_server_environment_installed(comps) is expected ++ ++ ++def test_process_produces_install_task(monkeypatch): ++ comps = _installed_comps([gnomesettingsdaemoncheck.GRAPHICAL_SERVER_ENV]) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[comps])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ gnomesettingsdaemoncheck.process() ++ ++ assert api.produce.called == 1 ++ task = api.produce.model_instances[0] ++ assert isinstance(task, RpmTransactionTasks) ++ assert gnomesettingsdaemoncheck.GSD_SERVER_DEFAULTS_PKG in task.to_install ++ ++ ++def test_process_no_install_when_env_not_present(monkeypatch): ++ comps = _installed_comps(['minimal-environment']) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[comps])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ gnomesettingsdaemoncheck.process() ++ ++ assert api.produce.called == 0 ++ ++ ++def test_process_no_install_when_no_comps_message(monkeypatch): ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ gnomesettingsdaemoncheck.process() ++ ++ assert api.produce.called == 0 +-- +2.54.0 + diff --git a/0092-Bump-leapp-framework.patch b/0092-Bump-leapp-framework.patch new file mode 100644 index 0000000..3185f4a --- /dev/null +++ b/0092-Bump-leapp-framework.patch @@ -0,0 +1,26 @@ +From d976a0824d09a02c639a5648427150b77d01d394 Mon Sep 17 00:00:00 2001 +From: Matej Matuska +Date: Thu, 11 Jun 2026 09:53:17 +0200 +Subject: [PATCH 092/108] Bump leapp-framework + +I forgot to bump it here and also in the framework with b70ff3. +--- + packaging/leapp-repository.spec | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/packaging/leapp-repository.spec b/packaging/leapp-repository.spec +index 70b1d7d3..627fd11a 100644 +--- a/packaging/leapp-repository.spec ++++ b/packaging/leapp-repository.spec +@@ -120,7 +120,7 @@ Requires: leapp-repository-dependencies = %{leapp_repo_deps} + + # IMPORTANT: this is capability provided by the leapp framework rpm. + # Check that 'version' instead of the real framework rpm version. +-Requires: leapp-framework >= 6.5, leapp-framework < 7 ++Requires: leapp-framework >= 6.6, leapp-framework < 7 + + # Since we provide sub-commands for the leapp utility, we expect the leapp + # tool to be installed as well. +-- +2.54.0 + diff --git a/0093-raid-mdadm-include-configuration-in-initramfs.patch b/0093-raid-mdadm-include-configuration-in-initramfs.patch new file mode 100644 index 0000000..d71bd3e --- /dev/null +++ b/0093-raid-mdadm-include-configuration-in-initramfs.patch @@ -0,0 +1,491 @@ +From 28fd5ea3fccb9d62bb7de90916eaa7ee8943a50e Mon Sep 17 00:00:00 2001 +From: Michal Hecko +Date: Thu, 28 May 2026 15:30:14 +0200 +Subject: [PATCH 093/108] raid(mdadm): include configuration in initramfs + +Add code to handle detection of mdraid arrays based on the outputs of +`mdadm --detail --scan`. The code is structured to handle raids in +general (there is also dm-raid), i.e., it follows the usual pattern: +scan, check; boot entry addition is not being modified for now. New +model RAIDInfo is introduced, which provides basic information about +RAID technologies used. To activate all arrays during boot, it is +necessary to have rd.md.uuid=UUID on the kernel cmdline for every RAID +array. + +Jira-ref: RHEL-36249 +--- + .../common/actors/checkraid/actor.py | 22 ++++ + .../actors/checkraid/libraries/checkraid.py | 33 +++++ + .../actors/checkraid/tests/test_checkraid.py | 112 ++++++++++++++++ + .../upgradeinitramfsgenerator/actor.py | 2 + + .../libraries/upgradeinitramfsgenerator.py | 5 + + .../common/actors/scanraid/actor.py | 21 +++ + .../actors/scanraid/libraries/scanraid.py | 40 ++++++ + .../actors/scanraid/tests/test_scanraid.py | 122 ++++++++++++++++++ + .../system_upgrade/common/models/raidinfo.py | 19 +++ + 9 files changed, 376 insertions(+) + create mode 100644 repos/system_upgrade/common/actors/checkraid/actor.py + create mode 100644 repos/system_upgrade/common/actors/checkraid/libraries/checkraid.py + create mode 100644 repos/system_upgrade/common/actors/checkraid/tests/test_checkraid.py + create mode 100644 repos/system_upgrade/common/actors/scanraid/actor.py + create mode 100644 repos/system_upgrade/common/actors/scanraid/libraries/scanraid.py + create mode 100644 repos/system_upgrade/common/actors/scanraid/tests/test_scanraid.py + create mode 100644 repos/system_upgrade/common/models/raidinfo.py + +diff --git a/repos/system_upgrade/common/actors/checkraid/actor.py b/repos/system_upgrade/common/actors/checkraid/actor.py +new file mode 100644 +index 00000000..7af1124f +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkraid/actor.py +@@ -0,0 +1,22 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import checkraid ++from leapp.models import RAIDInfo, TargetUserSpaceUpgradeTasks ++from leapp.tags import ChecksPhaseTag, IPUWorkflowTag ++ ++ ++class CheckRaid(Actor): ++ """ ++ Ensure RAID configuration files are available in the target userspace. ++ ++ If mdadm software RAID is in use, copies present mdadm configuration ++ files and directories into the target userspace container so that ++ dracut can include them in the upgrade initramfs. ++ """ ++ ++ name = 'check_raid' ++ consumes = (RAIDInfo,) ++ produces = (TargetUserSpaceUpgradeTasks,) ++ tags = (ChecksPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ checkraid.process() +diff --git a/repos/system_upgrade/common/actors/checkraid/libraries/checkraid.py b/repos/system_upgrade/common/actors/checkraid/libraries/checkraid.py +new file mode 100644 +index 00000000..dbd02f54 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkraid/libraries/checkraid.py +@@ -0,0 +1,33 @@ ++import os ++ ++from leapp.libraries.stdlib import api ++from leapp.models import CopyFile, RAIDInfo, TargetUserSpaceUpgradeTasks ++ ++# Host paths for mdadm configuration (see mdadm.conf(5) FILES section). ++MDADM_CONFIG_PATHS = ( ++ '/etc/mdadm.conf', ++ '/etc/mdadm.conf.d', ++ '/etc/mdadm/mdadm.conf', ++ '/etc/mdadm/mdadm.conf.d', ++) ++ ++ ++def _mdadm_config_paths_present(): ++ for path in MDADM_CONFIG_PATHS: ++ if os.path.isfile(path) or os.path.isdir(path): ++ yield path ++ ++ ++def process(): ++ raid_info = next(api.consume(RAIDInfo), None) ++ if not raid_info or not raid_info.md_arrays: ++ return ++ ++ copy_files = [CopyFile(src=path) for path in _mdadm_config_paths_present()] ++ if not copy_files: ++ api.current_logger().warning( ++ 'mdadm software RAID is in use but no mdadm configuration was found under: %s', ++ ', '.join(MDADM_CONFIG_PATHS), ++ ) ++ else: ++ api.produce(TargetUserSpaceUpgradeTasks(copy_files=copy_files)) +diff --git a/repos/system_upgrade/common/actors/checkraid/tests/test_checkraid.py b/repos/system_upgrade/common/actors/checkraid/tests/test_checkraid.py +new file mode 100644 +index 00000000..02a6122a +--- /dev/null ++++ b/repos/system_upgrade/common/actors/checkraid/tests/test_checkraid.py +@@ -0,0 +1,112 @@ ++import os ++ ++from leapp.libraries.actor import checkraid ++from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import MDArray, RAIDInfo, TargetUserSpaceUpgradeTasks ++ ++ ++def _mock_path_checks(monkeypatch, files=(), directories=()): ++ def isfile(path): ++ return path in files ++ ++ def isdir(path): ++ return path in directories ++ ++ monkeypatch.setattr(os.path, 'isfile', isfile) ++ monkeypatch.setattr(os.path, 'isdir', isdir) ++ ++ ++def _md_arrays(*uuids): ++ return [MDArray(uuid=uuid) for uuid in uuids] ++ ++ ++def test_md_arrays_with_conf_dir(monkeypatch): ++ _mock_path_checks( ++ monkeypatch, ++ files=('/etc/mdadm.conf',), ++ directories=('/etc/mdadm.conf.d',), ++ ) ++ ++ msgs = [RAIDInfo(md_arrays=_md_arrays('aaa:bbb'))] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkraid.process() ++ ++ assert api.produce.called == 1 ++ copy_tasks = [m for m in api.produce.model_instances if isinstance(m, TargetUserSpaceUpgradeTasks)] ++ assert len(copy_tasks) == 1 ++ assert [task.src for task in copy_tasks[0].copy_files] == [ ++ '/etc/mdadm.conf', ++ '/etc/mdadm.conf.d', ++ ] ++ ++ ++def test_md_arrays_without_conf_dir(monkeypatch): ++ _mock_path_checks(monkeypatch, files=('/etc/mdadm.conf',)) ++ ++ msgs = [RAIDInfo(md_arrays=_md_arrays('aaa:bbb'))] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkraid.process() ++ ++ copy_tasks = [m for m in api.produce.model_instances if isinstance(m, TargetUserSpaceUpgradeTasks)] ++ assert len(copy_tasks) == 1 ++ assert [task.src for task in copy_tasks[0].copy_files] == ['/etc/mdadm.conf'] ++ ++ ++def test_md_arrays_alternative_mdadm_conf(monkeypatch): ++ _mock_path_checks( ++ monkeypatch, ++ files=('/etc/mdadm/mdadm.conf',), ++ directories=('/etc/mdadm/mdadm.conf.d',), ++ ) ++ ++ msgs = [RAIDInfo(md_arrays=_md_arrays('aaa:bbb'))] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkraid.process() ++ ++ copy_tasks = [m for m in api.produce.model_instances if isinstance(m, TargetUserSpaceUpgradeTasks)] ++ assert len(copy_tasks) == 1 ++ assert [task.src for task in copy_tasks[0].copy_files] == [ ++ '/etc/mdadm/mdadm.conf', ++ '/etc/mdadm/mdadm.conf.d', ++ ] ++ ++ ++def test_md_arrays_no_config_paths(monkeypatch): ++ _mock_path_checks(monkeypatch) ++ ++ msgs = [RAIDInfo(md_arrays=_md_arrays('aaa:bbb'))] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkraid.process() ++ ++ copy_tasks = [m for m in api.produce.model_instances if isinstance(m, TargetUserSpaceUpgradeTasks)] ++ assert not copy_tasks ++ assert not api.produce.called ++ ++ ++def test_no_raid_info(monkeypatch): ++ msgs = [] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkraid.process() ++ ++ assert not api.produce.called ++ ++ ++def test_no_md_arrays(monkeypatch): ++ msgs = [RAIDInfo(md_arrays=[])] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkraid.process() ++ ++ assert not api.produce.called +diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py +index c0c93036..32016ab0 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py +@@ -7,6 +7,7 @@ from leapp.models import ( + FIPSInfo, + LiveModeConfig, + LVMConfig, ++ RaidInfo, + TargetOSInstallationImage, + TargetUserSpaceInfo, + TargetUserSpaceUpgradeTasks, +@@ -33,6 +34,7 @@ class UpgradeInitramfsGenerator(Actor): + FIPSInfo, + LiveModeConfig, + LVMConfig, ++ RaidInfo, + RequiredUpgradeInitramPackages, # deprecated + TargetOSInstallationImage, + TargetUserSpaceInfo, +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 24f35847..f2b45ae7 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py +@@ -14,6 +14,7 @@ from leapp.models import ( + BootContent, + LiveModeConfig, + LVMConfig, ++ RaidInfo, + TargetOSInstallationImage, + TargetUserSpaceInfo, + TargetUserSpaceUpgradeTasks, +@@ -385,6 +386,10 @@ def generate_initram_disk(context): + if next(api.consume(LVMConfig), None): + env_variables.append('LEAPP_DRACUT_LVMCONF="1"') + ++ raid_info = next(api.consume(RaidInfo), None) ++ if raid_info and raid_info.md_arrays: ++ env_variables.append('LEAPP_DRACUT_MDADMCONF="1"') ++ + env_variables = ' '.join(env_variables) + env_variables = env_variables.format( + kernel_version=_get_target_kernel_version(context), +diff --git a/repos/system_upgrade/common/actors/scanraid/actor.py b/repos/system_upgrade/common/actors/scanraid/actor.py +new file mode 100644 +index 00000000..62234b93 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/scanraid/actor.py +@@ -0,0 +1,21 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import scanraid ++from leapp.models import DistributionSignedRPM, RAIDInfo ++from leapp.tags import FactsPhaseTag, IPUWorkflowTag ++ ++ ++class ScanRaid(Actor): ++ """ ++ Detect whether software RAID is in use on the system. ++ ++ Checks if the mdadm package is installed and whether any MD arrays ++ are currently assembled and active by scanning mdadm configuration. ++ """ ++ ++ name = 'scan_raid' ++ consumes = (DistributionSignedRPM,) ++ produces = (RAIDInfo,) ++ tags = (FactsPhaseTag, IPUWorkflowTag) ++ ++ def process(self): ++ scanraid.process() +diff --git a/repos/system_upgrade/common/actors/scanraid/libraries/scanraid.py b/repos/system_upgrade/common/actors/scanraid/libraries/scanraid.py +new file mode 100644 +index 00000000..a810a238 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/scanraid/libraries/scanraid.py +@@ -0,0 +1,40 @@ ++import re ++ ++from leapp.libraries.common.rpms import has_package ++from leapp.libraries.stdlib import api, CalledProcessError, run ++from leapp.models import DistributionSignedRPM, MDArray, RAIDInfo ++ ++MDADM_SCAN_CMD = ['mdadm', '--detail', '--scan', '--verbose'] ++UUID_PATTERN = re.compile(r'UUID=([0-9a-fA-F:]+)') ++ ++ ++def _scan_md_array_uuids(): ++ try: ++ result = run(MDADM_SCAN_CMD) ++ except CalledProcessError as err: ++ api.current_logger().warning('Failed to scan mdadm arrays: %s', err) ++ return [] ++ ++ uuids = [] ++ for line in result['stdout'].splitlines(): ++ if not line.startswith('ARRAY '): ++ continue ++ match = UUID_PATTERN.search(line) ++ if match: ++ uuids.append(match.group(1)) ++ ++ return uuids ++ ++ ++def process(): ++ if not has_package(DistributionSignedRPM, 'mdadm'): ++ api.current_logger().debug('The mdadm package is not installed. Skipping.') ++ return ++ ++ uuids = _scan_md_array_uuids() ++ if not uuids: ++ api.current_logger().debug('No active mdadm software RAID arrays found.') ++ return ++ ++ api.current_logger().info('Detected active mdadm software RAID arrays.') ++ api.produce(RAIDInfo(md_arrays=[MDArray(uuid=uuid) for uuid in uuids])) +diff --git a/repos/system_upgrade/common/actors/scanraid/tests/test_scanraid.py b/repos/system_upgrade/common/actors/scanraid/tests/test_scanraid.py +new file mode 100644 +index 00000000..255be095 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/scanraid/tests/test_scanraid.py +@@ -0,0 +1,122 @@ ++import pytest ++ ++from leapp.libraries.actor import scanraid ++from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked, produce_mocked ++from leapp.libraries.stdlib import api, CalledProcessError ++from leapp.models import DistributionSignedRPM, MDArray, RAIDInfo, RPM ++ ++_MDADM_RPM = RPM( ++ name='mdadm', ++ version='4.2', ++ release='1.el9', ++ epoch='0', ++ packager='', ++ arch='x86_64', ++ pgpsig='RSA/SHA256, Mon 01 Jan 1970 00:00:00 AM -03, Key ID 199e2f91fd431d51' ++) ++ ++MDADM_SCAN_WITH_ARRAY = ( ++ 'ARRAY /dev/md0 level=raid1 num-devices=2 metadata=1.2 ' ++ 'name=localhost.localdomain:0 UUID=c4acea6e:d56e1598:91822e3f:fb26832c\n' ++) ++ ++MDADM_SCAN_WITH_TWO_ARRAYS = ( ++ 'ARRAY /dev/md0 metadata=1.2 UUID=c4acea6e:d56e1598:91822e3f:fb26832c\n' ++ 'ARRAY /dev/md1 metadata=1.2 UUID=5eb8cf98:a8d13c2e:3e91b4ca:2e1ac678\n' ++) ++ ++ ++class RunMocked: ++ ++ def __init__(self, stdout='', raise_err=False): ++ self.called = 0 ++ self.args = None ++ self.stdout = stdout ++ self.raise_err = raise_err ++ ++ def __call__(self, args, encoding=None): ++ self.called += 1 ++ self.args = args ++ if self.raise_err: ++ raise CalledProcessError( ++ message='A Leapp Command Error occurred.', ++ command=args, ++ result={'signal': None, 'exit_code': 1, 'pid': 0, 'stdout': 'fake', 'stderr': 'fake'} ++ ) ++ assert args == scanraid.MDADM_SCAN_CMD ++ return {'stdout': self.stdout} ++ ++ ++def test_mdadm_not_installed(monkeypatch): ++ msgs = [DistributionSignedRPM(items=[])] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ scanraid.process() ++ ++ assert not api.produce.called ++ ++ ++def test_mdadm_installed_with_active_arrays(monkeypatch): ++ run_mocked = RunMocked(stdout=MDADM_SCAN_WITH_ARRAY) ++ monkeypatch.setattr(scanraid, 'run', run_mocked) ++ ++ msgs = [DistributionSignedRPM(items=[_MDADM_RPM])] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ scanraid.process() ++ ++ assert run_mocked.called == 1 ++ assert api.produce.called == 1 ++ assert len(api.produce.model_instances) == 1 ++ produced = api.produce.model_instances[0] ++ assert isinstance(produced, RAIDInfo) ++ assert produced.md_arrays == [MDArray(uuid='c4acea6e:d56e1598:91822e3f:fb26832c')] ++ ++ ++def test_mdadm_installed_with_multiple_arrays(monkeypatch): ++ run_mocked = RunMocked(stdout=MDADM_SCAN_WITH_TWO_ARRAYS) ++ monkeypatch.setattr(scanraid, 'run', run_mocked) ++ ++ msgs = [DistributionSignedRPM(items=[_MDADM_RPM])] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ scanraid.process() ++ ++ produced = api.produce.model_instances[0] ++ assert [md_array.uuid for md_array in produced.md_arrays] == [ ++ 'c4acea6e:d56e1598:91822e3f:fb26832c', ++ '5eb8cf98:a8d13c2e:3e91b4ca:2e1ac678', ++ ] ++ ++ ++def test_mdadm_installed_no_active_arrays(monkeypatch): ++ run_mocked = RunMocked(stdout='') ++ monkeypatch.setattr(scanraid, 'run', run_mocked) ++ ++ msgs = [DistributionSignedRPM(items=[_MDADM_RPM])] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ scanraid.process() ++ ++ assert run_mocked.called == 1 ++ assert not api.produce.called ++ ++ ++def test_mdadm_installed_scan_failure(monkeypatch): ++ run_mocked = RunMocked(raise_err=True) ++ monkeypatch.setattr(scanraid, 'run', run_mocked) ++ monkeypatch.setattr(api, 'current_logger', logger_mocked()) ++ ++ msgs = [DistributionSignedRPM(items=[_MDADM_RPM])] ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ scanraid.process() ++ ++ assert run_mocked.called == 1 ++ assert not api.produce.called ++ assert api.current_logger.warnmsg +diff --git a/repos/system_upgrade/common/models/raidinfo.py b/repos/system_upgrade/common/models/raidinfo.py +new file mode 100644 +index 00000000..69188f1e +--- /dev/null ++++ b/repos/system_upgrade/common/models/raidinfo.py +@@ -0,0 +1,19 @@ ++from leapp.models import fields, Model ++from leapp.topics import SystemInfoTopic ++ ++ ++class MDArray(Model): ++ """Information about a single mdadm software RAID array.""" ++ ++ topic = SystemInfoTopic ++ ++ uuid = fields.String() ++ """UUID of the mdadm array.""" ++ ++ ++class RAIDInfo(Model): ++ """Information about RAID usage on the source system.""" ++ topic = SystemInfoTopic ++ ++ md_arrays = fields.List(fields.Model(MDArray), default=[]) ++ """List of active mdadm software RAID arrays.""" +-- +2.54.0 + diff --git a/0094-feat-upgrade_initramfs_gen-write-rd.md.uuids-into-cm.patch b/0094-feat-upgrade_initramfs_gen-write-rd.md.uuids-into-cm.patch new file mode 100644 index 0000000..3fac56a --- /dev/null +++ b/0094-feat-upgrade_initramfs_gen-write-rd.md.uuids-into-cm.patch @@ -0,0 +1,235 @@ +From 19384aabcb467ed49e337809db9a0fd305bc3136 Mon Sep 17 00:00:00 2001 +From: Michal Hecko +Date: Thu, 11 Jun 2026 12:18:22 +0200 +Subject: [PATCH 094/108] feat(upgrade_initramfs_gen): write rd.md.uuids into + cmdline.d + +To avoid inflating the kernel cmdline, the rd.md.uuid args required to +activate all md arrays during boot are written into +/etc/cmdline.d/50-leapp.conf inside the target userspace. Dracut then +loads these args during boot, avoiding boot loader limitations. + +Jira-ref: RHEL-36249 +--- + .../upgradeinitramfsgenerator/actor.py | 4 +- + .../libraries/upgradeinitramfsgenerator.py | 40 +++++++++- + .../unit_test_upgradeinitramfsgenerator.py | 74 +++++++++++++++++++ + 3 files changed, 113 insertions(+), 5 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py +index 32016ab0..ee6df26f 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py +@@ -7,7 +7,7 @@ from leapp.models import ( + FIPSInfo, + LiveModeConfig, + LVMConfig, +- RaidInfo, ++ RAIDInfo, + TargetOSInstallationImage, + TargetUserSpaceInfo, + TargetUserSpaceUpgradeTasks, +@@ -34,7 +34,7 @@ class UpgradeInitramfsGenerator(Actor): + FIPSInfo, + LiveModeConfig, + LVMConfig, +- RaidInfo, ++ RAIDInfo, + RequiredUpgradeInitramPackages, # deprecated + TargetOSInstallationImage, + TargetUserSpaceInfo, +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 f2b45ae7..f9a00eec 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py +@@ -14,7 +14,7 @@ from leapp.models import ( + BootContent, + LiveModeConfig, + LVMConfig, +- RaidInfo, ++ RAIDInfo, + TargetOSInstallationImage, + TargetUserSpaceInfo, + TargetUserSpaceUpgradeTasks, +@@ -25,6 +25,7 @@ from leapp.utils.deprecation import suppress_deprecation + + INITRAM_GEN_SCRIPT_NAME = 'generate-initram.sh' + DRACUT_DIR = '/dracut' ++LEAPP_CMDLINE_CONF_PATH = '/etc/cmdline.d/50-leapp.conf' + DEDICATED_LEAPP_PART_URL = 'https://access.redhat.com/solutions/7011704' + + +@@ -329,6 +330,32 @@ def _check_free_space(context): + ) + + ++def write_md_uuid_cmdline_conf(context): ++ """ ++ Write /etc/cmdline.d/50-leapp.conf into the target userspace when MD RAID is in use. ++ ++ :returns: Path of the written file, or None if no file was written. ++ :rtype: str or None ++ """ ++ raid_info = next(api.consume(RAIDInfo), None) ++ if not raid_info or not raid_info.md_arrays: ++ return None ++ ++ md_uuids = ['rd.md.uuid={}'.format(md_array.uuid) for md_array in raid_info.md_arrays] ++ content = '{}\n'.format(' '.join(md_uuids)) ++ ++ context.makedirs(os.path.dirname(LEAPP_CMDLINE_CONF_PATH), exists_ok=True) ++ with context.open(LEAPP_CMDLINE_CONF_PATH, mode='w') as cmdline_conf: ++ cmdline_conf.write(content) ++ ++ api.current_logger().debug( ++ 'Written {path} with content: {content}'.format( ++ path=LEAPP_CMDLINE_CONF_PATH, content=content.rstrip('\n') ++ ) ++ ) ++ return LEAPP_CMDLINE_CONF_PATH ++ ++ + InitramfsIncludes = namedtuple('InitramfsIncludes', ('files', 'dracut_modules', 'kernel_modules')) + + +@@ -375,6 +402,10 @@ def generate_initram_disk(context): + def fmt_module_list(module_list): + return ','.join(mod.name for mod in module_list) + ++ md_cmdline_conf = write_md_uuid_cmdline_conf(context) ++ if md_cmdline_conf: ++ initramfs_includes.files.append(md_cmdline_conf) ++ + env_variables = [ + 'LEAPP_KERNEL_VERSION={kernel_version}', + 'LEAPP_ADD_DRACUT_MODULES="{dracut_modules}"', +@@ -386,8 +417,7 @@ def generate_initram_disk(context): + if next(api.consume(LVMConfig), None): + env_variables.append('LEAPP_DRACUT_LVMCONF="1"') + +- raid_info = next(api.consume(RaidInfo), None) +- if raid_info and raid_info.md_arrays: ++ if md_cmdline_conf: + env_variables.append('LEAPP_DRACUT_MDADMCONF="1"') + + env_variables = ' '.join(env_variables) +@@ -453,6 +483,10 @@ def _generate_livemode_initramfs(context, userspace_initramfs_dest, target_kerne + copy_dracut_modules(context, initramfs_includes.dracut_modules) + copy_kernel_modules(context, initramfs_includes.kernel_modules) + ++ md_cmdline_conf = write_md_uuid_cmdline_conf(context) ++ if md_cmdline_conf: ++ initramfs_includes.files.append(md_cmdline_conf) ++ + dracut_modules = ['livenet', 'dmsquash-live'] + [mod.name for mod in initramfs_includes.dracut_modules] + + cmd = ['dracut', '--verbose', '--compress', 'xz', +diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py +index 165a4df0..b1960578 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py +@@ -17,6 +17,8 @@ from leapp.models import ( # isort:skip + CopyFile, + DracutModule, + KernelModule, ++ MDArray, ++ RAIDInfo, + TargetUserSpaceUpgradeTasks, + UpgradeInitramfsTasks, + ) +@@ -89,6 +91,7 @@ class MockedContext: + self.called_copy_to = [] + self.called_call = [] + self.called_makedirs = [] ++ self.written_files = {} + self.content = set() + self.base_dir = "/base/dir" + """ +@@ -135,6 +138,25 @@ class MockedContext: + def full_path(self, path): + return os.path.join(self.base_dir, os.path.abspath(path).lstrip('/')) + ++ def open(self, path, *args, mode='r', **kwargs): # pylint: disable=no-self-use ++ if 'w' not in mode: ++ raise NotImplementedError('MockedContext.open only supports write mode') ++ ++ written_files = self.written_files ++ contents = [] ++ ++ class _Writer: ++ def write(self, data): # pylint: disable=no-self-use ++ contents.append(data) ++ ++ def __enter__(self): ++ return self ++ ++ def __exit__(self, *dummy): ++ written_files[path] = ''.join(contents) ++ ++ return _Writer() ++ + + class MockedLogger(logger_mocked): + +@@ -186,6 +208,58 @@ def _sort_files(copy_files): + return sorted(copy_files, key=lambda x: (x.src, x.dst)) + + ++@pytest.mark.parametrize('raid_info,expected_path,expected_content', [ ++ (None, None, None), ++ (RAIDInfo(md_arrays=[]), None, None), ++ ( ++ RAIDInfo(md_arrays=[MDArray(uuid='aaa:bbb')]), ++ upgradeinitramfsgenerator.LEAPP_CMDLINE_CONF_PATH, ++ 'rd.md.uuid=aaa:bbb\n', ++ ), ++ ( ++ RAIDInfo(md_arrays=[MDArray(uuid='aaa:bbb'), MDArray(uuid='ccc:ddd')]), ++ upgradeinitramfsgenerator.LEAPP_CMDLINE_CONF_PATH, ++ 'rd.md.uuid=aaa:bbb rd.md.uuid=ccc:ddd\n', ++ ), ++]) ++def test_write_md_uuid_cmdline_conf(monkeypatch, raid_info, expected_path, expected_content): ++ context = MockedContext() ++ msgs = [raid_info] if raid_info is not None else [] ++ monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ result = upgradeinitramfsgenerator.write_md_uuid_cmdline_conf(context) ++ ++ assert result == expected_path ++ if expected_path: ++ assert '/etc/cmdline.d' in context.called_makedirs ++ assert context.written_files[expected_path] == expected_content ++ else: ++ assert not context.written_files ++ assert not context.called_makedirs ++ ++ ++def test_generate_initram_disk_includes_md_cmdline_conf(monkeypatch): ++ context = MockedContext() ++ raid_info = RAIDInfo(md_arrays=[MDArray(uuid='aaa:bbb')]) ++ input_msgs = gen_UDM_list(MODULES[0]) + [raid_info] ++ curr_actor = CurrentActorMocked(msgs=input_msgs, arch=architecture.ARCH_X86_64) ++ monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_actor', curr_actor) ++ monkeypatch.setattr(upgradeinitramfsgenerator, 'copy_dracut_modules', MockedCopyArgs()) ++ monkeypatch.setattr(upgradeinitramfsgenerator, '_get_target_kernel_version', lambda _: '1.0-1.x86_64') ++ monkeypatch.setattr(upgradeinitramfsgenerator, 'copy_kernel_modules', MockedCopyArgs()) ++ monkeypatch.setattr(upgradeinitramfsgenerator, 'copy_boot_files', lambda dummy: None) ++ monkeypatch.setattr(upgradeinitramfsgenerator, '_get_fspace', MockedGetFspace(2*2**30)) ++ ++ upgradeinitramfsgenerator.generate_initram_disk(context) ++ ++ assert context.written_files[upgradeinitramfsgenerator.LEAPP_CMDLINE_CONF_PATH] == 'rd.md.uuid=aaa:bbb\n' ++ assert len(context.called_call) == 1 ++ shell_cmd = context.called_call[0][0][0][2] ++ assert 'LEAPP_DRACUT_INSTALL_FILES="{path}"'.format( ++ path=upgradeinitramfsgenerator.LEAPP_CMDLINE_CONF_PATH ++ ) in shell_cmd ++ assert 'LEAPP_DRACUT_MDADMCONF="1"' in shell_cmd ++ ++ + def test_prepare_userspace_for_initram_no_script(monkeypatch): + monkeypatch.setattr(upgradeinitramfsgenerator.api, 'get_actor_file_path', lambda dummy: None) + with pytest.raises(StopActorExecutionError) as err: +-- +2.54.0 + diff --git a/0095-Update-actions-checkout-action-to-v7.patch b/0095-Update-actions-checkout-action-to-v7.patch new file mode 100644 index 0000000..c762862 --- /dev/null +++ b/0095-Update-actions-checkout-action-to-v7.patch @@ -0,0 +1,53 @@ +From 19e6ac5f04af5d584c7ad5c2607aa35561a9eb67 Mon Sep 17 00:00:00 2001 +From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> +Date: Thu, 18 Jun 2026 19:01:33 +0000 +Subject: [PATCH 095/108] Update actions/checkout action to v7 + +--- + .github/workflows/codespell.yml | 2 +- + .github/workflows/differential-shellcheck.yml | 2 +- + .github/workflows/unit-tests.yml | 2 +- + 3 files changed, 3 insertions(+), 3 deletions(-) + +diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml +index 2ac322e5..03a62d51 100644 +--- a/.github/workflows/codespell.yml ++++ b/.github/workflows/codespell.yml +@@ -14,7 +14,7 @@ jobs: + runs-on: ubuntu-latest + + steps: +- - uses: actions/checkout@v6 ++ - uses: actions/checkout@v7 + - uses: codespell-project/actions-codespell@v2 + with: + ignore_words_list: ro,fo,couldn,repositor,zeor,bootup +diff --git a/.github/workflows/differential-shellcheck.yml b/.github/workflows/differential-shellcheck.yml +index 3b92d771..fc29bf30 100644 +--- a/.github/workflows/differential-shellcheck.yml ++++ b/.github/workflows/differential-shellcheck.yml +@@ -19,7 +19,7 @@ jobs: + + steps: + - name: Repository checkout +- uses: actions/checkout@v6 ++ uses: actions/checkout@v7 + with: + fetch-depth: 0 + +diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml +index f855baa2..bd829936 100644 +--- a/.github/workflows/unit-tests.yml ++++ b/.github/workflows/unit-tests.yml +@@ -52,7 +52,7 @@ jobs: + + steps: + - name: Checkout code +- uses: actions/checkout@v6 ++ uses: actions/checkout@v7 + with: + # NOTE(ivasilev) fetch-depth 0 is critical here as leapp deps discovery depends on specific substring in + # commit message and default 1 option will get us just merge commit which has an unrelevant message. +-- +2.54.0 + diff --git a/0096-Update-distro-values-for-rhui-jobs-follow-up-on-9to1.patch b/0096-Update-distro-values-for-rhui-jobs-follow-up-on-9to1.patch new file mode 100644 index 0000000..be0b66a --- /dev/null +++ b/0096-Update-distro-values-for-rhui-jobs-follow-up-on-9to1.patch @@ -0,0 +1,115 @@ +From 53baea1521ed27991c807f75f03675dbb849701e Mon Sep 17 00:00:00 2001 +From: Daniel Diblik +Date: Wed, 10 Jun 2026 12:23:17 +0200 +Subject: [PATCH 096/108] Update distro values for rhui jobs, follow up on + 9to10 + +Signed-off-by: Daniel Diblik +--- + .packit.yaml | 62 ++++++++++++++++++++++++++++++++-------------------- + 1 file changed, 38 insertions(+), 24 deletions(-) + +diff --git a/.packit.yaml b/.packit.yaml +index 49e251d8..63fe17d3 100644 +--- a/.packit.yaml ++++ b/.packit.yaml +@@ -158,7 +158,7 @@ jobs: + - aws + targets: + epel-8-x86_64: +- distros: [RHEL-8.10-rhui] ++ distros: [RHEL-8-rhui] + identifier: sanity-abstract-8to9-aws + + - &beaker-minimal-8to9-abstract-ondemand +@@ -235,28 +235,6 @@ jobs: + env: + <<: *env-810to96 + +-- &sanity-810to96-aws +- <<: *sanity-abstract-8to9-aws +- trigger: pull_request +- targets: +- epel-8-x86_64: +- distros: [RHEL-8.10-rhui] +- identifier: sanity-8.10to9.6-aws +- tf_extra_params: +- test: +- tmt: +- plan_filter: 'tag:8to9 & tag:rhui-aws-tier0 & enabled:true & tag:-rhsm' +- environments: +- - tmt: +- context: *tmt-context-810to96 +- settings: +- provisioning: +- tags: +- BusinessUnit: sst_upgrades@leapp_upstream_test +- env: +- <<: *env-810to96 +- +- + # ###################################################################### # + # ############################# 8.10 > 9.8 ############################# # + # ###################################################################### # +@@ -311,6 +289,24 @@ jobs: + env: + <<: *env-810to98 + ++- &sanity-810to98-aws ++ <<: *sanity-abstract-8to9-aws ++ trigger: pull_request ++ identifier: sanity-8.10to9.8-aws ++ tf_extra_params: ++ test: ++ tmt: ++ plan_filter: 'tag:8to9 & tag:rhui-aws-tier0 & enabled:true & tag:-rhsm' ++ environments: ++ - tmt: ++ context: *tmt-context-810to98 ++ settings: ++ provisioning: ++ tags: ++ BusinessUnit: sst_upgrades@leapp_upstream_test ++ env: ++ <<: *env-810to98 ++ + # ###################################################################### # + # ############################# 8.10 > 9.9 ############################# # + # ###################################################################### # +@@ -452,7 +448,7 @@ jobs: + - aws + targets: + epel-9-x86_64: +- distros: [RHEL-9.6-rhui] ++ distros: [RHEL-9-rhui] + identifier: sanity-abstract-9to10-aws + + - &beaker-minimal-9to10-abstract-ondemand +@@ -598,6 +594,24 @@ jobs: + env: + <<: *env-98to102 + ++- &sanity-98to102-aws ++ <<: *sanity-abstract-9to10-aws ++ trigger: pull_request ++ identifier: sanity-9.8to10.2-aws ++ tf_extra_params: ++ test: ++ tmt: ++ plan_filter: 'tag:9to10 & tag:rhui-aws-tier0 & enabled:true & tag:-rhsm' ++ environments: ++ - tmt: ++ context: *tmt-context-98to102 ++ settings: ++ provisioning: ++ tags: ++ BusinessUnit: sst_upgrades@leapp_upstream_test ++ env: ++ <<: *env-98to102 ++ + # ###################################################################### # + # ############################# 9.9 > 10.3 ############################# # + # ###################################################################### # +-- +2.54.0 + diff --git a/0097-1-9-Merge-and-refactor-of-repo-related-actors.patch b/0097-1-9-Merge-and-refactor-of-repo-related-actors.patch new file mode 100644 index 0000000..bec09ca --- /dev/null +++ b/0097-1-9-Merge-and-refactor-of-repo-related-actors.patch @@ -0,0 +1,2493 @@ +From 6661f06a594d5e9a136398c183eb7c6b496158d3 Mon Sep 17 00:00:00 2001 +From: karolinku +Date: Tue, 19 May 2026 21:16:59 +0200 +Subject: [PATCH 097/108] [1/9] Merge and refactor of repo-related actors + +Consolidate repositoriesmapping, peseventsscanner, +repositoriesblacklist, and setuptargetrepos into a single actor. +New actor executes four stages (previously handled by separeate +actors) sequentially: + - Load repository mapping (repomap.json) and produce RepositoriesMapping + - Compute blacklisted target repos (e.g. unsupported CRB) + - Process PES events to determine RPM transaction tasks + - Build final list of target repositories + +Data that previously flow through the messages between these +actors is now passed directly as function arguments, simplifying the +workflow and eliminating the circular dependency workaround between +repositoriesblacklist and the rest. + +This is initial commit starting the work. The whole change is split +into 9 commits to improve the readability of the changes. + +Additional initial changes: +* Deprecate RepositoriesBlacklisted model in favour + of RepositoriesBlocklisted. + +* Introduce the `to_block` field in the `RepositoriesSetupTasks` model + to be able to block specific repository IDs. + +* Rename repomap related libraries to distinguish better their + purpose. Original names `repomap` and `repositoriesmapping` next + to each other are confusing. Also, the `repomap` library name was + conflicting often with the code (e.g. variable name `repomap` used + in fuctions when the `repomap` library was imported as well. + It was not clear without checking of the scope what exactly + is executed. I am thinking also about their merge in future, but + let's keep them separated at least for now. + +* Rename the `repositoriesblacklist` library + to `repositoriesblocklist`. It's possible that later or earlier this + change would happen so let's rename it now, so all future changes + are applied under the new filename. + +* Also synchronize quoting in the actor to use single-quotes everywhere. +* All imports and calls in libraries and tests have been adjusted. + +Unit tests updated with assistance of AI. + +Co-Authored-by: Petr Stodulka +Assisted-by: Claude Code (Opus 4.6) +Jira: RHEL-115867 +--- + .../common/actors/peseventsscanner/actor.py | 41 -- + .../tests/files/sample02.json | 1 - + .../tests/files/sample03.json | 0 + .../actors/repositoriesblacklist/actor.py | 31 -- + .../tests/test_repositoriesblacklist.py | 221 ----------- + .../actors/repositoriesmapping/actor.py | 21 - + .../tests/test_scancustommodifications.py | 9 +- + .../common/actors/setuptargetrepos/actor.py | 45 --- + .../libraries/setuptargetrepos_repomap.py | 367 ------------------ + .../actors/targetcontentresolver/actor.py | 65 ++++ + .../libraries/pes_event_parsing.py | 0 + .../libraries/pes_events_scanner.py | 73 ++-- + .../libraries/repomap_calc.py} | 0 + .../libraries/repomap_loader.py} | 7 +- + .../libraries/repositoriesblocklist.py} | 95 +++-- + .../libraries/setuptargetrepos.py | 85 ++-- + .../libraries/targetcontentresolver.py | 39 ++ + .../tests/files/repomap_example.json | 0 + .../tests/files/sample01.json | 0 + .../tests/files/sample04.json | 0 + .../tests/test_event_parsing.py | 0 + .../tests/test_pes_event_scanner.py | 27 +- + .../tests/test_repomap_operations.py} | 6 +- + .../tests/test_repositoriesblocklist.py | 329 ++++++++++++++++ + .../tests/test_setuptargetrepos.py | 83 +++- + .../tests/test_targetcontentresolver.py | 63 +++ + .../tests/unit_test_repomap_loader.py} | 38 +- + .../common/models/repositoriesblacklisted.py | 11 - + .../common/models/repositoriesblocklisted.py | 34 ++ + .../common/models/repositoriessetuptasks.py | 15 +- + 30 files changed, 794 insertions(+), 912 deletions(-) + delete mode 100644 repos/system_upgrade/common/actors/peseventsscanner/actor.py + delete mode 100644 repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample02.json + delete mode 100644 repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample03.json + delete mode 100644 repos/system_upgrade/common/actors/repositoriesblacklist/actor.py + delete mode 100644 repos/system_upgrade/common/actors/repositoriesblacklist/tests/test_repositoriesblacklist.py + delete mode 100644 repos/system_upgrade/common/actors/repositoriesmapping/actor.py + delete mode 100644 repos/system_upgrade/common/actors/setuptargetrepos/actor.py + delete mode 100644 repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos_repomap.py + create mode 100644 repos/system_upgrade/common/actors/targetcontentresolver/actor.py + rename repos/system_upgrade/common/actors/{peseventsscanner => targetcontentresolver}/libraries/pes_event_parsing.py (100%) + rename repos/system_upgrade/common/actors/{peseventsscanner => targetcontentresolver}/libraries/pes_events_scanner.py (93%) + rename repos/system_upgrade/common/actors/{peseventsscanner/libraries/peseventsscanner_repomap.py => targetcontentresolver/libraries/repomap_calc.py} (100%) + rename repos/system_upgrade/common/actors/{repositoriesmapping/libraries/repositoriesmapping.py => targetcontentresolver/libraries/repomap_loader.py} (98%) + rename repos/system_upgrade/common/actors/{repositoriesblacklist/libraries/repositoriesblacklist.py => targetcontentresolver/libraries/repositoriesblocklist.py} (63%) + rename repos/system_upgrade/common/actors/{setuptargetrepos => targetcontentresolver}/libraries/setuptargetrepos.py (71%) + create mode 100644 repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py + rename repos/system_upgrade/common/actors/{repositoriesmapping => targetcontentresolver}/tests/files/repomap_example.json (100%) + rename repos/system_upgrade/common/actors/{peseventsscanner => targetcontentresolver}/tests/files/sample01.json (100%) + rename repos/system_upgrade/common/actors/{peseventsscanner => targetcontentresolver}/tests/files/sample04.json (100%) + rename repos/system_upgrade/common/actors/{peseventsscanner => targetcontentresolver}/tests/test_event_parsing.py (100%) + rename repos/system_upgrade/common/actors/{peseventsscanner => targetcontentresolver}/tests/test_pes_event_scanner.py (96%) + rename repos/system_upgrade/common/actors/{setuptargetrepos/tests/test_repomapping.py => targetcontentresolver/tests/test_repomap_operations.py} (99%) + create mode 100644 repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py + rename repos/system_upgrade/common/actors/{setuptargetrepos => targetcontentresolver}/tests/test_setuptargetrepos.py (74%) + create mode 100644 repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py + rename repos/system_upgrade/common/actors/{repositoriesmapping/tests/unit_test_repositoriesmapping.py => targetcontentresolver/tests/unit_test_repomap_loader.py} (88%) + delete mode 100644 repos/system_upgrade/common/models/repositoriesblacklisted.py + create mode 100644 repos/system_upgrade/common/models/repositoriesblocklisted.py + +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/actor.py b/repos/system_upgrade/common/actors/peseventsscanner/actor.py +deleted file mode 100644 +index f801f1a1..00000000 +--- a/repos/system_upgrade/common/actors/peseventsscanner/actor.py ++++ /dev/null +@@ -1,41 +0,0 @@ +-from leapp.actors import Actor +-from leapp.libraries.actor.pes_events_scanner import process +-from leapp.models import ( +- ConsumedDataAsset, +- DistributionSignedRPM, +- EnabledModules, +- PESRpmTransactionTasks, +- RepositoriesBlacklisted, +- RepositoriesFacts, +- RepositoriesMapping, +- RepositoriesSetupTasks, +- RHUIInfo, +- RpmTransactionTasks +-) +-from leapp.reporting import Report +-from leapp.tags import FactsPhaseTag, IPUWorkflowTag +- +- +-class PesEventsScanner(Actor): +- """ +- Provides data about package events from Package Evolution Service. +- +- After collecting data from a provided JSON file containing Package Evolution Service events, a +- message with relevant data will be produced to help DNF Upgrade transaction calculation. +- """ +- +- name = 'pes_events_scanner' +- consumes = ( +- EnabledModules, +- DistributionSignedRPM, +- RepositoriesBlacklisted, +- RepositoriesFacts, +- RepositoriesMapping, +- RHUIInfo, +- RpmTransactionTasks, +- ) +- produces = (ConsumedDataAsset, PESRpmTransactionTasks, RepositoriesSetupTasks, Report) +- tags = (IPUWorkflowTag, FactsPhaseTag) +- +- def process(self): +- process() +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample02.json b/repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample02.json +deleted file mode 100644 +index 0967ef42..00000000 +--- a/repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample02.json ++++ /dev/null +@@ -1 +0,0 @@ +-{} +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample03.json b/repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample03.json +deleted file mode 100644 +index e69de29b..00000000 +diff --git a/repos/system_upgrade/common/actors/repositoriesblacklist/actor.py b/repos/system_upgrade/common/actors/repositoriesblacklist/actor.py +deleted file mode 100644 +index b8f6135f..00000000 +--- a/repos/system_upgrade/common/actors/repositoriesblacklist/actor.py ++++ /dev/null +@@ -1,31 +0,0 @@ +-from leapp.actors import Actor +-from leapp.libraries.actor import repositoriesblacklist +-from leapp.models import CustomTargetRepository, RepositoriesBlacklisted, RepositoriesFacts, RepositoriesMapping +-from leapp.reporting import Report +-from leapp.tags import FactsPhaseTag, IPUWorkflowTag +- +- +-class RepositoriesBlacklist(Actor): +- """ +- Exclude target repositories provided by Red Hat without support. +- +- Conditions to exclude: +- - there are not such repositories already enabled on the source system +- (e.g. "Optional" repositories) +- - such repositories are not required for the upgrade explicitly by the user +- (e.g. via the --enablerepo option or via the /etc/leapp/files/leapp_upgrade_repositories.repo file) +- +- E.g. CRB repository is provided by Red Hat but it is without the support. +- """ +- +- name = "repositories_blacklist" +- consumes = ( +- CustomTargetRepository, +- RepositoriesFacts, +- RepositoriesMapping, +- ) +- produces = (RepositoriesBlacklisted, Report) +- tags = (IPUWorkflowTag, FactsPhaseTag) +- +- def process(self): +- repositoriesblacklist.process() +diff --git a/repos/system_upgrade/common/actors/repositoriesblacklist/tests/test_repositoriesblacklist.py b/repos/system_upgrade/common/actors/repositoriesblacklist/tests/test_repositoriesblacklist.py +deleted file mode 100644 +index 945007c6..00000000 +--- a/repos/system_upgrade/common/actors/repositoriesblacklist/tests/test_repositoriesblacklist.py ++++ /dev/null +@@ -1,221 +0,0 @@ +-import pytest +- +-from leapp import reporting +-from leapp.libraries.actor import repositoriesblacklist +-from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked +-from leapp.libraries.stdlib import api +-from leapp.models import ( +- CustomTargetRepository, +- PESIDRepositoryEntry, +- RepoMapEntry, +- RepositoriesBlacklisted, +- RepositoriesFacts, +- RepositoriesMapping, +- RepositoryData, +- RepositoryFile +-) +- +- +-@pytest.fixture +-def repofacts_opts_disabled(): +- repos_data = [ +- RepositoryData( +- repoid="codeready-builder-for-rhel-8-x86_64-rpms", +- name="RHEL 8 CRB", +- enabled=False, +- ) +- ] +- repos_files = [ +- RepositoryFile(file="/etc/yum.repos.d/redhat.repo", data=repos_data) +- ] +- return RepositoriesFacts(repositories=repos_files) +- +- +-@pytest.fixture +-def rhel8_crb_pesidrepo(): +- return PESIDRepositoryEntry( +- pesid='rhel8-CRB', +- major_version='8', +- repoid='codeready-builder-for-rhel-8-x86_64-rpms', +- rhui='', +- arch='x86_64', +- channel='ga', +- repo_type='rpm', +- distro='rhel', +- ) +- +- +-@pytest.fixture +-def rhel9_crb_pesidrepo(): +- return PESIDRepositoryEntry( +- pesid='rhel9-CRB', +- major_version='9', +- repoid='codeready-builder-for-rhel-9-x86_64-rpms', +- rhui='', +- arch='x86_64', +- channel='ga', +- repo_type='rpm', +- distro='rhel', +- ) +- +- +-@pytest.fixture +-def repomap_opts_only(rhel8_crb_pesidrepo, rhel9_crb_pesidrepo): +- return RepositoriesMapping( +- mapping=[RepoMapEntry(source='rhel8-CRB', target=['rhel9-CRB'])], +- repositories=[rhel8_crb_pesidrepo, rhel9_crb_pesidrepo] +- ) +- +- +-def test_all_target_optionals_blacklisted_when_no_optional_on_source(monkeypatch, repomap_opts_only): +- """ +- Tests whether every target optional repository gets blacklisted +- if no optional repositories are used on the source system. +- """ +- +- repos_data = [ +- RepositoryData( +- repoid="rhel-8-server-rpms", +- name="RHEL 8 Server", +- enabled=True, +- ) +- ] +- repos_files = [ +- RepositoryFile(file="/etc/yum.repos.d/redhat.repo", data=repos_data) +- ] +- repo_facts = RepositoriesFacts(repositories=repos_files) +- +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts, repomap_opts_only])) +- monkeypatch.setattr(api, 'produce', produce_mocked()) +- monkeypatch.setattr(reporting, 'create_report', produce_mocked()) +- +- repositoriesblacklist.process() +- +- assert api.produce.called +- assert 'codeready-builder-for-rhel-9-x86_64-rpms' in api.produce.model_instances[0].repoids +- +- +-def test_with_no_mapping_for_optional_repos(monkeypatch, repomap_opts_only, repofacts_opts_disabled): +- """ +- Tests whether nothing gets produced if no valid target is found for an optional repository in mapping data. +- """ +- +- repomap_opts_only.repositories[1].pesid = 'test_pesid' +- +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled, repomap_opts_only])) +- monkeypatch.setattr(api, 'produce', produce_mocked()) +- +- repositoriesblacklist.process() +- +- assert not api.produce.called +- +- +-def test_blacklist_produced_when_optional_repo_disabled(monkeypatch, repofacts_opts_disabled, repomap_opts_only): +- """ +- Tests whether a correct blacklist is generated when there is disabled optional repo on the system. +- """ +- +- monkeypatch.setattr( +- api, +- "current_actor", +- CurrentActorMocked(msgs=[repofacts_opts_disabled, repomap_opts_only]), +- ) +- monkeypatch.setattr(api, "produce", produce_mocked()) +- monkeypatch.setattr(reporting, "create_report", produce_mocked()) +- +- repositoriesblacklist.process() +- +- assert api.produce.model_instances, 'A blacklist should get generated.' +- +- expected_blacklisted_repoid = 'codeready-builder-for-rhel-9-x86_64-rpms' +- err_msg = 'Blacklist does not contain expected repoid.' +- assert expected_blacklisted_repoid in api.produce.model_instances[0].repoids, err_msg +- +- +-def test_no_blacklist_produced_when_optional_repo_enabled(monkeypatch, repofacts_opts_disabled, repomap_opts_only): +- """ +- Tests whether nothing is produced when an optional repository is enabled. +- +- Data are set up in such a fashion so that the determined blacklist would not be empty. +- """ +- +- repofacts_opts_disabled.repositories[0].data[0].enabled = True +- +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled, repomap_opts_only])) +- monkeypatch.setattr(api, "produce", produce_mocked()) +- monkeypatch.setattr(reporting, "create_report", produce_mocked()) +- +- repositoriesblacklist.process() +- +- assert not api.produce.called +- +- +-def test_repositoriesblacklist_not_empty(monkeypatch, repofacts_opts_disabled, repomap_opts_only): +- """ +- Tests whether a message containing correct packages from the determined blacklist is produced. +- """ +- +- blacklisted_repoid = 'test' +- monkeypatch.setattr(repositoriesblacklist, "_get_repoids_to_exclude", lambda dummy_mapping: {blacklisted_repoid}) +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled, repomap_opts_only])) +- monkeypatch.setattr(api, "produce", produce_mocked()) +- monkeypatch.setattr(reporting, "create_report", produce_mocked()) +- +- repositoriesblacklist.process() +- assert api.produce.called == 1 +- assert isinstance(api.produce.model_instances[0], RepositoriesBlacklisted) +- assert api.produce.model_instances[0].repoids[0] == blacklisted_repoid +- assert reporting.create_report.called == 1 +- +- +-def test_repositoriesblacklist_empty(monkeypatch, repofacts_opts_disabled, repomap_opts_only): +- """ +- Tests whether nothing is produced if there are some disabled optional +- repos, but an empty blacklist is determined from the repo mapping data. +- """ +- +- msgs_to_feed = [repofacts_opts_disabled, repomap_opts_only] +- +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs_to_feed)) +- monkeypatch.setattr( +- repositoriesblacklist, +- "_get_repoids_to_exclude", +- lambda dummy_mapping: set() +- ) +- monkeypatch.setattr(api, "produce", produce_mocked()) +- +- repositoriesblacklist.process() +- assert api.produce.called == 0 +- +- +-@pytest.mark.parametrize( +- ("enabled_repo", "exp_report_title", "message_produced"), +- [ +- ("codeready-builder-for-rhel-9-x86_64-rpms", "Using repository not supported by Red Hat", False), +- ("some_other_enabled_repo", "Excluded target system repositories", True), +- (None, "Excluded target system repositories", True), +- ], +-) +-def test_enablerepo_option(monkeypatch, +- repofacts_opts_disabled, +- repomap_opts_only, +- enabled_repo, +- exp_report_title, +- message_produced): +- """ +- Tests whether the actor respects CustomTargetRepository messages when constructing the blacklist. +- """ +- +- msgs_to_feed = [repomap_opts_only, repofacts_opts_disabled] +- +- if enabled_repo: +- msgs_to_feed.append(CustomTargetRepository(repoid=enabled_repo)) +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs_to_feed)) +- monkeypatch.setattr(api, 'produce', produce_mocked()) +- monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) +- repositoriesblacklist.process() +- assert reporting.create_report.report_fields["title"] == exp_report_title +- if message_produced: +- assert isinstance(api.produce.model_instances[0], RepositoriesBlacklisted) +- else: +- assert not api.produce.model_instances +diff --git a/repos/system_upgrade/common/actors/repositoriesmapping/actor.py b/repos/system_upgrade/common/actors/repositoriesmapping/actor.py +deleted file mode 100644 +index 3ed4ff7b..00000000 +--- a/repos/system_upgrade/common/actors/repositoriesmapping/actor.py ++++ /dev/null +@@ -1,21 +0,0 @@ +-from leapp.actors import Actor +-from leapp.libraries.actor.repositoriesmapping import scan_repositories +-from leapp.models import ConsumedDataAsset, RepositoriesMapping +-from leapp.tags import FactsPhaseTag, IPUWorkflowTag +- +- +-class RepositoriesMappingScanner(Actor): +- """ +- Produces message containing repository mapping based on provided file. +- +- The actor filters out data irrelevant to the current IPU (data with different +- source/target major versions) from the raw repository mapping data. +- """ +- +- name = 'repository_mapping' +- consumes = () +- produces = (ConsumedDataAsset, RepositoriesMapping,) +- tags = (IPUWorkflowTag, FactsPhaseTag) +- +- def process(self): +- scan_repositories() +diff --git a/repos/system_upgrade/common/actors/scancustommodifications/tests/test_scancustommodifications.py b/repos/system_upgrade/common/actors/scancustommodifications/tests/test_scancustommodifications.py +index 33d0660f..8dbf9b00 100644 +--- a/repos/system_upgrade/common/actors/scancustommodifications/tests/test_scancustommodifications.py ++++ b/repos/system_upgrade/common/actors/scancustommodifications/tests/test_scancustommodifications.py +@@ -44,13 +44,12 @@ S.5....T. c etc/leapp/files/pes-events.json + ('repos/system_upgrade/common/libraries/dnfplugin.py', ''), + ('repos/system_upgrade/common/libraries/testutils.py', ''), + # the rest of false positives discovered by dkubek +- ('repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos_repomap.py', 'setuptargetrepos'), ++ ('repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_calc.py', ++ 'target_content_resolver'), + ('repos/system_upgrade/el8toel9/actors/sssdfacts/libraries/sssdfacts8to9.py', 'sssd_facts_8to9'), + ('repos/system_upgrade/el8toel9/actors/nisscanner/libraries/nisscan.py', 'nis_scanner'), +- ('repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos_repomap.py', 'setuptargetrepos'), +- ('repos/system_upgrade/common/actors/repositoriesmapping/libraries/repositoriesmapping.py', 'repository_mapping'), +- ('repos/system_upgrade/common/actors/peseventsscanner/libraries/peseventsscanner_repomap.py', +- 'pes_events_scanner') ++ ('repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_loader.py', ++ 'target_content_resolver') + ]) + def test_deduce_actor_name_from_file(a_file, name): + assert scancustommodifications.deduce_actor_name(a_file) == name +diff --git a/repos/system_upgrade/common/actors/setuptargetrepos/actor.py b/repos/system_upgrade/common/actors/setuptargetrepos/actor.py +deleted file mode 100644 +index 91855818..00000000 +--- a/repos/system_upgrade/common/actors/setuptargetrepos/actor.py ++++ /dev/null +@@ -1,45 +0,0 @@ +-from leapp.actors import Actor +-from leapp.libraries.actor import setuptargetrepos +-from leapp.models import ( +- CustomTargetRepository, +- InstalledRPM, +- RepositoriesBlacklisted, +- RepositoriesFacts, +- RepositoriesMapping, +- RepositoriesSetupTasks, +- RHUIInfo, +- SkippedRepositories, +- TargetRepositories, +- UsedRepositories +-) +-from leapp.tags import FactsPhaseTag, IPUWorkflowTag +- +- +-class SetupTargetRepos(Actor): +- """ +- Produces list of repositories that should be available to be used during IPU process. +- +- The list of expected target repositories is produced based on: +- * required custom target repositories, +- * discovered enabled repositories, +- * repositories from which originates the installed content, +- * and the system distribution. +- +- The list of repositories can be additionally affected in case of RHEL by +- required channel (e.g. eus) or by detected use of RHUI. +- """ +- +- name = 'setuptargetrepos' +- consumes = (CustomTargetRepository, +- InstalledRPM, +- RepositoriesSetupTasks, +- RepositoriesMapping, +- RepositoriesFacts, +- RepositoriesBlacklisted, +- RHUIInfo, +- UsedRepositories) +- produces = (TargetRepositories, SkippedRepositories) +- tags = (IPUWorkflowTag, FactsPhaseTag) +- +- def process(self): +- setuptargetrepos.process() +diff --git a/repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos_repomap.py b/repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos_repomap.py +deleted file mode 100644 +index 763eddc6..00000000 +--- a/repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos_repomap.py ++++ /dev/null +@@ -1,367 +0,0 @@ +-from leapp.libraries.common.config import get_source_distro_id, get_target_distro_id, get_target_product_channel +-from leapp.libraries.common.config.version import get_source_major_version, get_target_major_version +-from leapp.libraries.stdlib import api +- +-DEFAULT_PESID = { +- '8': 'rhel8-BaseOS', +- '9': 'rhel9-BaseOS', +- '10': 'rhel10-BaseOS' +-} +- +- +-def _get_channel_prio(pesid_repo): +- priorities = { +- 'beta': 0, +- 'ga': 1, +- } +- return priorities.get(pesid_repo.channel, 10) +- +- +-class RepoMapDataHandler: +- """ +- Provide the basic functionality to work with the repository data easily. +- """ +- +- def __init__( +- self, +- repo_map, +- source_distro="", +- target_distro="", +- cloud_provider="", +- default_channels=None, +- ): +- """ +- Initialize the object based on the given RepositoriesMapping msg. +- +- Expects that msg contains just stuff related for the current IPU +- (at least mapping and repos for the used upgrade path and architecture). +- +- :param repo_map: A valid RepositoryMapping message. +- :type repo_map: RepositoryMapping +- :param source_distro: The distribution to map repos from, default to current +- :type source_distro: str +- :param target_distro: The distribution to map repos to, default to current target distro +- :type target_distro: str +- :param default_channels: A list of default channels to use when a target repository +- equivalent exactly matching a source repository was not found. +- :type default_channels: List[str] +- :param prio_channel: Prefer repositories with this channel when looking for target equivalents. +- :type prio_channel: str +- """ +- # NOTE: currently I am keeping this default data structure that is not +- # ideal for work, but there is not any significant impact.. +- self.repositories = repo_map.repositories +- self.mapping = repo_map.mapping +- +- self.source_distro = source_distro or get_source_distro_id() +- self.target_distro = target_distro or get_target_distro_id() +- # FIXME(pstodulk): what about default_channel -> fallback_channel +- # hardcoded always as ga? instead of list of channels.. +- # it'd be possibly confusing naming now... +- self.default_channels = default_channels or ['ga'] +- +- # Make self.prio_channel None if the user did not specify any target channels, so that self.default_channels +- # will be used instead +- self.prio_channel = get_target_product_channel(default=None) +- +- self.cloud_provider = cloud_provider +- +- # Cloud provider might have multiple variants, e.g, aws: (aws, aws-sap-es4) - normalize it +- cloud_providers = ('aws', 'azure', 'google', 'alibaba') +- for provider in cloud_providers: +- if cloud_provider.startswith(provider): +- self.cloud_provider = provider +- break +- +- def set_default_channels(self, default_channels): +- """ +- Set the default channels that are used as a fallback when searching +- for the right target repository. +- +- Usually it's not problem to find a target repository that matches +- the source repository, however in some cases the target repository +- doesn't have to be available in the required (premium) channel but +- could be present in the standard one. +- +- This is used usually for fallbacks and it's prerequisite for the time +- the required channel will not be present for the particular repository. +- E.g. can happen for layered products which has different lifecycles +- and doesn't have to provide a special premium channels at all. E.g. +- the Extras repository has GA and Beta channels, but no EUS. In case the +- EUS is required, returns the GA one instead when this is present. +- +- It's recommended to make the GA ('ga') always present in the default +- list. +- +- :param default_channels: Default channels to use when a target equivalent to a source repository that +- matches its target channel properties exactly could not be found. +- :type default_channels: List[str] +- """ +- self.default_channels = default_channels +- +- def get_pesid_repo_entry(self, repoid, major_version, distro): +- """ +- Retrieve the PESIDRepositoryEntry that matches the given repoid, distro and OS major version +- +- If multiple pesid repo entries with the same repoid were found, the entry with rhui matching the source +- system's rhui info will be returned. If no entry with matching rhui exists, the CDN one is returned if any. +- +- :param repoid: RepoID that the PESIDRepositoryEntry should match. +- :type repoid: str +- :param major_version: Major version that the PESIDRepositoryEntry should match. +- :type major_version: str +- :param distro: Distro that the PESIDRepositoryEntry should match. +- :type distro: str +- :return: The PESIDRepositoryEntry matching the given repoid and major_version or None if no such +- entry could be found. +- :rtype: Optional[PESIDRepositoryEntry] +- """ +- matching_pesid_repos = [] +- for pesid_repo in self.repositories: +- # FIXME(pstodulk): Why we do not check actually architecture here? +- # It seems obvious we should check it, but it's not clear why we +- # don't and investigation might be required. +- # For the investigation: +- # # check repoids matching various architectures +- # # check repoids without $arch in substring on how many architectures they are present +- # Investigate and: add a comment with an explanation or fix the +- # condition. +- if ( +- pesid_repo.repoid == repoid +- and pesid_repo.major_version == major_version +- and pesid_repo.distro == distro +- ): +- matching_pesid_repos.append(pesid_repo) +- +- # FIXME: when a PESID is present for multiple architectures, there +- # are multiple matching repos even though there should really be just +- # one, the condition below fails even though it shouldn't +- if len(matching_pesid_repos) == 1: +- # Perform no heuristics if only a single pesid repository with matching repoid found +- return matching_pesid_repos[0] +- +- # Multiple (different) repositories with the same repoid found (can happen in clouds) - prefer the cloud one +- cdn_pesid_repo = None +- for pesid_repo in matching_pesid_repos: +- if pesid_repo.rhui == self.cloud_provider: +- return pesid_repo +- if not pesid_repo.rhui: +- cdn_pesid_repo = pesid_repo +- +- # If we did not find a repoid for the current cloud provider, return the CDN repository +- return cdn_pesid_repo # might be None e.g. if we are on Azure with an AWS repository enabled (unlikely) +- +- def get_target_pesids(self, source_pesid): +- """ +- Return sorted list of target PES IDs for the given source PES ID. +- +- :param source_pesid: Source PES ID to find equivalents for. +- :type source_pesid: src +- :return: The list of target PES IDs the provided source_pesid is mapped to. +- :rtype: List[PESIDRepositoryEntry] +- """ +- pesids = set() +- for repomap in self.mapping: +- if repomap.source == source_pesid: +- pesids.update(repomap.target) +- return sorted(pesids) +- +- def get_pesid_repos(self, pesid, major_version, distro): +- """ +- Get the list of PESIDRepositoryEntry with the specified PES ID, +- OS major version, and OS release ID. +- +- :param pesid: PES ID of the repositories to be retrieved. +- :type pesid: str +- :param major_version: OS major version of repositories to be retrieved. +- :type major_version: str +- :param distro: OS release ID of repositories to be retrieved, +- :type distro: str +- :return: A list of PESIDRepositoryEntries that match the provided PES ID, OS major version, and OS release ID. +- :rtype: List[PESIDRepositoryEntry] +- """ +- pesid_repos = [] +- for pesid_repo in self.repositories: +- if ( +- pesid_repo.pesid == pesid +- and pesid_repo.major_version == major_version +- and pesid_repo.distro == distro +- ): +- pesid_repos.append(pesid_repo) +- return pesid_repos +- +- def get_source_pesid_repos(self, pesid): +- """ +- Return the list of PESIDRepositoryEntry objects for a specified PES ID +- matching the source OS. +- +- :param pesid: The PES ID for which to retrieve PESIDRepositoryEntries. +- :type pesid: str +- :return: A list of PESIDRepositoryEntries that match the provided PES ID and have +- the OS Major version same as the source OS. +- :rtype: List[PESIDRepositoryEntry] +- """ +- return self.get_pesid_repos(pesid, get_source_major_version(), self.source_distro) +- +- def get_target_pesid_repos(self, pesid): +- """ +- Return the list of PESIDRepositoryEntry objects for a specified PES ID +- matching the target OS. +- +- :param pesid: The PES ID for which to retrieve PESIDRepositoryEntries. +- :type pesid: str +- :return: A list of PESIDRepositoryEntries that match the provided PES ID and have +- the OS Major version same as the target OS. +- :rtype: List[PESIDRepositoryEntry] +- """ +- return self.get_pesid_repos(pesid, get_target_major_version(), self.target_distro) +- +- def _find_repository_target_equivalent(self, src_pesidrepo, target_pesid): +- """ +- Find the target repository that is the best-match to the source one with the given +- target PES ID. +- +- :param src_pesidrepo: The source repository to find equivalent to. +- :type src_pesidrepo: PESIDRepositoryEntry +- :param target_pesid: The target PES ID which the target repository must contain. +- :type target_pesid: str +- :return: A target equivalent of given repository. +- :rtype: Optional[PESIDRepositoryEntry] +- """ +- +- candidates = [] +- for candidate in self.get_target_pesid_repos(target_pesid): +- matches_rhui = candidate.rhui == src_pesidrepo.rhui +- matches_repo_type = candidate.repo_type == 'rpm' +- matches_arch = candidate.arch == api.current_actor().configuration.architecture +- matches_distro = candidate.distro == self.target_distro +- +- if matches_rhui and matches_arch and matches_distro and matches_repo_type: +- # user can specify in future the specific channel should be +- # prioritized always (e.g. want to go to EUS...). +- channel = self.prio_channel or src_pesidrepo.channel +- if candidate.channel == channel: +- return candidate +- candidates.append(candidate) +- +- # Fallback... +- # Could not find exact-match, so go through candidates if we find an +- # alternative in one of default channels (usually just 'ga') +- for channel in self.default_channels: +- for candidate in candidates: +- if channel == candidate.channel: +- return candidate +- +- # This is a case, that must be handled by the caller +- return None +- +- def get_mapped_target_pesid_repos(self, src_pesidrepo): +- """ +- Returns the dict of form {target_pesid: target PESIDRepositoryEntry} containing +- pesids of repositories that is the source pesidrepo mapped to as keys and with +- the best fitting repository for each of the target pesids as values. +- +- The function always returns the best candidate for every target pesid +- that fits to the given source repository. In case no repository is find +- for a pesid, the value in dict is None: {pesid: None} +- +- :param src_pesidrepo: The PESIDRepositoryEntry to find the target pesids and the corresponding +- best-fitting repositories to. +- :type src_pesidrepo: PESIDRepositoryEntry +- :return: A dictionary of the form described above. +- :rtype: Dict[str, PESIDRepositoryEntry] +- """ +- result = {} +- for target_pesid in self.get_target_pesids(src_pesidrepo.pesid): +- result[target_pesid] = self._find_repository_target_equivalent(src_pesidrepo, target_pesid) +- return result +- +- def get_mapped_target_repoids(self, src_pesidrepo): +- """ +- Return the list of target repoids for the given src_pesidrepo. +- +- Some actors do not has to check whether a target repository has been +- found for each target PES ID and details about the target repositories. +- For such actors it's ok to see just list of repoids they should be +- interested about and keep the proper checks for the right actor. +- +- :param src_pesidrepo: The PESIDRepositoryEntry to find the corresponding best-fitting repositories to. +- :type src_pesidrepo: PESIDRepositoryEntry +- :return: A dictionary of the form described above. +- :rtype: Dict[str, PESIDRepositoryEntry] +- """ +- return [repo.repoid for repo in self.get_mapped_target_pesid_repos(src_pesidrepo).values() if repo] +- +- def get_expected_target_pesid_repos(self, src_repoids): +- """ +- Return {target_pesid: PESIDRepositoryEntry} with expected target repositories. +- +- If some repositories are mapped to a target pesid for which no equivalent +- repository is discovered, such a key contains just None value. +- +- :param src_repoids: list of present source repoids that should be mapped to target repositories +- :type src_repoids: List[str] +- :rtype: {str: PESIDRepositoryEntry} +- """ +- # {pesid: target_repo} +- target_repos_best_candidates = {} +- for src_repoid in src_repoids: +- src_pesidrepo = self.get_pesid_repo_entry(src_repoid, get_source_major_version(), self.source_distro) +- if not src_pesidrepo: +- # unmapped or custom repo -> skip this one +- continue +- +- for target_pesid, target_candidate in self.get_mapped_target_pesid_repos(src_pesidrepo).items(): +- best_candidate = target_repos_best_candidates.get(target_pesid, None) +- if not best_candidate: +- # we need to initialize the pesid even when the target_candidate is empty +- # to know we possibly miss something +- target_repos_best_candidates[target_pesid] = target_candidate +- if not target_candidate: +- # It's not crucial in this moment - the pesid can be still filled +- # by other maps. However log the warning as it is still unexpected +- # with the valid repomap data +- api.current_logger().warning( +- 'Cannot find any mapped target repository from the' +- ' {pesid} family for the {repoid} repository.' +- .format(repoid=src_repoid, pesid=target_pesid) +- ) +- continue +- +- if best_candidate and _get_channel_prio(target_candidate) > _get_channel_prio(best_candidate): +- # NOTE(pstodulk): we want just one target repository from the "PESID family" +- # priority: beta < ga < all_else +- # Why all_else? +- # -> we do not expect multiple different premium channels present on the system +- target_repos_best_candidates[target_pesid] = target_candidate +- return target_repos_best_candidates +- +- +-def get_default_repository_channels(repomap, src_repoids): +- """ +- Returns the default repository channels. The 'ga' channel is always included and is the last +- one in the list, so it is the lowest priority when checking for channels. +- +- :param repomap: A RepoMapDataHandler instance containing the RepositoriesMapping data. +- :type repomap: RepoMapDataHandler +- :param src_repoids: Repositories present on the source system. +- :type src_repoids: List[str] +- :rtype: List[str] +- """ +- default_pesid = DEFAULT_PESID[get_source_major_version()] +- top_prio_pesid_repo = None +- for repoid in src_repoids: +- pesid_repo = repomap.get_pesid_repo_entry( +- repoid, get_source_major_version(), get_source_distro_id() +- ) +- if not pesid_repo or pesid_repo.pesid != default_pesid: +- continue +- if not top_prio_pesid_repo or _get_channel_prio(pesid_repo) > _get_channel_prio(top_prio_pesid_repo): +- top_prio_pesid_repo = pesid_repo +- +- # always return at least 'ga' +- if not top_prio_pesid_repo or top_prio_pesid_repo.channel == 'ga': +- return ['ga'] +- +- # keep this order to prefer higher prio to check first +- return [top_prio_pesid_repo.channel, 'ga'] +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/actor.py b/repos/system_upgrade/common/actors/targetcontentresolver/actor.py +new file mode 100644 +index 00000000..6ea95732 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/actor.py +@@ -0,0 +1,65 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor.targetcontentresolver import process ++from leapp.models import ( ++ ConsumedDataAsset, ++ CustomTargetRepository, ++ DistributionSignedRPM, ++ EnabledModules, ++ InstalledRPM, ++ PESRpmTransactionTasks, ++ RepositoriesBlacklisted, ++ RepositoriesBlocklisted, ++ RepositoriesFacts, ++ RepositoriesMapping, ++ RepositoriesSetupTasks, ++ RHUIInfo, ++ RpmTransactionTasks, ++ SkippedRepositories, ++ TargetRepositories, ++ UsedRepositories ++) ++from leapp.reporting import Report ++from leapp.tags import FactsPhaseTag, IPUWorkflowTag ++from leapp.utils.deprecation import suppress_deprecation ++ ++ ++@suppress_deprecation(RepositoriesBlacklisted) ++class TargetContentResolver(Actor): ++ """ ++ Resolve the complete set of target repositories and RPM transaction tasks for the upgrade. ++ ++ This actor consolidates four steps that were previously separate actors: ++ 1. Load the repository mapping (repomap.json) and produce RepositoriesMapping. ++ 2. Determine which target repositories should be excluded (e.g. unsupported CRB repos). ++ 3. Process PES events (pes-events.json) to compute package changes and ++ produce PESRpmTransactionTasks. ++ 4. Determine the final list of target repositories to enable, producing ++ TargetRepositories and SkippedRepositories. ++ """ ++ ++ name = 'target_content_resolver' ++ consumes = ( ++ CustomTargetRepository, ++ DistributionSignedRPM, ++ EnabledModules, ++ InstalledRPM, ++ RepositoriesFacts, ++ RepositoriesSetupTasks, ++ RHUIInfo, ++ RpmTransactionTasks, ++ UsedRepositories, ++ ) ++ produces = ( ++ ConsumedDataAsset, ++ PESRpmTransactionTasks, ++ Report, ++ RepositoriesBlacklisted, ++ RepositoriesBlocklisted, ++ RepositoriesMapping, ++ SkippedRepositories, ++ TargetRepositories, ++ ) ++ tags = (IPUWorkflowTag, FactsPhaseTag) ++ ++ def process(self): ++ process() +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/libraries/pes_event_parsing.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_event_parsing.py +similarity index 100% +rename from repos/system_upgrade/common/actors/peseventsscanner/libraries/pes_event_parsing.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_event_parsing.py +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/libraries/pes_events_scanner.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +similarity index 93% +rename from repos/system_upgrade/common/actors/peseventsscanner/libraries/pes_events_scanner.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +index 6ba16ced..a03071ae 100644 +--- a/repos/system_upgrade/common/actors/peseventsscanner/libraries/pes_events_scanner.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +@@ -3,7 +3,7 @@ from functools import partial + + from leapp import reporting + from leapp.exceptions import StopActorExecutionError +-from leapp.libraries.actor import peseventsscanner_repomap ++from leapp.libraries.actor import repomap_calc + from leapp.libraries.actor.pes_event_parsing import Action, get_pes_events, Package + from leapp.libraries.common import rpms + from leapp.libraries.common.config import get_target_distro_id, version +@@ -16,10 +16,7 @@ from leapp.models import ( + Module, + PESIDRepositoryEntry, + PESRpmTransactionTasks, +- RepositoriesBlacklisted, + RepositoriesFacts, +- RepositoriesMapping, +- RepositoriesSetupTasks, + RHUIInfo, + RpmTransactionTasks + ) +@@ -323,11 +320,14 @@ def report_skipped_packages(title, message, skipped_pkgs, remediation=None): + api.current_logger().info(summary) + + +-def remove_new_packages_from_blacklisted_repos(source_pkgs, target_pkgs): ++def remove_new_packages_from_blacklisted_repos(source_pkgs, target_pkgs, blacklisted_repoids=None): + """ + Remove newly installed packages from blacklisted repositories that were computed to be on the target system. ++ ++ :param blacklisted_repoids: Set of repoids to exclude. If None, an empty set is used. + """ +- blacklisted_repoids = get_blacklisted_repoids() ++ if blacklisted_repoids is None: ++ blacklisted_repoids = set() + new_pkgs = target_pkgs.difference(source_pkgs) + pkgs_from_blacklisted_repos = set(pkg for pkg in new_pkgs if pkg.repository in blacklisted_repoids) + +@@ -340,13 +340,6 @@ def remove_new_packages_from_blacklisted_repos(source_pkgs, target_pkgs): + return blacklisted_repoids, target_pkgs.difference(pkgs_from_blacklisted_repos) + + +-def get_blacklisted_repoids(): +- repos_blacklisted = set() +- for blacklist in api.consume(RepositoriesBlacklisted): +- repos_blacklisted.update(blacklist.repoids) +- return repos_blacklisted +- +- + def get_enabled_repoids(): + """ + Collects repoids of all enabled repositories on the source system. +@@ -364,44 +357,34 @@ def get_enabled_repoids(): + return enabled_repoids + + +-def get_pesid_to_repoid_map(target_pesids): ++def get_pesid_to_repoid_map(target_pesids, repositories_map_msg): + """ + Get a dictionary mapping all PESID repositories to their corresponding repoid. + + :param target_pesids: The set of target PES IDs needed to be mapped ++ :param repositories_map_msg: RepositoriesMapping message. + :return: Dictionary mapping the target_pesids to their corresponding repoid + """ +- +- repositories_map_msgs = api.consume(RepositoriesMapping) +- repositories_map_msg = next(repositories_map_msgs, None) +- if list(repositories_map_msgs): +- api.current_logger().warning('Unexpectedly received more than one RepositoriesMapping message.') +- if not repositories_map_msg: +- raise StopActorExecutionError( +- 'Cannot parse RepositoriesMapping data properly', +- details={'Problem': 'Did not receive a message with mapped repositories'} +- ) +- + rhui_info = next(api.consume(RHUIInfo), None) + cloud_provider = rhui_info.provider if rhui_info else '' + +- repomap = peseventsscanner_repomap.RepoMapDataHandler(repositories_map_msg, cloud_provider=cloud_provider) ++ repomap_handler = repomap_calc.RepoMapDataHandler(repositories_map_msg, cloud_provider=cloud_provider) + + # NOTE: We have to calculate expected target repositories like in the setuptargetrepos actor. + # It's planned to handle this in different a way in future... + + enabled_repoids = get_enabled_repoids() +- default_channels = peseventsscanner_repomap.get_default_repository_channels(repomap, enabled_repoids) +- repomap.set_default_channels(default_channels) ++ default_channels = repomap_calc.get_default_repository_channels(repomap_handler, enabled_repoids) ++ repomap_handler.set_default_channels(default_channels) + +- exp_pesid_repos = repomap.get_expected_target_pesid_repos(enabled_repoids) ++ exp_pesid_repos = repomap_handler.get_expected_target_pesid_repos(enabled_repoids) + # FIXME: this is hack now. In case some packages will need a repository + # with pesid that is not mapped by default regarding the enabled repos, + # let's use this one representative repository (baseos/appstream) to get + # data for a guess of the best repository from the requires target pesid.. + # FIXME: this could now fail in case all repos are disabled... + representative_repo = exp_pesid_repos.get( +- peseventsscanner_repomap.DEFAULT_PESID[version.get_target_major_version()], None ++ repomap_calc.DEFAULT_PESID[version.get_target_major_version()], None + ) + if not representative_repo: + api.current_logger().warning('Cannot determine the representative target base repository.') +@@ -409,7 +392,7 @@ def get_pesid_to_repoid_map(target_pesids): + 'Fallback: Create an artificial representative PESIDRepositoryEntry for the repository mapping' + ) + representative_repo = PESIDRepositoryEntry( +- pesid=peseventsscanner_repomap.DEFAULT_PESID[version.get_target_major_version()], ++ pesid=repomap_calc.DEFAULT_PESID[version.get_target_major_version()], + arch=api.current_actor().configuration.architecture, + major_version=version.get_target_major_version(), + repoid='artificial-repoid', +@@ -429,7 +412,7 @@ def get_pesid_to_repoid_map(target_pesids): + if not representative_repo: + api.current_logger().warning('Cannot find suitable repository for PES ID: {}'.format(pesid)) + continue +- pesid_repo = repomap._find_repository_target_equivalent(representative_repo, pesid) ++ pesid_repo = repomap_handler._find_repository_target_equivalent(representative_repo, pesid) + + if not pesid_repo: + api.current_logger().warning('Cannot find suitable repository for PES ID: {}'.format(pesid)) +@@ -450,7 +433,7 @@ def get_pesid_to_repoid_map(target_pesids): + return repositories_mapping + + +-def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids): ++def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repositories_map_msg): + """Replace packages with PESID in their .repository field with ones that have repoid providing the package.""" + # We want to map only PESIDs - if some package had no events, it will its repository set to source system repoid + packages_with_pesid = {pkg for pkg in packages if pkg.repository not in source_pkgs_repoids} +@@ -458,7 +441,7 @@ def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids): + + required_target_pesids = {pkg.repository for pkg in packages_with_pesid} + +- pesid_to_target_repoid_map = get_pesid_to_repoid_map(required_target_pesids) ++ pesid_to_target_repoid_map = get_pesid_to_repoid_map(required_target_pesids, repositories_map_msg) + + packages_with_unknown_target_repoid = { + pkg +@@ -572,11 +555,19 @@ def include_instructions_from_transaction_configuration(rpm_tasks, transaction_c + modules_to_reset=modules_to_reset) + + +-def process(): ++def scan_pes_events(repositories_map_msg, blacklisted_repoids=None): ++ """ ++ Process PES events and compute RPM transaction tasks. ++ ++ :param repositories_map_msg: RepositoriesMapping message. ++ :param blacklisted_repoids: Set of repoids to exclude from target packages. If None, an empty set is used. ++ :returns: Set of target repoids that need to be enabled, or None if no PES events are found. ++ :rtype: Optional[set] ++ """ + # Retrieve data - installed_pkgs, transaction configuration, pes events + events = get_pes_events('/etc/leapp/files', 'pes-events.json') + if not events: +- return ++ return None + + releases = get_relevant_releases(events) + installed_pkgs = get_installed_pkgs() +@@ -595,16 +586,14 @@ def process(): + events, releases) + + # Packages coming out of the events have PESID as their repository, however, we need real repoid +- target_pkgs = replace_pesids_with_repoids_in_packages(target_pkgs, repoids_of_source_pkgs) ++ target_pkgs = replace_pesids_with_repoids_in_packages(target_pkgs, repoids_of_source_pkgs, repositories_map_msg) + + # Apply the desired repository blacklisting + blacklisted_repoids, target_pkgs = remove_new_packages_from_blacklisted_repos(pkgs_to_begin_computation_with, +- target_pkgs) ++ target_pkgs, blacklisted_repoids) + + # Look at the target packages and determine what repositories to enable +- target_repoids = sorted(set(p.repository for p in target_pkgs) - blacklisted_repoids - repoids_of_source_pkgs) +- repos_to_enable = RepositoriesSetupTasks(to_enable=target_repoids) +- api.produce(repos_to_enable) ++ pes_requested_repoids = set(p.repository for p in target_pkgs) - blacklisted_repoids - repoids_of_source_pkgs + + # Compare the packages on source system and the computed packages on target system and determine what to install + rpm_tasks = compute_rpm_tasks_from_pkg_set_diff(pkgs_to_begin_computation_with, target_pkgs, pkgs_to_demodularize) +@@ -612,3 +601,5 @@ def process(): + installed_pkgs) + if rpm_tasks: + api.produce(rpm_tasks) ++ ++ return pes_requested_repoids +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/libraries/peseventsscanner_repomap.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_calc.py +similarity index 100% +rename from repos/system_upgrade/common/actors/peseventsscanner/libraries/peseventsscanner_repomap.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_calc.py +diff --git a/repos/system_upgrade/common/actors/repositoriesmapping/libraries/repositoriesmapping.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_loader.py +similarity index 98% +rename from repos/system_upgrade/common/actors/repositoriesmapping/libraries/repositoriesmapping.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_loader.py +index 503e66a3..52b8337d 100644 +--- a/repos/system_upgrade/common/actors/repositoriesmapping/libraries/repositoriesmapping.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_loader.py +@@ -183,10 +183,12 @@ def scan_repositories(read_repofile_func=_read_repofile): + mapping = repomap_data.get_mappings(get_source_major_version(), get_target_major_version()) + + valid_major_versions = [get_source_major_version(), get_target_major_version()] +- api.produce(RepositoriesMapping( ++ repositories_mapping = RepositoriesMapping( + mapping=mapping, + repositories=repomap_data.get_repositories(valid_major_versions) +- )) ++ ) ++ api.produce(repositories_mapping) ++ return repositories_mapping + except ModelViolationError as err: + err_message = ( + 'The repository mapping file is invalid: ' +@@ -200,3 +202,4 @@ def scan_repositories(read_repofile_func=_read_repofile): + except ValueError as err: + # The error should contain enough information, so we do not need to clarify it further + _inhibit_upgrade('The repository mapping file is invalid: {}'.format(err)) ++ return None +diff --git a/repos/system_upgrade/common/actors/repositoriesblacklist/libraries/repositoriesblacklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +similarity index 63% +rename from repos/system_upgrade/common/actors/repositoriesblacklist/libraries/repositoriesblacklist.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +index 5059f619..48656187 100644 +--- a/repos/system_upgrade/common/actors/repositoriesblacklist/libraries/repositoriesblacklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +@@ -2,7 +2,14 @@ from leapp import reporting + from leapp.exceptions import StopActorExecutionError + from leapp.libraries.common.config.version import get_source_major_version, get_target_major_version + from leapp.libraries.stdlib import api +-from leapp.models import CustomTargetRepository, RepositoriesBlacklisted, RepositoriesFacts, RepositoriesMapping ++from leapp.models import ( ++ CustomTargetRepository, ++ RepositoriesBlacklisted, ++ RepositoriesBlocklisted, ++ RepositoriesFacts, ++ RepositoriesSetupTasks ++) ++from leapp.utils.deprecation import suppress_deprecation + + # {OS_MAJOR_VERSION: PESID} + UNSUPPORTED_PESIDS = { +@@ -14,11 +21,11 @@ UNSUPPORTED_PESIDS = { + + def _report_using_unsupported_repos(repos): + report = [ +- reporting.Title("Using repository not supported by Red Hat"), ++ reporting.Title('Using repository not supported by Red Hat'), + reporting.Summary( +- "The following repositories have been used for the " +- "upgrade, but they are not supported by the Red Hat.:\n" +- "- {}".format("\n - ".join(repos)) ++ 'The following repositories have been used for the ' ++ 'upgrade, but they are not supported by the Red Hat.:\n' ++ '- {}'.format('\n - '.join(repos)) + ), + reporting.Severity(reporting.Severity.HIGH), + reporting.Groups([reporting.Groups.REPOSITORY]), +@@ -28,25 +35,25 @@ def _report_using_unsupported_repos(repos): + + def _report_excluded_repos(repos): + api.current_logger().info( +- "The CRB repository is not enabled. Excluding {} from the upgrade".format(repos) ++ 'The CRB repository is not enabled. Excluding {} from the upgrade'.format(repos) + ) + + report = [ +- reporting.Title("Excluded target system repositories"), ++ reporting.Title('Excluded target system repositories'), + reporting.Summary( +- "The following repositories are not supported by " +- "Red Hat and are excluded from the list of repositories " +- "used during the upgrade.\n- {}".format("\n- ".join(repos)) ++ 'The following repositories are not supported by ' ++ 'Red Hat and are excluded from the list of repositories ' ++ 'used during the upgrade.\n- {}'.format('\n- '.join(repos)) + ), + reporting.Severity(reporting.Severity.INFO), + reporting.Groups([reporting.Groups.REPOSITORY]), + reporting.Groups([reporting.Groups.FAILURE]), + reporting.Remediation( + hint=( +- "If some of excluded repositories are still required to be used" +- " during the upgrade, execute leapp with the --enablerepo option" +- " with the repoid of the repository required to be enabled" +- " as an argument (the option can be used multiple times)." ++ 'If some of excluded repositories are still required to be used' ++ ' during the upgrade, execute leapp with the --enablerepo option' ++ ' with the repoid of the repository required to be enabled' ++ ' as an argument (the option can be used multiple times).' + ) + ), + ] +@@ -87,10 +94,10 @@ def _get_pesid_repos(repo_mapping, pesid, major_version): + + def _get_repoids_to_exclude(repo_mapping): + """ +- Returns a set of repoids that should be blacklisted on the target system. ++ Returns a set of repoids that should be blocklisted on the target system. + + :param RepositoriesMapping repo_mapping: Repository mapping data. +- :returns: A set of repoids to blacklist on the target system. ++ :returns: A set of repoids to blocklist on the target system. + :rtype: Set[str] + """ + pesid_repos_to_exclude = _get_pesid_repos(repo_mapping, +@@ -123,7 +130,7 @@ def _are_optional_repos_disabled(repo_mapping, repos_on_system): + return True + + +-def process(): ++def get_blocklisted_repoids(repo_mapping): + """ + Exclude target repositories provided by Red Hat without support. + +@@ -134,27 +141,22 @@ def process(): + (e.g. via the --enablerepo option or via the /etc/leapp/files/leapp_upgrade_repositories.repo file) + + E.g. CRB repository is provided by Red Hat but it is without the support. +- """ + +- repo_mapping = next(api.consume(RepositoriesMapping), None) ++ :param repo_mapping: RepositoriesMapping message. ++ :returns: Set of repoids to blocklist on the target system. ++ :rtype: set ++ """ + repos_facts = next(api.consume(RepositoriesFacts), None) + +- # Handle required messages not received +- missing_messages = [] +- if not repo_mapping: +- missing_messages.append('RepositoriesMapping') + if not repos_facts: +- missing_messages.append('RepositoriesFacts') +- if missing_messages: + raise StopActorExecutionError('Actor didn\'t receive required messages: {0}'.format( +- ', '.join(missing_messages) ++ 'RepositoriesFacts' + )) + + if not _are_optional_repos_disabled(repo_mapping, repos_facts): + # nothing to do - an optional repository is enabled +- return ++ return set() + +- # Optional repos are either not present or they are present, but disabled -> blacklist them on target system + repos_to_exclude = _get_repoids_to_exclude(repo_mapping) + + # Do not exclude repos manually enabled from the CLI +@@ -165,4 +167,39 @@ def process(): + _report_using_unsupported_repos(manually_enabled_repos) + if filtered_repos_to_exclude: + _report_excluded_repos(filtered_repos_to_exclude) +- api.produce(RepositoriesBlacklisted(repoids=list(filtered_repos_to_exclude))) ++ ++ return filtered_repos_to_exclude ++ ++ ++@suppress_deprecation(RepositoriesBlacklisted) ++def compute_blocklist(repo_mapping): ++ """ ++ Compute the full blocklist and extract external repoid requests. ++ ++ Merges the internally determined blocklist (unsupported repos like CRB) ++ with external blocklist requests from ``RepositoriesSetupTasks.to_block``. ++ When the resulting blocklist is non-empty, produces both ++ ``RepositoriesBlocklisted`` and the deprecated ``RepositoriesBlacklisted`` ++ messages. ++ ++ Also extracts ``to_enable`` repoids from the same external tasks so that ++ the caller does not need to re-consume the messages. ++ ++ :param repo_mapping: RepositoriesMapping message. ++ :returns: Tuple of (full_blocklist_repoids, external_repoids_requests). ++ :rtype: tuple[set, set] ++ """ ++ internal_blocklist = get_blocklisted_repoids(repo_mapping) ++ ++ external_blocklist_repoids = set() ++ external_repoids_requests = set() ++ for task in api.consume(RepositoriesSetupTasks): ++ external_blocklist_repoids.update(task.to_block) ++ external_repoids_requests.update(task.to_enable) ++ ++ full_blocklist_repoids = internal_blocklist.union(external_blocklist_repoids) ++ if full_blocklist_repoids: ++ api.produce(RepositoriesBlocklisted(repoids=sorted(full_blocklist_repoids))) ++ api.produce(RepositoriesBlacklisted(repoids=sorted(full_blocklist_repoids))) ++ ++ return full_blocklist_repoids, external_repoids_requests +diff --git a/repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +similarity index 71% +rename from repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +index df17a217..b9e5a1c4 100644 +--- a/repos/system_upgrade/common/actors/setuptargetrepos/libraries/setuptargetrepos.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +@@ -1,4 +1,5 @@ +-from leapp.libraries.actor import setuptargetrepos_repomap ++from leapp.libraries.actor import repomap_calc ++from leapp.libraries.actor.pes_events_scanner import get_enabled_repoids as _get_enabled_repoids + from leapp.libraries.common.config import get_source_distro_id, get_target_distro_id + from leapp.libraries.common.config.version import get_source_major_version, get_source_version + from leapp.libraries.stdlib import api +@@ -6,10 +7,6 @@ from leapp.models import ( + CustomTargetRepository, + DistroTargetRepository, + InstalledRPM, +- RepositoriesBlacklisted, +- RepositoriesFacts, +- RepositoriesMapping, +- RepositoriesSetupTasks, + RHELTargetRepository, + RHUIInfo, + SkippedRepositories, +@@ -24,22 +21,6 @@ RHUI_CLIENT_REPOIDS_RHEL88_TO_RHEL810 = { + } + + +-def _get_enabled_repoids(): +- """ +- Collects repoids of all enabled repositories on the source system. +- +- :returns: Set of all enabled repository IDs present on the source system. +- :rtype: Set[str] +- """ +- enabled_repoids = set() +- for repos in api.consume(RepositoriesFacts): +- for repo_file in repos.repositories: +- for repo in repo_file.data: +- if repo.enabled: +- enabled_repoids.add(repo.repoid) +- return enabled_repoids +- +- + def _get_repoids_from_installed_packages(): + repoids_from_installed_packages = set() + for installed_packages in api.consume(InstalledRPM): +@@ -48,13 +29,6 @@ def _get_repoids_from_installed_packages(): + return repoids_from_installed_packages + + +-def _get_blacklisted_repoids(): +- repos_blacklisted = set() +- for blacklist in api.consume(RepositoriesBlacklisted): +- repos_blacklisted.update(blacklist.repoids) +- return repos_blacklisted +- +- + def _get_custom_target_repos(): + custom_repos = [] + for repo in api.consume(CustomTargetRepository): +@@ -84,24 +58,35 @@ def _get_mapped_repoids(repomap, src_repoids): + + + @suppress_deprecation(RHELTargetRepository) +-def process(): ++def setup_target_repos(repositories_map_msg, pes_requested_repoids=None, ++ blacklisted_repoids=None, external_repoids_requests=None): ++ """ ++ Determine the final list of target repositories. ++ ++ :param repositories_map_msg: RepositoriesMapping message. ++ :param pes_requested_repoids: Set of repoids derived from PES events that need to be enabled. ++ :param blacklisted_repoids: Set of repoids to exclude from target repos. If None, an empty set is used. ++ :param external_repoids_requests: Set of repoids requested by external actors (e.g. satellite_upgrade_facts). ++ """ + # Load relevant data from messages + used_repoids_dict = _get_used_repo_dict() + enabled_repoids = _get_enabled_repoids() +- excluded_repoids = _get_blacklisted_repoids() ++ excluded_repoids = blacklisted_repoids if blacklisted_repoids is not None else set() + custom_repos = _get_custom_target_repos() + repoids_from_installed_packages = _get_repoids_from_installed_packages() + + # Setup repomap handler +- repo_mappig_msg = next(api.consume(RepositoriesMapping), RepositoriesMapping()) ++ repo_mapping_msg = repositories_map_msg + + rhui_info = next(api.consume(RHUIInfo), None) + cloud_provider = rhui_info.provider if rhui_info else '' + +- repomap = setuptargetrepos_repomap.RepoMapDataHandler(repo_mappig_msg, cloud_provider=cloud_provider) ++ repomap_handler = repomap_calc.RepoMapDataHandler(repo_mapping_msg, cloud_provider=cloud_provider) + + # Filter set of repoids from installed packages so that it contains only repoids with mapping +- repoids_from_installed_packages_with_mapping = _get_mapped_repoids(repomap, repoids_from_installed_packages) ++ repoids_from_installed_packages_with_mapping = _get_mapped_repoids( ++ repomap_handler, repoids_from_installed_packages ++ ) + + # Set of repoid that are going to be mapped to target repoids containing enabled repoids and also repoids from + # installed packages that have mapping to prevent missing repositories that are disabled during the upgrade, but +@@ -118,13 +103,13 @@ def process(): + repoids_to_map.remove(rhel810_rhui_client_repoid) + repoids_to_map.add(rhel88_rhui_client_repoid) + +- # Set default repository channels for the repomap ++ # Set default repository channels for the repositories mapping + # TODO(pstodulk): what about skip this completely and keep the default 'ga'..? +- default_channels = setuptargetrepos_repomap.get_default_repository_channels(repomap, repoids_to_map) +- repomap.set_default_channels(default_channels) ++ default_channels = repomap_calc.get_default_repository_channels(repomap_handler, repoids_to_map) ++ repomap_handler.set_default_channels(default_channels) + +- # Get target distro repoids based on the repomap +- expected_repos = repomap.get_expected_target_pesid_repos(repoids_to_map) ++ # Get target distro repoids based on the repositories mapping ++ expected_repos = repomap_handler.get_expected_target_pesid_repos(repoids_to_map) + target_distro_repoids = set() + for target_pesid, target_pesidrepo in expected_repos.items(): + if not target_pesidrepo: +@@ -145,17 +130,17 @@ def process(): + continue + target_distro_repoids.add(target_pesidrepo.repoid) + +- # FIXME: this could possibly result into a try to enable multiple repositories +- # from the same family (pesid). But unless we have a bug in previous actors, +- # it should not happen :) it's not blocker error anyway, so survive it. +- # - We expect to deliver the fix as part of the refactoring when we merge +- # setuptargetrepos & peseventsscanner actors together (+ blacklistrepos?) +- for task in api.consume(RepositoriesSetupTasks): +- for repo in task.to_enable: +- if repo in excluded_repoids: +- api.current_logger().debug('Skipping the {} repo from setup task (excluded).'.format(repo)) +- continue +- target_distro_repoids.add(repo) ++ # FIXME: this could possibly result in enabling multiple repositories ++ # from the same family (pesid). Proper fix requires deduplication by ++ # PESID family, picking the best candidate per family. ++ all_requested_repoids = set(pes_requested_repoids or set()) ++ all_requested_repoids.update(external_repoids_requests or set()) ++ ++ for repo in all_requested_repoids: ++ if repo in excluded_repoids: ++ api.current_logger().debug('Skipping the {} repo from setup task (excluded).'.format(repo)) ++ continue ++ target_distro_repoids.add(repo) + + # create the final lists and sort them (for easier testing) + if get_target_distro_id() == 'rhel': +@@ -167,7 +152,7 @@ def process(): + custom_repos = sorted(custom_repos, key=lambda x: x.repoid) + + # produce message about skipped repositories +- enabled_repoids_with_mapping = _get_mapped_repoids(repomap, enabled_repoids) ++ enabled_repoids_with_mapping = _get_mapped_repoids(repomap_handler, enabled_repoids) + skipped_repoids = enabled_repoids & set(used_repoids_dict.keys()) - enabled_repoids_with_mapping + if skipped_repoids: + pkgs = set() +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +new file mode 100644 +index 00000000..4db9003d +--- /dev/null ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -0,0 +1,39 @@ ++from leapp.libraries.actor import pes_events_scanner, repositoriesblocklist, setuptargetrepos ++from leapp.libraries.actor.repomap_loader import scan_repositories ++ ++ ++def process(): ++ """ ++ Orchestrate the four stages of target content resolution. ++ ++ 1. Load and produce the repository mapping (repomap.json). The produced ++ RepositoriesMapping message is also used directly by the subsequent ++ stages, avoiding a redundant round-trip through the message bus. ++ 2. Determine which target repositories should be excluded (blocklisted), ++ e.g. CRB repos that are unsupported and not explicitly enabled. ++ Merge with any external blocklist requests from RepositoriesSetupTasks ++ and produce RepositoriesBlocklisted / RepositoriesBlacklisted messages. ++ 3. Process PES events to compute RPM transaction tasks (what to install, ++ remove, keep) and determine which new repositories are needed. RPM ++ tasks are produced for downstream actors; the set of requested target ++ repoids is returned for direct use in stage 4. ++ 4. Build the final list of target repositories by combining PES-requested ++ repoids from stage 3, external repoid requests (to_enable from ++ RepositoriesSetupTasks, e.g. from satellite_upgrade_facts), enabled ++ source repos, installed package repos, and custom/blacklisted repo ++ configuration. ++ """ ++ repositories_map_msg = scan_repositories() ++ ++ full_blocklist_repoids, external_repoids_requests = ( ++ repositoriesblocklist.compute_blocklist(repositories_map_msg) ++ ) ++ ++ pes_requested_repoids = pes_events_scanner.scan_pes_events(repositories_map_msg, full_blocklist_repoids) ++ ++ setuptargetrepos.setup_target_repos( ++ repositories_map_msg, ++ pes_requested_repoids=pes_requested_repoids, ++ blacklisted_repoids=full_blocklist_repoids, ++ external_repoids_requests=external_repoids_requests, ++ ) +diff --git a/repos/system_upgrade/common/actors/repositoriesmapping/tests/files/repomap_example.json b/repos/system_upgrade/common/actors/targetcontentresolver/tests/files/repomap_example.json +similarity index 100% +rename from repos/system_upgrade/common/actors/repositoriesmapping/tests/files/repomap_example.json +rename to repos/system_upgrade/common/actors/targetcontentresolver/tests/files/repomap_example.json +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample01.json b/repos/system_upgrade/common/actors/targetcontentresolver/tests/files/sample01.json +similarity index 100% +rename from repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample01.json +rename to repos/system_upgrade/common/actors/targetcontentresolver/tests/files/sample01.json +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample04.json b/repos/system_upgrade/common/actors/targetcontentresolver/tests/files/sample04.json +similarity index 100% +rename from repos/system_upgrade/common/actors/peseventsscanner/tests/files/sample04.json +rename to repos/system_upgrade/common/actors/targetcontentresolver/tests/files/sample04.json +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/tests/test_event_parsing.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_event_parsing.py +similarity index 100% +rename from repos/system_upgrade/common/actors/peseventsscanner/tests/test_event_parsing.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/tests/test_event_parsing.py +diff --git a/repos/system_upgrade/common/actors/peseventsscanner/tests/test_pes_event_scanner.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +similarity index 96% +rename from repos/system_upgrade/common/actors/peseventsscanner/tests/test_pes_event_scanner.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +index c8c14528..f872a947 100644 +--- a/repos/system_upgrade/common/actors/peseventsscanner/tests/test_pes_event_scanner.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +@@ -22,7 +22,6 @@ from leapp.models import ( + RepoMapEntry, + RepositoriesFacts, + RepositoriesMapping, +- RepositoriesSetupTasks, + RepositoryData, + RepositoryFile, + RPM, +@@ -271,7 +270,7 @@ def test_actor_performs(monkeypatch): + monkeypatch.setattr(api, 'produce', produced_messages) + monkeypatch.setattr(reporting, 'create_report', created_report) + +- pes_events_scanner.process() ++ pes_events_scanner.scan_pes_events(repositories_mapping) + + assert produced_messages.called + +@@ -307,15 +306,14 @@ def test_transaction_configuration_has_effect(monkeypatch): + def test_repository_blacklist_is_correctly_applied(monkeypatch): + _Pkg = partial(Package, modulestream=None) + +- monkeypatch.setattr(pes_events_scanner, 'get_blacklisted_repoids', lambda: {'repo-a', 'repo-b', 'repo-c'}) + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + + source_pkgs = {_Pkg('pkg-b', 'repo-b')} + target_pkgs = {_Pkg('pkg-a', 'repo-a'), _Pkg('pkg-b', 'repo-b'), _Pkg('pkg-c', 'repo-c'), _Pkg('pkg-d', 'repo-d')} + +- blacklisted_repoids, target_pkgs = pes_events_scanner.remove_new_packages_from_blacklisted_repos(source_pkgs, +- target_pkgs) ++ blacklisted_repoids, target_pkgs = pes_events_scanner.remove_new_packages_from_blacklisted_repos( ++ source_pkgs, target_pkgs, {'repo-a', 'repo-b', 'repo-c'}) + result = pkgs_into_tuples(target_pkgs) + + assert blacklisted_repoids == {'repo-a', 'repo-b', 'repo-c'} +@@ -342,15 +340,14 @@ def test_blacklisted_repoid_is_not_produced(monkeypatch): + monkeypatch.setattr(pes_events_scanner, 'get_installed_pkgs', lambda: installed_pkgs) + monkeypatch.setattr(pes_events_scanner, 'get_pes_events', lambda folder, filename: events) + monkeypatch.setattr(pes_events_scanner, 'apply_transaction_configuration', lambda pkgs, transaction_cfg: pkgs) +- monkeypatch.setattr(pes_events_scanner, 'get_blacklisted_repoids', lambda: {'blacklisted-rhel9'}) + monkeypatch.setattr(pes_events_scanner, 'replace_pesids_with_repoids_in_packages', +- lambda pkgs, src_pkgs_repoids: pkgs) ++ lambda pkgs, src_pkgs_repoids, repo_map_msg=None: pkgs) + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) + monkeypatch.setattr(api, 'produce', produce_mocked()) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + +- pes_events_scanner.process() ++ setup_tasks = pes_events_scanner.scan_pes_events(None, blacklisted_repoids={'blacklisted-rhel9'}) + + assert not reporting.create_report.called + +@@ -361,9 +358,8 @@ def test_blacklisted_repoid_is_not_produced(monkeypatch): + 'no PESRpmTransactionTasks should be produced, however, they were.') + assert not rpm_tasks, fail_desc + +- repo_setup_tasks = [msg for msg in api.produce.model_instances if isinstance(msg, RepositoriesSetupTasks)] +- assert len(repo_setup_tasks) == 1 +- assert repo_setup_tasks[0].to_enable == ['repoid-rhel9'] ++ assert setup_tasks is not None ++ assert setup_tasks == {'repoid-rhel9'} + + + @pytest.mark.parametrize( +@@ -501,10 +497,10 @@ def test_transaction_configuration_is_applied(monkeypatch): + monkeypatch.setattr(pes_events_scanner, 'remove_undesired_events', lambda events, releases: events) + monkeypatch.setattr(pes_events_scanner, '_get_enabled_modules', lambda *args: []) + monkeypatch.setattr(pes_events_scanner, 'replace_pesids_with_repoids_in_packages', +- lambda target_pkgs, repoids_of_source_pkgs: target_pkgs) ++ lambda target_pkgs, repoids_of_source_pkgs, repo_map_msg=None: target_pkgs) + monkeypatch.setattr(pes_events_scanner, + 'remove_new_packages_from_blacklisted_repos', +- lambda source_pkgs, target_pkgs: (set(), target_pkgs)) ++ lambda source_pkgs, target_pkgs, bl=None: (set(), target_pkgs)) + + msgs = [ + RpmTransactionTasks(to_remove=['pkg-not-in-events']), +@@ -517,9 +513,10 @@ def test_transaction_configuration_is_applied(monkeypatch): + + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- pes_events_scanner.process() ++ setup_tasks = pes_events_scanner.scan_pes_events(None) + +- assert api.produce.called == 2 ++ assert api.produce.called == 1 ++ assert setup_tasks is not None + + produced_rpm_transaction_tasks = [ + msg for msg in api.produce.model_instances if isinstance(msg, PESRpmTransactionTasks) +diff --git a/repos/system_upgrade/common/actors/setuptargetrepos/tests/test_repomapping.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repomap_operations.py +similarity index 99% +rename from repos/system_upgrade/common/actors/setuptargetrepos/tests/test_repomapping.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repomap_operations.py +index 32af8609..ab5a4587 100644 +--- a/repos/system_upgrade/common/actors/setuptargetrepos/tests/test_repomapping.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repomap_operations.py +@@ -3,8 +3,8 @@ import logging + + import pytest + +-from leapp.libraries.actor import setuptargetrepos_repomap +-from leapp.libraries.actor.setuptargetrepos_repomap import get_default_repository_channels, RepoMapDataHandler ++from leapp.libraries.actor import repomap_calc ++from leapp.libraries.actor.repomap_calc import get_default_repository_channels, RepoMapDataHandler + from leapp.libraries.common.testutils import CurrentActorMocked + from leapp.libraries.stdlib import api + from leapp.models import PESIDRepositoryEntry, RepoMapEntry, RepositoriesMapping +@@ -287,7 +287,7 @@ def test_get_source_pesid_repos(monkeypatch, repomap_data_for_pesid_repo_retriev + fail_description = ( + 'The get_source_pesid_repos method does not take into account the source system version correctly.' + ) +- monkeypatch.setattr(setuptargetrepos_repomap, 'get_source_major_version', lambda: '10') ++ monkeypatch.setattr(repomap_calc, 'get_source_major_version', lambda: '10') + + # Repeat the same test as above to make sure it respects the source OS major version + assert [] == handler.get_source_pesid_repos('pesid1'), fail_description +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +new file mode 100644 +index 00000000..5ffa0990 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +@@ -0,0 +1,329 @@ ++import pytest ++ ++from leapp import reporting ++from leapp.libraries.actor import repositoriesblocklist ++from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import ( ++ CustomTargetRepository, ++ PESIDRepositoryEntry, ++ RepoMapEntry, ++ RepositoriesBlacklisted, ++ RepositoriesBlocklisted, ++ RepositoriesFacts, ++ RepositoriesMapping, ++ RepositoriesSetupTasks, ++ RepositoryData, ++ RepositoryFile ++) ++from leapp.utils.deprecation import suppress_deprecation ++ ++ ++@pytest.fixture ++def repofacts_opts_disabled(): ++ repos_data = [ ++ RepositoryData( ++ repoid="codeready-builder-for-rhel-8-x86_64-rpms", ++ name="RHEL 8 CRB", ++ enabled=False, ++ ) ++ ] ++ repos_files = [ ++ RepositoryFile(file="/etc/yum.repos.d/redhat.repo", data=repos_data) ++ ] ++ return RepositoriesFacts(repositories=repos_files) ++ ++ ++@pytest.fixture ++def rhel8_crb_pesidrepo(): ++ return PESIDRepositoryEntry( ++ pesid='rhel8-CRB', ++ major_version='8', ++ repoid='codeready-builder-for-rhel-8-x86_64-rpms', ++ rhui='', ++ arch='x86_64', ++ channel='ga', ++ repo_type='rpm', ++ distro='rhel', ++ ) ++ ++ ++@pytest.fixture ++def rhel9_crb_pesidrepo(): ++ return PESIDRepositoryEntry( ++ pesid='rhel9-CRB', ++ major_version='9', ++ repoid='codeready-builder-for-rhel-9-x86_64-rpms', ++ rhui='', ++ arch='x86_64', ++ channel='ga', ++ repo_type='rpm', ++ distro='rhel', ++ ) ++ ++ ++@pytest.fixture ++def repomap_opts_only(rhel8_crb_pesidrepo, rhel9_crb_pesidrepo): ++ return RepositoriesMapping( ++ mapping=[RepoMapEntry(source='rhel8-CRB', target=['rhel9-CRB'])], ++ repositories=[rhel8_crb_pesidrepo, rhel9_crb_pesidrepo] ++ ) ++ ++ ++def test_all_target_optionals_blacklisted_when_no_optional_on_source(monkeypatch, repomap_opts_only): ++ """ ++ Tests whether every target optional repository gets blacklisted ++ if no optional repositories are used on the source system. ++ """ ++ ++ repos_data = [ ++ RepositoryData( ++ repoid="rhel-8-server-rpms", ++ name="RHEL 8 Server", ++ enabled=True, ++ ) ++ ] ++ repos_files = [ ++ RepositoryFile(file="/etc/yum.repos.d/redhat.repo", data=repos_data) ++ ] ++ repo_facts = RepositoriesFacts(repositories=repos_files) ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ monkeypatch.setattr(reporting, 'create_report', produce_mocked()) ++ ++ result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ ++ assert 'codeready-builder-for-rhel-9-x86_64-rpms' in result ++ ++ ++def test_with_no_mapping_for_optional_repos(monkeypatch, repomap_opts_only, repofacts_opts_disabled): ++ """ ++ Tests whether an empty set is returned if no valid target is found for an optional repository in mapping data. ++ """ ++ ++ repomap_opts_only.repositories[1].pesid = 'test_pesid' ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled])) ++ ++ result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ ++ assert not result ++ ++ ++def test_blacklist_produced_when_optional_repo_disabled(monkeypatch, repofacts_opts_disabled, repomap_opts_only): ++ """ ++ Tests whether a correct blacklist is generated when there is disabled optional repo on the system. ++ """ ++ ++ monkeypatch.setattr( ++ api, ++ "current_actor", ++ CurrentActorMocked(msgs=[repofacts_opts_disabled]), ++ ) ++ monkeypatch.setattr(api, "produce", produce_mocked()) ++ monkeypatch.setattr(reporting, "create_report", produce_mocked()) ++ ++ result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ ++ assert result, 'A blacklist should get generated.' ++ ++ expected_blacklisted_repoid = 'codeready-builder-for-rhel-9-x86_64-rpms' ++ err_msg = 'Blacklist does not contain expected repoid.' ++ assert expected_blacklisted_repoid in result, err_msg ++ ++ ++def test_no_blacklist_produced_when_optional_repo_enabled(monkeypatch, repofacts_opts_disabled, repomap_opts_only): ++ """ ++ Tests whether an empty set is returned when an optional repository is enabled. ++ ++ Data are set up in such a fashion so that the determined blacklist would not be empty. ++ """ ++ ++ repofacts_opts_disabled.repositories[0].data[0].enabled = True ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled])) ++ ++ result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ ++ assert not result ++ ++ ++def test_repositoriesblacklist_not_empty(monkeypatch, repofacts_opts_disabled, repomap_opts_only): ++ """ ++ Tests whether the correct set of blacklisted repoids is returned. ++ """ ++ ++ blacklisted_repoid = 'test' ++ monkeypatch.setattr(repositoriesblocklist, "_get_repoids_to_exclude", lambda dummy_mapping: {blacklisted_repoid}) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled])) ++ monkeypatch.setattr(api, "produce", produce_mocked()) ++ monkeypatch.setattr(reporting, "create_report", produce_mocked()) ++ ++ result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ assert blacklisted_repoid in result ++ assert reporting.create_report.called == 1 ++ ++ ++def test_repositoriesblacklist_empty(monkeypatch, repofacts_opts_disabled, repomap_opts_only): ++ """ ++ Tests whether an empty set is returned if there are some disabled optional ++ repos, but an empty blacklist is determined from the repo mapping data. ++ """ ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled])) ++ monkeypatch.setattr( ++ repositoriesblocklist, ++ "_get_repoids_to_exclude", ++ lambda dummy_mapping: set() ++ ) ++ ++ result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ assert not result ++ ++ ++@pytest.mark.parametrize( ++ ("enabled_repo", "exp_report_title", "message_produced"), ++ [ ++ ("codeready-builder-for-rhel-9-x86_64-rpms", "Using repository not supported by Red Hat", False), ++ ("some_other_enabled_repo", "Excluded target system repositories", True), ++ (None, "Excluded target system repositories", True), ++ ], ++) ++def test_enablerepo_option(monkeypatch, ++ repofacts_opts_disabled, ++ repomap_opts_only, ++ enabled_repo, ++ exp_report_title, ++ message_produced): ++ """ ++ Tests whether the actor respects CustomTargetRepository messages when constructing the blacklist. ++ """ ++ ++ msgs_to_feed = [repofacts_opts_disabled] ++ ++ if enabled_repo: ++ msgs_to_feed.append(CustomTargetRepository(repoid=enabled_repo)) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs_to_feed)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) ++ result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ assert reporting.create_report.report_fields["title"] == exp_report_title ++ if message_produced: ++ assert result ++ else: ++ assert not result ++ ++ ++def _get_produced_blocklisted(): ++ return [m for m in api.produce.model_instances ++ if isinstance(m, RepositoriesBlocklisted) and not isinstance(m, RepositoriesBlacklisted)] ++ ++ ++def _get_produced_blacklisted(): ++ return [m for m in api.produce.model_instances if isinstance(m, RepositoriesBlacklisted)] ++ ++ ++@suppress_deprecation(RepositoriesBlacklisted) ++def test_compute_blocklist_merges_internal_and_external(monkeypatch): ++ """ ++ compute_blocklist merges internal blocklist with external ++ RepositoriesSetupTasks.to_block and produces both messages. ++ """ ++ external_tasks = [ ++ RepositoriesSetupTasks(to_enable=['some-repo'], to_block=['ext-blocked-1', 'ext-blocked-2']), ++ ] ++ monkeypatch.setattr( ++ repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'crb-repo-1'} ++ ) ++ monkeypatch.setattr(api, 'consume', lambda _model: iter(external_tasks)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ ++ assert full_blocklist == {'crb-repo-1', 'ext-blocked-1', 'ext-blocked-2'} ++ assert external_repoids == {'some-repo'} ++ ++ blocklisted_msgs = _get_produced_blocklisted() ++ assert len(blocklisted_msgs) == 1 ++ assert set(blocklisted_msgs[0].repoids) == full_blocklist ++ ++ blacklisted_msgs = _get_produced_blacklisted() ++ assert len(blacklisted_msgs) == 1 ++ assert set(blacklisted_msgs[0].repoids) == full_blocklist ++ ++ ++def test_compute_blocklist_no_messages_when_empty(monkeypatch): ++ """No blocklist messages are produced when blocklist is empty.""" ++ monkeypatch.setattr( ++ repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: set() ++ ) ++ monkeypatch.setattr(api, 'consume', lambda _model: iter([])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ ++ assert full_blocklist == set() ++ assert external_repoids == set() ++ assert len(_get_produced_blocklisted()) == 0 ++ assert len(_get_produced_blacklisted()) == 0 ++ ++ ++@suppress_deprecation(RepositoriesBlacklisted) ++def test_compute_blocklist_only_internal(monkeypatch): ++ """Only internal blocklist when no external tasks are present.""" ++ monkeypatch.setattr( ++ repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'crb-repo-1'} ++ ) ++ monkeypatch.setattr(api, 'consume', lambda _model: iter([])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ ++ assert full_blocklist == {'crb-repo-1'} ++ assert external_repoids == set() ++ assert len(_get_produced_blocklisted()) == 1 ++ ++ ++def test_compute_blocklist_multiple_external_tasks(monkeypatch): ++ """Blocklist and to_enable from multiple external tasks are aggregated.""" ++ external_tasks = [ ++ RepositoriesSetupTasks(to_block=['ext-1'], to_enable=['repo-a']), ++ RepositoriesSetupTasks(to_block=['ext-2', 'ext-3'], to_enable=['repo-b']), ++ ] ++ monkeypatch.setattr( ++ repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: set() ++ ) ++ monkeypatch.setattr(api, 'consume', lambda _model: iter(external_tasks)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ ++ assert full_blocklist == {'ext-1', 'ext-2', 'ext-3'} ++ assert external_repoids == {'repo-a', 'repo-b'} ++ ++ ++@suppress_deprecation(RepositoriesBlacklisted) ++def test_compute_blocklist_overlapping_internal_and_external(monkeypatch): ++ """Overlapping repoids between internal and external blocklists are deduplicated.""" ++ external_tasks = [ ++ RepositoriesSetupTasks(to_block=['shared-repo', 'ext-only']), ++ ] ++ monkeypatch.setattr( ++ repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'shared-repo', 'internal-only'} ++ ) ++ monkeypatch.setattr(api, 'consume', lambda _model: iter(external_tasks)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ ++ assert full_blocklist == {'shared-repo', 'internal-only', 'ext-only'} ++ assert external_repoids == set() ++ ++ blocklisted_msgs = _get_produced_blocklisted() ++ assert len(blocklisted_msgs) == 1 ++ assert set(blocklisted_msgs[0].repoids) == full_blocklist ++ ++ blacklisted_msgs = _get_produced_blacklisted() ++ assert len(blacklisted_msgs) == 1 ++ assert set(blacklisted_msgs[0].repoids) == full_blocklist +diff --git a/repos/system_upgrade/common/actors/setuptargetrepos/tests/test_setuptargetrepos.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py +similarity index 74% +rename from repos/system_upgrade/common/actors/setuptargetrepos/tests/test_setuptargetrepos.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py +index c3ff5f49..652bf30a 100644 +--- a/repos/system_upgrade/common/actors/setuptargetrepos/tests/test_setuptargetrepos.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py +@@ -8,10 +8,8 @@ from leapp.models import ( + InstalledRPM, + PESIDRepositoryEntry, + RepoMapEntry, +- RepositoriesBlacklisted, + RepositoriesFacts, + RepositoriesMapping, +- RepositoriesSetupTasks, + RepositoryData, + RepositoryFile, + RPM +@@ -36,7 +34,7 @@ def test_minimal_execution(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setuptargetrepos.process() ++ setuptargetrepos.setup_target_repos(msgs[0]) + + + def test_custom_repos(monkeypatch): +@@ -54,19 +52,17 @@ def test_custom_repos(monkeypatch): + baseurl='https://.../dist/rhel/blacklisted/8/os', + enabled=True) + +- repos_blacklisted = RepositoriesBlacklisted(repoids=['rhel-8-blacklisted-rpms']) +- + repositories_mapping = RepositoriesMapping( + mapping=[], + repositories=[] + ) + +- msgs = [custom, blacklisted, repos_blacklisted, repositories_mapping] ++ msgs = [custom, blacklisted, repositories_mapping] + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setuptargetrepos.process() ++ setuptargetrepos.setup_target_repos(repositories_mapping, blacklisted_repoids={'rhel-8-blacklisted-rpms'}) + + assert api.produce.called + +@@ -77,19 +73,19 @@ def test_custom_repos(monkeypatch): + + def test_repositories_setup_tasks(monkeypatch): + """ +- Tests whether the actor propagates repositories received via a RepositoriesSetupTasks message ++ Tests whether the actor propagates requested repoids + to the resulting TargetRepositories (and blacklist filtering is applied to them). + """ +- repositories_setup_tasks = RepositoriesSetupTasks(to_enable=['rhel-8-server-rpms', +- 'rhel-8-blacklisted-rpms']) +- repos_blacklisted = RepositoriesBlacklisted(repoids=['rhel-8-blacklisted-rpms']) + repositories_mapping = RepositoriesMapping(mapping=[], repositories=[]) +- msgs = [repositories_setup_tasks, repos_blacklisted, repositories_mapping] ++ msgs = [repositories_mapping] + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setuptargetrepos.process() ++ setuptargetrepos.setup_target_repos( ++ repositories_mapping, ++ blacklisted_repoids={'rhel-8-blacklisted-rpms'}, ++ external_repoids_requests={'rhel-8-server-rpms', 'rhel-8-blacklisted-rpms'}) + + assert api.produce.called + +@@ -187,9 +183,7 @@ def test_repos_mapping_for_distro(monkeypatch, src_distro, dst_distro): + ] + ) + +- repos_blacklisted = RepositoriesBlacklisted(repoids=['{}-9-blacklisted-rpms'.format(dst_distro)]) +- +- msgs = [facts, repomap, repos_blacklisted, installed_rpms] ++ msgs = [facts, repomap, installed_rpms] + + monkeypatch.setattr( + api, +@@ -198,7 +192,7 @@ def test_repos_mapping_for_distro(monkeypatch, src_distro, dst_distro): + ) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setuptargetrepos.process() ++ setuptargetrepos.setup_target_repos(repomap, blacklisted_repoids={'{}-9-blacklisted-rpms'.format(dst_distro)}) + assert api.produce.called + + distro_repos = api.produce.model_instances[0].distro_repos +@@ -221,3 +215,58 @@ def test_repos_mapping_for_distro(monkeypatch, src_distro, dst_distro): + assert produced_rhel_repoids == expected_repoids + else: + assert not rhel_repos ++ ++ ++def test_pes_requested_repoids_added_to_target(monkeypatch): ++ """ ++ Tests whether PES-requested repoids are added to the target repositories ++ and that blacklisted PES-requested repoids are excluded. ++ """ ++ repositories_mapping = RepositoriesMapping(mapping=[], repositories=[]) ++ msgs = [repositories_mapping] ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ setuptargetrepos.setup_target_repos( ++ repositories_mapping, ++ pes_requested_repoids={'pes-repo-1', 'pes-repo-2', 'pes-blocked'}, ++ blacklisted_repoids={'pes-blocked'}, ++ ) ++ ++ assert api.produce.called ++ ++ distro_repos = api.produce.model_instances[0].distro_repos ++ produced_repoids = {repo.repoid for repo in distro_repos} ++ ++ assert 'pes-repo-1' in produced_repoids ++ assert 'pes-repo-2' in produced_repoids ++ assert 'pes-blocked' not in produced_repoids ++ ++ ++def test_pes_and_external_repoids_combined(monkeypatch): ++ """ ++ Tests whether PES-requested and external repoids are combined ++ in the final target repositories, with blacklist filtering applied to both. ++ """ ++ repositories_mapping = RepositoriesMapping(mapping=[], repositories=[]) ++ msgs = [repositories_mapping] ++ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ setuptargetrepos.setup_target_repos( ++ repositories_mapping, ++ pes_requested_repoids={'pes-repo'}, ++ blacklisted_repoids={'blocked-repo'}, ++ external_repoids_requests={'ext-repo', 'blocked-repo'}, ++ ) ++ ++ assert api.produce.called ++ ++ distro_repos = api.produce.model_instances[0].distro_repos ++ produced_repoids = {repo.repoid for repo in distro_repos} ++ ++ assert 'pes-repo' in produced_repoids ++ assert 'ext-repo' in produced_repoids ++ assert 'blocked-repo' not in produced_repoids +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +new file mode 100644 +index 00000000..4e425da6 +--- /dev/null ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +@@ -0,0 +1,63 @@ ++from leapp.libraries.actor import targetcontentresolver ++ ++ ++def test_process_orchestration(monkeypatch): ++ """ ++ Tests that process() wires data between the four stages correctly: ++ scan_repositories -> compute_blocklist -> scan_pes_events -> setup_target_repos. ++ """ ++ call_log = [] ++ ++ fake_repo_map = object() ++ fake_blocklist = frozenset({'blocked-repo'}) ++ fake_external_repoids = frozenset({'ext-repo'}) ++ fake_pes_repoids = frozenset({'pes-repo'}) ++ ++ def mock_scan_repositories(): ++ call_log.append('scan_repositories') ++ return fake_repo_map ++ ++ def mock_compute_blocklist(repo_mapping): ++ call_log.append('compute_blocklist') ++ assert repo_mapping is fake_repo_map ++ return fake_blocklist, fake_external_repoids ++ ++ def mock_scan_pes_events(repo_mapping, blacklisted_repoids): ++ call_log.append('scan_pes_events') ++ assert repo_mapping is fake_repo_map ++ assert blacklisted_repoids is fake_blocklist ++ return fake_pes_repoids ++ ++ def mock_setup_target_repos(repo_mapping, pes_requested_repoids=None, ++ blacklisted_repoids=None, external_repoids_requests=None): ++ call_log.append('setup_target_repos') ++ assert repo_mapping is fake_repo_map ++ assert pes_requested_repoids is fake_pes_repoids ++ assert blacklisted_repoids is fake_blocklist ++ assert external_repoids_requests is fake_external_repoids ++ ++ monkeypatch.setattr( ++ 'leapp.libraries.actor.targetcontentresolver.scan_repositories', ++ mock_scan_repositories, ++ ) ++ monkeypatch.setattr( ++ 'leapp.libraries.actor.targetcontentresolver.repositoriesblocklist.compute_blocklist', ++ mock_compute_blocklist, ++ ) ++ monkeypatch.setattr( ++ 'leapp.libraries.actor.targetcontentresolver.pes_events_scanner.scan_pes_events', ++ mock_scan_pes_events, ++ ) ++ monkeypatch.setattr( ++ 'leapp.libraries.actor.targetcontentresolver.setuptargetrepos.setup_target_repos', ++ mock_setup_target_repos, ++ ) ++ ++ targetcontentresolver.process() ++ ++ assert call_log == [ ++ 'scan_repositories', ++ 'compute_blocklist', ++ 'scan_pes_events', ++ 'setup_target_repos', ++ ] +diff --git a/repos/system_upgrade/common/actors/repositoriesmapping/tests/unit_test_repositoriesmapping.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/unit_test_repomap_loader.py +similarity index 88% +rename from repos/system_upgrade/common/actors/repositoriesmapping/tests/unit_test_repositoriesmapping.py +rename to repos/system_upgrade/common/actors/targetcontentresolver/tests/unit_test_repomap_loader.py +index 6d1a173a..50cf5962 100644 +--- a/repos/system_upgrade/common/actors/repositoriesmapping/tests/unit_test_repositoriesmapping.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/unit_test_repomap_loader.py +@@ -5,7 +5,7 @@ import pytest + import requests + + from leapp.exceptions import StopActorExecutionError +-from leapp.libraries.actor import repositoriesmapping ++from leapp.libraries.actor import repomap_loader + from leapp.libraries.common import fetch + from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked + from leapp.libraries.stdlib import api +@@ -32,9 +32,13 @@ def test_scan_existing_valid_data(monkeypatch, adjust_cwd): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='7.9', dst_ver='8.4')) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- repositoriesmapping.scan_repositories(lambda dummy: data) ++ result = repomap_loader.scan_repositories(lambda dummy: data) + + assert api.produce.called, 'Actor did not produce any message when deserializing valid repomap data.' ++ assert result is not None, 'scan_repositories should return the RepositoriesMapping message.' ++ assert result is api.produce.model_instances[0], ( ++ 'The returned mapping should be the same object that was produced.' ++ ) + + fail_description = 'Actor produced multiple messages, but only one was expected.' + assert len(api.produce.model_instances) == 1, fail_description +@@ -140,7 +144,7 @@ def test_scan_repositories_with_missing_data(monkeypatch): + monkeypatch.setattr(fetch, 'read_or_fetch', read_or_fetch_mocked) + + with pytest.raises(StopActorExecutionError) as missing_data_error: +- repositoriesmapping.scan_repositories() ++ repomap_loader.scan_repositories() + assert 'does not contain a valid JSON object' in str(missing_data_error) + + +@@ -153,7 +157,7 @@ def test_scan_repositories_with_empty_data(monkeypatch): + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as empty_data_error: +- repositoriesmapping.scan_repositories(lambda dummy: {}) ++ repomap_loader.scan_repositories(lambda dummy: {}) + assert 'the JSON is missing a required field' in str(empty_data_error) + + +@@ -175,7 +179,7 @@ def test_scan_repositories_with_bad_json_data_version(monkeypatch, version_forma + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as bad_version_error: +- repositoriesmapping.scan_repositories(lambda dummy: json_data) ++ repomap_loader.scan_repositories(lambda dummy: json_data) + + assert 'mapping file is invalid' in str(bad_version_error) + +@@ -187,7 +191,7 @@ def test_scan_repositories_with_mapping_to_pesid_without_repos(monkeypatch): + """ + json_data = { + 'datetime': '202107141655Z', +- 'version_format': repositoriesmapping.RepoMapData.VERSION_FORMAT, ++ 'version_format': repomap_loader.RepoMapData.VERSION_FORMAT, + 'mapping': [ + { + 'source_major_version': '7', +@@ -221,7 +225,7 @@ def test_scan_repositories_with_mapping_to_pesid_without_repos(monkeypatch): + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as error_info: +- repositoriesmapping.scan_repositories(lambda dummy: json_data) ++ repomap_loader.scan_repositories(lambda dummy: json_data) + + assert 'pesid is not related to any repository' in error_info.value.message + +@@ -234,7 +238,7 @@ def test_scan_repositories_with_repo_entry_missing_required_fields(monkeypatch): + + json_data = { + 'datetime': '202107141655Z', +- 'version_format': repositoriesmapping.RepoMapData.VERSION_FORMAT, ++ 'version_format': repomap_loader.RepoMapData.VERSION_FORMAT, + 'mapping': [ + { + 'source_major_version': '7', +@@ -275,7 +279,7 @@ def test_scan_repositories_with_repo_entry_missing_required_fields(monkeypatch): + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as error_info: +- repositoriesmapping.scan_repositories(lambda dummy: json_data) ++ repomap_loader.scan_repositories(lambda dummy: json_data) + + assert 'the JSON is missing a required field' in error_info.value.message + +@@ -288,7 +292,7 @@ def test_scan_repositories_with_repo_entry_mapping_target_not_a_list(monkeypatch + + json_data = { + 'datetime': '202107141655Z', +- 'version_format': repositoriesmapping.RepoMapData.VERSION_FORMAT, ++ 'version_format': repomap_loader.RepoMapData.VERSION_FORMAT, + 'mapping': [ + { + 'source_major_version': '7', +@@ -329,6 +333,18 @@ def test_scan_repositories_with_repo_entry_mapping_target_not_a_list(monkeypatch + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as error_info: +- repositoriesmapping.scan_repositories(lambda dummy: json_data) ++ repomap_loader.scan_repositories(lambda dummy: json_data) + + assert 'repository mapping file is invalid' in error_info.value.message ++ ++ ++def test_scan_repositories_returns_none_on_error(monkeypatch): ++ """ ++ Tests that scan_repositories returns None when the data is invalid ++ and a StopActorExecutionError is raised. ++ """ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='7.9', dst_ver='8.4')) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ with pytest.raises(StopActorExecutionError): ++ repomap_loader.scan_repositories(lambda dummy: {}) +diff --git a/repos/system_upgrade/common/models/repositoriesblacklisted.py b/repos/system_upgrade/common/models/repositoriesblacklisted.py +deleted file mode 100644 +index 056eaff4..00000000 +--- a/repos/system_upgrade/common/models/repositoriesblacklisted.py ++++ /dev/null +@@ -1,11 +0,0 @@ +-from leapp.models import fields, Model +-from leapp.topics import SystemFactsTopic +- +- +-class RepositoriesBlacklisted(Model): +- """ +- Repositories ID that should be ignored by Leapp during upgrade process +- """ +- topic = SystemFactsTopic +- +- repoids = fields.List(fields.String(), default=[]) +diff --git a/repos/system_upgrade/common/models/repositoriesblocklisted.py b/repos/system_upgrade/common/models/repositoriesblocklisted.py +new file mode 100644 +index 00000000..c1825ec3 +--- /dev/null ++++ b/repos/system_upgrade/common/models/repositoriesblocklisted.py +@@ -0,0 +1,34 @@ ++from leapp.models import fields, Model ++from leapp.topics import SystemFactsTopic ++from leapp.utils.deprecation import deprecated ++ ++ ++class RepositoriesBlocklisted(Model): ++ """ ++ Repository IDs that have been excluded from the target system during the upgrade. ++ ++ This message is expected to be produced just by one actor, ++ but it can be consumed by others to check what repositories have been ignored ++ during the upgrade process. ++ """ ++ topic = SystemFactsTopic ++ ++ repoids = fields.List(fields.String(), default=[]) ++ """ ++ List of excluded (blocked) repository IDs. ++ """ ++ ++ ++@deprecated( ++ since='2026-06-01', ++ message=( ++ 'This model has been deprecated and replaced. ' ++ 'To get the list of blocklisted repositories, use RepositoriesBlocklisted. ' ++ 'To request a repository to be blocklisted, use RepositoriesSetupTasks.blocklist.' ++ ), ++) ++class RepositoriesBlacklisted(RepositoriesBlocklisted): ++ """ ++ Repository IDs that should be ignored by Leapp during the upgrade process. ++ """ ++ topic = SystemFactsTopic +diff --git a/repos/system_upgrade/common/models/repositoriessetuptasks.py b/repos/system_upgrade/common/models/repositoriessetuptasks.py +index fb267ad0..35f627e4 100644 +--- a/repos/system_upgrade/common/models/repositoriessetuptasks.py ++++ b/repos/system_upgrade/common/models/repositoriessetuptasks.py +@@ -6,10 +6,23 @@ class RepositoriesSetupTasks(Model): + """ + Information about repositories that must be managed in order to complete upgrade process. + +- 'to_enable' field consists of a list of repositories that should be enabled in order to complete ++ * 'to_enable' field consists of a list of repositories that should be enabled in order to complete + upgrade process. This information should be processed by an actor dedicated to manage + repositories. ++ * 'to_block' field consists of a list of repositories that should be ignored during upgrade process. + """ + topic = SystemFactsTopic + + to_enable = fields.List(fields.String(), default=[]) ++ """ ++ List of repositories that should be enabled and used during the upgrade. ++ """ ++ ++ to_block = fields.List(fields.String(), default=[]) ++ """ ++ List of repositories that should be ignored (blocked) during the upgrade. ++ """ ++ # TODO(pstodulk): update the docstring: ++ # * what has precedence? to_enable or to_block? ++ # * note the effect of custom repositories! ++ # The code needs to be updated to correctly answer these Q at this moment. +-- +2.54.0 + diff --git a/0098-2-9-targetcontentresolver-Init-message-consumption-c.patch b/0098-2-9-targetcontentresolver-Init-message-consumption-c.patch new file mode 100644 index 0000000..043940b --- /dev/null +++ b/0098-2-9-targetcontentresolver-Init-message-consumption-c.patch @@ -0,0 +1,751 @@ +From 4ff499df1e88ef3a7af57aca5ac58a648ddd4707 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Thu, 4 Jun 2026 07:20:14 +0200 +Subject: [PATCH 098/108] [2/9] targetcontentresolver: Init message consumption + centralization + +Centralize consumption of RepositoriesFacts, RepositoriesSetupTasks, +and CustomTargetRepository into a new InputData class in +targetcontentresolver. This deduplicates logic previously scattered +across repositoriesblocklist and pes_events_scanner, and validates +that required messages are present before processing begins. + +This is initial set of changes for future refactoring in individual +libraries of the actor. + +Some details about individual changes: +* setuptargetrepos: + * Drop the rename of get_enabled_repoids to a private functions + when importing. + +* targetcontentresolver: + * Introduce InputData class that consumes and validates required + messages, warns about unexpected duplicates, and raises + StopActorExecutionError when required messages are missing. + * Introduce ExternalRepoSetupTasks named tuple to collect all + external repository requests (to_enable, to_block, custom) + from RepositoriesSetupTasks and CustomTargetRepository messages. + +* pes_events_scanner: + * Pass enabled_repoids as parameter through scan_pes_events, + get_pesid_to_repoid_map, and replace_pesids_with_repoids_in_packages + instead of fetching it internally via get_enabled_repoids(). + * Drop default parameter for blacklisted_repoids in scan_pes_events + (must be always specified). + +* repositoriesblocklist.compute_blocklist: + * Accept external_tasks (ExternalRepoSetupTasks) instead of consuming + RepositoriesSetupTasks directly. + * Return only the set of blocklisted repoids instead of a tuple of + (blocklisted, enabled). + * Move RepositoriesFacts missing-message check to InputData. + * Update docstring to describe expected precedence order for + conflicting requests (implementation deferred to next commit). + * Remove misused FAILURE group from the CRB exclusion report. + +Unit tests adjusted with assistance of AI. + +Jira: RHEL-115867 +Assisted-by: Claude Code (Opus 4.6) +Co-authored-by: Matej Matuska +Signed-off-by: Petr Stodulka +--- + .../libraries/pes_event_parsing.py | 1 + + .../libraries/pes_events_scanner.py | 18 +-- + .../libraries/repositoriesblocklist.py | 81 +++++++------ + .../libraries/setuptargetrepos.py | 4 +- + .../libraries/targetcontentresolver.py | 93 ++++++++++++-- + .../tests/test_pes_event_scanner.py | 12 +- + .../tests/test_repositoriesblocklist.py | 49 +++----- + .../tests/test_targetcontentresolver.py | 114 +++++++++++++++++- + 8 files changed, 278 insertions(+), 94 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_event_parsing.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_event_parsing.py +index f24dda68..febf8578 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_event_parsing.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_event_parsing.py +@@ -109,6 +109,7 @@ def get_pes_events(pes_json_directory, pes_json_filename): + reporting.Groups([reporting.Groups.SANITY, reporting.Groups.INHIBITOR]), + reporting.RelatedResource('file', os.path.join(pes_json_directory, pes_json_filename)) + ]) ++ + raise StopActorExecution() + + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +index a03071ae..cbc99adf 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +@@ -320,7 +320,7 @@ def report_skipped_packages(title, message, skipped_pkgs, remediation=None): + api.current_logger().info(summary) + + +-def remove_new_packages_from_blacklisted_repos(source_pkgs, target_pkgs, blacklisted_repoids=None): ++def remove_new_packages_from_blacklisted_repos(source_pkgs, target_pkgs, blacklisted_repoids): + """ + Remove newly installed packages from blacklisted repositories that were computed to be on the target system. + +@@ -357,7 +357,7 @@ def get_enabled_repoids(): + return enabled_repoids + + +-def get_pesid_to_repoid_map(target_pesids, repositories_map_msg): ++def get_pesid_to_repoid_map(target_pesids, repositories_map_msg, enabled_repoids): + """ + Get a dictionary mapping all PESID repositories to their corresponding repoid. + +@@ -373,7 +373,6 @@ def get_pesid_to_repoid_map(target_pesids, repositories_map_msg): + # NOTE: We have to calculate expected target repositories like in the setuptargetrepos actor. + # It's planned to handle this in different a way in future... + +- enabled_repoids = get_enabled_repoids() + default_channels = repomap_calc.get_default_repository_channels(repomap_handler, enabled_repoids) + repomap_handler.set_default_channels(default_channels) + +@@ -433,7 +432,7 @@ def get_pesid_to_repoid_map(target_pesids, repositories_map_msg): + return repositories_mapping + + +-def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repositories_map_msg): ++def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repositories_map_msg, enabled_repoids): + """Replace packages with PESID in their .repository field with ones that have repoid providing the package.""" + # We want to map only PESIDs - if some package had no events, it will its repository set to source system repoid + packages_with_pesid = {pkg for pkg in packages if pkg.repository not in source_pkgs_repoids} +@@ -441,7 +440,7 @@ def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repos + + required_target_pesids = {pkg.repository for pkg in packages_with_pesid} + +- pesid_to_target_repoid_map = get_pesid_to_repoid_map(required_target_pesids, repositories_map_msg) ++ pesid_to_target_repoid_map = get_pesid_to_repoid_map(required_target_pesids, repositories_map_msg, enabled_repoids) + + packages_with_unknown_target_repoid = { + pkg +@@ -555,7 +554,7 @@ def include_instructions_from_transaction_configuration(rpm_tasks, transaction_c + modules_to_reset=modules_to_reset) + + +-def scan_pes_events(repositories_map_msg, blacklisted_repoids=None): ++def scan_pes_events(repositories_map_msg, blacklisted_repoids, enabled_repoids): + """ + Process PES events and compute RPM transaction tasks. + +@@ -586,7 +585,12 @@ def scan_pes_events(repositories_map_msg, blacklisted_repoids=None): + events, releases) + + # Packages coming out of the events have PESID as their repository, however, we need real repoid +- target_pkgs = replace_pesids_with_repoids_in_packages(target_pkgs, repoids_of_source_pkgs, repositories_map_msg) ++ target_pkgs = replace_pesids_with_repoids_in_packages( ++ target_pkgs, ++ repoids_of_source_pkgs, ++ repositories_map_msg, ++ enabled_repoids ++ ) + + # Apply the desired repository blacklisting + blacklisted_repoids, target_pkgs = remove_new_packages_from_blacklisted_repos(pkgs_to_begin_computation_with, +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +index 48656187..216bea52 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +@@ -1,14 +1,7 @@ + from leapp import reporting +-from leapp.exceptions import StopActorExecutionError + from leapp.libraries.common.config.version import get_source_major_version, get_target_major_version + from leapp.libraries.stdlib import api +-from leapp.models import ( +- CustomTargetRepository, +- RepositoriesBlacklisted, +- RepositoriesBlocklisted, +- RepositoriesFacts, +- RepositoriesSetupTasks +-) ++from leapp.models import CustomTargetRepository, RepositoriesBlacklisted, RepositoriesBlocklisted, RepositoriesFacts + from leapp.utils.deprecation import suppress_deprecation + + # {OS_MAJOR_VERSION: PESID} +@@ -47,7 +40,6 @@ def _report_excluded_repos(repos): + ), + reporting.Severity(reporting.Severity.INFO), + reporting.Groups([reporting.Groups.REPOSITORY]), +- reporting.Groups([reporting.Groups.FAILURE]), + reporting.Remediation( + hint=( + 'If some of excluded repositories are still required to be used' +@@ -136,7 +128,7 @@ def get_blocklisted_repoids(repo_mapping): + + Conditions to exclude: + - there are not such repositories already enabled on the source system +- (e.g. "Optional" repositories) ++ (e.g. "CRB" repositories) + - such repositories are not required for the upgrade explicitly by the user + (e.g. via the --enablerepo option or via the /etc/leapp/files/leapp_upgrade_repositories.repo file) + +@@ -146,13 +138,9 @@ def get_blocklisted_repoids(repo_mapping): + :returns: Set of repoids to blocklist on the target system. + :rtype: set + """ ++ # TODO(pstodulk): replace it by enabled_repos + repos_facts = next(api.consume(RepositoriesFacts), None) + +- if not repos_facts: +- raise StopActorExecutionError('Actor didn\'t receive required messages: {0}'.format( +- 'RepositoriesFacts' +- )) +- + if not _are_optional_repos_disabled(repo_mapping, repos_facts): + # nothing to do - an optional repository is enabled + return set() +@@ -172,34 +160,51 @@ def get_blocklisted_repoids(repo_mapping): + + + @suppress_deprecation(RepositoriesBlacklisted) +-def compute_blocklist(repo_mapping): ++def compute_blocklist(repo_mapping, external_tasks): + """ +- Compute the full blocklist and extract external repoid requests. ++ Create the blocklist of repositories that should be blocked during the upgrade. + +- Merges the internally determined blocklist (unsupported repos like CRB) +- with external blocklist requests from ``RepositoriesSetupTasks.to_block``. +- When the resulting blocklist is non-empty, produces both +- ``RepositoriesBlocklisted`` and the deprecated ``RepositoriesBlacklisted`` +- messages. ++ The content in the CRB repository is considered as unsupported. Hence ++ we do not want to enable such a repository by default on the target system ++ unless ++ * it is explicitely requested, or ++ * it is already enabled on the source system ++ This is important as some functionality originally supported on the source ++ source system can be moved to the CRB repository on the target system. ++ In such a case, we do not want to install automatically such a content ++ that lost a support. + +- Also extracts ``to_enable`` repoids from the same external tasks so that +- the caller does not need to re-consume the messages. ++ For this reason, compute the list of repositories that should be blocklisted ++ and take into account also external requests. ++ In case that multiple requests are raised for a specific repository, the ++ priority order is following: ++ internal block rq > external enable rq > external block rq > external custom rq ++ ++ The external custom request has the highest priority as this comes from ++ the system configuration or from the `--enablerepo` option used when executing ++ leapp - so it's understood as decision made by user explicitely for ++ the in-place upgrade purpose. Currently there is no explicit custom ++ configuration to disable a repo. ++ ++ Once the list is calculated, the RepositoriesBlocklisted and ++ RepositoriesBlacklisted messages are produced. + + :param repo_mapping: RepositoriesMapping message. +- :returns: Tuple of (full_blocklist_repoids, external_repoids_requests). +- :rtype: tuple[set, set] ++ :type repo_mapping: RepositoriesMapping ++ :param external_tasks: External repositories tasks represented by object with following fields: ++ ++ * ``to_enable`` - repositories that should be enabled ++ * ``to_block`` - repositories that should be blocklisted ++ * ``custom`` - repositories explicitely requested by user to be used during the upgrade ++ ++ :type external_tasks: targetcontentresolver.ExternalRepoSetupTasks ++ :returns: List of blocklisted repositories ++ :rtype: List[str] + """ + internal_blocklist = get_blocklisted_repoids(repo_mapping) ++ full_blocklist = internal_blocklist.union(external_tasks.to_block) ++ if full_blocklist: ++ api.produce(RepositoriesBlocklisted(repoids=sorted(full_blocklist))) ++ api.produce(RepositoriesBlacklisted(repoids=sorted(full_blocklist))) + +- external_blocklist_repoids = set() +- external_repoids_requests = set() +- for task in api.consume(RepositoriesSetupTasks): +- external_blocklist_repoids.update(task.to_block) +- external_repoids_requests.update(task.to_enable) +- +- full_blocklist_repoids = internal_blocklist.union(external_blocklist_repoids) +- if full_blocklist_repoids: +- api.produce(RepositoriesBlocklisted(repoids=sorted(full_blocklist_repoids))) +- api.produce(RepositoriesBlacklisted(repoids=sorted(full_blocklist_repoids))) +- +- return full_blocklist_repoids, external_repoids_requests ++ return full_blocklist +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +index b9e5a1c4..d6bfb91d 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +@@ -1,5 +1,5 @@ + from leapp.libraries.actor import repomap_calc +-from leapp.libraries.actor.pes_events_scanner import get_enabled_repoids as _get_enabled_repoids ++from leapp.libraries.actor.pes_events_scanner import get_enabled_repoids + from leapp.libraries.common.config import get_source_distro_id, get_target_distro_id + from leapp.libraries.common.config.version import get_source_major_version, get_source_version + from leapp.libraries.stdlib import api +@@ -70,7 +70,7 @@ def setup_target_repos(repositories_map_msg, pes_requested_repoids=None, + """ + # Load relevant data from messages + used_repoids_dict = _get_used_repo_dict() +- enabled_repoids = _get_enabled_repoids() ++ enabled_repoids = get_enabled_repoids() + excluded_repoids = blacklisted_repoids if blacklisted_repoids is not None else set() + custom_repos = _get_custom_target_repos() + repoids_from_installed_packages = _get_repoids_from_installed_packages() +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +index 4db9003d..c1b46916 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -1,5 +1,83 @@ ++from collections import namedtuple ++ ++from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import pes_events_scanner, repositoriesblocklist, setuptargetrepos + from leapp.libraries.actor.repomap_loader import scan_repositories ++from leapp.libraries.stdlib import api ++from leapp.models import CustomTargetRepository, RepositoriesFacts, RepositoriesSetupTasks ++ ++ExternalRepoSetupTasks = namedtuple('ExternalRepoSetupTasks', ('to_enable', 'to_block', 'custom')) ++ ++ ++class InputData(): ++ """ ++ Provide data from consumed messages ++ """ ++ ++ def __init__(self): ++ self._missing_messages = [] ++ ++ self._get_enabled_repoids() ++ self._get_external_reposetup_tasks() ++ ++ if self._missing_messages: ++ # NOTE(pstodulk): This would be an invalid object, ++ # so let's end the story here. ++ raise StopActorExecutionError( ++ message='Cannot calculate target content requirements', ++ details={ ++ 'details': 'Some of required leapp messages are missing.', ++ 'missing': [i.__name__ for i in self._missing_messages] ++ } ++ ) ++ ++ def _treat_consume_msg(self, model): ++ msgs = api.consume(model) ++ msg = next(msgs, None) ++ if list(msgs): ++ w_msg = f'Unexpectedly received more than one {model.__name__} message.' ++ api.current_logger().warning(w_msg) ++ if not msg: ++ self._missing_messages.append(model) ++ return None ++ return msg ++ ++ def _get_external_reposetup_tasks(self): ++ """ ++ Collect repository related tasks from other actors (or configs in future). ++ ++ This includes consumption of RepositoriesSetupTasks and CustomTargetRepository. ++ The second one should be understood as task as well based on the definition, ++ even when the name does not suggest it's actually task as well. ++ ++ Return named tuple with `to_enable`, `to_block`, and `custom` fields. ++ The last one is based on reposids in CustomTargetRepository messages. ++ """ ++ self.external_tasks = ExternalRepoSetupTasks(set(), set(), set()) ++ for task in api.consume(RepositoriesSetupTasks): ++ self.external_tasks.to_block.update(task.to_block) ++ self.external_tasks.to_enable.update(task.to_enable) ++ ++ self.external_tasks.custom.update({repo.repoid for repo in api.consume(CustomTargetRepository)}) ++ ++ def _get_enabled_repoids(self): ++ """ ++ Return set of repository IDs enabled on the source system. ++ ++ The information is consumed from the RepositoriesFacts message. ++ ++ :return: Set of all enabled repository IDs present on the source system. ++ :rtype: Set[str] ++ """ ++ self.enabled_repoids = set() ++ repos_facts = self._treat_consume_msg(RepositoriesFacts) ++ if repos_facts is None: ++ return ++ ++ for repo_file in repos_facts.repositories: ++ for repo in repo_file.data: ++ if repo.enabled: ++ self.enabled_repoids.add(repo.repoid) + + + def process(): +@@ -23,17 +101,18 @@ def process(): + source repos, installed package repos, and custom/blacklisted repo + configuration. + """ ++ indata = InputData() + repositories_map_msg = scan_repositories() +- +- full_blocklist_repoids, external_repoids_requests = ( +- repositoriesblocklist.compute_blocklist(repositories_map_msg) ++ blocklisted_repoids = repositoriesblocklist.compute_blocklist(repositories_map_msg, indata.external_tasks) ++ pes_requested_repoids = pes_events_scanner.scan_pes_events( ++ repositories_map_msg, ++ blocklisted_repoids, ++ indata.enabled_repoids + ) + +- pes_requested_repoids = pes_events_scanner.scan_pes_events(repositories_map_msg, full_blocklist_repoids) +- + setuptargetrepos.setup_target_repos( + repositories_map_msg, + pes_requested_repoids=pes_requested_repoids, +- blacklisted_repoids=full_blocklist_repoids, +- external_repoids_requests=external_repoids_requests, ++ blacklisted_repoids=blocklisted_repoids, ++ external_repoids_requests=indata.external_tasks.to_enable, + ) +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +index f872a947..a1f3a6e1 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +@@ -270,7 +270,7 @@ def test_actor_performs(monkeypatch): + monkeypatch.setattr(api, 'produce', produced_messages) + monkeypatch.setattr(reporting, 'create_report', created_report) + +- pes_events_scanner.scan_pes_events(repositories_mapping) ++ pes_events_scanner.scan_pes_events(repositories_mapping, set(), {'rhel8-repo'}) + + assert produced_messages.called + +@@ -341,13 +341,13 @@ def test_blacklisted_repoid_is_not_produced(monkeypatch): + monkeypatch.setattr(pes_events_scanner, 'get_pes_events', lambda folder, filename: events) + monkeypatch.setattr(pes_events_scanner, 'apply_transaction_configuration', lambda pkgs, transaction_cfg: pkgs) + monkeypatch.setattr(pes_events_scanner, 'replace_pesids_with_repoids_in_packages', +- lambda pkgs, src_pkgs_repoids, repo_map_msg=None: pkgs) ++ lambda pkgs, src_pkgs_repoids, repo_map_msg, enabled_repoids: pkgs) + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) + monkeypatch.setattr(api, 'produce', produce_mocked()) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + +- setup_tasks = pes_events_scanner.scan_pes_events(None, blacklisted_repoids={'blacklisted-rhel9'}) ++ setup_tasks = pes_events_scanner.scan_pes_events(None, {'blacklisted-rhel9'}, set()) + + assert not reporting.create_report.called + +@@ -497,10 +497,10 @@ def test_transaction_configuration_is_applied(monkeypatch): + monkeypatch.setattr(pes_events_scanner, 'remove_undesired_events', lambda events, releases: events) + monkeypatch.setattr(pes_events_scanner, '_get_enabled_modules', lambda *args: []) + monkeypatch.setattr(pes_events_scanner, 'replace_pesids_with_repoids_in_packages', +- lambda target_pkgs, repoids_of_source_pkgs, repo_map_msg=None: target_pkgs) ++ lambda target_pkgs, repoids_of_source_pkgs, repo_map_msg, enabled_repoids: target_pkgs) + monkeypatch.setattr(pes_events_scanner, + 'remove_new_packages_from_blacklisted_repos', +- lambda source_pkgs, target_pkgs, bl=None: (set(), target_pkgs)) ++ lambda source_pkgs, target_pkgs, bl: (set(), target_pkgs)) + + msgs = [ + RpmTransactionTasks(to_remove=['pkg-not-in-events']), +@@ -513,7 +513,7 @@ def test_transaction_configuration_is_applied(monkeypatch): + + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setup_tasks = pes_events_scanner.scan_pes_events(None) ++ setup_tasks = pes_events_scanner.scan_pes_events(None, set(), set()) + + assert api.produce.called == 1 + assert setup_tasks is not None +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +index 5ffa0990..fc657824 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +@@ -2,6 +2,7 @@ import pytest + + from leapp import reporting + from leapp.libraries.actor import repositoriesblocklist ++from leapp.libraries.actor.targetcontentresolver import ExternalRepoSetupTasks + from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked + from leapp.libraries.stdlib import api + from leapp.models import ( +@@ -12,7 +13,6 @@ from leapp.models import ( + RepositoriesBlocklisted, + RepositoriesFacts, + RepositoriesMapping, +- RepositoriesSetupTasks, + RepositoryData, + RepositoryFile + ) +@@ -228,21 +228,19 @@ def _get_produced_blacklisted(): + def test_compute_blocklist_merges_internal_and_external(monkeypatch): + """ + compute_blocklist merges internal blocklist with external +- RepositoriesSetupTasks.to_block and produces both messages. ++ ExternalRepoSetupTasks.to_block and produces both messages. + """ +- external_tasks = [ +- RepositoriesSetupTasks(to_enable=['some-repo'], to_block=['ext-blocked-1', 'ext-blocked-2']), +- ] ++ external_tasks = ExternalRepoSetupTasks( ++ to_enable={'some-repo'}, to_block={'ext-blocked-1', 'ext-blocked-2'}, custom=set() ++ ) + monkeypatch.setattr( + repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'crb-repo-1'} + ) +- monkeypatch.setattr(api, 'consume', lambda _model: iter(external_tasks)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) + + assert full_blocklist == {'crb-repo-1', 'ext-blocked-1', 'ext-blocked-2'} +- assert external_repoids == {'some-repo'} + + blocklisted_msgs = _get_produced_blocklisted() + assert len(blocklisted_msgs) == 1 +@@ -255,16 +253,15 @@ def test_compute_blocklist_merges_internal_and_external(monkeypatch): + + def test_compute_blocklist_no_messages_when_empty(monkeypatch): + """No blocklist messages are produced when blocklist is empty.""" ++ external_tasks = ExternalRepoSetupTasks(to_enable=set(), to_block=set(), custom=set()) + monkeypatch.setattr( + repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: set() + ) +- monkeypatch.setattr(api, 'consume', lambda _model: iter([])) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) + + assert full_blocklist == set() +- assert external_repoids == set() + assert len(_get_produced_blocklisted()) == 0 + assert len(_get_produced_blacklisted()) == 0 + +@@ -272,53 +269,47 @@ def test_compute_blocklist_no_messages_when_empty(monkeypatch): + @suppress_deprecation(RepositoriesBlacklisted) + def test_compute_blocklist_only_internal(monkeypatch): + """Only internal blocklist when no external tasks are present.""" ++ external_tasks = ExternalRepoSetupTasks(to_enable=set(), to_block=set(), custom=set()) + monkeypatch.setattr( + repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'crb-repo-1'} + ) +- monkeypatch.setattr(api, 'consume', lambda _model: iter([])) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) + + assert full_blocklist == {'crb-repo-1'} +- assert external_repoids == set() + assert len(_get_produced_blocklisted()) == 1 + + +-def test_compute_blocklist_multiple_external_tasks(monkeypatch): +- """Blocklist and to_enable from multiple external tasks are aggregated.""" +- external_tasks = [ +- RepositoriesSetupTasks(to_block=['ext-1'], to_enable=['repo-a']), +- RepositoriesSetupTasks(to_block=['ext-2', 'ext-3'], to_enable=['repo-b']), +- ] ++def test_compute_blocklist_aggregated_external_tasks(monkeypatch): ++ """Blocklist from pre-aggregated external tasks is merged with internal.""" ++ external_tasks = ExternalRepoSetupTasks( ++ to_enable={'repo-a', 'repo-b'}, to_block={'ext-1', 'ext-2', 'ext-3'}, custom=set() ++ ) + monkeypatch.setattr( + repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: set() + ) +- monkeypatch.setattr(api, 'consume', lambda _model: iter(external_tasks)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) + + assert full_blocklist == {'ext-1', 'ext-2', 'ext-3'} +- assert external_repoids == {'repo-a', 'repo-b'} + + + @suppress_deprecation(RepositoriesBlacklisted) + def test_compute_blocklist_overlapping_internal_and_external(monkeypatch): + """Overlapping repoids between internal and external blocklists are deduplicated.""" +- external_tasks = [ +- RepositoriesSetupTasks(to_block=['shared-repo', 'ext-only']), +- ] ++ external_tasks = ExternalRepoSetupTasks( ++ to_enable=set(), to_block={'shared-repo', 'ext-only'}, custom=set() ++ ) + monkeypatch.setattr( + repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'shared-repo', 'internal-only'} + ) +- monkeypatch.setattr(api, 'consume', lambda _model: iter(external_tasks)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist, external_repoids = repositoriesblocklist.compute_blocklist(None) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) + + assert full_blocklist == {'shared-repo', 'internal-only', 'ext-only'} +- assert external_repoids == set() + + blocklisted_msgs = _get_produced_blocklisted() + assert len(blocklisted_msgs) == 1 +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +index 4e425da6..98f19ea8 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +@@ -1,4 +1,17 @@ ++import pytest ++ ++from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import targetcontentresolver ++from leapp.libraries.actor.targetcontentresolver import ExternalRepoSetupTasks, InputData ++from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import ( ++ CustomTargetRepository, ++ RepositoriesFacts, ++ RepositoriesSetupTasks, ++ RepositoryData, ++ RepositoryFile ++) + + + def test_process_orchestration(monkeypatch): +@@ -10,22 +23,33 @@ def test_process_orchestration(monkeypatch): + + fake_repo_map = object() + fake_blocklist = frozenset({'blocked-repo'}) +- fake_external_repoids = frozenset({'ext-repo'}) ++ fake_external_tasks = ExternalRepoSetupTasks( ++ to_enable=frozenset({'ext-repo'}), to_block=frozenset(), custom=frozenset() ++ ) ++ fake_enabled_repoids = frozenset({'enabled-repo'}) + fake_pes_repoids = frozenset({'pes-repo'}) + ++ class FakeInputData: ++ def __init__(self): ++ call_log.append('InputData') ++ self.external_tasks = fake_external_tasks ++ self.enabled_repoids = fake_enabled_repoids ++ + def mock_scan_repositories(): + call_log.append('scan_repositories') + return fake_repo_map + +- def mock_compute_blocklist(repo_mapping): ++ def mock_compute_blocklist(repo_mapping, external_tasks): + call_log.append('compute_blocklist') + assert repo_mapping is fake_repo_map +- return fake_blocklist, fake_external_repoids ++ assert external_tasks is fake_external_tasks ++ return fake_blocklist + +- def mock_scan_pes_events(repo_mapping, blacklisted_repoids): ++ def mock_scan_pes_events(repo_mapping, blacklisted_repoids, enabled_repoids): + call_log.append('scan_pes_events') + assert repo_mapping is fake_repo_map + assert blacklisted_repoids is fake_blocklist ++ assert enabled_repoids is fake_enabled_repoids + return fake_pes_repoids + + def mock_setup_target_repos(repo_mapping, pes_requested_repoids=None, +@@ -34,8 +58,12 @@ def test_process_orchestration(monkeypatch): + assert repo_mapping is fake_repo_map + assert pes_requested_repoids is fake_pes_repoids + assert blacklisted_repoids is fake_blocklist +- assert external_repoids_requests is fake_external_repoids ++ assert external_repoids_requests is fake_external_tasks.to_enable + ++ monkeypatch.setattr( ++ 'leapp.libraries.actor.targetcontentresolver.InputData', ++ FakeInputData, ++ ) + monkeypatch.setattr( + 'leapp.libraries.actor.targetcontentresolver.scan_repositories', + mock_scan_repositories, +@@ -56,8 +84,84 @@ def test_process_orchestration(monkeypatch): + targetcontentresolver.process() + + assert call_log == [ ++ 'InputData', + 'scan_repositories', + 'compute_blocklist', + 'scan_pes_events', + 'setup_target_repos', + ] ++ ++ ++def _make_repo_facts(repoids_enabled=None, repoids_disabled=None): ++ repos_data = [] ++ for repoid in (repoids_enabled or []): ++ repos_data.append(RepositoryData(repoid=repoid, name=repoid, enabled=True)) ++ for repoid in (repoids_disabled or []): ++ repos_data.append(RepositoryData(repoid=repoid, name=repoid, enabled=False)) ++ return RepositoriesFacts( ++ repositories=[RepositoryFile(file='/etc/yum.repos.d/test.repo', data=repos_data)] ++ ) ++ ++ ++def test_inputdata_collects_enabled_repoids(monkeypatch): ++ repo_facts = _make_repo_facts( ++ repoids_enabled=['repo-a', 'repo-b'], ++ repoids_disabled=['repo-c'], ++ ) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts])) ++ ++ indata = InputData() ++ ++ assert indata.enabled_repoids == {'repo-a', 'repo-b'} ++ ++ ++def test_inputdata_raises_when_repofacts_missing(monkeypatch): ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[])) ++ ++ with pytest.raises(StopActorExecutionError): ++ InputData() ++ ++ ++def test_inputdata_aggregates_external_tasks(monkeypatch): ++ repo_facts = _make_repo_facts(repoids_enabled=['repo-a']) ++ setup_tasks_1 = RepositoriesSetupTasks(to_enable=['en-1'], to_block=['bl-1']) ++ setup_tasks_2 = RepositoriesSetupTasks(to_enable=['en-2'], to_block=['bl-2', 'bl-3']) ++ custom_1 = CustomTargetRepository(repoid='custom-1') ++ custom_2 = CustomTargetRepository(repoid='custom-2') ++ ++ monkeypatch.setattr( ++ api, 'current_actor', ++ CurrentActorMocked(msgs=[repo_facts, setup_tasks_1, setup_tasks_2, custom_1, custom_2]) ++ ) ++ ++ indata = InputData() ++ ++ assert indata.external_tasks.to_enable == {'en-1', 'en-2'} ++ assert indata.external_tasks.to_block == {'bl-1', 'bl-2', 'bl-3'} ++ assert indata.external_tasks.custom == {'custom-1', 'custom-2'} ++ ++ ++def test_inputdata_empty_external_tasks(monkeypatch): ++ repo_facts = _make_repo_facts(repoids_enabled=['repo-a']) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts])) ++ ++ indata = InputData() ++ ++ assert indata.external_tasks.to_enable == set() ++ assert indata.external_tasks.to_block == set() ++ assert indata.external_tasks.custom == set() ++ ++ ++def test_inputdata_warns_on_duplicate_repofacts(monkeypatch): ++ repo_facts_1 = _make_repo_facts(repoids_enabled=['repo-a']) ++ repo_facts_2 = _make_repo_facts(repoids_enabled=['repo-b']) ++ monkeypatch.setattr( ++ api, 'current_actor', ++ CurrentActorMocked(msgs=[repo_facts_1, repo_facts_2]) ++ ) ++ monkeypatch.setattr(api, 'current_logger', logger_mocked()) ++ ++ indata = InputData() ++ ++ assert indata.enabled_repoids == {'repo-a'} ++ assert any('more than one' in msg for msg in api.current_logger.warnmsg) +-- +2.54.0 + diff --git a/0099-3-9-targetcontentresolver-RepositoriesBlacklisted-us.patch b/0099-3-9-targetcontentresolver-RepositoriesBlacklisted-us.patch new file mode 100644 index 0000000..aef74a1 --- /dev/null +++ b/0099-3-9-targetcontentresolver-RepositoriesBlacklisted-us.patch @@ -0,0 +1,309 @@ +From 51af2cfd4b028efa58c75d34c55b7b65eac7df27 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Sat, 6 Jun 2026 04:25:39 +0200 +Subject: [PATCH 099/108] [3/9] targetcontentresolver: RepositoriesBlacklisted: + use it as task + +Previous "refactoring" commits changes the purpose of the message +from + 1. task specifying what should be blocklisted + 2. fact what repositories are blocklisted +leaving it to be just the fact message. As discussed in team, +we believe that if the model has been used by any other third-party +actors, it was used in the first way to request blocklist of specific +repositories. So updating the behaviour in the way the message is +consumed and processed now instead of producing it. + +Note that both purposes cannot be kept in this original message +due to planned changes - which is one of reasons why the model +becomes deprecated in previous commits. + +Unit tests adjusted with assistance of AI. + +Jira: RHEL-115867 +Assisted-by: Claude Code (Opus 4.6) +Signed-off-by: Petr Stodulka +--- + .../libraries-and-api/deprecations-list.md | 2 +- + .../actors/targetcontentresolver/actor.py | 4 +-- + .../libraries/repositoriesblocklist.py | 8 ++--- + .../libraries/targetcontentresolver.py | 8 ++++- + .../tests/test_repositoriesblocklist.py | 23 ++---------- + .../tests/test_targetcontentresolver.py | 36 +++++++++++++++++++ + .../common/models/repositoriesblocklisted.py | 8 +++-- + 7 files changed, 55 insertions(+), 34 deletions(-) + +diff --git a/docs/source/libraries-and-api/deprecations-list.md b/docs/source/libraries-and-api/deprecations-list.md +index 301de558..1968c8af 100644 +--- a/docs/source/libraries-and-api/deprecations-list.md ++++ b/docs/source/libraries-and-api/deprecations-list.md +@@ -18,12 +18,12 @@ 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. ++ - **`RepositoriesBlacklisted`** - To get the list of blocklisted repositories, consume `RepositoriesBlocklisted` message. To request a repository to be blocklisted, use `RepositoriesSetupTasks.to_block` instead. + - 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 + - **`leapp.libraries.common.config.get_distro_id()`** - The function has been replaced by variants for source and target distros - `leapp.libraries.common.config.get_source_distro_id()` and `leapp.libraries.common.config.get_target_distro_id()`. +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/actor.py b/repos/system_upgrade/common/actors/targetcontentresolver/actor.py +index 6ea95732..e132cc3a 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/actor.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/actor.py +@@ -43,9 +43,10 @@ class TargetContentResolver(Actor): + DistributionSignedRPM, + EnabledModules, + InstalledRPM, ++ RHUIInfo, ++ RepositoriesBlacklisted, + RepositoriesFacts, + RepositoriesSetupTasks, +- RHUIInfo, + RpmTransactionTasks, + UsedRepositories, + ) +@@ -53,7 +54,6 @@ class TargetContentResolver(Actor): + ConsumedDataAsset, + PESRpmTransactionTasks, + Report, +- RepositoriesBlacklisted, + RepositoriesBlocklisted, + RepositoriesMapping, + SkippedRepositories, +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +index 216bea52..f92f64fb 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +@@ -1,8 +1,7 @@ + from leapp import reporting + from leapp.libraries.common.config.version import get_source_major_version, get_target_major_version + from leapp.libraries.stdlib import api +-from leapp.models import CustomTargetRepository, RepositoriesBlacklisted, RepositoriesBlocklisted, RepositoriesFacts +-from leapp.utils.deprecation import suppress_deprecation ++from leapp.models import CustomTargetRepository, RepositoriesBlocklisted, RepositoriesFacts + + # {OS_MAJOR_VERSION: PESID} + UNSUPPORTED_PESIDS = { +@@ -159,7 +158,6 @@ def get_blocklisted_repoids(repo_mapping): + return filtered_repos_to_exclude + + +-@suppress_deprecation(RepositoriesBlacklisted) + def compute_blocklist(repo_mapping, external_tasks): + """ + Create the blocklist of repositories that should be blocked during the upgrade. +@@ -186,8 +184,7 @@ def compute_blocklist(repo_mapping, external_tasks): + the in-place upgrade purpose. Currently there is no explicit custom + configuration to disable a repo. + +- Once the list is calculated, the RepositoriesBlocklisted and +- RepositoriesBlacklisted messages are produced. ++ Once the list is calculated the RepositoriesBlocklisted message is produced. + + :param repo_mapping: RepositoriesMapping message. + :type repo_mapping: RepositoriesMapping +@@ -205,6 +202,5 @@ def compute_blocklist(repo_mapping, external_tasks): + full_blocklist = internal_blocklist.union(external_tasks.to_block) + if full_blocklist: + api.produce(RepositoriesBlocklisted(repoids=sorted(full_blocklist))) +- api.produce(RepositoriesBlacklisted(repoids=sorted(full_blocklist))) + + return full_blocklist +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +index c1b46916..e6885e44 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -4,7 +4,8 @@ from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import pes_events_scanner, repositoriesblocklist, setuptargetrepos + from leapp.libraries.actor.repomap_loader import scan_repositories + from leapp.libraries.stdlib import api +-from leapp.models import CustomTargetRepository, RepositoriesFacts, RepositoriesSetupTasks ++from leapp.models import CustomTargetRepository, RepositoriesBlacklisted, RepositoriesFacts, RepositoriesSetupTasks ++from leapp.utils.deprecation import suppress_deprecation + + ExternalRepoSetupTasks = namedtuple('ExternalRepoSetupTasks', ('to_enable', 'to_block', 'custom')) + +@@ -42,6 +43,7 @@ class InputData(): + return None + return msg + ++ @suppress_deprecation(RepositoriesBlacklisted) + def _get_external_reposetup_tasks(self): + """ + Collect repository related tasks from other actors (or configs in future). +@@ -58,6 +60,10 @@ class InputData(): + self.external_tasks.to_block.update(task.to_block) + self.external_tasks.to_enable.update(task.to_enable) + ++ # DEPRECATED: Drop after 2026-07 ++ for task in api.consume(RepositoriesBlacklisted): ++ self.external_tasks.to_block.update(task.repoids) ++ + self.external_tasks.custom.update({repo.repoid for repo in api.consume(CustomTargetRepository)}) + + def _get_enabled_repoids(self): +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +index fc657824..ce72dc9e 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +@@ -9,14 +9,12 @@ from leapp.models import ( + CustomTargetRepository, + PESIDRepositoryEntry, + RepoMapEntry, +- RepositoriesBlacklisted, + RepositoriesBlocklisted, + RepositoriesFacts, + RepositoriesMapping, + RepositoryData, + RepositoryFile + ) +-from leapp.utils.deprecation import suppress_deprecation + + + @pytest.fixture +@@ -216,19 +214,13 @@ def test_enablerepo_option(monkeypatch, + + + def _get_produced_blocklisted(): +- return [m for m in api.produce.model_instances +- if isinstance(m, RepositoriesBlocklisted) and not isinstance(m, RepositoriesBlacklisted)] ++ return [m for m in api.produce.model_instances if isinstance(m, RepositoriesBlocklisted)] + + +-def _get_produced_blacklisted(): +- return [m for m in api.produce.model_instances if isinstance(m, RepositoriesBlacklisted)] +- +- +-@suppress_deprecation(RepositoriesBlacklisted) + def test_compute_blocklist_merges_internal_and_external(monkeypatch): + """ + compute_blocklist merges internal blocklist with external +- ExternalRepoSetupTasks.to_block and produces both messages. ++ ExternalRepoSetupTasks.to_block and produces RepositoriesBlocklisted. + """ + external_tasks = ExternalRepoSetupTasks( + to_enable={'some-repo'}, to_block={'ext-blocked-1', 'ext-blocked-2'}, custom=set() +@@ -246,10 +238,6 @@ def test_compute_blocklist_merges_internal_and_external(monkeypatch): + assert len(blocklisted_msgs) == 1 + assert set(blocklisted_msgs[0].repoids) == full_blocklist + +- blacklisted_msgs = _get_produced_blacklisted() +- assert len(blacklisted_msgs) == 1 +- assert set(blacklisted_msgs[0].repoids) == full_blocklist +- + + def test_compute_blocklist_no_messages_when_empty(monkeypatch): + """No blocklist messages are produced when blocklist is empty.""" +@@ -263,10 +251,8 @@ def test_compute_blocklist_no_messages_when_empty(monkeypatch): + + assert full_blocklist == set() + assert len(_get_produced_blocklisted()) == 0 +- assert len(_get_produced_blacklisted()) == 0 + + +-@suppress_deprecation(RepositoriesBlacklisted) + def test_compute_blocklist_only_internal(monkeypatch): + """Only internal blocklist when no external tasks are present.""" + external_tasks = ExternalRepoSetupTasks(to_enable=set(), to_block=set(), custom=set()) +@@ -296,7 +282,6 @@ def test_compute_blocklist_aggregated_external_tasks(monkeypatch): + assert full_blocklist == {'ext-1', 'ext-2', 'ext-3'} + + +-@suppress_deprecation(RepositoriesBlacklisted) + def test_compute_blocklist_overlapping_internal_and_external(monkeypatch): + """Overlapping repoids between internal and external blocklists are deduplicated.""" + external_tasks = ExternalRepoSetupTasks( +@@ -314,7 +299,3 @@ def test_compute_blocklist_overlapping_internal_and_external(monkeypatch): + blocklisted_msgs = _get_produced_blocklisted() + assert len(blocklisted_msgs) == 1 + assert set(blocklisted_msgs[0].repoids) == full_blocklist +- +- blacklisted_msgs = _get_produced_blacklisted() +- assert len(blacklisted_msgs) == 1 +- assert set(blacklisted_msgs[0].repoids) == full_blocklist +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +index 98f19ea8..97aaad33 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +@@ -7,11 +7,13 @@ from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked + from leapp.libraries.stdlib import api + from leapp.models import ( + CustomTargetRepository, ++ RepositoriesBlacklisted, + RepositoriesFacts, + RepositoriesSetupTasks, + RepositoryData, + RepositoryFile + ) ++from leapp.utils.deprecation import suppress_deprecation + + + def test_process_orchestration(monkeypatch): +@@ -165,3 +167,37 @@ def test_inputdata_warns_on_duplicate_repofacts(monkeypatch): + + assert indata.enabled_repoids == {'repo-a'} + assert any('more than one' in msg for msg in api.current_logger.warnmsg) ++ ++ ++@suppress_deprecation(RepositoriesBlacklisted) ++def test_inputdata_consumes_deprecated_blacklisted(monkeypatch): ++ """RepositoriesBlacklisted repoids are added to external_tasks.to_block.""" ++ repo_facts = _make_repo_facts(repoids_enabled=['repo-a']) ++ blacklisted = RepositoriesBlacklisted(repoids=['bl-1', 'bl-2']) ++ ++ monkeypatch.setattr( ++ api, 'current_actor', ++ CurrentActorMocked(msgs=[repo_facts, blacklisted]) ++ ) ++ ++ indata = InputData() ++ ++ assert indata.external_tasks.to_block == {'bl-1', 'bl-2'} ++ ++ ++@suppress_deprecation(RepositoriesBlacklisted) ++def test_inputdata_merges_blacklisted_with_setup_tasks(monkeypatch): ++ """RepositoriesBlacklisted repoids are merged with RepositoriesSetupTasks.to_block.""" ++ repo_facts = _make_repo_facts(repoids_enabled=['repo-a']) ++ setup_tasks = RepositoriesSetupTasks(to_enable=['en-1'], to_block=['bl-from-setup']) ++ blacklisted = RepositoriesBlacklisted(repoids=['bl-from-deprecated']) ++ ++ monkeypatch.setattr( ++ api, 'current_actor', ++ CurrentActorMocked(msgs=[repo_facts, setup_tasks, blacklisted]) ++ ) ++ ++ indata = InputData() ++ ++ assert indata.external_tasks.to_block == {'bl-from-setup', 'bl-from-deprecated'} ++ assert indata.external_tasks.to_enable == {'en-1'} +diff --git a/repos/system_upgrade/common/models/repositoriesblocklisted.py b/repos/system_upgrade/common/models/repositoriesblocklisted.py +index c1825ec3..95fbd2dc 100644 +--- a/repos/system_upgrade/common/models/repositoriesblocklisted.py ++++ b/repos/system_upgrade/common/models/repositoriesblocklisted.py +@@ -23,12 +23,14 @@ class RepositoriesBlocklisted(Model): + since='2026-06-01', + message=( + 'This model has been deprecated and replaced. ' +- 'To get the list of blocklisted repositories, use RepositoriesBlocklisted. ' +- 'To request a repository to be blocklisted, use RepositoriesSetupTasks.blocklist.' ++ 'To get the list of blocklisted repositories, consume RepositoriesBlocklisted. ' ++ 'To request a repository to be blocklisted, produce RepositoriesSetupTasks.to_block.' + ), + ) + class RepositoriesBlacklisted(RepositoriesBlocklisted): + """ +- Repository IDs that should be ignored by Leapp during the upgrade process. ++ Specify list of repository IDs that should be blocked during the upgrade. ++ ++ Note this is deprecated and you should use RepositoriesSetupTasks.to_block + """ + topic = SystemFactsTopic +-- +2.54.0 + diff --git a/0100-4-9-targetcontentresolver-repositoriesblacklist-rede.patch b/0100-4-9-targetcontentresolver-repositoriesblacklist-rede.patch new file mode 100644 index 0000000..c457948 --- /dev/null +++ b/0100-4-9-targetcontentresolver-repositoriesblacklist-rede.patch @@ -0,0 +1,816 @@ +From 6d588f44f726eb2e16b15498caa1fc2887faccec Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Sat, 6 Jun 2026 06:52:09 +0200 +Subject: [PATCH 100/108] [4/9] targetcontentresolver: repositoriesblacklist: + redesign + +* The library does not consume any messages anymore: + * RepositoriesFacts were consumed to obtain information about + enabled repositories on the source system. This information is now + propagated - given to the entry `compute_blocklist()` function + as `enabled_repoids` parameter + + * ConsumeTargetRepository is not needed to consume anymore as the + information is included inside external_tasks. + +* Simplify the logig around "internal" (or implicit) blocklisting. + The original code has been written in more generic way as a relict + from IPU 7 -> 8 time, where "Optional" repositories existed + and CRB repositories have been newly introduced. Nowadays, we have + just CRB repositories and it's clear that only CRB repositories + will need still this special handling. So the solution has been + adjusted to CRB repositories only and all related code and comments + have been updated. + +* Specify the priority order when processing implicit/internal (CRB), + external, and explicit customer requests. The order is following: + internal (CRB) < external enable rq < external block rq < explicit user request + +* Work with sets instead of lists everywhere. This reduced amount + of code by another 25% when keeping it simple. + +* Filter out irrelevant CRB repositories from the report. + Originally the report about blocklisted CRB repositories included + all target CRB repoids - for all architectures and distributions. + Include only repoids related to the current architurecture. + Another filtering is planned to be applied later in the repomapping + itself, so only relevant data will be provided. + +* Update generated reports. + * Remove the "Using repository not supported by Red Hat" report. + It was inconsistent (printed only when a CRB repo has not been + enabled on the source system and user enabled it manually for the + upgrade process only. Also the report does not make sense for any + other (non-RHEL) distro. Discussing it with PO, we agreed on the + removal of the report. + * Add the stable Key to the exclusion report + +* Adjust the logic around implicit CRB repositories blocking. + * Do not blocklist any CRB repositories implicitly when any of them + is enabled by user manually. The behaviour is now consistent with + the one when a CRB repository is enabled on the source system. + Having a different behaviour to these both cases did not make a sense. + * Additionally blocks CRB repositories only on RHEL systems, + as other distros are not supported by us directly so the report + does not make a sense. + +Unit tests adjusted with assistance of AI. + +Jira: RHEL-115867 +Assisted-by: Claude Code (Opus 4.6) +Signed-off-by: Petr Stodulka +--- + .../libraries/repositoriesblocklist.py | 209 +++++------- + .../libraries/targetcontentresolver.py | 6 +- + .../tests/test_repositoriesblocklist.py | 308 ++++++++++-------- + .../tests/test_targetcontentresolver.py | 3 +- + .../common/models/repositoriessetuptasks.py | 7 + + 5 files changed, 275 insertions(+), 258 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +index f92f64fb..e526bcbd 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +@@ -1,28 +1,8 @@ + from leapp import reporting ++from leapp.libraries.common.config import get_target_distro_id + from leapp.libraries.common.config.version import get_source_major_version, get_target_major_version + from leapp.libraries.stdlib import api +-from leapp.models import CustomTargetRepository, RepositoriesBlocklisted, RepositoriesFacts +- +-# {OS_MAJOR_VERSION: PESID} +-UNSUPPORTED_PESIDS = { +- "8": "rhel8-CRB", +- "9": "rhel9-CRB", +- "10": "rhel10-CRB" +-} +- +- +-def _report_using_unsupported_repos(repos): +- report = [ +- reporting.Title('Using repository not supported by Red Hat'), +- reporting.Summary( +- 'The following repositories have been used for the ' +- 'upgrade, but they are not supported by the Red Hat.:\n' +- '- {}'.format('\n - '.join(repos)) +- ), +- reporting.Severity(reporting.Severity.HIGH), +- reporting.Groups([reporting.Groups.REPOSITORY]), +- ] +- reporting.create_report(report) ++from leapp.models import RepositoriesBlocklisted + + + def _report_excluded_repos(repos): +@@ -47,125 +27,110 @@ def _report_excluded_repos(repos): + ' as an argument (the option can be used multiple times).' + ) + ), ++ reporting.Key('1b9132cb2362ae7830e48eee7811be9527747de8') + ] + reporting.create_report(report) + + +-def _get_manually_enabled_repos(): ++def _get_crb_repos(repo_mapping, major_version): + """ +- Get a set of repositories (repoids) that are manually enabled. ++ Return set of relevant CRB repoids for the specified major version + +- manually enabled means ( +- specified by --enablerepo option of the leapp command or +- inside the /etc/leapp/files/leapp_upgrade_repositories.repo), +- ) +- :rtype: set [repoid] +- """ +- try: +- return {repo.repoid for repo in api.consume(CustomTargetRepository)} +- except StopIteration: +- return set() +- +- +-def _get_pesid_repos(repo_mapping, pesid, major_version): +- """ +- Returns a list of pesid repos with the specified pesid and major version. +- +- :param str pesid: The PES ID representing the family of repositories. +- :param str major_version: The major version of the RHEL OS. +- :returns: A set of repoids with the specified pesid and major version. +- :rtype: List[PESIDRepositoryEntry] +- """ +- pesid_repos = [] +- for pesid_repo in repo_mapping.repositories: +- if pesid_repo.pesid == pesid and pesid_repo.major_version == major_version: +- pesid_repos.append(pesid_repo) +- return pesid_repos +- +- +-def _get_repoids_to_exclude(repo_mapping): +- """ +- Returns a set of repoids that should be blocklisted on the target system. +- +- :param RepositoriesMapping repo_mapping: Repository mapping data. +- :returns: A set of repoids to blocklist on the target system. +- :rtype: Set[str] +- """ +- pesid_repos_to_exclude = _get_pesid_repos(repo_mapping, +- UNSUPPORTED_PESIDS[get_target_major_version()], +- get_target_major_version()) +- return {pesid_repo.repoid for pesid_repo in pesid_repos_to_exclude} +- +- +-def _are_optional_repos_disabled(repo_mapping, repos_on_system): +- """ +- Checks whether all optional repositories are disabled. +- +- :param RepositoriesMapping repo_mapping: Repository mapping data. +- :param RepositoriesFacts repos_on_system: Installed repositories on the source system. +- :returns: True if there are any optional repositories enabled on the source system. +- """ +- +- # Get a set of all repo_ids that are optional +- optional_pesid_repos = _get_pesid_repos(repo_mapping, +- UNSUPPORTED_PESIDS[get_source_major_version()], +- get_source_major_version()) +- +- optional_repoids = [optional_pesid_repo.repoid for optional_pesid_repo in optional_pesid_repos] +- +- # Gather all optional repositories on the source system that are not enabled +- for repofile in repos_on_system.repositories: +- for repository in repofile.data: +- if repository.repoid in optional_repoids and repository.enabled: +- return False +- return True +- +- +-def get_blocklisted_repoids(repo_mapping): +- """ +- Exclude target repositories provided by Red Hat without support. +- +- Conditions to exclude: +- - there are not such repositories already enabled on the source system +- (e.g. "CRB" repositories) +- - such repositories are not required for the upgrade explicitly by the user +- (e.g. via the --enablerepo option or via the /etc/leapp/files/leapp_upgrade_repositories.repo file) +- +- E.g. CRB repository is provided by Red Hat but it is without the support. ++ Only CRB repositories for the current architecture and distribution are ++ relevant. + + :param repo_mapping: RepositoriesMapping message. +- :returns: Set of repoids to blocklist on the target system. +- :rtype: set ++ :type repo_mapping: RepositoriesMapping ++ :param major_version: The OS major version. ++ :type major_version: str ++ :returns: A set of repoids with the specified pesid and major version. ++ :rtype: Set[str] + """ +- # TODO(pstodulk): replace it by enabled_repos +- repos_facts = next(api.consume(RepositoriesFacts), None) ++ pesid = f'rhel{major_version}-CRB' ++ curr_arch = api.current_actor().configuration.architecture ++ crb_repoids = set() ++ for pesid_repo in repo_mapping.repositories: ++ if pesid_repo.major_version != major_version or pesid_repo.arch != curr_arch: ++ # irrelevant repository ++ continue ++ if pesid_repo.pesid == pesid and pesid_repo.major_version == major_version: ++ crb_repoids.add(pesid_repo.repoid) ++ return crb_repoids + +- if not _are_optional_repos_disabled(repo_mapping, repos_facts): +- # nothing to do - an optional repository is enabled ++ ++def _are_crb_repos_disabled(repo_mapping, enabled_repoids): ++ """ ++ Checks whether all CRB repositories are disabled. ++ ++ :param repo_mapping: RepositoriesMapping message. ++ :type repo_mapping: RepositoriesMapping ++ :param enabled_repoids: Set of repoids enabled on the source system. ++ :type enabled_repoids: Set[str] ++ :returns: False if any CRB repositories are enabled on the source system, True otherwise. ++ :rtype: bool ++ """ ++ return enabled_repoids.isdisjoint(_get_crb_repos(repo_mapping, get_source_major_version())) ++ ++ ++def _calc_internal_blocklist(repo_mapping, external_tasks, enabled_repoids): ++ """ ++ Calculate the internal blocklist of target CRB repositories. ++ ++ Conditions to exclude: ++ - no CRB repositories are enabled on the source system ++ - repository is not explicitly configured by user to be used during the upgrade ++ (e.g. via the --enablerepo option or via the /etc/leapp/files/leapp_upgrade_repositories.repo file) ++ ++ Also report explicitly enabled and blocklisted CRB repositories, unless ++ CRB is already already enabled on the source system. ++ ++ :param repo_mapping: RepositoriesMapping message. ++ :type repo_mapping: RepositoriesMapping ++ :param external_tasks: External repositories tasks represented by object with following fields: ++ ++ * ``to_enable`` - repositories that should be enabled ++ * ``to_block`` - repositories that should be blocklisted ++ * ``custom`` - repositories explicitly requested by user to be used during the upgrade ++ ++ :type external_tasks: targetcontentresolver.ExternalRepoSetupTasks ++ :param enabled_repoids: Set of repoids enabled on the source system. ++ :type enabled_repoids: Set[str] ++ :returns: Set of CRB repoids to blocklist on the target system. ++ :rtype: Set[str] ++ """ ++ if not _are_crb_repos_disabled(repo_mapping, enabled_repoids): ++ # nothing to do - a CRB repo is enabled + return set() + +- repos_to_exclude = _get_repoids_to_exclude(repo_mapping) ++ repos_to_exclude = _get_crb_repos(repo_mapping, get_target_major_version()) + +- # Do not exclude repos manually enabled from the CLI +- manually_enabled_repos = _get_manually_enabled_repos() & repos_to_exclude +- filtered_repos_to_exclude = repos_to_exclude - manually_enabled_repos ++ # Do not exclude repositories explicitly required by user for the upgrade ++ manually_enabled_repos = external_tasks.custom & repos_to_exclude + + if manually_enabled_repos: +- _report_using_unsupported_repos(manually_enabled_repos) +- if filtered_repos_to_exclude: +- _report_excluded_repos(filtered_repos_to_exclude) ++ # NOTE(pstodulk): Same like in case that a CRB repo is enabled on src OS ++ return set() + +- return filtered_repos_to_exclude ++ if get_target_distro_id() == 'rhel': ++ # TODO(pstodulk): unify reports about blocklisted repos and effect on ++ # rpms tasks in pes_events_scanner ++ _report_excluded_repos(repos_to_exclude) ++ ++ return repos_to_exclude + + +-def compute_blocklist(repo_mapping, external_tasks): ++def compute_blocklist(repo_mapping, external_tasks, enabled_repoids): + """ + Create the blocklist of repositories that should be blocked during the upgrade. + ++ Additional effects: ++ * Once the list is calculated the RepositoriesBlocklisted message is produced. ++ * Generate reports related CRB repositories ++ + The content in the CRB repository is considered as unsupported. Hence + we do not want to enable such a repository by default on the target system + unless +- * it is explicitely requested, or ++ * it is explicitly requested, or + * it is already enabled on the source system + This is important as some functionality originally supported on the source + source system can be moved to the CRB repository on the target system. +@@ -180,25 +145,25 @@ def compute_blocklist(repo_mapping, external_tasks): + + The external custom request has the highest priority as this comes from + the system configuration or from the `--enablerepo` option used when executing +- leapp - so it's understood as decision made by user explicitely for ++ leapp - so it's understood as decision made by user explicitly for + the in-place upgrade purpose. Currently there is no explicit custom + configuration to disable a repo. + +- Once the list is calculated the RepositoriesBlocklisted message is produced. +- + :param repo_mapping: RepositoriesMapping message. + :type repo_mapping: RepositoriesMapping + :param external_tasks: External repositories tasks represented by object with following fields: + + * ``to_enable`` - repositories that should be enabled + * ``to_block`` - repositories that should be blocklisted +- * ``custom`` - repositories explicitely requested by user to be used during the upgrade ++ * ``custom`` - repositories explicitly requested by user to be used during the upgrade + + :type external_tasks: targetcontentresolver.ExternalRepoSetupTasks ++ :param enabled_repoids: Set of repoids enabled on the source system. ++ :type enabled_repoids: Set[str] + :returns: List of blocklisted repositories + :rtype: List[str] + """ +- internal_blocklist = get_blocklisted_repoids(repo_mapping) ++ internal_blocklist = _calc_internal_blocklist(repo_mapping, external_tasks, enabled_repoids) + full_blocklist = internal_blocklist.union(external_tasks.to_block) + if full_blocklist: + api.produce(RepositoriesBlocklisted(repoids=sorted(full_blocklist))) +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +index e6885e44..e70346b0 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -109,7 +109,11 @@ def process(): + """ + indata = InputData() + repositories_map_msg = scan_repositories() +- blocklisted_repoids = repositoriesblocklist.compute_blocklist(repositories_map_msg, indata.external_tasks) ++ blocklisted_repoids = repositoriesblocklist.compute_blocklist( ++ repositories_map_msg, ++ indata.external_tasks, ++ indata.enabled_repoids ++ ) + pes_requested_repoids = pes_events_scanner.scan_pes_events( + repositories_map_msg, + blocklisted_repoids, +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +index ce72dc9e..843c8f74 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +@@ -5,31 +5,9 @@ from leapp.libraries.actor import repositoriesblocklist + from leapp.libraries.actor.targetcontentresolver import ExternalRepoSetupTasks + from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked + from leapp.libraries.stdlib import api +-from leapp.models import ( +- CustomTargetRepository, +- PESIDRepositoryEntry, +- RepoMapEntry, +- RepositoriesBlocklisted, +- RepositoriesFacts, +- RepositoriesMapping, +- RepositoryData, +- RepositoryFile +-) ++from leapp.models import PESIDRepositoryEntry, RepoMapEntry, RepositoriesBlocklisted, RepositoriesMapping + +- +-@pytest.fixture +-def repofacts_opts_disabled(): +- repos_data = [ +- RepositoryData( +- repoid="codeready-builder-for-rhel-8-x86_64-rpms", +- name="RHEL 8 CRB", +- enabled=False, +- ) +- ] +- repos_files = [ +- RepositoryFile(file="/etc/yum.repos.d/redhat.repo", data=repos_data) +- ] +- return RepositoriesFacts(repositories=repos_files) ++_NO_TASKS = ExternalRepoSetupTasks(to_enable=set(), to_block=set(), custom=set()) + + + @pytest.fixture +@@ -68,149 +46,211 @@ def repomap_opts_only(rhel8_crb_pesidrepo, rhel9_crb_pesidrepo): + ) + + +-def test_all_target_optionals_blacklisted_when_no_optional_on_source(monkeypatch, repomap_opts_only): ++def test_crb_repos_blocked_when_no_crb_on_source(monkeypatch, repomap_opts_only): + """ +- Tests whether every target optional repository gets blacklisted +- if no optional repositories are used on the source system. ++ Target CRB repos are blocklisted when no CRB repository ++ is enabled on the source system. + """ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' ++ )) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + +- repos_data = [ +- RepositoryData( +- repoid="rhel-8-server-rpms", +- name="RHEL 8 Server", +- enabled=True, +- ) +- ] +- repos_files = [ +- RepositoryFile(file="/etc/yum.repos.d/redhat.repo", data=repos_data) +- ] +- repo_facts = RepositoriesFacts(repositories=repos_files) +- +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts])) +- monkeypatch.setattr(api, 'produce', produce_mocked()) +- monkeypatch.setattr(reporting, 'create_report', produce_mocked()) +- +- result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ result = repositoriesblocklist._calc_internal_blocklist( ++ repomap_opts_only, _NO_TASKS, enabled_repoids={'rhel-8-server-rpms'} ++ ) + + assert 'codeready-builder-for-rhel-9-x86_64-rpms' in result + + +-def test_with_no_mapping_for_optional_repos(monkeypatch, repomap_opts_only, repofacts_opts_disabled): ++def test_empty_result_when_no_crb_pesid_in_mapping(monkeypatch, repomap_opts_only): + """ +- Tests whether an empty set is returned if no valid target is found for an optional repository in mapping data. ++ Empty set is returned if no valid CRB target is found in mapping data. + """ +- + repomap_opts_only.repositories[1].pesid = 'test_pesid' + +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' ++ )) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + +- result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) +- +- assert not result +- +- +-def test_blacklist_produced_when_optional_repo_disabled(monkeypatch, repofacts_opts_disabled, repomap_opts_only): +- """ +- Tests whether a correct blacklist is generated when there is disabled optional repo on the system. +- """ +- +- monkeypatch.setattr( +- api, +- "current_actor", +- CurrentActorMocked(msgs=[repofacts_opts_disabled]), ++ result = repositoriesblocklist._calc_internal_blocklist( ++ repomap_opts_only, _NO_TASKS, enabled_repoids=set() + ) +- monkeypatch.setattr(api, "produce", produce_mocked()) +- monkeypatch.setattr(reporting, "create_report", produce_mocked()) +- +- result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) +- +- assert result, 'A blacklist should get generated.' +- +- expected_blacklisted_repoid = 'codeready-builder-for-rhel-9-x86_64-rpms' +- err_msg = 'Blacklist does not contain expected repoid.' +- assert expected_blacklisted_repoid in result, err_msg +- +- +-def test_no_blacklist_produced_when_optional_repo_enabled(monkeypatch, repofacts_opts_disabled, repomap_opts_only): +- """ +- Tests whether an empty set is returned when an optional repository is enabled. +- +- Data are set up in such a fashion so that the determined blacklist would not be empty. +- """ +- +- repofacts_opts_disabled.repositories[0].data[0].enabled = True +- +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled])) +- +- result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) + + assert not result + + +-def test_repositoriesblacklist_not_empty(monkeypatch, repofacts_opts_disabled, repomap_opts_only): ++def test_blocklist_generated_when_crb_disabled(monkeypatch, repomap_opts_only): + """ +- Tests whether the correct set of blacklisted repoids is returned. ++ Blocklist is generated when CRB repos are disabled on the source system. + """ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' ++ )) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + ++ result = repositoriesblocklist._calc_internal_blocklist( ++ repomap_opts_only, _NO_TASKS, enabled_repoids=set() ++ ) ++ ++ assert result, 'A blocklist should get generated.' ++ ++ expected_blocklisted_repoid = 'codeready-builder-for-rhel-9-x86_64-rpms' ++ err_msg = 'Blocklist does not contain expected repoid.' ++ assert expected_blocklisted_repoid in result, err_msg ++ ++ ++def test_no_blocklist_when_crb_enabled_on_source(monkeypatch, repomap_opts_only): ++ """ ++ Empty set is returned when a CRB repository is enabled on the source system. ++ """ ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' ++ )) ++ ++ result = repositoriesblocklist._calc_internal_blocklist( ++ repomap_opts_only, _NO_TASKS, ++ enabled_repoids={'codeready-builder-for-rhel-8-x86_64-rpms'} ++ ) ++ ++ assert not result ++ ++ ++def test_blocklist_not_empty_with_mocked_crb_repos(monkeypatch, repomap_opts_only): ++ """ ++ Non-empty blocklist is returned when _get_crb_repos returns repos to exclude. ++ """ + blacklisted_repoid = 'test' +- monkeypatch.setattr(repositoriesblocklist, "_get_repoids_to_exclude", lambda dummy_mapping: {blacklisted_repoid}) +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled])) +- monkeypatch.setattr(api, "produce", produce_mocked()) +- monkeypatch.setattr(reporting, "create_report", produce_mocked()) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' ++ )) ++ monkeypatch.setattr( ++ repositoriesblocklist, '_are_crb_repos_disabled', lambda _m, _e: True ++ ) ++ monkeypatch.setattr( ++ repositoriesblocklist, '_get_crb_repos', lambda _m, _v: {blacklisted_repoid} ++ ) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + +- result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ result = repositoriesblocklist._calc_internal_blocklist( ++ repomap_opts_only, _NO_TASKS, enabled_repoids=set() ++ ) + assert blacklisted_repoid in result + assert reporting.create_report.called == 1 + + +-def test_repositoriesblacklist_empty(monkeypatch, repofacts_opts_disabled, repomap_opts_only): ++def test_blocklist_empty_with_mocked_empty_crb_repos(monkeypatch, repomap_opts_only): + """ +- Tests whether an empty set is returned if there are some disabled optional +- repos, but an empty blacklist is determined from the repo mapping data. ++ Empty set returned when _get_crb_repos returns no repos, even though ++ CRB is disabled on the source. + """ +- +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repofacts_opts_disabled])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' ++ )) + monkeypatch.setattr( +- repositoriesblocklist, +- "_get_repoids_to_exclude", +- lambda dummy_mapping: set() ++ repositoriesblocklist, '_are_crb_repos_disabled', lambda _m, _e: True + ) ++ monkeypatch.setattr( ++ repositoriesblocklist, '_get_crb_repos', lambda _m, _v: set() ++ ) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + +- result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) ++ result = repositoriesblocklist._calc_internal_blocklist( ++ repomap_opts_only, _NO_TASKS, enabled_repoids=set() ++ ) + assert not result + + + @pytest.mark.parametrize( +- ("enabled_repo", "exp_report_title", "message_produced"), ++ ('custom_repoids', 'exp_report_title', 'exp_blocklist_empty'), + [ +- ("codeready-builder-for-rhel-9-x86_64-rpms", "Using repository not supported by Red Hat", False), +- ("some_other_enabled_repo", "Excluded target system repositories", True), +- (None, "Excluded target system repositories", True), ++ ( ++ {'codeready-builder-for-rhel-9-x86_64-rpms'}, ++ None, ++ True, ++ ), ++ ( ++ {'some_other_enabled_repo'}, ++ 'Excluded target system repositories', ++ False, ++ ), ++ ( ++ set(), ++ 'Excluded target system repositories', ++ False, ++ ), + ], + ) +-def test_enablerepo_option(monkeypatch, +- repofacts_opts_disabled, +- repomap_opts_only, +- enabled_repo, +- exp_report_title, +- message_produced): ++def test_custom_enablerepo_effect(monkeypatch, repomap_opts_only, ++ custom_repoids, exp_report_title, ++ exp_blocklist_empty): + """ +- Tests whether the actor respects CustomTargetRepository messages when constructing the blacklist. ++ When a CRB repo is in external_tasks.custom the blocklist is empty. ++ When custom contains only non-CRB repos or is empty, CRB repos are excluded and reported. + """ +- +- msgs_to_feed = [repofacts_opts_disabled] +- +- if enabled_repo: +- msgs_to_feed.append(CustomTargetRepository(repoid=enabled_repo)) +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs_to_feed)) +- monkeypatch.setattr(api, 'produce', produce_mocked()) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' ++ )) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) +- result = repositoriesblocklist.get_blocklisted_repoids(repomap_opts_only) +- assert reporting.create_report.report_fields["title"] == exp_report_title +- if message_produced: +- assert result ++ ++ external_tasks = ExternalRepoSetupTasks( ++ to_enable=set(), to_block=set(), custom=custom_repoids ++ ) ++ ++ result = repositoriesblocklist._calc_internal_blocklist( ++ repomap_opts_only, external_tasks, enabled_repoids=set() ++ ) ++ ++ if exp_report_title: ++ assert reporting.create_report.report_fields['title'] == exp_report_title + else: ++ assert reporting.create_report.called == 0 ++ ++ if exp_blocklist_empty: + assert not result ++ else: ++ assert result ++ ++ ++def test_get_crb_repos_filters_by_architecture(monkeypatch): ++ """Only CRB repos matching the current architecture are returned.""" ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' ++ )) ++ ++ repos = [ ++ PESIDRepositoryEntry( ++ pesid='rhel9-CRB', major_version='9', ++ repoid='crb-x86_64', rhui='', arch='x86_64', ++ channel='ga', repo_type='rpm', distro='rhel', ++ ), ++ PESIDRepositoryEntry( ++ pesid='rhel9-CRB', major_version='9', ++ repoid='crb-aarch64', rhui='', arch='aarch64', ++ channel='ga', repo_type='rpm', distro='rhel', ++ ), ++ ] ++ repo_mapping = RepositoriesMapping(mapping=[], repositories=repos) ++ ++ result = repositoriesblocklist._get_crb_repos(repo_mapping, '9') ++ ++ assert result == {'crb-x86_64'} ++ ++ ++def test_no_report_for_non_rhel_distro(monkeypatch, repomap_opts_only): ++ """No exclusion report generated for non-RHEL distros, but repos are still excluded.""" ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='centos' ++ )) ++ monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) ++ ++ result = repositoriesblocklist._calc_internal_blocklist( ++ repomap_opts_only, _NO_TASKS, enabled_repoids=set() ++ ) ++ ++ assert result ++ assert reporting.create_report.called == 0 + + + def _get_produced_blocklisted(): +@@ -226,11 +266,11 @@ def test_compute_blocklist_merges_internal_and_external(monkeypatch): + to_enable={'some-repo'}, to_block={'ext-blocked-1', 'ext-blocked-2'}, custom=set() + ) + monkeypatch.setattr( +- repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'crb-repo-1'} ++ repositoriesblocklist, '_calc_internal_blocklist', lambda _rm, _et, _er: {'crb-repo-1'} + ) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks, set()) + + assert full_blocklist == {'crb-repo-1', 'ext-blocked-1', 'ext-blocked-2'} + +@@ -243,11 +283,11 @@ def test_compute_blocklist_no_messages_when_empty(monkeypatch): + """No blocklist messages are produced when blocklist is empty.""" + external_tasks = ExternalRepoSetupTasks(to_enable=set(), to_block=set(), custom=set()) + monkeypatch.setattr( +- repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: set() ++ repositoriesblocklist, '_calc_internal_blocklist', lambda _rm, _et, _er: set() + ) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks, set()) + + assert full_blocklist == set() + assert len(_get_produced_blocklisted()) == 0 +@@ -257,11 +297,11 @@ def test_compute_blocklist_only_internal(monkeypatch): + """Only internal blocklist when no external tasks are present.""" + external_tasks = ExternalRepoSetupTasks(to_enable=set(), to_block=set(), custom=set()) + monkeypatch.setattr( +- repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'crb-repo-1'} ++ repositoriesblocklist, '_calc_internal_blocklist', lambda _rm, _et, _er: {'crb-repo-1'} + ) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks, set()) + + assert full_blocklist == {'crb-repo-1'} + assert len(_get_produced_blocklisted()) == 1 +@@ -273,11 +313,11 @@ def test_compute_blocklist_aggregated_external_tasks(monkeypatch): + to_enable={'repo-a', 'repo-b'}, to_block={'ext-1', 'ext-2', 'ext-3'}, custom=set() + ) + monkeypatch.setattr( +- repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: set() ++ repositoriesblocklist, '_calc_internal_blocklist', lambda _rm, _et, _er: set() + ) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks, set()) + + assert full_blocklist == {'ext-1', 'ext-2', 'ext-3'} + +@@ -288,11 +328,11 @@ def test_compute_blocklist_overlapping_internal_and_external(monkeypatch): + to_enable=set(), to_block={'shared-repo', 'ext-only'}, custom=set() + ) + monkeypatch.setattr( +- repositoriesblocklist, 'get_blocklisted_repoids', lambda _repo_map: {'shared-repo', 'internal-only'} ++ repositoriesblocklist, '_calc_internal_blocklist', lambda _rm, _et, _er: {'shared-repo', 'internal-only'} + ) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks) ++ full_blocklist = repositoriesblocklist.compute_blocklist(None, external_tasks, set()) + + assert full_blocklist == {'shared-repo', 'internal-only', 'ext-only'} + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +index 97aaad33..0f28d656 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +@@ -41,10 +41,11 @@ def test_process_orchestration(monkeypatch): + call_log.append('scan_repositories') + return fake_repo_map + +- def mock_compute_blocklist(repo_mapping, external_tasks): ++ def mock_compute_blocklist(repo_mapping, external_tasks, enabled_repoids): + call_log.append('compute_blocklist') + assert repo_mapping is fake_repo_map + assert external_tasks is fake_external_tasks ++ assert enabled_repoids is fake_enabled_repoids + return fake_blocklist + + def mock_scan_pes_events(repo_mapping, blacklisted_repoids, enabled_repoids): +diff --git a/repos/system_upgrade/common/models/repositoriessetuptasks.py b/repos/system_upgrade/common/models/repositoriessetuptasks.py +index 35f627e4..88056d97 100644 +--- a/repos/system_upgrade/common/models/repositoriessetuptasks.py ++++ b/repos/system_upgrade/common/models/repositoriessetuptasks.py +@@ -10,6 +10,13 @@ class RepositoriesSetupTasks(Model): + upgrade process. This information should be processed by an actor dedicated to manage + repositories. + * 'to_block' field consists of a list of repositories that should be ignored during upgrade process. ++ ++ The priority order of the requests is following: ++ to_enable < to_block < "external custom enablement request" ++ The external custom request can be made by user: ++ ++ * execute leapp with --enablerepo option ++ * using configuration files + """ + topic = SystemFactsTopic + +-- +2.54.0 + diff --git a/0101-5-9-repositoriesmapping-scan_repositories-load_repos.patch b/0101-5-9-repositoriesmapping-scan_repositories-load_repos.patch new file mode 100644 index 0000000..6a83d29 --- /dev/null +++ b/0101-5-9-repositoriesmapping-scan_repositories-load_repos.patch @@ -0,0 +1,288 @@ +From 614a7f3d793ce1d47b7360a4133f2e605cdbe8a3 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Sat, 6 Jun 2026 12:30:55 +0200 +Subject: [PATCH 101/108] [5/9] repositoriesmapping: scan_repositories -> + load_repositories_mapping + +The original `scan_repositories()` name was confusing and it did not +reflect the purpose of the function. The new name +`load_repositories_mapping()` seems to be more intuitive. + +Also dropped obsolete check for the "repomap.csv" file. The file existed +only on RHEL 7, more than 3 years ago. So it's a good time to remove +this relict from the past. + +Unit tests adjusted with assistance of AI. + +Jira: RHEL-115867 +Assisted-by: Claude Code (Opus 4.6) +Signed-off-by: Petr Stodulka +--- + .../libraries/repomap_loader.py | 31 ++++++-------- + .../libraries/targetcontentresolver.py | 4 +- + .../tests/test_targetcontentresolver.py | 12 +++--- + .../tests/unit_test_repomap_loader.py | 40 +++++++++---------- + 4 files changed, 41 insertions(+), 46 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_loader.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_loader.py +index 52b8337d..81716f01 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_loader.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repomap_loader.py +@@ -9,9 +9,6 @@ from leapp.libraries.stdlib import api + from leapp.models import PESIDRepositoryEntry, RepoMapEntry, RepositoriesMapping + from leapp.models.fields import ModelViolationError + +-OLD_REPOMAP_FILE = 'repomap.csv' +-"""The name of the old, deprecated repository mapping file (no longer used).""" +- + REPOMAP_FILE = 'repomap.json' + """The name of the new repository mapping file.""" + +@@ -159,23 +156,21 @@ def _read_repofile(repofile): + return repofile_data + + +-def scan_repositories(read_repofile_func=_read_repofile): ++def load_repositories_mapping(read_repofile_func=_read_repofile): + """ +- Scan the repository mapping file and produce RepositoriesMap msg. ++ Load the mapping of DNF repositories related to the current upgrade path. + +- See the description of the actor for more details. ++ Read the mapping from the repomap.json file and filter out data irrelevant ++ for the current and target system major version. ++ ++ Produce and return the RepositoriesMapping message. ++ ++ :param read_repofile_func: The function that accept filename string to read the repomap file and return dict ++ :type read_repofile_func: Callable[[str], Dict] ++ :rtype: RepositoriesMapping ++ :raises StopActorExecutionError: When cannot produce valid RepositoriesMapping + """ + # TODO: add filter based on the current arch +- # TODO: deprecate the product type and introduce the "channels" ?.. more or less +- # NOTE: product type is changed, now it's channel: eus,e4s,aus,tus,ga,beta +- +- if os.path.exists(os.path.join('/etc/leapp/files', OLD_REPOMAP_FILE)): +- # NOTE: what about creating the report (instead of warning) +- api.current_logger().warning( +- 'The old repomap file /etc/leapp/files/repomap.csv is present.' +- ' The file has been replaced by the repomap.json file and it is' +- ' not used anymore.' +- ) + + json_data = read_repofile_func(REPOMAP_FILE) + try: +@@ -188,7 +183,6 @@ def scan_repositories(read_repofile_func=_read_repofile): + repositories=repomap_data.get_repositories(valid_major_versions) + ) + api.produce(repositories_mapping) +- return repositories_mapping + except ModelViolationError as err: + err_message = ( + 'The repository mapping file is invalid: ' +@@ -202,4 +196,5 @@ def scan_repositories(read_repofile_func=_read_repofile): + except ValueError as err: + # The error should contain enough information, so we do not need to clarify it further + _inhibit_upgrade('The repository mapping file is invalid: {}'.format(err)) +- return None ++ ++ return repositories_mapping +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +index e70346b0..b66168e6 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -2,7 +2,7 @@ from collections import namedtuple + + from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import pes_events_scanner, repositoriesblocklist, setuptargetrepos +-from leapp.libraries.actor.repomap_loader import scan_repositories ++from leapp.libraries.actor.repomap_loader import load_repositories_mapping + from leapp.libraries.stdlib import api + from leapp.models import CustomTargetRepository, RepositoriesBlacklisted, RepositoriesFacts, RepositoriesSetupTasks + from leapp.utils.deprecation import suppress_deprecation +@@ -108,7 +108,7 @@ def process(): + configuration. + """ + indata = InputData() +- repositories_map_msg = scan_repositories() ++ repositories_map_msg = load_repositories_mapping() + blocklisted_repoids = repositoriesblocklist.compute_blocklist( + repositories_map_msg, + indata.external_tasks, +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +index 0f28d656..970fd797 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +@@ -19,7 +19,7 @@ from leapp.utils.deprecation import suppress_deprecation + def test_process_orchestration(monkeypatch): + """ + Tests that process() wires data between the four stages correctly: +- scan_repositories -> compute_blocklist -> scan_pes_events -> setup_target_repos. ++ load_repositories_mapping -> compute_blocklist -> scan_pes_events -> setup_target_repos. + """ + call_log = [] + +@@ -37,8 +37,8 @@ def test_process_orchestration(monkeypatch): + self.external_tasks = fake_external_tasks + self.enabled_repoids = fake_enabled_repoids + +- def mock_scan_repositories(): +- call_log.append('scan_repositories') ++ def mock_load_repositories_mapping(): ++ call_log.append('load_repositories_mapping') + return fake_repo_map + + def mock_compute_blocklist(repo_mapping, external_tasks, enabled_repoids): +@@ -68,8 +68,8 @@ def test_process_orchestration(monkeypatch): + FakeInputData, + ) + monkeypatch.setattr( +- 'leapp.libraries.actor.targetcontentresolver.scan_repositories', +- mock_scan_repositories, ++ 'leapp.libraries.actor.targetcontentresolver.load_repositories_mapping', ++ mock_load_repositories_mapping, + ) + monkeypatch.setattr( + 'leapp.libraries.actor.targetcontentresolver.repositoriesblocklist.compute_blocklist', +@@ -88,7 +88,7 @@ def test_process_orchestration(monkeypatch): + + assert call_log == [ + 'InputData', +- 'scan_repositories', ++ 'load_repositories_mapping', + 'compute_blocklist', + 'scan_pes_events', + 'setup_target_repos', +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/unit_test_repomap_loader.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/unit_test_repomap_loader.py +index 50cf5962..be9b3bb2 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/unit_test_repomap_loader.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/unit_test_repomap_loader.py +@@ -32,10 +32,10 @@ def test_scan_existing_valid_data(monkeypatch, adjust_cwd): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='7.9', dst_ver='8.4')) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- result = repomap_loader.scan_repositories(lambda dummy: data) ++ result = repomap_loader.load_repositories_mapping(lambda dummy: data) + + assert api.produce.called, 'Actor did not produce any message when deserializing valid repomap data.' +- assert result is not None, 'scan_repositories should return the RepositoriesMapping message.' ++ assert result is not None, 'load_repositories_mapping should return the RepositoriesMapping message.' + assert result is api.produce.model_instances[0], ( + 'The returned mapping should be the same object that was produced.' + ) +@@ -126,9 +126,9 @@ def test_scan_existing_valid_data(monkeypatch, adjust_cwd): + assert expected_pesid_repo in pesid_repos, fail_description + + +-def test_scan_repositories_with_missing_data(monkeypatch): ++def test_load_repositories_mapping_with_missing_data(monkeypatch): + """ +- Tests whether the scanning process fails gracefully when no data are read. ++ Tests whether the loading process fails gracefully when no data are read. + """ + mocked_actor = CurrentActorMocked(src_ver='7.9', dst_ver='8.4', msgs=[]) + +@@ -144,25 +144,25 @@ def test_scan_repositories_with_missing_data(monkeypatch): + monkeypatch.setattr(fetch, 'read_or_fetch', read_or_fetch_mocked) + + with pytest.raises(StopActorExecutionError) as missing_data_error: +- repomap_loader.scan_repositories() ++ repomap_loader.load_repositories_mapping() + assert 'does not contain a valid JSON object' in str(missing_data_error) + + +-def test_scan_repositories_with_empty_data(monkeypatch): ++def test_load_repositories_mapping_with_empty_data(monkeypatch): + """ +- Tests whether the scanning process fails gracefully when empty json data received. ++ Tests whether the loading process fails gracefully when empty json data received. + """ + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='7.9', dst_ver='8.4')) + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as empty_data_error: +- repomap_loader.scan_repositories(lambda dummy: {}) ++ repomap_loader.load_repositories_mapping(lambda dummy: {}) + assert 'the JSON is missing a required field' in str(empty_data_error) + + + @pytest.mark.parametrize('version_format', ('0.0.0', '1.0.1', '1.1.0', '2.0.0')) +-def test_scan_repositories_with_bad_json_data_version(monkeypatch, version_format): ++def test_load_repositories_mapping_with_bad_json_data_version(monkeypatch, version_format): + """ + Tests whether the json data is checked for the version field and error is raised if the version + does not match the latest one. +@@ -179,12 +179,12 @@ def test_scan_repositories_with_bad_json_data_version(monkeypatch, version_forma + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as bad_version_error: +- repomap_loader.scan_repositories(lambda dummy: json_data) ++ repomap_loader.load_repositories_mapping(lambda dummy: json_data) + + assert 'mapping file is invalid' in str(bad_version_error) + + +-def test_scan_repositories_with_mapping_to_pesid_without_repos(monkeypatch): ++def test_load_repositories_mapping_with_mapping_to_pesid_without_repos(monkeypatch): + """ + Tests that the loading of repositories mapping recognizes when there is a mapping with target pesid that does + not have any repositories and inhibits the upgrade. +@@ -225,12 +225,12 @@ def test_scan_repositories_with_mapping_to_pesid_without_repos(monkeypatch): + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as error_info: +- repomap_loader.scan_repositories(lambda dummy: json_data) ++ repomap_loader.load_repositories_mapping(lambda dummy: json_data) + + assert 'pesid is not related to any repository' in error_info.value.message + + +-def test_scan_repositories_with_repo_entry_missing_required_fields(monkeypatch): ++def test_load_repositories_mapping_with_repo_entry_missing_required_fields(monkeypatch): + """ + Tests whether deserialization of pesid repo entries missing some of the required fields + is handled internally and StopActorExecutionError is propagated to the user. +@@ -279,12 +279,12 @@ def test_scan_repositories_with_repo_entry_missing_required_fields(monkeypatch): + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as error_info: +- repomap_loader.scan_repositories(lambda dummy: json_data) ++ repomap_loader.load_repositories_mapping(lambda dummy: json_data) + + assert 'the JSON is missing a required field' in error_info.value.message + + +-def test_scan_repositories_with_repo_entry_mapping_target_not_a_list(monkeypatch): ++def test_load_repositories_mapping_with_repo_entry_mapping_target_not_a_list(monkeypatch): + """ + Tests whether deserialization of a mapping entry that has its target field set to a string + is handled internally and StopActorExecutionError is propagated to the user. +@@ -333,18 +333,18 @@ def test_scan_repositories_with_repo_entry_mapping_target_not_a_list(monkeypatch + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError) as error_info: +- repomap_loader.scan_repositories(lambda dummy: json_data) ++ repomap_loader.load_repositories_mapping(lambda dummy: json_data) + + assert 'repository mapping file is invalid' in error_info.value.message + + +-def test_scan_repositories_returns_none_on_error(monkeypatch): ++def test_load_repositories_mapping_raises_on_invalid_data(monkeypatch): + """ +- Tests that scan_repositories returns None when the data is invalid +- and a StopActorExecutionError is raised. ++ Tests that load_repositories_mapping raises StopActorExecutionError ++ when the data is invalid. + """ + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='7.9', dst_ver='8.4')) + monkeypatch.setattr(api, 'produce', produce_mocked()) + + with pytest.raises(StopActorExecutionError): +- repomap_loader.scan_repositories(lambda dummy: {}) ++ repomap_loader.load_repositories_mapping(lambda dummy: {}) +-- +2.54.0 + diff --git a/0102-6-9-targetcontentresolver-operate-with-RepoMapDataHa.patch b/0102-6-9-targetcontentresolver-operate-with-RepoMapDataHa.patch new file mode 100644 index 0000000..a51286e --- /dev/null +++ b/0102-6-9-targetcontentresolver-operate-with-RepoMapDataHa.patch @@ -0,0 +1,615 @@ +From d28e4c283de9ac3e6f172c900f58f91e7cbb66d1 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Sun, 7 Jun 2026 01:31:39 +0200 +Subject: [PATCH 102/108] [6/9] targetcontentresolver: operate with + RepoMapDataHandler + +Originally various libraries required RepositoriesMapping message +in the input parameters. Then some of them just initialized +RepoMapDataHandler (which requires to consume RHUIInfo message) +and in case of the `repositoriesblocklist` library it even duplicated +functionality implemented in the handler. + +The handler is now initialized inside the orchestrator and all other +libraries accept just the handler as the input parameter. This leads +to another deduplication and simplification of the codebase. + +Unit tests adjusted with assistance of AI. + +Jira: RHEL-115867 +Assisted-by: Claude Code (Opus 4.6) +Signed-off-by: Petr Stodulka +--- + .../libraries/pes_events_scanner.py | 27 +++++------ + .../libraries/repositoriesblocklist.py | 47 +++++++++---------- + .../libraries/setuptargetrepos.py | 14 ++---- + .../libraries/targetcontentresolver.py | 24 ++++++++-- + .../tests/test_pes_event_scanner.py | 5 +- + .../tests/test_repositoriesblocklist.py | 41 ++++++++++++---- + .../tests/test_setuptargetrepos.py | 23 +++++---- + .../tests/test_targetcontentresolver.py | 20 ++++---- + 8 files changed, 116 insertions(+), 85 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +index cbc99adf..96f26a59 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +@@ -17,7 +17,6 @@ from leapp.models import ( + PESIDRepositoryEntry, + PESRpmTransactionTasks, + RepositoriesFacts, +- RHUIInfo, + RpmTransactionTasks + ) + +@@ -112,6 +111,7 @@ def get_relevant_releases(events): + + + def _get_enabled_modules(): ++ # TODO(pstodulk): take a look + enabled_modules_msgs = api.consume(EnabledModules) + enabled_modules_msg = next(enabled_modules_msgs, None) + if list(enabled_modules_msgs): +@@ -357,22 +357,20 @@ def get_enabled_repoids(): + return enabled_repoids + + +-def get_pesid_to_repoid_map(target_pesids, repositories_map_msg, enabled_repoids): ++def get_pesid_to_repoid_map(target_pesids, repomap_handler, enabled_repoids): + """ + Get a dictionary mapping all PESID repositories to their corresponding repoid. + + :param target_pesids: The set of target PES IDs needed to be mapped +- :param repositories_map_msg: RepositoriesMapping message. ++ :param repomap_handler: Operator to work with the repositories mapping data ++ :type repomap_handler: repomap_calc.RepoMapDataHandler + :return: Dictionary mapping the target_pesids to their corresponding repoid + """ +- rhui_info = next(api.consume(RHUIInfo), None) +- cloud_provider = rhui_info.provider if rhui_info else '' +- +- repomap_handler = repomap_calc.RepoMapDataHandler(repositories_map_msg, cloud_provider=cloud_provider) +- + # NOTE: We have to calculate expected target repositories like in the setuptargetrepos actor. + # It's planned to handle this in different a way in future... +- ++ # TODO(pstodulk): ?? hmm..what about moving it to the orchestrator? ++ # TODO(pstodulk): what about returning requests just for PESIDS? Setuptargetrepo ++ # lib will pick the right one for us later. + default_channels = repomap_calc.get_default_repository_channels(repomap_handler, enabled_repoids) + repomap_handler.set_default_channels(default_channels) + +@@ -432,7 +430,7 @@ def get_pesid_to_repoid_map(target_pesids, repositories_map_msg, enabled_repoids + return repositories_mapping + + +-def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repositories_map_msg, enabled_repoids): ++def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repomap_handler, enabled_repoids): + """Replace packages with PESID in their .repository field with ones that have repoid providing the package.""" + # We want to map only PESIDs - if some package had no events, it will its repository set to source system repoid + packages_with_pesid = {pkg for pkg in packages if pkg.repository not in source_pkgs_repoids} +@@ -440,7 +438,7 @@ def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repos + + required_target_pesids = {pkg.repository for pkg in packages_with_pesid} + +- pesid_to_target_repoid_map = get_pesid_to_repoid_map(required_target_pesids, repositories_map_msg, enabled_repoids) ++ pesid_to_target_repoid_map = get_pesid_to_repoid_map(required_target_pesids, repomap_handler, enabled_repoids) + + packages_with_unknown_target_repoid = { + pkg +@@ -554,11 +552,12 @@ def include_instructions_from_transaction_configuration(rpm_tasks, transaction_c + modules_to_reset=modules_to_reset) + + +-def scan_pes_events(repositories_map_msg, blacklisted_repoids, enabled_repoids): ++def scan_pes_events(repomap_handler, blacklisted_repoids, enabled_repoids): + """ + Process PES events and compute RPM transaction tasks. + +- :param repositories_map_msg: RepositoriesMapping message. ++ :param repomap_handler: Operator to work with the repositories mapping data ++ :type repomap_handler: repomap_calc.RepoMapDataHandler + :param blacklisted_repoids: Set of repoids to exclude from target packages. If None, an empty set is used. + :returns: Set of target repoids that need to be enabled, or None if no PES events are found. + :rtype: Optional[set] +@@ -588,7 +587,7 @@ def scan_pes_events(repositories_map_msg, blacklisted_repoids, enabled_repoids): + target_pkgs = replace_pesids_with_repoids_in_packages( + target_pkgs, + repoids_of_source_pkgs, +- repositories_map_msg, ++ repomap_handler, + enabled_repoids + ) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +index e526bcbd..c77a1f13 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/repositoriesblocklist.py +@@ -32,44 +32,39 @@ def _report_excluded_repos(repos): + reporting.create_report(report) + + +-def _get_crb_repos(repo_mapping, major_version): ++def _get_crb_repos(repo_mapping, flag_source_os): + """ +- Return set of relevant CRB repoids for the specified major version ++ Return set of relevant CRB repoids for the source or target OS. + +- Only CRB repositories for the current architecture and distribution are +- relevant. +- +- :param repo_mapping: RepositoriesMapping message. +- :type repo_mapping: RepositoriesMapping +- :param major_version: The OS major version. +- :type major_version: str ++ :param repo_mapping: Operator to work with the repositories mapping data ++ :type repo_mapping: repomap_calc.RepoMapDataHandler ++ :param flag_source_os: Set True for the source OS, False for the target OS. ++ :type flag_source_os: bool + :returns: A set of repoids with the specified pesid and major version. + :rtype: Set[str] + """ +- pesid = f'rhel{major_version}-CRB' + curr_arch = api.current_actor().configuration.architecture +- crb_repoids = set() +- for pesid_repo in repo_mapping.repositories: +- if pesid_repo.major_version != major_version or pesid_repo.arch != curr_arch: +- # irrelevant repository +- continue +- if pesid_repo.pesid == pesid and pesid_repo.major_version == major_version: +- crb_repoids.add(pesid_repo.repoid) +- return crb_repoids ++ if flag_source_os: ++ pesid = 'rhel{}-CRB'.format(get_source_major_version()) ++ crb_repos = repo_mapping.get_source_pesid_repos(pesid) ++ else: ++ pesid = 'rhel{}-CRB'.format(get_target_major_version()) ++ crb_repos = repo_mapping.get_target_pesid_repos(pesid) ++ return {repo.repoid for repo in crb_repos if repo.arch == curr_arch} + + + def _are_crb_repos_disabled(repo_mapping, enabled_repoids): + """ + Checks whether all CRB repositories are disabled. + +- :param repo_mapping: RepositoriesMapping message. +- :type repo_mapping: RepositoriesMapping ++ :param repo_mapping: Operator to work with the repositories mapping data ++ :type repo_mapping: repomap_calc.RepoMapDataHandler + :param enabled_repoids: Set of repoids enabled on the source system. + :type enabled_repoids: Set[str] + :returns: False if any CRB repositories are enabled on the source system, True otherwise. + :rtype: bool + """ +- return enabled_repoids.isdisjoint(_get_crb_repos(repo_mapping, get_source_major_version())) ++ return enabled_repoids.isdisjoint(_get_crb_repos(repo_mapping, True)) + + + def _calc_internal_blocklist(repo_mapping, external_tasks, enabled_repoids): +@@ -84,8 +79,8 @@ def _calc_internal_blocklist(repo_mapping, external_tasks, enabled_repoids): + Also report explicitly enabled and blocklisted CRB repositories, unless + CRB is already already enabled on the source system. + +- :param repo_mapping: RepositoriesMapping message. +- :type repo_mapping: RepositoriesMapping ++ :param repo_mapping: Operator to work with the repositories mapping data ++ :type repo_mapping: repomap_calc.RepoMapDataHandler + :param external_tasks: External repositories tasks represented by object with following fields: + + * ``to_enable`` - repositories that should be enabled +@@ -102,7 +97,7 @@ def _calc_internal_blocklist(repo_mapping, external_tasks, enabled_repoids): + # nothing to do - a CRB repo is enabled + return set() + +- repos_to_exclude = _get_crb_repos(repo_mapping, get_target_major_version()) ++ repos_to_exclude = _get_crb_repos(repo_mapping, False) + + # Do not exclude repositories explicitly required by user for the upgrade + manually_enabled_repos = external_tasks.custom & repos_to_exclude +@@ -149,8 +144,8 @@ def compute_blocklist(repo_mapping, external_tasks, enabled_repoids): + the in-place upgrade purpose. Currently there is no explicit custom + configuration to disable a repo. + +- :param repo_mapping: RepositoriesMapping message. +- :type repo_mapping: RepositoriesMapping ++ :param repo_mapping: Operator to work with the repositories mapping data ++ :type repo_mapping: repomap_calc.RepoMapDataHandler + :param external_tasks: External repositories tasks represented by object with following fields: + + * ``to_enable`` - repositories that should be enabled +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +index d6bfb91d..4f7ffd9f 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +@@ -8,7 +8,6 @@ from leapp.models import ( + DistroTargetRepository, + InstalledRPM, + RHELTargetRepository, +- RHUIInfo, + SkippedRepositories, + TargetRepositories, + UsedRepositories +@@ -58,12 +57,13 @@ def _get_mapped_repoids(repomap, src_repoids): + + + @suppress_deprecation(RHELTargetRepository) +-def setup_target_repos(repositories_map_msg, pes_requested_repoids=None, ++def setup_target_repos(repomap_handler, pes_requested_repoids=None, + blacklisted_repoids=None, external_repoids_requests=None): + """ + Determine the final list of target repositories. + +- :param repositories_map_msg: RepositoriesMapping message. ++ :param repomap_handler: Operator to work with the repositories mapping data ++ :type repomap_handler: repomap_calc.RepoMapDataHandler + :param pes_requested_repoids: Set of repoids derived from PES events that need to be enabled. + :param blacklisted_repoids: Set of repoids to exclude from target repos. If None, an empty set is used. + :param external_repoids_requests: Set of repoids requested by external actors (e.g. satellite_upgrade_facts). +@@ -75,14 +75,6 @@ def setup_target_repos(repositories_map_msg, pes_requested_repoids=None, + custom_repos = _get_custom_target_repos() + repoids_from_installed_packages = _get_repoids_from_installed_packages() + +- # Setup repomap handler +- repo_mapping_msg = repositories_map_msg +- +- rhui_info = next(api.consume(RHUIInfo), None) +- cloud_provider = rhui_info.provider if rhui_info else '' +- +- repomap_handler = repomap_calc.RepoMapDataHandler(repo_mapping_msg, cloud_provider=cloud_provider) +- + # Filter set of repoids from installed packages so that it contains only repoids with mapping + repoids_from_installed_packages_with_mapping = _get_mapped_repoids( + repomap_handler, repoids_from_installed_packages +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +index b66168e6..942a2680 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -2,9 +2,16 @@ from collections import namedtuple + + from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import pes_events_scanner, repositoriesblocklist, setuptargetrepos ++from leapp.libraries.actor.repomap_calc import RepoMapDataHandler + from leapp.libraries.actor.repomap_loader import load_repositories_mapping + from leapp.libraries.stdlib import api +-from leapp.models import CustomTargetRepository, RepositoriesBlacklisted, RepositoriesFacts, RepositoriesSetupTasks ++from leapp.models import ( ++ CustomTargetRepository, ++ RepositoriesBlacklisted, ++ RepositoriesFacts, ++ RepositoriesSetupTasks, ++ RHUIInfo ++) + from leapp.utils.deprecation import suppress_deprecation + + ExternalRepoSetupTasks = namedtuple('ExternalRepoSetupTasks', ('to_enable', 'to_block', 'custom')) +@@ -86,6 +93,13 @@ class InputData(): + self.enabled_repoids.add(repo.repoid) + + ++def _init_repomap_handler(): ++ repomap_msg = load_repositories_mapping() ++ rhui_info = next(api.consume(RHUIInfo), None) ++ cloud_provider = rhui_info.provider if rhui_info else '' ++ return RepoMapDataHandler(repomap_msg, cloud_provider=cloud_provider) ++ ++ + def process(): + """ + Orchestrate the four stages of target content resolution. +@@ -108,20 +122,20 @@ def process(): + configuration. + """ + indata = InputData() +- repositories_map_msg = load_repositories_mapping() ++ repomap_handler = _init_repomap_handler() + blocklisted_repoids = repositoriesblocklist.compute_blocklist( +- repositories_map_msg, ++ repomap_handler, + indata.external_tasks, + indata.enabled_repoids + ) + pes_requested_repoids = pes_events_scanner.scan_pes_events( +- repositories_map_msg, ++ repomap_handler, + blocklisted_repoids, + indata.enabled_repoids + ) + + setuptargetrepos.setup_target_repos( +- repositories_map_msg, ++ repomap_handler, + pes_requested_repoids=pes_requested_repoids, + blacklisted_repoids=blocklisted_repoids, + external_repoids_requests=indata.external_tasks.to_enable, +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +index a1f3a6e1..c6f16823 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +@@ -13,6 +13,7 @@ from leapp.libraries.actor.pes_events_scanner import ( + reporting, + TransactionConfiguration + ) ++from leapp.libraries.actor.repomap_calc import RepoMapDataHandler + from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked + from leapp.models import ( + DistributionSignedRPM, +@@ -265,12 +266,14 @@ def test_actor_performs(monkeypatch): + ), + ) + ++ repomap_handler = RepoMapDataHandler(repositories_mapping) ++ + produced_messages = produce_mocked() + created_report = create_report_mocked() + monkeypatch.setattr(api, 'produce', produced_messages) + monkeypatch.setattr(reporting, 'create_report', created_report) + +- pes_events_scanner.scan_pes_events(repositories_mapping, set(), {'rhel8-repo'}) ++ pes_events_scanner.scan_pes_events(repomap_handler, set(), {'rhel8-repo'}) + + assert produced_messages.called + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +index 843c8f74..5a2d1705 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_repositoriesblocklist.py +@@ -2,6 +2,7 @@ import pytest + + from leapp import reporting + from leapp.libraries.actor import repositoriesblocklist ++from leapp.libraries.actor.repomap_calc import RepoMapDataHandler + from leapp.libraries.actor.targetcontentresolver import ExternalRepoSetupTasks + from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked + from leapp.libraries.stdlib import api +@@ -56,8 +57,9 @@ def test_crb_repos_blocked_when_no_crb_on_source(monkeypatch, repomap_opts_only) + )) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + ++ handler = RepoMapDataHandler(repomap_opts_only) + result = repositoriesblocklist._calc_internal_blocklist( +- repomap_opts_only, _NO_TASKS, enabled_repoids={'rhel-8-server-rpms'} ++ handler, _NO_TASKS, enabled_repoids={'rhel-8-server-rpms'} + ) + + assert 'codeready-builder-for-rhel-9-x86_64-rpms' in result +@@ -74,8 +76,9 @@ def test_empty_result_when_no_crb_pesid_in_mapping(monkeypatch, repomap_opts_onl + )) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + ++ handler = RepoMapDataHandler(repomap_opts_only) + result = repositoriesblocklist._calc_internal_blocklist( +- repomap_opts_only, _NO_TASKS, enabled_repoids=set() ++ handler, _NO_TASKS, enabled_repoids=set() + ) + + assert not result +@@ -90,8 +93,9 @@ def test_blocklist_generated_when_crb_disabled(monkeypatch, repomap_opts_only): + )) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + ++ handler = RepoMapDataHandler(repomap_opts_only) + result = repositoriesblocklist._calc_internal_blocklist( +- repomap_opts_only, _NO_TASKS, enabled_repoids=set() ++ handler, _NO_TASKS, enabled_repoids=set() + ) + + assert result, 'A blocklist should get generated.' +@@ -109,8 +113,9 @@ def test_no_blocklist_when_crb_enabled_on_source(monkeypatch, repomap_opts_only) + arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='rhel' + )) + ++ handler = RepoMapDataHandler(repomap_opts_only) + result = repositoriesblocklist._calc_internal_blocklist( +- repomap_opts_only, _NO_TASKS, ++ handler, _NO_TASKS, + enabled_repoids={'codeready-builder-for-rhel-8-x86_64-rpms'} + ) + +@@ -198,8 +203,9 @@ def test_custom_enablerepo_effect(monkeypatch, repomap_opts_only, + to_enable=set(), to_block=set(), custom=custom_repoids + ) + ++ handler = RepoMapDataHandler(repomap_opts_only) + result = repositoriesblocklist._calc_internal_blocklist( +- repomap_opts_only, external_tasks, enabled_repoids=set() ++ handler, external_tasks, enabled_repoids=set() + ) + + if exp_report_title: +@@ -232,21 +238,38 @@ def test_get_crb_repos_filters_by_architecture(monkeypatch): + ), + ] + repo_mapping = RepositoriesMapping(mapping=[], repositories=repos) ++ handler = RepoMapDataHandler(repo_mapping) + +- result = repositoriesblocklist._get_crb_repos(repo_mapping, '9') ++ result = repositoriesblocklist._get_crb_repos(handler, False) + + assert result == {'crb-x86_64'} + + +-def test_no_report_for_non_rhel_distro(monkeypatch, repomap_opts_only): ++def test_no_report_for_non_rhel_distro(monkeypatch): + """No exclusion report generated for non-RHEL distros, but repos are still excluded.""" + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( +- arch='x86_64', src_ver='8.10', dst_ver='9.6', dst_distro='centos' ++ arch='x86_64', src_ver='8.10', dst_ver='9.6', src_distro='centos', dst_distro='centos' + )) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + ++ centos_repomap = RepositoriesMapping( ++ mapping=[RepoMapEntry(source='rhel8-CRB', target=['rhel9-CRB'])], ++ repositories=[ ++ PESIDRepositoryEntry( ++ pesid='rhel8-CRB', major_version='8', ++ repoid='codeready-builder-for-centos-8-x86_64-rpms', ++ rhui='', arch='x86_64', channel='ga', repo_type='rpm', distro='centos', ++ ), ++ PESIDRepositoryEntry( ++ pesid='rhel9-CRB', major_version='9', ++ repoid='codeready-builder-for-centos-9-x86_64-rpms', ++ rhui='', arch='x86_64', channel='ga', repo_type='rpm', distro='centos', ++ ), ++ ] ++ ) ++ handler = RepoMapDataHandler(centos_repomap) + result = repositoriesblocklist._calc_internal_blocklist( +- repomap_opts_only, _NO_TASKS, enabled_repoids=set() ++ handler, _NO_TASKS, enabled_repoids=set() + ) + + assert result +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py +index 652bf30a..5192e65a 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py +@@ -1,6 +1,7 @@ + import pytest + + from leapp.libraries.actor import setuptargetrepos ++from leapp.libraries.actor.repomap_calc import RepoMapDataHandler + from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked + from leapp.libraries.stdlib import api + from leapp.models import ( +@@ -27,14 +28,13 @@ def test_minimal_execution(monkeypatch): + """ + Tests whether the actor does not fail if no messages except the RepositoriesMapping are provided. + """ +- msgs = [ +- RepositoriesMapping(mapping=[], repositories=[]) +- ] ++ repos_mapping = RepositoriesMapping(mapping=[], repositories=[]) ++ msgs = [repos_mapping] + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setuptargetrepos.setup_target_repos(msgs[0]) ++ setuptargetrepos.setup_target_repos(RepoMapDataHandler(repos_mapping)) + + + def test_custom_repos(monkeypatch): +@@ -62,7 +62,8 @@ def test_custom_repos(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setuptargetrepos.setup_target_repos(repositories_mapping, blacklisted_repoids={'rhel-8-blacklisted-rpms'}) ++ handler = RepoMapDataHandler(repositories_mapping) ++ setuptargetrepos.setup_target_repos(handler, blacklisted_repoids={'rhel-8-blacklisted-rpms'}) + + assert api.produce.called + +@@ -82,8 +83,9 @@ def test_repositories_setup_tasks(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + ++ handler = RepoMapDataHandler(repositories_mapping) + setuptargetrepos.setup_target_repos( +- repositories_mapping, ++ handler, + blacklisted_repoids={'rhel-8-blacklisted-rpms'}, + external_repoids_requests={'rhel-8-server-rpms', 'rhel-8-blacklisted-rpms'}) + +@@ -192,7 +194,8 @@ def test_repos_mapping_for_distro(monkeypatch, src_distro, dst_distro): + ) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setuptargetrepos.setup_target_repos(repomap, blacklisted_repoids={'{}-9-blacklisted-rpms'.format(dst_distro)}) ++ handler = RepoMapDataHandler(repomap) ++ setuptargetrepos.setup_target_repos(handler, blacklisted_repoids={'{}-9-blacklisted-rpms'.format(dst_distro)}) + assert api.produce.called + + distro_repos = api.produce.model_instances[0].distro_repos +@@ -228,8 +231,9 @@ def test_pes_requested_repoids_added_to_target(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + ++ handler = RepoMapDataHandler(repositories_mapping) + setuptargetrepos.setup_target_repos( +- repositories_mapping, ++ handler, + pes_requested_repoids={'pes-repo-1', 'pes-repo-2', 'pes-blocked'}, + blacklisted_repoids={'pes-blocked'}, + ) +@@ -255,8 +259,9 @@ def test_pes_and_external_repoids_combined(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + ++ handler = RepoMapDataHandler(repositories_mapping) + setuptargetrepos.setup_target_repos( +- repositories_mapping, ++ handler, + pes_requested_repoids={'pes-repo'}, + blacklisted_repoids={'blocked-repo'}, + external_repoids_requests={'ext-repo', 'blocked-repo'}, +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +index 970fd797..750f6f9e 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +@@ -23,7 +23,7 @@ def test_process_orchestration(monkeypatch): + """ + call_log = [] + +- fake_repo_map = object() ++ fake_repomap_handler = object() + fake_blocklist = frozenset({'blocked-repo'}) + fake_external_tasks = ExternalRepoSetupTasks( + to_enable=frozenset({'ext-repo'}), to_block=frozenset(), custom=frozenset() +@@ -37,20 +37,20 @@ def test_process_orchestration(monkeypatch): + self.external_tasks = fake_external_tasks + self.enabled_repoids = fake_enabled_repoids + +- def mock_load_repositories_mapping(): +- call_log.append('load_repositories_mapping') +- return fake_repo_map ++ def mock_init_repomap_handler(): ++ call_log.append('_init_repomap_handler') ++ return fake_repomap_handler + + def mock_compute_blocklist(repo_mapping, external_tasks, enabled_repoids): + call_log.append('compute_blocklist') +- assert repo_mapping is fake_repo_map ++ assert repo_mapping is fake_repomap_handler + assert external_tasks is fake_external_tasks + assert enabled_repoids is fake_enabled_repoids + return fake_blocklist + + def mock_scan_pes_events(repo_mapping, blacklisted_repoids, enabled_repoids): + call_log.append('scan_pes_events') +- assert repo_mapping is fake_repo_map ++ assert repo_mapping is fake_repomap_handler + assert blacklisted_repoids is fake_blocklist + assert enabled_repoids is fake_enabled_repoids + return fake_pes_repoids +@@ -58,7 +58,7 @@ def test_process_orchestration(monkeypatch): + def mock_setup_target_repos(repo_mapping, pes_requested_repoids=None, + blacklisted_repoids=None, external_repoids_requests=None): + call_log.append('setup_target_repos') +- assert repo_mapping is fake_repo_map ++ assert repo_mapping is fake_repomap_handler + assert pes_requested_repoids is fake_pes_repoids + assert blacklisted_repoids is fake_blocklist + assert external_repoids_requests is fake_external_tasks.to_enable +@@ -68,8 +68,8 @@ def test_process_orchestration(monkeypatch): + FakeInputData, + ) + monkeypatch.setattr( +- 'leapp.libraries.actor.targetcontentresolver.load_repositories_mapping', +- mock_load_repositories_mapping, ++ 'leapp.libraries.actor.targetcontentresolver._init_repomap_handler', ++ mock_init_repomap_handler, + ) + monkeypatch.setattr( + 'leapp.libraries.actor.targetcontentresolver.repositoriesblocklist.compute_blocklist', +@@ -88,7 +88,7 @@ def test_process_orchestration(monkeypatch): + + assert call_log == [ + 'InputData', +- 'load_repositories_mapping', ++ '_init_repomap_handler', + 'compute_blocklist', + 'scan_pes_events', + 'setup_target_repos', +-- +2.54.0 + diff --git a/0103-7-9-targetcontentresolver-setuptargetrepos-minor-upd.patch b/0103-7-9-targetcontentresolver-setuptargetrepos-minor-upd.patch new file mode 100644 index 0000000..92f9180 --- /dev/null +++ b/0103-7-9-targetcontentresolver-setuptargetrepos-minor-upd.patch @@ -0,0 +1,262 @@ +From c8efe616de0859c22de73573bb55d65949f2d180 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Sun, 7 Jun 2026 01:53:07 +0200 +Subject: [PATCH 103/108] [7/9] targetcontentresolver: setuptargetrepos: minor + updates + +* accept enabled repositories +* update the blacklist -> blocklist wording +* drop unneeded rename blocklisted_repoids -> excluded_repoids + - the rename was there just for the purpose to check whether + the blocklisted_repoids input parameter is a set or not. but we + know it will be always the set +* make all input parameters in setup_target_repos mandatory + - dropping default value which does not make sense anymore + +Unit tests adjusted with assistance of AI. + +Jira: RHEL-115867 +Co-authored-by: Matej Matuska +Assisted-by: Claude Code (Opus 4.6) +Signed-off-by: Petr Stodulka +--- + .../libraries/setuptargetrepos.py | 15 ++--- + .../libraries/targetcontentresolver.py | 3 +- + .../tests/test_setuptargetrepos.py | 56 ++++++++++++++----- + .../tests/test_targetcontentresolver.py | 7 ++- + 4 files changed, 54 insertions(+), 27 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +index 4f7ffd9f..4896bf9b 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/setuptargetrepos.py +@@ -1,5 +1,4 @@ + from leapp.libraries.actor import repomap_calc +-from leapp.libraries.actor.pes_events_scanner import get_enabled_repoids + from leapp.libraries.common.config import get_source_distro_id, get_target_distro_id + from leapp.libraries.common.config.version import get_source_major_version, get_source_version + from leapp.libraries.stdlib import api +@@ -57,21 +56,19 @@ def _get_mapped_repoids(repomap, src_repoids): + + + @suppress_deprecation(RHELTargetRepository) +-def setup_target_repos(repomap_handler, pes_requested_repoids=None, +- blacklisted_repoids=None, external_repoids_requests=None): ++def setup_target_repos(repomap_handler, enabled_repoids, pes_requested_repoids, ++ blocklisted_repoids, external_repoids_requests): + """ + Determine the final list of target repositories. + + :param repomap_handler: Operator to work with the repositories mapping data + :type repomap_handler: repomap_calc.RepoMapDataHandler + :param pes_requested_repoids: Set of repoids derived from PES events that need to be enabled. +- :param blacklisted_repoids: Set of repoids to exclude from target repos. If None, an empty set is used. ++ :param blocklisted_repoids: Set of repoids to exclude from target repos. If None, an empty set is used. + :param external_repoids_requests: Set of repoids requested by external actors (e.g. satellite_upgrade_facts). + """ + # Load relevant data from messages + used_repoids_dict = _get_used_repo_dict() +- enabled_repoids = get_enabled_repoids() +- excluded_repoids = blacklisted_repoids if blacklisted_repoids is not None else set() + custom_repos = _get_custom_target_repos() + repoids_from_installed_packages = _get_repoids_from_installed_packages() + +@@ -117,7 +114,7 @@ def setup_target_repos(repomap_handler, pes_requested_repoids=None, + .format(target_pesid) + ) + continue +- if target_pesidrepo.repoid in excluded_repoids: ++ if target_pesidrepo.repoid in blocklisted_repoids: + api.current_logger().debug('Skipping the {} repo (excluded).'.format(target_pesidrepo.repoid)) + continue + target_distro_repoids.add(target_pesidrepo.repoid) +@@ -129,7 +126,7 @@ def setup_target_repos(repomap_handler, pes_requested_repoids=None, + all_requested_repoids.update(external_repoids_requests or set()) + + for repo in all_requested_repoids: +- if repo in excluded_repoids: ++ if repo in blocklisted_repoids: + api.current_logger().debug('Skipping the {} repo from setup task (excluded).'.format(repo)) + continue + target_distro_repoids.add(repo) +@@ -140,7 +137,7 @@ def setup_target_repos(repomap_handler, pes_requested_repoids=None, + else: + rhel_repos = [] + distro_repos = [DistroTargetRepository(repoid=repoid) for repoid in sorted(target_distro_repoids)] +- custom_repos = [repo for repo in custom_repos if repo.repoid not in excluded_repoids] ++ custom_repos = [repo for repo in custom_repos if repo.repoid not in blocklisted_repoids] + custom_repos = sorted(custom_repos, key=lambda x: x.repoid) + + # produce message about skipped repositories +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +index 942a2680..374f1498 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -136,7 +136,8 @@ def process(): + + setuptargetrepos.setup_target_repos( + repomap_handler, ++ indata.enabled_repoids, + pes_requested_repoids=pes_requested_repoids, +- blacklisted_repoids=blocklisted_repoids, ++ blocklisted_repoids=blocklisted_repoids, + external_repoids_requests=indata.external_tasks.to_enable, + ) +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py +index 5192e65a..b2b4b8db 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_setuptargetrepos.py +@@ -34,22 +34,28 @@ def test_minimal_execution(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setuptargetrepos.setup_target_repos(RepoMapDataHandler(repos_mapping)) ++ setuptargetrepos.setup_target_repos( ++ RepoMapDataHandler(repos_mapping), ++ enabled_repoids=set(), ++ pes_requested_repoids=set(), ++ blocklisted_repoids=set(), ++ external_repoids_requests=set(), ++ ) + + + def test_custom_repos(monkeypatch): + """ + Tests whether the CustomRepos provided to the actor are propagated to the TargetRepositories after +- blacklist filtering is applied on them. ++ blocklist filtering is applied on them. + """ + custom = CustomTargetRepository(repoid='rhel-8-server-rpms', + name='RHEL 8 Server (RPMs)', + baseurl='https://.../dist/rhel/server/8/os', + enabled=True) + +- blacklisted = CustomTargetRepository(repoid='rhel-8-blacklisted-rpms', +- name='RHEL 8 Blacklisted (RPMs)', +- baseurl='https://.../dist/rhel/blacklisted/8/os', ++ blocklisted = CustomTargetRepository(repoid='rhel-8-blocklisted-rpms', ++ name='RHEL 8 Blocklisted (RPMs)', ++ baseurl='https://.../dist/rhel/blocklisted/8/os', + enabled=True) + + repositories_mapping = RepositoriesMapping( +@@ -57,13 +63,19 @@ def test_custom_repos(monkeypatch): + repositories=[] + ) + +- msgs = [custom, blacklisted, repositories_mapping] ++ msgs = [custom, blocklisted, repositories_mapping] + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs)) + monkeypatch.setattr(api, 'produce', produce_mocked()) + + handler = RepoMapDataHandler(repositories_mapping) +- setuptargetrepos.setup_target_repos(handler, blacklisted_repoids={'rhel-8-blacklisted-rpms'}) ++ setuptargetrepos.setup_target_repos( ++ handler, ++ enabled_repoids=set(), ++ pes_requested_repoids=set(), ++ blocklisted_repoids={'rhel-8-blocklisted-rpms'}, ++ external_repoids_requests=set(), ++ ) + + assert api.produce.called + +@@ -75,7 +87,7 @@ def test_custom_repos(monkeypatch): + def test_repositories_setup_tasks(monkeypatch): + """ + Tests whether the actor propagates requested repoids +- to the resulting TargetRepositories (and blacklist filtering is applied to them). ++ to the resulting TargetRepositories (and blocklist filtering is applied to them). + """ + repositories_mapping = RepositoriesMapping(mapping=[], repositories=[]) + msgs = [repositories_mapping] +@@ -86,8 +98,11 @@ def test_repositories_setup_tasks(monkeypatch): + handler = RepoMapDataHandler(repositories_mapping) + setuptargetrepos.setup_target_repos( + handler, +- blacklisted_repoids={'rhel-8-blacklisted-rpms'}, +- external_repoids_requests={'rhel-8-server-rpms', 'rhel-8-blacklisted-rpms'}) ++ enabled_repoids=set(), ++ pes_requested_repoids=set(), ++ blocklisted_repoids={'rhel-8-blacklisted-rpms'}, ++ external_repoids_requests={'rhel-8-server-rpms', 'rhel-8-blacklisted-rpms'}, ++ ) + + assert api.produce.called + +@@ -195,7 +210,17 @@ def test_repos_mapping_for_distro(monkeypatch, src_distro, dst_distro): + monkeypatch.setattr(api, 'produce', produce_mocked()) + + handler = RepoMapDataHandler(repomap) +- setuptargetrepos.setup_target_repos(handler, blacklisted_repoids={'{}-9-blacklisted-rpms'.format(dst_distro)}) ++ enabled_repoids = { ++ '{}-8-server-rpms'.format(src_distro), ++ '{}-8-blacklisted-rpms'.format(src_distro), ++ } ++ setuptargetrepos.setup_target_repos( ++ handler, ++ enabled_repoids=enabled_repoids, ++ pes_requested_repoids=set(), ++ blocklisted_repoids={'{}-9-blacklisted-rpms'.format(dst_distro)}, ++ external_repoids_requests=set(), ++ ) + assert api.produce.called + + distro_repos = api.produce.model_instances[0].distro_repos +@@ -223,7 +248,7 @@ def test_repos_mapping_for_distro(monkeypatch, src_distro, dst_distro): + def test_pes_requested_repoids_added_to_target(monkeypatch): + """ + Tests whether PES-requested repoids are added to the target repositories +- and that blacklisted PES-requested repoids are excluded. ++ and that blocklisted PES-requested repoids are excluded. + """ + repositories_mapping = RepositoriesMapping(mapping=[], repositories=[]) + msgs = [repositories_mapping] +@@ -234,8 +259,10 @@ def test_pes_requested_repoids_added_to_target(monkeypatch): + handler = RepoMapDataHandler(repositories_mapping) + setuptargetrepos.setup_target_repos( + handler, ++ enabled_repoids=set(), + pes_requested_repoids={'pes-repo-1', 'pes-repo-2', 'pes-blocked'}, +- blacklisted_repoids={'pes-blocked'}, ++ blocklisted_repoids={'pes-blocked'}, ++ external_repoids_requests=set(), + ) + + assert api.produce.called +@@ -262,8 +289,9 @@ def test_pes_and_external_repoids_combined(monkeypatch): + handler = RepoMapDataHandler(repositories_mapping) + setuptargetrepos.setup_target_repos( + handler, ++ enabled_repoids=set(), + pes_requested_repoids={'pes-repo'}, +- blacklisted_repoids={'blocked-repo'}, ++ blocklisted_repoids={'blocked-repo'}, + external_repoids_requests={'ext-repo', 'blocked-repo'}, + ) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +index 750f6f9e..c0ca2d93 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +@@ -55,12 +55,13 @@ def test_process_orchestration(monkeypatch): + assert enabled_repoids is fake_enabled_repoids + return fake_pes_repoids + +- def mock_setup_target_repos(repo_mapping, pes_requested_repoids=None, +- blacklisted_repoids=None, external_repoids_requests=None): ++ def mock_setup_target_repos(repo_mapping, enabled_repoids, pes_requested_repoids, ++ blocklisted_repoids, external_repoids_requests): + call_log.append('setup_target_repos') + assert repo_mapping is fake_repomap_handler ++ assert enabled_repoids is fake_enabled_repoids + assert pes_requested_repoids is fake_pes_repoids +- assert blacklisted_repoids is fake_blocklist ++ assert blocklisted_repoids is fake_blocklist + assert external_repoids_requests is fake_external_tasks.to_enable + + monkeypatch.setattr( +-- +2.54.0 + diff --git a/0104-8-9-targetcontentresolver-pes_events_scanner-refacto.patch b/0104-8-9-targetcontentresolver-pes_events_scanner-refacto.patch new file mode 100644 index 0000000..d2d945b --- /dev/null +++ b/0104-8-9-targetcontentresolver-pes_events_scanner-refacto.patch @@ -0,0 +1,849 @@ +From e8be7466b224df83691796a913792c759f736cd3 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Sat, 6 Jun 2026 12:30:21 +0200 +Subject: [PATCH 104/108] [8/9] targetcontentresolver: pes_events_scanner: + refactoring + +* Distinguish public & private functions +* Require installed rpms and enabled modules as input parameters + instead of consuming them directly + * Deduplication of enabled modules is moved to the orchestrator + library +* Code-stylistic changes +* Updated the blacklist -> blocklist wording in the code +* Drop dead code + +Unit tests adjusted with assistance of AI. + +Jira: RHEL-115867 +Co-authored-by: Matej Matuska +Assisted-by: Claude Code (Opus 4.6) +Signed-off-by: Petr Stodulka +--- + .../libraries/pes_events_scanner.py | 227 +++++++----------- + .../libraries/targetcontentresolver.py | 25 +- + .../tests/test_pes_event_scanner.py | 60 +++-- + .../tests/test_targetcontentresolver.py | 32 ++- + 4 files changed, 162 insertions(+), 182 deletions(-) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +index 96f26a59..2ea63f72 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/pes_events_scanner.py +@@ -2,7 +2,6 @@ from collections import defaultdict, namedtuple + from functools import partial + + from leapp import reporting +-from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import repomap_calc + from leapp.libraries.actor.pes_event_parsing import Action, get_pes_events, Package + from leapp.libraries.common import rpms +@@ -10,15 +9,7 @@ from leapp.libraries.common.config import get_target_distro_id, version + from leapp.libraries.common.distro import DISTRO_REPORT_NAMES + from leapp.libraries.stdlib import api + from leapp.libraries.stdlib.config import is_verbose +-from leapp.models import ( +- DistributionSignedRPM, +- EnabledModules, +- Module, +- PESIDRepositoryEntry, +- PESRpmTransactionTasks, +- RepositoriesFacts, +- RpmTransactionTasks +-) ++from leapp.models import Module, PESIDRepositoryEntry, PESRpmTransactionTasks, RpmTransactionTasks + + SKIPPED_PKGS_MSG = ( + 'packages will be skipped because they are available only in ' +@@ -32,48 +23,22 @@ SKIPPED_PKGS_MSG = ( + TransactionConfiguration = namedtuple('TransactionConfiguration', ('to_install', 'to_remove', 'to_keep')) + + +-def get_cloud_provider_name(cloud_provider_variant): +- for cloud_provider_prefix in ('aws', 'azure', 'google'): +- if cloud_provider_variant.startswith(cloud_provider_prefix): +- return cloud_provider_prefix +- return cloud_provider_variant +- +- +-def get_best_pesid_candidate(candidate_a, candidate_b, cloud_provider): +- cdn_candidate = None +- for candidate in (candidate_a, candidate_b): +- if candidate.rhui == cloud_provider: +- return candidate +- if not candidate.rhui: +- cdn_candidate = candidate +- +- # None of the candidate matches cloud provider and none of them is from CDN - +- # do not return anything as we don't want to get content from different cloud providers +- return cdn_candidate +- +- +-def get_installed_pkgs(): ++def _rpms_to_package_set(rpm_list): + installed_pkgs = set() +- +- installed_rh_signed_rpm_msgs = api.consume(DistributionSignedRPM) +- installed_rh_signed_rpm_msg = next(installed_rh_signed_rpm_msgs, None) +- if list(installed_rh_signed_rpm_msgs): +- api.current_logger().warning('Unexpectedly received more than one DistributionSignedRPM message.') +- if not installed_rh_signed_rpm_msg: +- raise StopActorExecutionError('Cannot parse PES data properly due to missing list of installed packages', +- details={'Problem': 'Did not receive a message with installed Red Hat-signed ' +- 'packages (DistributionSignedRPM)'}) +- +- for pkg in installed_rh_signed_rpm_msg.items: ++ for rpm in rpm_list: + modulestream = None +- if pkg.module and pkg.stream: +- modulestream = (pkg.module, pkg.stream) +- installed_pkgs.add(Package(name=pkg.name, repository=pkg.repository, modulestream=modulestream)) ++ if rpm.module and rpm.stream: ++ modulestream = (rpm.module, rpm.stream) ++ installed_pkgs.add(Package( ++ name=rpm.name, ++ repository=rpm.repository, ++ modulestream=modulestream ++ )) + + return installed_pkgs + + +-def get_transaction_configuration(): ++def _get_transaction_configuration(): + """ + Get pkgs to install, keep and remove from RpmTransactionTasks messages. + +@@ -94,7 +59,7 @@ def get_transaction_configuration(): + return transaction_configuration + + +-def get_relevant_releases(events): ++def _get_relevant_releases(events): + """ + Get releases present in the PES Events that are relevant for this IPU. + +@@ -110,24 +75,11 @@ def get_relevant_releases(events): + return sorted(releases) + + +-def _get_enabled_modules(): +- # TODO(pstodulk): take a look +- enabled_modules_msgs = api.consume(EnabledModules) +- enabled_modules_msg = next(enabled_modules_msgs, None) +- if list(enabled_modules_msgs): +- api.current_logger().warning('Unexpectedly received more than one EnabledModules message.') +- if not enabled_modules_msg: +- raise StopActorExecutionError('Cannot parse PES data properly due to missing list of enabled modules', +- details={'Problem': 'Did not receive a message with enabled module ' +- 'streams (EnabledModules)'}) +- return enabled_modules_msg.modules +- +- +-def compute_pkg_changes_between_consequent_releases(source_installed_pkgs, +- events, +- release, +- seen_pkgs, +- pkgs_to_demodularize): ++def _compute_pkg_changes_between_consequent_releases(source_installed_pkgs, ++ events, ++ release, ++ seen_pkgs, ++ pkgs_to_demodularize): + logger = api.current_logger() + # Start with the installed packages and modify the set according to release events + target_pkgs = set(source_installed_pkgs) +@@ -194,7 +146,7 @@ def compute_pkg_changes_between_consequent_releases(source_installed_pkgs, + return (target_pkgs, pkgs_to_demodularize) + + +-def remove_undesired_events(events, relevant_to_releases): ++def _remove_undesired_events(events, relevant_to_releases): + """ + Conservatively remove events that needless, or cause problems for the current implementation: + - (needless) events with to_release not in relevant releases +@@ -243,7 +195,7 @@ def remove_undesired_events(events, relevant_to_releases): + return cleaned_events + + +-def compute_packages_on_target_system(source_pkgs, events, releases): ++def _compute_packages_on_target_system(source_pkgs, events, releases): + + seen_pkgs = set(source_pkgs) # Used to track whether PRESENCE events can be applied + target_pkgs = set(source_pkgs) +@@ -257,9 +209,9 @@ def compute_packages_on_target_system(source_pkgs, events, releases): + did_processing_cross_major_version = True + pkgs_to_demodularize = {pkg for pkg in target_pkgs if pkg.modulestream} + +- target_pkgs, pkgs_to_demodularize = compute_pkg_changes_between_consequent_releases(target_pkgs, events, +- release, seen_pkgs, +- pkgs_to_demodularize) ++ target_pkgs, pkgs_to_demodularize = _compute_pkg_changes_between_consequent_releases(target_pkgs, events, ++ release, seen_pkgs, ++ pkgs_to_demodularize) + seen_pkgs = seen_pkgs.union(target_pkgs) + + demodularized_pkgs = {Package(pkg.name, pkg.repository, None) for pkg in pkgs_to_demodularize} +@@ -268,34 +220,33 @@ def compute_packages_on_target_system(source_pkgs, events, releases): + return (demodularized_target_pkgs, pkgs_to_demodularize) + + +-def compute_rpm_tasks_from_pkg_set_diff(source_pkgs, target_pkgs, pkgs_to_demodularize): ++def _compute_rpm_tasks_from_pkg_set_diff(source_pkgs, target_pkgs, pkgs_to_demodularize, modules_to_reset): + source_state_pkg_names = {pkg.name for pkg in source_pkgs} + target_state_pkg_names = {pkg.name for pkg in target_pkgs} + + pkgs_to_install = sorted(target_state_pkg_names.difference(source_state_pkg_names)) + pkgs_to_remove = sorted(source_state_pkg_names.difference(target_state_pkg_names)) + +- if pkgs_to_install or pkgs_to_remove: +- # NOTE(mhecko): Here we do not want to consider any package that does not have a reference in PES data. There +- # might be missing modularity information, and although the algorithm is correct, trying to enable +- # a non-existent modulestream due to missing modulestream information results in a crash. +- target_pkgs_without_demodularized_pkgs = target_pkgs.difference(pkgs_to_demodularize) ++ if not (pkgs_to_install or pkgs_to_remove): ++ return None + +- # Collect the enabled modules as tuples in a set, so we produce every module to reset exactly once +- enabled_modules = {(module.name, module.stream) for module in _get_enabled_modules()} +- modules_to_reset = [Module(name=ms[0], stream=ms[1]) for ms in enabled_modules] ++ # NOTE(mhecko): Here we do not want to consider any package that does not have a reference in PES data. There ++ # might be missing modularity information, and although the algorithm is correct, trying to enable ++ # a non-existent modulestream due to missing modulestream information results in a crash. ++ target_pkgs_without_demodularized_pkgs = target_pkgs.difference(pkgs_to_demodularize) + +- target_modulestreams = {pkg.modulestream for pkg in target_pkgs_without_demodularized_pkgs if pkg.modulestream} +- modules_to_enable = [Module(name=ms[0], stream=ms[1]) for ms in target_modulestreams] ++ target_modulestreams = {pkg.modulestream for pkg in target_pkgs_without_demodularized_pkgs if pkg.modulestream} ++ modules_to_enable = [Module(name=ms[0], stream=ms[1]) for ms in target_modulestreams] + +- return PESRpmTransactionTasks(to_install=pkgs_to_install, +- to_remove=pkgs_to_remove, +- modules_to_enable=modules_to_enable, +- modules_to_reset=modules_to_reset) +- return None ++ return PESRpmTransactionTasks( ++ to_install=pkgs_to_install, ++ to_remove=pkgs_to_remove, ++ modules_to_enable=modules_to_enable, ++ modules_to_reset=modules_to_reset ++ ) + + +-def report_skipped_packages(title, message, skipped_pkgs, remediation=None): ++def _report_skipped_packages(title, message, skipped_pkgs, remediation=None): + skipped_pkgs = sorted(skipped_pkgs) + + def make_summary_entry_for_skipped_pkg(pkg): +@@ -320,44 +271,25 @@ def report_skipped_packages(title, message, skipped_pkgs, remediation=None): + api.current_logger().info(summary) + + +-def remove_new_packages_from_blacklisted_repos(source_pkgs, target_pkgs, blacklisted_repoids): ++def _remove_new_packages_from_blocklisted_repos(source_pkgs, target_pkgs, blocklisted_repoids): + """ +- Remove newly installed packages from blacklisted repositories that were computed to be on the target system. ++ Remove newly installed packages from blocklisted repositories that were computed to be on the target system. + +- :param blacklisted_repoids: Set of repoids to exclude. If None, an empty set is used. ++ :param blocklisted_repoids: Set of repoids to exclude. If None, an empty set is used. + """ +- if blacklisted_repoids is None: +- blacklisted_repoids = set() + new_pkgs = target_pkgs.difference(source_pkgs) +- pkgs_from_blacklisted_repos = set(pkg for pkg in new_pkgs if pkg.repository in blacklisted_repoids) ++ pkgs_from_blocklisted_repos = set(pkg for pkg in new_pkgs if pkg.repository in blocklisted_repoids) + +- if pkgs_from_blacklisted_repos: +- report_skipped_packages( ++ if pkgs_from_blocklisted_repos: ++ _report_skipped_packages( + title='Packages available in excluded repositories will not be installed', + message=SKIPPED_PKGS_MSG, +- skipped_pkgs=pkgs_from_blacklisted_repos, ++ skipped_pkgs=pkgs_from_blocklisted_repos, + ) +- return blacklisted_repoids, target_pkgs.difference(pkgs_from_blacklisted_repos) ++ return target_pkgs.difference(pkgs_from_blocklisted_repos) + + +-def get_enabled_repoids(): +- """ +- Collects repoids of all enabled repositories on the source system. +- +- :param repositories_facts: Iterable of RepositoriesFacts containing repositories related info about source system. +- :return: Set of all enabled repository IDs present on the source system. +- :rtype: Set[str] +- """ +- enabled_repoids = set() +- for repos in api.consume(RepositoriesFacts): +- for repo_file in repos.repositories: +- for repo in repo_file.data: +- if repo.enabled: +- enabled_repoids.add(repo.repoid) +- return enabled_repoids +- +- +-def get_pesid_to_repoid_map(target_pesids, repomap_handler, enabled_repoids): ++def _get_pesid_to_repoid_map(target_pesids, repomap_handler, enabled_repoids): + """ + Get a dictionary mapping all PESID repositories to their corresponding repoid. + +@@ -430,7 +362,7 @@ def get_pesid_to_repoid_map(target_pesids, repomap_handler, enabled_repoids): + return repositories_mapping + + +-def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repomap_handler, enabled_repoids): ++def _replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repomap_handler, enabled_repoids): + """Replace packages with PESID in their .repository field with ones that have repoid providing the package.""" + # We want to map only PESIDs - if some package had no events, it will its repository set to source system repoid + packages_with_pesid = {pkg for pkg in packages if pkg.repository not in source_pkgs_repoids} +@@ -438,7 +370,7 @@ def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repom + + required_target_pesids = {pkg.repository for pkg in packages_with_pesid} + +- pesid_to_target_repoid_map = get_pesid_to_repoid_map(required_target_pesids, repomap_handler, enabled_repoids) ++ pesid_to_target_repoid_map = _get_pesid_to_repoid_map(required_target_pesids, repomap_handler, enabled_repoids) + + packages_with_unknown_target_repoid = { + pkg +@@ -447,7 +379,7 @@ def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repom + } + + if packages_with_unknown_target_repoid: +- report_skipped_packages( ++ _report_skipped_packages( + title='Packages from unknown repositories may not be installed', + message='packages may not be installed or upgraded due to repositories unknown to leapp:', + skipped_pkgs=packages_with_unknown_target_repoid, +@@ -458,7 +390,8 @@ def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repom + ' You can also review the projected DNF upgrade transaction result' + ' in the logs to see what is going to happen, as this does not necessarily mean' + ' that the listed packages will not be upgraded. You can also' +- ' install any missing packages after the in-place upgrade manually.'.format( ++ ' install any missing packages after the in-place upgrade manually.' ++ .format( + 'RHEL (provided by Red Hat on CDN)' + if get_target_distro_id() == 'rhel' + else DISTRO_REPORT_NAMES.target +@@ -474,7 +407,7 @@ def replace_pesids_with_repoids_in_packages(packages, source_pkgs_repoids, repom + return packages_with_repoid.union(packages_without_pesid) + + +-def apply_transaction_configuration(source_pkgs, transaction_configuration): ++def _apply_transaction_configuration(source_pkgs, transaction_configuration): + source_pkgs_with_conf_applied = set(source_pkgs) + + source_pkgs_with_conf_applied = source_pkgs.union(transaction_configuration.to_install) +@@ -494,7 +427,7 @@ def apply_transaction_configuration(source_pkgs, transaction_configuration): + return source_pkgs_with_conf_applied + + +-def remove_leapp_related_events(events): ++def _remove_leapp_related_events(events): + major_vers = ['7', '8', '9'] + leapp_pkgs = rpms.get_leapp_dep_packages(major_vers) + rpms.get_leapp_packages(major_vers) + res = [] +@@ -508,7 +441,7 @@ def remove_leapp_related_events(events): + return res + + +-def include_instructions_from_transaction_configuration(rpm_tasks, transaction_configuration, installed_pkgs): ++def _include_instructions_from_transaction_configuration(rpm_tasks, transaction_configuration, installed_pkgs): + """ + Extend current rpm_tasks applying data from transaction_configuration + +@@ -552,13 +485,13 @@ def include_instructions_from_transaction_configuration(rpm_tasks, transaction_c + modules_to_reset=modules_to_reset) + + +-def scan_pes_events(repomap_handler, blacklisted_repoids, enabled_repoids): ++def scan_pes_events(repomap_handler, blocklisted_repoids, enabled_repoids, installed_rpms, enabled_modules): + """ + Process PES events and compute RPM transaction tasks. + + :param repomap_handler: Operator to work with the repositories mapping data + :type repomap_handler: repomap_calc.RepoMapDataHandler +- :param blacklisted_repoids: Set of repoids to exclude from target packages. If None, an empty set is used. ++ :param blocklisted_repoids: Set of repoids to exclude from target packages. If None, an empty set is used. + :returns: Set of target repoids that need to be enabled, or None if no PES events are found. + :rtype: Optional[set] + """ +@@ -567,41 +500,55 @@ def scan_pes_events(repomap_handler, blacklisted_repoids, enabled_repoids): + if not events: + return None + +- releases = get_relevant_releases(events) +- installed_pkgs = get_installed_pkgs() +- transaction_configuration = get_transaction_configuration() +- pkgs_to_begin_computation_with = apply_transaction_configuration(installed_pkgs, transaction_configuration) ++ releases = _get_relevant_releases(events) ++ installed_pkgs = _rpms_to_package_set(installed_rpms) ++ transaction_configuration = _get_transaction_configuration() ++ pkgs_to_begin_computation_with = _apply_transaction_configuration(installed_pkgs, transaction_configuration) + + # Keep track of what repoids have the source packages to be able to determine what are the PESIDs of the computed + # packages of the target system, so we can distinguish what needs to be repomapped + repoids_of_source_pkgs = {pkg.repository for pkg in pkgs_to_begin_computation_with} + +- events = remove_leapp_related_events(events) +- events = remove_undesired_events(events, releases) ++ events = _remove_leapp_related_events(events) ++ events = _remove_undesired_events(events, releases) + + # Apply events - compute what packages should the target system have +- target_pkgs, pkgs_to_demodularize = compute_packages_on_target_system(pkgs_to_begin_computation_with, +- events, releases) ++ target_pkgs, pkgs_to_demodularize = _compute_packages_on_target_system( ++ pkgs_to_begin_computation_with, ++ events, ++ releases ++ ) + + # Packages coming out of the events have PESID as their repository, however, we need real repoid +- target_pkgs = replace_pesids_with_repoids_in_packages( ++ target_pkgs = _replace_pesids_with_repoids_in_packages( + target_pkgs, + repoids_of_source_pkgs, + repomap_handler, + enabled_repoids + ) + +- # Apply the desired repository blacklisting +- blacklisted_repoids, target_pkgs = remove_new_packages_from_blacklisted_repos(pkgs_to_begin_computation_with, +- target_pkgs, blacklisted_repoids) ++ # Apply the desired repository blocklisting ++ target_pkgs = _remove_new_packages_from_blocklisted_repos( ++ pkgs_to_begin_computation_with, ++ target_pkgs, ++ blocklisted_repoids ++ ) + + # Look at the target packages and determine what repositories to enable +- pes_requested_repoids = set(p.repository for p in target_pkgs) - blacklisted_repoids - repoids_of_source_pkgs ++ pes_requested_repoids = set(p.repository for p in target_pkgs) - blocklisted_repoids - repoids_of_source_pkgs + + # Compare the packages on source system and the computed packages on target system and determine what to install +- rpm_tasks = compute_rpm_tasks_from_pkg_set_diff(pkgs_to_begin_computation_with, target_pkgs, pkgs_to_demodularize) +- rpm_tasks = include_instructions_from_transaction_configuration(rpm_tasks, transaction_configuration, +- installed_pkgs) ++ rpm_tasks = _compute_rpm_tasks_from_pkg_set_diff( ++ pkgs_to_begin_computation_with, ++ target_pkgs, ++ pkgs_to_demodularize, ++ enabled_modules ++ ) ++ rpm_tasks = _include_instructions_from_transaction_configuration( ++ rpm_tasks, ++ transaction_configuration, ++ installed_pkgs ++ ) + if rpm_tasks: + api.produce(rpm_tasks) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +index 374f1498..83460d99 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -7,6 +7,9 @@ from leapp.libraries.actor.repomap_loader import load_repositories_mapping + from leapp.libraries.stdlib import api + from leapp.models import ( + CustomTargetRepository, ++ DistributionSignedRPM, ++ EnabledModules, ++ Module, + RepositoriesBlacklisted, + RepositoriesFacts, + RepositoriesSetupTasks, +@@ -26,6 +29,8 @@ class InputData(): + self._missing_messages = [] + + self._get_enabled_repoids() ++ self._get_installed_rpms() ++ self._get_enabled_modules() + self._get_external_reposetup_tasks() + + if self._missing_messages: +@@ -92,6 +97,22 @@ class InputData(): + if repo.enabled: + self.enabled_repoids.add(repo.repoid) + ++ def _get_enabled_modules(self): ++ self.enabled_modules = [] ++ modules_facts = self._treat_consume_msg(EnabledModules) ++ if modules_facts is None: ++ return ++ ++ # NOTE(pstodulk): This deduplication is weird, not sure why the input ++ # data would not be unique. But let's rather keep for now. ++ uniq_modules = {(module.name, module.stream) for module in modules_facts.modules} ++ self.enabled_modules = [Module(name=ms[0], stream=ms[1]) for ms in uniq_modules] ++ ++ def _get_installed_rpms(self): ++ distro_rpms = self._treat_consume_msg(DistributionSignedRPM) ++ if distro_rpms: ++ self.installed_rpms = distro_rpms.items ++ + + def _init_repomap_handler(): + repomap_msg = load_repositories_mapping() +@@ -131,7 +152,9 @@ def process(): + pes_requested_repoids = pes_events_scanner.scan_pes_events( + repomap_handler, + blocklisted_repoids, +- indata.enabled_repoids ++ indata.enabled_repoids, ++ indata.installed_rpms, ++ indata.enabled_modules + ) + + setuptargetrepos.setup_target_repos( +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +index c6f16823..b0beebbb 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_pes_event_scanner.py +@@ -5,10 +5,10 @@ import pytest + from leapp.libraries.actor import pes_events_scanner + from leapp.libraries.actor.pes_event_parsing import Event + from leapp.libraries.actor.pes_events_scanner import ( ++ _compute_packages_on_target_system, ++ _compute_rpm_tasks_from_pkg_set_diff, + Action, + api, +- compute_packages_on_target_system, +- compute_rpm_tasks_from_pkg_set_diff, + Package, + reporting, + TransactionConfiguration +@@ -16,8 +16,6 @@ from leapp.libraries.actor.pes_events_scanner import ( + from leapp.libraries.actor.repomap_calc import RepoMapDataHandler + from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked + from leapp.models import ( +- DistributionSignedRPM, +- EnabledModules, + PESIDRepositoryEntry, + PESRpmTransactionTasks, + RepoMapEntry, +@@ -135,7 +133,7 @@ def pkgs_into_tuples(pkgs): + def test_event_application_fundamentals(monkeypatch, installed_pkgs, events, releases, expected_target_pkgs): + """Trivial checks validating that the core event application algorithm reflects event semantics as expected.""" + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) +- actual_target_pkgs, dummy_demodularized_pkgs = compute_packages_on_target_system(installed_pkgs, events, releases) ++ actual_target_pkgs, dummy_demodularized_pkgs = _compute_packages_on_target_system(installed_pkgs, events, releases) + + # Perform strict comparison + actual_pkg_tuple_set = {(pkg.name, pkg.repository, pkg.modulestream) for pkg in actual_target_pkgs} +@@ -174,7 +172,9 @@ def test_compute_pkg_state(monkeypatch): + Package('reintroduced', 'rhel7-repo', None), + } + +- target_pkgs, dummy_demodularized_pkgs = compute_packages_on_target_system(installed_pkgs, events, [(8, 0), (8, 1)]) ++ target_pkgs, dummy_demodularized_pkgs = _compute_packages_on_target_system( ++ installed_pkgs, events, [(8, 0), (8, 1)] ++ ) + + expected_target_pkgs = { + Package('split01', 'rhel8-repo', None), +@@ -186,7 +186,6 @@ def test_compute_pkg_state(monkeypatch): + + + def test_compute_rpm_tasks_from_pkg_set_diff(monkeypatch): +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[EnabledModules(modules=[])])) + source_pkgs = { + Package('removed1', '7repo', None), + Package('removed2', '7repo', None), +@@ -201,7 +200,7 @@ def test_compute_rpm_tasks_from_pkg_set_diff(monkeypatch): + Package('installed2', '8repo2', None), + } + +- rpm_tasks = compute_rpm_tasks_from_pkg_set_diff(source_pkgs, target_pkgs, set()) ++ rpm_tasks = _compute_rpm_tasks_from_pkg_set_diff(source_pkgs, target_pkgs, set(), []) + + assert rpm_tasks.to_install == ['installed1', 'installed2'] + assert rpm_tasks.to_remove == ['removed1', 'removed2'] +@@ -236,9 +235,9 @@ def test_actor_performs(monkeypatch): + + _RPM = partial(RPM, epoch='', packager='', version='', release='', arch='', pgpsig='') + +- installed_pkgs = DistributionSignedRPM(items=[ ++ installed_rpms = [ + _RPM(name='split-in'), _RPM(name='moved-in'), _RPM(name='removed') +- ]) ++ ] + + repositories_mapping = RepositoriesMapping( + mapping=[ +@@ -251,7 +250,6 @@ def test_actor_performs(monkeypatch): + repo_type='rpm', channel='ga', rhui='', distro='rhel')] + ) + +- enabled_modules = EnabledModules(modules=[]) + repo_facts = RepositoriesFacts( + repositories=[RepositoryFile(file='', data=[RepositoryData(repoid='rhel8-repo', name='RHEL8 repo')])] + ) +@@ -260,7 +258,7 @@ def test_actor_performs(monkeypatch): + api, + "current_actor", + CurrentActorMocked( +- msgs=[installed_pkgs, repositories_mapping, enabled_modules, repo_facts], ++ msgs=[repositories_mapping, repo_facts], + src_ver="8.10", + dst_ver="9.1", + ), +@@ -273,7 +271,7 @@ def test_actor_performs(monkeypatch): + monkeypatch.setattr(api, 'produce', produced_messages) + monkeypatch.setattr(reporting, 'create_report', created_report) + +- pes_events_scanner.scan_pes_events(repomap_handler, set(), {'rhel8-repo'}) ++ pes_events_scanner.scan_pes_events(repomap_handler, set(), {'rhel8-repo'}, installed_rpms, []) + + assert produced_messages.called + +@@ -299,14 +297,14 @@ def test_transaction_configuration_has_effect(monkeypatch): + ) + + packages = {_Pkg('pkg-a'), _Pkg('pkg-c')} +- _result = pes_events_scanner.apply_transaction_configuration(packages, transaction_cfg) ++ _result = pes_events_scanner._apply_transaction_configuration(packages, transaction_cfg) + result = {(p.name, p.repository, p.modulestream) for p in _result} + expected = {('pkg-a', None, None), ('pkg-b', None, None)} + + assert result == expected + + +-def test_repository_blacklist_is_correctly_applied(monkeypatch): ++def test_repository_blocklist_is_correctly_applied(monkeypatch): + _Pkg = partial(Package, modulestream=None) + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) +@@ -315,11 +313,10 @@ def test_repository_blacklist_is_correctly_applied(monkeypatch): + source_pkgs = {_Pkg('pkg-b', 'repo-b')} + target_pkgs = {_Pkg('pkg-a', 'repo-a'), _Pkg('pkg-b', 'repo-b'), _Pkg('pkg-c', 'repo-c'), _Pkg('pkg-d', 'repo-d')} + +- blacklisted_repoids, target_pkgs = pes_events_scanner.remove_new_packages_from_blacklisted_repos( ++ target_pkgs = pes_events_scanner._remove_new_packages_from_blocklisted_repos( + source_pkgs, target_pkgs, {'repo-a', 'repo-b', 'repo-c'}) + result = pkgs_into_tuples(target_pkgs) + +- assert blacklisted_repoids == {'repo-a', 'repo-b', 'repo-c'} + assert result == {('pkg-b', 'repo-b', None), ('pkg-d', 'repo-d', None)} + + assert reporting.create_report.called +@@ -340,17 +337,17 @@ def test_blacklisted_repoid_is_not_produced(monkeypatch): + (9, 0), (9, 1), []), + ] + +- monkeypatch.setattr(pes_events_scanner, 'get_installed_pkgs', lambda: installed_pkgs) ++ monkeypatch.setattr(pes_events_scanner, '_rpms_to_package_set', lambda rpm_list: installed_pkgs) + monkeypatch.setattr(pes_events_scanner, 'get_pes_events', lambda folder, filename: events) +- monkeypatch.setattr(pes_events_scanner, 'apply_transaction_configuration', lambda pkgs, transaction_cfg: pkgs) +- monkeypatch.setattr(pes_events_scanner, 'replace_pesids_with_repoids_in_packages', ++ monkeypatch.setattr(pes_events_scanner, '_apply_transaction_configuration', lambda pkgs, transaction_cfg: pkgs) ++ monkeypatch.setattr(pes_events_scanner, '_replace_pesids_with_repoids_in_packages', + lambda pkgs, src_pkgs_repoids, repo_map_msg, enabled_repoids: pkgs) + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) + monkeypatch.setattr(api, 'produce', produce_mocked()) + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + +- setup_tasks = pes_events_scanner.scan_pes_events(None, {'blacklisted-rhel9'}, set()) ++ setup_tasks = pes_events_scanner.scan_pes_events(None, {'blacklisted-rhel9'}, set(), [], []) + + assert not reporting.create_report.called + +@@ -383,7 +380,7 @@ def test_modularity_info_distinguishes_pkgs(monkeypatch, installed_pkgs, expecte + ] + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) +- target_pkgs, dummy_demodularized_pkgs = compute_packages_on_target_system(installed_pkgs, events, [(8, 1)]) ++ target_pkgs, dummy_demodularized_pkgs = _compute_packages_on_target_system(installed_pkgs, events, [(8, 1)]) + + assert pkgs_into_tuples(target_pkgs) == expected_target_pkgs + +@@ -403,7 +400,7 @@ def test_pkgs_are_demodularized_when_crossing_major_version(monkeypatch): + Package('demodularized', 'repo', ('module-demodularized', 'stream')) + } + +- target_pkgs, demodularized_pkgs = compute_packages_on_target_system(installed_pkgs, events, [(8, 0)]) ++ target_pkgs, demodularized_pkgs = _compute_packages_on_target_system(installed_pkgs, events, [(8, 0)]) + + expected_target_pkgs = { + Package('modular', 'repo1-out', ('module2', 'stream')), +@@ -473,7 +470,7 @@ def test_remove_leapp_related_events(monkeypatch): + {Package('other-pkg-with-leapp-in-the-name', 'repoid-rhel8', None)}, (7, 0), (8, 0), []), + ] + +- out_events = pes_events_scanner.remove_leapp_related_events(in_events) ++ out_events = pes_events_scanner._remove_leapp_related_events(in_events) + assert out_events == expected_out_events + + +@@ -483,7 +480,7 @@ def test_transaction_configuration_is_applied(monkeypatch): + Package(name='split-in', repository='rhel7-base', modulestream=None), + Package(name='pkg-not-in-events', repository='rhel7-base', modulestream=None), + } +- monkeypatch.setattr(pes_events_scanner, 'get_installed_pkgs', lambda *args, **kwags: installed_pkgs) ++ monkeypatch.setattr(pes_events_scanner, '_rpms_to_package_set', lambda rpm_list: installed_pkgs) + + Pkg = partial(Package, modulestream=None) + events = [ +@@ -496,14 +493,13 @@ def test_transaction_configuration_is_applied(monkeypatch): + (7, 9), (8, 0), []), + ] + monkeypatch.setattr(pes_events_scanner, 'get_pes_events', lambda *args, **kwargs: events) +- monkeypatch.setattr(pes_events_scanner, 'remove_leapp_related_events', lambda events: events) +- monkeypatch.setattr(pes_events_scanner, 'remove_undesired_events', lambda events, releases: events) +- monkeypatch.setattr(pes_events_scanner, '_get_enabled_modules', lambda *args: []) +- monkeypatch.setattr(pes_events_scanner, 'replace_pesids_with_repoids_in_packages', ++ monkeypatch.setattr(pes_events_scanner, '_remove_leapp_related_events', lambda events: events) ++ monkeypatch.setattr(pes_events_scanner, '_remove_undesired_events', lambda events, releases: events) ++ monkeypatch.setattr(pes_events_scanner, '_replace_pesids_with_repoids_in_packages', + lambda target_pkgs, repoids_of_source_pkgs, repo_map_msg, enabled_repoids: target_pkgs) + monkeypatch.setattr(pes_events_scanner, +- 'remove_new_packages_from_blacklisted_repos', +- lambda source_pkgs, target_pkgs, bl: (set(), target_pkgs)) ++ '_remove_new_packages_from_blocklisted_repos', ++ lambda source_pkgs, target_pkgs, bl: target_pkgs) + + msgs = [ + RpmTransactionTasks(to_remove=['pkg-not-in-events']), +@@ -516,7 +512,7 @@ def test_transaction_configuration_is_applied(monkeypatch): + + monkeypatch.setattr(api, 'produce', produce_mocked()) + +- setup_tasks = pes_events_scanner.scan_pes_events(None, set(), set()) ++ setup_tasks = pes_events_scanner.scan_pes_events(None, set(), set(), [], []) + + assert api.produce.called == 1 + assert setup_tasks is not None +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +index c0ca2d93..f4fbe32e 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/tests/test_targetcontentresolver.py +@@ -7,11 +7,14 @@ from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked + from leapp.libraries.stdlib import api + from leapp.models import ( + CustomTargetRepository, ++ DistributionSignedRPM, ++ EnabledModules, + RepositoriesBlacklisted, + RepositoriesFacts, + RepositoriesSetupTasks, + RepositoryData, +- RepositoryFile ++ RepositoryFile, ++ RPM + ) + from leapp.utils.deprecation import suppress_deprecation + +@@ -29,6 +32,8 @@ def test_process_orchestration(monkeypatch): + to_enable=frozenset({'ext-repo'}), to_block=frozenset(), custom=frozenset() + ) + fake_enabled_repoids = frozenset({'enabled-repo'}) ++ fake_installed_rpms = [] ++ fake_enabled_modules = [] + fake_pes_repoids = frozenset({'pes-repo'}) + + class FakeInputData: +@@ -36,6 +41,8 @@ def test_process_orchestration(monkeypatch): + call_log.append('InputData') + self.external_tasks = fake_external_tasks + self.enabled_repoids = fake_enabled_repoids ++ self.installed_rpms = fake_installed_rpms ++ self.enabled_modules = fake_enabled_modules + + def mock_init_repomap_handler(): + call_log.append('_init_repomap_handler') +@@ -48,11 +55,13 @@ def test_process_orchestration(monkeypatch): + assert enabled_repoids is fake_enabled_repoids + return fake_blocklist + +- def mock_scan_pes_events(repo_mapping, blacklisted_repoids, enabled_repoids): ++ def mock_scan_pes_events(repo_mapping, blacklisted_repoids, enabled_repoids, installed_rpms, enabled_modules): + call_log.append('scan_pes_events') + assert repo_mapping is fake_repomap_handler + assert blacklisted_repoids is fake_blocklist + assert enabled_repoids is fake_enabled_repoids ++ assert installed_rpms is fake_installed_rpms ++ assert enabled_modules is fake_enabled_modules + return fake_pes_repoids + + def mock_setup_target_repos(repo_mapping, enabled_repoids, pes_requested_repoids, +@@ -96,6 +105,11 @@ def test_process_orchestration(monkeypatch): + ] + + ++def _make_base_msgs(): ++ """Create DistributionSignedRPM and EnabledModules messages required by InputData.""" ++ return [DistributionSignedRPM(items=[]), EnabledModules(modules=[])] ++ ++ + def _make_repo_facts(repoids_enabled=None, repoids_disabled=None): + repos_data = [] + for repoid in (repoids_enabled or []): +@@ -112,7 +126,7 @@ def test_inputdata_collects_enabled_repoids(monkeypatch): + repoids_enabled=['repo-a', 'repo-b'], + repoids_disabled=['repo-c'], + ) +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts] + _make_base_msgs())) + + indata = InputData() + +@@ -120,7 +134,7 @@ def test_inputdata_collects_enabled_repoids(monkeypatch): + + + def test_inputdata_raises_when_repofacts_missing(monkeypatch): +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=_make_base_msgs())) + + with pytest.raises(StopActorExecutionError): + InputData() +@@ -135,7 +149,7 @@ def test_inputdata_aggregates_external_tasks(monkeypatch): + + monkeypatch.setattr( + api, 'current_actor', +- CurrentActorMocked(msgs=[repo_facts, setup_tasks_1, setup_tasks_2, custom_1, custom_2]) ++ CurrentActorMocked(msgs=[repo_facts, setup_tasks_1, setup_tasks_2, custom_1, custom_2] + _make_base_msgs()) + ) + + indata = InputData() +@@ -147,7 +161,7 @@ def test_inputdata_aggregates_external_tasks(monkeypatch): + + def test_inputdata_empty_external_tasks(monkeypatch): + repo_facts = _make_repo_facts(repoids_enabled=['repo-a']) +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[repo_facts] + _make_base_msgs())) + + indata = InputData() + +@@ -161,7 +175,7 @@ def test_inputdata_warns_on_duplicate_repofacts(monkeypatch): + repo_facts_2 = _make_repo_facts(repoids_enabled=['repo-b']) + monkeypatch.setattr( + api, 'current_actor', +- CurrentActorMocked(msgs=[repo_facts_1, repo_facts_2]) ++ CurrentActorMocked(msgs=[repo_facts_1, repo_facts_2] + _make_base_msgs()) + ) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + +@@ -179,7 +193,7 @@ def test_inputdata_consumes_deprecated_blacklisted(monkeypatch): + + monkeypatch.setattr( + api, 'current_actor', +- CurrentActorMocked(msgs=[repo_facts, blacklisted]) ++ CurrentActorMocked(msgs=[repo_facts, blacklisted] + _make_base_msgs()) + ) + + indata = InputData() +@@ -196,7 +210,7 @@ def test_inputdata_merges_blacklisted_with_setup_tasks(monkeypatch): + + monkeypatch.setattr( + api, 'current_actor', +- CurrentActorMocked(msgs=[repo_facts, setup_tasks, blacklisted]) ++ CurrentActorMocked(msgs=[repo_facts, setup_tasks, blacklisted] + _make_base_msgs()) + ) + + indata = InputData() +-- +2.54.0 + diff --git a/0105-9-9-targetcontentresolver-Make-TODO-notes.patch b/0105-9-9-targetcontentresolver-Make-TODO-notes.patch new file mode 100644 index 0000000..7938598 --- /dev/null +++ b/0105-9-9-targetcontentresolver-Make-TODO-notes.patch @@ -0,0 +1,52 @@ +From a3a0c1d59f72c5c367b2541a510e01d08c470266 Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Mon, 22 Jun 2026 15:02:05 +0200 +Subject: [PATCH 105/108] [9/9] targetcontentresolver: Make TODO notes + +The current refactoring progressed significantly, but there are still +anti-patterns and bugs in the code, which cannot be easily resolved +without the second part of the refactoring. Due to lack of time, we +decided to stop here, considering the situation much better in +comparison to the previous state. Making notes in the code about +additional expected changes so we do not forget them :) + +Co-authored-by: Matej Matuska +Jira: RHEL-115867 +Signed-off-by: Petr Stodulka +--- + .../libraries/targetcontentresolver.py | 19 +++++++++++++++++++ + 1 file changed, 19 insertions(+) + +diff --git a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +index 83460d99..f969d0f9 100644 +--- a/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py ++++ b/repos/system_upgrade/common/actors/targetcontentresolver/libraries/targetcontentresolver.py +@@ -142,6 +142,25 @@ def process(): + source repos, installed package repos, and custom/blacklisted repo + configuration. + """ ++ # TODO: update pes_even_scanner to work just with PESIDs instead of repoids. ++ # The blocklisted events needs to be then evaluated / calculated after ++ # the setuptargetrepos calculations. Similarly we can update the blocklist ++ # library. The setuptargetrepos tells exactly what's going to be allowed ++ # (repoids) and based on that we can finish the calculations. ++ # This includes: ++ # * all reports will be generated (or directly called) from the orchestrator ++ # * all/most of messages should be produced in the orchestrator ++ # * we will need at least 2 public functions in pes_events_scanner ++ # # and repositoriesblocklist libs: ++ # # * these that we have right now ++ # # * additional ones for the final calculation / filtering based on ++ # # the real list of blocklisted repoids ++ # TL;DR; the workflow will be like this: ++ # * Make interim pes-events calculation (work just with PESIDS) ++ # * Make interim blocklist calculation ++ # * Calc expected & really blocked target repositories ++ # * take the result and finish the calculation ++ # * generate reports & messages + indata = InputData() + repomap_handler = _init_repomap_handler() + blocklisted_repoids = repositoriesblocklist.compute_blocklist( +-- +2.54.0 + diff --git a/0106-fix-support-for-kernels-with-64k-page-size.patch b/0106-fix-support-for-kernels-with-64k-page-size.patch new file mode 100644 index 0000000..a38995a --- /dev/null +++ b/0106-fix-support-for-kernels-with-64k-page-size.patch @@ -0,0 +1,1560 @@ +From 86a3954390849060fc0c7eef1abe6926ac811b24 Mon Sep 17 00:00:00 2001 +From: Peter Mocary +Date: Wed, 3 Jun 2026 17:19:59 +0200 +Subject: [PATCH 106/108] fix support for kernels with 64k page size + +On aarch64, dedicated kernel packages for systems using 64k memory pages +(kernel-64k and kernel-rt-64k) exist. However, for ppc64le the standard +kernel packages are used despite having 64k page size. The upgrade now +detects the kernel page size and selects the correct kernel packages for +both the target OS and the internal upgrade environment. + +The kernel-rt-64k RPM's kernel-uname-r provide uses a different suffix +(+rt_64k) than what uname -r returns (+rt-64k). A normalization +workaround is included to handle both variants until the RPM metadata +or uname -r output is fixed. + +- Add ``page_size`` field to the ``KernelInfo`` model +- Add ``KernelPageSize`` enum and ``determine_kernel_page_size()`` using + ``getconf PAGE_SIZE`` to the kernel shared library +- Update ``scansourcekernel`` to detect and produce the ``page_size`` +- Recognize ``+rt-64k`` uname-r suffix as realtime kernel type +- Add ``get_target_kernel_pkg_names()`` to resolve kernel package names based + on type (ordinary/rt), page size (4k/64k), and architecture (special + 64k kernel RPMs only exist on aarch64) +- Add ``KernelPackageInfoError`` exception and update + ``get_kernel_pkg_info()`` to raise it instead of letting + ``CalledProcessError`` propagate +- Update ``commonleappdracutmodules`` to dynamically select kernel + packages instead of hardcoding kernel/kernel-core/kernel-modules +- Update ``scaninstalledtargetkernelversion`` to use + ``get_target_kernel_pkg_names()`` with fallback from rt to ordinary + preserving page size +- Update ``upgradeinitramfsgenerator`` to query the correct kernel + variant inside the target userspace container +- Update ``generate-initram.sh`` fallback to query all kernel variants +- Deprecate the unused ``CurrentKernel`` model in favor of ``KernelInfo`` + +Jira: RHEL-111860 +--- + .../libraries-and-api/deprecations-list.md | 1 + + .../actors/commonleappdracutmodules/actor.py | 4 +- + .../libraries/modscan.py | 18 +- + .../test_modscan_commonleappdracutmodules.py | 76 +++++++- + .../libraries/targetinitramfsgenerator.py | 4 +- + .../tests/test_targetinitramfsgenerator.py | 2 +- + .../upgradeinitramfsgenerator/actor.py | 9 +- + .../files/generate-initram.sh | 5 +- + .../libraries/upgradeinitramfsgenerator.py | 57 +++--- + .../unit_test_upgradeinitramfsgenerator.py | 114 ++++++++++-- + .../scaninstalledtargetkernelversion/actor.py | 17 +- + .../libraries/scankernel.py | 67 ++++--- + ...kernel_scaninstalledtargetkernelversion.py | 169 ++++++++++++++---- + .../libraries/scan_source_kernel.py | 6 +- + .../system_upgrade/common/libraries/kernel.py | 132 +++++++++++--- + .../common/libraries/tests/test_kernel_lib.py | 143 ++++++++++++--- + .../common/models/installedkernelversion.py | 5 + + .../models/installedtargetkernelversion.py | 14 +- + 18 files changed, 678 insertions(+), 165 deletions(-) + +diff --git a/docs/source/libraries-and-api/deprecations-list.md b/docs/source/libraries-and-api/deprecations-list.md +index 1968c8af..8cb89882 100644 +--- a/docs/source/libraries-and-api/deprecations-list.md ++++ b/docs/source/libraries-and-api/deprecations-list.md +@@ -17,6 +17,7 @@ Only the versions in which a deprecation has been made are listed. + - **`LEAPP_DISABLE_NET_NAMING_SCHEMES`** - The `net.naming-scheme` kernel argument provides much better alternative to the legacy solution enabled using this variable. Hence the legacy solution is deprecated together with this environment variable. + - **`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: ++ - **`CurrentKernel`** - The model is not used by any actor and provides incomplete information. Use the `KernelInfo` model instead for information about the source distribution kernel. + - **`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. + - **`RepositoriesBlacklisted`** - To get the list of blocklisted repositories, consume `RepositoriesBlocklisted` message. To request a repository to be blocklisted, use `RepositoriesSetupTasks.to_block` instead. + - Shared libraries +diff --git a/repos/system_upgrade/common/actors/commonleappdracutmodules/actor.py b/repos/system_upgrade/common/actors/commonleappdracutmodules/actor.py +index 6be0657f..bc2c38d8 100644 +--- a/repos/system_upgrade/common/actors/commonleappdracutmodules/actor.py ++++ b/repos/system_upgrade/common/actors/commonleappdracutmodules/actor.py +@@ -4,6 +4,7 @@ from leapp.tags import InterimPreparationPhaseTag, IPUWorkflowTag + from leapp.utils.deprecation import suppress_deprecation + + from leapp.models import ( # isort:skip ++ KernelInfo, + LiveModeConfig, + RequiredUpgradeInitramPackages, # deprecated + UpgradeDracutModule, # deprecated +@@ -21,10 +22,11 @@ class CommonLeappDracutModules(Actor): + required to be installed in the target userspace so required fields can be included. + Modules to be added are specified via the UpgradeDracutModule message. + Packages to install on the target userspace are specified by the RequiredUpgradeInitramPackages message. ++ Kernel packages are selected based on the source system's kernel page size (e.g. kernel vs kernel-64k). + """ + + name = 'common_leapp_dracut_modules' +- consumes = (LiveModeConfig,) ++ consumes = (KernelInfo, LiveModeConfig,) + produces = ( + RequiredUpgradeInitramPackages, # deprecated + TargetUserSpaceUpgradeTasks, +diff --git a/repos/system_upgrade/common/actors/commonleappdracutmodules/libraries/modscan.py b/repos/system_upgrade/common/actors/commonleappdracutmodules/libraries/modscan.py +index 43cf60e6..e91c9f75 100644 +--- a/repos/system_upgrade/common/actors/commonleappdracutmodules/libraries/modscan.py ++++ b/repos/system_upgrade/common/actors/commonleappdracutmodules/libraries/modscan.py +@@ -1,12 +1,15 @@ + import os + import re + ++from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common import kernel as kernel_lib + from leapp.libraries.common.config import architecture, version + from leapp.libraries.stdlib import api + from leapp.utils.deprecation import suppress_deprecation + + from leapp.models import ( # isort:skip + CopyFile, ++ KernelInfo, + LiveModeConfig, + RequiredUpgradeInitramPackages, # deprecated + UpgradeDracutModule, # deprecated +@@ -28,9 +31,6 @@ _REQUIRED_PACKAGES = [ + 'hostname', + 'iscsi-initiator-utils', + 'kbd', +- 'kernel', +- 'kernel-core', +- 'kernel-modules', + 'keyutils', + 'kmod', + 'lldpad', +@@ -95,6 +95,18 @@ def _create_initram_packages(): + required_pkgs.append('NetworkManager') + if version.get_target_major_version() == '9': + required_pkgs += ['policycoreutils', 'rng-tools'] ++ ++ kernel_info = next(api.consume(KernelInfo), None) ++ if not kernel_info: ++ raise StopActorExecutionError('Could not retrieve KernelInfo message.') ++ page_size = kernel_info.page_size ++ kernel_packages = kernel_lib.get_target_kernel_pkg_names( ++ kernel_lib.KernelType.ORDINARY, ++ page_size, ++ api.current_actor().configuration.architecture ++ ) ++ required_pkgs.extend(kernel_packages) ++ + return ( + RequiredUpgradeInitramPackages(packages=required_pkgs), + TargetUserSpaceUpgradeTasks(install_rpms=required_pkgs) +diff --git a/repos/system_upgrade/common/actors/commonleappdracutmodules/tests/test_modscan_commonleappdracutmodules.py b/repos/system_upgrade/common/actors/commonleappdracutmodules/tests/test_modscan_commonleappdracutmodules.py +index 781d0b48..5974e777 100644 +--- a/repos/system_upgrade/common/actors/commonleappdracutmodules/tests/test_modscan_commonleappdracutmodules.py ++++ b/repos/system_upgrade/common/actors/commonleappdracutmodules/tests/test_modscan_commonleappdracutmodules.py +@@ -4,6 +4,7 @@ from collections import namedtuple + + import pytest + ++from leapp.exceptions import StopActorExecutionError + from leapp.libraries.actor import modscan + from leapp.libraries.common.config import architecture + from leapp.libraries.common.testutils import CurrentActorMocked +@@ -11,12 +12,38 @@ from leapp.libraries.stdlib import api + from leapp.utils.deprecation import suppress_deprecation + + from leapp.models import ( # isort:skip ++ KernelInfo, ++ RPM, + RequiredUpgradeInitramPackages, # deprecated + UpgradeDracutModule, # deprecated + TargetUserSpaceUpgradeTasks, + UpgradeInitramfsTasks + ) + ++_KERNEL_INFO_4K = KernelInfo( ++ pkg=RPM(name='kernel-core', arch='x86_64', version='5.14.0', release='100.el9', ++ epoch='0', packager='', pgpsig='SIG'), ++ type='ordinary', ++ uname_r='5.14.0-100.el9.x86_64', ++ page_size='4k', ++) ++ ++_KERNEL_INFO_64K = KernelInfo( ++ pkg=RPM(name='kernel-64k-core', arch='aarch64', version='5.14.0', release='100.el9', ++ epoch='0', packager='', pgpsig='SIG'), ++ type='ordinary', ++ uname_r='5.14.0-100.el9.aarch64+64k', ++ page_size='64k', ++) ++ ++_KERNEL_INFO_64K_PPC = KernelInfo( ++ pkg=RPM(name='kernel-core', arch='ppc64le', version='5.14.0', release='100.el9', ++ epoch='0', packager='', pgpsig='SIG'), ++ type='ordinary', ++ uname_r='5.14.0-100.el9.ppc64le', ++ page_size='64k', ++) ++ + + def _files_get_folder_path(name): + path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'files', name) +@@ -74,8 +101,7 @@ def test_created_modules(monkeypatch): + ('8.4', '9.0', architecture.ARCH_S390X), + )) + def test_required_packages(monkeypatch, src_ver, dst_ver, arch): +- # the default set of required rpms should not contain biosdevname +- for pkg in ['biosdevname', 'policycoreutils', 'rng-tools']: ++ for pkg in ['biosdevname', 'policycoreutils', 'rng-tools', 'kernel', 'kernel-core', 'kernel-modules']: + assert pkg not in modscan._REQUIRED_PACKAGES + + required_packages = modscan._REQUIRED_PACKAGES[:] +@@ -83,17 +109,59 @@ def test_required_packages(monkeypatch, src_ver, dst_ver, arch): + required_packages += ['policycoreutils', 'rng-tools'] + if arch == architecture.ARCH_X86_64: + required_packages += ['biosdevname'] ++ required_packages += ['kernel', 'kernel-core', 'kernel-modules'] + +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver=src_ver, dst_ver=dst_ver, arch=arch)) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ src_ver=src_ver, dst_ver=dst_ver, arch=arch, msgs=[_KERNEL_INFO_4K] ++ )) + old_initram, new_initram = modscan._create_initram_packages() + assert (set(required_packages) + == set(old_initram.packages) + == set(new_initram.install_rpms)) + + ++def test_required_packages_no_kernel_info(monkeypatch): ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(src_ver='9.8', dst_ver='10.2')) ++ with pytest.raises(StopActorExecutionError): ++ modscan._create_initram_packages() ++ ++ ++@pytest.mark.parametrize('kernel_info,arch,expected_kernel_pkgs,unexpected_kernel_pkgs', ( ++ (_KERNEL_INFO_4K, architecture.ARCH_ARM64, ++ ['kernel', 'kernel-core', 'kernel-modules'], ++ ['kernel-64k', 'kernel-64k-core', 'kernel-64k-modules']), ++ (_KERNEL_INFO_64K, architecture.ARCH_ARM64, ++ ['kernel-64k', 'kernel-64k-core', 'kernel-64k-modules'], ++ ['kernel', 'kernel-core', 'kernel-modules']), ++ # ppc64le: 64k page size but standard kernel packages (no kernel-64k RPMs) ++ (_KERNEL_INFO_64K_PPC, architecture.ARCH_PPC64LE, ++ ['kernel', 'kernel-core', 'kernel-modules'], ++ ['kernel-64k', 'kernel-64k-core', 'kernel-64k-modules']), ++ # x86_64: 4k page size, standard kernel packages ++ (_KERNEL_INFO_4K, architecture.ARCH_X86_64, ++ ['kernel', 'kernel-core', 'kernel-modules'], ++ ['kernel-64k', 'kernel-64k-core', 'kernel-64k-modules']), ++ # s390x: 4k page size, standard kernel packages ++ (_KERNEL_INFO_4K, architecture.ARCH_S390X, ++ ['kernel', 'kernel-core', 'kernel-modules'], ++ ['kernel-64k', 'kernel-64k-core', 'kernel-64k-modules']), ++)) ++def test_required_packages_page_size(monkeypatch, kernel_info, arch, expected_kernel_pkgs, unexpected_kernel_pkgs): ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked( ++ src_ver='9.6', dst_ver='10.0', arch=arch, msgs=[kernel_info] ++ )) ++ old_initram, new_initram = modscan._create_initram_packages() ++ for pkg in expected_kernel_pkgs: ++ assert pkg in new_initram.install_rpms ++ assert pkg in old_initram.packages ++ for pkg in unexpected_kernel_pkgs: ++ assert pkg not in new_initram.install_rpms ++ assert pkg not in old_initram.packages ++ ++ + @suppress_deprecation(UpgradeDracutModule) + def test_process_produces_modules(monkeypatch): +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=[_KERNEL_INFO_4K])) + messages = [] + monkeypatch.setattr(api, 'produce', lambda *x: messages.extend(x)) + monkeypatch.setattr(api, 'get_actor_folder_path', _files_get_folder_path) +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 e3b9352e..2e4b48bc 100644 +--- a/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/libraries/targetinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/targetinitramfsgenerator/libraries/targetinitramfsgenerator.py +@@ -110,8 +110,8 @@ def process(): + target_kernel_info = next(api.consume(InstalledTargetKernelInfo), None) + if not target_kernel_info: + raise StopActorExecutionError( +- 'Cannot get version of the installed RHEL-8 kernel', +- details={'Problem': 'Did not receive a message with installed RHEL-8 kernel version' ++ 'Cannot get version of the installed target OS kernel', ++ details={'Problem': 'Did not receive a message with installed target OS kernel version' + ' (InstalledTargetKernelVersion)'}) + + _copy_modules(modules['dracut'], DRACUT_DIR, 'dracut') +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 d21cdce8..9e7efa73 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 +@@ -105,7 +105,7 @@ def test_no_kernel_version(monkeypatch, msgs): + + with pytest.raises(StopActorExecutionError) as e: + targetinitramfsgenerator.process() +- assert 'Cannot get version of the installed RHEL-8 kernel' in str(e) ++ assert 'Cannot get version of the installed target OS kernel' in str(e) + assert not run_mocked.called + + +diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py +index ee6df26f..21a6e720 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py +@@ -5,6 +5,7 @@ from leapp.models import UpgradeDracutModule # deprecated + from leapp.models import ( + BootContent, + FIPSInfo, ++ KernelInfo, + LiveModeConfig, + LVMConfig, + RAIDInfo, +@@ -22,9 +23,10 @@ class UpgradeInitramfsGenerator(Actor): + Creates the upgrade initramfs + + Creates an initram disk within a systemd-nspawn container using the target +- system userspace, including new kernel. The creation of the initram disk +- can be influenced with the UpgradeInitramfsTasks message (e.g. specifying +- what files or dracut modules should be installed in the upgrade initramfs) ++ system userspace, including the appropriate kernel variant based on the ++ source system's page size. The creation of the initram disk can be ++ influenced with the UpgradeInitramfsTasks message (e.g. specifying what ++ files or dracut modules should be installed in the upgrade initramfs). + + See the UpgradeInitramfsTasks model for more details. + """ +@@ -32,6 +34,7 @@ class UpgradeInitramfsGenerator(Actor): + name = 'upgrade_initramfs_generator' + consumes = ( + FIPSInfo, ++ KernelInfo, + LiveModeConfig, + LVMConfig, + RAIDInfo, +diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/files/generate-initram.sh b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/files/generate-initram.sh +index 9648234c..6dc145d1 100755 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/files/generate-initram.sh ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/files/generate-initram.sh +@@ -7,7 +7,10 @@ stage() { + } + + get_kernel_version() { +- rpm -qa kernel --qf '%{VERSION}-%{RELEASE}.%{ARCH}\n' | sort --version-sort | tail --lines=1 ++ # The actor should always provide the version via LEAPP_KERNEL_VERSION and ensure ++ # only one kernel is installed in the container. This is simple fallback in case ++ # the version is not provided. ++ rpm -q --whatprovides kernel --qf '%{VERSION}-%{RELEASE}.%{ARCH}\n' | sort --version-sort | tail --lines=1 + } + + dracut_install_modules() +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 f9a00eec..d62a3162 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py +@@ -4,6 +4,7 @@ import shutil + from collections import namedtuple + + from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common import kernel as kernel_lib + from leapp.libraries.common import mounting + from leapp.libraries.common.config.version import get_target_major_version + from leapp.libraries.common.dnflibs import dnfplugin +@@ -12,6 +13,7 @@ from leapp.models import RequiredUpgradeInitramPackages # deprecated + from leapp.models import UpgradeDracutModule # deprecated + from leapp.models import ( + BootContent, ++ KernelInfo, + LiveModeConfig, + LVMConfig, + RAIDInfo, +@@ -33,53 +35,42 @@ def _get_target_kernel_version(context): + """ + Get the version of the most recent kernel version within the container. + """ ++ kernel_info = next(api.consume(KernelInfo), None) ++ if not kernel_info: ++ raise StopActorExecutionError('Could not retrieve KernelInfo message.') ++ page_size = kernel_info.page_size ++ arch = api.current_actor().configuration.architecture ++ kernel_pkg_names = kernel_lib.get_target_kernel_pkg_names(kernel_lib.KernelType.ORDINARY, page_size, arch) ++ kernel_core_pkg = kernel_pkg_names.core + +- kernel_version = None + try: +- # NOTE: Currently we install/use always kernel-core in the upgrade +- # initramfs. We do not use currently any different kernel package +- # in the container. Note this could change in future e.g. on aarch64 +- # for IPU 9 -> 10. +- # TODO(pstodulk): Investigate situation on ARM systems. OAMG-11433 +- results = context.call(['rpm', '-qa', 'kernel-core'], split=True)['stdout'] ++ results = context.call(['rpm', '-qa', kernel_core_pkg], split=True)['stdout'] + except CalledProcessError: + raise StopActorExecutionError( + 'Cannot get version of the installed kernel inside container.', + details={'Problem': 'Could not query the currently installed kernel inside container using rpm.'}) + ++ if not results: ++ raise StopActorExecutionError( ++ 'Cannot get version of the installed kernel inside container.', ++ details={'Problem': 'An rpm query for the available kernels did not produce any results.'}) ++ + if len(results) > 1: +- # this is should not happen. It's hypothetic situation, which alone it's +- # already error. So skipping more sophisticated implementation. +- # The container is always created during the upgrade and as that we expect +- # always one-and-only kernel installed. + raise StopActorExecutionError( + 'Cannot get version of the installed kernel inside container.', + details={'Problem': 'Detected unexpectedly multiple kernels inside target userspace container.'} + ) + +- # kernel version == version-release from package +- kernel_version = '-'.join(results[0].rsplit("-", 2)[-2:]) +- api.current_logger().debug('Detected kernel version inside container: {}.'.format(kernel_version)) +- ++ kernel_version = kernel_lib.get_uname_r_provided_by_kernel_pkg(results[0], context=context) + if not kernel_version: + raise StopActorExecutionError( + 'Cannot get version of the installed kernel inside container.', +- details={'Problem': 'An rpm query for the available kernels did not produce any results.'}) ++ details={'Problem': 'Failed to determine uname-r provided by {}.'.format(results[0])}) + ++ api.current_logger().debug('Detected kernel version inside container: {}.'.format(kernel_version)) + return kernel_version + + +-def _get_target_kernel_modules_dir(context): +- """ +- Return the path where the custom kernel modules should be copied. +- """ +- +- kernel_version = _get_target_kernel_version(context) +- modules_dir = os.path.join('/', 'lib', 'modules', kernel_version, 'extra', 'leapp') +- +- return modules_dir +- +- + def _reinstall_leapp_repository_hint(): + """ + Convenience function for creating a detail for StopActorExecutionError with a hint to reinstall the +@@ -153,7 +144,7 @@ def copy_dracut_modules(context, modules): + _copy_modules(context, modules, DRACUT_DIR, 'dracut') + + +-def copy_kernel_modules(context, modules): ++def copy_kernel_modules(context, modules, kernel_version): + """ + Copy kernel modules into the target userspace. + +@@ -161,7 +152,7 @@ def copy_kernel_modules(context, modules): + StopActorExecutionError exception is raised. + """ + +- dst_dir = _get_target_kernel_modules_dir(context) ++ dst_dir = os.path.join('/', 'lib', 'modules', kernel_version, 'extra', 'leapp') + _copy_modules(context, modules, dst_dir, 'kernel') + + +@@ -396,8 +387,10 @@ def generate_initram_disk(context): + # Issue #645 + initramfs_includes = collect_initramfs_includes() + ++ kernel_version = _get_target_kernel_version(context) ++ + copy_dracut_modules(context, initramfs_includes.dracut_modules) +- copy_kernel_modules(context, initramfs_includes.kernel_modules) ++ copy_kernel_modules(context, initramfs_includes.kernel_modules, kernel_version) + + def fmt_module_list(module_list): + return ','.join(mod.name for mod in module_list) +@@ -422,7 +415,7 @@ def generate_initram_disk(context): + + env_variables = ' '.join(env_variables) + env_variables = env_variables.format( +- kernel_version=_get_target_kernel_version(context), ++ kernel_version=kernel_version, + dracut_modules=fmt_module_list(initramfs_includes.dracut_modules), + kernel_modules=fmt_module_list(initramfs_includes.kernel_modules), + arch=api.current_actor().configuration.architecture, +@@ -481,7 +474,7 @@ def _generate_livemode_initramfs(context, userspace_initramfs_dest, target_kerne + initramfs_includes = collect_initramfs_includes() + + copy_dracut_modules(context, initramfs_includes.dracut_modules) +- copy_kernel_modules(context, initramfs_includes.kernel_modules) ++ copy_kernel_modules(context, initramfs_includes.kernel_modules, target_kernel_ver) + + md_cmdline_conf = write_md_uuid_cmdline_conf(context) + if md_cmdline_conf: +diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py +index b1960578..62f6bb34 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py +@@ -11,6 +11,8 @@ from leapp.utils.deprecation import suppress_deprecation + + from leapp.models import ( # isort:skip + FIPSInfo, ++ KernelInfo, ++ RPM, + RequiredUpgradeInitramPackages, # deprecated + UpgradeDracutModule, # deprecated + BootContent, +@@ -356,7 +358,7 @@ def test_generate_initram_disk(monkeypatch, input_msgs, dracut_modules, kernel_m + curr_actor = CurrentActorMocked(msgs=input_msgs, arch=architecture.ARCH_X86_64) + monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_actor', curr_actor) + monkeypatch.setattr(upgradeinitramfsgenerator, 'copy_dracut_modules', MockedCopyArgs()) +- monkeypatch.setattr(upgradeinitramfsgenerator, '_get_target_kernel_version', lambda _: '') ++ monkeypatch.setattr(upgradeinitramfsgenerator, '_get_target_kernel_version', lambda _: '5.14.0-100.el9.x86_64') + monkeypatch.setattr(upgradeinitramfsgenerator, 'copy_kernel_modules', MockedCopyArgs()) + monkeypatch.setattr(upgradeinitramfsgenerator, 'copy_boot_files', lambda dummy: None) + monkeypatch.setattr(upgradeinitramfsgenerator, '_get_fspace', MockedGetFspace(2*2**30)) +@@ -382,6 +384,8 @@ def test_generate_initram_disk(monkeypatch, input_msgs, dracut_modules, kernel_m + detected_kernel_modules.add(module) + assert detected_kernel_modules == _kernel_modules + ++ assert upgradeinitramfsgenerator.copy_kernel_modules.args[2] == '5.14.0-100.el9.x86_64' ++ + # TODO(pstodulk): this test is not created properly, as context.call check + # is skipped completely. Testing will more convenient with fixed #376 + # similar to the files... +@@ -423,26 +427,28 @@ def test_copy_modules_fail(monkeypatch, kind): + context.copytree_to = copytree_to_error + monkeypatch.setattr(os.path, 'exists', mock_context_path_exists) + monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_logger', MockedLogger()) +- monkeypatch.setattr(upgradeinitramfsgenerator, '_get_target_kernel_modules_dir', lambda _: '/kernel_modules') + + module_class = None + copy_fn = None +- dst_path = None ++ dst_dir = None + if kind == 'dracut': + module_class = DracutModule + copy_fn = upgradeinitramfsgenerator.copy_dracut_modules +- dst_path = 'dracut' ++ dst_dir = 'dracut' + elif kind == 'kernel': + module_class = KernelModule + copy_fn = upgradeinitramfsgenerator.copy_kernel_modules +- dst_path = 'kernel_modules' ++ dst_dir = 'lib/modules/dummy/extra/leapp' + + modules = [module_class(name='foo', module_path='/path/foo')] + with pytest.raises(StopActorExecutionError) as err: +- copy_fn(context, modules) ++ if kind == 'dracut': ++ copy_fn(context, modules) ++ else: ++ copy_fn(context, modules, 'dummy') + assert err.value.message.startswith('Failed to install {kind} modules'.format(kind=kind)) +- expected_err_log = 'Failed to copy {kind} module "foo" from "/path/foo" to "/base/dir/{dst_path}"'.format( +- kind=kind, dst_path=dst_path) ++ expected_err_log = 'Failed to copy {kind} module "foo" from "/path/foo" to "/base/dir/{dst_dir}"'.format( ++ kind=kind, dst_dir=dst_dir) + assert expected_err_log in upgradeinitramfsgenerator.api.current_logger.errmsg + + +@@ -456,7 +462,6 @@ def test_copy_modules_duplicate_skip(monkeypatch, kind): + + monkeypatch.setattr(os.path, 'exists', mock_context_path_exists) + monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_logger', MockedLogger()) +- monkeypatch.setattr(upgradeinitramfsgenerator, '_get_target_kernel_modules_dir', lambda _: '/kernel_modules') + + module_class = None + copy_fn = None +@@ -470,7 +475,10 @@ def test_copy_modules_duplicate_skip(monkeypatch, kind): + module = module_class(name='foo', module_path='/path/foo') + modules = [module, module] + +- copy_fn(context, modules) ++ if kind == 'dracut': ++ copy_fn(context, modules) ++ else: ++ copy_fn(context, modules, 'dummy') + + debugmsg = 'The foo {kind} module has been already installed. Skipping.'.format(kind=kind) + assert context.content +@@ -558,3 +566,89 @@ def test_prepare_boot_files_for_livemode(monkeypatch): + assert boot_content.kernel_path == '/boot/vmlinuz-upgrade.x86_64' + assert boot_content.initram_path == '/boot/initramfs-upgrade.x86_64.img' + assert boot_content.kernel_hmac_path == '/boot/.vmlinuz-upgrade.x86_64.hmac' ++ ++ ++def _make_kernel_info(page_size='4k', arch='aarch64'): ++ return KernelInfo( ++ pkg=RPM(name='kernel-core', arch=arch, version='5.14.0', release='100.el9', ++ epoch='0', packager='', pgpsig='SIG'), ++ type='ordinary', ++ uname_r='5.14.0-100.el9.{}'.format(arch), ++ page_size=page_size, ++ ) ++ ++ ++@pytest.mark.parametrize('page_size,arch,expected_pkg,expected_uname_r', [ ++ ('4k', 'aarch64', 'kernel-core', '5.14.0-100.el9.aarch64'), ++ ('4k', 'x86_64', 'kernel-core', '5.14.0-100.el9.x86_64'), ++ ('4k', 's390x', 'kernel-core', '5.14.0-100.el9.s390x'), ++ # aarch64: 64k page size and special kernel packages ++ ('64k', 'aarch64', 'kernel-64k-core', '5.14.0-100.el9.aarch64+64k'), ++ # ppc64le: 64k page size but standard kernel packages ++ ('64k', 'ppc64le', 'kernel-core', '5.14.0-100.el9.ppc64le'), ++]) ++def test_get_target_kernel_version_page_size(monkeypatch, page_size, arch, expected_pkg, expected_uname_r): ++ kernel_info = _make_kernel_info(page_size, arch) ++ monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_actor', ++ CurrentActorMocked(arch=arch, msgs=[kernel_info])) ++ ++ context = MockedContext() ++ target_nevra = '{}-5.14.0-100.el9.{}'.format(expected_pkg, arch) ++ queried_cmds = [] ++ ++ def mock_call(cmd, *args, **kwargs): ++ queried_cmds.append(cmd) ++ if cmd[:2] == ['rpm', '-qa']: ++ return {'stdout': [target_nevra]} ++ if cmd[:3] == ['rpm', '-q', '--provides']: ++ return {'stdout': ['kernel-uname-r = {}'.format(expected_uname_r)]} ++ return {'stdout': []} ++ ++ context.call = mock_call ++ ++ version = upgradeinitramfsgenerator._get_target_kernel_version(context) ++ assert version == expected_uname_r ++ assert queried_cmds[0] == ['rpm', '-qa', expected_pkg] ++ assert queried_cmds[1] == ['rpm', '-q', '--provides', target_nevra] ++ ++ ++def test_get_target_kernel_version_no_kernel_info(monkeypatch): ++ monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_actor', ++ CurrentActorMocked(msgs=[])) ++ ++ context = MockedContext() ++ ++ with pytest.raises(StopActorExecutionError, match='Could not retrieve KernelInfo message'): ++ upgradeinitramfsgenerator._get_target_kernel_version(context) ++ ++ ++def test_get_target_kernel_version_empty_results(monkeypatch): ++ kernel_info = _make_kernel_info(page_size='4k', arch='x86_64') ++ monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_actor', ++ CurrentActorMocked(arch='x86_64', msgs=[kernel_info])) ++ ++ context = MockedContext() ++ context.call = lambda cmd, *args, **kwargs: {'stdout': []} ++ ++ with pytest.raises(StopActorExecutionError, match='Cannot get version of the installed kernel'): ++ upgradeinitramfsgenerator._get_target_kernel_version(context) ++ ++ ++def test_get_target_kernel_version_empty_uname_r(monkeypatch): ++ kernel_info = _make_kernel_info(page_size='4k', arch='x86_64') ++ monkeypatch.setattr(upgradeinitramfsgenerator.api, 'current_actor', ++ CurrentActorMocked(arch='x86_64', msgs=[kernel_info])) ++ ++ context = MockedContext() ++ ++ def mock_call(cmd, *args, **kwargs): ++ if cmd[:2] == ['rpm', '-qa']: ++ return {'stdout': ['kernel-core-5.14.0-100.el9.x86_64']} ++ if cmd[:3] == ['rpm', '-q', '--provides']: ++ return {'stdout': ['some-other-provide = foo']} ++ return {'stdout': []} ++ ++ context.call = mock_call ++ ++ with pytest.raises(StopActorExecutionError, match='Cannot get version of the installed kernel'): ++ upgradeinitramfsgenerator._get_target_kernel_version(context) +diff --git a/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/actor.py b/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/actor.py +index 8b71d2d9..617c242a 100644 +--- a/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/actor.py ++++ b/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/actor.py +@@ -8,10 +8,19 @@ class ScanInstalledTargetKernelVersion(Actor): + """ + Scan for the version of the newly installed kernel + +- This actor will query rpm for all kernel-core packages and reports the +- first matching target system kernel RPM. In case the RHEL Real Time has +- been detected on the original system, the kernel-rt-core rpm is searched. +- If the rpm is missing, fallback for standard kernel RPM. ++ Based on the source kernel type and page size, this actor determines the ++ appropriate target kernel package (e.g. kernel-core, kernel-64k-core, ++ kernel-rt-core) and produces InstalledTargetKernelInfo containing the installed target kernel RPM. If the ++ expected variant is missing, it falls back to the ordinary kernel for ++ the same page size. Note that a fallback from a realtime to an ordinary ++ kernel may occur when the target realtime kernel package is not ++ available. This can happen if the user does not have a repository ++ providing the realtime kernel enabled, and there is currently no way ++ to verify package availability beforehand (the dnf plugin does not ++ propagate this information back to the actor). In such cases, the user ++ must enable the appropriate repository and install the realtime kernel ++ after the upgrade manually, which is preferred to ending up with an ++ unbootable system. + """ + + name = 'scan_installed_target_kernel_version' +diff --git a/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/libraries/scankernel.py b/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/libraries/scankernel.py +index 76f13caf..7ee2d253 100644 +--- a/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/libraries/scankernel.py ++++ b/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/libraries/scankernel.py +@@ -11,23 +11,15 @@ from leapp.utils.deprecation import suppress_deprecation + KernelBootFiles = namedtuple('KernelBootFiles', ('vmlinuz_path', 'initramfs_path')) + + +-def get_kernel_pkg_name(rhel_major_version, kernel_type): ++def get_target_kernel_package_nevra(kernel_pkg_name: str) -> str: + """ +- Get the name of the package providing kernel binaries. ++ Get the NEVRA of the installed target kernel package. + +- :param str rhel_major_version: RHEL major version +- :param KernelType kernel_type: Type of the kernel +- :returns: Kernel package name ++ :param kernel_pkg_name: Name of the kernel package (e.g. ``kernel-core``) ++ :type kernel_pkg_name: str ++ :returns: NEVRA of the target kernel package, or empty string if not found + :rtype: str + """ +- kernel_pkg_name_table = { +- kernel_lib.KernelType.ORDINARY: 'kernel-core', +- kernel_lib.KernelType.REALTIME: 'kernel-rt-core' +- } +- return kernel_pkg_name_table[kernel_type] +- +- +-def get_target_kernel_package_nevra(kernel_pkg_name): + try: + kernel_nevras = run(['rpm', '-q', kernel_pkg_name], split=True)['stdout'] + except CalledProcessError: +@@ -40,10 +32,19 @@ def get_target_kernel_package_nevra(kernel_pkg_name): + return '' + + +-def get_boot_files_provided_by_kernel_pkg(kernel_nevra): ++def get_boot_files_provided_by_kernel_pkg(kernel_nevra: str) -> KernelBootFiles: ++ """ ++ Get paths to the vmlinuz and initramfs files provided by a given kernel package. ++ ++ :param kernel_nevra: NEVRA of the kernel package ++ :type kernel_nevra: str ++ :returns: Paths to vmlinuz and initramfs in ``/boot`` ++ :rtype: KernelBootFiles ++ :raises StopActorExecutionError: If the boot files cannot be determined ++ """ + initramfs_path = '' + vmlinuz_path = '' +- err_msg = 'Cannot determine location of the target kernel boot image and corresponding initramfs .' ++ err_msg = 'Cannot determine location of the target kernel boot image and corresponding initramfs.' + try: + kernel_pkg_files = run(['rpm', '-q', '-l', kernel_nevra], split=True)['stdout'] + for kernel_file_path in kernel_pkg_files: +@@ -63,7 +64,13 @@ def get_boot_files_provided_by_kernel_pkg(kernel_nevra): + + + @suppress_deprecation(InstalledTargetKernelVersion) +-def process(): ++def process() -> None: ++ """ ++ Identify the target kernel package and produce ``InstalledTargetKernelInfo``. ++ ++ Selects the target kernel package based on the source kernel type and page size. ++ Falls back to a non-realtime kernel if the matching realtime variant is not available. ++ """ + # pylint: disable=no-else-return # false positive + # TODO: should we take care about stuff of kernel-rt and kernel in the same + # time when both are present? or just one? currently, handle only one +@@ -73,19 +80,33 @@ def process(): + if not src_kernel_info: + return # Will not happen, other actors would inhibit the upgrade + +- target_ver = get_target_major_version() +- target_kernel_pkg_name = get_kernel_pkg_name(target_ver, src_kernel_info.type) +- target_kernel_nevra = get_target_kernel_package_nevra(target_kernel_pkg_name) ++ arch = api.current_actor().configuration.architecture ++ target_kernel_pkg_names = kernel_lib.get_target_kernel_pkg_names( ++ src_kernel_info.type, ++ src_kernel_info.page_size, ++ arch ++ ) ++ target_kernel_core_pkg = target_kernel_pkg_names.core ++ target_kernel_nevra = get_target_kernel_package_nevra(target_kernel_core_pkg) + + if src_kernel_info.type != kernel_lib.KernelType.ORDINARY and not target_kernel_nevra: +- api.current_logger().warning('The kernel-rt-core rpm from the target RHEL has not been detected. Switching ' +- 'to non-preemptive kernel.') +- target_kernel_pkg_name = get_kernel_pkg_name(target_ver, kernel_lib.KernelType.ORDINARY) +- target_kernel_nevra = get_target_kernel_package_nevra(target_kernel_pkg_name) ++ # Fallback to ordinary kernel if target kernel nevra could not be determined ++ api.current_logger().warning('The %s rpm from the target RHEL has not been detected. Switching ' ++ 'to non-preemptive kernel.', target_kernel_core_pkg) ++ target_kernel_pkg_names = kernel_lib.get_target_kernel_pkg_names( ++ kernel_lib.KernelType.ORDINARY, ++ src_kernel_info.page_size, ++ arch ++ ) ++ target_kernel_core_pkg = target_kernel_pkg_names.core ++ target_kernel_nevra = get_target_kernel_package_nevra(target_kernel_core_pkg) + + if target_kernel_nevra: + boot_files = get_boot_files_provided_by_kernel_pkg(target_kernel_nevra) + target_kernel_version = kernel_lib.get_uname_r_provided_by_kernel_pkg(target_kernel_nevra) ++ if not target_kernel_version: ++ api.current_logger().warning('Failed to determine uname-r provided by %s.', target_kernel_nevra) ++ return + installed_kernel_info = InstalledTargetKernelInfo(pkg_nevra=target_kernel_nevra, + uname_r=target_kernel_version, + kernel_img_path=boot_files.vmlinuz_path, +diff --git a/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/tests/test_scaninstalledkernel_scaninstalledtargetkernelversion.py b/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/tests/test_scaninstalledkernel_scaninstalledtargetkernelversion.py +index 8f9f8b7a..ffe6e820 100644 +--- a/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/tests/test_scaninstalledkernel_scaninstalledtargetkernelversion.py ++++ b/repos/system_upgrade/common/actors/scaninstalledtargetkernelversion/tests/test_scaninstalledkernel_scaninstalledtargetkernelversion.py +@@ -4,6 +4,9 @@ from leapp.exceptions import StopActorExecutionError + from leapp.libraries import stdlib + from leapp.libraries.actor import scankernel + from leapp.libraries.common import kernel as kernel_lib ++from leapp.libraries.common.config import architecture ++from leapp.libraries.common.config.architecture import ARCH_ARM64, ARCH_PPC64LE, ARCH_X86_64 ++from leapp.libraries.common.kernel import KernelPageSize, KernelType + from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked + from leapp.libraries.stdlib import api + from leapp.models import InstalledTargetKernelInfo, InstalledTargetKernelVersion, KernelInfo, RPM +@@ -11,8 +14,14 @@ from leapp.utils.deprecation import suppress_deprecation + + TARGET_KERNEL_NEVRA = 'kernel-core-1.2.3-4.el9.x86_64' + TARGET_RT_KERNEL_NEVRA = 'kernel-rt-core-1.2.3-4.rt56.7.el9.x86_64' ++TARGET_64K_KERNEL_NEVRA = 'kernel-64k-core-1.2.3-4.el10.aarch64' ++TARGET_RT_64K_KERNEL_NEVRA = 'kernel-rt-64k-core-1.2.3-4.el10.aarch64' ++TARGET_PPC_KERNEL_NEVRA = 'kernel-core-1.2.3-4.el10.ppc64le' + OLD_KERNEL_NEVRA = 'kernel-core-0.1.2-3.el8.x86_64' + OLD_RT_KERNEL_NEVRA = 'kernel-rt-core-0.1.2-3.rt4.5.el8.x86_64' ++OLD_64K_KERNEL_NEVRA = 'kernel-64k-core-0.1.2-3.el9.aarch64' ++OLD_RT_64K_KERNEL_NEVRA = 'kernel-rt-64k-core-0.1.2-3.el9.aarch64' ++OLD_PPC_KERNEL_NEVRA = 'kernel-core-0.1.2-3.el9.ppc64le' + + + class MockedRun: +@@ -22,7 +31,7 @@ class MockedRun: + self._stdouts = stdouts + + def __call__(self, *args, **kwargs): +- for key in ('kernel-core', 'kernel-rt-core'): ++ for key in ('kernel-rt-64k-core', 'kernel-64k-core', 'kernel-rt-core', 'kernel-core'): + if key in args[0]: + return {'stdout': self._stdouts.get(key, [])} + return {'stdout': []} +@@ -30,7 +39,9 @@ class MockedRun: + + @suppress_deprecation(InstalledTargetKernelVersion) + def assert_produced_messages_are_correct(produced_messages, expected_target_nevra, initramfs_path, kernel_img_path): +- target_evra = expected_target_nevra.replace('kernel-core-', '').replace('kernel-rt-core-', '') ++ target_evra = expected_target_nevra ++ for prefix in ('kernel-rt-64k-core-', 'kernel-64k-core-', 'kernel-rt-core-', 'kernel-core-'): ++ target_evra = target_evra.replace(prefix, '') + installed_kernel_ver = [msg for msg in produced_messages if isinstance(msg, InstalledTargetKernelVersion)] + assert len(installed_kernel_ver) == 1, 'Actor should produce InstalledTargetKernelVersion (backwards compat.)' + assert installed_kernel_ver[0].version == target_evra +@@ -44,38 +55,112 @@ def assert_produced_messages_are_correct(produced_messages, expected_target_nevr + + + @pytest.mark.parametrize( +- ('is_rt', 'expected_target_nevra', 'stdouts'), ++ ('kernel_type', 'page_size', 'arch', 'expected_target_nevra', 'stdouts', 'dst_ver'), + [ +- (False, TARGET_KERNEL_NEVRA, {'kernel-core': [OLD_KERNEL_NEVRA, TARGET_KERNEL_NEVRA]}), +- (False, TARGET_KERNEL_NEVRA, {'kernel-core': [TARGET_KERNEL_NEVRA, OLD_KERNEL_NEVRA]}), +- (False, TARGET_KERNEL_NEVRA, { +- 'kernel-core': [TARGET_KERNEL_NEVRA, OLD_KERNEL_NEVRA], +- 'kernel-rt-core': [TARGET_RT_KERNEL_NEVRA, OLD_RT_KERNEL_NEVRA], +- }), +- (True, TARGET_RT_KERNEL_NEVRA, { +- 'kernel-rt-core': [OLD_RT_KERNEL_NEVRA, TARGET_RT_KERNEL_NEVRA] +- }), +- (True, TARGET_RT_KERNEL_NEVRA, { +- 'kernel-rt-core': [TARGET_RT_KERNEL_NEVRA, OLD_RT_KERNEL_NEVRA] +- }), +- (True, TARGET_RT_KERNEL_NEVRA, { +- 'kernel-core': [TARGET_KERNEL_NEVRA, OLD_KERNEL_NEVRA], +- 'kernel-rt-core': [TARGET_RT_KERNEL_NEVRA, OLD_RT_KERNEL_NEVRA], +- }), ++ ( ++ KernelType.ORDINARY, ++ KernelPageSize.PAGE_SIZE_4K, ++ ARCH_X86_64, ++ TARGET_KERNEL_NEVRA, ++ {'kernel-core': [OLD_KERNEL_NEVRA, TARGET_KERNEL_NEVRA]}, ++ '9.0', ++ ), ++ ( ++ KernelType.ORDINARY, ++ KernelPageSize.PAGE_SIZE_4K, ++ ARCH_X86_64, ++ TARGET_KERNEL_NEVRA, ++ {'kernel-core': [TARGET_KERNEL_NEVRA, OLD_KERNEL_NEVRA]}, ++ '9.0', ++ ), ++ ( ++ KernelType.ORDINARY, ++ KernelPageSize.PAGE_SIZE_4K, ++ ARCH_X86_64, ++ TARGET_KERNEL_NEVRA, ++ { ++ 'kernel-core': [TARGET_KERNEL_NEVRA, OLD_KERNEL_NEVRA], ++ 'kernel-rt-core': [TARGET_RT_KERNEL_NEVRA, OLD_RT_KERNEL_NEVRA], ++ }, ++ '9.0', ++ ), ++ ( ++ KernelType.REALTIME, ++ KernelPageSize.PAGE_SIZE_4K, ++ ARCH_X86_64, ++ TARGET_RT_KERNEL_NEVRA, ++ {'kernel-rt-core': [OLD_RT_KERNEL_NEVRA, TARGET_RT_KERNEL_NEVRA]}, ++ '9.0', ++ ), ++ ( ++ KernelType.REALTIME, ++ KernelPageSize.PAGE_SIZE_4K, ++ ARCH_X86_64, ++ TARGET_RT_KERNEL_NEVRA, ++ {'kernel-rt-core': [TARGET_RT_KERNEL_NEVRA, OLD_RT_KERNEL_NEVRA]}, ++ '9.0', ++ ), ++ ( ++ KernelType.REALTIME, ++ KernelPageSize.PAGE_SIZE_4K, ++ ARCH_X86_64, ++ TARGET_RT_KERNEL_NEVRA, ++ { ++ 'kernel-core': [TARGET_KERNEL_NEVRA, OLD_KERNEL_NEVRA], ++ 'kernel-rt-core': [TARGET_RT_KERNEL_NEVRA, OLD_RT_KERNEL_NEVRA], ++ }, ++ '9.0', ++ ), ++ # 64k kernel variants on aarch64 ++ ( ++ KernelType.ORDINARY, ++ KernelPageSize.PAGE_SIZE_64K, ++ ARCH_ARM64, ++ TARGET_64K_KERNEL_NEVRA, ++ {'kernel-64k-core': [OLD_64K_KERNEL_NEVRA, TARGET_64K_KERNEL_NEVRA]}, ++ '10.0', ++ ), ++ ( ++ KernelType.REALTIME, ++ KernelPageSize.PAGE_SIZE_64K, ++ ARCH_ARM64, ++ TARGET_RT_64K_KERNEL_NEVRA, ++ {'kernel-rt-64k-core': [OLD_RT_64K_KERNEL_NEVRA, TARGET_RT_64K_KERNEL_NEVRA]}, ++ '10.0', ++ ), ++ ( ++ KernelType.REALTIME, ++ KernelPageSize.PAGE_SIZE_64K, ++ ARCH_ARM64, ++ TARGET_RT_64K_KERNEL_NEVRA, ++ { ++ 'kernel-64k-core': [TARGET_64K_KERNEL_NEVRA, OLD_64K_KERNEL_NEVRA], ++ 'kernel-rt-64k-core': [TARGET_RT_64K_KERNEL_NEVRA, OLD_RT_64K_KERNEL_NEVRA], ++ }, ++ '10.0', ++ ), ++ # 64k page size on ppc64le: standard kernel packages (no kernel-64k RPMs) ++ ( ++ KernelType.ORDINARY, ++ KernelPageSize.PAGE_SIZE_64K, ++ ARCH_PPC64LE, ++ TARGET_PPC_KERNEL_NEVRA, ++ {'kernel-core': [OLD_PPC_KERNEL_NEVRA, TARGET_PPC_KERNEL_NEVRA]}, ++ '10.0', ++ ), + ] + ) +-def test_scaninstalledkernel(monkeypatch, is_rt, expected_target_nevra, stdouts): ++def test_scaninstalledkernel(monkeypatch, kernel_type, page_size, arch, expected_target_nevra, stdouts, dst_ver): + src_kernel_pkg = RPM(name='kernel-core', arch='x86_64', version='0.1.2', release='3', + epoch='0', packager='', pgpsig='SOME_OTHER_SIG_X') +- src_kernel_type = kernel_lib.KernelType.REALTIME if is_rt else kernel_lib.KernelType.ORDINARY +- src_kernel_info = KernelInfo(pkg=src_kernel_pkg, type=src_kernel_type, uname_r='X') ++ src_kernel_info = KernelInfo(pkg=src_kernel_pkg, type=kernel_type, page_size=page_size, uname_r='X') + + def patched_get_boot_files(nevra): + assert nevra == expected_target_nevra + return scankernel.KernelBootFiles(vmlinuz_path='/boot/vmlinuz-X', initramfs_path='/boot/initramfs-X') + + result = [] +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(dst_ver='9.0', msgs=[src_kernel_info])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch=arch, dst_ver=dst_ver, msgs=[src_kernel_info])) + monkeypatch.setattr(api, 'produce', result.append) + monkeypatch.setattr(scankernel, 'run', MockedRun(stdouts)) + monkeypatch.setattr(scankernel, 'get_boot_files_provided_by_kernel_pkg', patched_get_boot_files) +@@ -120,19 +205,43 @@ def test_get_boot_files_provided_by_kernel_pkg(monkeypatch, vmlinuz_path, initra + assert result.initramfs_path == initramfs_path + + +-def test_scaninstalledkernel_missing_rt(monkeypatch): ++@pytest.mark.parametrize( ++ ('page_size', 'arch', 'expected_fallback_nevra', 'stdouts', 'dst_ver'), ++ [ ++ ( ++ KernelPageSize.PAGE_SIZE_4K, ++ ARCH_X86_64, ++ TARGET_KERNEL_NEVRA, ++ { ++ 'kernel-core': [TARGET_KERNEL_NEVRA], ++ 'kernel-rt-core': [OLD_RT_KERNEL_NEVRA], ++ }, ++ '9.0', ++ ), ++ ( ++ KernelPageSize.PAGE_SIZE_64K, ++ ARCH_ARM64, ++ TARGET_64K_KERNEL_NEVRA, ++ { ++ 'kernel-64k-core': [TARGET_64K_KERNEL_NEVRA], ++ 'kernel-rt-64k-core': [OLD_RT_64K_KERNEL_NEVRA], ++ }, ++ '10.0', ++ ), ++ ] ++) ++def test_scaninstalledkernel_missing_rt(monkeypatch, page_size, arch, expected_fallback_nevra, stdouts, dst_ver): + src_kernel_pkg = RPM(name='kernel-rt-core', arch='x86_64', version='0.1.2', release='3', + epoch='0', packager='', pgpsig='SOME_OTHER_SIG_X') +- src_kernel_type = kernel_lib.KernelType.REALTIME +- src_kernel_info = KernelInfo(pkg=src_kernel_pkg, type=src_kernel_type, uname_r='X') ++ src_kernel_info = KernelInfo(pkg=src_kernel_pkg, type=KernelType.REALTIME, ++ page_size=page_size, uname_r='X') + + result = [] +- stdouts = {'kernel-core': [TARGET_KERNEL_NEVRA], 'kernel-rt-core': [OLD_RT_KERNEL_NEVRA]} + + def patched_get_boot_content(target_nevra): + return scankernel.KernelBootFiles(vmlinuz_path='/boot/vmlinuz-X', initramfs_path='/boot/initramfs-X') + +- monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(dst_ver='9.0', msgs=[src_kernel_info])) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch=arch, dst_ver=dst_ver, msgs=[src_kernel_info])) + monkeypatch.setattr(api, 'current_logger', logger_mocked()) + monkeypatch.setattr(api, 'produce', result.append) + monkeypatch.setattr(scankernel, 'run', MockedRun(stdouts)) +@@ -143,13 +252,13 @@ def test_scaninstalledkernel_missing_rt(monkeypatch): + + assert api.current_logger.warnmsg + +- assert_produced_messages_are_correct(result, TARGET_KERNEL_NEVRA, '/boot/initramfs-X', '/boot/vmlinuz-X') ++ assert_produced_messages_are_correct(result, expected_fallback_nevra, '/boot/initramfs-X', '/boot/vmlinuz-X') + + + def test_scaninstalledkernel_missing(monkeypatch): + src_kernel_pkg = RPM(name='kernel-rt-core', arch='x86_64', version='0.1.2', release='3', + epoch='0', packager='', pgpsig='SOME_OTHER_SIG_X') +- src_kernel_type = kernel_lib.KernelType.REALTIME ++ src_kernel_type = KernelType.REALTIME + src_kernel_info = KernelInfo(pkg=src_kernel_pkg, type=src_kernel_type, uname_r='X') + + result = [] +diff --git a/repos/system_upgrade/common/actors/scansourcekernel/libraries/scan_source_kernel.py b/repos/system_upgrade/common/actors/scansourcekernel/libraries/scan_source_kernel.py +index ec622234..38d957a7 100644 +--- a/repos/system_upgrade/common/actors/scansourcekernel/libraries/scan_source_kernel.py ++++ b/repos/system_upgrade/common/actors/scansourcekernel/libraries/scan_source_kernel.py +@@ -7,12 +7,14 @@ from leapp.libraries.stdlib import api + from leapp.models import DistributionSignedRPM, KernelInfo + + +-def scan_source_kernel(): ++def scan_source_kernel() -> None: ++ """Identify the booted kernel package and produce ``KernelInfo``.""" + uname_r = api.current_actor().configuration.kernel + installed_rpms = [msg.items for msg in api.consume(DistributionSignedRPM)] + installed_rpms = list(itertools.chain(*installed_rpms)) + + kernel_type = kernel_lib.determine_kernel_type_from_uname(get_source_version(), uname_r) ++ kernel_page_size = kernel_lib.determine_kernel_page_size() + kernel_pkg_info = kernel_lib.get_kernel_pkg_info_for_uname_r(uname_r) + + kernel_pkg_id = (kernel_pkg_info.name, kernel_pkg_info.version, kernel_pkg_info.release, kernel_pkg_info.arch) +@@ -26,5 +28,5 @@ def scan_source_kernel(): + if not kernel_pkg: + raise StopActorExecutionError(message='Unable to identify package providing the booted kernel.') + +- kernel_info = KernelInfo(pkg=kernel_pkg, type=kernel_type, uname_r=uname_r) ++ kernel_info = KernelInfo(pkg=kernel_pkg, type=kernel_type, uname_r=uname_r, page_size=kernel_page_size) + api.produce(kernel_info) +diff --git a/repos/system_upgrade/common/libraries/kernel.py b/repos/system_upgrade/common/libraries/kernel.py +index 0a160e7f..469ea112 100644 +--- a/repos/system_upgrade/common/libraries/kernel.py ++++ b/repos/system_upgrade/common/libraries/kernel.py +@@ -1,10 +1,19 @@ + from collections import namedtuple + + from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common import mounting ++from leapp.libraries.common.config import architecture as arch_config + from leapp.libraries.common.config.version import matches_version + from leapp.libraries.stdlib import api, CalledProcessError, run + + KernelPkgInfo = namedtuple('KernelPkgInfo', ('name', 'version', 'release', 'arch', 'nevra')) ++KernelPkgNames = namedtuple('KernelPkgNames', ('base', 'core', 'modules')) ++ ++ ++class KernelPackageInfoError(StopActorExecutionError): ++ """ ++ Raised when kernel package information cannot be determined. ++ """ + + + KERNEL_UNAME_R_PROVIDES = ['kernel-uname-r', 'kernel-rt-uname-r'] +@@ -15,18 +24,32 @@ class KernelType: + REALTIME = 'realtime' + + ++class KernelPageSize: ++ PAGE_SIZE_4K = '4k' ++ PAGE_SIZE_64K = '64k' ++ ++ ++def _normalize_rt64k_suffix(uname_r): ++ # TODO(pmocary): Remove when the kernel-rt-64k RPM provides match the actual uname -r output. ++ return uname_r.replace('+rt_64k', '+rt-64k') ++ ++ + # TODO: rename rhel_version to something distro agnostic in the new function + # when this is deprecated +-def determine_kernel_type_from_uname(rhel_version, kernel_uname_r): ++def determine_kernel_type_from_uname(rhel_version: str, kernel_uname_r: str) -> str: + """ + Determine kernel type from given kernel release (uname-r). + +- :param str rhel_version: Version of RHEL for which the kernel with the uname-r is targeted. +- :param str kernel_uname_r: Kernel release (uname-r) +- :returns: Kernel type based on a given uname_r +- :rtype: KernelType ++ :param rhel_version: Version of RHEL for which the kernel with the uname-r is targeted. ++ :type rhel_version: str ++ :param kernel_uname_r: Kernel release (uname-r) ++ :type kernel_uname_r: str ++ :returns: Kernel type based on a given uname_r (values come exclusively from the KernelType class) ++ :rtype: str + """ + if matches_version(['<= 9.2'], rhel_version): ++ # NOTE: 64k page size kernel was introduced in 9.2 for aarch64 only. However, ++ # the 64k realtime kernel was introduced in 9.6, thus no special infix exists. + uname_r_infixes = { + '.rt': KernelType.REALTIME + } +@@ -35,9 +58,11 @@ def determine_kernel_type_from_uname(rhel_version, kernel_uname_r): + return kernel_type + else: + uname_r_suffixes = { +- '+rt': KernelType.REALTIME ++ '+rt': KernelType.REALTIME, ++ '+rt-64k': KernelType.REALTIME, + } + ++ kernel_uname_r = _normalize_rt64k_suffix(kernel_uname_r) + for suffix, kernel_type in uname_r_suffixes.items(): + if kernel_uname_r.endswith(suffix): + return kernel_type +@@ -45,20 +70,45 @@ def determine_kernel_type_from_uname(rhel_version, kernel_uname_r): + return KernelType.ORDINARY + + +-def get_uname_r_provided_by_kernel_pkg(kernel_pkg_nevra): ++def determine_kernel_page_size() -> str: ++ """ ++ Determine kernel page size for the running kernel using ``getconf PAGE_SIZE``. ++ ++ :returns: Kernel page size (values come exclusively from the KernelPageSize class) ++ :rtype: str ++ """ ++ try: ++ result = run(['getconf', 'PAGE_SIZE'])['stdout'].strip() ++ if result == '65536': ++ return KernelPageSize.PAGE_SIZE_64K ++ except CalledProcessError: ++ api.current_logger().warning('Failed to determine kernel page size via getconf, defaulting to 4k.') ++ return KernelPageSize.PAGE_SIZE_4K ++ ++ ++def get_uname_r_provided_by_kernel_pkg(kernel_pkg_nevra: str, context: mounting.IsolatedActions = None) -> str: + """ + Get kernel release (uname-r) provided by a given kernel package. + +- Calls the ``rpm`` command internally and might raise CalledProcessError if the rpm query fails. ++ Calls the ``rpm`` command internally. Returns an empty string if the rpm query fails or no ++ matching provide is found. + +- :param str kernel_pkg_nevra: NEVRA of an installed kernel package +- :returns: uname-r provided by the given package ++ :param kernel_pkg_nevra: NEVRA of an installed kernel package ++ :type kernel_pkg_nevra: str ++ :param context: An isolation context for running commands (e.g. inside a target userspace container). ++ If ``None``, commands run on the host. ++ :type context: mounting.IsolatedActions or None ++ :returns: uname-r provided by the given package, or empty string on failure + :rtype: str + """ +- provides = run(['rpm', '-q', '--provides', kernel_pkg_nevra], +- split=True, +- callback_raw=lambda fd, value: None, +- callback_linebuffered=lambda fd, value: None)['stdout'] ++ context = context or mounting.NotIsolatedActions(base_dir='/') ++ try: ++ provides = context.call(['rpm', '-q', '--provides', kernel_pkg_nevra], ++ split=True, ++ callback_raw=lambda fd, value: None, ++ callback_linebuffered=lambda fd, value: None)['stdout'] ++ except CalledProcessError: ++ return '' + for provide_line in provides: + if '=' not in provide_line: + continue +@@ -69,30 +119,36 @@ def get_uname_r_provided_by_kernel_pkg(kernel_pkg_nevra): + return '' + + +-def get_kernel_pkg_info(kernel_pkg_nevra): ++def get_kernel_pkg_info(kernel_pkg_nevra: str) -> KernelPkgInfo: + """ + Query the RPM database for information about the given kernel package. + +- Calls the ``rpm`` command internally and might raise CalledProcessError if the rpm query fails. +- +- :param str kernel_pkg_nevra: NEVRA of an installed kernel package ++ :param kernel_pkg_nevra: NEVRA of an installed kernel package ++ :type kernel_pkg_nevra: str + :returns: Information about the given kernel package + :rtype: KernelPkgInfo ++ :raises KernelPackageInfoError: If the rpm query fails + """ + query_format = '%{NAME}|%{VERSION}|%{RELEASE}|%{ARCH}|' +- pkg_info = run(['rpm', '-q', '--queryformat', query_format, kernel_pkg_nevra])['stdout'].strip().split('|') ++ try: ++ pkg_info = run(['rpm', '-q', '--queryformat', query_format, kernel_pkg_nevra])['stdout'].strip().split('|') ++ except CalledProcessError as err: ++ raise KernelPackageInfoError( ++ message='Unable to obtain kernel package information.', ++ details={'Problem': 'Failed to query RPM info for {}: {}'.format(kernel_pkg_nevra, err)}) + return KernelPkgInfo(name=pkg_info[0], version=pkg_info[1], release=pkg_info[2], arch=pkg_info[3], + nevra=kernel_pkg_nevra) + + +-def get_kernel_pkg_info_for_uname_r(uname_r): ++def get_kernel_pkg_info_for_uname_r(uname_r: str) -> KernelPkgInfo: + """ + Identify the kernel package providing a kernel with the given kernel release (uname-r). + +- Raises ``StopActorExecutionError`` if no package provides given uname_r or if the internal rpm query fails. +- :param str uname_r: NEVRA of an installed kernel package ++ :param uname_r: Kernel release (uname-r) ++ :type uname_r: str + :returns: Information about the kernel package providing given uname_r + :rtype: KernelPkgInfo ++ :raises KernelPackageInfoError: If no package provides given uname_r or if the internal rpm query fails + """ + kernel_pkg_nevras = [] + for kernel_uname_r_provide in KERNEL_UNAME_R_PROVIDES: +@@ -103,12 +159,40 @@ def get_kernel_pkg_info_for_uname_r(uname_r): + + kernel_pkg_nevras = set(kernel_pkg_nevras) + ++ uname_r = _normalize_rt64k_suffix(uname_r) + for kernel_pkg_nevra in kernel_pkg_nevras: + provided_uname = get_uname_r_provided_by_kernel_pkg(kernel_pkg_nevra) # We know all packages provide a uname + if not provided_uname: + api.current_logger().warning('Failed to obtain uname-r provided by %s', kernel_pkg_nevra) ++ continue ++ provided_uname = _normalize_rt64k_suffix(provided_uname) + if provided_uname == uname_r: + return get_kernel_pkg_info(kernel_pkg_nevra) + +- raise StopActorExecutionError(message='Unable to obtain kernel information of the booted kernel: no package is ' +- 'providing the booted kernel release returned by uname.') ++ raise KernelPackageInfoError(message='Unable to obtain kernel information of the booted kernel: no package is ' ++ 'providing the booted kernel release returned by uname.') ++ ++ ++def get_target_kernel_pkg_names(kernel_type: str, kernel_page_size: str, arch: str) -> KernelPkgNames: ++ """ ++ Get the base, ``-core``, and ``-modules`` package names for the given kernel variant. ++ ++ The ``kernel-64k`` RPM variant only exists on aarch64. On all other architectures the standard ++ kernel packages are used regardless of page size. ++ ++ :param kernel_type: Type of the kernel ++ :type kernel_type: str ++ :param kernel_page_size: Page size of the kernel ++ :type kernel_page_size: str ++ :param arch: System architecture (e.g. ``x86_64``, ``aarch64``, ``ppc64le``) ++ :type arch: str ++ :returns: Kernel package names ++ :rtype: KernelPkgNames ++ """ ++ parts = ['kernel'] ++ if kernel_type == KernelType.REALTIME: ++ parts.append('rt') ++ if kernel_page_size == KernelPageSize.PAGE_SIZE_64K and arch == arch_config.ARCH_ARM64: ++ parts.append('64k') ++ base = '-'.join(parts) ++ return KernelPkgNames(base=base, core='{}-core'.format(base), modules='{}-modules'.format(base)) +diff --git a/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py b/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py +index 1687d0dc..c70be5af 100644 +--- a/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py ++++ b/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py +@@ -2,11 +2,10 @@ import functools + + import pytest + +-from leapp.exceptions import StopActorExecutionError + from leapp.libraries.common import kernel as kernel_lib +-from leapp.libraries.common.kernel import KernelType +-from leapp.libraries.common.testutils import CurrentActorMocked +-from leapp.libraries.stdlib import api ++from leapp.libraries.common.kernel import KernelPageSize, KernelType ++from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked ++from leapp.libraries.stdlib import api, CalledProcessError + + + @pytest.mark.parametrize( +@@ -21,6 +20,9 @@ from leapp.libraries.stdlib import api + (('9.2', None), '5.14.0-284.11.1.rt14.296.el9_2.x86_64', KernelType.REALTIME), + (('9.3', None), '5.14.0-354.el9.x86_64', KernelType.ORDINARY), + (('9.3', None), '5.14.0-354.el9.x86_64+rt', KernelType.REALTIME), ++ (('9.3', None), '5.14.0-354.el9.aarch64+64k', KernelType.ORDINARY), ++ (('9.3', None), '5.14.0-354.el9.aarch64+rt-64k', KernelType.REALTIME), ++ (('9.3', None), '5.14.0-354.el9.aarch64+rt_64k', KernelType.REALTIME), + # centos + (('8', '8.7'), '4.18.0-425.3.1.el8.x86_64', KernelType.ORDINARY), + (('8', '8.7'), '4.18.0-425.3.1.rt7.213.el8.x86_64', KernelType.REALTIME), +@@ -28,6 +30,9 @@ from leapp.libraries.stdlib import api + (('9', '9.2'), '5.14.0-284.11.1.rt14.296.el9_2.x86_64', KernelType.REALTIME), + (('9', '9.3'), '5.14.0-354.el9.x86_64', KernelType.ORDINARY), + (('9', '9.3'), '5.14.0-354.el9.x86_64+rt', KernelType.REALTIME), ++ (('9', '9.3'), '5.14.0-354.el9.aarch64+64k', KernelType.ORDINARY), ++ (('9', '9.3'), '5.14.0-354.el9.aarch64+rt-64k', KernelType.REALTIME), ++ (('9', '9.3'), '5.14.0-354.el9.aarch64+rt_64k', KernelType.REALTIME), + ) + ) + def test_determine_kernel_type_from_uname(monkeypatch, version, uname_r, expected_kernel_type): +@@ -36,9 +41,9 @@ def test_determine_kernel_type_from_uname(monkeypatch, version, uname_r, expecte + actor_mock = CurrentActorMocked( + release_id='centos' if '.' not in real_ver else 'rhel', + src_ver=real_ver, +- dst_ver="irrelevant", ++ dst_ver='irrelevant', + virtual_source_version=virtual_ver or real_ver, +- virtual_target_version="irrelevant", ++ virtual_target_version='irrelevant', + ) + monkeypatch.setattr(api, 'current_actor', actor_mock) + +@@ -46,42 +51,104 @@ def test_determine_kernel_type_from_uname(monkeypatch, version, uname_r, expecte + assert kernel_type == expected_kernel_type + + +-def test_get_uname_r_provided_by_kernel_pkg(monkeypatch): ++@pytest.mark.parametrize( ++ ('getconf_output', 'expected_page_size'), ++ ( ++ ('4096', KernelPageSize.PAGE_SIZE_4K), ++ ('65536', KernelPageSize.PAGE_SIZE_64K), ++ ) ++) ++def test_determine_kernel_page_size(monkeypatch, getconf_output, expected_page_size): ++ def run_mocked(cmd, *args, **kwargs): ++ assert cmd == ['getconf', 'PAGE_SIZE'] ++ return {'stdout': getconf_output} ++ ++ monkeypatch.setattr(kernel_lib, 'run', run_mocked) ++ page_size = kernel_lib.determine_kernel_page_size() ++ assert page_size == expected_page_size ++ ++ ++def test_determine_kernel_page_size_getconf_failure(monkeypatch): ++ def run_mocked(cmd, *args, **kwargs): ++ raise CalledProcessError(message='Command getconf PAGE_SIZE failed with exit code 1.', command=cmd, ++ result={'exit_code': 1, 'stdout': '', 'stderr': ''}) ++ ++ monkeypatch.setattr(kernel_lib, 'run', run_mocked) ++ monkeypatch.setattr(api, 'current_logger', logger_mocked()) ++ page_size = kernel_lib.determine_kernel_page_size() ++ assert page_size == KernelPageSize.PAGE_SIZE_4K ++ assert api.current_logger.warnmsg ++ ++ ++def _mock_context_for_provides(kernel_nevra, provides_lines): ++ class _MockContext: ++ def call(self, cmd, *args, **kwargs): ++ assert cmd == ['rpm', '-q', '--provides', kernel_nevra] ++ return {'stdout': provides_lines} ++ return _MockContext() ++ ++ ++def test_get_uname_r_provided_by_kernel_pkg(): ++ kernel_nevra = 'kernel-core-5.14.0-354.el9.x86_64' ++ provides_lines = [ ++ 'kmod(virtio_ring.ko)', ++ 'kernel(zlib_inflate_blob) = 0x65408378', ++ 'kernel-uname-r = 5.14.0-354.el9.x86_64' ++ ] ++ ++ uname_r = kernel_lib.get_uname_r_provided_by_kernel_pkg( ++ kernel_nevra, context=_mock_context_for_provides(kernel_nevra, provides_lines) ++ ) ++ assert uname_r == '5.14.0-354.el9.x86_64' ++ ++ ++def test_get_uname_r_provided_by_kernel_pkg_default_context(monkeypatch): + kernel_nevra = 'kernel-core-5.14.0-354.el9.x86_64' + + def run_mocked(cmd, *args, **kwargs): + assert cmd == ['rpm', '-q', '--provides', kernel_nevra] +- output_lines = [ +- 'kmod(virtio_ring.ko)', +- 'kernel(zlib_inflate_blob) = 0x65408378', ++ return {'stdout': [ + 'kernel-uname-r = 5.14.0-354.el9.x86_64' +- ] +- return {'stdout': output_lines} ++ ]} + +- monkeypatch.setattr(kernel_lib, 'run', run_mocked) ++ monkeypatch.setattr(kernel_lib.mounting, 'run', run_mocked) + + uname_r = kernel_lib.get_uname_r_provided_by_kernel_pkg(kernel_nevra) + assert uname_r == '5.14.0-354.el9.x86_64' + + +-@pytest.mark.parametrize('kernel_pkg_with_uname_installed', (True, False)) +-def test_get_kernel_pkg_info_for_uname_r(monkeypatch, kernel_pkg_with_uname_installed): +- uname_r = '5.14.0-354.el9.x86_64' if kernel_pkg_with_uname_installed else 'other-uname' +- ++@pytest.mark.parametrize( ++ ('uname_r', 'expected_nevra'), ++ ( ++ ('5.14.0-354.el9.x86_64', 'kernel-core-5.14.0-354.el9.x86_64.rpm'), ++ ('5.14.0-354.el9.x86_64+rt', 'kernel-rt-core-5.14.0-354.el9.x86_64.rpm'), ++ ('5.14.0-687.el9.aarch64+64k', 'kernel-64k-core-5.14.0-687.el9.aarch64.rpm'), ++ # RPM provides +rt_64k (underscore), uname -r reports +rt-64k (hyphen) ++ ('5.14.0-687.el9.aarch64+rt-64k', 'kernel-rt-64k-core-5.14.0-687.el9.aarch64.rpm'), ++ # Reversed: uname -r reports +rt_64k (underscore) ++ ('5.14.0-687.el9.aarch64+rt_64k', 'kernel-rt-64k-core-5.14.0-687.el9.aarch64.rpm'), ++ ('unknown', None), ++ ) ++) ++def test_get_kernel_pkg_info_for_uname_r(monkeypatch, uname_r, expected_nevra): + def run_mocked(cmd, *args, **kwargs): + assert cmd[0:3] == ['rpm', '-q', '--whatprovides'] + output_lines = [ + 'kernel-core-5.14.0-354.el9.x86_64.rpm', + 'kernel-rt-core-5.14.0-354.el9.x86_64.rpm', ++ 'kernel-64k-core-5.14.0-687.el9.aarch64.rpm', ++ 'kernel-rt-64k-core-5.14.0-687.el9.aarch64.rpm', + ] + return {'stdout': output_lines} + + def get_uname_provided_by_pkg_mocked(pkg_nevra): + nevra_uname_table = { + 'kernel-core-5.14.0-354.el9.x86_64.rpm': '5.14.0-354.el9.x86_64', +- 'kernel-rt-core-5.14.0-354.el9.x86_64.rpm': '5.14.0-354.el9.x86_64+rt' ++ 'kernel-rt-core-5.14.0-354.el9.x86_64.rpm': '5.14.0-354.el9.x86_64+rt', ++ 'kernel-64k-core-5.14.0-687.el9.aarch64.rpm': '5.14.0-687.el9.aarch64+64k', ++ 'kernel-rt-64k-core-5.14.0-687.el9.aarch64.rpm': '5.14.0-687.el9.aarch64+rt_64k', + } +- return nevra_uname_table[pkg_nevra] # Will raise if a different nevra is used than ones from run_mocked ++ return nevra_uname_table[pkg_nevra] + + monkeypatch.setattr(kernel_lib, 'run', run_mocked) + monkeypatch.setattr(kernel_lib, 'get_uname_r_provided_by_kernel_pkg', get_uname_provided_by_pkg_mocked) +@@ -91,9 +158,39 @@ def test_get_kernel_pkg_info_for_uname_r(monkeypatch, kernel_pkg_with_uname_inst + 'get_kernel_pkg_info', + lambda dummy_nevra: mk_pkg_info(nevra=dummy_nevra)) + +- if kernel_pkg_with_uname_installed: ++ if expected_nevra: + pkg_info = kernel_lib.get_kernel_pkg_info_for_uname_r(uname_r) +- assert pkg_info == mk_pkg_info(nevra='kernel-core-5.14.0-354.el9.x86_64.rpm') ++ assert pkg_info == mk_pkg_info(nevra=expected_nevra) + else: +- with pytest.raises(StopActorExecutionError): +- pkg_info = kernel_lib.get_kernel_pkg_info_for_uname_r(uname_r) ++ with pytest.raises(kernel_lib.KernelPackageInfoError): ++ kernel_lib.get_kernel_pkg_info_for_uname_r(uname_r) ++ ++ ++@pytest.mark.parametrize( ++ ('kernel_type', 'page_size', 'arch', 'expected'), ++ ( ++ # 4k page size: standard packages on all architectures ++ (KernelType.ORDINARY, KernelPageSize.PAGE_SIZE_4K, 'x86_64', ++ ('kernel', 'kernel-core', 'kernel-modules')), ++ (KernelType.ORDINARY, KernelPageSize.PAGE_SIZE_4K, 'aarch64', ++ ('kernel', 'kernel-core', 'kernel-modules')), ++ (KernelType.ORDINARY, KernelPageSize.PAGE_SIZE_4K, 'ppc64le', ++ ('kernel', 'kernel-core', 'kernel-modules')), ++ (KernelType.REALTIME, KernelPageSize.PAGE_SIZE_4K, 'x86_64', ++ ('kernel-rt', 'kernel-rt-core', 'kernel-rt-modules')), ++ # 64k page size on aarch64: 64k variant packages ++ (KernelType.ORDINARY, KernelPageSize.PAGE_SIZE_64K, 'aarch64', ++ ('kernel-64k', 'kernel-64k-core', 'kernel-64k-modules')), ++ (KernelType.REALTIME, KernelPageSize.PAGE_SIZE_64K, 'aarch64', ++ ('kernel-rt-64k', 'kernel-rt-64k-core', 'kernel-rt-64k-modules')), ++ # 64k page size on non-aarch64: standard packages (no kernel-64k RPMs exist) ++ (KernelType.ORDINARY, KernelPageSize.PAGE_SIZE_64K, 'ppc64le', ++ ('kernel', 'kernel-core', 'kernel-modules')), ++ (KernelType.ORDINARY, KernelPageSize.PAGE_SIZE_64K, 'x86_64', ++ ('kernel', 'kernel-core', 'kernel-modules')), ++ (KernelType.ORDINARY, KernelPageSize.PAGE_SIZE_64K, 's390x', ++ ('kernel', 'kernel-core', 'kernel-modules')), ++ ) ++) ++def test_get_target_kernel_pkg_names(kernel_type, page_size, arch, expected): ++ assert kernel_lib.get_target_kernel_pkg_names(kernel_type, page_size, arch) == expected +diff --git a/repos/system_upgrade/common/models/installedkernelversion.py b/repos/system_upgrade/common/models/installedkernelversion.py +index cd1f08fe..6f582c68 100644 +--- a/repos/system_upgrade/common/models/installedkernelversion.py ++++ b/repos/system_upgrade/common/models/installedkernelversion.py +@@ -1,7 +1,12 @@ + from leapp.models import fields, Model + from leapp.topics import SystemInfoTopic ++from leapp.utils.deprecation import deprecated + + ++@deprecated( ++ since='2026-06-04', ++ message='This model has been deprecated! Use KernelInfo model for information about source distribution kernel.' ++) + class CurrentKernel(Model): + topic = SystemInfoTopic + version = fields.String() +diff --git a/repos/system_upgrade/common/models/installedtargetkernelversion.py b/repos/system_upgrade/common/models/installedtargetkernelversion.py +index 09df5509..89ada935 100644 +--- a/repos/system_upgrade/common/models/installedtargetkernelversion.py ++++ b/repos/system_upgrade/common/models/installedtargetkernelversion.py +@@ -18,18 +18,28 @@ class KernelInfo(Model): + """ + Information about the booted kernel. + """ ++ + topic = SystemInfoTopic + + pkg = fields.Model(RPM) +- """ Package providing the booted kernel. """ ++ """ ++ Package providing the booted kernel. ++ """ + + uname_r = fields.String() +- """``uname -r`` of the booted kernel.""" ++ """ ++ ``uname -r`` of the booted kernel. ++ """ + + type = fields.StringEnum(['ordinary', 'realtime'], default='ordinary') + # @FixMe(mhecko): I want to use kernel_lib.KernelType here, but I cannot import any library code (yet). + # # Figure out how to do it. + ++ page_size = fields.StringEnum(['4k', '64k'], default='4k') ++ """ ++ Memory page size of the kernel. ++ """ ++ + + class InstalledTargetKernelInfo(Model): + """Information about the installed target kernel.""" +-- +2.54.0 + diff --git a/0107-rework-kernel-package-identification-to-use-lib-modu.patch b/0107-rework-kernel-package-identification-to-use-lib-modu.patch new file mode 100644 index 0000000..19a7557 --- /dev/null +++ b/0107-rework-kernel-package-identification-to-use-lib-modu.patch @@ -0,0 +1,363 @@ +From d9214d7e5623efa8a5f6b8eaa8dafae2e5322c97 Mon Sep 17 00:00:00 2001 +From: Peter Mocary +Date: Sun, 21 Jun 2026 12:22:06 +0000 +Subject: [PATCH 107/108] rework kernel package identification to use + /lib/modules paths + +The ``kernel-uname-r`` rpm provide uses a different suffix format than +the actual ``uname -r`` output for RT kernels with 64k page size: +``uname -r`` returns ``+rt-64k`` while the provide has ``+rt_64k``. +This mismatch causes the existing provide-based lookup to fail to +identify the running kernel's package and resulted in a normalization +fix that mitigates it. + +This patch switches a different file-based approach which is more +reliable: +- ``get_kernel_pkg_info_for_uname_r()`` now queries + ``rpm --whatprovides /lib/modules/`` to directly find the + owning packages, then selects the ``-core`` package from the results +- ``get_uname_r_provided_by_kernel_pkg()`` now extracts the version + from the package's ``/lib/modules//`` file listing instead + of parsing provides, and accepts an optional context parameter for + in-container queries +- Deprecated ``KERNEL_UNAME_R_PROVIDES`` as it is no longer used +- Removed ``_normalize_rt64k_suffix`` as it is no longer needed + +Jira: RHEL-111860 +--- + .../libraries-and-api/deprecations-list.md | 1 + + .../unit_test_upgradeinitramfsgenerator.py | 10 +-- + .../system_upgrade/common/libraries/kernel.py | 69 +++++++------- + .../common/libraries/tests/test_kernel_lib.py | 90 +++++++++---------- + 4 files changed, 81 insertions(+), 89 deletions(-) + +diff --git a/docs/source/libraries-and-api/deprecations-list.md b/docs/source/libraries-and-api/deprecations-list.md +index 8cb89882..dda3f155 100644 +--- a/docs/source/libraries-and-api/deprecations-list.md ++++ b/docs/source/libraries-and-api/deprecations-list.md +@@ -21,6 +21,7 @@ Only the versions in which a deprecation has been made are listed. + - **`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. + - **`RepositoriesBlacklisted`** - To get the list of blocklisted repositories, consume `RepositoriesBlocklisted` message. To request a repository to be blocklisted, use `RepositoriesSetupTasks.to_block` instead. + - Shared libraries ++ - **`leapp.libraries.common.kernel.KERNEL_UNAME_R_PROVIDES`** - The constant is no longer used. + - **`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. +diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py +index 62f6bb34..0e92e5b6 100644 +--- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py ++++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py +@@ -600,8 +600,8 @@ def test_get_target_kernel_version_page_size(monkeypatch, page_size, arch, expec + queried_cmds.append(cmd) + if cmd[:2] == ['rpm', '-qa']: + return {'stdout': [target_nevra]} +- if cmd[:3] == ['rpm', '-q', '--provides']: +- return {'stdout': ['kernel-uname-r = {}'.format(expected_uname_r)]} ++ if cmd[:3] == ['rpm', '-q', '-l']: ++ return {'stdout': ['/lib/modules/{}/vmlinuz'.format(expected_uname_r)]} + return {'stdout': []} + + context.call = mock_call +@@ -609,7 +609,7 @@ def test_get_target_kernel_version_page_size(monkeypatch, page_size, arch, expec + version = upgradeinitramfsgenerator._get_target_kernel_version(context) + assert version == expected_uname_r + assert queried_cmds[0] == ['rpm', '-qa', expected_pkg] +- assert queried_cmds[1] == ['rpm', '-q', '--provides', target_nevra] ++ assert queried_cmds[1] == ['rpm', '-q', '-l', target_nevra] + + + def test_get_target_kernel_version_no_kernel_info(monkeypatch): +@@ -644,8 +644,8 @@ def test_get_target_kernel_version_empty_uname_r(monkeypatch): + def mock_call(cmd, *args, **kwargs): + if cmd[:2] == ['rpm', '-qa']: + return {'stdout': ['kernel-core-5.14.0-100.el9.x86_64']} +- if cmd[:3] == ['rpm', '-q', '--provides']: +- return {'stdout': ['some-other-provide = foo']} ++ if cmd[:3] == ['rpm', '-q', '-l']: ++ return {'stdout': ['/boot/vmlinuz-5.14.0-100.el9.x86_64']} + return {'stdout': []} + + context.call = mock_call +diff --git a/repos/system_upgrade/common/libraries/kernel.py b/repos/system_upgrade/common/libraries/kernel.py +index 469ea112..054783c2 100644 +--- a/repos/system_upgrade/common/libraries/kernel.py ++++ b/repos/system_upgrade/common/libraries/kernel.py +@@ -16,6 +16,7 @@ class KernelPackageInfoError(StopActorExecutionError): + """ + + ++# Deprecated: KERNEL_UNAME_R_PROVIDES is no longer used. + KERNEL_UNAME_R_PROVIDES = ['kernel-uname-r', 'kernel-rt-uname-r'] + + +@@ -29,11 +30,6 @@ class KernelPageSize: + PAGE_SIZE_64K = '64k' + + +-def _normalize_rt64k_suffix(uname_r): +- # TODO(pmocary): Remove when the kernel-rt-64k RPM provides match the actual uname -r output. +- return uname_r.replace('+rt_64k', '+rt-64k') +- +- + # TODO: rename rhel_version to something distro agnostic in the new function + # when this is deprecated + def determine_kernel_type_from_uname(rhel_version: str, kernel_uname_r: str) -> str: +@@ -62,7 +58,6 @@ def determine_kernel_type_from_uname(rhel_version: str, kernel_uname_r: str) -> + '+rt-64k': KernelType.REALTIME, + } + +- kernel_uname_r = _normalize_rt64k_suffix(kernel_uname_r) + for suffix, kernel_type in uname_r_suffixes.items(): + if kernel_uname_r.endswith(suffix): + return kernel_type +@@ -90,8 +85,8 @@ def get_uname_r_provided_by_kernel_pkg(kernel_pkg_nevra: str, context: mounting. + """ + Get kernel release (uname-r) provided by a given kernel package. + +- Calls the ``rpm`` command internally. Returns an empty string if the rpm query fails or no +- matching provide is found. ++ Extracts the kernel version from the package's ``/lib/modules/`` file paths. ++ Returns an empty string if the rpm query fails or no matching path is found. + + :param kernel_pkg_nevra: NEVRA of an installed kernel package + :type kernel_pkg_nevra: str +@@ -103,19 +98,20 @@ def get_uname_r_provided_by_kernel_pkg(kernel_pkg_nevra: str, context: mounting. + """ + context = context or mounting.NotIsolatedActions(base_dir='/') + try: +- provides = context.call(['rpm', '-q', '--provides', kernel_pkg_nevra], +- split=True, +- callback_raw=lambda fd, value: None, +- callback_linebuffered=lambda fd, value: None)['stdout'] ++ file_list = context.call(['rpm', '-q', '-l', kernel_pkg_nevra], ++ split=True, ++ callback_raw=lambda fd, value: None, ++ callback_linebuffered=lambda fd, value: None)['stdout'] + except CalledProcessError: + return '' +- for provide_line in provides: +- if '=' not in provide_line: ++ modules_prefix = '/lib/modules/' ++ for file_path in file_list: ++ if not file_path.startswith(modules_prefix): + continue +- provide, value = provide_line.split('=', 1) +- provide = provide.strip() +- if provide in KERNEL_UNAME_R_PROVIDES: +- return value.strip() ++ # Strip the /lib/modules/ prefix and take the first path component (the kernel version) ++ version = file_path[len(modules_prefix):].split('/', 1)[0] ++ if version: ++ return version + return '' + + +@@ -144,33 +140,34 @@ def get_kernel_pkg_info_for_uname_r(uname_r: str) -> KernelPkgInfo: + """ + Identify the kernel package providing a kernel with the given kernel release (uname-r). + ++ Queries RPM for packages owning ``/lib/modules/`` and returns info about the ``-core`` ++ kernel package among the results. ++ + :param uname_r: Kernel release (uname-r) + :type uname_r: str + :returns: Information about the kernel package providing given uname_r + :rtype: KernelPkgInfo + :raises KernelPackageInfoError: If no package provides given uname_r or if the internal rpm query fails + """ +- kernel_pkg_nevras = [] +- for kernel_uname_r_provide in KERNEL_UNAME_R_PROVIDES: +- try: +- kernel_pkg_nevras += run(['rpm', '-q', '--whatprovides', kernel_uname_r_provide], split=True)['stdout'] +- except CalledProcessError: # There is nothing providing a particular provide, e.g, kernel-rt-uname-r +- continue # Nothing bad happened, continue ++ try: ++ pkg_nevras = run(['rpm', '-q', '--whatprovides', '/lib/modules/{}'.format(uname_r)], ++ split=True)['stdout'] ++ except CalledProcessError as err: ++ raise KernelPackageInfoError( ++ message='Unable to obtain kernel information of the booted kernel.', ++ details={'Problem': 'No package owns /lib/modules/{}: {}'.format(uname_r, err)}) + +- kernel_pkg_nevras = set(kernel_pkg_nevras) +- +- uname_r = _normalize_rt64k_suffix(uname_r) +- for kernel_pkg_nevra in kernel_pkg_nevras: +- provided_uname = get_uname_r_provided_by_kernel_pkg(kernel_pkg_nevra) # We know all packages provide a uname +- if not provided_uname: +- api.current_logger().warning('Failed to obtain uname-r provided by %s', kernel_pkg_nevra) ++ for pkg_nevra in pkg_nevras: ++ # Skip packages that are clearly not kernel core packages to avoid unnecessary rpm queries ++ if '-core' not in pkg_nevra or '-modules' in pkg_nevra: + continue +- provided_uname = _normalize_rt64k_suffix(provided_uname) +- if provided_uname == uname_r: +- return get_kernel_pkg_info(kernel_pkg_nevra) ++ pkg_info = get_kernel_pkg_info(pkg_nevra) ++ if pkg_info.name.endswith('-core'): ++ return pkg_info + +- raise KernelPackageInfoError(message='Unable to obtain kernel information of the booted kernel: no package is ' +- 'providing the booted kernel release returned by uname.') ++ raise KernelPackageInfoError( ++ message='Unable to obtain kernel information of the booted kernel.', ++ details={'Problem': 'No installed package owning /lib/modules/{} is a kernel core package.'.format(uname_r)}) + + + def get_target_kernel_pkg_names(kernel_type: str, kernel_page_size: str, arch: str) -> KernelPkgNames: +diff --git a/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py b/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py +index c70be5af..860d258a 100644 +--- a/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py ++++ b/repos/system_upgrade/common/libraries/tests/test_kernel_lib.py +@@ -1,5 +1,3 @@ +-import functools +- + import pytest + + from leapp.libraries.common import kernel as kernel_lib +@@ -22,7 +20,6 @@ from leapp.libraries.stdlib import api, CalledProcessError + (('9.3', None), '5.14.0-354.el9.x86_64+rt', KernelType.REALTIME), + (('9.3', None), '5.14.0-354.el9.aarch64+64k', KernelType.ORDINARY), + (('9.3', None), '5.14.0-354.el9.aarch64+rt-64k', KernelType.REALTIME), +- (('9.3', None), '5.14.0-354.el9.aarch64+rt_64k', KernelType.REALTIME), + # centos + (('8', '8.7'), '4.18.0-425.3.1.el8.x86_64', KernelType.ORDINARY), + (('8', '8.7'), '4.18.0-425.3.1.rt7.213.el8.x86_64', KernelType.REALTIME), +@@ -32,7 +29,6 @@ from leapp.libraries.stdlib import api, CalledProcessError + (('9', '9.3'), '5.14.0-354.el9.x86_64+rt', KernelType.REALTIME), + (('9', '9.3'), '5.14.0-354.el9.aarch64+64k', KernelType.ORDINARY), + (('9', '9.3'), '5.14.0-354.el9.aarch64+rt-64k', KernelType.REALTIME), +- (('9', '9.3'), '5.14.0-354.el9.aarch64+rt_64k', KernelType.REALTIME), + ) + ) + def test_determine_kernel_type_from_uname(monkeypatch, version, uname_r, expected_kernel_type): +@@ -80,24 +76,26 @@ def test_determine_kernel_page_size_getconf_failure(monkeypatch): + assert api.current_logger.warnmsg + + +-def _mock_context_for_provides(kernel_nevra, provides_lines): ++def _mock_context_for_file_list(kernel_nevra, file_lines): + class _MockContext: +- def call(self, cmd, *args, **kwargs): +- assert cmd == ['rpm', '-q', '--provides', kernel_nevra] +- return {'stdout': provides_lines} ++ def call(self, cmd, *args, **kwargs): # pylint: disable=no-self-use ++ assert cmd == ['rpm', '-q', '-l', kernel_nevra] ++ return {'stdout': file_lines} + return _MockContext() + + + def test_get_uname_r_provided_by_kernel_pkg(): + kernel_nevra = 'kernel-core-5.14.0-354.el9.x86_64' +- provides_lines = [ +- 'kmod(virtio_ring.ko)', +- 'kernel(zlib_inflate_blob) = 0x65408378', +- 'kernel-uname-r = 5.14.0-354.el9.x86_64' ++ file_lines = [ ++ '/lib/modules', ++ '/lib/modules/5.14.0-354.el9.x86_64', ++ '/lib/modules/5.14.0-354.el9.x86_64/.vmlinuz.hmac', ++ '/lib/modules/5.14.0-354.el9.x86_64/vmlinuz', ++ '/boot/vmlinuz-5.14.0-354.el9.x86_64', + ] + + uname_r = kernel_lib.get_uname_r_provided_by_kernel_pkg( +- kernel_nevra, context=_mock_context_for_provides(kernel_nevra, provides_lines) ++ kernel_nevra, context=_mock_context_for_file_list(kernel_nevra, file_lines) + ) + assert uname_r == '5.14.0-354.el9.x86_64' + +@@ -106,9 +104,9 @@ def test_get_uname_r_provided_by_kernel_pkg_default_context(monkeypatch): + kernel_nevra = 'kernel-core-5.14.0-354.el9.x86_64' + + def run_mocked(cmd, *args, **kwargs): +- assert cmd == ['rpm', '-q', '--provides', kernel_nevra] ++ assert cmd == ['rpm', '-q', '-l', kernel_nevra] + return {'stdout': [ +- 'kernel-uname-r = 5.14.0-354.el9.x86_64' ++ '/lib/modules/5.14.0-354.el9.x86_64/vmlinuz' + ]} + + monkeypatch.setattr(kernel_lib.mounting, 'run', run_mocked) +@@ -118,49 +116,45 @@ def test_get_uname_r_provided_by_kernel_pkg_default_context(monkeypatch): + + + @pytest.mark.parametrize( +- ('uname_r', 'expected_nevra'), ++ ('uname_r', 'whatprovides_nevras', 'expected_nevra'), + ( +- ('5.14.0-354.el9.x86_64', 'kernel-core-5.14.0-354.el9.x86_64.rpm'), +- ('5.14.0-354.el9.x86_64+rt', 'kernel-rt-core-5.14.0-354.el9.x86_64.rpm'), +- ('5.14.0-687.el9.aarch64+64k', 'kernel-64k-core-5.14.0-687.el9.aarch64.rpm'), +- # RPM provides +rt_64k (underscore), uname -r reports +rt-64k (hyphen) +- ('5.14.0-687.el9.aarch64+rt-64k', 'kernel-rt-64k-core-5.14.0-687.el9.aarch64.rpm'), +- # Reversed: uname -r reports +rt_64k (underscore) +- ('5.14.0-687.el9.aarch64+rt_64k', 'kernel-rt-64k-core-5.14.0-687.el9.aarch64.rpm'), +- ('unknown', None), ++ ('5.14.0-354.el9.x86_64', ++ ['kernel-modules-core-5.14.0-354.el9.x86_64', 'kernel-core-5.14.0-354.el9.x86_64'], ++ 'kernel-core-5.14.0-354.el9.x86_64'), ++ ('5.14.0-354.el9.x86_64+rt', ++ ['kernel-rt-modules-core-5.14.0-354.el9.x86_64', 'kernel-rt-core-5.14.0-354.el9.x86_64'], ++ 'kernel-rt-core-5.14.0-354.el9.x86_64'), ++ ('5.14.0-687.el9.aarch64+64k', ++ ['kernel-64k-modules-core-5.14.0-687.el9.aarch64', 'kernel-64k-core-5.14.0-687.el9.aarch64'], ++ 'kernel-64k-core-5.14.0-687.el9.aarch64'), ++ ('5.14.0-687.el9.aarch64+rt-64k', ++ ['kernel-rt-64k-modules-core-5.14.0-687.el9.aarch64', 'kernel-rt-64k-core-5.14.0-687.el9.aarch64'], ++ 'kernel-rt-64k-core-5.14.0-687.el9.aarch64'), ++ ('unknown', None, None), + ) + ) +-def test_get_kernel_pkg_info_for_uname_r(monkeypatch, uname_r, expected_nevra): ++def test_get_kernel_pkg_info_for_uname_r(monkeypatch, uname_r, whatprovides_nevras, expected_nevra): + def run_mocked(cmd, *args, **kwargs): +- assert cmd[0:3] == ['rpm', '-q', '--whatprovides'] +- output_lines = [ +- 'kernel-core-5.14.0-354.el9.x86_64.rpm', +- 'kernel-rt-core-5.14.0-354.el9.x86_64.rpm', +- 'kernel-64k-core-5.14.0-687.el9.aarch64.rpm', +- 'kernel-rt-64k-core-5.14.0-687.el9.aarch64.rpm', +- ] +- return {'stdout': output_lines} ++ assert cmd[:3] == ['rpm', '-q', '--whatprovides'] ++ assert cmd[3] == '/lib/modules/{}'.format(uname_r) ++ if whatprovides_nevras is None: ++ raise CalledProcessError( ++ message='rpm query failed', command=cmd, ++ result={'exit_code': 1, 'stdout': '', 'stderr': ''}) ++ return {'stdout': whatprovides_nevras} + +- def get_uname_provided_by_pkg_mocked(pkg_nevra): +- nevra_uname_table = { +- 'kernel-core-5.14.0-354.el9.x86_64.rpm': '5.14.0-354.el9.x86_64', +- 'kernel-rt-core-5.14.0-354.el9.x86_64.rpm': '5.14.0-354.el9.x86_64+rt', +- 'kernel-64k-core-5.14.0-687.el9.aarch64.rpm': '5.14.0-687.el9.aarch64+64k', +- 'kernel-rt-64k-core-5.14.0-687.el9.aarch64.rpm': '5.14.0-687.el9.aarch64+rt_64k', +- } +- return nevra_uname_table[pkg_nevra] ++ def get_kernel_pkg_info_mocked(nevra): ++ name = nevra.rsplit('-', 2)[0] ++ return kernel_lib.KernelPkgInfo(name=name, version='', release='', arch='', nevra=nevra) + + monkeypatch.setattr(kernel_lib, 'run', run_mocked) +- monkeypatch.setattr(kernel_lib, 'get_uname_r_provided_by_kernel_pkg', get_uname_provided_by_pkg_mocked) +- +- mk_pkg_info = functools.partial(kernel_lib.KernelPkgInfo, name='', version='', release='', arch='') +- monkeypatch.setattr(kernel_lib, +- 'get_kernel_pkg_info', +- lambda dummy_nevra: mk_pkg_info(nevra=dummy_nevra)) ++ monkeypatch.setattr(kernel_lib, 'get_kernel_pkg_info', get_kernel_pkg_info_mocked) + + if expected_nevra: + pkg_info = kernel_lib.get_kernel_pkg_info_for_uname_r(uname_r) +- assert pkg_info == mk_pkg_info(nevra=expected_nevra) ++ assert pkg_info.nevra == expected_nevra ++ assert pkg_info.name.endswith('-core') ++ assert '-modules' not in pkg_info.name + else: + with pytest.raises(kernel_lib.KernelPackageInfoError): + kernel_lib.get_kernel_pkg_info_for_uname_r(uname_r) +-- +2.54.0 + diff --git a/0108-el8toel9-checkkernelarm-Install-64k-kernel.patch b/0108-el8toel9-checkkernelarm-Install-64k-kernel.patch new file mode 100644 index 0000000..ac4c5fa --- /dev/null +++ b/0108-el8toel9-checkkernelarm-Install-64k-kernel.patch @@ -0,0 +1,187 @@ +From f7ccf5aefdd87856c4dda80c2c410d5cb34d0c1d Mon Sep 17 00:00:00 2001 +From: Petr Stodulka +Date: Wed, 24 Jun 2026 08:58:38 +0200 +Subject: [PATCH 108/108] el8toel9: checkkernelarm: Install 64k kernel + +On RHEL 8 for ARM, the default kernel is 64k (pagesize) and there is +no 4k alternative. On RHEL 9 it's the opposite nowadays (since 9.4?), +where the default kernel is 4k, but for 64k the kernel-64k RPMs +need to be installed. + +So for the upgrade 8 -> 9 on ARM, it's important to create explicit +instruction for the installation of kernel-64k packages. On other +systems we do not have this problem so leaving it just in el8toel9 +repository. + +Also, we cannot prevent installation of standard kernel packages, +so the system after the upgrade will have both kernels installed +(4k & 64k versions). + +At this moment, no report is generated to informing user about the +need of manual 4k kernel removal. But it's a better situation than +in case of the requirement to manually install 64k kernel, reboot, +and then remove the 4k one. The report can be eventually created +as a follow up. + +Note that without this fix, the upgrade 8 -> 9 would end in emergency +mode on ARM machines after previously introduced changes dealing with +various kernel flavours - where a specific kernel is expected to be +discovered after the upgrade. + +Used AI to prepare unit-tests. + +JIRA: RHEL-111860 +Assisted-By: Claude Opus 4.6 +Signed-off-by: Petr Stodulka +--- + .../el8toel9/actors/checkkernelarm/actor.py | 22 ++++++ + .../libraries/checkkernelarm.py | 28 ++++++++ + .../tests/unit_test_checkkernelarm.py | 72 +++++++++++++++++++ + 3 files changed, 122 insertions(+) + create mode 100644 repos/system_upgrade/el8toel9/actors/checkkernelarm/actor.py + create mode 100644 repos/system_upgrade/el8toel9/actors/checkkernelarm/libraries/checkkernelarm.py + create mode 100644 repos/system_upgrade/el8toel9/actors/checkkernelarm/tests/unit_test_checkkernelarm.py + +diff --git a/repos/system_upgrade/el8toel9/actors/checkkernelarm/actor.py b/repos/system_upgrade/el8toel9/actors/checkkernelarm/actor.py +new file mode 100644 +index 00000000..9fa9eaa1 +--- /dev/null ++++ b/repos/system_upgrade/el8toel9/actors/checkkernelarm/actor.py +@@ -0,0 +1,22 @@ ++from leapp.actors import Actor ++from leapp.libraries.actor import checkkernelarm ++from leapp.models import KernelInfo, RpmTransactionTasks ++from leapp.tags import ChecksPhaseTag, IPUWorkflowTag ++ ++ ++class CheckKernelARM(Actor): ++ """ ++ Instruct the installation of 64k kernel on ARM machines. ++ ++ On RHEL 8 all ARM machines use kernel with the 64k pagesize. ++ However, in case of RHEL 9 the default ARM kernel has 4k pagesize instead. ++ Ensure that 64k kernel is installed on the target system for ARM. ++ """ ++ ++ name = 'check_kernel_arm' ++ consumes = (KernelInfo,) ++ produces = (RpmTransactionTasks,) ++ tags = (IPUWorkflowTag, ChecksPhaseTag) ++ ++ def process(self): ++ checkkernelarm.process() +diff --git a/repos/system_upgrade/el8toel9/actors/checkkernelarm/libraries/checkkernelarm.py b/repos/system_upgrade/el8toel9/actors/checkkernelarm/libraries/checkkernelarm.py +new file mode 100644 +index 00000000..78bf0bb6 +--- /dev/null ++++ b/repos/system_upgrade/el8toel9/actors/checkkernelarm/libraries/checkkernelarm.py +@@ -0,0 +1,28 @@ ++from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.common import kernel as kernel_lib ++from leapp.libraries.common.config import architecture ++from leapp.libraries.stdlib import api ++from leapp.models import KernelInfo, RpmTransactionTasks ++ ++ ++def process(): ++ if not architecture.matches_architecture(architecture.ARCH_ARM64): ++ # Nothing to do. ++ return ++ ++ kernel_info = next(api.consume(KernelInfo), None) ++ if not kernel_info: ++ raise StopActorExecutionError('Could not retrieve KernelInfo message.') ++ ++ target_pkgs = kernel_lib.get_target_kernel_pkg_names( ++ kernel_info.type, ++ kernel_info.page_size, ++ api.current_actor().configuration.architecture ++ ) ++ ++ api.current_logger().debug( ++ "Register expected target kernel RPMs for installation on ARM." ++ ) ++ api.produce(RpmTransactionTasks( ++ to_install=sorted(list(target_pkgs))) ++ ) +diff --git a/repos/system_upgrade/el8toel9/actors/checkkernelarm/tests/unit_test_checkkernelarm.py b/repos/system_upgrade/el8toel9/actors/checkkernelarm/tests/unit_test_checkkernelarm.py +new file mode 100644 +index 00000000..9d34fd4a +--- /dev/null ++++ b/repos/system_upgrade/el8toel9/actors/checkkernelarm/tests/unit_test_checkkernelarm.py +@@ -0,0 +1,72 @@ ++import pytest ++ ++from leapp.exceptions import StopActorExecutionError ++from leapp.libraries.actor import checkkernelarm ++from leapp.libraries.common.config.architecture import ARCH_ARM64, ARCH_X86_64 ++from leapp.libraries.common.kernel import KernelPageSize, KernelType ++from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked ++from leapp.libraries.stdlib import api ++from leapp.models import KernelInfo, RPM, RpmTransactionTasks ++ ++_KERNEL_PKG = RPM(name='kernel-core', arch='aarch64', version='4.18.0', release='1.el8', ++ epoch='0', packager='', pgpsig='SIG') ++ ++ ++def _make_kernel_info(kernel_type=KernelType.ORDINARY, page_size=KernelPageSize.PAGE_SIZE_64K): ++ return KernelInfo( ++ pkg=_KERNEL_PKG, type=kernel_type, page_size=page_size, uname_r='4.18.0-1.el8.aarch64' ++ ) ++ ++ ++def test_skip_non_arm_architecture(monkeypatch): ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch=ARCH_X86_64)) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkkernelarm.process() ++ ++ assert not api.produce.called ++ ++ ++def test_raises_when_no_kernel_info(monkeypatch): ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch=ARCH_ARM64)) ++ ++ with pytest.raises(StopActorExecutionError, match='Could not retrieve KernelInfo'): ++ checkkernelarm.process() ++ ++ ++@pytest.mark.parametrize( ++ ('kernel_type', 'page_size', 'expected_pkgs'), ++ [ ++ ( ++ KernelType.ORDINARY, ++ KernelPageSize.PAGE_SIZE_64K, ++ ['kernel-64k', 'kernel-64k-core', 'kernel-64k-modules'], ++ ), ++ ( ++ KernelType.ORDINARY, ++ KernelPageSize.PAGE_SIZE_4K, ++ ['kernel', 'kernel-core', 'kernel-modules'], ++ ), ++ ( ++ KernelType.REALTIME, ++ KernelPageSize.PAGE_SIZE_64K, ++ ['kernel-rt-64k', 'kernel-rt-64k-core', 'kernel-rt-64k-modules'], ++ ), ++ ( ++ KernelType.REALTIME, ++ KernelPageSize.PAGE_SIZE_4K, ++ ['kernel-rt', 'kernel-rt-core', 'kernel-rt-modules'], ++ ), ++ ] ++) ++def test_produces_rpm_transaction_tasks(monkeypatch, kernel_type, page_size, expected_pkgs): ++ kernel_info = _make_kernel_info(kernel_type=kernel_type, page_size=page_size) ++ monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch=ARCH_ARM64, msgs=[kernel_info])) ++ monkeypatch.setattr(api, 'produce', produce_mocked()) ++ ++ checkkernelarm.process() ++ ++ assert api.produce.called == 1 ++ produced = api.produce.model_instances[0] ++ assert isinstance(produced, RpmTransactionTasks) ++ assert sorted(produced.to_install) == sorted(expected_pkgs) +-- +2.54.0 + diff --git a/leapp-repository.spec b/leapp-repository.spec index 65ca3fc..18c2518 100644 --- a/leapp-repository.spec +++ b/leapp-repository.spec @@ -52,7 +52,7 @@ py2_byte_compile "%1" "%2"} Name: leapp-repository Version: 0.24.0 -Release: 2%{?dist} +Release: 3%{?dist} Summary: Repositories for leapp License: ASL 2.0 @@ -109,6 +109,71 @@ Patch00041: 0041-python-tests-deps-update-version-requirements-per-py.patch Patch00042: 0042-Drop-dependency-on-mock.patch Patch00043: 0043-remove-single-quoting-from-remediation-commands-1520.patch Patch00044: 0044-Update-leapp-data-data-stream-4.3-CTC-1.patch +Patch00045: 0045-repofileutils-disable-ConfigParser-interpolation-whe.patch +Patch00046: 0046-Drop-six-dependency-and-simplify-config-parsing-to-P.patch +Patch00047: 0047-mounting-ignore-failures-when-callling-mount-a.patch +Patch00048: 0048-mount_unit_generator-do-not-make-nofail-mounts-requi.patch +Patch00049: 0049-leapp_resume.service-use-multi-user.target-instead-o.patch +Patch00050: 0050-LiveMode-update-do-upgrade.sh-to-reflect-upstream-ch.patch +Patch00051: 0051-LiveMode-improve-do-upgrade.sh-output-when-.noreboot.patch +Patch00052: 0052-multipathutil-handle-the-overrides-protocol-subsecti.patch +Patch00053: 0053-multipath-move-config_reader-Actor-and-8to9-Models-t.patch +Patch00054: 0054-multipath-Add-MultipathConfig9to10-and-MultipathConf.patch +Patch00055: 0055-multipath-Add-MultipathConfRead9to10-config_reader-a.patch +Patch00056: 0056-multipath-Add-MultipathConfCheck9to10-mpath_conf_che.patch +Patch00057: 0057-multipath-Add-MultipathUpgradeConfUpdate9to10-mpath_.patch +Patch00058: 0058-multipath-Add-bindings-wwids-prkeys-file-copying-to-.patch +Patch00059: 0059-multipath-Add-mpathfiles-library-and-use-it-in-el9to.patch +Patch00060: 0060-model-upgrade_cmdline-add-to_remove-field.patch +Patch00061: 0061-checkluks-emit-messages-only-once.patch +Patch00062: 0062-checkluks-remove-rd.luks.uuid-from-the-upgrade-entry.patch +Patch00063: 0063-conftest-exit-actor-context-when-switching-repos.patch +Patch00064: 0064-testutils-logger_mocked-accepts-kwargs.patch +Patch00065: 0065-conftest-Add-leapp_tmpdir-fixture-for-standardized-t.patch +Patch00066: 0066-Reorganize-DNF-libraries-into-dnflibs-package.patch +Patch00067: 0067-Update-imports-to-use-new-dnflibs-modules.patch +Patch00068: 0068-dnflibs-introduce-DNFError-exception.patch +Patch00069: 0069-dnflibs.dnfplugin-Drop-dead-code-_handle_transaction.patch +Patch00070: 0070-dnflibs.dnfplugin-Drop-deprecated-DNF_PLUGIN_PATH.patch +Patch00071: 0071-dnflibs.dnfplugin-Introduce-new-DNF-exceptions-and-t.patch +Patch00072: 0072-dnflibs-dnfmodule-Move-init-of-dnf.Base-to-dnflibs-a.patch +Patch00073: 0073-dnflibs.dnfconfig-Raise-proper-exceptions-and-add-te.patch +Patch00074: 0074-DOCSTRINGS-Document-DNF-related-exception-propagatio.patch +Patch00075: 0075-pulseaudiocheck-fix-for-users-with-None-home-directo.patch +Patch00076: 0076-fix-target_initramfs-always-regenerate-initramfs-153.patch +Patch00077: 0077-Add-AI-assistant-harness-with-AGENTS.md-and-task-ski.patch +Patch00078: 0078-el8toel9-networkdeprecations-warn-about-ifcfg-device.patch +Patch00079: 0079-Remove-enforcing-1-from-upgrade-and-target-kernel-bo.patch +Patch00080: 0080-Fix-utils-actor_path.py-crash-on-invalid-repo-paths.patch +Patch00081: 0081-fix-breadcrumbs-use-distro-aware-OS-names-for-source.patch +Patch00082: 0082-leapp_resume.service-use-install_exec_t-SELinux-cont.patch +Patch00083: 0083-config-rhui-Add-config-for-setting-rpm-gpg-keys-to-r.patch +Patch00084: 0084-Add-inhibitor-for-invalid-fstab-entries-on-pseudo-fi.patch +Patch00085: 0085-lib-version-Remove-deprecated-is_rhel_realtime-funct.patch +Patch00086: 0086-lib-kernel-Fix-rt-kernel-detection-on-centos.patch +Patch00087: 0087-Add-CLAUDE.md-and-YAML-frontmatter-to-skills-for-Cla.patch +Patch00088: 0088-Add-inhibitor-for-incorrect-var-run-symlink-configur.patch +Patch00089: 0089-checkvarrunsymlink-Log-warning-when-var-run-is-missi.patch +Patch00090: 0090-Add-scandnfcomps-actor-to-scan-installed-DNF-comps.patch +Patch00091: 0091-IPU-9-10-Fix-the-upgrade-of-Graphical-Server.patch +Patch00092: 0092-Bump-leapp-framework.patch +Patch00093: 0093-raid-mdadm-include-configuration-in-initramfs.patch +Patch00094: 0094-feat-upgrade_initramfs_gen-write-rd.md.uuids-into-cm.patch +Patch00095: 0095-Update-actions-checkout-action-to-v7.patch +Patch00096: 0096-Update-distro-values-for-rhui-jobs-follow-up-on-9to1.patch +Patch00097: 0097-1-9-Merge-and-refactor-of-repo-related-actors.patch +Patch00098: 0098-2-9-targetcontentresolver-Init-message-consumption-c.patch +Patch00099: 0099-3-9-targetcontentresolver-RepositoriesBlacklisted-us.patch +Patch00100: 0100-4-9-targetcontentresolver-repositoriesblacklist-rede.patch +Patch00101: 0101-5-9-repositoriesmapping-scan_repositories-load_repos.patch +Patch00102: 0102-6-9-targetcontentresolver-operate-with-RepoMapDataHa.patch +Patch00103: 0103-7-9-targetcontentresolver-setuptargetrepos-minor-upd.patch +Patch00104: 0104-8-9-targetcontentresolver-pes_events_scanner-refacto.patch +Patch00105: 0105-9-9-targetcontentresolver-Make-TODO-notes.patch +Patch00106: 0106-fix-support-for-kernels-with-64k-page-size.patch +Patch00107: 0107-rework-kernel-package-identification-to-use-lib-modu.patch +Patch00108: 0108-el8toel9-checkkernelarm-Install-64k-kernel.patch + %description %{summary} @@ -163,7 +228,7 @@ Requires: leapp-repository-dependencies = %{leapp_repo_deps} # IMPORTANT: this is capability provided by the leapp framework rpm. # Check that 'version' instead of the real framework rpm version. -Requires: leapp-framework >= 6.5 +Requires: leapp-framework >= 6.6 # Since we provide sub-commands for the leapp utility, we expect the leapp # tool to be installed as well. @@ -333,6 +398,70 @@ Requires: fapolicyd %patch -P 0042 -p1 %patch -P 0043 -p1 %patch -P 0044 -p1 +%patch -P 0045 -p1 +%patch -P 0046 -p1 +%patch -P 0047 -p1 +%patch -P 0048 -p1 +%patch -P 0049 -p1 +%patch -P 0050 -p1 +%patch -P 0051 -p1 +%patch -P 0052 -p1 +%patch -P 0053 -p1 +%patch -P 0054 -p1 +%patch -P 0055 -p1 +%patch -P 0056 -p1 +%patch -P 0057 -p1 +%patch -P 0058 -p1 +%patch -P 0059 -p1 +%patch -P 0060 -p1 +%patch -P 0061 -p1 +%patch -P 0062 -p1 +%patch -P 0063 -p1 +%patch -P 0064 -p1 +%patch -P 0065 -p1 +%patch -P 0066 -p1 +%patch -P 0067 -p1 +%patch -P 0068 -p1 +%patch -P 0069 -p1 +%patch -P 0070 -p1 +%patch -P 0071 -p1 +%patch -P 0072 -p1 +%patch -P 0073 -p1 +%patch -P 0074 -p1 +%patch -P 0075 -p1 +%patch -P 0076 -p1 +%patch -P 0077 -p1 +%patch -P 0078 -p1 +%patch -P 0079 -p1 +%patch -P 0080 -p1 +%patch -P 0081 -p1 +%patch -P 0082 -p1 +%patch -P 0083 -p1 +%patch -P 0084 -p1 +%patch -P 0085 -p1 +%patch -P 0086 -p1 +%patch -P 0087 -p1 +%patch -P 0088 -p1 +%patch -P 0089 -p1 +%patch -P 0090 -p1 +%patch -P 0091 -p1 +%patch -P 0092 -p1 +%patch -P 0093 -p1 +%patch -P 0094 -p1 +%patch -P 0095 -p1 +%patch -P 0096 -p1 +%patch -P 0097 -p1 +%patch -P 0098 -p1 +%patch -P 0099 -p1 +%patch -P 0100 -p1 +%patch -P 0101 -p1 +%patch -P 0102 -p1 +%patch -P 0103 -p1 +%patch -P 0104 -p1 +%patch -P 0105 -p1 +%patch -P 0106 -p1 +%patch -P 0107 -p1 +%patch -P 0108 -p1 %build cp -a leapp*deps*el%{next_major_ver}.noarch.rpm repos/system_upgrade/%{repo_shortname}/files/bundled-rpms/ @@ -428,6 +557,31 @@ fi %changelog +* Thu Jul 25 2026 Matej Matuska - 0.24.0-3 +- Requires leapp-framework 6.6+ +- Initial implementation of upgrades on systems with configured Software RAID +- Install the kernel variant matching the system memory page size on ARM systems +- Fix multipath device initialization race in the upgrade environment +- Detect misconfigured /var/run on the source system +- Detect misconfigured kernel & systemd API mountpoints in FSTAB +- Drop the inconsistent report about used CRB repositories +- Ensure SELinux is set to permissive mode during the upgrade even when enforcing=1 is set on the kernel cmdline +- FIX additional AVC SELinux warnings +- Fix mount failures for FSTAB entries with the `nofail` option specified +- Fix source and target distribution names in /etc/migration-results +- Fix target system initramfs containing outdated configuration files in some circumstances +- Fix upgrade crashing when dnf repofiles contain URL encoded characters +- Fix upgrade incorrectly resuming during SELinux relabeling +- Fix upgrades for systems with /boot/efi on a Software RAID +- Fix upgrades on systems with multiple LUKS devices +- Fix upgrades with the RealTime kernel on CentOS Stream +- Inhibit the upgrade when network devices configured with ifcfg files could depend on NM +- Introduce rhui.obsolete_gpg_keys configuration option for the removal of obsoleted RPM GPG keys of RHUI Cloud providers +- LiveMode: Fix system version determination in the upgrade environment on CentOS Stream +- LiveMode: Fix upgrade getting stuck when the `.leapp_upgrade_failed` file exists +- Unify behaviour of upgrades on systems with enabled CRB repositories and explicitly required CRB repositories by user during the upgrade +- Resolves: RHEL-3289, RHEL-17842, RHEL-36249, RHEL-56040, RHEL-56176, RHEL-60060, RHEL-76846, RHEL-104384, RHEL-145136, RHEL-148631, RHEL-162192, RHEL-185508 + * Fri Apr 17 2026 Matej Matuska - 0.24.0-2 - Introduce new upgrade paths 8.10 -> 9.9 - Requires leapp-framework 6.5+