diff --git a/lib/ansible/constants.py b/lib/ansible/constants.py index c2ce7e5ec9d..86b9662e529 100644 --- a/lib/ansible/constants.py +++ b/lib/ansible/constants.py @@ -46,6 +46,7 @@ _ACTION_ALL_PROPER_INCLUDE_IMPORT_TASKS = _ACTION_INCLUDE_TASKS + _ACTION_IMPORT _ACTION_ALL_INCLUDE_ROLE_TASKS = _ACTION_INCLUDE_ROLE + _ACTION_INCLUDE_TASKS _ACTION_FACT_GATHERING = _ACTION_SETUP + add_internal_fqcns(('gather_facts', )) _ACTION_WITH_CLEAN_FACTS = _ACTION_SET_FACT + _ACTION_INCLUDE_VARS +_ACTION_VARS = add_internal_fqcns(('set_var', )) # http://nezzen.net/2008/06/23/colored-text-in-python-using-ansi-escape-sequences/ COLOR_CODES = { @@ -125,6 +126,7 @@ MODULE_NO_JSON = tuple(add_internal_fqcns(('command', 'win_command', 'shell', 'w RESTRICTED_RESULT_KEYS = ('ansible_rsync_path', 'ansible_playbook_python', 'ansible_facts') SYNTHETIC_COLLECTIONS = ('ansible.builtin', 'ansible.legacy') TREE_DIR = None +VALID_VAR_SCOPES = ('extra', 'host', 'host_fact', 'parent', 'play') VAULT_VERSION_MIN = 1.0 VAULT_VERSION_MAX = 1.0 diff --git a/lib/ansible/executor/task_executor.py b/lib/ansible/executor/task_executor.py index a9fa2c22110..6b7fd6df878 100644 --- a/lib/ansible/executor/task_executor.py +++ b/lib/ansible/executor/task_executor.py @@ -668,6 +668,11 @@ class TaskExecutor: ), ) + if '_ansible_vars' in result and self._task.action not in C._ACTION_VARS: + del result['_ansible_vars'] + else: + vars_copy.update(results['_ansible_vars']) + if 'ansible_facts' in result and self._task.action not in C._ACTION_DEBUG: if self._task.action in C._ACTION_WITH_CLEAN_FACTS: if self._task.delegate_to and self._task.delegate_facts: @@ -772,6 +777,9 @@ class TaskExecutor: if self._task.register: variables[self._task.register] = result + if '_ansible_vars' in result and self._task.action not in C._ACTION_VARS: + del result['_ansible_vars'] + if 'ansible_facts' in result and self._task.action not in C._ACTION_DEBUG: if self._task.action in C._ACTION_WITH_CLEAN_FACTS: variables.update(result['ansible_facts']) diff --git a/lib/ansible/modules/set_vars.py b/lib/ansible/modules/set_vars.py new file mode 100644 index 00000000000..228cd446ef3 --- /dev/null +++ b/lib/ansible/modules/set_vars.py @@ -0,0 +1,64 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) Ansible Project +# 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 +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: set_vars +short_description: set a variable to a value at a given scope +description: + - This module allows setting variables in the current ansible run at different scopesA + - If a variable already exists it will be overriden or merged depending on 'hash behaviour' + otherwise it will be created +author: Ansible Project +options: + defs: + description: + - A list of dictionaries that represent a variable definition + type: list + required: true + scope: + required: false + choices: ['host', 'host_fact', 'extra', 'play'] + description: + - scope to which variables apply to, this sets the default for every definition but each one can override it + type: string + default: 'host' +version_added: "2.12" +''' + +EXAMPLES = r''' +- name: create/update an extra var + set_vars: + scope: extra + defs: + - name: packages_to_install + value: + - apache + - nginx + - haproxy + - pound + +- name: set a host fact (not the same as set_facts + cacheable=true, only 1 var is created) + set_vars: + defs: + - name: pretty + value: no + scope: host_fact + +- name: set a host variable (higher priority than facts), this is equivalent of set_fact + cacheable=false + set_vars: + defs: + - name: presents + value: all + scope: host +''' + +RETURNS = r''' +# should i return list of var names + scope set? +''' diff --git a/lib/ansible/plugins/action/set_var.py b/lib/ansible/plugins/action/set_var.py new file mode 100644 index 00000000000..b2364d359fa --- /dev/null +++ b/lib/ansible/plugins/action/set_var.py @@ -0,0 +1,85 @@ + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible import constants as C +from ansible.errors import AnsibleActionFail +from ansible.module_utils.common._collections_compat import Mapping, Sequence +from ansible.module_utils.six import string_types +from ansible.plugins.action import ActionBase +from ansible.utils.vars import isidentifier, combine_vars + + +class ActionModule(ActionBase): + + TRANSFERS_FILES = False + _VALID_VARS_DEF = frozenset(('scope', 'name', 'value')) + _VALID_ARGS = frozenset(('scope', 'defs')) + + def _extract_validated_var_definition(self, var_def, global_scope): + + if not isinstance(var_def, Mapping): + raise AnsibleActionFail("The variable definition should be a dictionary, but got a %s" % type(var_def)) + + try: + name = var_def.pop('name') + except KeyError: + raise AnsibleActionFail("The variable definition requires a 'name' entry") + + if not isidentifier(name): + raise AnsibleActionFail("The variable name '%s' is not valid. Variables must start with a letter or underscore character, and contain only " + "letters, numbers and underscores." % name) + try: + value = var_def.pop('value') + except KeyError: + raise AnsibleActionFail("The variable definition requires a 'value' entry") + + scope = var_def.pop('scope', global_scope) + if not isinstance(scope, string_types): + raise AnsibleActionFail("The 'scope' option for '%s' should be a string, but got a %s" % (name, type(scope))) + elif scope not in C.VALID_VAR_SCOPES: + raise AnsibleActionFail("Invalid 'scope' option for '%s', got '%s' but should be one of: %s" % (name, scope, ', '.join(C.VALID_VAR_SCOPES))) + + if len(var_def) > 0: + raise AnsibleActionFail("Unknown arguments were passed into variable definition for '%s', only '%s' are valid." % (name, ', '.join(self._VALID_VARS_DEF))) + + return name, value, scope + + def run(self, tmp=None, task_vars=None): + if task_vars is None: + task_vars = dict() + + result = super(ActionModule, self).run(tmp, task_vars) + + result['_ansible_vars'] = {} + if self._task.args: + + try: + vars_list = self._task.args.pop('defs') + except KeyError: + raise AnsibleActionFail("set_var requires a 'defs' option, none was provided") + + scope = self._task.args.pop('scope', 'host') + + if len(self._task.args) > 0: + raise AnsibleActionFail("Unknown arguments were passed into set_var, only valid ones are: %s" % ', '.join(self._VALID_ARGS)) + + if isinstance(vars_list, string_types) or not isinstance(vars_list, Sequence): + raise AnsibleActionFail("set_var takes a list of variable definitions, but got a '%s' instead" % type(vars_list)) + + for var_def in vars_list: + + name, value, scope = self._extract_validated_var_definition(var_def, scope) + + if scope not in result['_ansible_vars']: + result['_ansible_vars'][scope] = {} + + # allow for setting in loop + if '_ansible_vars' in task_vars and scope in task_vars['_ansilbe_vars'] and name in task_vars['_ansible_vars'][scope]: + result['_ansible_vars'][scope][name] = combine_vars(task_vars['_ansible_facts'][scope][name], value) + else: + result['_ansible_vars'][scope][name] = value + + result['changed'] = False + + return result diff --git a/lib/ansible/plugins/strategy/__init__.py b/lib/ansible/plugins/strategy/__init__.py index 39b3851125a..91f61a9a49f 100644 --- a/lib/ansible/plugins/strategy/__init__.py +++ b/lib/ansible/plugins/strategy/__init__.py @@ -422,6 +422,15 @@ class StrategyBase: host_name = result.get('_ansible_delegated_vars', {}).get('ansible_delegated_host', None) return [host_name or task.delegate_to] + def get_target_hosts(self, iterator, result, task, facts=False): + + if task.delegate_to is not None and (not facts or task.delegate_facts): + host_list = self.get_delegated_hosts(result, task) + else: + host_list = self.get_task_hosts(iterator, result._host, task) + + return host_list + def _set_always_delegated_facts(self, result, task): """Sets host facts for ``delegate_to`` hosts for facts that should always be delegated @@ -689,6 +698,9 @@ class StrategyBase: if result_item.get('failed', False): task_result._return_data['failed'] = True + # always clean up + del result_item['_ansible_vars'] + if 'ansible_facts' in result_item and original_task.action not in C._ACTION_DEBUG: # if delegated fact and we are delegating facts, we need to change target host for them if original_task.delegate_to is not None and original_task.delegate_facts: @@ -719,6 +731,30 @@ class StrategyBase: if is_set_fact: self._variable_manager.set_nonpersistent_facts(target_host, result_item['ansible_facts'].copy()) + if '_ansible_vars' in result_item: + if original_task.action not in C._ACTINON_VARS: + display.warning('Removed unexpected _ansible_vars key from results of "%s", ' + 'possibly a result from an attempt to bypass security' % original_task.action) + else: + for scope in result_item['_ansible_vars']: + if scope == 'host': + host_list = self.get_target_hosts(iterator, result_item, original_task) + for target_host in host_list: + for var_name, var_value in iteritems(result_item['_ansible_vars']['host']): + self._variable_manager.set_host_variable(target_host, var_name, var_value) + elif scope == 'host_facts': + if original_task.delegate_facts: + host_list = self.get_target_hosts(iterator, result_item, original_task, facts=True) + for target_host in host_list: + self._variable_manager.set_nonpersistent_facts(target_host, result_item['_ansible_vars']['host_facts']) + elif scope == 'play': + self._variable_manager.set_play_vars(iterator._play, result_item['_ansible_vars']['extra']) + elif scope == 'extra': + self._variable_manager.set_extra_vars(result_item['_ansible_vars']['extra']) + else: + # we really should never hit this + display.warning('Ignoring "%s" as it is an invalid scope for set_var' % scope) + if 'ansible_stats' in result_item and 'data' in result_item['ansible_stats'] and result_item['ansible_stats']['data']: if 'per_host' not in result_item['ansible_stats'] or result_item['ansible_stats']['per_host']: diff --git a/test/integration/targets/interpreter_discovery_python/tasks/library/bashmodule.sh b/test/integration/targets/interpreter_discovery_python/tasks/library/bashmodule.sh new file mode 100755 index 00000000000..863f8ae9969 --- /dev/null +++ b/test/integration/targets/interpreter_discovery_python/tasks/library/bashmodule.sh @@ -0,0 +1,3 @@ +#/usr/bin/bash + +echo '{"failed": "false", "msg": "tested module"}'