Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Allow to specify module/plugin requirements in a machine-readable way #7720

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions plugins/action/plugin_requirements_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <[email protected]>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)

__metaclass__ = type

from ansible.errors import AnsibleError
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.common._collections_compat import Mapping
from ansible.plugins.action import ActionBase
from ansible.utils.display import Display
from ansible.plugins.loader import connection_loader

from ansible.constants import DOCUMENTABLE_PLUGINS

from ansible.module_utils.common.validation import (
check_type_bool,
check_type_dict,
check_type_list,
check_type_str,
)

from ansible_collections.community.general.plugins.plugin_utils._dependencies import (
LoadingError,
UnknownPlugin,
UnknownPluginType,
retrieve_plugin_dependencies,
get_needed_facts,
get_used_facts,
Requirements,
RequirementFinder,
)

display = Display()


class TimedOutException(Exception):
pass


class ActionModule(ActionBase):
TRANSFERS_FILES = False
_VALID_ARGS = frozenset((
'plugins',
'modules_on_remote',
))

def __init__(self, *args, **kwargs):
super(ActionModule, self).__init__(*args, **kwargs)

def _load_facts(self, task_vars, hostname):
display.vvv('<{host}> {action}: running setup module to get facts'.format(host=hostname, action=self._task.action))
module_output = self._execute_module(
task_vars=task_vars,
module_name='ansible.legacy.setup',
module_args={'gather_subset': 'min'})
if module_output.get('failed', False):
raise AnsibleError('Failed to determine system distribution. {0}, {1}'.format(
to_native(module_output['module_stdout']).strip(),
to_native(module_output['module_stderr']).strip()))
result = {}
used_facts = get_used_facts()
for k, v in module_output['ansible_facts'].items():
if k.startswith('ansible_'):
k = k[8:]
if k in used_facts:
result[k] = v
return result

def _extract_facts(self, task_vars):
if not isinstance(task_vars, Mapping):
return None
if 'ansible_facts' not in task_vars:
return None
ansible_facts = task_vars['ansible_facts']
needed_facts = get_needed_facts()
if any(k not in ansible_facts for k in needed_facts):
return None
used_facts = get_used_facts()
return {k: ansible_facts[k] for k in used_facts if k in ansible_facts}

def _get_facts(self, local, task_vars, hostname):
if local and self._connection.transport != 'local':
format_vars = dict(host=hostname, action=self._task.action)
result = self._extract_facts({'ansible_facts': self._templar.template("{{hostvars['localhost']['ansible_facts']}}")})
if result:
display.vvv('<{host}> {action}: already have local facts'.format(**format_vars))
return result
original_connection = self._connection
try:
display.vvv('<{host}> {action}: getting hold of local connection...'.format(**format_vars))
self._connection = connection_loader.get('ansible.legacy.local', self._play_context)
display.vvv('<{host}> {action}: retrieving local facts...'.format(**format_vars))
return self._load_facts(task_vars, hostname)
finally:
self._connection = original_connection
elif not local and self._connection.transport == 'local':
raise AnsibleError('Cannot retrieve remote facts if connection is local')

format_vars = dict(host=hostname, action=self._task.action, local_remote='local' if local else 'remote')
result = self._extract_facts(task_vars)
if result:
display.vvv('<{host}> {action}: already have {local_remote} facts'.format(**format_vars))
return result
display.vvv('<{host}> {action}: retrieving {local_remote} facts...'.format(**format_vars))
return self._load_facts(task_vars, hostname)

def run(self, tmp=None, task_vars=None):
self._supports_check_mode = True

if task_vars is None:
task_vars = {}

result = super(ActionModule, self).run(tmp, task_vars)

if result.get('skipped', False) or result.get('failed', False):
return result

try:
if 'plugins' not in self._task.args:
raise TypeError('missing required arguments: plugins')
modules_on_remote = check_type_bool(self._task.args.get('modules_on_remote', True))
plugins = []
for plugin in [check_type_dict(plug) for plug in check_type_list(self._task.args['plugins'])]:
if 'name' not in plugin:
raise TypeError('missing required arguments: name found in plugins')
plugin_type = check_type_str(plugin.get('type', 'module'))
if plugin_type not in DOCUMENTABLE_PLUGINS:
raise TypeError('unknown plugin type %s' % plugin_type)
plugins.append({
'name': check_type_str(plugin.get('name')),
'type': plugin_type,
})
except TypeError as exc:
result['failed'] = True
result['msg'] = to_native(exc)
return result

hostname = task_vars.get('inventory_hostname')

need_remote_facts = modules_on_remote and any(plugin['type'] == 'module' for plugin in plugins)
need_local_facts = (plugins and not modules_on_remote) or any(plugin['type'] != 'module' for plugin in plugins)
if self._connection.transport == 'local':
need_remote_facts = False
need_local_facts = bool(plugins)

