Repository: ansible/ansible

Issue:
zypper module expands to <pkg>-<version definition> AND <pkg>
 - Bug Report

##### COMPONENT NAME
zypper_module

##### ANSIBLE VERSION
<!--- Paste verbatim output from “ansible --version” between quotes below -->
```
ansible 2.2.1.0
```

##### CONFIGURATION

##### OS / ENVIRONMENT

##### SUMMARY

I have in a role a tasks/main.yml which contains:
```yaml
  - include: library/setup_packages.yml
    vars:
      pkg:   "{{ item }}"
      state: present
    with_items:
      - "{{ wanted_packages | default([]) }}"
    tags:
      - packages
```
and a library/setup_packages.yml which contains:
```yaml
---
  - debug:
      msg:   "{{ pkg }} -> {{ state }}"

  - zypper:
      pkg:   "{{ pkg }}"
      state: "{{ state }}"
```

When I call this with a list wanted_packages like this:
wanted_packages:
  - rlwrap=0.37-2.1

This fails for me because there are two versions of rlwrap in the repository directory. Only the older one is installable in my context. So I tried to set a specific version. But the zypper module generates a commandline with both  "rlwrap=0.37-2.1" AND "rlwrap" which leads into a failure.
The output is:
```
TASK [oracle : debug] **********************************************************
ok: [hhlokora12-tpl] => {
    "msg": "rlwrap=0.37-2.1 -> present"
}

TASK [oracle : zypper] *********************************************************
fatal: [hhlokora12-tpl]: FAILED! => {"changed": false, "cmd": ["/usr/bin/zypper", "--quiet", "--non-interactive", "--xmlout", "install", "--type", "package", "--auto-agree-with-licenses", "--no-recommends", "--oldpackage", "--", "rlwrap=0.37-2.1", "rlwrap"], "failed": true, "msg": "Zypper run command failed with return code 4.", "rc": 4, "stderr": "", "stdout": "<?xml version='1.0'?>\n<stream>\n<prompt id=\"1\">\n<description>Problem: nothing provides libreadline.so.5()(64bit) needed by rlwrap-0.42-24.1.x86_64\n Solution 1: do not install rlwrap-0.42-24.1.x86_64\n Solution 2: break rlwrap-0.42-24.1.x86_64 by ignoring some of its dependencies\n</description>\n<text>Choose from above solutions by number or cancel</text>\n<option value=\"1\" desc=\"\"/>\n<option value=\"2\" desc=\"\"/>\n<option default=\"1\" value=\"c\" desc=\"\"/>\n</prompt>\n</stream>\n", "stdout_lines": ["<?xml version='1.0'?>", "<stream>", "<prompt id=\"1\">", "<description>Problem: nothing provides libreadline.so.5()(64bit) needed by rlwrap-0.42-24.1.x86_64", " Solution 1: do not install rlwrap-0.42-24.1.x86_64", " Solution 2: break rlwrap-0.42-24.1.x86_64 by ignoring some of its dependencies", "</description>", "<text>Choose from above solutions by number or cancel</text>", "<option value=\"1\" desc=\"\"/>", "<option value=\"2\" desc=\"\"/>", "<option default=\"1\" value=\"c\" desc=\"\"/>", "</prompt>", "</stream>"]}
```

Why is it expanding to both?
If it is needed a switch like exact_version or so which leads only to the specific version would be nice.

##### STEPS TO REPRODUCE
<!--- Paste example playbooks or commands between quotes below -->
```yaml

```

<!--- You can also paste gist.github.com links for larger files -->

##### EXPECTED RESULTS
<!--- What did you expect to happen when running the steps above? -->

##### ACTUAL RESULTS
<!--- What actually happened? If possible run with extra verbosity (-vvvv) -->

<!--- Paste verbatim command output between quotes below -->
```

```



Discussion:
cc @robinro
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@Popyllol Thanks for this bug report.
The duplication is unintentional, but harmless in most usecases (except yours).

