diff --git a/changelog/67975.fixed.md b/changelog/67975.fixed.md new file mode 100644 index 00000000000..434e4ebd213 --- /dev/null +++ b/changelog/67975.fixed.md @@ -0,0 +1 @@ +Fixed ``pkg.group_list`` and ``pkg.group_info`` on dnf5 systems (Fedora 41+, RHEL/AlmaLinux 10). dnf5 changed the ``group list``/``group info`` output format, which the yum/dnf parser did not understand, so the group functions (and ``pkg.group_installed``) returned empty or incorrect data. The group name column is now tokenized so a name containing the word "yes" or "no" is no longer mistaken for the installed column. diff --git a/salt/modules/yumpkg.py b/salt/modules/yumpkg.py index f1694e90716..90758bce972 100644 --- a/salt/modules/yumpkg.py +++ b/salt/modules/yumpkg.py @@ -2634,6 +2634,37 @@ def group_list(): "available language groups:": "available languages", } + if _yum() == "dnf5": + # dnf5 lists environment groups and language groups under separate + # subcommands ("dnf5 environment list"), not under "group list", so the + # "installed/available environments" and "available languages" keys + # stay empty on dnf5 (the dnf/yum path below still fills them). + out = __salt__["cmd.run_stdout"]( + [_yum(), "group", "list", "--hidden"], + output_loglevel="trace", + python_shell=False, + ) + for line in salt.utils.itertools.split(out, "\n"): + # dnf5 'group list' is a whitespace-aligned table: + # ID Name (may contain spaces) Installed (yes|no) + # Tokenize the row rather than matching the name with a regex, so a + # group name that happens to contain the word "yes" or "no" cannot + # be mistaken for the trailing Installed column. The header row and + # any blank/administrative lines have no yes/no last column and are + # skipped here. + parts = line.split() + if len(parts) < 3: + continue + installed = parts[-1].lower() + if installed not in ("yes", "no"): + continue + group_id = parts[0] + if installed == "yes": + ret["installed"].append(group_id) + else: + ret["available"].append(group_id) + return ret + out = __salt__["cmd.run_stdout"]( [_yum(), "grouplist", "hidden"], output_loglevel="trace", python_shell=False ) @@ -2737,7 +2768,10 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): } ) - cmd = [_yum(), "--quiet"] + options + ["groupinfo", name] + if _yum() == "dnf5": + cmd = [_yum(), "--quiet"] + options + ["group", "info", name] + else: + cmd = [_yum(), "--quiet"] + options + ["groupinfo", name] out = __salt__["cmd.run_stdout"](cmd, output_loglevel="trace", python_shell=False) g_info = {} @@ -2752,9 +2786,17 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): ret["type"] = "environment group" elif "group" in g_info: ret["type"] = "package group" + elif "name" in g_info: + # dnf5 'group info' labels a package group with "Name"/"Id" rather than + # the "Group"/"Group-Id" that dnf uses. + ret["type"] = "package group" - ret["group"] = g_info.get("environment group") or g_info.get("group") - ret["id"] = g_info.get("environment-id") or g_info.get("group-id") + ret["group"] = ( + g_info.get("environment group") or g_info.get("group") or g_info.get("name") + ) + ret["id"] = ( + g_info.get("environment-id") or g_info.get("group-id") or g_info.get("id") + ) if not ret["group"] and not ret["id"]: raise CommandExecutionError(f"Group '{name}' not found") @@ -2765,7 +2807,14 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): for pkgtype in pkgtypes: target_found = False for line in salt.utils.itertools.split(out, "\n"): - line = line.strip().lstrip(string.punctuation) + line = line.strip().lstrip(string.punctuation).strip() + # ``member`` is the group member (a package or, for environment + # groups, a subgroup) this line contributes. For an ordinary member + # line it is the line itself; a dnf5 section header (below) carries + # its section's first member inline and overrides it. + member = line + # dnf (yum): the section header sits on its own line, e.g. + # "Mandatory Packages:", with members on the lines that follow. match = re.match( pkgtypes_capturegroup + r" (?:groups|packages):\s*$", line.lower() ) @@ -2778,16 +2827,38 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): # We've reached the targeted section target_found = True continue + # dnf5: the section header carries this section's first member + # inline, e.g. "Mandatory packages : gettext". + match_dnf5 = re.match( + pkgtypes_capturegroup + r" (?:groups|packages)\s*:\s*(.*?)$", + line.lower(), + ) + if match_dnf5: + if target_found: + # We've reached a new section, break from loop + break + if match_dnf5.group(1) != pkgtype: + continue + # We've reached the targeted section + target_found = True + # Pull the inline first member into its own variable (keeping + # the original case) rather than overwriting ``line``: a header + # with trailing spaces or localized text could otherwise leave + # an empty string and silently drop the member. + first_member = re.match(r"^[^:]+:\s*(.+)$", line) + if first_member is None: + continue + member = first_member.group(1).strip() if target_found: if expand and ret["type"] == "environment group": - if not line or line in completed_groups: + if not member or member in completed_groups: continue log.trace( 'Adding group "%s" to completed list: %s', - line, + member, completed_groups, ) - completed_groups.append(line) + completed_groups.append(member) # The @ prefix disambiguates single-token group ids (e.g. # gnome-desktop) that would otherwise match multiple # groups, but dnf cannot resolve "@" + a multi-word group @@ -2797,17 +2868,20 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): # name so multi-word member groups still expand (#60276). try: expanded = group_info( - "@" + line, expand=True, ignore_groups=completed_groups + "@" + member, expand=True, ignore_groups=completed_groups ) except CommandExecutionError: expanded = group_info( - line, expand=True, ignore_groups=completed_groups + member, expand=True, ignore_groups=completed_groups ) # Don't shadow the pkgtype variable from the outer loop for p_type in pkgtypes: ret[p_type].update(set(expanded[p_type])) - else: - ret[pkgtype].add(line) + elif member: + # Skip blank lines that fall inside the section (e.g. a + # trailing/blank line before the next header) rather than + # recording an empty package name. + ret[pkgtype].add(member) for pkgtype in pkgtypes: ret[pkgtype] = sorted(ret[pkgtype]) diff --git a/tests/pytests/unit/modules/test_yumpkg.py b/tests/pytests/unit/modules/test_yumpkg.py index fe8f4faf8ed..89d0e6e2b89 100644 --- a/tests/pytests/unit/modules/test_yumpkg.py +++ b/tests/pytests/unit/modules/test_yumpkg.py @@ -2944,7 +2944,6 @@ def test_group_info(): "yelp", ], "optional": [ - "", "alacarte", "dconf-editor", "dvgrab", @@ -3580,3 +3579,106 @@ def fake_parse(*args, **kwargs): yumpkg.install("fnord", version=new) call = cmd_mock.mock_calls[0][1][0] assert call == expected_cmd + + +def test_67975_dnf5_group_info(): + """ + Test yumpkg.group_info parsing of the dnf5 'group info' format, where each + package section carries its first member inline after the colon and the + rest on continuation lines. + """ + cmd_out = """\ +Id : libreoffice +Name : LibreOffice +Description : LibreOffice Productivity Suite +Installed : yes +Order : +Langonly : +Uservisible : yes +Repositories : @System +Mandatory packages : libreoffice-calc + : libreoffice-emailmerge + : libreoffice-graphicfilter + : libreoffice-impress + : libreoffice-writer +Optional packages : libreoffice-base + : libreoffice-draw + : libreoffice-math + : libreoffice-pyuno""" + expected = { + "mandatory": [ + "libreoffice-calc", + "libreoffice-emailmerge", + "libreoffice-graphicfilter", + "libreoffice-impress", + "libreoffice-writer", + ], + "optional": [ + "libreoffice-base", + "libreoffice-draw", + "libreoffice-math", + "libreoffice-pyuno", + ], + "default": [], + "conditional": [], + "type": "package group", + "group": "LibreOffice", + "id": "libreoffice", + "description": "LibreOffice Productivity Suite", + } + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + assert yumpkg.group_info("libreoffice") == expected + + +def test_67975_dnf5_group_list(): + """ + Test yumpkg.group_list parsing of the dnf5 'group list' table. The group + name column is tokenized rather than matched with a regex, so a name that + contains or ends with the word "yes"/"no" (``Just (testing) yes``) is not + mistaken for the trailing Installed column, and a row with trailing + whitespace (``last``) is still classified rather than dropped. + """ + cmd_out = ( + "ID Name Installed\n" + "foo Foo package no\n" + "bar Bar package no\n" + "brackets Just (testing) yes yes\n" + "cleaners Mop and bucket yes\n" + "last But not least no \n" + ) + expected = { + "installed": ["brackets", "cleaners"], + "available": ["foo", "bar", "last"], + "installed environments": [], + "available environments": [], + "available languages": {}, + } + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + assert yumpkg.group_list() == expected + + +def test_dnf5_group_info_skips_blank_member_lines(): + """ + A blank line inside a package section (e.g. between sections) must not be + recorded as an empty package name. + """ + cmd_out = ( + "Id : development-tools\n" + "Name : Development Tools\n" + "Installed : no\n" + "Mandatory packages : gettext\n" + "\n" + "Optional packages : cmake\n" + ) + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + info = yumpkg.group_info("Development Tools") + assert info["mandatory"] == ["gettext"] + assert info["optional"] == ["cmake"] + assert "" not in info["mandatory"] + assert "" not in info["optional"]