Repository: ansible-collections/community.general

Issue:
Allow alternatives to be installed without automatically setting them as the current selection
### Summary

Presently, installing an alternative also sets it as the current selection (see [here](https://github.com/ansible-collections/community.general/blob/a675afcba9ab167832a6d273eb272e6a3d5ac216/plugins/modules/system/alternatives.py#L146-L149)). This can be problematic for a few reasons:
* After running ansible, the selected alternative is always whichever is run last in your playbook.
* If your ansible configuration installs multiple alternatives for a program, it marks each one as changed because it sets each one as the selection as it iterates through the alternatives.
* A user may manually change the selected alternative, which would then be overridden every time ansible runs.

I suggest adding a parameter which specifies whether an alternative should be set as the current selection. If not, it will simply be installed and the current selection will remain.

### Issue Type

Feature Idea

### Component Name

alternatives

### Additional Information

#### Current behavior
Configuring `go` alternatives this way:
```
- name: Set up Go alternatives
  become: true
  community.general.alternatives:
    name: go
    path: "{{ item.path }}"
    link: /usr/bin/go
    priority: "{{ item.priority }}"
  loop:
    - { path: /usr/lib/go-1.13/bin/go, priority: 5 } 
    - { path: /usr/lib/go-1.14/bin/go, priority: 1 } 
    - { path: /usr/lib/go-1.16/bin/go, priority: 7 } 
    - { path: /usr/local/lib/go-1.17/bin/go, priority: 10 }
    - { path: /usr/local/lib/go-1.18/bin/go, priority: 9 }
```
First pass results in:
```
TASK [Set up Go alternatives] *********************************
changed: [127.0.0.1] => (item={'path': '/usr/lib/go-1.13/bin/go', 'priority': 5})
changed: [127.0.0.1] => (item={'path': '/usr/lib/go-1.14/bin/go', 'priority': 1})
changed: [127.0.0.1] => (item={'path': '/usr/lib/go-1.16/bin/go', 'priority': 7})
changed: [127.0.0.1] => (item={'path': '/usr/local/lib/go-1.17/bin/go', 'priority': 10})
changed: [127.0.0.1] => (item={'path': '/usr/local/lib/go-1.18/bin/go', 'priority': 9})
```
and
```
$ update-alternatives --config go
There are 5 choices for the alternative go (providing /usr/bin/go).

  Selection    Path                           Priority   Status
------------------------------------------------------------
  0            /usr/local/lib/go-1.17/bin/go   10        auto mode
  1            /usr/lib/go-1.13/bin/go         5         manual mode
  2            /usr/lib/go-1.14/bin/go         1         manual mode
  3            /usr/lib/go-1.16/bin/go         7         manual mode
  4            /usr/local/lib/go-1.17/bin/go   10        manual mode
* 5            /usr/local/lib/go-1.18/bin/go   9         manual mode
```
So by default, it forces manual mode and activates the last alternative to be installed.

Then, if I work on a project using Go 1.16 and switch it manually, then run Ansible again, it does exactly the same thing, selecting each alternative as it iterates through the loop:
```
TASK [Set up Go alternatives] *********************************
changed: [127.0.0.1] => (item={'path': '/usr/lib/go-1.13/bin/go', 'priority': 5})
changed: [127.0.0.1] => (item={'path': '/usr/lib/go-1.14/bin/go', 'priority': 1})
changed: [127.0.0.1] => (item={'path': '/usr/lib/go-1.16/bin/go', 'priority': 7})
changed: [127.0.0.1] => (item={'path': '/usr/local/lib/go-1.17/bin/go', 'priority': 10})
changed: [127.0.0.1] => (item={'path': '/usr/local/lib/go-1.18/bin/go', 'priority': 9})
```
and Go 1.18 is selected again at the end:
```
$ update-alternatives --config go
There are 5 choices for the alternative go (providing /usr/bin/go).

  Selection    Path                           Priority   Status
------------------------------------------------------------
  0            /usr/local/lib/go-1.17/bin/go   10        auto mode
  1            /usr/lib/go-1.13/bin/go         5         manual mode
  2            /usr/lib/go-1.14/bin/go         1         manual mode
  3            /usr/lib/go-1.16/bin/go         7         manual mode
  4            /usr/local/lib/go-1.17/bin/go   10        manual mode
* 5            /usr/local/lib/go-1.18/bin/go   9         manual mode
```

So I don't like that it turns off auto mode and that successive runs of Ansible switch the selected alternative, which a user may want to manage manually.

#### Desired behavior
I would like to be able to install alternatives *without* selecting them as the current choice.
I.e., the result of the first run of Ansible to install all of the alternatives would be:
```
$ update-alternatives --config go
There are 5 choices for the alternative go (providing /usr/bin/go).

  Selection    Path                           Priority   Status
------------------------------------------------------------
* 0            /usr/local/lib/go-1.17/bin/go   10        auto mode
  1            /usr/lib/go-1.13/bin/go         5         manual mode
  2            /usr/lib/go-1.14/bin/go         1         manual mode
  3            /usr/lib/go-1.16/bin/go         7         manual mode
  4            /usr/local/lib/go-1.17/bin/go   10        manual mode
  5            /usr/local/lib/go-1.18/bin/go   9         manual mode
```
So it's still in `auto` mode. Then, I can change the selection manually:
```
  Selection    Path                           Priority   Status
------------------------------------------------------------
  0            /usr/local/lib/go-1.17/bin/go   10        auto mode
  1            /usr/lib/go-1.13/bin/go         5         manual mode
  2            /usr/lib/go-1.14/bin/go         1         manual mode
* 3            /usr/lib/go-1.16/bin/go         7         manual mode
  4            /usr/local/lib/go-1.17/bin/go   10        manual mode
  5            /usr/local/lib/go-1.18/bin/go   9         manual mode
```
and re-run Ansible:
```
TASK [Set up Go alternatives] *********************************
ok: [127.0.0.1] => (item={'path': '/usr/lib/go-1.13/bin/go', 'priority': 5})
ok: [127.0.0.1] => (item={'path': '/usr/lib/go-1.14/bin/go', 'priority': 1})
ok: [127.0.0.1] => (item={'path': '/usr/lib/go-1.16/bin/go', 'priority': 7})
ok: [127.0.0.1] => (item={'path': '/usr/local/lib/go-1.17/bin/go', 'priority': 10})
ok: [127.0.0.1] => (item={'path': '/usr/local/lib/go-1.18/bin/go', 'priority': 9})
```
and it marks them as `ok`, since it's not setting them as the current selection, and my manual changes to the alternative selection is preserved.

#### Possible implementation
I tested a possible implementation [here](https://github.com/tprestegard/community.general/commit/973423101d362c1b2ac09a074b36b4b478b74c08), which adds an `activate` parameter (defaults to `True`). If `activate` is not provided or is set to `True`, the current behavior will occur. If `activate` is set to `False`, the alternative will not be set as the current selection. This preserves `auto` mode when installing an alternatives group (if all of them have `activate = False`), and also will not override any manual changes a user has made to an alternatives selection.


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/system/alternatives.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/system/alternatives.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/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @mulby
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Can't you already do this by simply setting the `priority` to a number that is less than the existing "Best" alternative?

(FWIW yesterday I wrote [some Bash](https://github.com/kellyjonbrazil/jc/issues/230#issue-1211487421) that I intend to adapt to generate `ansible_local.alternatives` facts that can be compared with proposed alternatives so that the `community.general.alternatives` tasks are only run when necessary.)
> Can't you already do this by simply setting the `priority` to a number that is less than the existing "Best" alternative?
> 
> (FWIW yesterday I wrote [some Bash](https://github.com/kellyjonbrazil/jc/issues/230#issue-1211487421) that I intend to adapt to generate `ansible_local.alternatives` facts that can be compared with proposed alternatives so that the `community.general.alternatives` tasks are only run when necessary.)

@chriscroome I have added an (overly) detailed description of the current behavior and desired behavior in the issue summary. To answer your question directly, the module currently runs `update-alternatives --set` any time an alternative is not the currently selected one, so auto mode is always disabled and the priority is ignored.

Resolving patch:
diff --git a/plugins/modules/system/alternatives.py b/plugins/modules/system/alternatives.py
--- a/plugins/modules/system/alternatives.py
+++ b/plugins/modules/system/alternatives.py
@@ -41,6 +41,16 @@
       - The priority of the alternative.
     type: int
     default: 50
+  state:
+    description:
+      - C(present) - install the alternative (if not already installed), but do
+        not set it as the currently selected alternative for the group.
+      - C(selected) - install the alternative (if not already installed), and
+        set it as the currently selected alternative for the group.
+    choices: [ present, selected ]
+    default: selected
+    type: str
+    version_added: 4.8.0
 requirements: [ update-alternatives ]
 '''
 
@@ -61,6 +71,13 @@
     name: java
     path: /usr/lib/jvm/java-7-openjdk-i386/jre/bin/java
     priority: -10
+
+- name: Install Python 3.5 but do not select it
+  community.general.alternatives:
+    name: python
+    path: /usr/bin/python3.5
+    link: /usr/bin/python
+    state: present
 '''
 
 import os
@@ -70,6 +87,15 @@
 from ansible.module_utils.basic import AnsibleModule
 
 
+class AlternativeState:
+    PRESENT = "present"
+    SELECTED = "selected"
+
+    @classmethod
+    def to_list(cls):
+        return [cls.PRESENT, cls.SELECTED]
+
+
 def main():
 
     module = AnsibleModule(
@@ -78,6 +104,11 @@ def main():
             path=dict(type='path', required=True),
             link=dict(type='path'),
             priority=dict(type='int', default=50),
+            state=dict(
+                type='str',
+                choices=AlternativeState.to_list(),
+                default=AlternativeState.SELECTED,
+            ),
         ),
         supports_check_mode=True,
     )
@@ -87,6 +118,7 @@ def main():
     path = params['path']
     link = params['link']
     priority = params['priority']
+    state = params['state']
 
     UPDATE_ALTERNATIVES = module.get_bin_path('update-alternatives', True)
 
@@ -126,9 +158,20 @@ def main():
                         link = line.split()[1]
                         break
 
+    changed = False
     if current_path != path:
+
+        # Check mode: expect a change if this alternative is not already
+        # installed, or if it is to be set as the current selection.
         if module.check_mode:
-            module.exit_json(changed=True, current_path=current_path)
+            module.exit_json(
+                changed=(
+                    path not in all_alternatives or
+                    state == AlternativeState.SELECTED
+                ),
+                current_path=current_path,
+            )
+
         try:
             # install the requested path if necessary
             if path not in all_alternatives:
@@ -141,18 +184,34 @@ def main():
                     [UPDATE_ALTERNATIVES, '--install', link, name, path, str(priority)],
                     check_rc=True
                 )
+                changed = True
 
-            # select the requested path
+            # set the current selection to this path (if requested)
+            if state == AlternativeState.SELECTED:
+                module.run_command(
+                    [UPDATE_ALTERNATIVES, '--set', name, path],
+                    check_rc=True
+                )
+                changed = True
+
+        except subprocess.CalledProcessError as cpe:
+            module.fail_json(msg=str(dir(cpe)))
+    elif current_path == path and state == AlternativeState.PRESENT:
+        # Case where alternative is currently selected, but state is set
+        # to 'present'. In this case, we set to auto mode.
+        if module.check_mode:
+            module.exit_json(changed=True, current_path=current_path)
+
+        changed = True
+        try:
             module.run_command(
-                [UPDATE_ALTERNATIVES, '--set', name, path],
-                check_rc=True
+                [UPDATE_ALTERNATIVES, '--auto', name],
+                check_rc=True,
             )
-
-            module.exit_json(changed=True)
         except subprocess.CalledProcessError as cpe:
             module.fail_json(msg=str(dir(cpe)))
-    else:
-        module.exit_json(changed=False)
+
+    module.exit_json(changed=changed)
 
 
 if __name__ == '__main__':


Test patch:
diff --git a/tests/integration/targets/alternatives/tasks/main.yml b/tests/integration/targets/alternatives/tasks/main.yml
--- a/tests/integration/targets/alternatives/tasks/main.yml
+++ b/tests/integration/targets/alternatives/tasks/main.yml
@@ -49,6 +49,12 @@
   # Test that path is checked: alternatives must fail when path is nonexistent
   - import_tasks: path_is_checked.yml
 
+  # Test operation of the 'state' parameter
+  - block:
+    - include_tasks: remove_links.yml
+    - include_tasks: tests_state.yml
+
+  # Cleanup
   always:
     - include_tasks: remove_links.yml
 
@@ -62,6 +68,7 @@
         path: '/usr/bin/dummy{{ item }}'
         state: absent
       with_sequence: start=1 end=4
+
   # *Disable tests on Fedora 24*
   # Shippable Fedora 24 image provides chkconfig-1.7-2.fc24.x86_64 but not the
   # latest available version (chkconfig-1.8-1.fc24.x86_64). update-alternatives
diff --git a/tests/integration/targets/alternatives/tasks/remove_links.yml b/tests/integration/targets/alternatives/tasks/remove_links.yml
--- a/tests/integration/targets/alternatives/tasks/remove_links.yml
+++ b/tests/integration/targets/alternatives/tasks/remove_links.yml
@@ -3,5 +3,6 @@
     path: '{{ item }}'
     state: absent
   with_items:
+    - "{{ alternatives_dir }}/dummy"
     - /etc/alternatives/dummy
     - /usr/bin/dummy
diff --git a/tests/integration/targets/alternatives/tasks/tests_state.yml b/tests/integration/targets/alternatives/tasks/tests_state.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/alternatives/tasks/tests_state.yml
@@ -0,0 +1,71 @@
+# Add a few dummy alternatives with state = present and make sure that the
+# group is in 'auto' mode and the highest priority alternative is selected.
+- name: Add some dummy alternatives with state = present
+  alternatives:
+    name: dummy
+    path: "/usr/bin/dummy{{ item.n }}"
+    link: /usr/bin/dummy
+    priority: "{{ item.priority }}"
+    state: present
+  loop:
+    - { n: 1, priority: 50 }
+    - { n: 2, priority: 70 }
+    - { n: 3, priority: 25 }
+
+- name: Ensure that the link group is in auto mode
+  shell: 'head -n1 {{ alternatives_dir }}/dummy | grep "^auto$"'
+
+# Execute current selected 'dummy' and ensure it's the alternative we expect
+- name: Execute the current dummy command
+  shell: dummy
+  register: cmd
+
+- name: Ensure that the expected command was executed
+  assert:
+    that:
+      - cmd.stdout == "dummy2"
+
+# Add another alternative with state = 'selected' and make sure that
+# this change results in the group being set to manual mode, and the
+# new alternative being the selected one.
+- name: Add another dummy alternative with state = selected
+  alternatives:
+    name: dummy
+    path: /usr/bin/dummy4
+    link: /usr/bin/dummy
+    priority: 10
+    state: selected
+
+- name: Ensure that the link group is in manual mode
+  shell: 'head -n1 {{ alternatives_dir }}/dummy | grep "^manual$"'
+
+- name: Execute the current dummy command
+  shell: dummy
+  register: cmd
+
+- name: Ensure that the expected command was executed
+  assert:
+    that:
+      - cmd.stdout == "dummy4"
+
+# Set the currently selected alternative to state = 'present' (was previously
+# selected), and ensure that this results in the group being set to 'auto'
+# mode, and the highest priority alternative is selected.
+- name: Set current selected dummy to state = present
+  alternatives:
+    name: dummy
+    path: /usr/bin/dummy4
+    link: /usr/bin/dummy
+    state: present
+
+- name: Ensure that the link group is in auto mode
+  shell: 'head -n1 {{ alternatives_dir }}/dummy | grep "^auto$"'
+
+- name: Execute the current dummy command
+  shell: dummy
+  register: cmd
+
+- name: Ensure that the expected command was executed
+  assert:
+    that:
+      - cmd.stdout == "dummy2"