controller_ansible_facts = self._get_facts(local=True, task_vars=task_vars, hostname=hostname) if need_local_facts else {}
remote_ansible_facts = self._get_facts(local=False, task_vars=task_vars, hostname=hostname) if need_remote_facts else {}

try:
requirement_finder = RequirementFinder(self._templar, controller_ansible_facts, remote_ansible_facts)
all_deps = Requirements()
for plugin in plugins:
display.vvv('<{host}> {action}: retrieving installable requirements of {type} {name}'.format(host=hostname, action=self._task.action, **plugin))
installable_requirements = retrieve_plugin_dependencies(plugin['name'], plugin['type'])
deps = requirement_finder.find(installable_requirements, modules_for_remote=modules_on_remote and self._connection.transport != 'local')
all_deps.merge(deps)
result['python'] = sorted(all_deps.python)
result['system'] = sorted(all_deps.system)
result['changed'] = False
return result
except UnknownPluginType as exc:
result['failed'] = True
result['msg'] = 'Unknown plugin type: %s' % to_native(exc)
return result
except UnknownPlugin as exc:
result['failed'] = True
result['msg'] = 'Unknown plugin: %s' % to_native(exc)
return result
except LoadingError as exc:
result['failed'] = True
result['msg'] = to_native(exc)
return result
24 changes: 24 additions & 0 deletions plugins/modules/java_cert.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,30 @@
choices: [ absent, present ]
default: present
requirements: [openssl, keytool]
installable_requirements:
- name: Java keytool
blocks:
- system:
- openjdk11-jre-headless
when: ansible_facts.os_family == 'Alpine'
- system:
- java-11-openjdk-headless
when: ansible_facts.os_family in ['RedHat', 'Suse']
- system:
- jre11-openjdk-headless
when: ansible_facts.os_family == 'Archlinux'
- system:
- ca-certificates-java
when: ansible_facts.distribution == 'Debian' and ansible_facts.distribution_major_version | int < 12
- system:
- ca-certificates-java
- openjdk-17-jre-headless
when: ansible_facts.os_family == 'Debian'
- name: OpenSSL
blocks:
- system:
- openssl
when: true
author:
- Adam Hamsik (@haad)
'''
Expand Down
112 changes: 112 additions & 0 deletions plugins/modules/plugin_requirements_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <[email protected]>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type


DOCUMENTATION = r'''
module: plugin_requirements_info
short_description: Gather requirements for one or multiple plugins
description:
- Gather requirements for one or multiple plugins.
version_added: 8.2.0
extends_documentation_fragment:
- community.general.attributes
- community.general.attributes.info_module
- community.general.attributes.flow
attributes:
action:
support: full
async:
support: none
requirements: []
installable_requirements: []
options:
plugins:
description:
- A list of plugins to query requirements for.
required: true
type: list
elements: dict
suboptions:
name:
description:
- The name of the plugin.
required: true
type: str
type:
description:
- The type of the plugin.
- Not all types are supported by all versions of ansible-core. Generally C(ansible-doc -t <type>) must work.
default: 'module'
type: str
choices:
# CONFIGURABLE_PLUGINS
- become
- cache
- callback
- cliconf
- connection
- httpapi
- inventory
- lookup
- netconf
- shell
- vars
# DOCUMENTABLE_PLUGINS
- module
- strategy
- test
- filter
modules_on_remote:
description:
- Whether to assume that modules run on the remote, and not the controller.
- Set to V(false) if you plan to run the module(s) on the controller.
type: bool
default: true

author:
- Felix Fontein (@felixfontein)
'''

EXAMPLES = r'''
- name: Unconditionally shut down the machine with all defaults
community.general.plugin_requirements_info:
plugins:
- name: community.general.plugin_requirements_info
register: requirements

- name: Install system requirements
ansible.builtin.package:
name: "{{ requirements.system }}"
when: "{{ requirements.system }}"
felixfontein marked this conversation as resolved.
Show resolved Hide resolved

- name: Install Python requirements
ansible.builtin.pip:
name: "{{ requirements.python }}"
when: "{{ requirements.python }}"
felixfontein marked this conversation as resolved.
Show resolved Hide resolved
'''

RETURN = r'''
system:
description:
- A list of system requirements.
type: list
elements: str
returned: success
sample:
- openssl

python:
description:
- A list of Python requirements.
type: list
elements: str
returned: success
sample:
- cryptography
'''
5 changes: 5 additions & 0 deletions plugins/modules/ufw.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
support: full
diff_mode:
support: none
installable_requirements:
- name: ufw firewall
blocks:
- system:
- ufw
options:
state:
description:
Expand Down
Loading
Loading