Repository: ansible/ansible
Issue category: Bug Report

DNF5 state: latest fails when installing
### Summary

When using `ansible.builtin.dnf` with `state: latest` the previous behavior was to install and update all packages.
Fedora 41 uses DNF5 which does not install the package but instead fails.
A Bug report is filed on the DNF5 repo: https://github.com/rpm-software-management/dnf5/issues/1824
But this may not be reverted because this may be intended behavior.

### Issue Type

Bug Report

### Component Name

dnf

### Ansible Version

```console
$ ansible --version
ansible [core 2.17.5]
  config file = ...
  configured module search path = ['...', '/usr/share/ansible/plugins/modules']
  ansible python module location = ...
  ansible collection location = ...:/usr/share/ansible/collections
  executable location = ...
  python version = 3.12.7 (main, Oct  1 2024, 02:05:46) [GCC 13.3.0] (...)
  jinja version = 3.1.4
  libyaml = True
```


### Configuration

```console
# if using a version older than ansible-core 2.12 you should omit the '-t all'
$ ansible-config dump --only-changed -t all
ansible-config [core 2.17.5]
  config file = [...]/ansible.cfg
  configured module search path = ['[...]/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = [...]/python3.12/site-packages/ansible
  ansible collection location = [...]/.ansible/collections:/usr/share/ansible/collections
  executable location = [...]/bin/ansible-config
  python version = 3.12.7 (main, Oct  1 2024, 02:05:46) [GCC 13.3.0] ([...]/bin/python3.12)
  jinja version = 3.1.4
  libyaml = True
Using [...]/inventory/ansible.cfg as config file
CONFIG_FILE() = [...]/ansible.cfg
DEFAULT_HOST_LIST([...]/ansible.cfg) = ['[...]/hosts.yml']
DEFAULT_VERBOSITY(/[...]/ansible.cfg) = 2
EDITOR(env: EDITOR) = /usr/bin/nano
PERSISTENT_COMMAND_TIMEOUT([...]/ansible.cfg) = 30
PERSISTENT_CONNECT_TIMEOUT([...]/ansible.cfg) = 30
```


### OS / Environment

Fedora 41

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)

```
- name: Install sssd
  ansible.builtin.dnf:
    name:
      - sssd
    state: latest

### Expected Results

SSSD is expected to be installed.
Documentation: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/dnf_module.html#parameter-state
> Whether to install (present, latest), or remove (absent) a package.

`latest` should install and update the packages

### Actual Results

```console
{
  "failures": [
    "Packages for argument 'sssd' available, but not installed."
  ],
  "rc": 1,
  "msg": "Failed to install some of the specified packages",
  "invocation": {
    "module_args": {
      "name": [
        "sssd"
      ],
      "state": "latest",
      "allow_downgrade": false,
      "autoremove": false,
      "bugfix": false,
      "cacheonly": false,
      "disable_gpg_check": false,
      "disable_plugin": [],
      "disablerepo": [],
      "download_only": false,
      "enable_plugin": [],
      "enablerepo": [],
      "exclude": [],
      "installroot": "/",
      "install_repoquery": true,
      "install_weak_deps": true,
      "security": false,
      "skip_broken": false,
      "update_cache": false,
      "update_only": false,
      "validate_certs": true,
      "sslverify": true,
      "lock_timeout": 30,
      "allowerasing": false,
      "nobest": false,
      "conf_file": null,
      "disable_excludes": null,
      "download_dir": null,
      "list": null,
      "releasever": null
    }
  },
  "_ansible_no_log": false,
  "changed": false
}
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Functions to edit:
lib/ansible/modules/dnf5.py:is_installed

Patch:
diff --git a/lib/ansible/modules/dnf5.py b/lib/ansible/modules/dnf5.py
index df4ee206748716..b157158514ffab 100644
--- a/lib/ansible/modules/dnf5.py
+++ b/lib/ansible/modules/dnf5.py
@@ -358,6 +358,15 @@
 
 def is_installed(base, spec):
     settings = libdnf5.base.ResolveSpecSettings()
