Repository: ansible/ansible-modules-extras

Issue:
The alternatives module is erroneously reporting "changed" status.
##### Issue Type:

Bug Report
##### Ansible Version:

ansible 1.8 (devel ba4693051b) last updated 2014/08/06 15:37:06 (GMT -600)
##### Environment:

CentOS 6
##### Summary:

The alternatives module is reporting "changed" status when re-running a task that has already been successfully applied.
##### Steps To Reproduce:

Given a play:

```

---
- hosts: all
  sudo: yes
  vars:
    jdk_dir: /usr/java/jdk1.7.0_55

  tasks:
  - name: Configure Java alternatives
    alternatives: name={{ item.name }} link={{ item.link }} path={{ item.path }}
    with_items:
    - { name: jar, link: /usr/bin/jar, path: "{{ jdk_dir }}/bin/jar" }
    - { name: jps, link: /usr/bin/jps, path: "{{ jdk_dir }}/bin/jps" }
    - { name: java, link: /usr/bin/java, path: "{{ jdk_dir }}/bin/java" }
    - { name: jmap, link: /usr/bin/jmap, path: "{{ jdk_dir }}/bin/jmap" }
    - { name: javac, link: /usr/bin/javac, path: "{{ jdk_dir }}/bin/javac" }
    - { name: javaws, link: /usr/bin/javaws, path: "{{ jdk_dir }}/bin/javaws" }
    - { name: jstack, link: /usr/bin/jstack, path: "{{ jdk_dir }}/bin/jstack" }
    - { name: jvisualvm, link: /usr/bin/jvisualvm, path: "{{ jdk_dir }}/bin/jvisualvm" }
```
##### Expected Results:

The first time the task is run the status is "changed" as expected.  We expect subsequent runs with the identical path specified to indicate an "ok" status.
##### Actual Results:

When running the task subsequently the status is actually reporting "changed".

