mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
Collection content loading (#52194)
* basic plugin loading working (with many hacks) * task collections working * play/block-level collection module/action working * implement PEP302 loader * implicit package support (no need for __init.py__ in collections) * provides future options for secure loading of content that shouldn't execute inside controller (eg, actively ignore __init__.py on content/module paths) * provide hook for synthetic collection setup (eg ansible.core pseudo-collection for specifying built-in plugins without legacy path, etc) * synthetic package support * ansible.core.plugins mapping works, others don't * synthetic collections working for modules/actions * fix direct-load legacy * change base package name to ansible_collections * note * collection role loading * expand paths from installed content root vars * feature complete? * rename ansible.core to ansible.builtin * and various sanity fixes * sanity tweaks * unittest fixes * less grabby error handler on has_plugin * probably need to replace with a or harden callers * fix win_ping test * disable module test with explicit file extension; might be able to support in some scenarios, but can't see any other tests that verify that behavior... * fix unicode conversion issues on py2 * attempt to keep things working-ish on py2.6 * python2.6 test fun round 2 * rename dirs/configs to "collections" * add wrapper dir for content-adjacent * fix pythoncheck to use localhost * unicode tweaks, native/bytes string prefixing * rename COLLECTION_PATHS to COLLECTIONS_PATHS * switch to pathspec * path handling cleanup * change expensive `all` back to or chain * unused import cleanup * quotes tweak * use wrapped iter/len in Jinja proxy * var name expansion * comment seemingly overcomplicated playbook_paths resolution * drop unnecessary conditional nesting * eliminate extraneous local * zap superfluous validation function * use slice for rolespec NS assembly * misc naming/unicode fixes * collection callback loader asks if valid FQ name instead of just '.' * switch collection role resolution behavior to be internally `text` as much as possible * misc fixmes * to_native in exception constructor * (slightly) detangle tuple accumulation mess in module_utils __init__ walker * more misc fixmes * tighten up action dispatch, add unqualified action test * rename Collection mixin to CollectionSearch * (attempt to) avoid potential confusion/conflict with builtin collections, etc * stale fixmes * tighten up pluginloader collections determination * sanity test fixes * ditch regex escape * clarify comment * update default collections paths config entry * use PATH format instead of list * skip integration tests on Python 2.6 ci_complete
This commit is contained in:
parent
5173548a9f
commit
f86345f777
56 changed files with 1512 additions and 109 deletions
4
changelogs/fragments/collections.yml
Normal file
4
changelogs/fragments/collections.yml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
major_changes:
|
||||||
|
- Experimental support for Ansible Collections and content namespacing - Ansible content can now be packaged in a
|
||||||
|
collection and addressed via namespaces. This allows for easier sharing, distribution, and installation of bundled
|
||||||
|
modules/roles/plugins, and consistent rules for accessing specific content via namespaces.
|
|
@ -16,8 +16,10 @@ from ansible.executor.playbook_executor import PlaybookExecutor
|
||||||
from ansible.module_utils._text import to_bytes
|
from ansible.module_utils._text import to_bytes
|
||||||
from ansible.playbook.block import Block
|
from ansible.playbook.block import Block
|
||||||
from ansible.utils.display import Display
|
from ansible.utils.display import Display
|
||||||
|
from ansible.utils.collection_loader import set_collection_playbook_paths
|
||||||
from ansible.plugins.loader import add_all_plugin_dirs
|
from ansible.plugins.loader import add_all_plugin_dirs
|
||||||
|
|
||||||
|
|
||||||
display = Display()
|
display = Display()
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,19 +78,21 @@ class PlaybookCLI(CLI):
|
||||||
|
|
||||||
# initial error check, to make sure all specified playbooks are accessible
|
# initial error check, to make sure all specified playbooks are accessible
|
||||||
# before we start running anything through the playbook executor
|
# before we start running anything through the playbook executor
|
||||||
|
|
||||||
|
b_playbook_dirs = []
|
||||||
for playbook in context.CLIARGS['args']:
|
for playbook in context.CLIARGS['args']:
|
||||||
if not os.path.exists(playbook):
|
if not os.path.exists(playbook):
|
||||||
raise AnsibleError("the playbook: %s could not be found" % playbook)
|
raise AnsibleError("the playbook: %s could not be found" % playbook)
|
||||||
if not (os.path.isfile(playbook) or stat.S_ISFIFO(os.stat(playbook).st_mode)):
|
if not (os.path.isfile(playbook) or stat.S_ISFIFO(os.stat(playbook).st_mode)):
|
||||||
raise AnsibleError("the playbook: %s does not appear to be a file" % playbook)
|
raise AnsibleError("the playbook: %s does not appear to be a file" % playbook)
|
||||||
|
|
||||||
|
b_playbook_dir = os.path.dirname(os.path.abspath(to_bytes(playbook, errors='surrogate_or_strict')))
|
||||||
# load plugins from all playbooks in case they add callbacks/inventory/etc
|
# load plugins from all playbooks in case they add callbacks/inventory/etc
|
||||||
add_all_plugin_dirs(
|
add_all_plugin_dirs(b_playbook_dir)
|
||||||
os.path.dirname(
|
|
||||||
os.path.abspath(
|
b_playbook_dirs.append(b_playbook_dir)
|
||||||
to_bytes(playbook, errors='surrogate_or_strict')
|
|
||||||
)
|
set_collection_playbook_paths(b_playbook_dirs)
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# don't deal with privilege escalation or passwords when we don't need to
|
# don't deal with privilege escalation or passwords when we don't need to
|
||||||
if not (context.CLIARGS['listhosts'] or context.CLIARGS['listtasks'] or
|
if not (context.CLIARGS['listhosts'] or context.CLIARGS['listtasks'] or
|
||||||
|
|
|
@ -215,6 +215,14 @@ CACHE_PLUGIN_TIMEOUT:
|
||||||
- {key: fact_caching_timeout, section: defaults}
|
- {key: fact_caching_timeout, section: defaults}
|
||||||
type: integer
|
type: integer
|
||||||
yaml: {key: facts.cache.timeout}
|
yaml: {key: facts.cache.timeout}
|
||||||
|
COLLECTIONS_PATHS:
|
||||||
|
name: ordered list of root paths for loading installed Ansible collections content
|
||||||
|
default: ~/.ansible/collections:/usr/share/ansible/collections
|
||||||
|
type: pathspec
|
||||||
|
env:
|
||||||
|
- {name: ANSIBLE_COLLECTIONS_PATHS}
|
||||||
|
ini:
|
||||||
|
- {key: collections_paths, section: defaults}
|
||||||
COLOR_CHANGED:
|
COLOR_CHANGED:
|
||||||
name: Color for 'changed' task status
|
name: Color for 'changed' task status
|
||||||
default: yellow
|
default: yellow
|
||||||
|
|
|
@ -29,6 +29,7 @@ import os
|
||||||
import shlex
|
import shlex
|
||||||
import zipfile
|
import zipfile
|
||||||
import re
|
import re
|
||||||
|
import pkgutil
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from ansible.release import __version__, __author__
|
from ansible.release import __version__, __author__
|
||||||
|
@ -45,6 +46,18 @@ from ansible.executor import action_write_locks
|
||||||
|
|
||||||
from ansible.utils.display import Display
|
from ansible.utils.display import Display
|
||||||
|
|
||||||
|
# HACK: keep Python 2.6 controller tests happy in CI until they're properly split
|
||||||
|
try:
|
||||||
|
from importlib import import_module
|
||||||
|
except ImportError:
|
||||||
|
import_module = __import__
|
||||||
|
|
||||||
|
# if we're on a Python that doesn't have FNFError, redefine it as IOError (since that's what we'll see)
|
||||||
|
try:
|
||||||
|
FileNotFoundError
|
||||||
|
except NameError:
|
||||||
|
FileNotFoundError = IOError
|
||||||
|
|
||||||
display = Display()
|
display = Display()
|
||||||
|
|
||||||
REPLACER = b"#<<INCLUDE_ANSIBLE_MODULE_COMMON>>"
|
REPLACER = b"#<<INCLUDE_ANSIBLE_MODULE_COMMON>>"
|
||||||
|
@ -429,10 +442,14 @@ class ModuleDepFinder(ast.NodeVisitor):
|
||||||
|
|
||||||
def visit_Import(self, node):
|
def visit_Import(self, node):
|
||||||
# import ansible.module_utils.MODLIB[.MODLIBn] [as asname]
|
# import ansible.module_utils.MODLIB[.MODLIBn] [as asname]
|
||||||
for alias in (a for a in node.names if a.name.startswith('ansible.module_utils.')):
|
for alias in node.names:
|
||||||
py_mod = alias.name[self.IMPORT_PREFIX_SIZE:]
|
if alias.name.startswith('ansible.module_utils.'):
|
||||||
py_mod = tuple(py_mod.split('.'))
|
py_mod = alias.name[self.IMPORT_PREFIX_SIZE:]
|
||||||
self.submodules.add(py_mod)
|
py_mod = tuple(py_mod.split('.'))
|
||||||
|
self.submodules.add(py_mod)
|
||||||
|
elif alias.name.startswith('ansible_collections.'):
|
||||||
|
# keep 'ansible_collections.' as a sentinel prefix to trigger collection-loaded MU path
|
||||||
|
self.submodules.add(tuple(alias.name.split('.')))
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
def visit_ImportFrom(self, node):
|
def visit_ImportFrom(self, node):
|
||||||
|
@ -453,6 +470,10 @@ class ModuleDepFinder(ast.NodeVisitor):
|
||||||
# from ansible.module_utils import MODLIB [,MODLIB2] [as asname]
|
# from ansible.module_utils import MODLIB [,MODLIB2] [as asname]
|
||||||
for alias in node.names:
|
for alias in node.names:
|
||||||
self.submodules.add((alias.name,))
|
self.submodules.add((alias.name,))
|
||||||
|
|
||||||
|
elif node.module.startswith('ansible_collections.'):
|
||||||
|
# TODO: finish out the subpackage et al cases
|
||||||
|
self.submodules.add(tuple(node.module.split('.')))
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
|
||||||
|
@ -555,6 +576,20 @@ def recursive_finder(name, data, py_module_names, py_module_cache, zf):
|
||||||
module_info = imp.find_module('_six', [os.path.join(p, 'six') for p in module_utils_paths])
|
module_info = imp.find_module('_six', [os.path.join(p, 'six') for p in module_utils_paths])
|
||||||
py_module_name = ('six', '_six')
|
py_module_name = ('six', '_six')
|
||||||
idx = 0
|
idx = 0
|
||||||
|
elif py_module_name[0] == 'ansible_collections':
|
||||||
|
# FIXME: replicate module name resolution like below for granular imports
|
||||||
|
# this is a collection-hosted MU; look it up with get_data
|
||||||
|
package_name = '.'.join(py_module_name[:-1])
|
||||||
|
resource_name = py_module_name[-1] + '.py'
|
||||||
|
try:
|
||||||
|
# FIXME: need this in py2 for some reason TBD, but we shouldn't (get_data delegates to wrong loader without it)
|
||||||
|
pkg = import_module(package_name)
|
||||||
|
module_info = pkgutil.get_data(package_name, resource_name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# FIXME: implement package fallback code
|
||||||
|
raise AnsibleError('unable to load collection-hosted module_util {0}.{1}'.format(to_native(package_name),
|
||||||
|
to_native(resource_name)))
|
||||||
|
idx = 0
|
||||||
else:
|
else:
|
||||||
# Check whether either the last or the second to last identifier is
|
# Check whether either the last or the second to last identifier is
|
||||||
# a module name
|
# a module name
|
||||||
|
@ -577,56 +612,78 @@ def recursive_finder(name, data, py_module_names, py_module_cache, zf):
|
||||||
msg.append(py_module_name[-1])
|
msg.append(py_module_name[-1])
|
||||||
raise AnsibleError(' '.join(msg))
|
raise AnsibleError(' '.join(msg))
|
||||||
|
|
||||||
# Found a byte compiled file rather than source. We cannot send byte
|
if isinstance(module_info, bytes): # collection-hosted, just the code
|
||||||
# compiled over the wire as the python version might be different.
|
# HACK: maybe surface collection dirs in here and use existing find_module code?
|
||||||
# imp.find_module seems to prefer to return source packages so we just
|
normalized_name = py_module_name
|
||||||
# error out if imp.find_module returns byte compiled files (This is
|
normalized_data = module_info
|
||||||
# fragile as it depends on undocumented imp.find_module behaviour)
|
normalized_path = os.path.join(*py_module_name)
|
||||||
if module_info[2][2] not in (imp.PY_SOURCE, imp.PKG_DIRECTORY):
|
py_module_cache[normalized_name] = (normalized_data, normalized_path)
|
||||||
msg = ['Could not find python source for imported module support code for %s. Looked for' % name]
|
normalized_modules.add(normalized_name)
|
||||||
|
|
||||||
|
# HACK: walk back up the package hierarchy to pick up package inits; this won't do the right thing
|
||||||
|
# for actual packages yet...
|
||||||
|
accumulated_pkg_name = []
|
||||||
|
for pkg in py_module_name[:-1]:
|
||||||
|
accumulated_pkg_name.append(pkg) # we're accumulating this across iterations
|
||||||
|
normalized_name = tuple(accumulated_pkg_name[:] + ['__init__']) # extra machinations to get a hashable type (list is not)
|
||||||
|
if normalized_name not in py_module_cache:
|
||||||
|
normalized_path = os.path.join(*accumulated_pkg_name)
|
||||||
|
# HACK: possibly preserve some of the actual package file contents; problematic for extend_paths and others though?
|
||||||
|
normalized_data = ''
|
||||||
|
py_module_cache[normalized_name] = (normalized_data, normalized_path)
|
||||||
|
normalized_modules.add(normalized_name)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Found a byte compiled file rather than source. We cannot send byte
|
||||||
|
# compiled over the wire as the python version might be different.
|
||||||
|
# imp.find_module seems to prefer to return source packages so we just
|
||||||
|
# error out if imp.find_module returns byte compiled files (This is
|
||||||
|
# fragile as it depends on undocumented imp.find_module behaviour)
|
||||||
|
if module_info[2][2] not in (imp.PY_SOURCE, imp.PKG_DIRECTORY):
|
||||||
|
msg = ['Could not find python source for imported module support code for %s. Looked for' % name]
|
||||||
|
if idx == 2:
|
||||||
|
msg.append('either %s.py or %s.py' % (py_module_name[-1], py_module_name[-2]))
|
||||||
|
else:
|
||||||
|
msg.append(py_module_name[-1])
|
||||||
|
raise AnsibleError(' '.join(msg))
|
||||||
|
|
||||||
if idx == 2:
|
if idx == 2:
|
||||||
msg.append('either %s.py or %s.py' % (py_module_name[-1], py_module_name[-2]))
|
# We've determined that the last portion was an identifier and
|
||||||
else:
|
# thus, not part of the module name
|
||||||
msg.append(py_module_name[-1])
|
py_module_name = py_module_name[:-1]
|
||||||
raise AnsibleError(' '.join(msg))
|
|
||||||
|
|
||||||
if idx == 2:
|
# If not already processed then we've got work to do
|
||||||
# We've determined that the last portion was an identifier and
|
# If not in the cache, then read the file into the cache
|
||||||
# thus, not part of the module name
|
# We already have a file handle for the module open so it makes
|
||||||
py_module_name = py_module_name[:-1]
|
# sense to read it now
|
||||||
|
if py_module_name not in py_module_cache:
|
||||||
|
if module_info[2][2] == imp.PKG_DIRECTORY:
|
||||||
|
# Read the __init__.py instead of the module file as this is
|
||||||
|
# a python package
|
||||||
|
normalized_name = py_module_name + ('__init__',)
|
||||||
|
if normalized_name not in py_module_names:
|
||||||
|
normalized_path = os.path.join(module_info[1], '__init__.py')
|
||||||
|
normalized_data = _slurp(normalized_path)
|
||||||
|
py_module_cache[normalized_name] = (normalized_data, normalized_path)
|
||||||
|
normalized_modules.add(normalized_name)
|
||||||
|
else:
|
||||||
|
normalized_name = py_module_name
|
||||||
|
if normalized_name not in py_module_names:
|
||||||
|
normalized_path = module_info[1]
|
||||||
|
normalized_data = module_info[0].read()
|
||||||
|
module_info[0].close()
|
||||||
|
py_module_cache[normalized_name] = (normalized_data, normalized_path)
|
||||||
|
normalized_modules.add(normalized_name)
|
||||||
|
|
||||||
# If not already processed then we've got work to do
|
# Make sure that all the packages that this module is a part of
|
||||||
# If not in the cache, then read the file into the cache
|
# are also added
|
||||||
# We already have a file handle for the module open so it makes
|
for i in range(1, len(py_module_name)):
|
||||||
# sense to read it now
|
py_pkg_name = py_module_name[:-i] + ('__init__',)
|
||||||
if py_module_name not in py_module_cache:
|
if py_pkg_name not in py_module_names:
|
||||||
if module_info[2][2] == imp.PKG_DIRECTORY:
|
pkg_dir_info = imp.find_module(py_pkg_name[-1],
|
||||||
# Read the __init__.py instead of the module file as this is
|
[os.path.join(p, *py_pkg_name[:-1]) for p in module_utils_paths])
|
||||||
# a python package
|
normalized_modules.add(py_pkg_name)
|
||||||
normalized_name = py_module_name + ('__init__',)
|
py_module_cache[py_pkg_name] = (_slurp(pkg_dir_info[1]), pkg_dir_info[1])
|
||||||
if normalized_name not in py_module_names:
|
|
||||||
normalized_path = os.path.join(os.path.join(module_info[1], '__init__.py'))
|
|
||||||
normalized_data = _slurp(normalized_path)
|
|
||||||
py_module_cache[normalized_name] = (normalized_data, normalized_path)
|
|
||||||
normalized_modules.add(normalized_name)
|
|
||||||
else:
|
|
||||||
normalized_name = py_module_name
|
|
||||||
if normalized_name not in py_module_names:
|
|
||||||
normalized_path = module_info[1]
|
|
||||||
normalized_data = module_info[0].read()
|
|
||||||
module_info[0].close()
|
|
||||||
py_module_cache[normalized_name] = (normalized_data, normalized_path)
|
|
||||||
normalized_modules.add(normalized_name)
|
|
||||||
|
|
||||||
# Make sure that all the packages that this module is a part of
|
|
||||||
# are also added
|
|
||||||
for i in range(1, len(py_module_name)):
|
|
||||||
py_pkg_name = py_module_name[:-i] + ('__init__',)
|
|
||||||
if py_pkg_name not in py_module_names:
|
|
||||||
pkg_dir_info = imp.find_module(py_pkg_name[-1],
|
|
||||||
[os.path.join(p, *py_pkg_name[:-1]) for p in module_utils_paths])
|
|
||||||
normalized_modules.add(py_pkg_name)
|
|
||||||
py_module_cache[py_pkg_name] = (_slurp(pkg_dir_info[1]), pkg_dir_info[1])
|
|
||||||
|
|
||||||
# FIXME: Currently the AnsiBallZ wrapper monkeypatches module args into a global
|
# FIXME: Currently the AnsiBallZ wrapper monkeypatches module args into a global
|
||||||
# variable in basic.py. If a module doesn't import basic.py, then the AnsiBallZ wrapper will
|
# variable in basic.py. If a module doesn't import basic.py, then the AnsiBallZ wrapper will
|
||||||
|
@ -653,10 +710,16 @@ def recursive_finder(name, data, py_module_names, py_module_cache, zf):
|
||||||
unprocessed_py_module_names = normalized_modules.difference(py_module_names)
|
unprocessed_py_module_names = normalized_modules.difference(py_module_names)
|
||||||
|
|
||||||
for py_module_name in unprocessed_py_module_names:
|
for py_module_name in unprocessed_py_module_names:
|
||||||
|
# HACK: this seems to work as a way to identify a collections-based import, but a stronger identifier would be better
|
||||||
|
if not py_module_cache[py_module_name][1].startswith('/'):
|
||||||
|
dir_prefix = ''
|
||||||
|
else:
|
||||||
|
dir_prefix = 'ansible/module_utils'
|
||||||
|
|
||||||
py_module_path = os.path.join(*py_module_name)
|
py_module_path = os.path.join(*py_module_name)
|
||||||
py_module_file_name = '%s.py' % py_module_path
|
py_module_file_name = '%s.py' % py_module_path
|
||||||
|
|
||||||
zf.writestr(os.path.join("ansible/module_utils",
|
zf.writestr(os.path.join(dir_prefix,
|
||||||
py_module_file_name), py_module_cache[py_module_name][0])
|
py_module_file_name), py_module_cache[py_module_name][0])
|
||||||
display.vvvvv("Using module_utils file %s" % py_module_cache[py_module_name][1])
|
display.vvvvv("Using module_utils file %s" % py_module_cache[py_module_name][1])
|
||||||
|
|
||||||
|
|
|
@ -1013,13 +1013,18 @@ class TaskExecutor:
|
||||||
|
|
||||||
module_prefix = self._task.action.split('_')[0]
|
module_prefix = self._task.action.split('_')[0]
|
||||||
|
|
||||||
|
collections = self._task.collections
|
||||||
|
|
||||||
# let action plugin override module, fallback to 'normal' action plugin otherwise
|
# let action plugin override module, fallback to 'normal' action plugin otherwise
|
||||||
if self._task.action in self._shared_loader_obj.action_loader:
|
if self._shared_loader_obj.action_loader.has_plugin(self._task.action, collection_list=collections):
|
||||||
handler_name = self._task.action
|
handler_name = self._task.action
|
||||||
|
# FIXME: is this code path even live anymore? check w/ networking folks; it trips sometimes when it shouldn't
|
||||||
elif all((module_prefix in C.NETWORK_GROUP_MODULES, module_prefix in self._shared_loader_obj.action_loader)):
|
elif all((module_prefix in C.NETWORK_GROUP_MODULES, module_prefix in self._shared_loader_obj.action_loader)):
|
||||||
handler_name = module_prefix
|
handler_name = module_prefix
|
||||||
else:
|
else:
|
||||||
|
# FUTURE: once we're comfortable with collections impl, preface this action with ansible.builtin so it can't be hijacked
|
||||||
handler_name = 'normal'
|
handler_name = 'normal'
|
||||||
|
collections = None # until then, we don't want the task's collection list to be consulted; use the builtin
|
||||||
|
|
||||||
handler = self._shared_loader_obj.action_loader.get(
|
handler = self._shared_loader_obj.action_loader.get(
|
||||||
handler_name,
|
handler_name,
|
||||||
|
@ -1029,6 +1034,7 @@ class TaskExecutor:
|
||||||
loader=self._loader,
|
loader=self._loader,
|
||||||
templar=templar,
|
templar=templar,
|
||||||
shared_loader_obj=self._shared_loader_obj,
|
shared_loader_obj=self._shared_loader_obj,
|
||||||
|
collection_list=collections
|
||||||
)
|
)
|
||||||
|
|
||||||
if not handler:
|
if not handler:
|
||||||
|
|
|
@ -36,6 +36,7 @@ from ansible.playbook.play_context import PlayContext
|
||||||
from ansible.plugins.loader import callback_loader, strategy_loader, module_loader
|
from ansible.plugins.loader import callback_loader, strategy_loader, module_loader
|
||||||
from ansible.plugins.callback import CallbackBase
|
from ansible.plugins.callback import CallbackBase
|
||||||
from ansible.template import Templar
|
from ansible.template import Templar
|
||||||
|
from ansible.utils.collection_loader import is_collection_ref
|
||||||
from ansible.utils.helpers import pct_to_int
|
from ansible.utils.helpers import pct_to_int
|
||||||
from ansible.vars.hostvars import HostVars
|
from ansible.vars.hostvars import HostVars
|
||||||
from ansible.vars.reserved import warn_if_reserved
|
from ansible.vars.reserved import warn_if_reserved
|
||||||
|
@ -167,6 +168,10 @@ class TaskQueueManager:
|
||||||
" see the 2.4 porting guide for details." % callback_obj._load_name, version="2.9")
|
" see the 2.4 porting guide for details." % callback_obj._load_name, version="2.9")
|
||||||
self._callback_plugins.append(callback_obj)
|
self._callback_plugins.append(callback_obj)
|
||||||
|
|
||||||
|
for callback_plugin_name in (c for c in C.DEFAULT_CALLBACK_WHITELIST if is_collection_ref(c)):
|
||||||
|
callback_obj = callback_loader.get(callback_plugin_name)
|
||||||
|
self._callback_plugins.append(callback_obj)
|
||||||
|
|
||||||
self._callbacks_loaded = True
|
self._callbacks_loaded = True
|
||||||
|
|
||||||
def run(self, play):
|
def run(self, play):
|
||||||
|
|
|
@ -108,12 +108,13 @@ class ModuleArgsParser:
|
||||||
Args may also be munged for certain shell command parameters.
|
Args may also be munged for certain shell command parameters.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, task_ds=None):
|
def __init__(self, task_ds=None, collection_list=None):
|
||||||
task_ds = {} if task_ds is None else task_ds
|
task_ds = {} if task_ds is None else task_ds
|
||||||
|
|
||||||
if not isinstance(task_ds, dict):
|
if not isinstance(task_ds, dict):
|
||||||
raise AnsibleAssertionError("the type of 'task_ds' should be a dict, but is a %s" % type(task_ds))
|
raise AnsibleAssertionError("the type of 'task_ds' should be a dict, but is a %s" % type(task_ds))
|
||||||
self._task_ds = task_ds
|
self._task_ds = task_ds
|
||||||
|
self._collection_list = collection_list
|
||||||
|
|
||||||
def _split_module_string(self, module_string):
|
def _split_module_string(self, module_string):
|
||||||
'''
|
'''
|
||||||
|
@ -287,7 +288,8 @@ class ModuleArgsParser:
|
||||||
|
|
||||||
# walk the input dictionary to see we recognize a module name
|
# walk the input dictionary to see we recognize a module name
|
||||||
for (item, value) in iteritems(self._task_ds):
|
for (item, value) in iteritems(self._task_ds):
|
||||||
if item in BUILTIN_TASKS or item in action_loader or item in module_loader:
|
if item in BUILTIN_TASKS or action_loader.has_plugin(item, collection_list=self._collection_list) or \
|
||||||
|
module_loader.has_plugin(item, collection_list=self._collection_list):
|
||||||
# finding more than one module name is a problem
|
# finding more than one module name is a problem
|
||||||
if action is not None:
|
if action is not None:
|
||||||
raise AnsibleParserError("conflicting action statements: %s, %s" % (action, item), obj=self._task_ds)
|
raise AnsibleParserError("conflicting action statements: %s, %s" % (action, item), obj=self._task_ds)
|
||||||
|
|
|
@ -23,7 +23,7 @@ import os
|
||||||
|
|
||||||
from ansible import constants as C
|
from ansible import constants as C
|
||||||
from ansible.errors import AnsibleParserError
|
from ansible.errors import AnsibleParserError
|
||||||
from ansible.module_utils._text import to_bytes, to_text, to_native
|
from ansible.module_utils._text import to_text, to_native
|
||||||
from ansible.playbook.play import Play
|
from ansible.playbook.play import Play
|
||||||
from ansible.playbook.playbook_include import PlaybookInclude
|
from ansible.playbook.playbook_include import PlaybookInclude
|
||||||
from ansible.utils.display import Display
|
from ansible.utils.display import Display
|
||||||
|
|
|
@ -24,13 +24,14 @@ from ansible.playbook.attribute import FieldAttribute
|
||||||
from ansible.playbook.base import Base
|
from ansible.playbook.base import Base
|
||||||
from ansible.playbook.become import Become
|
from ansible.playbook.become import Become
|
||||||
from ansible.playbook.conditional import Conditional
|
from ansible.playbook.conditional import Conditional
|
||||||
|
from ansible.playbook.collectionsearch import CollectionSearch
|
||||||
from ansible.playbook.helpers import load_list_of_tasks
|
from ansible.playbook.helpers import load_list_of_tasks
|
||||||
from ansible.playbook.role import Role
|
from ansible.playbook.role import Role
|
||||||
from ansible.playbook.taggable import Taggable
|
from ansible.playbook.taggable import Taggable
|
||||||
from ansible.utils.sentinel import Sentinel
|
from ansible.utils.sentinel import Sentinel
|
||||||
|
|
||||||
|
|
||||||
class Block(Base, Become, Conditional, Taggable):
|
class Block(Base, Become, Conditional, CollectionSearch, Taggable):
|
||||||
|
|
||||||
# main block fields containing the task lists
|
# main block fields containing the task lists
|
||||||
_block = FieldAttribute(isa='list', default=list, inherit=False)
|
_block = FieldAttribute(isa='list', default=list, inherit=False)
|
||||||
|
|
26
lib/ansible/playbook/collectionsearch.py
Normal file
26
lib/ansible/playbook/collectionsearch.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# Copyright: (c) 2019, 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
|
||||||
|
|
||||||
|
from ansible.module_utils.six import string_types
|
||||||
|
from ansible.playbook.attribute import FieldAttribute
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionSearch:
|
||||||
|
# this needs to be populated before we can resolve tasks/roles/etc
|
||||||
|
_collections = FieldAttribute(isa='list', listof=string_types, priority=100)
|
||||||
|
|
||||||
|
def _load_collections(self, attr, ds):
|
||||||
|
if not ds:
|
||||||
|
# if empty/None, just return whatever was there; legacy behavior will do the right thing
|
||||||
|
return ds
|
||||||
|
|
||||||
|
if not isinstance(ds, list):
|
||||||
|
ds = [ds]
|
||||||
|
|
||||||
|
if 'ansible.builtin' not in ds and 'ansible.legacy' not in ds:
|
||||||
|
ds.append('ansible.legacy')
|
||||||
|
|
||||||
|
return ds
|
|
@ -117,7 +117,10 @@ def load_list_of_tasks(ds, play, block=None, role=None, task_include=None, use_h
|
||||||
)
|
)
|
||||||
task_list.append(t)
|
task_list.append(t)
|
||||||
else:
|
else:
|
||||||
args_parser = ModuleArgsParser(task_ds)
|
collection_list = task_ds.get('collections')
|
||||||
|
if collection_list is None and block is not None and block.collections:
|
||||||
|
collection_list = block.collections
|
||||||
|
args_parser = ModuleArgsParser(task_ds, collection_list=collection_list)
|
||||||
try:
|
try:
|
||||||
(action, args, delegate_to) = args_parser.parse()
|
(action, args, delegate_to) = args_parser.parse()
|
||||||
except AnsibleParserError as e:
|
except AnsibleParserError as e:
|
||||||
|
@ -382,7 +385,8 @@ def load_list_of_roles(ds, play, current_role_path=None, variable_manager=None,
|
||||||
|
|
||||||
roles = []
|
roles = []
|
||||||
for role_def in ds:
|
for role_def in ds:
|
||||||
i = RoleInclude.load(role_def, play=play, current_role_path=current_role_path, variable_manager=variable_manager, loader=loader)
|
i = RoleInclude.load(role_def, play=play, current_role_path=current_role_path, variable_manager=variable_manager,
|
||||||
|
loader=loader, collection_list=play.collections)
|
||||||
roles.append(i)
|
roles.append(i)
|
||||||
|
|
||||||
return roles
|
return roles
|
||||||
|
|
|
@ -27,6 +27,7 @@ from ansible.playbook.attribute import FieldAttribute
|
||||||
from ansible.playbook.base import Base
|
from ansible.playbook.base import Base
|
||||||
from ansible.playbook.become import Become
|
from ansible.playbook.become import Become
|
||||||
from ansible.playbook.block import Block
|
from ansible.playbook.block import Block
|
||||||
|
from ansible.playbook.collectionsearch import CollectionSearch
|
||||||
from ansible.playbook.helpers import load_list_of_blocks, load_list_of_roles
|
from ansible.playbook.helpers import load_list_of_blocks, load_list_of_roles
|
||||||
from ansible.playbook.role import Role
|
from ansible.playbook.role import Role
|
||||||
from ansible.playbook.taggable import Taggable
|
from ansible.playbook.taggable import Taggable
|
||||||
|
@ -39,7 +40,7 @@ display = Display()
|
||||||
__all__ = ['Play']
|
__all__ = ['Play']
|
||||||
|
|
||||||
|
|
||||||
class Play(Base, Taggable, Become):
|
class Play(Base, Taggable, Become, CollectionSearch):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
A play is a language feature that represents a list of roles and/or
|
A play is a language feature that represents a list of roles and/or
|
||||||
|
|
|
@ -27,6 +27,7 @@ from ansible.module_utils.common._collections_compat import Container, Mapping,
|
||||||
from ansible.playbook.attribute import FieldAttribute
|
from ansible.playbook.attribute import FieldAttribute
|
||||||
from ansible.playbook.base import Base
|
from ansible.playbook.base import Base
|
||||||
from ansible.playbook.become import Become
|
from ansible.playbook.become import Become
|
||||||
|
from ansible.playbook.collectionsearch import CollectionSearch
|
||||||
from ansible.playbook.conditional import Conditional
|
from ansible.playbook.conditional import Conditional
|
||||||
from ansible.playbook.helpers import load_list_of_blocks
|
from ansible.playbook.helpers import load_list_of_blocks
|
||||||
from ansible.playbook.role.metadata import RoleMetadata
|
from ansible.playbook.role.metadata import RoleMetadata
|
||||||
|
@ -91,7 +92,7 @@ def hash_params(params):
|
||||||
return frozenset((params,))
|
return frozenset((params,))
|
||||||
|
|
||||||
|
|
||||||
class Role(Base, Become, Conditional, Taggable):
|
class Role(Base, Become, Conditional, Taggable, CollectionSearch):
|
||||||
|
|
||||||
_delegate_to = FieldAttribute(isa='string')
|
_delegate_to = FieldAttribute(isa='string')
|
||||||
_delegate_facts = FieldAttribute(isa='bool')
|
_delegate_facts = FieldAttribute(isa='bool')
|
||||||
|
@ -99,6 +100,7 @@ class Role(Base, Become, Conditional, Taggable):
|
||||||
def __init__(self, play=None, from_files=None, from_include=False):
|
def __init__(self, play=None, from_files=None, from_include=False):
|
||||||
self._role_name = None
|
self._role_name = None
|
||||||
self._role_path = None
|
self._role_path = None
|
||||||
|
self._role_collection = None
|
||||||
self._role_params = dict()
|
self._role_params = dict()
|
||||||
self._loader = None
|
self._loader = None
|
||||||
|
|
||||||
|
@ -166,6 +168,7 @@ class Role(Base, Become, Conditional, Taggable):
|
||||||
if role_include.role not in play.ROLE_CACHE:
|
if role_include.role not in play.ROLE_CACHE:
|
||||||
play.ROLE_CACHE[role_include.role] = dict()
|
play.ROLE_CACHE[role_include.role] = dict()
|
||||||
|
|
||||||
|
# FIXME: how to handle cache keys for collection-based roles, since they're technically adjustable per task?
|
||||||
play.ROLE_CACHE[role_include.role][hashed_params] = r
|
play.ROLE_CACHE[role_include.role][hashed_params] = r
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
@ -176,6 +179,7 @@ class Role(Base, Become, Conditional, Taggable):
|
||||||
def _load_role_data(self, role_include, parent_role=None):
|
def _load_role_data(self, role_include, parent_role=None):
|
||||||
self._role_name = role_include.role
|
self._role_name = role_include.role
|
||||||
self._role_path = role_include.get_role_path()
|
self._role_path = role_include.get_role_path()
|
||||||
|
self._role_collection = role_include._role_collection
|
||||||
self._role_params = role_include.get_role_params()
|
self._role_params = role_include.get_role_params()
|
||||||
self._variable_manager = role_include.get_variable_manager()
|
self._variable_manager = role_include.get_variable_manager()
|
||||||
self._loader = role_include.get_loader()
|
self._loader = role_include.get_loader()
|
||||||
|
@ -194,9 +198,6 @@ class Role(Base, Become, Conditional, Taggable):
|
||||||
else:
|
else:
|
||||||
self._attributes[attr_name] = role_include._attributes[attr_name]
|
self._attributes[attr_name] = role_include._attributes[attr_name]
|
||||||
|
|
||||||
# ensure all plugins dirs for this role are added to plugin search path
|
|
||||||
add_all_plugin_dirs(self._role_path)
|
|
||||||
|
|
||||||
# vars and default vars are regular dictionaries
|
# vars and default vars are regular dictionaries
|
||||||
self._role_vars = self._load_role_yaml('vars', main=self._from_files.get('vars'), allow_dir=True)
|
self._role_vars = self._load_role_yaml('vars', main=self._from_files.get('vars'), allow_dir=True)
|
||||||
if self._role_vars is None:
|
if self._role_vars is None:
|
||||||
|
@ -218,6 +219,29 @@ class Role(Base, Become, Conditional, Taggable):
|
||||||
else:
|
else:
|
||||||
self._metadata = RoleMetadata()
|
self._metadata = RoleMetadata()
|
||||||
|
|
||||||
|
# reset collections list; roles do not inherit collections from parents, just use the defaults
|
||||||
|
# FUTURE: use a private config default for this so we can allow it to be overridden later
|
||||||
|
self.collections = []
|
||||||
|
|
||||||
|
# configure plugin/collection loading; either prepend the current role's collection or configure legacy plugin loading
|
||||||
|
# FIXME: need exception for explicit ansible.legacy?
|
||||||
|
if self._role_collection:
|
||||||
|
self.collections.insert(0, self._role_collection)
|
||||||
|
else:
|
||||||
|
# legacy role, ensure all plugin dirs under the role are added to plugin search path
|
||||||
|
add_all_plugin_dirs(self._role_path)
|
||||||
|
|
||||||
|
# collections can be specified in metadata for legacy or collection-hosted roles
|
||||||
|
if self._metadata.collections:
|
||||||
|
self.collections.extend(self._metadata.collections)
|
||||||
|
|
||||||
|
# if any collections were specified, ensure that core or legacy synthetic collections are always included
|
||||||
|
if self.collections:
|
||||||
|
# default append collection is core for collection-hosted roles, legacy for others
|
||||||
|
default_append_collection = 'ansible.builtin' if self.collections else 'ansible.legacy'
|
||||||
|
if 'ansible.builtin' not in self.collections and 'ansible.legacy' not in self.collections:
|
||||||
|
self.collections.append(default_append_collection)
|
||||||
|
|
||||||
task_data = self._load_role_yaml('tasks', main=self._from_files.get('tasks'))
|
task_data = self._load_role_yaml('tasks', main=self._from_files.get('tasks'))
|
||||||
if task_data:
|
if task_data:
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -28,9 +28,11 @@ from ansible.parsing.yaml.objects import AnsibleBaseYAMLObject, AnsibleMapping
|
||||||
from ansible.playbook.attribute import Attribute, FieldAttribute
|
from ansible.playbook.attribute import Attribute, FieldAttribute
|
||||||
from ansible.playbook.base import Base
|
from ansible.playbook.base import Base
|
||||||
from ansible.playbook.become import Become
|
from ansible.playbook.become import Become
|
||||||
|
from ansible.playbook.collectionsearch import CollectionSearch
|
||||||
from ansible.playbook.conditional import Conditional
|
from ansible.playbook.conditional import Conditional
|
||||||
from ansible.playbook.taggable import Taggable
|
from ansible.playbook.taggable import Taggable
|
||||||
from ansible.template import Templar
|
from ansible.template import Templar
|
||||||
|
from ansible.utils.collection_loader import get_collection_role_path, is_collection_ref
|
||||||
from ansible.utils.path import unfrackpath
|
from ansible.utils.path import unfrackpath
|
||||||
from ansible.utils.display import Display
|
from ansible.utils.display import Display
|
||||||
|
|
||||||
|
@ -39,11 +41,11 @@ __all__ = ['RoleDefinition']
|
||||||
display = Display()
|
display = Display()
|
||||||
|
|
||||||
|
|
||||||
class RoleDefinition(Base, Become, Conditional, Taggable):
|
class RoleDefinition(Base, Become, Conditional, Taggable, CollectionSearch):
|
||||||
|
|
||||||
_role = FieldAttribute(isa='string')
|
_role = FieldAttribute(isa='string')
|
||||||
|
|
||||||
def __init__(self, play=None, role_basedir=None, variable_manager=None, loader=None):
|
def __init__(self, play=None, role_basedir=None, variable_manager=None, loader=None, collection_list=None):
|
||||||
|
|
||||||
super(RoleDefinition, self).__init__()
|
super(RoleDefinition, self).__init__()
|
||||||
|
|
||||||
|
@ -52,8 +54,10 @@ class RoleDefinition(Base, Become, Conditional, Taggable):
|
||||||
self._loader = loader
|
self._loader = loader
|
||||||
|
|
||||||
self._role_path = None
|
self._role_path = None
|
||||||
|
self._role_collection = None
|
||||||
self._role_basedir = role_basedir
|
self._role_basedir = role_basedir
|
||||||
self._role_params = dict()
|
self._role_params = dict()
|
||||||
|
self._collection_list = collection_list
|
||||||
|
|
||||||
# def __repr__(self):
|
# def __repr__(self):
|
||||||
# return 'ROLEDEF: ' + self._attributes.get('role', '<no name set>')
|
# return 'ROLEDEF: ' + self._attributes.get('role', '<no name set>')
|
||||||
|
@ -139,6 +143,31 @@ class RoleDefinition(Base, Become, Conditional, Taggable):
|
||||||
append it to the default role path
|
append it to the default role path
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
# create a templar class to template the dependency names, in
|
||||||
|
# case they contain variables
|
||||||
|
if self._variable_manager is not None:
|
||||||
|
all_vars = self._variable_manager.get_vars(play=self._play)
|
||||||
|
else:
|
||||||
|
all_vars = dict()
|
||||||
|
|
||||||
|
templar = Templar(loader=self._loader, variables=all_vars)
|
||||||
|
role_name = templar.template(role_name)
|
||||||
|
|
||||||
|
role_tuple = None
|
||||||
|
|
||||||
|
# try to load as a collection-based role first
|
||||||
|
if self._collection_list or is_collection_ref(role_name):
|
||||||
|
role_tuple = get_collection_role_path(role_name, self._collection_list)
|
||||||
|
|
||||||
|
if role_tuple:
|
||||||
|
# we found it, stash collection data and return the name/path tuple
|
||||||
|
self._role_collection = role_tuple[2]
|
||||||
|
return role_tuple[0:2]
|
||||||
|
|
||||||
|
# FUTURE: refactor this to be callable from internal so we can properly order ansible.legacy searches with the collections keyword
|
||||||
|
if self._collection_list and 'ansible.legacy' not in self._collection_list:
|
||||||
|
raise AnsibleError("the role '%s' was not found in %s" % (role_name, ":".join(self._collection_list)), obj=self._ds)
|
||||||
|
|
||||||
# we always start the search for roles in the base directory of the playbook
|
# we always start the search for roles in the base directory of the playbook
|
||||||
role_search_paths = [
|
role_search_paths = [
|
||||||
os.path.join(self._loader.get_basedir(), u'roles'),
|
os.path.join(self._loader.get_basedir(), u'roles'),
|
||||||
|
@ -158,16 +187,6 @@ class RoleDefinition(Base, Become, Conditional, Taggable):
|
||||||
# the roles/ dir appended
|
# the roles/ dir appended
|
||||||
role_search_paths.append(self._loader.get_basedir())
|
role_search_paths.append(self._loader.get_basedir())
|
||||||
|
|
||||||
# create a templar class to template the dependency names, in
|
|
||||||
# case they contain variables
|
|
||||||
if self._variable_manager is not None:
|
|
||||||
all_vars = self._variable_manager.get_vars(play=self._play)
|
|
||||||
else:
|
|
||||||
all_vars = dict()
|
|
||||||
|
|
||||||
templar = Templar(loader=self._loader, variables=all_vars)
|
|
||||||
role_name = templar.template(role_name)
|
|
||||||
|
|
||||||
# now iterate through the possible paths and return the first one we find
|
# now iterate through the possible paths and return the first one we find
|
||||||
for path in role_search_paths:
|
for path in role_search_paths:
|
||||||
path = templar.template(path)
|
path = templar.template(path)
|
||||||
|
|
|
@ -43,11 +43,12 @@ class RoleInclude(RoleDefinition):
|
||||||
_delegate_to = FieldAttribute(isa='string')
|
_delegate_to = FieldAttribute(isa='string')
|
||||||
_delegate_facts = FieldAttribute(isa='bool', default=False)
|
_delegate_facts = FieldAttribute(isa='bool', default=False)
|
||||||
|
|
||||||
def __init__(self, play=None, role_basedir=None, variable_manager=None, loader=None):
|
def __init__(self, play=None, role_basedir=None, variable_manager=None, loader=None, collection_list=None):
|
||||||
super(RoleInclude, self).__init__(play=play, role_basedir=role_basedir, variable_manager=variable_manager, loader=loader)
|
super(RoleInclude, self).__init__(play=play, role_basedir=role_basedir, variable_manager=variable_manager,
|
||||||
|
loader=loader, collection_list=collection_list)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load(data, play, current_role_path=None, parent_role=None, variable_manager=None, loader=None):
|
def load(data, play, current_role_path=None, parent_role=None, variable_manager=None, loader=None, collection_list=None):
|
||||||
|
|
||||||
if not (isinstance(data, string_types) or isinstance(data, dict) or isinstance(data, AnsibleBaseYAMLObject)):
|
if not (isinstance(data, string_types) or isinstance(data, dict) or isinstance(data, AnsibleBaseYAMLObject)):
|
||||||
raise AnsibleParserError("Invalid role definition: %s" % to_native(data))
|
raise AnsibleParserError("Invalid role definition: %s" % to_native(data))
|
||||||
|
@ -55,5 +56,5 @@ class RoleInclude(RoleDefinition):
|
||||||
if isinstance(data, string_types) and ',' in data:
|
if isinstance(data, string_types) and ',' in data:
|
||||||
raise AnsibleError("Invalid old style role requirement: %s" % data)
|
raise AnsibleError("Invalid old style role requirement: %s" % data)
|
||||||
|
|
||||||
ri = RoleInclude(play=play, role_basedir=current_role_path, variable_manager=variable_manager, loader=loader)
|
ri = RoleInclude(play=play, role_basedir=current_role_path, variable_manager=variable_manager, loader=loader, collection_list=collection_list)
|
||||||
return ri.load_data(data, variable_manager=variable_manager, loader=loader)
|
return ri.load_data(data, variable_manager=variable_manager, loader=loader)
|
||||||
|
|
|
@ -23,17 +23,17 @@ import os
|
||||||
|
|
||||||
from ansible.errors import AnsibleParserError, AnsibleError
|
from ansible.errors import AnsibleParserError, AnsibleError
|
||||||
from ansible.module_utils._text import to_native
|
from ansible.module_utils._text import to_native
|
||||||
from ansible.module_utils.six import iteritems, string_types
|
from ansible.module_utils.six import string_types
|
||||||
from ansible.playbook.attribute import Attribute, FieldAttribute
|
from ansible.playbook.attribute import FieldAttribute
|
||||||
from ansible.playbook.base import Base
|
from ansible.playbook.base import Base
|
||||||
|
from ansible.playbook.collectionsearch import CollectionSearch
|
||||||
from ansible.playbook.helpers import load_list_of_roles
|
from ansible.playbook.helpers import load_list_of_roles
|
||||||
from ansible.playbook.role.include import RoleInclude
|
|
||||||
from ansible.playbook.role.requirement import RoleRequirement
|
from ansible.playbook.role.requirement import RoleRequirement
|
||||||
|
|
||||||
__all__ = ['RoleMetadata']
|
__all__ = ['RoleMetadata']
|
||||||
|
|
||||||
|
|
||||||
class RoleMetadata(Base):
|
class RoleMetadata(Base, CollectionSearch):
|
||||||
'''
|
'''
|
||||||
This class wraps the parsing and validation of the optional metadata
|
This class wraps the parsing and validation of the optional metadata
|
||||||
within each Role (meta/main.yml).
|
within each Role (meta/main.yml).
|
||||||
|
@ -105,7 +105,7 @@ class RoleMetadata(Base):
|
||||||
def serialize(self):
|
def serialize(self):
|
||||||
return dict(
|
return dict(
|
||||||
allow_duplicates=self._allow_duplicates,
|
allow_duplicates=self._allow_duplicates,
|
||||||
dependencies=self._dependencies,
|
dependencies=self._dependencies
|
||||||
)
|
)
|
||||||
|
|
||||||
def deserialize(self, data):
|
def deserialize(self, data):
|
||||||
|
|
|
@ -73,7 +73,7 @@ class IncludeRole(TaskInclude):
|
||||||
else:
|
else:
|
||||||
myplay = play
|
myplay = play
|
||||||
|
|
||||||
ri = RoleInclude.load(self._role_name, play=myplay, variable_manager=variable_manager, loader=loader)
|
ri = RoleInclude.load(self._role_name, play=myplay, variable_manager=variable_manager, loader=loader, collection_list=self.collections)
|
||||||
ri.vars.update(self.vars)
|
ri.vars.update(self.vars)
|
||||||
|
|
||||||
# build role
|
# build role
|
||||||
|
@ -97,9 +97,14 @@ class IncludeRole(TaskInclude):
|
||||||
|
|
||||||
p_block = self.build_parent_block()
|
p_block = self.build_parent_block()
|
||||||
|
|
||||||
|
# collections value is not inherited; override with the value we calculated during role setup
|
||||||
|
p_block.collections = actual_role.collections
|
||||||
|
|
||||||
blocks = actual_role.compile(play=myplay, dep_chain=dep_chain)
|
blocks = actual_role.compile(play=myplay, dep_chain=dep_chain)
|
||||||
for b in blocks:
|
for b in blocks:
|
||||||
b._parent = p_block
|
b._parent = p_block
|
||||||
|
# HACK: parent inheritance doesn't seem to have a way to handle this intermediate override until squashed/finalized
|
||||||
|
b.collections = actual_role.collections
|
||||||
|
|
||||||
# updated available handlers in play
|
# updated available handlers in play
|
||||||
handlers = actual_role.get_handler_blocks(play=myplay)
|
handlers = actual_role.get_handler_blocks(play=myplay)
|
||||||
|
|
|
@ -32,6 +32,7 @@ from ansible.playbook.attribute import FieldAttribute
|
||||||
from ansible.playbook.base import Base
|
from ansible.playbook.base import Base
|
||||||
from ansible.playbook.become import Become
|
from ansible.playbook.become import Become
|
||||||
from ansible.playbook.block import Block
|
from ansible.playbook.block import Block
|
||||||
|
from ansible.playbook.collectionsearch import CollectionSearch
|
||||||
from ansible.playbook.conditional import Conditional
|
from ansible.playbook.conditional import Conditional
|
||||||
from ansible.playbook.loop_control import LoopControl
|
from ansible.playbook.loop_control import LoopControl
|
||||||
from ansible.playbook.role import Role
|
from ansible.playbook.role import Role
|
||||||
|
@ -44,7 +45,7 @@ __all__ = ['Task']
|
||||||
display = Display()
|
display = Display()
|
||||||
|
|
||||||
|
|
||||||
class Task(Base, Conditional, Taggable, Become):
|
class Task(Base, Conditional, Taggable, Become, CollectionSearch):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
A task is a language feature that represents a call to a module, with given arguments and other parameters.
|
A task is a language feature that represents a call to a module, with given arguments and other parameters.
|
||||||
|
@ -180,7 +181,7 @@ class Task(Base, Conditional, Taggable, Become):
|
||||||
# use the args parsing class to determine the action, args,
|
# use the args parsing class to determine the action, args,
|
||||||
# and the delegate_to value from the various possible forms
|
# and the delegate_to value from the various possible forms
|
||||||
# supported as legacy
|
# supported as legacy
|
||||||
args_parser = ModuleArgsParser(task_ds=ds)
|
args_parser = ModuleArgsParser(task_ds=ds, collection_list=self.collections)
|
||||||
try:
|
try:
|
||||||
(action, args, delegate_to) = args_parser.parse()
|
(action, args, delegate_to) = args_parser.parse()
|
||||||
except AnsibleParserError as e:
|
except AnsibleParserError as e:
|
||||||
|
|
|
@ -165,7 +165,7 @@ class ActionBase(with_metaclass(ABCMeta, object)):
|
||||||
if key in module_args:
|
if key in module_args:
|
||||||
module_args[key] = self._connection._shell._unquote(module_args[key])
|
module_args[key] = self._connection._shell._unquote(module_args[key])
|
||||||
|
|
||||||
module_path = self._shared_loader_obj.module_loader.find_plugin(module_name, mod_type)
|
module_path = self._shared_loader_obj.module_loader.find_plugin(module_name, mod_type, collection_list=self._task.collections)
|
||||||
if module_path:
|
if module_path:
|
||||||
break
|
break
|
||||||
else: # This is a for-else: http://bit.ly/1ElPkyg
|
else: # This is a for-else: http://bit.ly/1ElPkyg
|
||||||
|
|
|
@ -11,6 +11,7 @@ import glob
|
||||||
import imp
|
import imp
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
|
import pkgutil
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
@ -23,9 +24,15 @@ from ansible.module_utils.six import string_types
|
||||||
from ansible.parsing.utils.yaml import from_yaml
|
from ansible.parsing.utils.yaml import from_yaml
|
||||||
from ansible.parsing.yaml.loader import AnsibleLoader
|
from ansible.parsing.yaml.loader import AnsibleLoader
|
||||||
from ansible.plugins import get_plugin_class, MODULE_CACHE, PATH_CACHE, PLUGIN_PATH_CACHE
|
from ansible.plugins import get_plugin_class, MODULE_CACHE, PATH_CACHE, PLUGIN_PATH_CACHE
|
||||||
|
from ansible.utils.collection_loader import AnsibleCollectionLoader, AnsibleFlatMapLoader, is_collection_ref
|
||||||
from ansible.utils.display import Display
|
from ansible.utils.display import Display
|
||||||
from ansible.utils.plugin_docs import add_fragments
|
from ansible.utils.plugin_docs import add_fragments
|
||||||
|
|
||||||
|
# HACK: keep Python 2.6 controller tests happy in CI until they're properly split
|
||||||
|
try:
|
||||||
|
from importlib import import_module
|
||||||
|
except ImportError:
|
||||||
|
import_module = __import__
|
||||||
|
|
||||||
display = Display()
|
display = Display()
|
||||||
|
|
||||||
|
@ -298,7 +305,69 @@ class PluginLoader:
|
||||||
self._clear_caches()
|
self._clear_caches()
|
||||||
display.debug('Added %s to loader search path' % (directory))
|
display.debug('Added %s to loader search path' % (directory))
|
||||||
|
|
||||||
def _find_plugin(self, name, mod_type='', ignore_deprecated=False, check_aliases=False):
|
def _find_fq_plugin(self, fq_name, extension):
|
||||||
|
fq_name = to_native(fq_name)
|
||||||
|
# prefix our extension Python namespace if it isn't already there
|
||||||
|
if not fq_name.startswith('ansible_collections.'):
|
||||||
|
fq_name = 'ansible_collections.' + fq_name
|
||||||
|
|
||||||
|
splitname = fq_name.rsplit('.', 1)
|
||||||
|
if len(splitname) != 2:
|
||||||
|
raise ValueError('{0} is not a valid namespace-qualified plugin name'.format(to_native(fq_name)))
|
||||||
|
|
||||||
|
package = splitname[0]
|
||||||
|
resource = splitname[1]
|
||||||
|
|
||||||
|
append_plugin_type = self.class_name or self.subdir
|
||||||
|
|
||||||
|
if append_plugin_type:
|
||||||
|
# only current non-class special case, module_utils don't use this loader method
|
||||||
|
if append_plugin_type == 'library':
|
||||||
|
append_plugin_type = 'modules'
|
||||||
|
else:
|
||||||
|
append_plugin_type = get_plugin_class(append_plugin_type)
|
||||||
|
package += '.plugins.{0}'.format(append_plugin_type)
|
||||||
|
|
||||||
|
if extension:
|
||||||
|
resource += extension
|
||||||
|
|
||||||
|
pkg = sys.modules.get(package)
|
||||||
|
if not pkg:
|
||||||
|
# FIXME: there must be cheaper/safer way to do this
|
||||||
|
pkg = import_module(package)
|
||||||
|
|
||||||
|
# if the package is one of our flatmaps, we need to consult its loader to find the path, since the file could be
|
||||||
|
# anywhere in the tree
|
||||||
|
if hasattr(pkg, '__loader__') and isinstance(pkg.__loader__, AnsibleFlatMapLoader):
|
||||||
|
try:
|
||||||
|
file_path = pkg.__loader__.find_file(resource)
|
||||||
|
return to_text(file_path)
|
||||||
|
except IOError:
|
||||||
|
# this loader already takes care of extensionless files, so if we didn't find it, just bail
|
||||||
|
return None
|
||||||
|
|
||||||
|
pkg_path = os.path.dirname(pkg.__file__)
|
||||||
|
|
||||||
|
resource_path = os.path.join(pkg_path, resource)
|
||||||
|
|
||||||
|
# FIXME: and is file or file link or ...
|
||||||
|
if os.path.exists(resource_path):
|
||||||
|
return to_text(resource_path)
|
||||||
|
|
||||||
|
# look for any matching extension in the package location (sans filter)
|
||||||
|
ext_blacklist = ['.pyc', '.pyo']
|
||||||
|
found_files = [f for f in glob.iglob(os.path.join(pkg_path, resource) + '.*') if os.path.isfile(f) and os.path.splitext(f)[1] not in ext_blacklist]
|
||||||
|
|
||||||
|
if not found_files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(found_files) > 1:
|
||||||
|
# TODO: warn?
|
||||||
|
pass
|
||||||
|
|
||||||
|
return to_text(found_files[0])
|
||||||
|
|
||||||
|
def _find_plugin(self, name, mod_type='', ignore_deprecated=False, check_aliases=False, collection_list=None):
|
||||||
''' Find a plugin named name '''
|
''' Find a plugin named name '''
|
||||||
|
|
||||||
global _PLUGIN_FILTERS
|
global _PLUGIN_FILTERS
|
||||||
|
@ -315,6 +384,38 @@ class PluginLoader:
|
||||||
# they can have any suffix
|
# they can have any suffix
|
||||||
suffix = ''
|
suffix = ''
|
||||||
|
|
||||||
|
# HACK: need this right now so we can still load shipped PS module_utils
|
||||||
|
if (is_collection_ref(name) or collection_list) and not name.startswith('Ansible'):
|
||||||
|
if '.' in name or not collection_list:
|
||||||
|
candidates = [name]
|
||||||
|
else:
|
||||||
|
candidates = ['{0}.{1}'.format(c, name) for c in collection_list]
|
||||||
|
# TODO: keep actual errors, not just assembled messages
|
||||||
|
errors = []
|
||||||
|
for candidate_name in candidates:
|
||||||
|
try:
|
||||||
|
# HACK: refactor this properly
|
||||||
|
if candidate_name.startswith('ansible.legacy'):
|
||||||
|
# just pass the raw name to the old lookup function to check in all the usual locations
|
||||||
|
p = self._find_plugin_legacy(name.replace('ansible.legacy.', '', 1), ignore_deprecated, check_aliases, suffix)
|
||||||
|
else:
|
||||||
|
p = self._find_fq_plugin(candidate_name, suffix)
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
except Exception as ex:
|
||||||
|
errors.append(to_native(ex))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
display.debug(msg='plugin lookup for {0} failed; errors: {1}'.format(name, '; '.join(errors)))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# if we got here, there's no collection list and it's not an FQ name, so do legacy lookup
|
||||||
|
|
||||||
|
return self._find_plugin_legacy(name, ignore_deprecated, check_aliases, suffix)
|
||||||
|
|
||||||
|
def _find_plugin_legacy(self, name, ignore_deprecated=False, check_aliases=False, suffix=None):
|
||||||
|
|
||||||
if check_aliases:
|
if check_aliases:
|
||||||
name = self.aliases.get(name, name)
|
name = self.aliases.get(name, name)
|
||||||
|
|
||||||
|
@ -388,13 +489,13 @@ class PluginLoader:
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_plugin(self, name, mod_type='', ignore_deprecated=False, check_aliases=False):
|
def find_plugin(self, name, mod_type='', ignore_deprecated=False, check_aliases=False, collection_list=None):
|
||||||
''' Find a plugin named name '''
|
''' Find a plugin named name '''
|
||||||
|
|
||||||
# Import here to avoid circular import
|
# Import here to avoid circular import
|
||||||
from ansible.vars.reserved import is_reserved_name
|
from ansible.vars.reserved import is_reserved_name
|
||||||
|
|
||||||
plugin = self._find_plugin(name, mod_type=mod_type, ignore_deprecated=ignore_deprecated, check_aliases=check_aliases)
|
plugin = self._find_plugin(name, mod_type=mod_type, ignore_deprecated=ignore_deprecated, check_aliases=check_aliases, collection_list=collection_list)
|
||||||
if plugin and self.package == 'ansible.modules' and name not in ('gather_facts',) and is_reserved_name(name):
|
if plugin and self.package == 'ansible.modules' and name not in ('gather_facts',) and is_reserved_name(name):
|
||||||
raise AnsibleError(
|
raise AnsibleError(
|
||||||
'Module "%s" shadows the name of a reserved keyword. Please rename or remove this module. Found at %s' % (name, plugin)
|
'Module "%s" shadows the name of a reserved keyword. Please rename or remove this module. Found at %s' % (name, plugin)
|
||||||
|
@ -402,10 +503,16 @@ class PluginLoader:
|
||||||
|
|
||||||
return plugin
|
return plugin
|
||||||
|
|
||||||
def has_plugin(self, name):
|
def has_plugin(self, name, collection_list=None):
|
||||||
''' Checks if a plugin named name exists '''
|
''' Checks if a plugin named name exists '''
|
||||||
|
|
||||||
return self.find_plugin(name) is not None
|
try:
|
||||||
|
return self.find_plugin(name, collection_list=collection_list) is not None
|
||||||
|
except Exception as ex:
|
||||||
|
if isinstance(ex, AnsibleError):
|
||||||
|
raise
|
||||||
|
# log and continue, likely an innocuous type/package loading failure in collections import
|
||||||
|
display.debug('has_plugin error: {0}'.format(to_native(ex)))
|
||||||
|
|
||||||
__contains__ = has_plugin
|
__contains__ = has_plugin
|
||||||
|
|
||||||
|
@ -436,9 +543,10 @@ class PluginLoader:
|
||||||
|
|
||||||
found_in_cache = True
|
found_in_cache = True
|
||||||
class_only = kwargs.pop('class_only', False)
|
class_only = kwargs.pop('class_only', False)
|
||||||
|
collection_list = kwargs.pop('collection_list', None)
|
||||||
if name in self.aliases:
|
if name in self.aliases:
|
||||||
name = self.aliases[name]
|
name = self.aliases[name]
|
||||||
path = self.find_plugin(name)
|
path = self.find_plugin(name, collection_list=collection_list)
|
||||||
if path is None:
|
if path is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -600,14 +708,20 @@ class Jinja2Loader(PluginLoader):
|
||||||
The filter and test plugins are Jinja2 plugins encapsulated inside of our plugin format.
|
The filter and test plugins are Jinja2 plugins encapsulated inside of our plugin format.
|
||||||
The way the calling code is setup, we need to do a few things differently in the all() method
|
The way the calling code is setup, we need to do a few things differently in the all() method
|
||||||
"""
|
"""
|
||||||
def find_plugin(self, name):
|
def find_plugin(self, name, collection_list=None):
|
||||||
# Nothing using Jinja2Loader use this method. We can't use the base class version because
|
# Nothing using Jinja2Loader use this method. We can't use the base class version because
|
||||||
# we deduplicate differently than the base class
|
# we deduplicate differently than the base class
|
||||||
|
if '.' in name:
|
||||||
|
return super(Jinja2Loader, self).find_plugin(name, collection_list=collection_list)
|
||||||
|
|
||||||
raise AnsibleError('No code should call find_plugin for Jinja2Loaders (Not implemented)')
|
raise AnsibleError('No code should call find_plugin for Jinja2Loaders (Not implemented)')
|
||||||
|
|
||||||
def get(self, name, *args, **kwargs):
|
def get(self, name, *args, **kwargs):
|
||||||
# Nothing using Jinja2Loader use this method. We can't use the base class version because
|
# Nothing using Jinja2Loader use this method. We can't use the base class version because
|
||||||
# we deduplicate differently than the base class
|
# we deduplicate differently than the base class
|
||||||
|
if '.' in name:
|
||||||
|
return super(Jinja2Loader, self).get(name, *args, **kwargs)
|
||||||
|
|
||||||
raise AnsibleError('No code should call find_plugin for Jinja2Loaders (Not implemented)')
|
raise AnsibleError('No code should call find_plugin for Jinja2Loaders (Not implemented)')
|
||||||
|
|
||||||
def all(self, *args, **kwargs):
|
def all(self, *args, **kwargs):
|
||||||
|
@ -695,11 +809,18 @@ def _load_plugin_filter():
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_collection_loader():
|
||||||
|
if not any((isinstance(l, AnsibleCollectionLoader) for l in sys.meta_path)):
|
||||||
|
sys.meta_path.insert(0, AnsibleCollectionLoader())
|
||||||
|
|
||||||
|
|
||||||
# TODO: All of the following is initialization code It should be moved inside of an initialization
|
# TODO: All of the following is initialization code It should be moved inside of an initialization
|
||||||
# function which is called at some point early in the ansible and ansible-playbook CLI startup.
|
# function which is called at some point early in the ansible and ansible-playbook CLI startup.
|
||||||
|
|
||||||
_PLUGIN_FILTERS = _load_plugin_filter()
|
_PLUGIN_FILTERS = _load_plugin_filter()
|
||||||
|
|
||||||
|
_configure_collection_loader()
|
||||||
|
|
||||||
# doc fragments first
|
# doc fragments first
|
||||||
fragment_loader = PluginLoader(
|
fragment_loader = PluginLoader(
|
||||||
'ModuleDocFragment',
|
'ModuleDocFragment',
|
||||||
|
|
|
@ -20,15 +20,14 @@ from __future__ import (absolute_import, division, print_function)
|
||||||
__metaclass__ = type
|
__metaclass__ = type
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
import contextlib
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
import pkgutil
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from io import StringIO
|
|
||||||
from numbers import Number
|
from numbers import Number
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -42,9 +41,9 @@ from jinja2.runtime import Context, StrictUndefined
|
||||||
|
|
||||||
from ansible import constants as C
|
from ansible import constants as C
|
||||||
from ansible.errors import AnsibleError, AnsibleFilterError, AnsibleUndefinedVariable, AnsibleAssertionError
|
from ansible.errors import AnsibleError, AnsibleFilterError, AnsibleUndefinedVariable, AnsibleAssertionError
|
||||||
from ansible.module_utils.six import string_types, text_type
|
from ansible.module_utils.six import iteritems, string_types, text_type
|
||||||
from ansible.module_utils._text import to_native, to_text, to_bytes
|
from ansible.module_utils._text import to_native, to_text, to_bytes
|
||||||
from ansible.module_utils.common._collections_compat import Sequence, Mapping
|
from ansible.module_utils.common._collections_compat import Sequence, Mapping, MutableMapping
|
||||||
from ansible.plugins.loader import filter_loader, lookup_loader, test_loader
|
from ansible.plugins.loader import filter_loader, lookup_loader, test_loader
|
||||||
from ansible.template.safe_eval import safe_eval
|
from ansible.template.safe_eval import safe_eval
|
||||||
from ansible.template.template import AnsibleJ2Template
|
from ansible.template.template import AnsibleJ2Template
|
||||||
|
@ -52,6 +51,12 @@ from ansible.template.vars import AnsibleJ2Vars
|
||||||
from ansible.utils.display import Display
|
from ansible.utils.display import Display
|
||||||
from ansible.utils.unsafe_proxy import UnsafeProxy, wrap_var
|
from ansible.utils.unsafe_proxy import UnsafeProxy, wrap_var
|
||||||
|
|
||||||
|
# HACK: keep Python 2.6 controller tests happy in CI until they're properly split
|
||||||
|
try:
|
||||||
|
from importlib import import_module
|
||||||
|
except ImportError:
|
||||||
|
import_module = __import__
|
||||||
|
|
||||||
display = Display()
|
display = Display()
|
||||||
|
|
||||||
|
|
||||||
|
@ -255,6 +260,83 @@ class AnsibleContext(Context):
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
class JinjaPluginIntercept(MutableMapping):
|
||||||
|
def __init__(self, delegatee, pluginloader, *args, **kwargs):
|
||||||
|
super(JinjaPluginIntercept, self).__init__(*args, **kwargs)
|
||||||
|
self._delegatee = delegatee
|
||||||
|
self._pluginloader = pluginloader
|
||||||
|
|
||||||
|
if self._pluginloader.class_name == 'FilterModule':
|
||||||
|
self._method_map_name = 'filters'
|
||||||
|
self._dirname = 'filter'
|
||||||
|
elif self._pluginloader.class_name == 'TestModule':
|
||||||
|
self._method_map_name = 'tests'
|
||||||
|
self._dirname = 'test'
|
||||||
|
|
||||||
|
self._collection_jinja_func_cache = {}
|
||||||
|
|
||||||
|
# FUTURE: we can cache FQ filter/test calls for the entire duration of a run, since a given collection's impl's
|
||||||
|
# aren't supposed to change during a run
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if not isinstance(key, string_types):
|
||||||
|
raise ValueError('key must be a string')
|
||||||
|
|
||||||
|
key = to_native(key)
|
||||||
|
|
||||||
|
if '.' not in key: # might be a built-in value, delegate to base dict
|
||||||
|
return self._delegatee.__getitem__(key)
|
||||||
|
|
||||||
|
func = self._collection_jinja_func_cache.get(key)
|
||||||
|
|
||||||
|
if func:
|
||||||
|
return func
|
||||||
|
|
||||||
|
components = key.split('.')
|
||||||
|
|
||||||
|
if len(components) != 3:
|
||||||
|
raise KeyError('invalid plugin name: {0}'.format(key))
|
||||||
|
|
||||||
|
collection_name = '.'.join(components[0:2])
|
||||||
|
collection_pkg = 'ansible_collections.{0}.plugins.{1}'.format(collection_name, self._dirname)
|
||||||
|
|
||||||
|
# FIXME: error handling for bogus plugin name, bogus impl, bogus filter/test
|
||||||
|
|
||||||
|
# FIXME: move this capability into the Jinja plugin loader
|
||||||
|
pkg = import_module(collection_pkg)
|
||||||
|
|
||||||
|
for dummy, module_name, ispkg in pkgutil.iter_modules(pkg.__path__, prefix=collection_name + '.'):
|
||||||
|
if ispkg:
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_impl = self._pluginloader.get(module_name)
|
||||||
|
|
||||||
|
method_map = getattr(plugin_impl, self._method_map_name)
|
||||||
|
|
||||||
|
for f in iteritems(method_map()):
|
||||||
|
fq_name = '.'.join((collection_name, f[0]))
|
||||||
|
self._collection_jinja_func_cache[fq_name] = f[1]
|
||||||
|
|
||||||
|
function_impl = self._collection_jinja_func_cache[key]
|
||||||
|
|
||||||
|
# FIXME: detect/warn on intra-collection function name collisions
|
||||||
|
|
||||||
|
return function_impl
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
return self._delegatee.__setitem__(key, value)
|
||||||
|
|
||||||
|
def __delitem__(self, key):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
# not strictly accurate since we're not counting dynamically-loaded values
|
||||||
|
return iter(self._delegatee)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
# not strictly accurate since we're not counting dynamically-loaded values
|
||||||
|
return len(self._delegatee)
|
||||||
|
|
||||||
|
|
||||||
class AnsibleEnvironment(Environment):
|
class AnsibleEnvironment(Environment):
|
||||||
'''
|
'''
|
||||||
Our custom environment, which simply allows us to override the class-level
|
Our custom environment, which simply allows us to override the class-level
|
||||||
|
@ -263,6 +345,12 @@ class AnsibleEnvironment(Environment):
|
||||||
context_class = AnsibleContext
|
context_class = AnsibleContext
|
||||||
template_class = AnsibleJ2Template
|
template_class = AnsibleJ2Template
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(AnsibleEnvironment, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.filters = JinjaPluginIntercept(self.filters, filter_loader)
|
||||||
|
self.tests = JinjaPluginIntercept(self.tests, test_loader)
|
||||||
|
|
||||||
|
|
||||||
class Templar:
|
class Templar:
|
||||||
'''
|
'''
|
||||||
|
|
304
lib/ansible/utils/collection_loader.py
Normal file
304
lib/ansible/utils/collection_loader.py
Normal file
|
@ -0,0 +1,304 @@
|
||||||
|
# (c) 2019 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
|
||||||
|
|
||||||
|
import os.path
|
||||||
|
import pkgutil
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
from ansible import constants as C
|
||||||
|
from ansible.module_utils._text import to_bytes, to_native, to_text
|
||||||
|
from ansible.module_utils.six import iteritems, string_types
|
||||||
|
|
||||||
|
# HACK: keep Python 2.6 controller tests happy in CI until they're properly split
|
||||||
|
try:
|
||||||
|
from importlib import import_module
|
||||||
|
except ImportError:
|
||||||
|
import_module = __import__
|
||||||
|
|
||||||
|
_SYNTHETIC_PACKAGES = {
|
||||||
|
'ansible_collections.ansible': dict(type='pkg_only'),
|
||||||
|
'ansible_collections.ansible.builtin': dict(type='pkg_only'),
|
||||||
|
'ansible_collections.ansible.builtin.plugins': dict(type='map', map='ansible.plugins'),
|
||||||
|
'ansible_collections.ansible.builtin.plugins.module_utils': dict(type='map', map='ansible.module_utils', graft=True),
|
||||||
|
'ansible_collections.ansible.builtin.plugins.modules': dict(type='flatmap', flatmap='ansible.modules', graft=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: tighten this up to subset Python identifier requirements (and however we want to restrict ns/collection names)
|
||||||
|
_collection_qualified_re = re.compile(to_text(r'^(\w+)\.(\w+)\.(\w+)$'))
|
||||||
|
|
||||||
|
|
||||||
|
# FIXME: exception handling/error logging
|
||||||
|
class AnsibleCollectionLoader(object):
|
||||||
|
def __init__(self):
|
||||||
|
self._n_configured_paths = C.config.get_config_value('COLLECTIONS_PATHS')
|
||||||
|
|
||||||
|
if isinstance(self._n_configured_paths, string_types):
|
||||||
|
self._n_configured_paths = [self._n_configured_paths]
|
||||||
|
elif self._n_configured_paths is None:
|
||||||
|
self._n_configured_paths = []
|
||||||
|
|
||||||
|
# expand any placeholders in configured paths
|
||||||
|
self._n_configured_paths = [to_native(os.path.expanduser(p), errors='surrogate_or_strict') for p in self._n_configured_paths]
|
||||||
|
|
||||||
|
self._n_playbook_paths = []
|
||||||
|
# pre-inject grafted package maps so we can force them to use the right loader instead of potentially delegating to a "normal" loader
|
||||||
|
for syn_pkg_def in (p for p in iteritems(_SYNTHETIC_PACKAGES) if p[1].get('graft')):
|
||||||
|
pkg_name = syn_pkg_def[0]
|
||||||
|
pkg_def = syn_pkg_def[1]
|
||||||
|
|
||||||
|
newmod = ModuleType(pkg_name)
|
||||||
|
newmod.__package__ = pkg_name
|
||||||
|
newmod.__file__ = '<ansible_synthetic_collection_package>'
|
||||||
|
pkg_type = pkg_def.get('type')
|
||||||
|
|
||||||
|
# TODO: need to rethink map style so we can just delegate all the loading
|
||||||
|
|
||||||
|
if pkg_type == 'flatmap':
|
||||||
|
newmod.__loader__ = AnsibleFlatMapLoader(import_module(pkg_def['flatmap']))
|
||||||
|
newmod.__path__ = []
|
||||||
|
|
||||||
|
sys.modules[pkg_name] = newmod
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _n_collection_paths(self):
|
||||||
|
return self._n_playbook_paths + self._n_configured_paths
|
||||||
|
|
||||||
|
def set_playbook_paths(self, b_playbook_paths):
|
||||||
|
if isinstance(b_playbook_paths, string_types):
|
||||||
|
b_playbook_paths = [b_playbook_paths]
|
||||||
|
|
||||||
|
# track visited paths; we have to preserve the dir order as-passed in case there are duplicate collections (first one wins)
|
||||||
|
added_paths = set()
|
||||||
|
|
||||||
|
# de-dupe and ensure the paths are native strings (Python seems to do this for package paths etc, so assume it's safe)
|
||||||
|
self._n_playbook_paths = [os.path.join(to_native(p), 'collections') for p in b_playbook_paths if not (p in added_paths or added_paths.add(p))]
|
||||||
|
# FIXME: only allow setting this once, or handle any necessary cache/package path invalidations internally?
|
||||||
|
|
||||||
|
def find_module(self, fullname, path=None):
|
||||||
|
# this loader is only concerned with items under the Ansible Collections namespace hierarchy, ignore others
|
||||||
|
if fullname.startswith('ansible_collections.') or fullname == 'ansible_collections':
|
||||||
|
return self
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_module(self, fullname):
|
||||||
|
if sys.modules.get(fullname):
|
||||||
|
return sys.modules[fullname]
|
||||||
|
|
||||||
|
# this loader implements key functionality for Ansible collections
|
||||||
|
# * implicit distributed namespace packages for the root Ansible namespace (no pkgutil.extend_path hackery reqd)
|
||||||
|
# * implicit package support for Python 2.7 (no need for __init__.py in collections, except to use standard Py2.7 tooling)
|
||||||
|
# * preventing controller-side code injection during collection loading
|
||||||
|
# * (default loader would execute arbitrary package code from all __init__.py's)
|
||||||
|
|
||||||
|
parent_pkg_name = '.'.join(fullname.split('.')[:-1])
|
||||||
|
|
||||||
|
parent_pkg = sys.modules.get(parent_pkg_name)
|
||||||
|
|
||||||
|
if parent_pkg_name and not parent_pkg:
|
||||||
|
raise ImportError('parent package {0} not found'.format(parent_pkg_name))
|
||||||
|
|
||||||
|
# are we at or below the collection level? eg a.mynamespace.mycollection.something.else
|
||||||
|
# if so, we don't want distributed namespace behavior; first mynamespace.mycollection on the path is where
|
||||||
|
# we'll load everything from (ie, don't fall back to another mynamespace.mycollection lower on the path)
|
||||||
|
sub_collection = fullname.count('.') > 1
|
||||||
|
|
||||||
|
synpkg_def = _SYNTHETIC_PACKAGES.get(fullname)
|
||||||
|
|
||||||
|
# FIXME: collapse as much of this back to on-demand as possible (maybe stub packages that get replaced when actually loaded?)
|
||||||
|
if synpkg_def:
|
||||||
|
pkg_type = synpkg_def.get('type')
|
||||||
|
if not pkg_type:
|
||||||
|
raise KeyError('invalid synthetic package type (no package "type" specified)')
|
||||||
|
if pkg_type == 'map':
|
||||||
|
map_package = synpkg_def.get('map')
|
||||||
|
|
||||||
|
if not map_package:
|
||||||
|
raise KeyError('invalid synthetic map package definition (no target "map" defined)')
|
||||||
|
mod = import_module(map_package)
|
||||||
|
|
||||||
|
sys.modules[fullname] = mod
|
||||||
|
|
||||||
|
return mod
|
||||||
|
elif pkg_type == 'flatmap':
|
||||||
|
raise NotImplementedError()
|
||||||
|
elif pkg_type == 'pkg_only':
|
||||||
|
newmod = ModuleType(fullname)
|
||||||
|
newmod.__package__ = fullname
|
||||||
|
newmod.__file__ = '<ansible_synthetic_collection_package>'
|
||||||
|
newmod.__loader__ = self
|
||||||
|
newmod.__path__ = []
|
||||||
|
|
||||||
|
sys.modules[fullname] = newmod
|
||||||
|
|
||||||
|
return newmod
|
||||||
|
|
||||||
|
if not parent_pkg: # top-level package, look for NS subpackages on all collection paths
|
||||||
|
package_paths = [self._extend_path_with_ns(p, fullname) for p in self._n_collection_paths]
|
||||||
|
else: # subpackage; search in all subpaths (we'll limit later inside a collection)
|
||||||
|
package_paths = [self._extend_path_with_ns(p, fullname) for p in parent_pkg.__path__]
|
||||||
|
|
||||||
|
for candidate_child_path in package_paths:
|
||||||
|
source = None
|
||||||
|
is_package = True
|
||||||
|
location = None
|
||||||
|
# check for implicit sub-package first
|
||||||
|
if os.path.isdir(candidate_child_path):
|
||||||
|
# Py3.x implicit namespace packages don't have a file location, so they don't support get_data
|
||||||
|
# (which assumes the parent dir or that the loader has an internal mapping); so we have to provide
|
||||||
|
# a bogus leaf file on the __file__ attribute for pkgutil.get_data to strip off
|
||||||
|
location = os.path.join(candidate_child_path, '__synthetic__')
|
||||||
|
else:
|
||||||
|
for source_path in [os.path.join(candidate_child_path, '__init__.py'),
|
||||||
|
candidate_child_path + '.py']:
|
||||||
|
if not os.path.isfile(source_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(source_path, 'rb') as fd:
|
||||||
|
source = fd.read()
|
||||||
|
location = source_path
|
||||||
|
is_package = source_path.endswith('__init__.py')
|
||||||
|
break
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
continue
|
||||||
|
|
||||||
|
newmod = ModuleType(fullname)
|
||||||
|
newmod.__package__ = fullname
|
||||||
|
newmod.__file__ = location
|
||||||
|
newmod.__loader__ = self
|
||||||
|
|
||||||
|
if is_package:
|
||||||
|
if sub_collection: # we never want to search multiple instances of the same collection; use first found
|
||||||
|
newmod.__path__ = [candidate_child_path]
|
||||||
|
else:
|
||||||
|
newmod.__path__ = package_paths
|
||||||
|
|
||||||
|
if source:
|
||||||
|
# FIXME: decide cases where we don't actually want to exec the code?
|
||||||
|
exec(source, newmod.__dict__)
|
||||||
|
|
||||||
|
sys.modules[fullname] = newmod
|
||||||
|
|
||||||
|
return newmod
|
||||||
|
|
||||||
|
# FIXME: need to handle the "no dirs present" case for at least the root and synthetic internal collections like ansible.builtin
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extend_path_with_ns(path, ns):
|
||||||
|
ns_path_add = ns.rsplit('.', 1)[-1]
|
||||||
|
|
||||||
|
return os.path.join(path, ns_path_add)
|
||||||
|
|
||||||
|
def get_data(self, filename):
|
||||||
|
with open(filename, 'rb') as fd:
|
||||||
|
return fd.read()
|
||||||
|
|
||||||
|
|
||||||
|
class AnsibleFlatMapLoader(object):
|
||||||
|
_extension_blacklist = ['.pyc', '.pyo']
|
||||||
|
|
||||||
|
def __init__(self, root_package):
|
||||||
|
self._root_package = root_package
|
||||||
|
self._dirtree = None
|
||||||
|
|
||||||
|
def _init_dirtree(self):
|
||||||
|
# FIXME: thread safety
|
||||||
|
root_path = os.path.dirname(self._root_package.__file__)
|
||||||
|
flat_files = []
|
||||||
|
# FIXME: make this a dict of filename->dir for faster direct lookup?
|
||||||
|
# FIXME: deal with _ prefixed deprecated files (or require another method for collections?)
|
||||||
|
# FIXME: fix overloaded filenames (eg, rename Windows setup to win_setup)
|
||||||
|
for root, dirs, files in os.walk(root_path):
|
||||||
|
# add all files in this dir that don't have a blacklisted extension
|
||||||
|
flat_files.extend(((root, f) for f in files if not any((f.endswith(ext) for ext in self._extension_blacklist))))
|
||||||
|
self._dirtree = flat_files
|
||||||
|
|
||||||
|
def find_file(self, filename):
|
||||||
|
# FIXME: thread safety
|
||||||
|
if not self._dirtree:
|
||||||
|
self._init_dirtree()
|
||||||
|
|
||||||
|
if '.' not in filename: # no extension specified, use extension regex to filter
|
||||||
|
extensionless_re = re.compile(r'^{0}(\..+)?$'.format(re.escape(filename)))
|
||||||
|
# why doesn't Python have first()?
|
||||||
|
try:
|
||||||
|
# FIXME: store extensionless in a separate direct lookup?
|
||||||
|
filepath = next(os.path.join(r, f) for r, f in self._dirtree if extensionless_re.match(f))
|
||||||
|
except StopIteration:
|
||||||
|
raise IOError("couldn't find {0}".format(filename))
|
||||||
|
else: # actual filename, just look it up
|
||||||
|
# FIXME: this case sucks; make it a lookup
|
||||||
|
try:
|
||||||
|
filepath = next(os.path.join(r, f) for r, f in self._dirtree if f == filename)
|
||||||
|
except StopIteration:
|
||||||
|
raise IOError("couldn't find {0}".format(filename))
|
||||||
|
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
def get_data(self, filename):
|
||||||
|
found_file = self.find_file(filename)
|
||||||
|
|
||||||
|
with open(found_file, 'rb') as fd:
|
||||||
|
return fd.read()
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: implement these for easier inline debugging?
|
||||||
|
# def get_source(self, fullname):
|
||||||
|
# def get_code(self, fullname):
|
||||||
|
# def is_package(self, fullname):
|
||||||
|
|
||||||
|
|
||||||
|
def get_collection_role_path(role_name, collection_list=None):
|
||||||
|
match = _collection_qualified_re.match(role_name)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
grps = match.groups()
|
||||||
|
collection_list = ['.'.join(grps[:2])]
|
||||||
|
role = grps[2]
|
||||||
|
elif not collection_list:
|
||||||
|
return None # not a FQ role and no collection search list spec'd, nothing to do
|
||||||
|
else:
|
||||||
|
role = role_name
|
||||||
|
|
||||||
|
for collection_name in collection_list:
|
||||||
|
try:
|
||||||
|
role_package = u'ansible_collections.{0}.roles.{1}'.format(collection_name, role)
|
||||||
|
# FIXME: error handling/logging; need to catch any import failures and move along
|
||||||
|
|
||||||
|
# FIXME: this line shouldn't be necessary, but py2 pkgutil.get_data is delegating back to built-in loader when it shouldn't
|
||||||
|
pkg = import_module(role_package + u'.tasks')
|
||||||
|
|
||||||
|
# get_data input must be a native string
|
||||||
|
tasks_file = pkgutil.get_data(to_native(role_package) + '.tasks', 'main.yml')
|
||||||
|
|
||||||
|
if tasks_file is not None:
|
||||||
|
# the package is now loaded, get the collection's package and ask where it lives
|
||||||
|
path = os.path.dirname(to_bytes(sys.modules[role_package].__file__, errors='surrogate_or_strict'))
|
||||||
|
return role, to_text(path, errors='surrogate_or_strict'), collection_name
|
||||||
|
|
||||||
|
except IOError:
|
||||||
|
continue
|
||||||
|
except Exception as ex:
|
||||||
|
# FIXME: pick out typical import errors first, then error logging
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_collection_ref(candidate_name):
|
||||||
|
return bool(_collection_qualified_re.match(candidate_name))
|
||||||
|
|
||||||
|
|
||||||
|
def set_collection_playbook_paths(b_playbook_paths):
|
||||||
|
# set for any/all AnsibleCollectionLoader instance(s) on meta_path
|
||||||
|
for loader in (l for l in sys.meta_path if isinstance(l, AnsibleCollectionLoader)):
|
||||||
|
loader.set_playbook_paths(b_playbook_paths)
|
3
test/integration/targets/collections/a.statichost.yml
Normal file
3
test/integration/targets/collections/a.statichost.yml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# use a plugin defined in a content-adjacent collection to ensure we added it properly
|
||||||
|
plugin: testns.content_adj.statichost
|
||||||
|
hostname: dynamic_host_a
|
2
test/integration/targets/collections/aliases
Normal file
2
test/integration/targets/collections/aliases
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
shippable/posix/group4
|
||||||
|
skip/python2.6
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(json.dumps(dict(changed=False, source='sys')))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(json.dumps(dict(changed=False, failed=True, msg='this collection should be masked by testcoll in the user content root')))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(json.dumps(dict(changed=False, source='sys')))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,2 @@
|
||||||
|
- fail:
|
||||||
|
msg: this role should never be visible or runnable
|
|
@ -0,0 +1,30 @@
|
||||||
|
from ansible.plugins.action import ActionBase
|
||||||
|
from ansible.plugins import loader
|
||||||
|
|
||||||
|
|
||||||
|
class ActionModule(ActionBase):
|
||||||
|
TRANSFERS_FILES = False
|
||||||
|
_VALID_ARGS = frozenset(('type', 'name'))
|
||||||
|
|
||||||
|
def run(self, tmp=None, task_vars=None):
|
||||||
|
if task_vars is None:
|
||||||
|
task_vars = dict()
|
||||||
|
|
||||||
|
result = super(ActionModule, self).run(None, task_vars)
|
||||||
|
|
||||||
|
type = self._task.args.get('type')
|
||||||
|
name = self._task.args.get('name')
|
||||||
|
|
||||||
|
result = dict(changed=False, collection_list=self._task.collections)
|
||||||
|
|
||||||
|
if all([type, name]):
|
||||||
|
attr_name = '{0}_loader'.format(type)
|
||||||
|
|
||||||
|
typed_loader = getattr(loader, attr_name, None)
|
||||||
|
|
||||||
|
if not typed_loader:
|
||||||
|
return (dict(failed=True, msg='invalid plugin type {0}'.format(type)))
|
||||||
|
|
||||||
|
result['plugin_path'] = typed_loader.find_plugin(name, collection_list=self._task.collections)
|
||||||
|
|
||||||
|
return result
|
|
@ -0,0 +1,24 @@
|
||||||
|
from ansible.plugins.callback import CallbackBase
|
||||||
|
|
||||||
|
DOCUMENTATION = '''
|
||||||
|
callback: usercallback
|
||||||
|
callback_type: notification
|
||||||
|
short_description: does stuff
|
||||||
|
description:
|
||||||
|
- does some stuff
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackModule(CallbackBase):
|
||||||
|
CALLBACK_VERSION = 2.0
|
||||||
|
CALLBACK_TYPE = 'aggregate'
|
||||||
|
CALLBACK_NAME = 'usercallback'
|
||||||
|
CALLBACK_NEEDS_WHITELIST = True
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
super(CallbackModule, self).__init__()
|
||||||
|
self._display.display("loaded usercallback from collection, yay")
|
||||||
|
|
||||||
|
def v2_runner_on_ok(self, result):
|
||||||
|
self._display.display("usercallback says ok")
|
|
@ -0,0 +1,38 @@
|
||||||
|
from ansible.module_utils._text import to_native
|
||||||
|
from ansible.plugins.connection import ConnectionBase
|
||||||
|
|
||||||
|
DOCUMENTATION = """
|
||||||
|
connection: localconn
|
||||||
|
short_description: do stuff local
|
||||||
|
description:
|
||||||
|
- does stuff
|
||||||
|
options:
|
||||||
|
connectionvar:
|
||||||
|
description:
|
||||||
|
- something we set
|
||||||
|
default: the_default
|
||||||
|
vars:
|
||||||
|
- name: ansible_localconn_connectionvar
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Connection(ConnectionBase):
|
||||||
|
transport = 'local'
|
||||||
|
has_pipelining = True
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def exec_command(self, cmd, in_data=None, sudoable=True):
|
||||||
|
stdout = 'localconn ran {0}'.format(to_native(cmd))
|
||||||
|
stderr = 'connectionvar is {0}'.format(to_native(self.get_option('connectionvar')))
|
||||||
|
return (0, stdout, stderr)
|
||||||
|
|
||||||
|
def put_file(self, in_path, out_path):
|
||||||
|
raise NotImplementedError('just a test')
|
||||||
|
|
||||||
|
def fetch_file(self, in_path, out_path):
|
||||||
|
raise NotImplementedError('just a test')
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._connected = False
|
|
@ -0,0 +1,10 @@
|
||||||
|
def testfilter(data):
|
||||||
|
return "{0}_from_userdir".format(data)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
'testfilter': testfilter
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
from ansible.plugins.lookup import LookupBase
|
||||||
|
|
||||||
|
|
||||||
|
class LookupModule(LookupBase):
|
||||||
|
|
||||||
|
def run(self, terms, variables, **kwargs):
|
||||||
|
|
||||||
|
return ['lookup_from_user_dir']
|
|
@ -0,0 +1,7 @@
|
||||||
|
# FIXME: this style (full module import via from) doesn't work yet from collections
|
||||||
|
# from ansible_collections.testns.testcoll.plugins.module_utils import secondary
|
||||||
|
import ansible_collections.testns.testcoll.plugins.module_utils.secondary
|
||||||
|
|
||||||
|
|
||||||
|
def thingtocall():
|
||||||
|
return "thingtocall in base called " + ansible_collections.testns.testcoll.plugins.module_utils.secondary.thingtocall()
|
|
@ -0,0 +1,2 @@
|
||||||
|
def thingtocall():
|
||||||
|
return "thingtocall in leaf"
|
|
@ -0,0 +1,2 @@
|
||||||
|
def thingtocall():
|
||||||
|
return "thingtocall in secondary"
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(json.dumps(dict(changed=False, source='user')))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(json.dumps(dict(changed=False, source='user')))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# FIXME: this is only required due to a bug around "new style module detection"
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
from ansible_collections.testns.testcoll.plugins.module_utils.base import thingtocall
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mu_result = thingtocall()
|
||||||
|
print(json.dumps(dict(changed=False, source='user', mu_result=mu_result)))
|
||||||
|
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# FIXME: this is only required due to a bug around "new style module detection"
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
import ansible_collections.testns.testcoll.plugins.module_utils.leaf
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mu_result = ansible_collections.testns.testcoll.plugins.module_utils.leaf.thingtocall()
|
||||||
|
print(json.dumps(dict(changed=False, source='user', mu_result=mu_result)))
|
||||||
|
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# FIXME: this is only required due to a bug around "new style module detection"
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
from ansible_collections.testns.testcoll.plugins.module_utils.leaf import thingtocall
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mu_result = thingtocall()
|
||||||
|
print(json.dumps(dict(changed=False, source='user', mu_result=mu_result)))
|
||||||
|
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# FIXME: this is only required due to a bug around "new style module detection"
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
# FIXME: this style doesn't work yet under collections
|
||||||
|
from ansible_collections.testns.testcoll.plugins.module_utils import leaf
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mu_result = leaf.thingtocall()
|
||||||
|
print(json.dumps(dict(changed=False, source='user', mu_result=mu_result)))
|
||||||
|
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,9 @@
|
||||||
|
def testtest(data):
|
||||||
|
return data == 'from_user'
|
||||||
|
|
||||||
|
|
||||||
|
class TestModule(object):
|
||||||
|
def tests(self):
|
||||||
|
return {
|
||||||
|
'testtest': testtest
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
collections:
|
||||||
|
- ansible.builtin
|
||||||
|
- testns.coll_in_sys
|
||||||
|
- bogus.fromrolemeta
|
|
@ -0,0 +1,30 @@
|
||||||
|
- name: check collections list from role meta
|
||||||
|
plugin_lookup:
|
||||||
|
register: pluginlookup_out
|
||||||
|
|
||||||
|
- name: call role-local ping module
|
||||||
|
ping:
|
||||||
|
register: ping_out
|
||||||
|
|
||||||
|
- name: call unqualified module in another collection listed in role meta (testns.coll_in_sys)
|
||||||
|
systestmodule:
|
||||||
|
register: systestmodule_out
|
||||||
|
|
||||||
|
# verify that pluginloader caching doesn't prevent us from explicitly calling a builtin plugin with the same name
|
||||||
|
- name: call builtin ping module explicitly
|
||||||
|
ansible.builtin.ping:
|
||||||
|
register: builtinping_out
|
||||||
|
|
||||||
|
- debug:
|
||||||
|
msg: '{{ test_role_input | default("(undefined)") }}'
|
||||||
|
register: test_role_output
|
||||||
|
|
||||||
|
# FIXME: add tests to ensure that block/task level stuff in a collection-hosted role properly inherit role default/meta values
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- pluginlookup_out.collection_list == ['testns.testcoll', 'ansible.builtin', 'testns.coll_in_sys', 'bogus.fromrolemeta']
|
||||||
|
- ping_out.source is defined and ping_out.source == 'user'
|
||||||
|
- systestmodule_out.source is defined and systestmodule_out.source == 'sys'
|
||||||
|
- builtinping_out.ping is defined and builtinping_out.ping == 'pong'
|
||||||
|
- test_role_input is not defined or test_role_input == test_role_output.msg
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Copyright (c) 2018 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 = '''
|
||||||
|
inventory: statichost
|
||||||
|
short_description: Add a single host
|
||||||
|
options:
|
||||||
|
plugin:
|
||||||
|
description: plugin name (must be statichost)
|
||||||
|
required: true
|
||||||
|
hostname:
|
||||||
|
description: Toggle display of stderr even when script was successful
|
||||||
|
type: list
|
||||||
|
'''
|
||||||
|
|
||||||
|
from ansible.errors import AnsibleParserError
|
||||||
|
from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryModule(BaseInventoryPlugin):
|
||||||
|
|
||||||
|
NAME = 'statichost'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
super(InventoryModule, self).__init__()
|
||||||
|
|
||||||
|
self._hosts = set()
|
||||||
|
|
||||||
|
def verify_file(self, path):
|
||||||
|
''' Verify if file is usable by this plugin, base does minimal accessibility check '''
|
||||||
|
|
||||||
|
if not path.endswith('.statichost.yml') and not path.endswith('.statichost.yaml'):
|
||||||
|
return False
|
||||||
|
return super(InventoryModule, self).verify_file(path)
|
||||||
|
|
||||||
|
def parse(self, inventory, loader, path, cache=None):
|
||||||
|
|
||||||
|
super(InventoryModule, self).parse(inventory, loader, path)
|
||||||
|
|
||||||
|
config_data = loader.load_from_file(path, cache=False)
|
||||||
|
host_to_add = config_data.get('hostname')
|
||||||
|
|
||||||
|
if not host_to_add:
|
||||||
|
raise AnsibleParserError("hostname was not specified")
|
||||||
|
|
||||||
|
# this is where the magic happens
|
||||||
|
self.inventory.add_host(host_to_add, 'all')
|
||||||
|
|
||||||
|
# self.inventory.add_group()...
|
||||||
|
# self.inventory.add_child()...
|
||||||
|
# self.inventory.set_variable()..
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(json.dumps(dict(changed=False, source='content_adj')))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
11
test/integration/targets/collections/library/ping.py
Normal file
11
test/integration/targets/collections/library/ping.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/python
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(json.dumps(dict(changed=False, source='legacy_library_dir')))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
280
test/integration/targets/collections/play.yml
Normal file
280
test/integration/targets/collections/play.yml
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
- hosts: testhost
|
||||||
|
tasks:
|
||||||
|
# basic test of FQ module lookup and that we got the right one (user-dir hosted)
|
||||||
|
- name: exec FQ module in a user-dir testns collection
|
||||||
|
testns.testcoll.testmodule:
|
||||||
|
register: testmodule_out
|
||||||
|
|
||||||
|
# verifies that distributed collection subpackages are visible under a multi-location namespace (testns exists in user and sys locations)
|
||||||
|
- name: exec FQ module in a sys-dir testns collection
|
||||||
|
testns.coll_in_sys.systestmodule:
|
||||||
|
register: systestmodule_out
|
||||||
|
|
||||||
|
# verifies that content-adjacent collections were automatically added to the installed content roots
|
||||||
|
- name: exec FQ module from content-adjacent collection
|
||||||
|
testns.content_adj.contentadjmodule:
|
||||||
|
register: contentadjmodule_out
|
||||||
|
|
||||||
|
# content should only be loaded from the first visible instance of a collection
|
||||||
|
- name: attempt to look up FQ module in a masked collection
|
||||||
|
testns.testcoll.plugin_lookup:
|
||||||
|
type: module
|
||||||
|
name: testns.testcoll.maskedmodule
|
||||||
|
register: maskedmodule_out
|
||||||
|
|
||||||
|
# module with a granular module_utils import (from (this collection).module_utils.leaf import thingtocall)
|
||||||
|
- name: exec module with granular module utils import from this collection
|
||||||
|
testns.testcoll.uses_leaf_mu_granular_import:
|
||||||
|
register: granular_out
|
||||||
|
|
||||||
|
# module with a granular nested module_utils import (from (this collection).module_utils.base import thingtocall,
|
||||||
|
# where base imports secondary from the same collection's module_utils)
|
||||||
|
- name: exec module with nested module utils from this collection
|
||||||
|
testns.testcoll.uses_base_mu_granular_nested_import:
|
||||||
|
register: granular_nested_out
|
||||||
|
|
||||||
|
# module with a flat module_utils import (import (this collection).module_utils.leaf)
|
||||||
|
- name: exec module with flat module_utils import from this collection
|
||||||
|
testns.testcoll.uses_leaf_mu_flat_import:
|
||||||
|
register: flat_out
|
||||||
|
|
||||||
|
# FIXME: this one doesn't work yet
|
||||||
|
# module with a full-module module_utils import using 'from' (from (this collection).module_utils import leaf)
|
||||||
|
- name: exec module with full-module module_utils import using 'from' from this collection
|
||||||
|
testns.testcoll.uses_leaf_mu_module_import_from:
|
||||||
|
ignore_errors: true
|
||||||
|
register: from_out
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- testmodule_out.source == 'user'
|
||||||
|
- systestmodule_out.source == 'sys'
|
||||||
|
- contentadjmodule_out.source == 'content_adj'
|
||||||
|
- not maskedmodule_out.plugin_path
|
||||||
|
- granular_out.mu_result == 'thingtocall in leaf'
|
||||||
|
- granular_nested_out.mu_result == 'thingtocall in base called thingtocall in secondary'
|
||||||
|
- flat_out.mu_result == 'thingtocall in leaf'
|
||||||
|
- from_out is failed # FIXME: switch back once this import is fixed --> from_out.mu_result == 'thingtocall in leaf'
|
||||||
|
|
||||||
|
- name: exercise filters/tests/lookups
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- "'data' | testns.testcoll.testfilter == 'data_from_userdir'"
|
||||||
|
- "'from_user' is testns.testcoll.testtest"
|
||||||
|
- lookup('testns.testcoll.mylookup') == 'lookup_from_user_dir'
|
||||||
|
|
||||||
|
# ensure that the synthetic ansible.builtin collection limits to builtin plugins, that ansible.legacy loads overrides
|
||||||
|
# from legacy plugin dirs, and that a same-named plugin loaded from a real collection is not masked by the others
|
||||||
|
- hosts: testhost
|
||||||
|
tasks:
|
||||||
|
- name: test unqualified ping from library dir
|
||||||
|
ping:
|
||||||
|
register: unqualified_ping_out
|
||||||
|
|
||||||
|
- name: test legacy-qualified ping from library dir
|
||||||
|
ansible.legacy.ping:
|
||||||
|
register: legacy_ping_out
|
||||||
|
|
||||||
|
- name: test builtin ping
|
||||||
|
ansible.builtin.ping:
|
||||||
|
register: builtin_ping_out
|
||||||
|
|
||||||
|
- name: test collection-based ping
|
||||||
|
testns.testcoll.ping:
|
||||||
|
register: collection_ping_out
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- unqualified_ping_out.source == 'legacy_library_dir'
|
||||||
|
- legacy_ping_out.source == 'legacy_library_dir'
|
||||||
|
- builtin_ping_out.ping == 'pong'
|
||||||
|
- collection_ping_out.source == 'user'
|
||||||
|
|
||||||
|
# verify the default value for the collections list is empty
|
||||||
|
- hosts: testhost
|
||||||
|
tasks:
|
||||||
|
- name: sample default collections value
|
||||||
|
testns.testcoll.plugin_lookup:
|
||||||
|
register: coll_default_out
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
# in original release, collections defaults to empty, which is mostly equivalent to ansible.legacy
|
||||||
|
- not coll_default_out.collection_list
|
||||||
|
|
||||||
|
|
||||||
|
# ensure that inheritance/masking works as expected, that the proper default values are injected when missing,
|
||||||
|
# and that the order is preserved if one of the magic values is explicitly specified
|
||||||
|
- name: verify collections keyword play/block/task inheritance and magic values
|
||||||
|
hosts: testhost
|
||||||
|
collections:
|
||||||
|
- bogus.fromplay
|
||||||
|
tasks:
|
||||||
|
- name: sample play collections value
|
||||||
|
testns.testcoll.plugin_lookup:
|
||||||
|
register: coll_play_out
|
||||||
|
|
||||||
|
- name: collections override block-level
|
||||||
|
collections:
|
||||||
|
- bogus.fromblock
|
||||||
|
block:
|
||||||
|
- name: sample block collections value
|
||||||
|
testns.testcoll.plugin_lookup:
|
||||||
|
register: coll_block_out
|
||||||
|
|
||||||
|
- name: sample task collections value
|
||||||
|
collections:
|
||||||
|
- bogus.fromtask
|
||||||
|
testns.testcoll.plugin_lookup:
|
||||||
|
register: coll_task_out
|
||||||
|
|
||||||
|
- name: sample task with explicit core
|
||||||
|
collections:
|
||||||
|
- ansible.builtin
|
||||||
|
- bogus.fromtaskexplicitcore
|
||||||
|
testns.testcoll.plugin_lookup:
|
||||||
|
register: coll_task_core
|
||||||
|
|
||||||
|
- name: sample task with explicit legacy
|
||||||
|
collections:
|
||||||
|
- ansible.legacy
|
||||||
|
- bogus.fromtaskexplicitlegacy
|
||||||
|
testns.testcoll.plugin_lookup:
|
||||||
|
register: coll_task_legacy
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
# ensure that parent value inheritance is masked properly by explicit setting
|
||||||
|
- coll_play_out.collection_list == ['bogus.fromplay', 'ansible.legacy']
|
||||||
|
- coll_block_out.collection_list == ['bogus.fromblock', 'ansible.legacy']
|
||||||
|
- coll_task_out.collection_list == ['bogus.fromtask', 'ansible.legacy']
|
||||||
|
- coll_task_core.collection_list == ['ansible.builtin', 'bogus.fromtaskexplicitcore']
|
||||||
|
- coll_task_legacy.collection_list == ['ansible.legacy', 'bogus.fromtaskexplicitlegacy']
|
||||||
|
|
||||||
|
- name: verify unqualified plugin resolution behavior
|
||||||
|
hosts: testhost
|
||||||
|
collections:
|
||||||
|
- testns.testcoll
|
||||||
|
- testns.coll_in_sys
|
||||||
|
- testns.contentadj
|
||||||
|
tasks:
|
||||||
|
# basic test of unqualified module lookup and that we got the right one (user-dir hosted, there's another copy of
|
||||||
|
# this one in the same-named collection in sys dir that should be masked
|
||||||
|
- name: exec unqualified module in a user-dir testns collection
|
||||||
|
testmodule:
|
||||||
|
register: testmodule_out
|
||||||
|
|
||||||
|
# use another collection to verify that we're looking in all collections listed on the play
|
||||||
|
- name: exec unqualified module in a sys-dir testns collection
|
||||||
|
systestmodule:
|
||||||
|
register: systestmodule_out
|
||||||
|
|
||||||
|
# ensure we're looking up actions properly
|
||||||
|
- name: unqualified action test
|
||||||
|
plugin_lookup:
|
||||||
|
register: pluginlookup_out
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- testmodule_out.source == 'user'
|
||||||
|
- systestmodule_out.source == 'sys'
|
||||||
|
- pluginlookup_out.collection_list == ['testns.testcoll', 'testns.coll_in_sys', 'testns.contentadj', 'ansible.legacy']
|
||||||
|
|
||||||
|
# FIXME: this won't work until collections list gets passed through task templar
|
||||||
|
# - name: exercise unqualified filters/tests/lookups
|
||||||
|
# assert:
|
||||||
|
# that:
|
||||||
|
# - "'data' | testfilter == 'data_from_userdir'"
|
||||||
|
# - "'from_user' is testtest"
|
||||||
|
# - lookup('mylookup') == 'lookup_from_user_dir'
|
||||||
|
|
||||||
|
|
||||||
|
# test keyword-static execution of a FQ collection-backed role
|
||||||
|
- name: verify collection-backed role execution (keyword static)
|
||||||
|
hosts: testhost
|
||||||
|
collections:
|
||||||
|
# set to ansible.builtin only to ensure that roles function properly without inheriting the play's collections config
|
||||||
|
- ansible.builtin
|
||||||
|
vars:
|
||||||
|
test_role_input: keyword static
|
||||||
|
roles:
|
||||||
|
- role: testns.testcoll.testrole
|
||||||
|
tasks:
|
||||||
|
- name: ensure role executed
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- test_role_output.msg == test_role_input
|
||||||
|
|
||||||
|
|
||||||
|
# test dynamic execution of a FQ collection-backed role
|
||||||
|
- name: verify collection-backed role execution (dynamic)
|
||||||
|
hosts: testhost
|
||||||
|
collections:
|
||||||
|
# set to ansible.builtin only to ensure that roles function properly without inheriting the play's collections config
|
||||||
|
- ansible.builtin
|
||||||
|
vars:
|
||||||
|
test_role_input: dynamic
|
||||||
|
tasks:
|
||||||
|
- include_role:
|
||||||
|
name: testns.testcoll.testrole
|
||||||
|
- name: ensure role executed
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- test_role_output.msg == test_role_input
|
||||||
|
|
||||||
|
|
||||||
|
# test task-static execution of a FQ collection-backed role
|
||||||
|
- name: verify collection-backed role execution (task static)
|
||||||
|
hosts: testhost
|
||||||
|
collections:
|
||||||
|
- ansible.builtin
|
||||||
|
vars:
|
||||||
|
test_role_input: task static
|
||||||
|
tasks:
|
||||||
|
- import_role:
|
||||||
|
name: testns.testcoll.testrole
|
||||||
|
- name: ensure role executed
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- test_role_output.msg == test_role_input
|
||||||
|
|
||||||
|
|
||||||
|
# test a legacy playbook-adjacent role, ensure that play collections config is not inherited
|
||||||
|
- name: verify legacy playbook-adjacent role behavior
|
||||||
|
hosts: testhost
|
||||||
|
collections:
|
||||||
|
- bogus.bogus
|
||||||
|
vars:
|
||||||
|
test_role_input: legacy playbook-adjacent
|
||||||
|
roles:
|
||||||
|
- testrole
|
||||||
|
# FIXME: this should technically work to look up a playbook-adjacent role
|
||||||
|
# - ansible.legacy.testrole
|
||||||
|
tasks:
|
||||||
|
- name: ensure role executed
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- test_role_output.msg == test_role_input
|
||||||
|
|
||||||
|
|
||||||
|
- name: test a collection-hosted connection plugin against a host from a collection-hosted inventory plugin
|
||||||
|
hosts: dynamic_host_a
|
||||||
|
vars:
|
||||||
|
ansible_connection: testns.testcoll.localconn
|
||||||
|
ansible_localconn_connectionvar: from_play
|
||||||
|
tasks:
|
||||||
|
- raw: echo 'hello world'
|
||||||
|
register: connection_out
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- connection_out.stdout == "localconn ran echo 'hello world'"
|
||||||
|
# ensure that the connection var we overrode above made it into the running config
|
||||||
|
- connection_out.stderr == "connectionvar is from_play"
|
||||||
|
|
||||||
|
- hosts: testhost
|
||||||
|
tasks:
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- hostvars['dynamic_host_a'] is defined
|
||||||
|
- hostvars['dynamic_host_a'].connection_out.stdout == "localconn ran echo 'hello world'"
|
8
test/integration/targets/collections/pythoncheck.yml
Normal file
8
test/integration/targets/collections/pythoncheck.yml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
# this test specifically avoids testhost because we need to know about the controller's Python
|
||||||
|
- hosts: localhost
|
||||||
|
gather_facts: yes
|
||||||
|
gather_subset: min
|
||||||
|
tasks:
|
||||||
|
- debug:
|
||||||
|
msg: UNSUPPORTEDPYTHON {{ ansible_python_version }}
|
||||||
|
when: ansible_python_version is version('2.7', '<')
|
|
@ -0,0 +1,25 @@
|
||||||
|
- debug:
|
||||||
|
msg: executing testrole from legacy playbook-adjacent roles dir
|
||||||
|
|
||||||
|
- name: exec a FQ module from a legacy role
|
||||||
|
testns.testcoll.testmodule:
|
||||||
|
register: coll_module_out
|
||||||
|
|
||||||
|
- name: exec a legacy playbook-adjacent module from a legacy role
|
||||||
|
ping:
|
||||||
|
register: ping_out
|
||||||
|
|
||||||
|
- name: sample collections list inside a legacy role (should be empty)
|
||||||
|
testns.testcoll.plugin_lookup:
|
||||||
|
register: plugin_lookup_out
|
||||||
|
|
||||||
|
- debug:
|
||||||
|
msg: '{{ test_role_input | default("(undefined)") }}'
|
||||||
|
register: test_role_output
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- coll_module_out.source == 'user'
|
||||||
|
# ensure we used the library/ ping override, not the builtin or one from another collection
|
||||||
|
- ping_out.source == 'legacy_library_dir'
|
||||||
|
- not plugin_lookup_out.collection_list
|
19
test/integration/targets/collections/runme.sh
Executable file
19
test/integration/targets/collections/runme.sh
Executable file
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -eux
|
||||||
|
|
||||||
|
export ANSIBLE_COLLECTIONS_PATHS=$PWD/collection_root_user:$PWD/collection_root_sys
|
||||||
|
export ANSIBLE_GATHERING=explicit
|
||||||
|
export ANSIBLE_GATHER_SUBSET=minimal
|
||||||
|
|
||||||
|
# temporary hack to keep this test from running on Python 2.6 in CI
|
||||||
|
if ansible-playbook -i ../../inventory pythoncheck.yml | grep UNSUPPORTEDPYTHON; then
|
||||||
|
echo skipping test for unsupported Python version...
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# test callback
|
||||||
|
ANSIBLE_CALLBACK_WHITELIST=testns.testcoll.usercallback ansible localhost -m ping | grep "usercallback says ok"
|
||||||
|
|
||||||
|
# run test playbook
|
||||||
|
ansible-playbook -i ../../inventory -i ./a.statichost.yml -v play.yml
|
|
@ -40,7 +40,8 @@
|
||||||
- win_ping_with_data_result.ping == '☠'
|
- win_ping_with_data_result.ping == '☠'
|
||||||
|
|
||||||
- name: test win_ping.ps1 with data as complex args
|
- name: test win_ping.ps1 with data as complex args
|
||||||
win_ping.ps1:
|
# win_ping.ps1: # TODO: do we want to actually support this? no other tests that I can see...
|
||||||
|
win_ping:
|
||||||
data: bleep
|
data: bleep
|
||||||
register: win_ping_ps1_result
|
register: win_ping_ps1_result
|
||||||
|
|
||||||
|
|
|
@ -329,6 +329,7 @@ class TestRole(unittest.TestCase):
|
||||||
})
|
})
|
||||||
|
|
||||||
mock_play = MagicMock()
|
mock_play = MagicMock()
|
||||||
|
mock_play.collections = None
|
||||||
mock_play.ROLE_CACHE = {}
|
mock_play.ROLE_CACHE = {}
|
||||||
|
|
||||||
i = RoleInclude.load('foo_metadata', play=mock_play, loader=fake_loader)
|
i = RoleInclude.load('foo_metadata', play=mock_play, loader=fake_loader)
|
||||||
|
|
|
@ -43,6 +43,8 @@ class MixinForMocks(object):
|
||||||
self.mock_tqm = MagicMock(name='MockTaskQueueManager')
|
self.mock_tqm = MagicMock(name='MockTaskQueueManager')
|
||||||
|
|
||||||
self.mock_play = MagicMock(name='MockPlay')
|
self.mock_play = MagicMock(name='MockPlay')
|
||||||
|
self.mock_play._attributes = []
|
||||||
|
self.mock_play.collections = None
|
||||||
|
|
||||||
self.mock_iterator = MagicMock(name='MockIterator')
|
self.mock_iterator = MagicMock(name='MockIterator')
|
||||||
self.mock_iterator._play = self.mock_play
|
self.mock_iterator._play = self.mock_play
|
||||||
|
|
|
@ -121,7 +121,7 @@ class TestActionBase(unittest.TestCase):
|
||||||
mock_connection = MagicMock()
|
mock_connection = MagicMock()
|
||||||
|
|
||||||
# create a mock shared loader object
|
# create a mock shared loader object
|
||||||
def mock_find_plugin(name, options):
|
def mock_find_plugin(name, options, collection_list=None):
|
||||||
if name == 'badmodule':
|
if name == 'badmodule':
|
||||||
return None
|
return None
|
||||||
elif '.ps1' in options:
|
elif '.ps1' in options:
|
||||||
|
|
Loading…
Reference in a new issue