Repository: ansible/ansible
Pull request: Fix dnf package matching
##### SUMMARY
Fixes false negatives when checking if a package name is installed. Package names need to be broken into their components before filtering. 

Fixes #75311

##### ISSUE TYPE
- Bugfix Pull Request


Problem statement:
Yum module upgrades packages with state: present or state: installed
### Summary

When running the yum module with state: present or state: installed, certain packages will be upgraded even though they should be skipped with that parameter.

### Issue Type

Bug Report

### Component Name

yum.py

### Ansible Version

```console
$ ansible --version
ansible 2.9.23
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/root/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3.6/site-packages/ansible
  executable location = /usr/bin/ansible
  python version = 3.6.8 (default, Aug 18 2020, 08:33:21) [GCC 8.3.1 20191121 (Red Hat 8.3.1-5)]
```


### Configuration

```console
$ ansible-config dump --only-changed
```


### OS / Environment

RHEL 8

### Steps to Reproduce

Using gtk2 as example package

1. verify package is not at latest version:
```
[root@node-1 tmp]# rpm -qa | grep gtk
gtk2-2.24.32-4.el8.i686
```
2. run playbook below 
3. verify package version:
```
[root@node-1 tmp]# rpm -qa | grep gtk
gtk2-2.24.32-5.el8.i686
```
```
---

- name: Test yum module
  hosts: localhost
  gather_facts: yes
  become: true

  tasks:
    - name: Install packages
      yum:
        name: "{{ packages }}"
        state: installed
      vars:
        packages:
        - gtk2.i686
~                        
```


### Expected Results

With state: present or installed, it is expected that packages with available updates do not get updated. 

### Actual Results

```console
TASK [Install packages] ********************************************************************************
task path: /tmp/yum.yml:9
<127.0.0.1> ESTABLISH LOCAL CONNECTION FOR USER: root
<127.0.0.1> EXEC /bin/sh -c 'echo ~root && sleep 0'
<127.0.0.1> EXEC /bin/sh -c '( umask 77 && mkdir -p "` echo /root/.ansible/tmp `"&& mkdir "` echo /root/.ansible/tmp/ansible-tmp-1627050288.7489398-29887-63179730413665 `" && echo ansible-tmp-1627050288.7489398-29887-63179730413665="` echo /root/.ansible/tmp/ansible-tmp-1627050288.7489398-29887-63179730413665 `" ) && sleep 0'
Using module file /usr/lib/python3.6/site-packages/ansible/modules/packaging/os/dnf.py
<127.0.0.1> PUT /root/.ansible/tmp/ansible-local-29831fpwi4rpz/tmpbhndnpc6 TO /root/.ansible/tmp/ansible-tmp-1627050288.7489398-29887-63179730413665/AnsiballZ_dnf.py
<127.0.0.1> EXEC /bin/sh -c 'chmod u+x /root/.ansible/tmp/ansible-tmp-1627050288.7489398-29887-63179730413665/ /root/.ansible/tmp/ansible-tmp-1627050288.7489398-29887-63179730413665/AnsiballZ_dnf.py && sleep 0'
<127.0.0.1> EXEC /bin/sh -c '/usr/bin/python3.6 /root/.ansible/tmp/ansible-tmp-1627050288.7489398-29887-63179730413665/AnsiballZ_dnf.py && sleep 0'
<127.0.0.1> EXEC /bin/sh -c 'rm -f -r /root/.ansible/tmp/ansible-tmp-1627050288.7489398-29887-63179730413665/ > /dev/null 2>&1 && sleep 0'
changed: [localhost] => {
    "changed": true,
    "invocation": {
        "module_args": {
            "allow_downgrade": false,
            "autoremove": false,
            "bugfix": false,
            "conf_file": null,
            "disable_excludes": null,
            "disable_gpg_check": false,
            "disable_plugin": [],
            "disablerepo": [],
            "download_dir": null,
            "download_only": false,
            "enable_plugin": [],
            "enablerepo": [],
            "exclude": [],
            "install_repoquery": true,
            "install_weak_deps": true,
            "installroot": "/",
            "list": null,
            "lock_timeout": 30,
            "name": [
                "gtk2.i686"
            ],
            "releasever": null,
            "security": false,
            "skip_broken": false,
            "state": "present",
            "update_cache": false,
            "update_only": false,
            "validate_certs": true
        }
    },
    "msg": "",
    "rc": 0,
    "results": [
        "Installed: gtk2-2.24.32-5.el8.i686",
        "Removed: gtk2-2.24.32-4.el8.i686"
    ]
}
```


### Code of Conduct

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