+    # Disable checking whether SPEC is a binary -> `/usr/(s)bin/<SPEC>`,
+    # this prevents scenarios like the following:
+    #   * the `sssd-common` package is installed and provides `/usr/sbin/sssd` binary
+    #   * the `sssd` package is NOT installed
+    #   * due to `set_with_binaries(True)` being default `is_installed(base, "sssd")` would "unexpectedly" return True
+    # If users wish to target the `sssd` binary they can by specifying the full path `name=/usr/sbin/sssd` explicitly
+    # due to settings.set_with_filenames(True) being default.
+    settings.set_with_binaries(False)
+
     installed_query = libdnf5.rpm.PackageQuery(base)
     installed_query.filter_installed()
     match, nevra = installed_query.resolve_pkg_spec(spec, settings, True)

Test patch: diff --git a/changelogs/fragments/84259-dnf5-latest-fix.yml b/changelogs/fragments/84259-dnf5-latest-fix.yml
new file mode 100644
index 00000000000000..40f6ddb740860f
--- /dev/null
+++ b/changelogs/fragments/84259-dnf5-latest-fix.yml
@@ -0,0 +1,2 @@
+bugfixes:
+  - "dnf5 - fix installing a package using ``state=latest`` when a binary of the same name as the package is already installed (https://github.com/ansible/ansible/issues/84259)"
diff --git a/test/integration/targets/dnf/tasks/repo.yml b/test/integration/targets/dnf/tasks/repo.yml
index 6ab8fa1a3b3cdd..ec31fe4a4ae39e 100644
--- a/test/integration/targets/dnf/tasks/repo.yml
+++ b/test/integration/targets/dnf/tasks/repo.yml
@@ -517,3 +517,50 @@
       dnf:
         name: provides_foo*
         state: absent
+
+# https://github.com/ansible/ansible/issues/84259
+- name: test installing a package named `package-name` while a package providing `/usr/sbin/package-name` is installed
+  block:
+    - dnf:
+        name: package-name
+        state: absent
+
+    - dnf:
+        name: provides-binary
+        state: present
+
+    - dnf:
+        name: package-name
+        state: latest
+      register: dnf_result
+
+    - assert:
+        that:
+          - dnf_result is changed
+  always:
+    - name: Clean up
+      dnf:
+        name:
+          - provides-binary
+          - package-name
+        state: absent
+
+- name: test installing a package that provides a binary by specifying the binary name
+  block:
+    - dnf:
+        name: provides-binary
+        state: absent
+
+    - dnf:
+        name: /usr/sbin/package-name
+        state: present
+      register: dnf_result
+
+    - assert:
+        that:
+          - dnf_result is changed
+  always:
+    - name: Clean up
+      dnf:
+        name: provides-binary
+        state: absent
diff --git a/test/integration/targets/setup_rpm_repo/library/create_repo.py b/test/integration/targets/setup_rpm_repo/library/create_repo.py
index 5acf2397195754..6fffe5ad90b487 100644
--- a/test/integration/targets/setup_rpm_repo/library/create_repo.py
+++ b/test/integration/targets/setup_rpm_repo/library/create_repo.py
@@ -15,8 +15,10 @@
     from rpmfluff.make import make_gif
     from rpmfluff.sourcefile import GeneratedSourceFile
     from rpmfluff.rpmbuild import SimpleRpmBuild
+    from rpmfluff.utils import expectedArch
     from rpmfluff.yumrepobuild import YumRepoBuild
 except ImportError:
+    expectedArch = None  # define here to avoid NameError as it is used on top level in SPECS
     HAS_RPMFLUFF = False
 
 
@@ -30,6 +32,7 @@ class RPM:
     recommends: list[str] | None = None
     requires: list[str] | None = None
     file: str | None = None
+    binary: str | None = None
 
 
 SPECS = [
@@ -58,6 +61,8 @@ class RPM:
     RPM(name='broken-b', version='1.0', requires=['broken-a = 1.2.3-1']),
     RPM(name='broken-c', version='1.0', requires=['broken-c = 1.2.4-1']),
     RPM(name='broken-d', version='1.0', requires=['broken-a']),
+    RPM(name='provides-binary', version='1.0', arch=[expectedArch], binary='/usr/sbin/package-name'),
+    RPM(name='package-name', version='1.0'),
 ]
 
 
@@ -81,10 +86,13 @@ def create_repo():
                 )
             )
 
+        if spec.binary:
+            pkg.add_simple_compilation(installPath=spec.binary)
+
         pkgs.append(pkg)
 
     repo = YumRepoBuild(pkgs)
-    repo.make('noarch', 'i686', 'x86_64')
+    repo.make('noarch', 'i686', 'x86_64', expectedArch)
 
     for pkg in pkgs:
         pkg.clean()