Note: This ticket was previously reported as [ansible #8613](https://github.com/ansible/ansible/issues/8613).



Discussion:
I am getting a different error when using this same module on Centos 6 (see #46), what python version are you using?

I was using a newer version of ansible than yours so maybe that explains the difference?  It's possible the issue you've reported has been fixed in the v1.8 series?  We're just using the standard version of python that ships with CentOS 6.5.

so i cannot reproduce this in current devel, can you give it a try?

I ran into this on 1.8.4, Ubuntu 14.04.2 LTS as the ansible master, invoking alternatives against RHEL 6.6.

@Mark2009 can you test against current development? there is a patch but i cannot reproduce this issue so i cannot verify it as fixed.

@bcoca I just tried to verify this but things are now blowing up with other errors.  Not sure if the alternatives module is currently broken in dev?  

I tested using:

```
ansible-playbook 1.9 (devel c81d981164) last updated 2015/02/25 10:50:42 (GMT -600)
  lib/ansible/modules/core: (detached HEAD 706be79f8f) last updated 2015/02/25 10:50:55 (GMT -600)
  lib/ansible/modules/extras: (detached HEAD 2ffd7235e5) last updated 2015/02/25 10:50:58 (GMT -600)
  v2/ansible/modules/core: (detached HEAD 34784b7a61) last updated 2015/02/25 10:51:02 (GMT -600)
  v2/ansible/modules/extras: (detached HEAD 312d59061c) last updated 2015/02/25 10:51:05 (GMT -600)
```

Partial output:

```
<192.168.33.10> path=/usr/java/jdk1.7.0_55/bin/jvisualvm link=/usr/bin/jvisualvm name=jvisualvm
<192.168.33.10> 
<192.168.33.10> 
<192.168.33.10> 'sudo -k && sudo -H -S -p "[sudo via ansible, key=dfaamhnreixhkszwbotflkpeiuzzqwbv] password: " -u root /bin/sh -c '"'"'echo SUDO-SUCCESS-dfaamhnreixhkszwbotflkpeiuzzqwbv; LANG=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 /usr/bin/python /tmp/.ansible/ansible-tmp-1424888740.45-89617031853269/alternatives; rm -rf /tmp/.ansible/ansible-tmp-1424888740.45-89617031853269/ >/dev/null 2>&1'"'"''
failed: [dbload-centos] => (item={'path': u'/usr/java/jdk1.7.0_55/bin/jvisualvm', 'link': '/usr/bin/jvisualvm', 'name': 'jvisualvm'}) => {"failed": true, "item": {"link": "/usr/bin/jvisualvm", "name": "jvisualvm", "path": "/usr/java/jdk1.7.0_55/bin/jvisualvm"}, "parsed": false}
SUDO-SUCCESS-dfaamhnreixhkszwbotflkpeiuzzqwbv
Traceback (most recent call last):
  File "/tmp/.ansible/ansible-tmp-1424888740.45-89617031853269/alternatives", line 1756, in <module>
    main()
  File "/tmp/.ansible/ansible-tmp-1424888740.45-89617031853269/alternatives", line 126, in main
    line_name, line_mode, line_path = line.strip().split("\t")
ValueError: need more than 1 value to unpack
```

Target host is running CentOS 6.4.

@yeroc I have not been able to reproduce that, can you provide a minimal playbook?

@bcoca [Here's a gist](https://gist.github.com/yeroc/2db8dca5eb31dad7a046) containing Vagrantfile, ansible-inventory & self-contained ansible-play-alternatives-test.yml (minimal playbook).  You should be able to drop those three files into a directory and run `vagrant up` to reproduce.

I'm seeing the original issue (always reports changed) with Ansible 1.8.2 on RH6 using the following task:

``` yaml
- name: install | update java alternatives
  alternatives:
    name: java
    path: "/opt/jdk{{ oraclejdk_version.major }}/jre/bin/java"
    link: /usr/bin/java
```

Here's the problem:

The module [correctly detects RedHat](https://github.com/ansible/ansible-modules-extras/blob/c7e3aee0f018c019860cf6f17ffa9be9b12b7155/system/alternatives.py#L118) since the initial command (with `--query`) returns 2.  The problem is that RH6 does not have a `--list` option, so current_path is never set.

```
sh# update-alternatives --list
alternatives version 1.3.49.3 - Copyright (C) 2001 Red Hat, Inc.
This may be freely redistributed under the terms of the GNU Public License.

usage: alternatives --install <link> <name> <path> <priority>
                    [--initscript <service>]
                    [--slave <link> <name> <path>]*
       alternatives --remove <name> <path>
       alternatives --auto <name>
       alternatives --config <name>
       alternatives --display <name>
       alternatives --set <name> <path>

common options: --verbose --test --help --usage --version
                --altdir <directory> --admindir <directory>
```

For RH6, the module should be using `--display`.  Example:

```
sh# update-alternatives --display mail
mail - status is manual.
 link currently points to /bin/mailx
/bin/mailx - priority 90
 slave mail-Mail: /bin/mailx
 slave mail-Mail-man: /usr/share/man/man1/mailx.1.gz
 slave mail-mail-man: /usr/share/man/man1/mailx.1.gz
Current `best' version is /bin/mailx.
```

The current value is the second line: `link currently points to <path>`.  RH7 has a `--display` option exactly like RH6, so you could just use `--display` if you wanted to avoid having different logic for RH6/7.


Resolving patch:
diff --git a/system/alternatives.py b/system/alternatives.py
--- a/system/alternatives.py
+++ b/system/alternatives.py
@@ -4,6 +4,7 @@
 """
 Ansible module to manage symbolic link alternatives.
 (c) 2014, Gabe Mulley <gabe.mulley@gmail.com>
+(c) 2015, David Wittman <dwittman@gmail.com>
 
 This file is part of Ansible
 
@@ -26,7 +27,7 @@
 module: alternatives
 short_description: Manages alternative programs for common commands
 description:
-    - Manages symbolic links using the 'update-alternatives' tool provided on debian-like systems.
+    - Manages symbolic links using the 'update-alternatives' tool
     - Useful when multiple programs are installed but provide similar functionality (e.g. different editors).
 version_added: "1.6"
 options:
@@ -41,6 +42,7 @@
   link:
     description:
       - The path to the symbolic link that should point to the real executable.
+      - This option is required on RHEL-based distributions
     required: false
 requirements: [ update-alternatives ]
 '''
@@ -55,12 +57,14 @@
 
 DEFAULT_LINK_PRIORITY = 50
 
+import re
+
 def main():
 
     module = AnsibleModule(
         argument_spec = dict(
             name = dict(required=True),
-            path  = dict(required=True),
+            path = dict(required=True),
             link = dict(required=False),
         ),
         supports_check_mode=True,
@@ -71,78 +75,55 @@ def main():
     path = params['path']
     link = params['link']
 
-    UPDATE_ALTERNATIVES =  module.get_bin_path('update-alternatives',True)
+    UPDATE_ALTERNATIVES = module.get_bin_path('update-alternatives',True)
 
     current_path = None
     all_alternatives = []
-    os_family = None
 
-    (rc, query_output, query_error) = module.run_command(
-        [UPDATE_ALTERNATIVES, '--query', name]
+    # Run `update-alternatives --display <name>` to find existing alternatives
+    (rc, display_output, _) = module.run_command(
+        [UPDATE_ALTERNATIVES, '--display', name]
     )
 
-    # Gather the current setting and all alternatives from the query output.
-    # Query output should look something like this on Debian systems:
-
-        # Name: java
-        # Link: /usr/bin/java
-        # Slaves:
-        #  java.1.gz /usr/share/man/man1/java.1.gz
-        # Status: manual
-        # Best: /usr/lib/jvm/java-7-openjdk-amd64/jre/bin/java
-        # Value: /usr/lib/jvm/java-6-openjdk-amd64/jre/bin/java
-
-        # Alternative: /usr/lib/jvm/java-6-openjdk-amd64/jre/bin/java
-        # Priority: 1061
-        # Slaves:
-        #  java.1.gz /usr/lib/jvm/java-6-openjdk-amd64/jre/man/man1/java.1.gz
-
-        # Alternative: /usr/lib/jvm/java-7-openjdk-amd64/jre/bin/java
-        # Priority: 1071
-        # Slaves:
-        #  java.1.gz /usr/lib/jvm/java-7-openjdk-amd64/jre/man/man1/java.1.gz
-
     if rc == 0:
-        os_family = "Debian"
-        for line in query_output.splitlines():
-            split_line = line.split(':')
-            if len(split_line) == 2:
-                key = split_line[0]
-                value = split_line[1].strip()
-                if key == 'Value':
-                    current_path = value
-                elif key == 'Alternative':
-                    all_alternatives.append(value)
-                elif key == 'Link' and not link:
-                    link = value
-    elif rc == 2:
-        os_family = "RedHat"
-        # This is the version of update-alternatives that is shipped with
-        # chkconfig on RedHat-based systems. Try again with the right options.
-        (rc, query_output, query_error) = module.run_command(
-            [UPDATE_ALTERNATIVES, '--list']
-        )
-        for line in query_output.splitlines():
-            line_name, line_mode, line_path = line.strip().split("\t")
-            if line_name != name:
-                continue
-            current_path = line_path
-            break
+        # Alternatives already exist for this link group
+        # Parse the output to determine the current path of the symlink and
+        # available alternatives
+        current_path_regex = re.compile(r'^\s*link currently points to (.*)$',
+                                        re.MULTILINE)
+        alternative_regex = re.compile(r'^(\/.*)\s-\spriority', re.MULTILINE)
+
+        current_path = current_path_regex.search(display_output).group(1)
+        all_alternatives = alternative_regex.findall(display_output)
+
+        if not link:
+            # Read the current symlink target from `update-alternatives --query`
+            # in case we need to install the new alternative before setting it.
+            #
+            # This is only compatible on Debian-based systems, as the other
+            # alternatives don't have --query available
+            rc, query_output, _ = module.run_command(
+                [UPDATE_ALTERNATIVES, '--query', name]
+            )
+            if rc == 0:
+                for line in query_output.splitlines():
+                    if line.startswith('Link:'):
+                        link = line.split()[1]
+                        break
 
     if current_path != path:
         if module.check_mode:
             module.exit_json(changed=True, current_path=current_path)
         try:
             # install the requested path if necessary
-            # (unsupported on the RedHat version)
-            if path not in all_alternatives and os_family == "Debian":
-                if link:
-                    module.run_command(
-                        [UPDATE_ALTERNATIVES, '--install', link, name, path, str(DEFAULT_LINK_PRIORITY)],
-                        check_rc=True
-                    )
-                else:
-                    module.fail_json("Needed to install the alternative, but unable to do so, as we are missking the link")
+            if path not in all_alternatives:
+                if not link:
+                    module.fail_json(msg="Needed to install the alternative, but unable to do so as we are missing the link")
+
+                module.run_command(
+                    [UPDATE_ALTERNATIVES, '--install', link, name, path, str(DEFAULT_LINK_PRIORITY)],
+                    check_rc=True
+                )
 
             # select the requested path
             module.run_command(
@@ -151,7 +132,7 @@ def main():
             )
 
             module.exit_json(changed=True)
-        except subprocess.CalledProcessError, cpe:
+        except subprocess.CalledProcessError as cpe:
             module.fail_json(msg=str(dir(cpe)))
     else:
         module.exit_json(changed=False)


Test patch: