summaryrefslogtreecommitdiffstats
path: root/lib/ansible/plugins/loader.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/ansible/plugins/loader.py')
-rw-r--r--lib/ansible/plugins/loader.py148
1 files changed, 114 insertions, 34 deletions
diff --git a/lib/ansible/plugins/loader.py b/lib/ansible/plugins/loader.py
index 8b7fbfc..9ff19bb 100644
--- a/lib/ansible/plugins/loader.py
+++ b/lib/ansible/plugins/loader.py
@@ -17,6 +17,7 @@ import warnings
from collections import defaultdict, namedtuple
from traceback import format_exc
+import ansible.module_utils.compat.typing as t
from .filter import AnsibleJinja2Filter
from .test import AnsibleJinja2Test
@@ -24,7 +25,7 @@ from .test import AnsibleJinja2Test
from ansible import __version__ as ansible_version
from ansible import constants as C
from ansible.errors import AnsibleError, AnsiblePluginCircularRedirect, AnsiblePluginRemovedError, AnsibleCollectionUnsupportedVersionError
-from ansible.module_utils._text import to_bytes, to_text, to_native
+from ansible.module_utils.common.text.converters import to_bytes, to_text, to_native
from ansible.module_utils.compat.importlib import import_module
from ansible.module_utils.six import string_types
from ansible.parsing.utils.yaml import from_yaml
@@ -33,7 +34,8 @@ from ansible.plugins import get_plugin_class, MODULE_CACHE, PATH_CACHE, PLUGIN_P
from ansible.utils.collection_loader import AnsibleCollectionConfig, AnsibleCollectionRef
from ansible.utils.collection_loader._collection_finder import _AnsibleCollectionFinder, _get_collection_metadata
from ansible.utils.display import Display
-from ansible.utils.plugin_docs import add_fragments, find_plugin_docfile
+from ansible.utils.plugin_docs import add_fragments
+from ansible.utils.unsafe_proxy import _is_unsafe
# TODO: take the packaging dep, or vendor SpecifierSet?
@@ -46,6 +48,7 @@ except ImportError:
import importlib.util
+_PLUGIN_FILTERS = defaultdict(frozenset) # type: t.DefaultDict[str, frozenset]
display = Display()
get_with_context_result = namedtuple('get_with_context_result', ['object', 'plugin_load_context'])
@@ -236,6 +239,7 @@ class PluginLoader:
self._module_cache = MODULE_CACHE[class_name]
self._paths = PATH_CACHE[class_name]
self._plugin_path_cache = PLUGIN_PATH_CACHE[class_name]
+ self._plugin_instance_cache = {} if self.subdir == 'vars_plugins' else None
self._searched_paths = set()
@@ -260,6 +264,7 @@ class PluginLoader:
self._module_cache = MODULE_CACHE[self.class_name]
self._paths = PATH_CACHE[self.class_name]
self._plugin_path_cache = PLUGIN_PATH_CACHE[self.class_name]
+ self._plugin_instance_cache = {} if self.subdir == 'vars_plugins' else None
self._searched_paths = set()
def __setstate__(self, data):
@@ -858,29 +863,52 @@ class PluginLoader:
def get_with_context(self, name, *args, **kwargs):
''' instantiates a plugin of the given name using arguments '''
+ if _is_unsafe(name):
+ # Objects constructed using the name wrapped as unsafe remain
+ # (correctly) unsafe. Using such unsafe objects in places
+ # where underlying types (builtin string in this case) are
+ # expected can cause problems.
+ # One such case is importlib.abc.Loader.exec_module failing
+ # with "ValueError: unmarshallable object" because the module
+ # object is created with the __path__ attribute being wrapped
+ # as unsafe which isn't marshallable.
+ # Manually removing the unsafe wrapper prevents such issues.
+ name = name._strip_unsafe()
found_in_cache = True
class_only = kwargs.pop('class_only', False)
collection_list = kwargs.pop('collection_list', None)
if name in self.aliases:
name = self.aliases[name]
+
+ if (cached_result := (self._plugin_instance_cache or {}).get(name)) and cached_result[1].resolved:
+ # Resolving the FQCN is slow, even if we've passed in the resolved FQCN.
+ # Short-circuit here if we've previously resolved this name.
+ # This will need to be restricted if non-vars plugins start using the cache, since
+ # some non-fqcn plugin need to be resolved again with the collections list.
+ return get_with_context_result(*cached_result)
+
plugin_load_context = self.find_plugin_with_context(name, collection_list=collection_list)
if not plugin_load_context.resolved or not plugin_load_context.plugin_resolved_path:
# FIXME: this is probably an error (eg removed plugin)
return get_with_context_result(None, plugin_load_context)
fq_name = plugin_load_context.resolved_fqcn
- if '.' not in fq_name:
+ if '.' not in fq_name and plugin_load_context.plugin_resolved_collection:
fq_name = '.'.join((plugin_load_context.plugin_resolved_collection, fq_name))
- name = plugin_load_context.plugin_resolved_name
+ resolved_type_name = plugin_load_context.plugin_resolved_name
path = plugin_load_context.plugin_resolved_path
+ if (cached_result := (self._plugin_instance_cache or {}).get(fq_name)) and cached_result[1].resolved:
+ # This is unused by vars plugins, but it's here in case the instance cache expands to other plugin types.
+ # We get here if we've seen this plugin before, but it wasn't called with the resolved FQCN.
+ return get_with_context_result(*cached_result)
redirected_names = plugin_load_context.redirect_list or []
if path not in self._module_cache:
- self._module_cache[path] = self._load_module_source(name, path)
+ self._module_cache[path] = self._load_module_source(resolved_type_name, path)
found_in_cache = False
- self._load_config_defs(name, self._module_cache[path], path)
+ self._load_config_defs(resolved_type_name, self._module_cache[path], path)
obj = getattr(self._module_cache[path], self.class_name)
@@ -897,24 +925,29 @@ class PluginLoader:
return get_with_context_result(None, plugin_load_context)
# FIXME: update this to use the load context
- self._display_plugin_load(self.class_name, name, self._searched_paths, path, found_in_cache=found_in_cache, class_only=class_only)
+ self._display_plugin_load(self.class_name, resolved_type_name, self._searched_paths, path, found_in_cache=found_in_cache, class_only=class_only)
if not class_only:
try:
# A plugin may need to use its _load_name in __init__ (for example, to set
# or get options from config), so update the object before using the constructor
instance = object.__new__(obj)
- self._update_object(instance, name, path, redirected_names, fq_name)
+ self._update_object(instance, resolved_type_name, path, redirected_names, fq_name)
obj.__init__(instance, *args, **kwargs) # pylint: disable=unnecessary-dunder-call
obj = instance
except TypeError as e:
if "abstract" in e.args[0]:
# Abstract Base Class or incomplete plugin, don't load
- display.v('Returning not found on "%s" as it has unimplemented abstract methods; %s' % (name, to_native(e)))
+ display.v('Returning not found on "%s" as it has unimplemented abstract methods; %s' % (resolved_type_name, to_native(e)))
return get_with_context_result(None, plugin_load_context)
raise
- self._update_object(obj, name, path, redirected_names, fq_name)
+ self._update_object(obj, resolved_type_name, path, redirected_names, fq_name)
+ if self._plugin_instance_cache is not None and getattr(obj, 'is_stateless', False):
+ self._plugin_instance_cache[fq_name] = (obj, plugin_load_context)
+ elif self._plugin_instance_cache is not None:
+ # The cache doubles as the load order, so record the FQCN even if the plugin hasn't set is_stateless = True
+ self._plugin_instance_cache[fq_name] = (None, PluginLoadContext())
return get_with_context_result(obj, plugin_load_context)
def _display_plugin_load(self, class_name, name, searched_paths, path, found_in_cache=None, class_only=None):
@@ -984,28 +1017,47 @@ class PluginLoader:
loaded_modules = set()
for path in all_matches:
+
name = os.path.splitext(path)[0]
basename = os.path.basename(name)
+ is_j2 = isinstance(self, Jinja2Loader)
- if basename in _PLUGIN_FILTERS[self.package]:
+ if is_j2:
+ ref_name = path
+ else:
+ ref_name = basename
+
+ if not is_j2 and basename in _PLUGIN_FILTERS[self.package]:
+ # j2 plugins get processed in own class, here they would just be container files
display.debug("'%s' skipped due to a defined plugin filter" % basename)
continue
if basename == '__init__' or (basename == 'base' and self.package == 'ansible.plugins.cache'):
# cache has legacy 'base.py' file, which is wrapper for __init__.py
- display.debug("'%s' skipped due to reserved name" % basename)
+ display.debug("'%s' skipped due to reserved name" % name)
continue
- if dedupe and basename in loaded_modules:
- display.debug("'%s' skipped as duplicate" % basename)
+ if dedupe and ref_name in loaded_modules:
+ # for j2 this is 'same file', other plugins it is basename
+ display.debug("'%s' skipped as duplicate" % ref_name)
continue
- loaded_modules.add(basename)
+ loaded_modules.add(ref_name)
if path_only:
yield path
continue
+ if path in legacy_excluding_builtin:
+ fqcn = basename
+ else:
+ fqcn = f"ansible.builtin.{basename}"
+
+ if (cached_result := (self._plugin_instance_cache or {}).get(fqcn)) and cached_result[1].resolved:
+ # Here just in case, but we don't call all() multiple times for vars plugins, so this should not be used.
+ yield cached_result[0]
+ continue
+
if path not in self._module_cache:
if self.type in ('filter', 'test'):
# filter and test plugin files can contain multiple plugins
@@ -1053,11 +1105,20 @@ class PluginLoader:
except TypeError as e:
display.warning("Skipping plugin (%s) as it seems to be incomplete: %s" % (path, to_text(e)))
- if path in legacy_excluding_builtin:
- fqcn = basename
- else:
- fqcn = f"ansible.builtin.{basename}"
self._update_object(obj, basename, path, resolved=fqcn)
+
+ if self._plugin_instance_cache is not None:
+ needs_enabled = False
+ if hasattr(obj, 'REQUIRES_ENABLED'):
+ needs_enabled = obj.REQUIRES_ENABLED
+ elif hasattr(obj, 'REQUIRES_WHITELIST'):
+ needs_enabled = obj.REQUIRES_WHITELIST
+ display.deprecated("The VarsModule class variable 'REQUIRES_WHITELIST' is deprecated. "
+ "Use 'REQUIRES_ENABLED' instead.", version=2.18)
+ if not needs_enabled:
+ # Use get_with_context to cache the plugin the first time we see it.
+ self.get_with_context(fqcn)[0]
+
yield obj
@@ -1333,7 +1394,7 @@ def get_fqcr_and_name(resource, collection='ansible.builtin'):
def _load_plugin_filter():
- filters = defaultdict(frozenset)
+ filters = _PLUGIN_FILTERS
user_set = False
if C.PLUGIN_FILTERS_CFG is None:
filter_cfg = '/etc/ansible/plugin_filters.yml'
@@ -1361,15 +1422,21 @@ def _load_plugin_filter():
version = to_text(version)
version = version.strip()
+ # Modules and action plugins share the same reject list since the difference between the
+ # two isn't visible to the users
if version == u'1.0':
- # Modules and action plugins share the same blacklist since the difference between the
- # two isn't visible to the users
+
+ if 'module_blacklist' in filter_data:
+ display.deprecated("'module_blacklist' is being removed in favor of 'module_rejectlist'", version='2.18')
+ if 'module_rejectlist' not in filter_data:
+ filter_data['module_rejectlist'] = filter_data['module_blacklist']
+ del filter_data['module_blacklist']
+
try:
- # reject list was documented but we never changed the code from blacklist, will be deprected in 2.15
- filters['ansible.modules'] = frozenset(filter_data.get('module_rejectlist)', filter_data['module_blacklist']))
+ filters['ansible.modules'] = frozenset(filter_data['module_rejectlist'])
except TypeError:
display.warning(u'Unable to parse the plugin filter file {0} as'
- u' module_blacklist is not a list.'
+ u' module_rejectlist is not a list.'
u' Skipping.'.format(filter_cfg))
return filters
filters['ansible.plugins.action'] = filters['ansible.modules']
@@ -1381,11 +1448,11 @@ def _load_plugin_filter():
display.warning(u'The plugin filter file, {0} does not exist.'
u' Skipping.'.format(filter_cfg))
- # Specialcase the stat module as Ansible can run very few things if stat is blacklisted.
+ # Specialcase the stat module as Ansible can run very few things if stat is rejected
if 'stat' in filters['ansible.modules']:
- raise AnsibleError('The stat module was specified in the module blacklist file, {0}, but'
+ raise AnsibleError('The stat module was specified in the module reject list file, {0}, but'
' Ansible will not function without the stat module. Please remove stat'
- ' from the blacklist.'.format(to_native(filter_cfg)))
+ ' from the reject list.'.format(to_native(filter_cfg)))
return filters
@@ -1425,25 +1492,38 @@ def _does_collection_support_ansible_version(requirement_string, ansible_version
return ss.contains(base_ansible_version)
-def _configure_collection_loader():
+def _configure_collection_loader(prefix_collections_path=None):
if AnsibleCollectionConfig.collection_finder:
# this must be a Python warning so that it can be filtered out by the import sanity test
warnings.warn('AnsibleCollectionFinder has already been configured')
return
- finder = _AnsibleCollectionFinder(C.COLLECTIONS_PATHS, C.COLLECTIONS_SCAN_SYS_PATH)
+ if prefix_collections_path is None:
+ prefix_collections_path = []
+
+ paths = list(prefix_collections_path) + C.COLLECTIONS_PATHS
+ finder = _AnsibleCollectionFinder(paths, C.COLLECTIONS_SCAN_SYS_PATH)
finder._install()
# this should succeed now
AnsibleCollectionConfig.on_collection_load += _on_collection_load_handler
-# 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.
+def init_plugin_loader(prefix_collections_path=None):
+ """Initialize the plugin filters and the collection loaders
+
+ This method must be called to configure and insert the collection python loaders
+ into ``sys.meta_path`` and ``sys.path_hooks``.
+
+ This method is only called in ``CLI.run`` after CLI args have been parsed, so that
+ instantiation of the collection finder can utilize parsed CLI args, and to not cause
+ side effects.
+ """
+ _load_plugin_filter()
+ _configure_collection_loader(prefix_collections_path)
-_PLUGIN_FILTERS = _load_plugin_filter()
-_configure_collection_loader()
+# TODO: Evaluate making these class instantiations lazy, but keep them in the global scope
# doc fragments first
fragment_loader = PluginLoader(