diff --git a/Makefile b/Makefile index 974c602682..5240608009 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ install: .install .pre-commit .install: virtualenv --system-site-packages --python $(PYTHON) $(VENV); \ . $(VENV)/bin/activate; \ - $(PIP) install --upgrade -r ./requirements/centos8.requirements.txt; \ + $(PIP) install --upgrade -r ./requirements/centos9.requirements.txt; \ touch $@ .pre-commit: diff --git a/config/convert2rhel.ini b/config/convert2rhel.ini index ea2ade660b..8741723389 100644 --- a/config/convert2rhel.ini +++ b/config/convert2rhel.ini @@ -11,3 +11,14 @@ # password = # activation_key = # org = + +[host_metering] +# configure_host_metering = "0" + +[inhibitor_overrides] +# incomplete_rollback = "0" +# tainted_kernel_module_check_skip = "0" +# outdated_package_check_skip = "0" +# allow_older_version = "0" +# allow_unavailable_kmods = "0" +# skip_kernel_currency_check = "0" diff --git a/convert2rhel/toolopts.py b/convert2rhel/toolopts.py index 4a3f0fd7b9..11b1456fc0 100644 --- a/convert2rhel/toolopts.py +++ b/convert2rhel/toolopts.py @@ -62,6 +62,21 @@ # For a list of modified rpm files after the conversion finishes for comparison purposes POST_RPM_VA_LOG_FILENAME = "rpm_va_after_conversion.log" +CONFIG_FILE_MAPPING_OPTIONS = { + "subscription_manager": ["username", "password", "org", "activation_key"], + "host_metering": [ + "configure_host_metering", + ], + "inhibitor_overrides": [ + "incomplete_rollback", + "tainted_kernel_module_check_skip", + "outdated_package_check_skip", + "allow_older_version", + "allow_unavailable_kmods", + "skip_kernel_currency_check", + ], +} + class ToolOpts: def __init__(self): @@ -87,13 +102,22 @@ def __init__(self): self.els = False self.activity = None + # Settings + self.incomplete_rollback = None + self.tainted_kernel_module_check_skip = None + self.outdated_package_check_skip = None + self.allow_older_version = None + self.allow_unavailable_kmods = None + self.configure_host_metering = None + self.skip_kernel_currency_check = None + def set_opts(self, supported_opts): """Set ToolOpts data using dict with values from config file. :param supported_opts: Supported options in config file """ for key, value in supported_opts.items(): - if value is not None and hasattr(self, key): + if value and hasattr(self, key): setattr(self, key, value) @@ -515,70 +539,118 @@ def _process_cli_options(self): ) -def _log_command_used(): - """We want to log the command used for convert2rhel to make it easier to know what command was used - when debugging the log files. Since we can't differentiate between the handlers we log to both stdout - and the logfile - """ - command = " ".join(utils.hide_secrets(sys.argv)) - loggerinst.info("convert2rhel command used:\n{0}".format(command)) - - -def options_from_config_files(cfg_path=None): +def options_from_config_files(cfg_path): """Parse the convert2rhel.ini configuration file. This function will try to parse the convert2rhel.ini configuration file and return a dictionary containing the values found in the file. .. note:: - This function will parse the configuration file following a specific - order, which is: - 1) Path provided by the user in cfg_path (Highest priority). - 2) ~/.convert2rhel.ini (The 2nd highest priority). - 3) /etc/convert2rhel.ini (The lowest priority). + This function will parse the configuration file in the following way: + + 1) If the path provided by the user in cfg_path is set (Highest + priority), then we use only that. + + Otherwise, if cfg_path is `None`, we proceed to check the following + paths: + + 2) ~/.convert2rhel.ini (The 2nd highest priority). + 3) /etc/convert2rhel.ini (The lowest priority). + + In any case, they are parsed in reversed order, meaning that we will + start with the lowest priority and go until the highest. :param cfg_path: Path of a custom configuration file :type cfg_path: str :return: Dict with the supported options alongside their values. - :rtype: dict[str, str | None] + :rtype: dict[str, str] """ - headers = ["subscription_manager"] # supported sections in config file - # Create dict with all supported options, all of them set to None - # needed for avoiding problems with files priority - # The name of supported option MUST correspond with the name in ToolOpts() - # Otherwise it won't be used - supported_opts = {"username": None, "password": None, "activation_key": None, "org": None} + # Paths for the configuration files. In case we have cfg_path defined + # (meaning that the user entered something through the `-c` option), we + # will use only that, as it has a higher priority over the rest + config_paths = [cfg_path] if cfg_path else CONFIG_PATHS + paths = [os.path.expanduser(path) for path in config_paths if os.path.exists(os.path.expanduser(path))] + + if cfg_path and not paths: + raise FileNotFoundError("No such file or directory: %s" % ", ".join(paths)) + + found_opts = _parse_options_from_config(paths) + return found_opts + +def _parse_options_from_config(paths): + """Parse the options from the given config files. + + .. note:: + If no configuration file is provided through the command line option + (`-c`), we will use the default paths and follow their priority. + + :param paths: List of paths to iterate through and gather the options from + them. + :type paths: list[str] + """ config_file = configparser.ConfigParser() - paths = [os.path.expanduser(path) for path in CONFIG_PATHS] - - if cfg_path: - cfg_path = os.path.expanduser(cfg_path) - if not os.path.exists(cfg_path): - raise OSError(2, "No such file or directory: '%s'" % cfg_path) - paths.insert(0, cfg_path) # highest priority - - for path in paths: - if os.path.exists(path): - if not oct(os.stat(path).st_mode)[-4:].endswith("00"): - loggerinst.critical("The %s file must only be accessible by the owner (0600)" % path) - config_file.read(path) - - for header in config_file.sections(): - if header in headers: - for option in config_file.options(header): - if option.lower() in supported_opts: - # Solving priority - if supported_opts[option.lower()] is None: - supported_opts[option] = config_file.get(header, option) - loggerinst.debug("Found %s in %s" % (option, path)) - else: - loggerinst.warning("Unsupported option %s in %s" % (option, path)) - elif header not in headers and header != "DEFAULT": - loggerinst.warning("Unsupported header %s in %s." % (header, path)) - - return supported_opts + found_opts = {} + + for path in reversed(paths): + loggerinst.debug("Checking configuration file at %s" % path) + # Check for correct permissions on file + if not oct(os.stat(path).st_mode)[-4:].endswith("00"): + loggerinst.critical("The %s file must only be accessible by the owner (0600)" % path) + + config_file.read(path) + + # Mapping of all supported options we can have in the config file + for supported_header, supported_opts in CONFIG_FILE_MAPPING_OPTIONS.items(): + loggerinst.debug("Checking for header '%s'" % supported_header) + if supported_header not in config_file.sections(): + loggerinst.warning("Couldn't find header '%s' in the configuration file %s." % (supported_header, path)) + continue + + options = _get_options_value(config_file, supported_header, supported_opts) + found_opts.update(options) + + return found_opts + + +def _get_options_value(config_file, header, supported_opts): + """Helper function to iterate through the options in a config file. + + :param config_file: An instance of `py:ConfigParser` after reading the file + to iterate through the options. + :type config_file: configparser.ConfigParser + :param header: The header name to get options from. + :type header: str + :param supported_opts: List of supported options that can be parsed from + the config file. + :type supported_opts: list[str] + """ + options = {} + conf_options = config_file.options(header) + + if len(conf_options) == 0: + loggerinst.debug("No options found for %s. It seems to be empty or commented." % header) + return options + + for option in conf_options: + if option.lower() not in supported_opts: + loggerinst.warning("Unsupported option '%s' in '%s'" % (option, header)) + continue + + options[option] = config_file.get(header, option).strip('"') + loggerinst.debug("Found %s in %s" % (option, header)) + + return options + + +def _log_command_used(): + """We want to log the command used for convert2rhel to make it easier to know what command was used + when debugging the log files. Since we can't differentiate between the handlers we log to both stdout + and the logfile + """ + command = " ".join(utils.hide_secrets(sys.argv)) + loggerinst.info("convert2rhel command used:\n{0}".format(command)) def _parse_subscription_manager_serverurl(serverurl): diff --git a/convert2rhel/unit_tests/toolopts_test.py b/convert2rhel/unit_tests/toolopts_test.py index b7df8aabe1..ad21196d1e 100644 --- a/convert2rhel/unit_tests/toolopts_test.py +++ b/convert2rhel/unit_tests/toolopts_test.py @@ -309,131 +309,262 @@ def test_multiple_auth_src_cli(argv, message, output, caplog, monkeypatch, globa assert convert2rhel.toolopts.tool_opts.password == output["password"] +@pytest.mark.parametrize( + ("content", "expected_message"), + ( + ( + """ +[subscription_manager] +incorect_option = yes + """, + "Unsupported option", + ), + ( + """ +[invalid_header] +username = correct_username + """, + "Couldn't find header", + ), + ), +) +def test_options_from_config_files_invalid_head_and_options(content, expected_message, tmpdir, caplog): + path = os.path.join(str(tmpdir), "convert2rhel.ini") + + with open(path, "w") as file: + file.write(content) + os.chmod(path, 0o600) + + opts = convert2rhel.toolopts.options_from_config_files(path) + + assert not opts + assert expected_message in caplog.text + + +@pytest.mark.parametrize( + ("content", "expected_message"), + ( + ( + """ +[subscription_manager] + """, + "No options found for subscription_manager. It seems to be empty or commented.", + ), + ), +) +def test_options_from_config_files_commented_out_options(content, expected_message, tmpdir, caplog): + path = os.path.join(str(tmpdir), "convert2rhel.ini") + + with open(path, "w") as file: + file.write(content) + os.chmod(path, 0o600) + + opts = convert2rhel.toolopts.options_from_config_files(path) + + assert not opts + assert expected_message in caplog.text + + @pytest.mark.parametrize( ("content", "output"), ( ( - "[subscription_manager]\nusername = correct_username", - {"username": "correct_username", "password": None, "activation_key": None, "org": None}, + """ +[subscription_manager] +username = correct_username + """, + {"username": "correct_username"}, ), + # Test if we will unquote this correctly ( - "[subscription_manager]\npassword = correct_password", - {"username": None, "password": "correct_password", "activation_key": None, "org": None}, + """ +[subscription_manager] +username = "correct_username" + """, + {"username": "correct_username"}, ), - pytest.param( - "[subscription_manager]\n" - "activation_key = correct_key\n" - "Password = correct_password\n" - "username = correct_username\n" - "org = correct_org\n", + ( + """ +[subscription_manager] +password = correct_password + """, + {"password": "correct_password"}, + ), + ( + """ +[subscription_manager] +activation_key = correct_key +password = correct_password +username = correct_username +org = correct_org + """, { "username": "correct_username", "password": "correct_password", "activation_key": "correct_key", "org": "correct_org", }, - id="All options used together", ), ( - "[subscription_manager]\norg = correct_org", - {"username": None, "password": None, "activation_key": None, "org": "correct_org"}, + """ +[subscription_manager] +org = correct_org + """, + {"org": "correct_org"}, + ), + ( + """ +[settings] +incomplete_rollback = 1 + """, + {"incomplete_rollback": "1"}, ), ( - "[subscription_manager]\nincorrect_option = incorrect_content", - {"username": None, "password": None, "activation_key": None, "org": None}, + """ +[subscription_manager] +org = correct_org + +[settings] +incomplete_rollback = 1 + """, + {"org": "correct_org", "incomplete_rollback": "1"}, ), ( - "[INVALID_HEADER]\nusername = correct_username\npassword = correct_password\nactivation_key = correct_key\norg = correct_org", - {"username": None, "password": None, "activation_key": None, "org": None}, + """ +[settings] +incomplete_rollback = 1 +tainted_kernel_module_check_skip = 1 +outdated_package_check_skip = 1 +allow_older_version = 1 +allow_unavailable_kmods = 1 +configure_host_metering = 1 +skip_kernel_currency_check = 1 + """, + { + "incomplete_rollback": "1", + "tainted_kernel_module_check_skip": "1", + "outdated_package_check_skip": "1", + "allow_older_version": "1", + "allow_unavailable_kmods": "1", + "configure_host_metering": "1", + "skip_kernel_currency_check": "1", + }, ), - (None, {"username": None, "password": None, "activation_key": None, "org": None}), ), ) -def test_options_from_config_files_default(content, output, monkeypatch, tmpdir, caplog): +def test_options_from_config_files_default(content, output, monkeypatch, tmpdir): """Test config files in default path.""" path = os.path.join(str(tmpdir), "convert2rhel.ini") - if content: - with open(path, "w") as file: - file.write(content) - os.chmod(path, 0o600) + + with open(path, "w") as file: + file.write(content) + os.chmod(path, 0o600) paths = ["/nonexisting/path", path] monkeypatch.setattr(convert2rhel.toolopts, "CONFIG_PATHS", value=paths) - opts = convert2rhel.toolopts.options_from_config_files() - - assert opts["username"] == output["username"] - assert opts["password"] == output["password"] - assert opts["activation_key"] == output["activation_key"] - assert opts["org"] == output["org"] + opts = convert2rhel.toolopts.options_from_config_files(None) - if content: - if "INVALID_HEADER" in content: - assert "Unsupported header" in caplog.text - if "incorrect_option" in content: - assert "Unsupported option" in caplog.text + for key in ["username", "password", "activation_key", "org"]: + if key in opts: + assert opts[key] == output[key] @pytest.mark.parametrize( ("content", "output", "content_lower_priority"), ( ( - "[subscription_manager]\nusername = correct_username\nactivation_key = correct_key", + """ +[subscription_manager] +username = correct_username +activation_key = correct_key + """, {"username": "correct_username", "password": None, "activation_key": "correct_key", "org": None}, - "[subscription_manager]\nusername = low_prior_username", + """ +[subscription_manager] +username = low_prior_username + """, ), ( - "[subscription_manager]\nusername = correct_username\nactivation_key = correct_key", + """ +[subscription_manager] +username = correct_username +activation_key = correct_key + """, {"username": "correct_username", "password": None, "activation_key": "correct_key", "org": None}, - "[subscription_manager]\nactivation_key = low_prior_key", + """ +[subscription_manager] +activation_key = low_prior_key + """, ), ( - "[subscription_manager]\nactivation_key = correct_key\norg = correct_org", + """ +[subscription_manager] +activation_key = correct_key +org = correct_org""", {"username": None, "password": None, "activation_key": "correct_key", "org": "correct_org"}, - "[subscription_manager]\norg = low_prior_org", + """ +[subscription_manager] +org = low_prior_org + """, ), ( - "[subscription_manager]\nactivation_key = correct_key\nPassword = correct_password", + """ +[subscription_manager] +activation_key = correct_key +Password = correct_password + """, {"username": None, "password": "correct_password", "activation_key": "correct_key", "org": None}, - "[subscription_manager]\npassword = low_prior_pass", + """ +[subscription_manager] +password = low_prior_pass + """, ), ( - "[subscription_manager]\nactivation_key = correct_key\nPassword = correct_password", + """ +[subscription_manager] +activation_key = correct_key +Password = correct_password + """, {"username": None, "password": "correct_password", "activation_key": "correct_key", "org": None}, - "[INVALID_HEADER]\npassword = low_prior_pass", + """ +[INVALID_HEADER] +password = low_prior_pass + """, ), ( - "[subscription_manager]\nactivation_key = correct_key\nPassword = correct_password", + """ +[subscription_manager] +activation_key = correct_key +Password = correct_password + """, {"username": None, "password": "correct_password", "activation_key": "correct_key", "org": None}, - "[subscription_manager]\nincorrect_option = incorrect_option", + """ +[subscription_manager] +incorrect_option = incorrect_option + """, ), ), ) -def test_options_from_config_files_specified(content, output, content_lower_priority, monkeypatch, tmpdir, caplog): +def test_options_from_config_files_specified(content, output, content_lower_priority, monkeypatch, tmpdir): """Test user specified path for config file.""" - path = os.path.join(str(tmpdir), "convert2rhel.ini") - with open(path, "w") as file: + path_higher_priority = os.path.join(str(tmpdir), "convert2rhel.ini") + with open(path_higher_priority, "w") as file: file.write(content) - os.chmod(path, 0o600) + os.chmod(path_higher_priority, 0o600) path_lower_priority = os.path.join(str(tmpdir), "convert2rhel_lower.ini") with open(path_lower_priority, "w") as file: file.write(content_lower_priority) os.chmod(path_lower_priority, 0o600) - paths = [path_lower_priority] + paths = [path_higher_priority, path_lower_priority] monkeypatch.setattr(convert2rhel.toolopts, "CONFIG_PATHS", value=paths) - # user specified path - opts = convert2rhel.toolopts.options_from_config_files(path) - assert opts["username"] == output["username"] - assert opts["password"] == output["password"] - assert opts["activation_key"] == output["activation_key"] - assert opts["org"] == output["org"] + opts = convert2rhel.toolopts.options_from_config_files(None) - if "INVALID_HEADER" in content or "INVALID_HEADER" in content_lower_priority: - assert "Unsupported header" in caplog.text - if "incorrect_option" in content or "incorrect_option" in content_lower_priority: - assert "Unsupported option" in caplog.text + for key in ["username", "password", "activation_key", "org"]: + if key in opts: + assert opts[key] == output[key] @pytest.mark.parametrize(