From bf914ac75d17be16b2b3156a472434b796535851 Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Tue, 8 Sep 2026 15:50:21 +0300 Subject: [PATCH 1/3] Add `remove_from_option` method to `Grub2ConfigEditor` Removes cmdline entries with `grubby --remove-args` semantics: a bare name drops the argument whichever value it holds, a name/value pair drops it only on an exact match. Signed-off-by: Mihaela Balutoiu --- coriolis/tests/test_utils.py | 63 ++++++++++++++++++++++++++++++++++++ coriolis/utils.py | 45 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/coriolis/tests/test_utils.py b/coriolis/tests/test_utils.py index 6aece2fa..aaa92aef 100644 --- a/coriolis/tests/test_utils.py +++ b/coriolis/tests/test_utils.py @@ -1882,6 +1882,69 @@ def test_append_to_option_adds_new_option(self): self.parser.append_to_option("new_option", new_value) self.assertEqual(self.parser._parsed, expected_value) + @ddt.data( + ( + 'GRUB_CMDLINE_LINUX="cloud-init=disabled console=ttyS0"', + {"opt_type": "single", "opt_val": "cloud-init"}, + 'GRUB_CMDLINE_LINUX="console=ttyS0"\n', + ), + ( + 'GRUB_CMDLINE_LINUX="cloud-init=enabled console=ttyS0"', + {"opt_type": "single", "opt_val": "cloud-init"}, + 'GRUB_CMDLINE_LINUX="console=ttyS0"\n', + ), + ( + 'GRUB_CMDLINE_LINUX="quiet console=ttyS0"', + {"opt_type": "single", "opt_val": "quiet"}, + 'GRUB_CMDLINE_LINUX="console=ttyS0"\n', + ), + ( + 'GRUB_CMDLINE_LINUX="cloud-init=enabled console=ttyS0"', + {"opt_type": "key_val", "opt_key": "cloud-init", "opt_val": "disabled"}, + 'GRUB_CMDLINE_LINUX="cloud-init=enabled console=ttyS0"\n', + ), + ( + 'GRUB_CMDLINE_LINUX="cloud-init=disabled console=ttyS0"', + {"opt_type": "key_val", "opt_key": "cloud-init", "opt_val": "disabled"}, + 'GRUB_CMDLINE_LINUX="console=ttyS0"\n', + ), + ( + 'GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0 quiet"', + {"opt_type": "single", "opt_val": "console"}, + 'GRUB_CMDLINE_LINUX="quiet"\n', + ), + ( + 'GRUB_CMDLINE_LINUX="console=ttyS0"', + {"opt_type": "single", "opt_val": "cloud-init"}, + 'GRUB_CMDLINE_LINUX="console=ttyS0"\n', + ), + ) + @ddt.unpack + def test_remove_from_option(self, cfg, value, expected_output): + parser = utils.Grub2ConfigEditor(cfg) + + parser.remove_from_option("GRUB_CMDLINE_LINUX", value) + + self.assertEqual(expected_output, parser.dump()) + + def test_remove_from_option_missing_option(self): + cfg = 'GRUB_CMDLINE_LINUX="console=ttyS0"' + parser = utils.Grub2ConfigEditor(cfg) + + parser.remove_from_option( + "GRUB_CMDLINE_LINUX_DEFAULT", {"opt_type": "single", "opt_val": "quiet"} + ) + + self.assertEqual('GRUB_CMDLINE_LINUX="console=ttyS0"\n', parser.dump()) + + def test_remove_from_option_invalid_value(self): + self.assertRaises( + ValueError, + self.parser.remove_from_option, + "GRUB_CMDLINE_LINUX", + {"opt_type": "invalid", "opt_val": "quiet"}, + ) + @ddt.data( ([{"type": "raw", "payload": "raw_data"}], "raw_data\n"), ( diff --git a/coriolis/utils.py b/coriolis/utils.py index 9e53e4c5..c4a8652b 100644 --- a/coriolis/utils.py +++ b/coriolis/utils.py @@ -1144,6 +1144,51 @@ def append_to_option(self, option, value): } ) + @staticmethod + def _split_value(value): + """Returns the (name, value) pair held by an option value. + + The value is None for a name which has none assigned to it. Values + of type "single" are also split, as they are used for opaque + arguments which may themselves be "name=value" pairs. + """ + + if value["opt_type"] == "key_val": + return str(value["opt_key"]), str(value["opt_val"]) + name, separator, val = str(value["opt_val"]).partition("=") + return name, val if separator else None + + @classmethod + def _matches_for_removal(cls, existing, value): + """Checks whether an existing option value is targeted by 'value'.""" + + existing_name, existing_val = cls._split_value(existing) + name, val = cls._split_value(value) + if existing_name != name: + return False + return val is None or existing_val == val + + def remove_from_option(self, option, value): + """Removes a value from the specified option. + + The semantics match those of 'grubby --remove-args': a bare name + removes the argument no matter which value it holds (removing + "cloud-init" drops both "cloud-init" and "cloud-init=disabled"), + while a name/value pair only removes an argument with that exact + same name and value. + """ + + self._validate_value(value) + for opt in self._parsed: + if opt.get("option_name") != option: + continue + opt["option_value"] = [ + val + for val in opt["option_value"] + if not self._matches_for_removal(val, value) + ] + break + def dump(self): """dumps the contents of the file""" tmp = StringIO() From 6e83c6cf712ecf823ad70ba0e8bc8bc7796ff35a Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Tue, 8 Sep 2026 16:09:46 +0300 Subject: [PATCH 2/3] Handle kernel command line arguments in the base OSMorphing tools Signed-off-by: Mihaela Balutoiu --- coriolis/osmorphing/base.py | 71 +++++++---- coriolis/osmorphing/debian.py | 26 +--- coriolis/osmorphing/suse.py | 28 +---- coriolis/tests/osmorphing/test_base.py | 146 ++++++++++++++--------- coriolis/tests/osmorphing/test_debian.py | 45 ++----- coriolis/tests/osmorphing/test_suse.py | 16 +-- 6 files changed, 164 insertions(+), 168 deletions(-) diff --git a/coriolis/osmorphing/base.py b/coriolis/osmorphing/base.py index de2b8b90..946b13f3 100644 --- a/coriolis/osmorphing/base.py +++ b/coriolis/osmorphing/base.py @@ -17,6 +17,7 @@ from coriolis.osmorphing.netpreserver import factory GRUB2_SERIAL = "serial --word=8 --stop=1 --speed=%d --parity=%s --unit=0" +GRUB2_CMDLINE_OPTIONS = ["GRUB_CMDLINE_LINUX", "GRUB_CMDLINE_LINUX_DEFAULT"] LOG = logging.getLogger(__name__) IFCFG_TEMPLATE = """ @@ -756,10 +757,7 @@ def _ensure_cloud_init_not_disabled(self): if self._test_path(grub_conf_disabler): contents = self._read_file_sudo(grub_conf_disabler) if "cloud-init=disabled" in contents: - self._exec_cmd_chroot( - "sed -i '/cloud-init=disabled/d' %s" % grub_conf_disabler - ) - self._schedule_grub2_update() + self._update_kernel_cmdline_args(args_to_remove=["cloud-init=disabled"]) def _reset_cloud_init_run(self): self._exec_cmd_chroot("cloud-init clean --logs") @@ -930,26 +928,53 @@ def replace_in_cfg(opt, val): cfg = self._read_file_sudo(config_obj["location"]) LOG.warning("TEMP CONFIG IS: %r" % cfg) - def _set_grub2_cmdline(self, config_obj, options, clobber=False): - kernel_cmd_def = config_obj["contents"].get("GRUB_CMDLINE_LINUX_DEFAULT") - kernel_cmd = config_obj["contents"].get("GRUB_CMDLINE_LINUX") - replace = kernel_cmd is not None + @staticmethod + def _normalize_kernel_cmdline_args(args): + """Returns the given kernel command line arguments as a list.""" - if clobber: - opt = " ".join(options) - self.set_grub_value("GRUB_CMDLINE_LINUX", opt, config_obj, replace=replace) - return - kernel_cmd_def = kernel_cmd_def or "" - kernel_cmd = kernel_cmd or "" - to_add = [] - for option in options: - if option not in kernel_cmd_def and option not in kernel_cmd: - to_add.append(option) - if len(to_add): - kernel_cmd = "%s %s" % (kernel_cmd, " ".join(to_add)) - self.set_grub_value( - "GRUB_CMDLINE_LINUX", kernel_cmd, config_obj, replace=replace + if isinstance(args, str): + return [args] if args else [] + return list(args or []) + + @staticmethod + def _get_kernel_cmdline_arg_value(arg): + """Converts a kernel command line argument to a Grub2ConfigEditor value.""" + + key, separator, value = arg.partition("=") + if not separator: + return {"opt_type": "single", "opt_val": key} + return {"opt_type": "key_val", "opt_key": key, "opt_val": value} + + def _update_kernel_cmdline_args(self, args_to_add=None, args_to_remove=None): + """Updates the kernel command line arguments in the GRUB2 defaults. + + The arguments are edited in '/etc/default/grub' and the GRUB2 config + regeneration is scheduled so that they reach the boot entries. + """ + args_to_add = self._normalize_kernel_cmdline_args(args_to_add) + args_to_remove = self._normalize_kernel_cmdline_args(args_to_remove) + if not args_to_add and not args_to_remove: + return False + + grub_conf = self._get_grub_default_conf() + if not grub_conf: + LOG.warning( + "Could not find '/etc/default/grub'. Skipping kernel command " + "line arguments update." ) + return False + + cfg = utils.Grub2ConfigEditor(self._read_file_sudo(grub_conf)) + for option in GRUB2_CMDLINE_OPTIONS: + for arg in args_to_remove: + cfg.remove_from_option(option, self._get_kernel_cmdline_arg_value(arg)) + for arg in args_to_add: + cfg.remove_from_option(option, self._get_kernel_cmdline_arg_value(arg)) + cfg.append_to_option(option, {"opt_type": "single", "opt_val": arg}) + self._write_file_sudo(grub_conf.lstrip("/"), cfg.dump()) + self._schedule_grub2_update() + + return True def _get_grub_default_conf(self): grub_conf = "/etc/default/grub" @@ -1055,8 +1080,8 @@ def _set_grub2_console_settings( c = "console=%s" % console options.append(c) - self._set_grub2_cmdline(config_obj, options) self._apply_grub2_config(config_obj, execute_update_grub) + self._update_kernel_cmdline_args(args_to_add=options) def _add_net_udev_rules(self, net_ifaces_info): coriolis_udev_rules_file = "etc/udev/rules.d/99-coriolis-net.rules" diff --git a/coriolis/osmorphing/debian.py b/coriolis/osmorphing/debian.py index 5d8595d4..cc0671ad 100644 --- a/coriolis/osmorphing/debian.py +++ b/coriolis/osmorphing/debian.py @@ -8,7 +8,7 @@ import yaml from oslo_log import log as logging -from coriolis import constants, exception, utils +from coriolis import constants, exception from coriolis.osmorphing import base from coriolis.osmorphing.osdetect import debian as debian_osdetect @@ -69,29 +69,7 @@ def check_os_supported(cls, detected_os_info): ) def disable_predictable_nic_names(self): - grub_cfg = "etc/default/grub" - if self._test_path_chroot(grub_cfg) is False: - return - contents = self._read_file_sudo(grub_cfg) - cfg = utils.Grub2ConfigEditor(contents) - cfg.append_to_option( - "GRUB_CMDLINE_LINUX_DEFAULT", - {"opt_type": "key_val", "opt_key": "net.ifnames", "opt_val": 0}, - ) - cfg.append_to_option( - "GRUB_CMDLINE_LINUX_DEFAULT", - {"opt_type": "key_val", "opt_key": "biosdevname", "opt_val": 0}, - ) - cfg.append_to_option( - "GRUB_CMDLINE_LINUX", - {"opt_type": "key_val", "opt_key": "net.ifnames", "opt_val": 0}, - ) - cfg.append_to_option( - "GRUB_CMDLINE_LINUX", - {"opt_type": "key_val", "opt_key": "biosdevname", "opt_val": 0}, - ) - self._write_file_sudo("etc/default/grub", cfg.dump()) - self._schedule_grub2_update() + self._update_kernel_cmdline_args(args_to_add=["net.ifnames=0", "biosdevname=0"]) def get_update_grub2_command(self): return "update-grub" diff --git a/coriolis/osmorphing/suse.py b/coriolis/osmorphing/suse.py index 6485ee20..bf45a8b1 100644 --- a/coriolis/osmorphing/suse.py +++ b/coriolis/osmorphing/suse.py @@ -69,33 +69,7 @@ def check_os_supported(cls, detected_os_info): return False def disable_predictable_nic_names(self): - grub_cfg = "etc/default/grub" - if not self._test_path(grub_cfg): - LOG.warning( - "Could not find /%s. Skipping predictable NIC names disabling.", - grub_cfg, - ) - return - contents = self._read_file_sudo(grub_cfg) - cfg = utils.Grub2ConfigEditor(contents) - cfg.append_to_option( - "GRUB_CMDLINE_LINUX_DEFAULT", - {"opt_type": "key_val", "opt_key": "net.ifnames", "opt_val": 0}, - ) - cfg.append_to_option( - "GRUB_CMDLINE_LINUX_DEFAULT", - {"opt_type": "key_val", "opt_key": "biosdevname", "opt_val": 0}, - ) - cfg.append_to_option( - "GRUB_CMDLINE_LINUX", - {"opt_type": "key_val", "opt_key": "net.ifnames", "opt_val": 0}, - ) - cfg.append_to_option( - "GRUB_CMDLINE_LINUX", - {"opt_type": "key_val", "opt_key": "biosdevname", "opt_val": 0}, - ) - self._write_file_sudo("etc/default/grub", cfg.dump()) - self._schedule_grub2_update() + self._update_kernel_cmdline_args(args_to_add=["net.ifnames=0", "biosdevname=0"]) def set_net_config(self, nics_info, dhcp): if dhcp: diff --git a/coriolis/tests/osmorphing/test_base.py b/coriolis/tests/osmorphing/test_base.py index 4358a9ec..9cb890b8 100644 --- a/coriolis/tests/osmorphing/test_base.py +++ b/coriolis/tests/osmorphing/test_base.py @@ -1006,14 +1006,14 @@ def test__disable_installer_cloud_config_no_file( ( (False, False, True), 'GRUB_CMDLINE_LINUX="console=ttyS0 cloud-init=disabled"', - ["sed -i '/cloud-init=disabled/d' /etc/default/grub"], + [], True, ), ((False, False, True), 'GRUB_CMDLINE_LINUX="console=ttyS0"', [], False), ) @ddt.unpack + @mock.patch.object(base.BaseLinuxOSMorphingTools, "_update_kernel_cmdline_args") @mock.patch.object(base.BaseLinuxOSMorphingTools, "_read_file_sudo") - @mock.patch.object(base.BaseLinuxOSMorphingTools, "_schedule_grub2_update") @mock.patch.object(base.BaseLinuxOSMorphingTools, "_exec_cmd_chroot") @mock.patch.object(base.BaseLinuxOSMorphingTools, "_test_path") def test__ensure_cloud_init_not_disabled( @@ -1024,8 +1024,8 @@ def test__ensure_cloud_init_not_disabled( updates_grub, mock__test_path, mock__exec_cmd_chroot, - mock__schedule_grub2_update, mock__read_file_sudo, + mock__update_kernel_cmdline_args, ): mock__test_path.side_effect = test_path_results mock__read_file_sudo.return_value = grub_defaults_contents @@ -1035,9 +1035,11 @@ def test__ensure_cloud_init_not_disabled( called_cmds = [call.args[0] for call in mock__exec_cmd_chroot.call_args_list] self.assertEqual(called_cmds, expected_cmds) if updates_grub: - mock__schedule_grub2_update.assert_called_once() + mock__update_kernel_cmdline_args.assert_called_once_with( + args_to_remove=["cloud-init=disabled"] + ) else: - mock__schedule_grub2_update.assert_not_called() + mock__update_kernel_cmdline_args.assert_not_called() @mock.patch.object(base.BaseLinuxOSMorphingTools, "_exec_cmd_chroot") def test__reset_cloud_init_run(self, mock__exec_cmd_chroot): @@ -1486,53 +1488,89 @@ def test_set_grub_value_with_embedded_quotes( shlex.split(mock_exec_cmd_chroot.call_args[0][0]), ) - @mock.patch.object(base.BaseLinuxOSMorphingTools, 'set_grub_value') - def test__set_grub2_cmdline_clobber(self, mock_set_grub_value): - config_obj = { - 'contents': { - 'GRUB_CMDLINE_LINUX_DEFAULT': mock.sentinel.default, - 'GRUB_CMDLINE_LINUX': mock.sentinel.linux, - }, - } - options = ['option1', 'option2'] - - self.os_morphing_tools._set_grub2_cmdline(config_obj, options, clobber=True) + @ddt.data( + ( + 'console=ttyS0', + {'opt_type': 'key_val', 'opt_key': 'console', 'opt_val': 'ttyS0'}, + ), + ('cloud-init', {'opt_type': 'single', 'opt_val': 'cloud-init'}), + ( + 'rd.lvm.lv=vg/root', + {'opt_type': 'key_val', 'opt_key': 'rd.lvm.lv', 'opt_val': 'vg/root'}, + ), + ) + @ddt.unpack + def test__get_kernel_cmdline_arg_value(self, arg, expected_value): + self.assertEqual( + expected_value, + self.os_morphing_tools._get_kernel_cmdline_arg_value(arg), + ) - mock_set_grub_value.assert_called_once_with( - 'GRUB_CMDLINE_LINUX', ' '.join(options), config_obj, replace=True + @ddt.data((None, None), ([], []), ('', '')) + @ddt.unpack + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_get_grub_default_conf') + def test__update_kernel_cmdline_args_no_args( + self, args_to_add, args_to_remove, mock_get_grub_default_conf + ): + result = self.os_morphing_tools._update_kernel_cmdline_args( + args_to_add=args_to_add, args_to_remove=args_to_remove ) - @mock.patch.object(base.BaseLinuxOSMorphingTools, 'set_grub_value') - def test__set_grub2_cmdline_add_options(self, mock_set_grub_value): - config_obj = { - 'contents': { - 'GRUB_CMDLINE_LINUX_DEFAULT': 'quiet_default', - 'GRUB_CMDLINE_LINUX': 'quiet_linux', - }, - } - options = ['option1', 'option2'] + self.assertFalse(result) + mock_get_grub_default_conf.assert_not_called() + + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_schedule_grub2_update') + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_write_file_sudo') + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_read_file_sudo') + @mock.patch.object( + base.BaseLinuxOSMorphingTools, + '_get_grub_default_conf', + return_value='/etc/default/grub', + ) + def test__update_kernel_cmdline_args( + self, + _mock_get_grub_default_conf, + mock_read_file_sudo, + mock_write_file_sudo, + mock_schedule_grub2_update, + ): + mock_read_file_sudo.return_value = ( + 'GRUB_CMDLINE_LINUX="console=ttyS1 cloud-init=disabled"\n' + ) - self.os_morphing_tools._set_grub2_cmdline(config_obj, options, clobber=False) + result = self.os_morphing_tools._update_kernel_cmdline_args( + args_to_add=['console=ttyS0'], args_to_remove=['cloud-init'] + ) - mock_set_grub_value.assert_called_once_with( - 'GRUB_CMDLINE_LINUX', - 'quiet_linux option1 option2', - config_obj, - replace=True, + self.assertTrue(result) + mock_read_file_sudo.assert_called_once_with('/etc/default/grub') + mock_write_file_sudo.assert_called_once_with( + 'etc/default/grub', + 'GRUB_CMDLINE_LINUX="console=ttyS1 console=ttyS0"\n' + 'GRUB_CMDLINE_LINUX_DEFAULT="console=ttyS0"\n', ) + mock_schedule_grub2_update.assert_called_once_with() - @mock.patch.object(base.BaseLinuxOSMorphingTools, 'set_grub_value') - def test__set_grub2_cmdline_no_options_to_add(self, mock_set_grub_value): - config_obj = { - 'contents': { - 'GRUB_CMDLINE_LINUX_DEFAULT': 'quiet_option1', - 'GRUB_CMDLINE_LINUX': 'quiet_option2', - }, - } - options = ['option1'] + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_schedule_grub2_update') + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_write_file_sudo') + @mock.patch.object( + base.BaseLinuxOSMorphingTools, + '_get_grub_default_conf', + return_value=None, + ) + def test__update_kernel_cmdline_args_no_grub_defaults( + self, + _mock_get_grub_default_conf, + mock_write_file_sudo, + mock_schedule_grub2_update, + ): + result = self.os_morphing_tools._update_kernel_cmdline_args( + args_to_add=['console=ttyS0'] + ) - self.os_morphing_tools._set_grub2_cmdline(config_obj, options, clobber=False) - mock_set_grub_value.assert_not_called() + self.assertFalse(result) + mock_write_file_sudo.assert_not_called() + mock_schedule_grub2_update.assert_not_called() @mock.patch.object( base.BaseLinuxOSMorphingTools, @@ -1792,16 +1830,16 @@ def test__set_grub2_console_settings_invalid_consoles(self): consoles='invalid_consoles', ) + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_update_kernel_cmdline_args') @mock.patch.object(base.BaseLinuxOSMorphingTools, '_apply_grub2_config') - @mock.patch.object(base.BaseLinuxOSMorphingTools, '_set_grub2_cmdline') @mock.patch.object(base.BaseLinuxOSMorphingTools, 'set_grub_value') @mock.patch.object(base.BaseLinuxOSMorphingTools, '_get_grub_config_obj') def test__set_grub2_console_settings_all_params( self, mock_get_grub_config_obj, mock_set_grub_value, - mock_set_grub2_cmdline, mock_apply_grub2_config, + mock_update_kernel_cmdline_args, ): consoles = ['tty0', 'ttyS0'] speed = 9600 @@ -1821,21 +1859,21 @@ def test__set_grub2_console_settings_all_params( mock_set_grub_value.assert_called_once_with( 'GRUB_SERIAL_COMMAND', serial_cmd, config_obj ) - mock_set_grub2_cmdline.assert_called_once_with( - config_obj, ['console=tty0', 'console=ttyS0'] - ) mock_apply_grub2_config.assert_called_once_with(config_obj, False) + mock_update_kernel_cmdline_args.assert_called_once_with( + args_to_add=['console=tty0', 'console=ttyS0'] + ) + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_update_kernel_cmdline_args') @mock.patch.object(base.BaseLinuxOSMorphingTools, '_apply_grub2_config') - @mock.patch.object(base.BaseLinuxOSMorphingTools, '_set_grub2_cmdline') @mock.patch.object(base.BaseLinuxOSMorphingTools, 'set_grub_value') @mock.patch.object(base.BaseLinuxOSMorphingTools, '_get_grub_config_obj') def test__set_grub2_console_settings_default_params( self, mock_get_grub_config_obj, mock_set_grub_value, - mock_set_grub2_cmdline, mock_apply_grub2_config, + mock_update_kernel_cmdline_args, ): grub_conf = '/etc/default/grub' @@ -1852,10 +1890,10 @@ def test__set_grub2_console_settings_default_params( 'serial --word=8 --stop=1 --speed=115200 --parity=no --unit=0', config_obj, ) - mock_set_grub2_cmdline.assert_called_once_with( - config_obj, ['console=tty0', 'console=ttyS0'] - ) mock_apply_grub2_config.assert_called_once_with(config_obj, True) + mock_update_kernel_cmdline_args.assert_called_once_with( + args_to_add=['console=tty0', 'console=ttyS0'] + ) @mock.patch.object(base.BaseLinuxOSMorphingTools, '_test_path') @mock.patch.object(base.BaseLinuxOSMorphingTools, '_write_file_sudo') diff --git a/coriolis/tests/osmorphing/test_debian.py b/coriolis/tests/osmorphing/test_debian.py index e74f0058..7848ad0e 100644 --- a/coriolis/tests/osmorphing/test_debian.py +++ b/coriolis/tests/osmorphing/test_debian.py @@ -71,7 +71,6 @@ def test_noninteractive_frontend_survives_set_environment(self): ) @mock.patch.object(debian.BaseDebianMorphingTools, '_schedule_grub2_update') - @mock.patch('coriolis.utils.Grub2ConfigEditor') @mock.patch.object(debian.BaseDebianMorphingTools, '_test_path_chroot') @mock.patch.object(debian.BaseDebianMorphingTools, '_write_file_sudo') @mock.patch.object(debian.BaseDebianMorphingTools, '_read_file_sudo') @@ -80,53 +79,35 @@ def test_disable_predictable_nic_names( mock_read_file_sudo, mock_write_file_sudo, mock_test_path_chroot, - mock_grub2_cfg_editor, mock_schedule_grub2_update, ): mock_test_path_chroot.return_value = True + mock_read_file_sudo.return_value = ( + 'GRUB_CMDLINE_LINUX_DEFAULT=""\nGRUB_CMDLINE_LINUX=""\n' + ) self.morpher.disable_predictable_nic_names() - mock_test_path_chroot.assert_called_once_with('etc/default/grub') - mock_grub2_cfg_editor.assert_called_once_with(mock_read_file_sudo.return_value) - mock_grub2_cfg_editor.return_value.append_to_option.assert_has_calls( - [ - mock.call( - "GRUB_CMDLINE_LINUX_DEFAULT", - {"opt_type": "key_val", "opt_key": "net.ifnames", "opt_val": 0}, - ), - mock.call( - "GRUB_CMDLINE_LINUX_DEFAULT", - {"opt_type": "key_val", "opt_key": "biosdevname", "opt_val": 0}, - ), - mock.call( - "GRUB_CMDLINE_LINUX", - {"opt_type": "key_val", "opt_key": "net.ifnames", "opt_val": 0}, - ), - mock.call( - "GRUB_CMDLINE_LINUX", - {"opt_type": "key_val", "opt_key": "biosdevname", "opt_val": 0}, - ), - ] - ) - mock_read_file_sudo.assert_called_once_with('etc/default/grub') - mock_write_file_sudo.assert_called_once_with( - "etc/default/grub", mock_grub2_cfg_editor.return_value.dump() - ) + mock_test_path_chroot.assert_called_once_with('/etc/default/grub') + mock_read_file_sudo.assert_called_once_with('/etc/default/grub') + written_path, written_contents = mock_write_file_sudo.call_args[0] + self.assertEqual("etc/default/grub", written_path) + self.assertIn("net.ifnames=0", written_contents) + self.assertIn("biosdevname=0", written_contents) mock_schedule_grub2_update.assert_called_once_with() - @mock.patch('coriolis.utils.Grub2ConfigEditor') + @mock.patch.object(debian.BaseDebianMorphingTools, '_schedule_grub2_update') @mock.patch.object(debian.BaseDebianMorphingTools, '_exec_cmd_chroot') @mock.patch.object(debian.BaseDebianMorphingTools, '_write_file_sudo') @mock.patch.object(debian.BaseDebianMorphingTools, '_read_file_sudo') @mock.patch.object(debian.BaseDebianMorphingTools, '_test_path_chroot') - def test_disable_predictable_nic_names_no_test_path_chroot( + def test_disable_predictable_nic_names_no_grub_defaults( self, mock_test_path_chroot, mock_read_file_sudo, mock_write_file_sudo, mock_exec_cmd_chroot, - mock_grub2_cfg_editor, + mock_schedule_grub2_update, ): mock_test_path_chroot.return_value = False @@ -136,7 +117,7 @@ def test_disable_predictable_nic_names_no_test_path_chroot( mock_read_file_sudo.assert_not_called() mock_write_file_sudo.assert_not_called() mock_exec_cmd_chroot.assert_not_called() - mock_grub2_cfg_editor.assert_not_called() + mock_schedule_grub2_update.assert_not_called() def test_get_update_grub2_command(self): result = self.morpher.get_update_grub2_command() diff --git a/coriolis/tests/osmorphing/test_suse.py b/coriolis/tests/osmorphing/test_suse.py index 7dc5af42..429a3ac8 100644 --- a/coriolis/tests/osmorphing/test_suse.py +++ b/coriolis/tests/osmorphing/test_suse.py @@ -477,22 +477,22 @@ def test__get_existing_ethernet_nmconnection_files_no_path( @mock.patch.object(base.BaseLinuxOSMorphingTools, '_write_file_sudo') @mock.patch.object(suse.BaseSUSEMorphingTools, '_schedule_grub2_update') @mock.patch.object(base.BaseLinuxOSMorphingTools, '_read_file_sudo') - @mock.patch.object(base.BaseLinuxOSMorphingTools, '_test_path') + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_test_path_chroot') def test_disable_predictable_nic_names( self, - mock_test_path, + mock_test_path_chroot, mock_read_file_sudo, mock_schedule_grub2_update, mock_write_file_sudo, ): - mock_test_path.return_value = True + mock_test_path_chroot.return_value = True mock_read_file_sudo.return_value = ( 'GRUB_CMDLINE_LINUX_DEFAULT=""\nGRUB_CMDLINE_LINUX=""\n' ) self.morphing_tools.disable_predictable_nic_names() - mock_read_file_sudo.assert_called_once_with("etc/default/grub") + mock_read_file_sudo.assert_called_once_with("/etc/default/grub") mock_write_file_sudo.assert_called_once() written_path, written_contents = mock_write_file_sudo.call_args[0] self.assertEqual("etc/default/grub", written_path) @@ -501,11 +501,11 @@ def test_disable_predictable_nic_names( # The (slow) grub regeneration must be deferred, not run eagerly. mock_schedule_grub2_update.assert_called_once_with() - @mock.patch.object(base.BaseLinuxOSMorphingTools, '_test_path') - def test_disable_predictable_nic_names_no_grub_cfg(self, mock_test_path): - mock_test_path.return_value = False + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_test_path_chroot') + def test_disable_predictable_nic_names_no_grub_cfg(self, mock_test_path_chroot): + mock_test_path_chroot.return_value = False - with self.assertLogs('coriolis.osmorphing.suse', level=logging.WARNING): + with self.assertLogs('coriolis.osmorphing.base', level=logging.WARNING): self.morphing_tools.disable_predictable_nic_names() def test__ifcfg_class_attributes(self): From f6177deedc29cd532afe055d1c31c413002ba8ee Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Tue, 8 Sep 2026 16:13:17 +0300 Subject: [PATCH 3/3] Use `grubby` to update kernel command line arguments on Red Hat Use `grubby --update-kernel=ALL` to update kernel arguments on BLS-based systems such as RHEL 10, where `grub2-mkconfig` does not propagate them to `/boot/loader/entries`. Signed-off-by: Mihaela Balutoiu --- coriolis/osmorphing/redhat.py | 33 +++++++++++++- coriolis/tests/osmorphing/test_redhat.py | 58 +++++++++++++++++++++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/coriolis/osmorphing/redhat.py b/coriolis/osmorphing/redhat.py index ed90055b..ac91923e 100644 --- a/coriolis/osmorphing/redhat.py +++ b/coriolis/osmorphing/redhat.py @@ -3,6 +3,7 @@ import os import re +import shlex import uuid from oslo_log import log as logging @@ -56,8 +57,36 @@ def __init__( ) def disable_predictable_nic_names(self): - cmd = 'grubby --update-kernel=ALL --args="%s"' - self._exec_cmd_chroot(cmd % "net.ifnames=0 biosdevname=0") + self._update_kernel_cmdline_args(args_to_add=["net.ifnames=0", "biosdevname=0"]) + + def _update_kernel_cmdline_args(self, args_to_add=None, args_to_remove=None): + """Updates the kernel command line arguments using 'grubby'. + + On BLS-based releases (RHEL 9+), 'grub2-mkconfig' does not propagate + kernel arguments into the '/boot/loader/entries' boot entries. + 'grubby --update-kernel=ALL' updates every boot entry and replaces + the base implementation rather than supplementing it, so it must not + be paired with a GRUB2 regeneration for the same update. + + """ + + args_to_add = self._normalize_kernel_cmdline_args(args_to_add) + args_to_remove = self._normalize_kernel_cmdline_args(args_to_remove) + if not args_to_add and not args_to_remove: + return False + + for option, args in ( + ("--remove-args", args_to_remove), + ("--args", args_to_add), + ): + if not args: + continue + self._exec_cmd_chroot( + "grubby --update-kernel=ALL %s=%s" + % (option, shlex.quote(" ".join(args))) + ) + + return True def get_update_grub2_command(self): location = self._get_grub2_cfg_location() diff --git a/coriolis/tests/osmorphing/test_redhat.py b/coriolis/tests/osmorphing/test_redhat.py index 15d0cf1a..203545fb 100644 --- a/coriolis/tests/osmorphing/test_redhat.py +++ b/coriolis/tests/osmorphing/test_redhat.py @@ -67,13 +67,67 @@ def test_check_os_not_supported(self): self.assertFalse(result) + @mock.patch.object(redhat.BaseRedHatMorphingTools, '_update_kernel_cmdline_args') + def test_disable_predictable_nic_names(self, mock_update_kernel_cmdline_args): + self.morphing_tools.disable_predictable_nic_names() + mock_update_kernel_cmdline_args.assert_called_once_with( + args_to_add=['net.ifnames=0', 'biosdevname=0'] + ) + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_exec_cmd_chroot') - def test_disable_predictable_nic_names(self, mock_exec_cmd_chroot): + def test_disable_predictable_nic_names_updates_all_kernels( + self, mock_exec_cmd_chroot + ): self.morphing_tools.disable_predictable_nic_names() mock_exec_cmd_chroot.assert_called_once_with( - 'grubby --update-kernel=ALL --args="net.ifnames=0 biosdevname=0"' + "grubby --update-kernel=ALL --args='net.ifnames=0 biosdevname=0'" + ) + + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_exec_cmd_chroot') + def test__update_kernel_cmdline_args_remove(self, mock_exec_cmd_chroot): + result = self.morphing_tools._update_kernel_cmdline_args( + args_to_remove=['cloud-init=disabled'] + ) + + self.assertTrue(result) + mock_exec_cmd_chroot.assert_called_once_with( + 'grubby --update-kernel=ALL --remove-args=cloud-init=disabled' + ) + + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_exec_cmd_chroot') + def test__update_kernel_cmdline_args_add_and_remove(self, mock_exec_cmd_chroot): + result = self.morphing_tools._update_kernel_cmdline_args( + args_to_add=['console=ttyS0'], args_to_remove=['console=ttyS1'] + ) + + self.assertTrue(result) + self.assertEqual( + [ + mock.call('grubby --update-kernel=ALL --remove-args=console=ttyS1'), + mock.call('grubby --update-kernel=ALL --args=console=ttyS0'), + ], + mock_exec_cmd_chroot.call_args_list, ) + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_schedule_grub2_update') + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_write_file_sudo') + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_exec_cmd_chroot') + def test__update_kernel_cmdline_args_does_not_touch_grub_defaults( + self, _mock_exec_cmd_chroot, mock_write_file_sudo, mock_schedule_grub2_update + ): + """Red Hat must use 'grubby' only, never both update mechanisms.""" + self.morphing_tools._update_kernel_cmdline_args(args_to_add=['edd=off']) + + mock_write_file_sudo.assert_not_called() + mock_schedule_grub2_update.assert_not_called() + + @mock.patch.object(base.BaseLinuxOSMorphingTools, '_exec_cmd_chroot') + def test__update_kernel_cmdline_args_no_args(self, mock_exec_cmd_chroot): + result = self.morphing_tools._update_kernel_cmdline_args() + + self.assertFalse(result) + mock_exec_cmd_chroot.assert_not_called() + @mock.patch.object(redhat.BaseRedHatMorphingTools, '_get_grub2_cfg_location') def test_get_update_grub2_command(self, mock_get_grub2_cfg_location): result = self.morphing_tools.get_update_grub2_command()