It happens in
https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/packaging/os/zypper.py#L370-L378

One should either remove the versioned entries from `name_install` or combine `install_version` and `name_install` in one variable. The version code was added on top later, and is not well integrated.

Do you want to have a go at fixing this?
needs_contributor
Thanks for you investigation so far!
But sorry, I am not your man if the code is Python :-(. Still a blind spot in my languages.
If one can fix this I would be glad :-).
If someone else can have a go I'd be happy to help/review.

If not I'll probably fix it myself some time in the future.
@Popyllol Can u test this patch? I do not have an environment with suse and two repos with different version of the same package. But I did check that the versioned package is included once in the cmdline and not twice as before.
@robinro I made a patch for this issue, can u review it?
https://github.com/ansible/ansible/pull/23936

Resolving patch:
diff --git a/lib/ansible/modules/packaging/os/zypper.py b/lib/ansible/modules/packaging/os/zypper.py
--- a/lib/ansible/modules/packaging/os/zypper.py
+++ b/lib/ansible/modules/packaging/os/zypper.py
@@ -180,9 +180,21 @@
 '''
 
 import xml
-from ansible.module_utils.pycompat24 import get_exception
-from xml.dom.minidom import parseString as parseXML
 import re
+from xml.dom.minidom import parseString as parseXML
+from ansible.module_utils.six import iteritems
+
+
+class Package:
+    def __init__(self, name, prefix, version):
+        self.name = name
+        self.prefix = prefix
+        self.version = version
+        self.shouldinstall = (prefix == '+')
+
+    def __str__(self):
+        return self.prefix + self.name + self.version
+
 
 
 def split_name_version(name):
@@ -202,35 +214,35 @@ def split_name_version(name):
     if name[0] in ['-', '~', '+']:
         prefix = name[0]
         name = name[1:]
+    if prefix == '~':
+        prefix = '-'
 
     version_check = re.compile('^(.*?)((?:<|>|<=|>=|=)[0-9.-]*)?$')
     try:
         reres = version_check.match(name)
         name, version = reres.groups()
+        if version is None:
+            version = ''
         return prefix, name, version
     except:
-        return prefix, name, None
+        return prefix, name, ''
 
 
-def get_want_state(m, names, remove=False):
-    packages_install = {}
-    packages_remove = {}
+def get_want_state(names, remove=False):
+    packages = []
     urls = []
     for name in names:
         if '://' in name or name.endswith('.rpm'):
             urls.append(name)
         else:
             prefix, pname, version = split_name_version(name)
-            if prefix in ['-', '~']:
-                packages_remove[pname] = version
-            elif prefix == '+':
-                packages_install[pname] = version
-            else:
+            if prefix not in ['-', '+']:
                 if remove:
-                    packages_remove[pname] = version
+                    prefix = '-'
                 else:
-                    packages_install[pname] = version
-    return packages_install, packages_remove, urls
+                    prefix = '+'
+            packages.append(Package(pname, prefix, version))
+    return packages, urls
 
 
 def get_installed_state(m, packages):
@@ -238,7 +250,7 @@ def get_installed_state(m, packages):
 
     cmd = get_cmd(m, 'search')
     cmd.extend(['--match-exact', '--details', '--installed-only'])
-    cmd.extend(packages)
+    cmd.extend([p.name for p in packages])
     return parse_zypper_xml(m, cmd, fail_not_found=False)[0]
 
 
@@ -340,42 +352,35 @@ def set_diff(m, retvals, result):
 def package_present(m, name, want_latest):
     "install and update (if want_latest) the packages in name_install, while removing the packages in name_remove"
     retvals = {'rc': 0, 'stdout': '', 'stderr': ''}
-    name_install, name_remove, urls = get_want_state(m, name)
-
-    # if a version string is given, pass it to zypper
-    install_version = [p+name_install[p] for p in name_install if name_install[p]]
-    remove_version = [p+name_remove[p] for p in name_remove if name_remove[p]]
+    packages, urls = get_want_state(name)
 
     # add oldpackage flag when a version is given to allow downgrades
-    if install_version or remove_version:
+    if any(p.version for p in packages):
         m.params['oldpackage'] = True
 
     if not want_latest:
         # for state=present: filter out already installed packages
-        install_and_remove = name_install.copy()
-        install_and_remove.update(name_remove)
-        prerun_state = get_installed_state(m, install_and_remove)
+        # if a version is given leave the package in to let zypper handle the version
+        # resolution
+        packageswithoutversion = [p for p in packages if not p.version]
+        prerun_state = get_installed_state(m, packageswithoutversion)
         # generate lists of packages to install or remove
-        name_install = [p for p in name_install if p not in prerun_state]
-        name_remove = [p for p in name_remove if p in prerun_state]
-        if not any((name_install, name_remove, urls, install_version, remove_version)):
-            # nothing to install/remove and nothing to update
-            return None, retvals
+        packages = [p for p in packages if p.shouldinstall != (p.name in prerun_state)]
+
+    if not packages and not urls:
+        # nothing to install/remove and nothing to update
+        return None, retvals
 
     # zypper install also updates packages
     cmd = get_cmd(m, 'install')
     cmd.append('--')
     cmd.extend(urls)
-
-    # pass packages with version information
-    cmd.extend(install_version)
-    cmd.extend(['-%s' % p for p in remove_version])
-
+    # pass packages to zypper
     # allow for + or - prefixes in install/remove lists
+    # also add version specifier if given
     # do this in one zypper run to allow for dependency-resolution
     # for example "-exim postfix" runs without removing packages depending on mailserver
-    cmd.extend(name_install)
-    cmd.extend(['-%s' % p for p in name_remove])
+    cmd.extend([str(p) for p in packages])
 
     retvals['cmd'] = cmd
     result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd)
@@ -402,22 +407,21 @@ def package_absent(m, name):
     "remove the packages in name"
     retvals = {'rc': 0, 'stdout': '', 'stderr': ''}
     # Get package state
-    name_install, name_remove, urls = get_want_state(m, name, remove=True)
-    if name_install:
+    packages, urls = get_want_state(name, remove=True)
+    if any(p.prefix == '+' for p in packages):
         m.fail_json(msg="Can not combine '+' prefix with state=remove/absent.")
     if urls:
         m.fail_json(msg="Can not remove via URL.")
     if m.params['type'] == 'patch':
         m.fail_json(msg="Can not remove patches.")
-    prerun_state = get_installed_state(m, name_remove)
-    remove_version = [p+name_remove[p] for p in name_remove if name_remove[p]]
-    name_remove = [p for p in name_remove if p in prerun_state]
-    if not name_remove and not remove_version:
+    prerun_state = get_installed_state(m, packages)
+    packages = [p for p in packages if p.name in prerun_state]
+
+    if not packages:
         return None, retvals
 
     cmd = get_cmd(m, 'remove')
-    cmd.extend(name_remove)
-    cmd.extend(remove_version)
+    cmd.extend([p.name + p.version for p in packages])
 
     retvals['cmd'] = cmd
     result, retvals['rc'], retvals['stdout'], retvals['stderr'] = parse_zypper_xml(m, cmd)


Test patch:
diff --git a/test/integration/targets/zypper/tasks/zypper.yml b/test/integration/targets/zypper/tasks/zypper.yml
--- a/test/integration/targets/zypper/tasks/zypper.yml
+++ b/test/integration/targets/zypper/tasks/zypper.yml
@@ -211,10 +211,14 @@
 # test simultaneous remove and install using +- prefixes
 
 - name: install hello to prep next task
-  zypper: name=hello, state=present
+  zypper:
+    name: hello
+    state: present
 
 - name: remove metamail to prep next task
-  zypper: name=hello, state=absent
+  zypper:
+    name: metamail
+    state: absent
 
 - name: install and remove in the same run, with +- prefix
   zypper: