diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index ecedff1e81b..d829d2a89f2 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1024,7 +1024,7 @@ files: $modules/system/alternatives.py: maintainers: mulby labels: alternatives - ignore: DavidWittman + ignore: DavidWittman jiuka $modules/system/aix_lvol.py: maintainers: adejoux $modules/system/awall.py: diff --git a/changelogs/fragments/4654-alternatives-add-subcommands.yml b/changelogs/fragments/4654-alternatives-add-subcommands.yml new file mode 100644 index 00000000000..f771e9b51c8 --- /dev/null +++ b/changelogs/fragments/4654-alternatives-add-subcommands.yml @@ -0,0 +1,3 @@ +minor_changes: + - alternatives - add ``subcommands`` parameter (https://github.com/ansible-collections/community.general/pull/4654). + - alternatives - add ``state=absent`` to be able to remove an alternative (https://github.com/ansible-collections/community.general/pull/4654). diff --git a/plugins/modules/system/alternatives.py b/plugins/modules/system/alternatives.py index ca075d69b4a..1fbc5ccddc2 100644 --- a/plugins/modules/system/alternatives.py +++ b/plugins/modules/system/alternatives.py @@ -3,6 +3,7 @@ # Copyright: (c) 2014, Gabe Mulley # Copyright: (c) 2015, David Wittman +# Copyright: (c) 2022, Marius Rieder # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function @@ -17,6 +18,7 @@ - Manages symbolic links using the 'update-alternatives' tool. - Useful when multiple programs are installed but provide similar functionality (e.g. different editors). author: + - Marius Rieder (@jiuka) - David Wittman (@DavidWittman) - Gabe Mulley (@mulby) options: @@ -47,10 +49,36 @@ 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 ] + - C(auto) - install the alternative (if not already installed), and + set the group to auto mode. Added in community.general 5.1.0. + - C(absent) - removes the alternative. Added in community.general 5.1.0. + choices: [ present, selected, auto, absent ] default: selected type: str version_added: 4.8.0 + subcommands: + description: + - A list of subcommands. + - Each subcommand needs a name, a link and a path parameter. + type: list + elements: dict + aliases: ['slaves'] + suboptions: + name: + description: + - The generic name of the subcommand. + type: str + required: true + path: + description: + - The path to the real executable that the subcommand should point to. + type: path + required: true + link: + description: + - The path to the symbolic link that should point to the real subcommand executable. + type: path + version_added: 5.1.0 requirements: [ update-alternatives ] ''' @@ -78,6 +106,23 @@ path: /usr/bin/python3.5 link: /usr/bin/python state: present + +- name: Install Python 3.5 and reset selection to auto + community.general.alternatives: + name: python + path: /usr/bin/python3.5 + link: /usr/bin/python + state: auto + +- name: keytool is a subcommand of java + community.general.alternatives: + name: java + link: /usr/bin/java + path: /usr/lib/jvm/java-7-openjdk-amd64/jre/bin/java + subcommands: + - name: keytool + link: /usr/bin/keytool + path: /usr/lib/jvm/java-7-openjdk-amd64/jre/bin/keytool ''' import os @@ -90,10 +135,235 @@ class AlternativeState: PRESENT = "present" SELECTED = "selected" + ABSENT = "absent" + AUTO = "auto" @classmethod def to_list(cls): - return [cls.PRESENT, cls.SELECTED] + return [cls.PRESENT, cls.SELECTED, cls.ABSENT, cls.AUTO] + + +class AlternativesModule(object): + _UPDATE_ALTERNATIVES = None + + def __init__(self, module): + self.module = module + self.result = dict(changed=False, diff=dict(before=dict(), after=dict())) + self.module.run_command_environ_update = {'LC_ALL': 'C'} + self.messages = [] + self.run() + + @property + def mode_present(self): + return self.module.params.get('state') in [AlternativeState.PRESENT, AlternativeState.SELECTED, AlternativeState.AUTO] + + @property + def mode_selected(self): + return self.module.params.get('state') == AlternativeState.SELECTED + + @property + def mode_auto(self): + return self.module.params.get('state') == AlternativeState.AUTO + + def run(self): + self.parse() + + if self.mode_present: + # Check if we need to (re)install + subcommands_parameter = self.module.params['subcommands'] + if ( + self.path not in self.current_alternatives or + self.current_alternatives[self.path].get('priority') != self.priority or + (subcommands_parameter is not None and ( + not all(s in subcommands_parameter for s in self.current_alternatives[self.path].get('subcommands')) or + not all(s in self.current_alternatives[self.path].get('subcommands') for s in subcommands_parameter) + )) + ): + self.install() + + # Check if we need to set the preference + if self.mode_selected and self.current_path != self.path: + self.set() + + # Check if we need to reset to auto + if self.mode_auto and self.current_mode == 'manual': + self.auto() + else: + # Check if we need to uninstall + if self.path in self.current_alternatives: + self.remove() + + self.result['msg'] = ' '.join(self.messages) + self.module.exit_json(**self.result) + + def install(self): + if not os.path.exists(self.path): + self.module.fail_json(msg="Specified path %s does not exist" % self.path) + if not self.link: + self.module.fail_json(msg='Needed to install the alternative, but unable to do so as we are missing the link') + + cmd = [self.UPDATE_ALTERNATIVES, '--install', self.link, self.name, self.path, str(self.priority)] + + if self.subcommands is not None: + subcommands = [['--slave', subcmd['link'], subcmd['name'], subcmd['path']] for subcmd in self.subcommands] + cmd += [item for sublist in subcommands for item in sublist] + + self.result['changed'] = True + self.messages.append("Install alternative '%s' for '%s'." % (self.path, self.name)) + + if not self.module.check_mode: + self.module.run_command(cmd, check_rc=True) + + if self.module._diff: + self.result['diff']['after'] = dict( + state=AlternativeState.PRESENT, + path=self.path, + priority=self.priority, + link=self.link, + ) + if self.subcommands: + self.result['diff']['after'].update(dict( + subcommands=self.subcommands + )) + + def remove(self): + cmd = [self.UPDATE_ALTERNATIVES, '--remove', self.name, self.path] + self.result['changed'] = True + self.messages.append("Remove alternative '%s' from '%s'." % (self.path, self.name)) + + if not self.module.check_mode: + self.module.run_command(cmd, check_rc=True) + + if self.module._diff: + self.result['diff']['after'] = dict(state=AlternativeState.ABSENT) + + def set(self): + cmd = [self.UPDATE_ALTERNATIVES, '--set', self.name, self.path] + self.result['changed'] = True + self.messages.append("Set alternative '%s' for '%s'." % (self.path, self.name)) + + if not self.module.check_mode: + self.module.run_command(cmd, check_rc=True) + + if self.module._diff: + self.result['diff']['after']['state'] = AlternativeState.SELECTED + + def auto(self): + cmd = [self.UPDATE_ALTERNATIVES, '--auto', self.name] + self.messages.append("Set alternative to auto for '%s'." % (self.name)) + self.result['changed'] = True + + if not self.module.check_mode: + self.module.run_command(cmd, check_rc=True) + + if self.module._diff: + self.result['diff']['after']['state'] = AlternativeState.PRESENT + + @property + def name(self): + return self.module.params.get('name') + + @property + def path(self): + return self.module.params.get('path') + + @property + def link(self): + return self.module.params.get('link') or self.current_link + + @property + def priority(self): + return self.module.params.get('priority') + + @property + def subcommands(self): + if self.module.params.get('subcommands') is not None: + return self.module.params.get('subcommands') + elif self.path in self.current_alternatives and self.current_alternatives[self.path].get('subcommands'): + return self.current_alternatives[self.path].get('subcommands') + return None + + @property + def UPDATE_ALTERNATIVES(self): + if self._UPDATE_ALTERNATIVES is None: + self._UPDATE_ALTERNATIVES = self.module.get_bin_path('update-alternatives', True) + return self._UPDATE_ALTERNATIVES + + def parse(self): + self.current_mode = None + self.current_path = None + self.current_link = None + self.current_alternatives = {} + + # Run `update-alternatives --display ` to find existing alternatives + (rc, display_output, dummy) = self.module.run_command( + [self.UPDATE_ALTERNATIVES, '--display', self.name] + ) + + if rc != 0: + self.module.debug("No current alternative found. '%s' exited with %s" % (self.UPDATE_ALTERNATIVES, rc)) + return + + current_mode_regex = re.compile(r'\s-\s(?:status\sis\s)?(\w*)(?:\smode|.)$', re.MULTILINE) + current_path_regex = re.compile(r'^\s*link currently points to (.*)$', re.MULTILINE) + current_link_regex = re.compile(r'^\s*link \w+ is (.*)$', re.MULTILINE) + subcmd_path_link_regex = re.compile(r'^\s*slave (\S+) is (.*)$', re.MULTILINE) + + alternative_regex = re.compile(r'^(\/.*)\s-\s(?:family\s\S+\s)?priority\s(\d+)((?:\s+slave.*)*)', re.MULTILINE) + subcmd_regex = re.compile(r'^\s+slave (.*): (.*)$', re.MULTILINE) + + match = current_mode_regex.search(display_output) + if not match: + self.module.debug("No current mode found in output") + return + self.current_mode = match.group(1) + + match = current_path_regex.search(display_output) + if not match: + self.module.debug("No current path found in output") + else: + self.current_path = match.group(1) + + match = current_link_regex.search(display_output) + if not match: + self.module.debug("No current link found in output") + else: + self.current_link = match.group(1) + + subcmd_path_map = dict(subcmd_path_link_regex.findall(display_output)) + if not subcmd_path_map and self.subcommands: + subcmd_path_map = dict((s['name'], s['link']) for s in self.subcommands) + + for path, prio, subcmd in alternative_regex.findall(display_output): + self.current_alternatives[path] = dict( + priority=int(prio), + subcommands=[dict( + name=name, + path=spath, + link=subcmd_path_map.get(name) + ) for name, spath in subcmd_regex.findall(subcmd) if spath != '(null)'] + ) + + if self.module._diff: + if self.path in self.current_alternatives: + self.result['diff']['before'].update(dict( + state=AlternativeState.PRESENT, + path=self.path, + priority=self.current_alternatives[self.path].get('priority'), + link=self.current_link, + )) + if self.current_alternatives[self.path].get('subcommands'): + self.result['diff']['before'].update(dict( + subcommands=self.current_alternatives[self.path].get('subcommands') + )) + if self.current_mode == 'manual' and self.current_path != self.path: + self.result['diff']['before'].update(dict( + state=AlternativeState.SELECTED + )) + else: + self.result['diff']['before'].update(dict( + state=AlternativeState.ABSENT + )) def main(): @@ -109,109 +379,16 @@ def main(): choices=AlternativeState.to_list(), default=AlternativeState.SELECTED, ), + subcommands=dict(type='list', elements='dict', aliases=['slaves'], options=dict( + name=dict(type='str', required=True), + path=dict(type='path', required=True), + link=dict(type='path'), + )), ), supports_check_mode=True, ) - params = module.params - name = params['name'] - path = params['path'] - link = params['link'] - priority = params['priority'] - state = params['state'] - - UPDATE_ALTERNATIVES = module.get_bin_path('update-alternatives', True) - - current_path = None - all_alternatives = [] - - # Run `update-alternatives --display ` to find existing alternatives - (rc, display_output, dummy) = module.run_command( - ['env', 'LC_ALL=C', UPDATE_ALTERNATIVES, '--display', name] - ) - - if rc == 0: - # 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-\s(?:family\s\S+\s)?priority', re.MULTILINE) - - match = current_path_regex.search(display_output) - if match: - current_path = match.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, dummy = module.run_command( - ['env', 'LC_ALL=C', UPDATE_ALTERNATIVES, '--query', name] - ) - if rc == 0: - for line in query_output.splitlines(): - if line.startswith('Link:'): - 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=( - 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: - if not os.path.exists(path): - module.fail_json(msg="Specified path %s does not exist" % path) - 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(priority)], - check_rc=True - ) - changed = True - - # 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, '--auto', name], - check_rc=True, - ) - except subprocess.CalledProcessError as cpe: - module.fail_json(msg=str(dir(cpe))) - - module.exit_json(changed=changed) + AlternativesModule(module) if __name__ == '__main__': diff --git a/tests/integration/targets/alternatives/tasks/main.yml b/tests/integration/targets/alternatives/tasks/main.yml index 1120cfd37de..70853e80054 100644 --- a/tests/integration/targets/alternatives/tasks/main.yml +++ b/tests/integration/targets/alternatives/tasks/main.yml @@ -49,6 +49,9 @@ # Test that path is checked: alternatives must fail when path is nonexistent - import_tasks: path_is_checked.yml + # Test that subcommands commands work + - import_tasks: subcommands.yml + # Test operation of the 'state' parameter - block: - include_tasks: remove_links.yml diff --git a/tests/integration/targets/alternatives/tasks/subcommands.yml b/tests/integration/targets/alternatives/tasks/subcommands.yml new file mode 100644 index 00000000000..ba4ecbbafe7 --- /dev/null +++ b/tests/integration/targets/alternatives/tasks/subcommands.yml @@ -0,0 +1,78 @@ +- name: Try with subcommands + alternatives: + name: dummymain + path: '/usr/bin/dummy1' + link: '/usr/bin/dummymain' + subcommands: + - name: dummysubcmd + path: '/usr/bin/dummy2' + link: '/usr/bin/dummysubcmd' + register: alternative + +- name: Check expected command was executed + assert: + that: + - 'alternative is changed' + +- name: Execute the current dummymain command + command: dummymain + register: cmd + +- name: Ensure that the expected command was executed + assert: + that: + - cmd.stdout == "dummy1" + +- name: Execute the current dummysubcmd command + command: dummysubcmd + register: cmd + +- name: Ensure that the expected command was executed + assert: + that: + - cmd.stdout == "dummy2" + +- name: Subcommands are not removed if not specified + alternatives: + name: dummymain + path: '/usr/bin/dummy1' + link: '/usr/bin/dummymain' + register: alternative + +- name: Check expected command was executed + assert: + that: + - 'alternative is not changed' + +- name: Execute the current dummysubcmd command + command: dummysubcmd + register: cmd + +- name: Ensure that the expected command was executed + assert: + that: + - cmd.stdout == "dummy2" + +- name: Subcommands are removed if set to an empty list + alternatives: + name: dummymain + path: '/usr/bin/dummy1' + link: '/usr/bin/dummymain' + subcommands: [] + register: alternative + +- name: Check expected command was executed + assert: + that: + - 'alternative is changed' + +- name: Execute the current dummysubcmd command + command: dummysubcmd + register: cmd + ignore_errors: True + +- name: Ensure that the subcommand is gone + assert: + that: + - cmd.rc == 2 + - '"No such file" in cmd.msg' \ No newline at end of file diff --git a/tests/integration/targets/alternatives/tasks/test.yml b/tests/integration/targets/alternatives/tasks/test.yml index 92721a995d3..a5b36ce9224 100644 --- a/tests/integration/targets/alternatives/tasks/test.yml +++ b/tests/integration/targets/alternatives/tasks/test.yml @@ -48,6 +48,4 @@ when: ansible_os_family == 'RedHat' and not with_alternatives and item == 1 - name: check that alternative has been updated - command: "grep -Pzq '/bin/dummy{{ item }}\\n' '{{ alternatives_dir }}/dummy'" - # priority doesn't seem updated - #command: "grep -Pzq '/bin/dummy{{ item }}\\n50' '{{ alternatives_dir }}/dummy'" + command: "grep -Pzq '/bin/dummy{{ item }}\\n50' '{{ alternatives_dir }}/dummy'" diff --git a/tests/integration/targets/alternatives/tasks/tests_set_priority.yml b/tests/integration/targets/alternatives/tasks/tests_set_priority.yml index ab79f62a3c9..a629e3f3682 100644 --- a/tests/integration/targets/alternatives/tasks/tests_set_priority.yml +++ b/tests/integration/targets/alternatives/tasks/tests_set_priority.yml @@ -21,3 +21,14 @@ - name: check that alternative has been updated command: "grep -Pzq '/bin/dummy{{ item }}\\n{{ 60 + item|int }}' '{{ alternatives_dir }}/dummy'" + +- name: update dummy priority + alternatives: + name: dummy + path: '/usr/bin/dummy{{ item }}' + link: /usr/bin/dummy + priority: '{{ 70 + item|int }}' + register: alternative + +- name: check that alternative priority has been updated + command: "grep -Pzq '/bin/dummy{{ item }}\\n{{ 70 + item|int }}' '{{ alternatives_dir }}/dummy'" \ No newline at end of file diff --git a/tests/integration/targets/alternatives/tasks/tests_state.yml b/tests/integration/targets/alternatives/tasks/tests_state.yml index 357da315ed4..dd3be565baa 100644 --- a/tests/integration/targets/alternatives/tasks/tests_state.yml +++ b/tests/integration/targets/alternatives/tasks/tests_state.yml @@ -49,6 +49,28 @@ - cmd.stdout == "dummy4" # Set the currently selected alternative to state = 'present' (was previously +# selected), and ensure that this results in the group not being set to 'auto' +# mode, and the alternative is still 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 "^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 = 'auto' (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 @@ -56,7 +78,7 @@ name: dummy path: /usr/bin/dummy4 link: /usr/bin/dummy - state: present + state: auto - name: Ensure that the link group is in auto mode shell: 'head -n1 {{ alternatives_dir }}/dummy | grep "^auto$"' @@ -69,3 +91,25 @@ assert: that: - cmd.stdout == "dummy2" + +# Remove an alternative with state = 'absent' and make sure that +# this change results in the alternative being removed. +- name: Remove best dummy alternative with state = absent + alternatives: + name: dummy + path: /usr/bin/dummy2 + state: absent + +- name: Ensure that the link group is in auto mode + shell: 'grep "/usr/bin/dummy2" {{ alternatives_dir }}/dummy' + register: cmd + failed_when: cmd.rc == 0 + +- name: Execute the current dummy command + shell: dummy + register: cmd + +- name: Ensure that the expected command was executed + assert: + that: + - cmd.stdout == "dummy1"