Hints from issue discussion:
Files identified in the description:
* [`lib/ansible/modules/yum.py`](https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/yum.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
Same issue here - RHEL 8.3 + ansible 2.9.4
DNF module behaves in the same way.

Edit: I guess this explains it:
Running dnf as the backend for the yum action plugin
Using module file /srv/app/prod/ansible/core/ansible-2.9/lib64/python3.6/site-packages/ansible/modules/packaging/os/dnf.py

Also I have loop with 2 packages:
role_csas_michal_packages:
  - { name: NetworkManager, state: present }
  - { name: curl, state: present }

both report change (check mode) but final result is
PLAY RECAP **********************************************************************************************************************************************************************************
tpsasss01.vs.csin.cz       : ok=29   **changed=1**    unreachable=0    failed=0    skipped=52   rescued=0    ignored=0

yum is an action plugin that seelects dnf/yum module as it find the appropriate API on the target. The dnf package provided it's own yum and recently was rebranded as yum which leads to a lot of confusion. So even though the subject and task is 'yum' in this case it was always the 'dnf' module being executed.

Commit message: Simplify splitting on the last '.'

Patch to review:
diff --git a/changelogs/fragments/fix-dnf-filtering-for-installed-package-name.yml b/changelogs/fragments/fix-dnf-filtering-for-installed-package-name.yml
new file mode 100644
index 00000000000000..f8a788a99206c9
--- /dev/null
+++ b/changelogs/fragments/fix-dnf-filtering-for-installed-package-name.yml
@@ -0,0 +1,2 @@
+bugfixes:
+  - dnf module - Use all components of a package name to determine if it's installed (https://github.com/ansible/ansible/issues/75311).
diff --git a/lib/ansible/modules/dnf.py b/lib/ansible/modules/dnf.py
index 0401d491cb6b03..37b32d8de56278 100644
--- a/lib/ansible/modules/dnf.py
+++ b/lib/ansible/modules/dnf.py
@@ -435,15 +435,7 @@ def _package_dict(self, package):
 
         return result
 
-    def _packagename_dict(self, packagename):
-        """
-        Return a dictionary of information for a package name string or None
-        if the package name doesn't contain at least all NVR elements
-        """
-
-        if packagename[-4:] == '.rpm':
-            packagename = packagename[:-4]
-
+    def _split_package_arch(self, packagename):
         # This list was auto generated on a Fedora 28 system with the following one-liner
         #   printf '[ '; for arch in $(ls /usr/lib/rpm/platform); do  printf '"%s", ' ${arch%-linux}; done; printf ']\n'
         redhat_rpm_arches = [
@@ -458,15 +450,26 @@ def _packagename_dict(self, packagename):
             "sparc", "sparcv8", "sparcv9", "sparcv9v", "x86_64"
         ]
 
-        rpm_arch_re = re.compile(r'(.*)\.(.*)')
+        name, delimiter, arch = packagename.rpartition('.')
+        if name and arch and arch in redhat_rpm_arches:
+            return name, arch
+        return packagename, None
+
+    def _packagename_dict(self, packagename):
+        """
+        Return a dictionary of information for a package name string or None
+        if the package name doesn't contain at least all NVR elements
+        """
+
+        if packagename[-4:] == '.rpm':
+            packagename = packagename[:-4]
+
         rpm_nevr_re = re.compile(r'(\S+)-(?:(\d*):)?(.*)-(~?\w+[\w.+]*)')
         try:
             arch = None
-            rpm_arch_match = rpm_arch_re.match(packagename)
-            if rpm_arch_match:
-                nevr, arch = rpm_arch_match.groups()
-                if arch in redhat_rpm_arches:
-                    packagename = nevr
+            nevr, arch = self._split_package_arch(packagename)
+            if arch:
+                packagename = nevr
             rpm_nevr_match = rpm_nevr_re.match(packagename)
             if rpm_nevr_match:
                 name, epoch, version, release = rpm_nevr_re.match(packagename).groups()
@@ -734,7 +737,23 @@ def list_items(self, command):
 
     def _is_installed(self, pkg):
         installed = self.base.sack.query().installed()
-        if installed.filter(name=pkg):
+
+        if pkg[-4:] == '.rpm':
+            pkg = pkg[:-4]
+
+        package_spec = {}
+        name, arch = self._split_package_arch(pkg)
+        if arch:
+            package_spec['arch'] = arch
+
+        package_details = self._packagename_dict(pkg)
+        if package_details:
+            package_details['epoch'] = int(package_details['epoch'])
+            package_spec.update(package_details)
+        else:
+            package_spec['name'] = name
+
+        if installed.filter(**package_spec):
             return True
         else:
             return False
diff --git a/test/integration/targets/dnf/tasks/filters.yml b/test/integration/targets/dnf/tasks/filters.yml
index d5e9ee906b28f0..ce7c7e9bebd820 100644
--- a/test/integration/targets/dnf/tasks/filters.yml
+++ b/test/integration/targets/dnf/tasks/filters.yml
@@ -98,6 +98,19 @@
         allow_downgrade: true
         disable_gpg_check: true
 
+    - name: Verify toaster is not upgraded with state=installed
+      dnf:
+        name: "{{ item }}"
+        state: installed
+      register: installed
+      loop:
+        - toaster
+        - toaster.noarch
+        - toaster-1.2.3.4-1.el8.noarch
+
+    - assert:
+        that: "[0, 1, 2] | map('extract', installed.results, 'changed') == [False, False, False]"
+
     - name: Ask for pending updates with bugfix=true and security=true
       dnf:
         name: '*'
@@ -118,6 +131,21 @@
           - '"Installed: oven-1.2.3.5-1.el8.noarch" in update_bugfix.results'
           - '"Removed: oven-1.2.3.4-1.el8.noarch" in update_bugfix.results'
 
+    - name: Install old version of toaster again
+      dnf:
+        name: "{{ updateinfo_repo }}/toaster-1.2.3.4-1.el8.noarch.rpm"
+        allow_downgrade: true
+        disable_gpg_check: true
+
+    - name: Verify toaster is upgraded with state=latest
+      dnf:
+        name: toaster.noarch
+        state: latest
+      register: result
+
+    - assert:
+        that: result.changed
+
   always:
     - name: Remove installed packages
       dnf:


Code review comments:
diff_hunk: @@ -734,7 +737,23 @@ def list_items(self, command):
 
     def _is_installed(self, pkg):
         installed = self.base.sack.query().installed()
-        if installed.filter(name=pkg):
+
+        if pkg[-4:] == '.rpm':
+            pkg = pkg[:-4]
+
path: lib/ansible/modules/dnf.py
text: @author:
_is_installed is just used for package names
```suggestion

```