1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

Add subcommands parameter for module alternatives. (#4654)

* Add slaves parameter for module alternatives.

* alternatives: Improve documentation abous slaves parameter

* alternatives: Apply suggestions from code review

Co-authored-by: Felix Fontein <felix@fontein.de>

* alternatives: Add schangelog for slaves parameter

* alernatives: Add integration tests

* alternatives: Improv tests

* alternatives: Update tests/integration/targets/alternatives/tasks/slaves.yml

Co-authored-by: Felix Fontein <felix@fontein.de>

* alternatives: Rework logic to support updating priority and subcommands

* alternatives: Use more inclusive naming

* alternatives: Fix linter warnings

* alternatives: Dont fail if link is absent

* alternatives: Update changelog fragment

* alternatives: Add tests for prio change and removing

* alternatives: Apply suggestions from code review

Co-authored-by: Felix Fontein <felix@fontein.de>

* alternatives: Add `state=auto`to reset mode to auto

* alternatives: Fix linter warnings

* alternatives: Fix documentation.

* alternatives: Combine multiple messages.

* alternatives: Set command env for all commands.

* alternatives: Do not update subcommands if parameter is omited

* alternatives: Fix a bug with python 2.7 var scoping

* alternatives: Improce diff before generation

* alternatives: Fix linter warnings

* alternatives: Fix test names

* alternatives: Simplify subcommands handling and improve diffs

* aliases: Only test for subcommand changes if subcommands parameter is set.

* Update plugins/modules/system/alternatives.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Apply suggestions from code review

Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
Marius Rieder 2022-06-06 10:33:39 +02:00 committed by GitHub
parent 62ff263ac1
commit 373da56b5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 420 additions and 106 deletions

2
.github/BOTMETA.yml vendored
View file

@ -1027,7 +1027,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:

View file

@ -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).

View file

@ -3,6 +3,7 @@
# Copyright: (c) 2014, Gabe Mulley <gabe.mulley@gmail.com>
# Copyright: (c) 2015, David Wittman <dwittman@gmail.com>
# Copyright: (c) 2022, Marius Rieder <marius.rieder@scs.com>
# 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 @@ description:
- 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 @@ options:
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 @@ EXAMPLES = r'''
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 @@ from ansible.module_utils.basic import AnsibleModule
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 <name>` 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 <name>` 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__':

View file

@ -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

View file

@ -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'

View file

@ -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'"

View file

@ -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'"

View file

@ -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"