diff --git a/commands/upgrade/__init__.py b/commands/upgrade/__init__.py index 7528297d82..57b4240ea8 100644 --- a/commands/upgrade/__init__.py +++ b/commands/upgrade/__init__.py @@ -8,7 +8,7 @@ from leapp.exceptions import CommandError, LeappError from leapp.logger import configure_logger from leapp.utils.audit import Execution -from leapp.utils.clicmd import command, command_opt +from leapp.utils.clicmd import command, command_opt, _ensure_command from leapp.utils.output import beautify_actor_exception, report_errors, report_info # NOTE: @@ -16,10 +16,36 @@ # otherwise there might be errors. +def command_opt_with_aliases(name, *aliases, **kwargs): + """Like command_opt, but registers -- AND extra long-form aliases. + + leapp framework's add_option (as of 0.18.0) accepts only one long name, + so a plain `aliases=` kwarg trips on `add_option() got an unexpected + keyword argument 'aliases'`. argparse, however, supports multiple long + forms natively when add_argument is called with several name strings. + We bypass add_option and call the lower-level _add_opt directly. + + `dest` is derived by argparse from the first long form (here `name`), + so existing consumers reading `args.` keep working unchanged. + """ + is_flag = kwargs.pop('is_flag', False) + help_text = kwargs.pop('help', '') + action = kwargs.pop('action', 'store_true' if is_flag else 'store') + inherit = kwargs.pop('inherit', False) + + @_ensure_command + def wrapper(f): + names = ['--' + n.lstrip('-') for n in (name,) + aliases] + f.command._add_opt(*names, action=action, help=help_text, + internal={'wrapped': f, 'inherit': inherit}, + **kwargs) + return f + return wrapper + + @command('upgrade', help='Upgrade the current system to the next available major version.') @command_opt('resume', is_flag=True, help='Continue the last execution after it was stopped (e.g. after reboot)') -@command_opt('nowarn', is_flag=True, help='Do not display interactive warnings', - aliases=['non-interactive']) +@command_opt_with_aliases('nowarn', 'non-interactive', is_flag=True, help='Do not display interactive warnings') @command_opt('reboot', is_flag=True, help='Automatically performs reboot when requested.') @command_opt('whitelist-experimental', action='append', metavar='ActorName', help='Enable experimental actors') @command_opt('debug', is_flag=True, help='Enable debug mode', inherit=False) diff --git a/packaging/leapp-repository.spec b/packaging/leapp-repository.spec index d4ecb209ea..7a56e21071 100644 --- a/packaging/leapp-repository.spec +++ b/packaging/leapp-repository.spec @@ -94,8 +94,17 @@ Conflicts: leapp-upgrade-el7toel8 %endif -# Requires tools which allow switching between channels -Requires: cln-switch-channel = 2 +# cln-switch-channel was provided by rhn-client-tools 2.x to support the +# CLN-side channel switch. rhn-client-tools 3.0+ removes both the binary and +# the Provide as part of the no-auth migration. The actor that invokes it +# (switchclnchannel) is gated on is_cln_package_channel_active() (CLOS-4056), +# so on no-auth systems it never runs and the missing binary is harmless. On +# CLN-active systems rhn-client-tools 2.x is installed, supplying the binary +# at runtime, so the install-time pin was redundant. Drop it to allow +# rhn-client-tools 3.0+ on the same system as leapp-upgrade-el8toel9 +# (otherwise leapp_qa Run #54 dnf_transaction_check fails: rhn-client-tools +# 3.x cannot be installed alongside an RPM that requires +# cln-switch-channel = 2). # IMPORTANT: every time the requirements are changed, increment number by one # - same for Provides in deps subpackage diff --git a/repos/system_upgrade/cloudlinux/actors/checkcllicense/actor.py b/repos/system_upgrade/cloudlinux/actors/checkcllicense/actor.py index ad95dc1861..5514c86e87 100644 --- a/repos/system_upgrade/cloudlinux/actors/checkcllicense/actor.py +++ b/repos/system_upgrade/cloudlinux/actors/checkcllicense/actor.py @@ -4,6 +4,7 @@ from leapp.tags import ChecksPhaseTag, IPUWorkflowTag from leapp.libraries.stdlib import CalledProcessError, run, api from leapp.libraries.common.cllaunch import run_on_cloudlinux +from leapp.libraries.common.cln_detect import is_cln_package_channel_active from leapp.models import ( TargetUserSpacePreupgradeTasks, @@ -29,10 +30,34 @@ class CheckClLicense(Actor): @run_on_cloudlinux def process(self): + # CLOS-4056: the rhn_check XML-RPC call only verifies licenses on + # systems that use CLN as the package channel. Under no-auth (SWNG) + # the license is conveyed by other means (IP-based licensing, + # cloudlinux-release content) and the rhn_check round-trip is not a + # meaningful gate - on rhn-client-tools 3.0+ it fails outright with + # "Invalid System Credentials" against systemid files written by + # clnreg_ks. Skip the check under no-auth. + if not is_cln_package_channel_active(): + api.current_logger().info( + "CLN is not the active package channel; skipping rhn_check" + " license verification (no-auth systems use IP licensing," + " not the CLN XML-RPC roundtrip)." + ) + return + res = None if os.path.exists(self.system_id_path): - res = run([self.rhn_check_bin]) - self.log.debug('rhn_check result: %s', res) + try: + res = run([self.rhn_check_bin]) + self.log.debug('rhn_check result: %s', res) + except CalledProcessError as e: + # The original implementation assigned `res = run(...)` + # bare, but `run()` raises on non-zero exit codes - so + # the "produce an inhibitor on non-zero / non-empty stderr" + # branch below was dead code. Catch the failure and let + # the existing reporting path take over. + self.log.debug('rhn_check failed: %s', e) + res = None if not res or res['exit_code'] != 0 or res['stderr']: title = 'Server does not have an active CloudLinux license' summary = 'Server does not have an active CloudLinux license. This renders key CloudLinux packages ' \ diff --git a/repos/system_upgrade/cloudlinux/actors/checkrhnversionoverride/actor.py b/repos/system_upgrade/cloudlinux/actors/checkrhnversionoverride/actor.py index 6a21e10b6e..3b37334ccc 100644 --- a/repos/system_upgrade/cloudlinux/actors/checkrhnversionoverride/actor.py +++ b/repos/system_upgrade/cloudlinux/actors/checkrhnversionoverride/actor.py @@ -1,8 +1,10 @@ from leapp.actors import Actor from leapp import reporting +from leapp.libraries.stdlib import api from leapp.reporting import Report from leapp.tags import ChecksPhaseTag, IPUWorkflowTag from leapp.libraries.common.cllaunch import run_on_cloudlinux +from leapp.libraries.common.cln_detect import is_cln_package_channel_active class CheckRhnVersionOverride(Actor): @@ -17,23 +19,37 @@ class CheckRhnVersionOverride(Actor): @run_on_cloudlinux def process(self): + if not is_cln_package_channel_active(): + # CLOS-4056: versionOverride only matters when CLN is delivering packages, + # since the upgrade rewrites it to drive channel selection. + # On no-auth systems this does not apply. + return + up2date_config = '/etc/sysconfig/rhn/up2date' - with open(up2date_config, 'r') as f: - config_data = f.readlines() - for line in config_data: - if line.startswith('versionOverride='): - stripped_line = line.strip().split("=") - versionOverrideValue = stripped_line[1] - # If the version is being overriden to 8, we can continue as is. - if versionOverrideValue not in ['', '8']: - title = 'RHN up2date: versionOverride overwritten by the upgrade' - summary = ("The RHN config file up2date has a set value of the versionOverride option: {}." - " This value will get overwritten by the upgrade process, and reset to an empty" - " value once it's complete.".format(versionOverrideValue)) - reporting.create_report([ - reporting.Title(title), - reporting.Summary(summary), - reporting.Severity(reporting.Severity.MEDIUM), - reporting.Groups([reporting.Groups.OS_FACTS]), - reporting.RelatedResource('file', '/etc/sysconfig/rhn/up2date') - ]) + try: + with open(up2date_config, 'r') as f: + config_data = f.readlines() + except (OSError, IOError): + api.current_logger().info( + "RHN up2date config %s not present; skipping versionOverride check", + up2date_config, + ) + return + + for line in config_data: + if line.startswith('versionOverride='): + stripped_line = line.strip().split("=") + versionOverrideValue = stripped_line[1] + # If the version is being overriden to 8, we can continue as is. + if versionOverrideValue not in ['', '8']: + title = 'RHN up2date: versionOverride overwritten by the upgrade' + summary = ("The RHN config file up2date has a set value of the versionOverride option: {}." + " This value will get overwritten by the upgrade process, and reset to an empty" + " value once it's complete.".format(versionOverrideValue)) + reporting.create_report([ + reporting.Title(title), + reporting.Summary(summary), + reporting.Severity(reporting.Severity.MEDIUM), + reporting.Groups([reporting.Groups.OS_FACTS]), + reporting.RelatedResource('file', '/etc/sysconfig/rhn/up2date') + ]) diff --git a/repos/system_upgrade/cloudlinux/actors/clmysqlrepositorysetup/libraries/clmysql_cloudlinux.py b/repos/system_upgrade/cloudlinux/actors/clmysqlrepositorysetup/libraries/clmysql_cloudlinux.py index 840ff5a43c..14a4760609 100644 --- a/repos/system_upgrade/cloudlinux/actors/clmysqlrepositorysetup/libraries/clmysql_cloudlinux.py +++ b/repos/system_upgrade/cloudlinux/actors/clmysqlrepositorysetup/libraries/clmysql_cloudlinux.py @@ -41,7 +41,7 @@ def clmysql_process(lib, repofile_name, repofile_data): reporting.Summary( "MySQL Governor records the installed database type as '{governor}', " "but the mysqld binary on disk belongs to '{rpm}'. " - "This usually means 'mysqlgovernor.py --mysql-version' was run " + "This usually means '/usr/share/lve/dbgovernor/mysqlgovernor.py --mysql-version' was run " "without a follow-up '--install', or packages were changed manually. " "Proceeding could enable the wrong DNF module stream and break the upgrade.".format( governor=detected.governor_type, rpm=detected.pkg_type @@ -56,11 +56,11 @@ def clmysql_process(lib, repofile_name, repofile_data): hint=( "Examine the current state of the system's DB packages." "Complete the pending Governor install:\n" - " mysqlgovernor.py --mysql-version={governor}\n" - " mysqlgovernor.py --install --yes\n" + " /usr/share/lve/dbgovernor/mysqlgovernor.py --mysql-version={governor}\n" + " /usr/share/lve/dbgovernor/mysqlgovernor.py --install --yes\n" "Or reset Governor to match the actual packages:\n" - " mysqlgovernor.py --mysql-version={rpm}\n" - " mysqlgovernor.py --install --yes\n" + " /usr/share/lve/dbgovernor/mysqlgovernor.py --mysql-version={rpm}\n" + " /usr/share/lve/dbgovernor/mysqlgovernor.py --install --yes\n" "Then restart the upgrade process.".format( governor=detected.governor_type, rpm=detected.pkg_type ) @@ -109,7 +109,7 @@ def clmysql_process(lib, repofile_name, repofile_data): "The detected database type is '{}', but the cl-mysql-meta " "repo URL points to '{}'. " "This may happen when the database version was changed " - "without a follow-up 'mysqlgovernor.py --install', or the " + "without a follow-up '/usr/share/lve/dbgovernor/mysqlgovernor.py --install', or the " "cl-mysql.repo file was manually edited. " "Proceeding with the wrong repository would result in " "an incorrect upgrade operation." @@ -125,13 +125,16 @@ def clmysql_process(lib, repofile_name, repofile_data): reporting.Groups([reporting.Groups.INHIBITOR]), reporting.Remediation( hint=( - "Re-run MySQL Governor to regenerate the repository file: " - "mysqlgovernor.py --install --yes, " - "then restart the upgrade process. " - "Alternatively, if the repository file was manually edited, " - "either correct the baseurl to match the installed DB type or " - "set the desired DB type in Governor and re-run --install " - "to have it write the correct URL." + "Download the correct repository file for the installed " + "database type: " + "curl -o /etc/yum.repos.d/cl-mysql.repo " + "http://repo.cloudlinux.com/other/" + "cl${{releasever}}/mysqlmeta/{expected}-common.repo\n" + "Or re-run MySQL Governor to regenerate it " + "(this reinstalls the full DB stack): " + "/usr/share/lve/dbgovernor/mysqlgovernor.py --install --yes\n" + "Then restart the upgrade process." + .format(expected=expected_fragment) ) ), ] diff --git a/repos/system_upgrade/cloudlinux/actors/copycllicense/actor.py b/repos/system_upgrade/cloudlinux/actors/copycllicense/actor.py index 8d7ec3e841..d9062ffc40 100644 --- a/repos/system_upgrade/cloudlinux/actors/copycllicense/actor.py +++ b/repos/system_upgrade/cloudlinux/actors/copycllicense/actor.py @@ -3,6 +3,7 @@ from leapp.reporting import Report from leapp.tags import ChecksPhaseTag, IPUWorkflowTag from leapp.libraries.common.cllaunch import run_on_cloudlinux +from leapp.libraries.common.cln_detect import is_cln_package_channel_active from leapp.libraries.stdlib import api from leapp.models import ( TargetUserSpacePreupgradeTasks, @@ -11,7 +12,18 @@ RHN_CONFIG_DIR = '/etc/sysconfig/rhn' -REQUIRED_PKGS = ['dnf-plugin-spacewalk', 'rhn-client-tools'] + +# rhn-client-tools is the CLN identity / licensing client. Keep it on the +# target regardless of repo scheme - licensing does not go away under +# no-auth, only repo management does. +LICENSE_PKGS = ['rhn-client-tools'] + +# dnf-plugin-spacewalk is the DNF plugin that fetches packages from the +# CLN-side spacewalk channel. Pure repo-management plumbing. Under +# no-auth packages come from cl-channel via /etc/yum.repos.d/cl.repo and +# this plugin is unused; rhn-client-tools >= 3.0.1 even Obsoletes it on +# CL8/9. +SPACEWALK_PLUGIN_PKG = 'dnf-plugin-spacewalk' class CopyClLicense(Actor): @@ -38,7 +50,22 @@ def process(self): if os.path.isfile(src_path): files_to_copy.append(CopyFile(src=src_path)) + # CLOS-4056: only the spacewalk plugin is repo-management and + # therefore conditional on the CLN package channel being active. + # Identity/licensing (rhn-client-tools, /etc/sysconfig/rhn) is + # unconditional - it stays even when we move the system off CLN as + # a package source. + install_rpms = list(LICENSE_PKGS) + if is_cln_package_channel_active(): + install_rpms.append(SPACEWALK_PLUGIN_PKG) + else: + api.current_logger().info( + "CLN is not the active package channel; skipping %s in target" + " userspace install set", + SPACEWALK_PLUGIN_PKG, + ) + api.produce(TargetUserSpacePreupgradeTasks( - install_rpms=REQUIRED_PKGS, + install_rpms=install_rpms, copy_files=files_to_copy )) diff --git a/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/actor.py b/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/actor.py index 4856c02894..e370791410 100644 --- a/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/actor.py +++ b/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/actor.py @@ -11,17 +11,18 @@ class EnableYumSpacewalkPlugin(Actor): consumes = () produces = (Report,) tags = (FirstBootPhaseTag, IPUWorkflowTag) - config = enableyumspacewalkplugin.DEFAULT_CONFIG_PATH + + CONFIG_PATH = enableyumspacewalkplugin.DEFAULT_CONFIG_PATH @run_on_cloudlinux def process(self): _, title = enableyumspacewalkplugin._enable_plugin( - self.config, enableyumspacewalkplugin.ParserClass, self.log + self.CONFIG_PATH, enableyumspacewalkplugin.ParserClass, self.log ) if title: reporting.create_report([ reporting.Title(title), - reporting.Summary("DNF spacewalk plugin must be enabled for CLN channels. Config path: " + self.config), + reporting.Summary("DNF spacewalk plugin must be enabled for CLN channels. Config path: " + self.CONFIG_PATH), reporting.Severity(reporting.Severity.MEDIUM), reporting.Groups([reporting.Groups.SANITY]) ]) diff --git a/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/libraries/enableyumspacewalkplugin.py b/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/libraries/enableyumspacewalkplugin.py index 32b75fbc98..2dd7b4a5cb 100644 --- a/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/libraries/enableyumspacewalkplugin.py +++ b/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/libraries/enableyumspacewalkplugin.py @@ -10,10 +10,11 @@ ParserClass = configparser.ConfigParser -# DNF plugin config path on the target system (CL8). FirstBoot runs after the -# target OS is already in place; on CL8 the plugin package is -# dnf-plugin-spacewalk (PES replacement for CL7's yum-rhn-plugin, -# pes-events id=1586) and its config ships with enabled=0. +# DNF plugin config path on the target system (CL8). +# FirstBoot runs after the target OS is already in place; +# on CL8 the plugin package is dnf-plugin-spacewalk +# (PES replacement for CL7's yum-rhn-plugin, pes-events id=1586) +# and its config ships with enabled=0. DEFAULT_CONFIG_PATH = '/etc/dnf/plugins/spacewalk.conf' @@ -24,8 +25,8 @@ def _enable_plugin(config_path, parser_cls=ParserClass, log=None): when the plugin is not installed, and otherwise a human-readable problem description suitable for a Leapp report. - Absence of `config_path` is treated as a silent skip: on no-auth / - SWNG systems (CLOS-4056) `rhn-client-tools >= 3.0.1` Obsoletes the + Absence of `config_path` is treated as a silent skip: on no-auth + systems (CLOS-4056) `rhn-client-tools >= 3.0.1` Obsoletes the `dnf-plugin-spacewalk` package, so the config file is either gone by then, or doesn't do anything. """ diff --git a/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/tests/test_enableyumspacewalkplugin.py b/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/tests/test_enableyumspacewalkplugin.py index 7e7cba2a79..d74da391e4 100644 --- a/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/tests/test_enableyumspacewalkplugin.py +++ b/repos/system_upgrade/cloudlinux/actors/enableyumspacewalkplugin/tests/test_enableyumspacewalkplugin.py @@ -17,10 +17,9 @@ def _write(tmp_path, body): def test_missing_config_is_silent_skip(tmp_path): """Config file absent -> silent skip: no change, no title, no report. - On no-auth / SWNG systems (CLOS-4056) the dnf-plugin-spacewalk - package is Obsoleted by rhn-client-tools >= 3.0.1 and the config - file is absent by design. Emitting a 'not found' report there - would be noise. + On no-auth systems (CLOS-4056) the dnf-plugin-spacewalk + package is Obsoleted by rhn-client-tools >= 3.0.1. + Emitting a 'not found' report there would be noise. """ changed, title = _enable_plugin(str(tmp_path / "absent.conf"), ParserClass) assert changed is False diff --git a/repos/system_upgrade/cloudlinux/actors/pinclnmirror/actor.py b/repos/system_upgrade/cloudlinux/actors/pinclnmirror/actor.py index bc1686233f..bc19301a03 100644 --- a/repos/system_upgrade/cloudlinux/actors/pinclnmirror/actor.py +++ b/repos/system_upgrade/cloudlinux/actors/pinclnmirror/actor.py @@ -4,6 +4,7 @@ from leapp.actors import Actor from leapp.libraries.stdlib import api from leapp.libraries.common.cllaunch import run_on_cloudlinux +from leapp.libraries.common.cln_detect import is_cln_package_channel_active from leapp.libraries.common.cln_switch import get_target_userspace_path from leapp.tags import DownloadPhaseTag, IPUWorkflowTag from leapp.libraries.common.config.version import get_target_major_version @@ -25,6 +26,14 @@ class PinClnMirror(Actor): @run_on_cloudlinux def process(self): """Pin CLN mirror""" + if not is_cln_package_channel_active(): + # CLOS-4056: pinning the CLN mirror is only meaningful when CLN is delivering packages. + # With the no-auth repo scheme active, there's no point in doing so. + api.current_logger().info( + "CLN is not the active package channel; skipping mirror pinning" + ) + return + target_userspace = get_target_userspace_path() api.current_logger().info("Pin CLN mirror: target userspace=%s", target_userspace) @@ -54,6 +63,11 @@ def process(self): api.current_logger().info("Pin CLN mirror %s in %s", mirror_url, mirrorlist_path) up2date_path = os.path.join(target_userspace, 'etc/sysconfig/rhn/up2date') - with open(up2date_path, 'a+') as file: - file.write('\nmirrorURL[comment]=Set mirror URL to /etc/mirrorlist\nmirrorURL=file:///etc/mirrorlist\n') - api.current_logger().info("Updated up2date_path %s", up2date_path) + try: + with open(up2date_path, 'a+') as file: + file.write('\nmirrorURL[comment]=Set mirror URL to /etc/mirrorlist\nmirrorURL=file:///etc/mirrorlist\n') + api.current_logger().info("Updated up2date_path %s", up2date_path) + except (OSError, IOError) as e: + api.current_logger().info( + "Could not update %s: %s", up2date_path, e, + ) diff --git a/repos/system_upgrade/cloudlinux/actors/resetrhnversionoverride/actor.py b/repos/system_upgrade/cloudlinux/actors/resetrhnversionoverride/actor.py index 21b2164cb0..32109201a3 100644 --- a/repos/system_upgrade/cloudlinux/actors/resetrhnversionoverride/actor.py +++ b/repos/system_upgrade/cloudlinux/actors/resetrhnversionoverride/actor.py @@ -1,6 +1,8 @@ from leapp.actors import Actor +from leapp.libraries.stdlib import api from leapp.tags import FinalizationPhaseTag, IPUWorkflowTag from leapp.libraries.common.cllaunch import run_on_cloudlinux +from leapp.libraries.common.cln_detect import is_cln_package_channel_active class ResetRhnVersionOverride(Actor): @@ -15,11 +17,28 @@ class ResetRhnVersionOverride(Actor): @run_on_cloudlinux def process(self): + if not is_cln_package_channel_active(): + # CLOS-4056: versionOverride only matters when CLN is delivering packages, + # since the upgrade rewrites it to drive channel selection. + # On no-auth systems this does not apply. + return + up2date_config = '/etc/sysconfig/rhn/up2date' - with open(up2date_config, 'r') as f: - config_data = f.readlines() - for line in config_data: - if line.startswith('versionOverride='): - line = 'versionOverride=' + try: + with open(up2date_config, 'r') as f: + config_data = f.readlines() + except (OSError, IOError): + api.current_logger().info( + "RHN up2date config %s not present; skipping versionOverride reset", + up2date_config, + ) + return + + new_data = [] + for line in config_data: + if line.startswith('versionOverride='): + new_data.append('versionOverride=\n') + else: + new_data.append(line) with open(up2date_config, 'w') as f: - f.writelines(config_data) + f.writelines(new_data) diff --git a/repos/system_upgrade/cloudlinux/actors/switchclnchannel/actor.py b/repos/system_upgrade/cloudlinux/actors/switchclnchannel/actor.py index 86421856b2..dc0ac24317 100644 --- a/repos/system_upgrade/cloudlinux/actors/switchclnchannel/actor.py +++ b/repos/system_upgrade/cloudlinux/actors/switchclnchannel/actor.py @@ -3,7 +3,8 @@ from leapp.tags import FirstBootPhaseTag, IPUWorkflowTag from leapp.libraries.stdlib import CalledProcessError from leapp.libraries.common.cllaunch import run_on_cloudlinux -from leapp.libraries.common.cln_switch import cln_switch, get_target_userspace_path +from leapp.libraries.common.cln_detect import is_cln_package_channel_active +from leapp.libraries.common.cln_switch import cln_switch from leapp import reporting from leapp.reporting import Report from leapp.libraries.common.config.version import get_target_major_version @@ -22,9 +23,21 @@ class SwitchClnChannel(Actor): @run_on_cloudlinux def process(self): + if not is_cln_package_channel_active(): + # CLOS-4056: CLN package channel is inactive, so skipping the channel switch + # is correct - packages come from standard repositories instead. + # Leapp manages those without custom actions through repomaps. + api.current_logger().info( + "CLN is not the active package channel; skipping channel switch" + ) + return + try: cln_switch(target=int(get_target_major_version())) except CalledProcessError as e: + # Do not inhibit. Even on systems that ARE using CLN as the package channel, + # a transient CLN-server reachability problem at FirstBoot + # shouldn't block the upgrade. reporting.create_report( [ reporting.Title( @@ -33,17 +46,20 @@ def process(self): reporting.Summary( "Command {} failed with exit code {}." " The most probable cause of that is a problem with this system's" - " CloudLinux Network registration.".format(e.command, e.exit_code) + " CloudLinux Network registration. If this system now uses the" + " no-auth (SWNG) repository scheme, this failure is harmless -" + " CL9 packages come from cl-channel / cloudlinux9-baseos instead" + " of CLN.".format(e.command, e.exit_code) ), reporting.Remediation( - hint="Check the state of this system's registration with \'rhn_check\'." - " Attempt to re-register the system with \'rhnreg_ks --force\'." + hint="If you rely on CLN: check registration with 'rhn_check' and" + " re-register with 'rhnreg_ks --force'. If you have migrated to" + " no-auth repos, this message can be ignored." ), - reporting.Severity(reporting.Severity.HIGH), + reporting.Severity(reporting.Severity.MEDIUM), reporting.Groups( [reporting.Groups.OS_FACTS, reporting.Groups.AUTHENTICATION] ), - reporting.Groups([reporting.Groups.INHIBITOR]), ] ) except OSError as e: diff --git a/repos/system_upgrade/cloudlinux/actors/unpinclnmirror/actor.py b/repos/system_upgrade/cloudlinux/actors/unpinclnmirror/actor.py index 8e7ffc93a0..7343117018 100644 --- a/repos/system_upgrade/cloudlinux/actors/unpinclnmirror/actor.py +++ b/repos/system_upgrade/cloudlinux/actors/unpinclnmirror/actor.py @@ -2,6 +2,7 @@ from leapp.actors import Actor from leapp.libraries.common.cllaunch import run_on_cloudlinux +from leapp.libraries.common.cln_detect import is_cln_package_channel_active from leapp.libraries.common.cln_switch import get_target_userspace_path from leapp.tags import FirstBootPhaseTag, IPUWorkflowTag @@ -19,6 +20,11 @@ class UnpinClnMirror(Actor): @run_on_cloudlinux def process(self): + if not is_cln_package_channel_active(): + # CLOS-4056: pinclnmirror skipped its work for the same reason + # (CLN package channel inactive), so there is nothing for us to unpin. + return + target_userspace = get_target_userspace_path() mirrorlist_path = os.path.join(target_userspace, 'etc/mirrorlist') diff --git a/repos/system_upgrade/cloudlinux/libraries/cln_detect.py b/repos/system_upgrade/cloudlinux/libraries/cln_detect.py new file mode 100644 index 0000000000..8e0d59494f --- /dev/null +++ b/repos/system_upgrade/cloudlinux/libraries/cln_detect.py @@ -0,0 +1,123 @@ +"""Detection helpers for the CloudLinux Network (CLN) package channel. + +CLN has historically combined two concerns: + +1. *Registration / identity* - the system is registered with the CLN +server (`/etc/sysconfig/rhn/systemid`, JWT token), used for licensing +regardless of how packages are delivered. + +2. *Package delivery* - the system pulls CloudLinux packages +through the spacewalk DNF/YUM plugin against the +CLN-side channel (`cloudlinux-x86_64-server-N`). + +The no-auth repository transition decouples these. +New CL8 and CL9 systems keep CLN *registration*, +but no longer use CLN as the *package channel* - packages come from the SWNG mirrorlist +via `/etc/yum.repos.d/cl.repo` (`cl-channel`) instead. +`rhn-client-tools >= 3.0.1` disables the spacewalk plugin to enforce this. + +The CLN-touching actors in this repo only care about the second concern: +they exist to make the CLN package channel work during ELevate. +On systems where the channel has been switched off they should stand down, +regardless of registration state. + +CLOS-4056: gate those actors on `is_cln_package_channel_active()`. +""" + +import os + +from leapp.libraries.stdlib import CalledProcessError, run + + +RHN_SYSTEMID = '/etc/sysconfig/rhn/systemid' +SPACEWALK_DNF_CONF = '/etc/dnf/plugins/spacewalk.conf' +SPACEWALK_YUM_CONF = '/etc/yum/pluginconf.d/spacewalk.conf' + +# Packages that ship the spacewalk-protocol DNF/YUM plugin. Any one of +# them being installed is sufficient evidence that CLN may serve +# packages here; if none of them are present the plugin cannot run, no +# matter what config files happen to be lying around (see +# _spacewalk_plugin_installed below). +_SPACEWALK_PLUGIN_PKGS = ( + 'dnf-plugin-spacewalk', + 'python3-dnf-plugin-spacewalk', + 'yum-rhn-plugin', +) + + +def _plugin_explicitly_disabled(conf_path): + try: + with open(conf_path) as f: + for line in f: + stripped = line.strip().lower() + if not stripped or stripped.startswith('#') or stripped.startswith('['): + continue + if stripped.startswith('enabled') and '=' in stripped: + value = stripped.split('=', 1)[1].strip() + return value == '0' + except (OSError, IOError): + pass + return False + + +def _spacewalk_plugin_installed(): + """True iff at least one spacewalk-protocol plugin package is installed. + + Done via `rpm -q --quiet ` per package: rpm returns 0 only when + *that* package is installed. We OR across the candidate names and + return on the first hit. Errors invoking rpm itself (broken database, + PATH issues) are treated as "not installed" - a false negative here + only causes CLN-related actors to stand down, which is the safe side + of the call. + """ + for pkg in _SPACEWALK_PLUGIN_PKGS: + try: + run(['rpm', '-q', '--quiet', pkg]) + return True + except CalledProcessError: + continue + except (OSError, IOError): + return False + return False + + +def is_cln_package_channel_active(): + """Return True when CLN is the active package channel for this system. + + Requires all of: + + * `/etc/sysconfig/rhn/systemid` present (CLN registration state), + * at least one spacewalk-protocol plugin package installed, + * a spacewalk plugin config file present, and + * none of the present plugin config files explicitly setting `enabled = 0`. + + A False result means the system is either deregistered, has no + spacewalk plugin installed, or has been moved to the no-auth (SWNG) + scheme, so CLN-targeting actions (channel switch, mirror pinning, + version overrides) are not meaningful and should be skipped. + + This is a deliberately heuristic check - it asks "is CLN going to + serve packages here", not "is the system registered with CLN" (the + two were the same thing pre-no-auth and have since diverged). + + The plugin-package check guards against stale-config edge cases: when + rhn-client-tools 3.0+ Obsoletes dnf-plugin-spacewalk, a leftover + /etc/dnf/plugins/spacewalk.conf (saved without the .rpmsave suffix, + or manually preserved) would otherwise make the helper claim CLN is + active when no plugin can actually run. + """ + if not os.path.exists(RHN_SYSTEMID): + return False + + if not _spacewalk_plugin_installed(): + return False + + configs = [p for p in (SPACEWALK_DNF_CONF, SPACEWALK_YUM_CONF) if os.path.exists(p)] + if not configs: + return False + + for conf in configs: + if _plugin_explicitly_disabled(conf): + return False + + return True diff --git a/repos/system_upgrade/cloudlinux/libraries/tests/test_cln_detect.py b/repos/system_upgrade/cloudlinux/libraries/tests/test_cln_detect.py new file mode 100644 index 0000000000..edafcdaea7 --- /dev/null +++ b/repos/system_upgrade/cloudlinux/libraries/tests/test_cln_detect.py @@ -0,0 +1,152 @@ +import pytest + +from leapp.libraries.common import cln_detect +from leapp.libraries.stdlib import CalledProcessError + + +@pytest.fixture +def clean_paths(monkeypatch, tmp_path): + """Point cln_detect at a clean tmp dir so each test starts from no state.""" + systemid = tmp_path / "systemid" + dnf_conf = tmp_path / "dnf_spacewalk.conf" + yum_conf = tmp_path / "yum_spacewalk.conf" + monkeypatch.setattr(cln_detect, "RHN_SYSTEMID", str(systemid)) + monkeypatch.setattr(cln_detect, "SPACEWALK_DNF_CONF", str(dnf_conf)) + monkeypatch.setattr(cln_detect, "SPACEWALK_YUM_CONF", str(yum_conf)) + return {"systemid": systemid, "dnf_conf": dnf_conf, "yum_conf": yum_conf} + + +@pytest.fixture +def fake_rpm(monkeypatch): + """Monkeypatch cln_detect.run with a fake `rpm -q --quiet ` driver. + + Tests mutate the returned set to declare which plugin packages should + look installed; the fake `run` mirrors the real `rpm -q --quiet` + semantics by raising CalledProcessError for non-installed packages. + """ + installed = set() + + def _run(cmd, **kwargs): + # We only expect cln_detect to call rpm in the single-package form; + # if that contract changes the test should fail loudly rather than + # silently lie. + assert cmd[:3] == ['rpm', '-q', '--quiet'] and len(cmd) == 4, ( + "unexpected rpm invocation: %r" % (cmd,) + ) + pkg = cmd[3] + if pkg in installed: + return {'exit_code': 0, 'stdout': '', 'stderr': ''} + raise CalledProcessError( + message='package %s is not installed' % pkg, + command=cmd, + result={'exit_code': 1, 'stdout': '', 'stderr': ''}, + ) + + monkeypatch.setattr(cln_detect, "run", _run) + return installed + + +def _touch(path, content=""): + path.write_text(content) + + +def test_no_systemid_means_channel_inactive(clean_paths, fake_rpm): + # Without registration the spacewalk plugin can't authenticate, so even + # if the plugin is installed it is not the active package channel. + fake_rpm.add('dnf-plugin-spacewalk') + assert cln_detect.is_cln_package_channel_active() is False + + +def test_systemid_but_no_plugin_installed_means_channel_inactive(clean_paths, fake_rpm): + # systemid is there and a plugin config file is even present, but no + # spacewalk plugin RPM is installed. This is the rhn-client-tools 3.0+ + # Obsoletes left-behind-config case: helper must return False here, + # otherwise CLN-assuming actors would mis-fire on a no-auth system. + _touch(clean_paths["systemid"]) + _touch(clean_paths["dnf_conf"], "[main]\nenabled = 1\n") + assert cln_detect.is_cln_package_channel_active() is False + + +def test_systemid_but_no_plugin_conf_means_channel_inactive(clean_paths, fake_rpm): + fake_rpm.add('dnf-plugin-spacewalk') + _touch(clean_paths["systemid"]) + assert cln_detect.is_cln_package_channel_active() is False + + +def test_systemid_and_enabled_dnf_plugin_means_channel_active(clean_paths, fake_rpm): + fake_rpm.add('dnf-plugin-spacewalk') + _touch(clean_paths["systemid"]) + _touch(clean_paths["dnf_conf"], "[main]\nenabled = 1\n") + assert cln_detect.is_cln_package_channel_active() is True + + +def test_explicit_disabled_dnf_plugin_means_channel_inactive(clean_paths, fake_rpm): + fake_rpm.add('dnf-plugin-spacewalk') + _touch(clean_paths["systemid"]) + _touch(clean_paths["dnf_conf"], "[main]\nenabled = 0\n") + assert cln_detect.is_cln_package_channel_active() is False + + +def test_explicit_disabled_yum_plugin_means_channel_inactive(clean_paths, fake_rpm): + fake_rpm.add('yum-rhn-plugin') + _touch(clean_paths["systemid"]) + _touch(clean_paths["yum_conf"], "[main]\nenabled=0\n") + assert cln_detect.is_cln_package_channel_active() is False + + +def test_one_plugin_disabled_one_not_means_channel_inactive(clean_paths, fake_rpm): + # If either plugin config disables the plugin, treat the channel as off. + fake_rpm.add('dnf-plugin-spacewalk') + _touch(clean_paths["systemid"]) + _touch(clean_paths["dnf_conf"], "[main]\nenabled = 1\n") + _touch(clean_paths["yum_conf"], "[main]\nenabled = 0\n") + assert cln_detect.is_cln_package_channel_active() is False + + +def test_plugin_conf_without_enabled_key_means_channel_active(clean_paths, fake_rpm): + # A plugin config that does not mention `enabled` defaults to enabled + # upstream, so we must treat the channel as active. + fake_rpm.add('dnf-plugin-spacewalk') + _touch(clean_paths["systemid"]) + _touch(clean_paths["dnf_conf"], "[main]\ntimeout = 120\n") + assert cln_detect.is_cln_package_channel_active() is True + + +def test_comments_and_blank_lines_ignored(clean_paths, fake_rpm): + fake_rpm.add('dnf-plugin-spacewalk') + _touch(clean_paths["systemid"]) + _touch( + clean_paths["dnf_conf"], + "# some comment\n\n[main]\n# enabled = 0\nenabled = 1\n", + ) + assert cln_detect.is_cln_package_channel_active() is True + + +def test_yum_plugin_alone_counts_as_installed(clean_paths, fake_rpm): + # Only the YUM-side plugin package is installed (CL7 case); the helper + # should still consider the channel potentially active. + fake_rpm.add('yum-rhn-plugin') + _touch(clean_paths["systemid"]) + _touch(clean_paths["yum_conf"], "[main]\nenabled = 1\n") + assert cln_detect.is_cln_package_channel_active() is True + + +def test_python3_plugin_package_alone_counts_as_installed(clean_paths, fake_rpm): + # The python3- subpackage of the DNF plugin counts too. + fake_rpm.add('python3-dnf-plugin-spacewalk') + _touch(clean_paths["systemid"]) + _touch(clean_paths["dnf_conf"], "[main]\nenabled = 1\n") + assert cln_detect.is_cln_package_channel_active() is True + + +def test_rpm_call_oserror_falls_through_to_inactive(monkeypatch, clean_paths): + # If rpm itself can't be invoked at all (broken PATH / db / etc.) the + # helper should fail safe by reporting the channel as inactive. That + # only makes CLN-assuming actors skip - the safe side of the call. + def _run_raises_os(cmd, **kwargs): + raise OSError(2, 'No such file or directory: rpm') + + monkeypatch.setattr(cln_detect, "run", _run_raises_os) + _touch(clean_paths["systemid"]) + _touch(clean_paths["dnf_conf"], "[main]\nenabled = 1\n") + assert cln_detect.is_cln_package_channel_active() is False diff --git a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py index 8e4579a7ec..2d40d19a8e 100644 --- a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py +++ b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py @@ -681,16 +681,26 @@ def _prep_repository_access(context, target_userspace): run(['rm', '-rf', os.path.join(target_etc, 'rhsm')]) context.copytree_from('/etc/rhsm', os.path.join(target_etc, 'rhsm')) - if os.path.isdir('/etc/sysconfig/rhn'): + # Set up spacewalk plugin config in the target chroot only if the plugin's + # config file actually exists there. Under the no-auth migration (CLOS-4056) + # rhn-client-tools >= 3.0.1 Obsoletes dnf-plugin-spacewalk on CL8/9, so + # the target userspace built from the no-auth-aware repos has no + # /etc/dnf/plugins/spacewalk.conf - the original unconditional open + # raised IOError [Errno 2] and crashed target_userspace_creator. The + # outer /etc/sysconfig/rhn directory check is on the source side and + # remains valid (CLN registration may persist for licensing/inventory), + # but the inner file presence is no longer guaranteed. + spacewalk_conf = os.path.join(target_etc, 'dnf/plugins/spacewalk.conf') + if os.path.isdir('/etc/sysconfig/rhn') and os.path.isfile(spacewalk_conf): # Set up spacewalk plugin config - with open(os.path.join(target_etc, 'dnf/plugins/spacewalk.conf'), 'r') as f: + with open(spacewalk_conf, 'r') as f: lines = f.readlines() new_lines = [] for line in lines: if 'enabled' in line: line = 'enabled = 1\n' new_lines.append(line) - with open(os.path.join(target_etc, 'dnf/plugins/spacewalk.conf'), 'w') as f: + with open(spacewalk_conf, 'w') as f: f.writelines(new_lines) if os.path.isfile('/etc/mirrorlist'): diff --git a/repos/system_upgrade/common/libraries/dnfplugin.py b/repos/system_upgrade/common/libraries/dnfplugin.py index 0fc011ab61..2d7c3fbd50 100644 --- a/repos/system_upgrade/common/libraries/dnfplugin.py +++ b/repos/system_upgrade/common/libraries/dnfplugin.py @@ -483,8 +483,6 @@ def _prepare_perform(used_repos, target_userspace_info, xfs_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): - if get_target_major_version() == '9': - _rebuild_rpm_db(context, root='/installroot') yield context, overlay, target_repoids