Repository: saltstack/salt
Pull request: [master][FEATURE REQUEST] Added support for dnf5 for Fedora
### What does this PR do?
Adds support for ```dnf5``` on Fedora. 
```dnf5``` has changed options etc from the current versions of ```dnf```, 
for example: ```--allowerasing``` is no longer a general option but tied to specific commands like ```install```.
```dnf5``` has changed other options too but has provided aliases to allow for backwards comparability, 
for example: ```--disablerepo=REPO_ID,...  Alias for '--disable-repo'```

### What issues does this PR fix or reference?
Fixes: https://github.com/saltstack/salt/issues/64532

### Previous Behavior
An install would fail since ```--allowerasing``` precedes the ```install``` command

### New Behavior
install now succeeds as options such as ```--allowerasing``` now follow the ```install``` command

### Merge requirements satisfied?
**[NOTICE] Bug fixes or features added to Salt require tests.**
<!-- Please review the [test documentation](https://docs.saltproject.io/en/master/topics/tutorials/writing_tests.html) for details on how to implement tests into Salt's test suite. -->
- [ ] Docs
- [X] Changelog - https://docs.saltproject.io/en/master/topics/development/changelog.html
- [X] Tests written/updated

### Commits signed with GPG?
Yes

Please review [Salt's Contributing Guide](https://docs.saltproject.io/en/master/topics/development/contributing.html) for best practices.

See GitHub's [page on GPG signing](https://help.github.com/articles/signing-commits-using-gpg/) for more information about signing commits with GPG.


Problem statement:
[FEATURE REQUEST] salt-minion pkg.* tasks fail if only dnf5 is present, not dnf
**Description**
pkg.* uses dnf with no issues for me on Fedora. However, starting with Fedora 39, dnf is obsoleted by dnf5. The command line options are different, so a simple symlink won't work.

**Setup**
```
install_fedora_packages:
  pkg.installed:
    - pkgs:
      - vim-enhanced
      - nmap
      - dnf-automatic
      - firewalld
```

Please be as specific as possible and give set-up details.
Install salt-minion on Fedora 39 instance, start minion, accept key on master. 


**Steps to Reproduce the behavior**
Apply the above sls.

**Expected behavior**
salt-minion installs packages.

**Screenshots**
----------
          ID: install_fedora_packages
    Function: pkg.installed
      Result: False
     Comment: Error occurred installing package(s). Additional info follows:
              
              errors:
                  - Unknown argument "--allowerasing" for command "dnf5". Add "--help" for more information about the arguments.
     Started: 19:45:43.710036
    Duration: 1480.451 ms
     Changes:   


**Versions Report**

Minion is Fedora 39 with same salt version.
Master:
```yaml
Master:
Salt Version:
          Salt: 3006.1
 
Python Version:
        Python: 3.11.3 (main, May 24 2023, 00:00:00) [GCC 13.1.1 20230511 (Red Hat 13.1.1-2)]
 
Dependency Versions:
          cffi: 1.15.1
      cherrypy: unknown
      dateutil: 2.8.2
     docker-py: Not Installed
         gitdb: 4.0.9
     gitpython: 3.1.30
        Jinja2: 3.0.3
       libgit2: 1.6.3
  looseversion: 1.2.0
      M2Crypto: 0.38.0
          Mako: 1.2.3
       msgpack: 1.0.4
  msgpack-pure: Not Installed
  mysql-python: Not Installed
     packaging: 23.0
     pycparser: 2.20
      pycrypto: 2.6.1
  pycryptodome: 3.17.0
        pygit2: 1.12.0
  python-gnupg: 0.5.0
        PyYAML: 6.0
         PyZMQ: 24.0.1
        relenv: Not Installed
         smmap: 5.0.0
       timelib: Not Installed
       Tornado: 4.5.3
           ZMQ: 4.3.4
 
System Versions:
          dist: fedora 38 
        locale: utf-8
       machine: x86_64
       release: 6.3.8-200.fc38.x86_64
        system: Linux
       version: Fedora Linux 38 
```
</details>

**Additional context**
With dnf5, the old dnf cannot be installed. This completely breaks any pkg usage on Fedora 39+.
I'm the Fedora Link salt RPM package maintainer.

Hints from issue discussion:
Hi there! Welcome to the Salt Community! Thank you for making your first contribution. We have a lengthy process for issues and PRs. Someone from the Core Team will follow up as soon as possible. In the meantime, here’s some information that may help as you continue your Salt journey.
Please be sure to review our [Code of Conduct](https://github.com/saltstack/salt/blob/master/CODE_OF_CONDUCT.md). Also, check out some of our community resources including:

  - [Community Wiki](https://github.com/saltstack/community/wiki)
  - [Salt’s Contributor Guide](https://docs.saltproject.io/en/master/topics/development/contributing.html)
  - [Join our Community Slack](https://join.slack.com/t/saltstackcommunity/shared_invite/zt-3av8jjyf-oBQ2M0vhXOhJpNpRkPWBvg)
  - [IRC on LiberaChat](https://web.libera.chat/#salt)
  - [Salt Project YouTube channel](https://www.youtube.com/channel/UCpveTIucFx9ljGelW63-BWg)
  - [Salt Project Twitch channel](https://www.twitch.tv/saltprojectoss)

There are lots of ways to get involved in our community. Every month, there are around a dozen opportunities to meet with other contributors and the Salt Core team and collaborate in real time. The best way to keep track is by subscribing to the Salt Community Events Calendar.
If you have additional questions, email us at saltproject@vmware.com. We’re glad you’ve joined our community and look forward to doing awesome things with you!

Related to https://bugzilla.redhat.com/show_bug.cgi?id=2216848
Need to review dnf5 https://dnf5.readthedocs.io/en/latest/changes.html and update Salt code accordingly 

Note: this is with Fedora built Salt 3006.1 and not the Salt built 'onedir' version of Salt.

Commit message: Added changelog entry

Patch to review:
diff --git a/changelog/64532.added.md b/changelog/64532.added.md
new file mode 100644
index 000000000000..53595d69280d
--- /dev/null
+++ b/changelog/64532.added.md
@@ -0,0 +1 @@
+Added support for dnf5 and its new command syntax
diff --git a/salt/modules/yumpkg.py b/salt/modules/yumpkg.py
index 413c0e12f77a..08a7d7d05860 100644
--- a/salt/modules/yumpkg.py
+++ b/salt/modules/yumpkg.py
@@ -14,6 +14,8 @@
 
 .. versionadded:: 3003
     Support for ``tdnf`` on Photon OS.
+.. versionadded:: 3007.0
+    Support for ``dnf5``` on Fedora 39
 """
 
 
@@ -122,12 +124,12 @@ def _get_hold(line, pattern=__HOLD_PATTERN, full=True):
     dnf ==> vim-enhanced-2:7.4.827-1.fc22.*
     """
     if full:
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
             lock_re = rf"({pattern}-\S+)"
         else:
             lock_re = rf"(\d+:{pattern}-\S+)"
     else:
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
             lock_re = rf"({pattern}-\S+)"
         else:
             lock_re = rf"\d+:({pattern}-\S+)"
@@ -145,7 +147,7 @@ def _get_hold(line, pattern=__HOLD_PATTERN, full=True):
 
 def _yum():
     """
-    Determine package manager name (yum or dnf),
+    Determine package manager name (yum or dnf[5]),
     depending on the executable existence in $PATH.
     """
 
@@ -168,7 +170,10 @@ def _check(file):
     contextkey = "yum_bin"
     if contextkey not in context:
         for dir in os.environ.get("PATH", os.defpath).split(os.pathsep):
-            if _check(os.path.join(dir, "dnf")):
+            if _check(os.path.join(dir, "dnf5")):
+                context[contextkey] = "dnf5"
+                break
+            elif _check(os.path.join(dir, "dnf")):
                 context[contextkey] = "dnf"
                 break
             elif _check(os.path.join(dir, "tdnf")):
@@ -245,7 +250,8 @@ def _versionlock_pkg(grains=None):
     """
     if grains is None:
         grains = __grains__
-    if _yum() == "dnf":
+
+    if _yum() == "dnf" or _yum() == "dnf5":
         if grains["os"].lower() == "fedora":
             return (
                 "python3-dnf-plugin-versionlock"
@@ -272,10 +278,11 @@ def _check_versionlock():
 
 def _get_options(**kwargs):
     """
-    Returns a list of options to be used in the yum/dnf command, based on the
+    Returns a list of options to be used in the yum/dnf[5] command, based on the
     kwargs passed.
     """
     # Get repo options from the kwargs
+    # dnf5 aliases dnf options, so no need to change
     fromrepo = kwargs.pop("fromrepo", "")
     repo = kwargs.pop("repo", "")
     disablerepo = kwargs.pop("disablerepo", "")
@@ -1053,7 +1060,9 @@ def list_upgrades(refresh=True, **kwargs):
 
     cmd = ["--quiet"]
     cmd.extend(options)
-    cmd.extend(["list", "upgrades" if _yum() == "dnf" else "updates"])
+    cmd.extend(
+        ["list", "upgrades" if (_yum() == "dnf" or _yum() == "dnf5") else "updates"]
+    )
     out = _call_yum(cmd, ignore_retcode=True)
     if out["retcode"] != 0 and "Error:" in out:
         return {}
@@ -1708,7 +1717,8 @@ def _add_common_args(cmd):
         if skip_verify:
             cmd.append("--nogpgcheck")
         if downloadonly:
-            cmd.append("--downloadonly")
+            if _yum() != "dnf5":
+                cmd.append("--downloadonly")
 
     try:
         holds = list_holds(full=False)
@@ -1769,6 +1779,8 @@ def _temporarily_unhold(pkgs, targets):
                 cmd.extend(["--best", "--allowerasing"])
             _add_common_args(cmd)
             cmd.append("install" if pkg_type != "advisory" else "update")
+            if _yum() == "dnf5":
+                cmd.extend(["--best", "--allowerasing"])
             cmd.extend(targets)
             out = _call_yum(cmd, ignore_retcode=False, redirect_stderr=True)
             if out["retcode"] != 0:
@@ -2002,7 +2014,7 @@ def upgrade(
 
         salt '*' pkg.upgrade security=True exclude='kernel*'
     """
-    if _yum() == "dnf" and not obsoletes:
+    if (_yum() == "dnf" or _yum() == "dnf5") and not obsoletes:
         # for dnf we can just disable obsoletes
         _setopt = [
             opt
@@ -2040,7 +2052,7 @@ def upgrade(
         cmd.append("upgrade" if not minimal else "upgrade-minimal")
     else:
         # do not force the removal of obsolete packages
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
             cmd.append("upgrade" if not minimal else "upgrade-minimal")
         else:
             # for yum we have to use update instead of upgrade
@@ -2396,7 +2408,7 @@ def unhold(name=None, pkgs=None, sources=None, **kwargs):  # pylint: disable=W06
 
         ret[target] = {"name": target, "changes": {}, "result": False, "comment": ""}
 
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
             search_locks = [x for x in current_locks if x == target]
         else:
             # To accommodate yum versionlock's lack of support for removing
@@ -3032,7 +3044,7 @@ def mod_repo(repo, basedir=None, **kwargs):
         if use_copr:
             # Is copr plugin installed?
             copr_plugin_name = ""
-            if _yum() == "dnf":
+            if _yum() == "dnf" or _yum() == "dnf5":
                 copr_plugin_name = "dnf-plugins-core"
             else:
                 copr_plugin_name = "yum-plugin-copr"
@@ -3493,7 +3505,7 @@ def services_need_restart(**kwargs):
 
         salt '*' pkg.services_need_restart
     """
-    if _yum() != "dnf":
+    if _yum() == "dnf":
         raise CommandExecutionError("dnf is required to list outdated services.")
     if not salt.utils.systemd.booted(__context__):
         raise CommandExecutionError("systemd is required to list outdated services.")
diff --git a/tests/pytests/unit/modules/test_yumpkg.py b/tests/pytests/unit/modules/test_yumpkg.py
index 1354ee5d2d0d..1b1992daf475 100644
--- a/tests/pytests/unit/modules/test_yumpkg.py
+++ b/tests/pytests/unit/modules/test_yumpkg.py
@@ -72,7 +72,7 @@ def list_repos_var():
 
 
 @pytest.fixture(
-    ids=["yum", "dnf"],
+    ids=["yum", "dnf", "dnf5"],
     params=[
         {
             "context": {"yum_bin": "yum"},
@@ -84,6 +84,11 @@ def list_repos_var():
             "grains": {"os": "Fedora", "osrelease": 27},
             "cmd": ["dnf", "-y", "--best", "--allowerasing"],
         },
+        {
+            "context": {"yum_bin": "dnf5"},
+            "grains": {"os": "Fedora", "osrelease": 39},
+            "cmd": ["dnf5", "-y"],
+        },
     ],
 )
 def yum_and_dnf(request):
@@ -692,7 +697,7 @@ def test_list_repo_pkgs_with_options(list_repos_var):
                         except AssertionError:
                             continue
                     else:
-                        pytest.fail("repo '{}' not checked".format(repo))
+                        pytest.fail(f"repo '{repo}' not checked")
 
 
 def test_list_upgrades_dnf():
@@ -2085,7 +2090,10 @@ def test_59705_version_as_accidental_float_should_become_text(
     new, full_pkg_string, yum_and_dnf
 ):
     name = "fnord"
-    expected_cmd = yum_and_dnf + ["install", full_pkg_string]
+    expected_cmd = yum_and_dnf + ["install"]
+    if expected_cmd[0] == "dnf5":
+        expected_cmd += ["--best", "--allowerasing"]
+    expected_cmd += [full_pkg_string]
     cmd_mock = MagicMock(
         return_value={"pid": 12345, "retcode": 0, "stdout": "", "stderr": ""}
     )


Code review comments:
diff_hunk: @@ -122,12 +124,12 @@ def _get_hold(line, pattern=__HOLD_PATTERN, full=True):
     dnf ==> vim-enhanced-2:7.4.827-1.fc22.*
     """
     if full:
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
path: salt/modules/yumpkg.py
text: @user1:
```suggestion
        if _yum() in ("dnf", "dnf5"):
```
Single call to `_yum()`

diff_hunk: @@ -2040,7 +2052,7 @@ def upgrade(
         cmd.append("upgrade" if not minimal else "upgrade-minimal")
     else:
         # do not force the removal of obsolete packages
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
path: salt/modules/yumpkg.py
text: @user1:
```suggestion
        if _yum() in ("dnf", "dnf5"):
```

diff_hunk: @@ -2396,7 +2408,7 @@ def unhold(name=None, pkgs=None, sources=None, **kwargs):  # pylint: disable=W06
 
         ret[target] = {"name": target, "changes": {}, "result": False, "comment": ""}
 
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
path: salt/modules/yumpkg.py
text: @user1:
```suggestion
        if _yum() in ("dnf", "dnf5"):
```

diff_hunk: @@ -3032,7 +3044,7 @@ def mod_repo(repo, basedir=None, **kwargs):
         if use_copr:
             # Is copr plugin installed?
             copr_plugin_name = ""
-            if _yum() == "dnf":
+            if _yum() == "dnf" or _yum() == "dnf5":
path: salt/modules/yumpkg.py
text: @user1:
```suggestion
            if _yum() in ("dnf", "dnf5"):
```

diff_hunk: @@ -245,7 +250,8 @@ def _versionlock_pkg(grains=None):
     """
     if grains is None:
         grains = __grains__
-    if _yum() == "dnf":
+
+    if _yum() == "dnf" or _yum() == "dnf5":
path: salt/modules/yumpkg.py
text: @user1:
```suggestion
    if _yum() in ("dnf", "dnf5"):
```

diff_hunk: @@ -122,12 +124,12 @@ def _get_hold(line, pattern=__HOLD_PATTERN, full=True):
     dnf ==> vim-enhanced-2:7.4.827-1.fc22.*
     """
     if full:
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
             lock_re = rf"({pattern}-\S+)"
         else:
             lock_re = rf"(\d+:{pattern}-\S+)"
     else:
-        if _yum() == "dnf":
+        if _yum() == "dnf" or _yum() == "dnf5":
path: salt/modules/yumpkg.py
text: @user1:
```suggestion
        if _yum() in ("dnf", "dnf5"):
```
Single call to `_yum()`

diff_hunk: @@ -1053,7 +1060,9 @@ def list_upgrades(refresh=True, **kwargs):
 
     cmd = ["--quiet"]
     cmd.extend(options)
-    cmd.extend(["list", "upgrades" if _yum() == "dnf" else "updates"])
+    cmd.extend(
+        ["list", "upgrades" if (_yum() == "dnf" or _yum() == "dnf5") else "updates"]
path: salt/modules/yumpkg.py
text: @user1:
```suggestion
        ["list", "upgrades" if _yum() in ("dnf", "dnf5") else "updates"]
```

diff_hunk: @@ -2002,7 +2014,7 @@ def upgrade(
 
         salt '*' pkg.upgrade security=True exclude='kernel*'
     """
-    if _yum() == "dnf" and not obsoletes:
+    if (_yum() == "dnf" or _yum() == "dnf5") and not obsoletes:
path: salt/modules/yumpkg.py
text: @user1:
```suggestion
    if _yum() in ("dnf", "dnf5") and not obsoletes:
```