diff options
Diffstat (limited to 'lib/ansible/plugins/inventory')
-rw-r--r-- | lib/ansible/plugins/inventory/__init__.py | 463 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/advanced_host_list.py | 63 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/auto.py | 63 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/constructed.py | 177 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/generator.py | 135 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/host_list.py | 66 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/ini.py | 393 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/script.py | 196 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/toml.py | 298 | ||||
-rw-r--r-- | lib/ansible/plugins/inventory/yaml.py | 183 |
10 files changed, 2037 insertions, 0 deletions
diff --git a/lib/ansible/plugins/inventory/__init__.py b/lib/ansible/plugins/inventory/__init__.py new file mode 100644 index 0000000..c0b4264 --- /dev/null +++ b/lib/ansible/plugins/inventory/__init__.py @@ -0,0 +1,463 @@ +# (c) 2017, Red Hat, inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <https://www.gnu.org/licenses/>. + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import hashlib +import os +import string + +from collections.abc import Mapping + +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.inventory.group import to_safe_group_name as original_safe +from ansible.parsing.utils.addresses import parse_address +from ansible.plugins import AnsiblePlugin +from ansible.plugins.cache import CachePluginAdjudicator as CacheObject +from ansible.module_utils._text import to_bytes, to_native +from ansible.module_utils.parsing.convert_bool import boolean +from ansible.module_utils.six import string_types +from ansible.template import Templar +from ansible.utils.display import Display +from ansible.utils.vars import combine_vars, load_extra_vars + +display = Display() + + +# Helper methods +def to_safe_group_name(name): + # placeholder for backwards compat + return original_safe(name, force=True, silent=True) + + +def detect_range(line=None): + ''' + A helper function that checks a given host line to see if it contains + a range pattern described in the docstring above. + + Returns True if the given line contains a pattern, else False. + ''' + return '[' in line + + +def expand_hostname_range(line=None): + ''' + A helper function that expands a given line that contains a pattern + specified in top docstring, and returns a list that consists of the + expanded version. + + The '[' and ']' characters are used to maintain the pseudo-code + appearance. They are replaced in this function with '|' to ease + string splitting. + + References: https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html#hosts-and-groups + ''' + all_hosts = [] + if line: + # A hostname such as db[1:6]-node is considered to consists + # three parts: + # head: 'db' + # nrange: [1:6]; range() is a built-in. Can't use the name + # tail: '-node' + + # Add support for multiple ranges in a host so: + # db[01:10:3]node-[01:10] + # - to do this we split off at the first [...] set, getting the list + # of hosts and then repeat until none left. + # - also add an optional third parameter which contains the step. (Default: 1) + # so range can be [01:10:2] -> 01 03 05 07 09 + + (head, nrange, tail) = line.replace('[', '|', 1).replace(']', '|', 1).split('|') + bounds = nrange.split(":") + if len(bounds) != 2 and len(bounds) != 3: + raise AnsibleError("host range must be begin:end or begin:end:step") + beg = bounds[0] + end = bounds[1] + if len(bounds) == 2: + step = 1 + else: + step = bounds[2] + if not beg: + beg = "0" + if not end: + raise AnsibleError("host range must specify end value") + if beg[0] == '0' and len(beg) > 1: + rlen = len(beg) # range length formatting hint + if rlen != len(end): + raise AnsibleError("host range must specify equal-length begin and end formats") + + def fill(x): + return str(x).zfill(rlen) # range sequence + + else: + fill = str + + try: + i_beg = string.ascii_letters.index(beg) + i_end = string.ascii_letters.index(end) + if i_beg > i_end: + raise AnsibleError("host range must have begin <= end") + seq = list(string.ascii_letters[i_beg:i_end + 1:int(step)]) + except ValueError: # not an alpha range + seq = range(int(beg), int(end) + 1, int(step)) + + for rseq in seq: + hname = ''.join((head, fill(rseq), tail)) + + if detect_range(hname): + all_hosts.extend(expand_hostname_range(hname)) + else: + all_hosts.append(hname) + + return all_hosts + + +def get_cache_plugin(plugin_name, **kwargs): + try: + cache = CacheObject(plugin_name, **kwargs) + except AnsibleError as e: + if 'fact_caching_connection' in to_native(e): + raise AnsibleError("error, '%s' inventory cache plugin requires the one of the following to be set " + "to a writeable directory path:\nansible.cfg:\n[default]: fact_caching_connection,\n" + "[inventory]: cache_connection;\nEnvironment:\nANSIBLE_INVENTORY_CACHE_CONNECTION,\n" + "ANSIBLE_CACHE_PLUGIN_CONNECTION." % plugin_name) + else: + raise e + + if plugin_name != 'memory' and kwargs and not getattr(cache._plugin, '_options', None): + raise AnsibleError('Unable to use cache plugin {0} for inventory. Cache options were provided but may not reconcile ' + 'correctly unless set via set_options. Refer to the porting guide if the plugin derives user settings ' + 'from ansible.constants.'.format(plugin_name)) + return cache + + +class BaseInventoryPlugin(AnsiblePlugin): + """ Parses an Inventory Source""" + + TYPE = 'generator' + + # 3rd party plugins redefine this to + # use custom group name sanitization + # since constructed features enforce + # it by default. + _sanitize_group_name = staticmethod(to_safe_group_name) + + def __init__(self): + + super(BaseInventoryPlugin, self).__init__() + + self._options = {} + self.inventory = None + self.display = display + self._vars = {} + + def parse(self, inventory, loader, path, cache=True): + ''' Populates inventory from the given data. Raises an error on any parse failure + :arg inventory: a copy of the previously accumulated inventory data, + to be updated with any new data this plugin provides. + The inventory can be empty if no other source/plugin ran successfully. + :arg loader: a reference to the DataLoader, which can read in YAML and JSON files, + it also has Vault support to automatically decrypt files. + :arg path: the string that represents the 'inventory source', + normally a path to a configuration file for this inventory, + but it can also be a raw string for this plugin to consume + :arg cache: a boolean that indicates if the plugin should use the cache or not + you can ignore if this plugin does not implement caching. + ''' + + self.loader = loader + self.inventory = inventory + self.templar = Templar(loader=loader) + self._vars = load_extra_vars(loader) + + def verify_file(self, path): + ''' Verify if file is usable by this plugin, base does minimal accessibility check + :arg path: a string that was passed as an inventory source, + it normally is a path to a config file, but this is not a requirement, + it can also be parsed itself as the inventory data to process. + So only call this base class if you expect it to be a file. + ''' + + valid = False + b_path = to_bytes(path, errors='surrogate_or_strict') + if (os.path.exists(b_path) and os.access(b_path, os.R_OK)): + valid = True + else: + self.display.vvv('Skipping due to inventory source not existing or not being readable by the current user') + return valid + + def _populate_host_vars(self, hosts, variables, group=None, port=None): + if not isinstance(variables, Mapping): + raise AnsibleParserError("Invalid data from file, expected dictionary and got:\n\n%s" % to_native(variables)) + + for host in hosts: + self.inventory.add_host(host, group=group, port=port) + for k in variables: + self.inventory.set_variable(host, k, variables[k]) + + def _read_config_data(self, path): + ''' validate config and set options as appropriate + :arg path: path to common yaml format config file for this plugin + ''' + + config = {} + try: + # avoid loader cache so meta: refresh_inventory can pick up config changes + # if we read more than once, fs cache should be good enough + config = self.loader.load_from_file(path, cache=False) + except Exception as e: + raise AnsibleParserError(to_native(e)) + + # a plugin can be loaded via many different names with redirection- if so, we want to accept any of those names + valid_names = getattr(self, '_redirected_names') or [self.NAME] + + if not config: + # no data + raise AnsibleParserError("%s is empty" % (to_native(path))) + elif config.get('plugin') not in valid_names: + # this is not my config file + raise AnsibleParserError("Incorrect plugin name in file: %s" % config.get('plugin', 'none found')) + elif not isinstance(config, Mapping): + # configs are dictionaries + raise AnsibleParserError('inventory source has invalid structure, it should be a dictionary, got: %s' % type(config)) + + self.set_options(direct=config, var_options=self._vars) + if 'cache' in self._options and self.get_option('cache'): + cache_option_keys = [('_uri', 'cache_connection'), ('_timeout', 'cache_timeout'), ('_prefix', 'cache_prefix')] + cache_options = dict((opt[0], self.get_option(opt[1])) for opt in cache_option_keys if self.get_option(opt[1]) is not None) + self._cache = get_cache_plugin(self.get_option('cache_plugin'), **cache_options) + + return config + + def _consume_options(self, data): + ''' update existing options from alternate configuration sources not normally used by Ansible. + Many API libraries already have existing configuration sources, this allows plugin author to leverage them. + :arg data: key/value pairs that correspond to configuration options for this plugin + ''' + + for k in self._options: + if k in data: + self._options[k] = data.pop(k) + + def _expand_hostpattern(self, hostpattern): + ''' + Takes a single host pattern and returns a list of hostnames and an + optional port number that applies to all of them. + ''' + # Can the given hostpattern be parsed as a host with an optional port + # specification? + + try: + (pattern, port) = parse_address(hostpattern, allow_ranges=True) + except Exception: + # not a recognizable host pattern + pattern = hostpattern + port = None + + # Once we have separated the pattern, we expand it into list of one or + # more hostnames, depending on whether it contains any [x:y] ranges. + + if detect_range(pattern): + hostnames = expand_hostname_range(pattern) + else: + hostnames = [pattern] + + return (hostnames, port) + + +class BaseFileInventoryPlugin(BaseInventoryPlugin): + """ Parses a File based Inventory Source""" + + TYPE = 'storage' + + def __init__(self): + + super(BaseFileInventoryPlugin, self).__init__() + + +class Cacheable(object): + + _cache = CacheObject() + + @property + def cache(self): + return self._cache + + def load_cache_plugin(self): + plugin_name = self.get_option('cache_plugin') + cache_option_keys = [('_uri', 'cache_connection'), ('_timeout', 'cache_timeout'), ('_prefix', 'cache_prefix')] + cache_options = dict((opt[0], self.get_option(opt[1])) for opt in cache_option_keys if self.get_option(opt[1]) is not None) + self._cache = get_cache_plugin(plugin_name, **cache_options) + + def get_cache_key(self, path): + return "{0}_{1}".format(self.NAME, self._get_cache_prefix(path)) + + def _get_cache_prefix(self, path): + ''' create predictable unique prefix for plugin/inventory ''' + + m = hashlib.sha1() + m.update(to_bytes(self.NAME, errors='surrogate_or_strict')) + d1 = m.hexdigest() + + n = hashlib.sha1() + n.update(to_bytes(path, errors='surrogate_or_strict')) + d2 = n.hexdigest() + + return 's_'.join([d1[:5], d2[:5]]) + + def clear_cache(self): + self._cache.flush() + + def update_cache_if_changed(self): + self._cache.update_cache_if_changed() + + def set_cache_plugin(self): + self._cache.set_cache() + + +class Constructable(object): + + def _compose(self, template, variables, disable_lookups=True): + ''' helper method for plugins to compose variables for Ansible based on jinja2 expression and inventory vars''' + t = self.templar + + try: + use_extra = self.get_option('use_extra_vars') + except Exception: + use_extra = False + + if use_extra: + t.available_variables = combine_vars(variables, self._vars) + else: + t.available_variables = variables + + return t.template('%s%s%s' % (t.environment.variable_start_string, template, t.environment.variable_end_string), + disable_lookups=disable_lookups) + + def _set_composite_vars(self, compose, variables, host, strict=False): + ''' loops over compose entries to create vars for hosts ''' + if compose and isinstance(compose, dict): + for varname in compose: + try: + composite = self._compose(compose[varname], variables) + except Exception as e: + if strict: + raise AnsibleError("Could not set %s for host %s: %s" % (varname, host, to_native(e))) + continue + self.inventory.set_variable(host, varname, composite) + + def _add_host_to_composed_groups(self, groups, variables, host, strict=False, fetch_hostvars=True): + ''' helper to create complex groups for plugins based on jinja2 conditionals, hosts that meet the conditional are added to group''' + # process each 'group entry' + if groups and isinstance(groups, dict): + if fetch_hostvars: + variables = combine_vars(variables, self.inventory.get_host(host).get_vars()) + self.templar.available_variables = variables + for group_name in groups: + conditional = "{%% if %s %%} True {%% else %%} False {%% endif %%}" % groups[group_name] + group_name = self._sanitize_group_name(group_name) + try: + result = boolean(self.templar.template(conditional)) + except Exception as e: + if strict: + raise AnsibleParserError("Could not add host %s to group %s: %s" % (host, group_name, to_native(e))) + continue + + if result: + # ensure group exists, use sanitized name + group_name = self.inventory.add_group(group_name) + # add host to group + self.inventory.add_child(group_name, host) + + def _add_host_to_keyed_groups(self, keys, variables, host, strict=False, fetch_hostvars=True): + ''' helper to create groups for plugins based on variable values and add the corresponding hosts to it''' + if keys and isinstance(keys, list): + for keyed in keys: + if keyed and isinstance(keyed, dict): + + if fetch_hostvars: + variables = combine_vars(variables, self.inventory.get_host(host).get_vars()) + try: + key = self._compose(keyed.get('key'), variables) + except Exception as e: + if strict: + raise AnsibleParserError("Could not generate group for host %s from %s entry: %s" % (host, keyed.get('key'), to_native(e))) + continue + default_value_name = keyed.get('default_value', None) + trailing_separator = keyed.get('trailing_separator') + if trailing_separator is not None and default_value_name is not None: + raise AnsibleParserError("parameters are mutually exclusive for keyed groups: default_value|trailing_separator") + if key or (key == '' and default_value_name is not None): + prefix = keyed.get('prefix', '') + sep = keyed.get('separator', '_') + raw_parent_name = keyed.get('parent_group', None) + if raw_parent_name: + try: + raw_parent_name = self.templar.template(raw_parent_name) + except AnsibleError as e: + if strict: + raise AnsibleParserError("Could not generate parent group %s for group %s: %s" % (raw_parent_name, key, to_native(e))) + continue + + new_raw_group_names = [] + if isinstance(key, string_types): + # if key is empty, 'default_value' will be used as group name + if key == '' and default_value_name is not None: + new_raw_group_names.append(default_value_name) + else: + new_raw_group_names.append(key) + elif isinstance(key, list): + for name in key: + # if list item is empty, 'default_value' will be used as group name + if name == '' and default_value_name is not None: + new_raw_group_names.append(default_value_name) + else: + new_raw_group_names.append(name) + elif isinstance(key, Mapping): + for (gname, gval) in key.items(): + bare_name = '%s%s%s' % (gname, sep, gval) + if gval == '': + # key's value is empty + if default_value_name is not None: + bare_name = '%s%s%s' % (gname, sep, default_value_name) + elif trailing_separator is False: + bare_name = gname + new_raw_group_names.append(bare_name) + else: + raise AnsibleParserError("Invalid group name format, expected a string or a list of them or dictionary, got: %s" % type(key)) + + for bare_name in new_raw_group_names: + if prefix == '' and self.get_option('leading_separator') is False: + sep = '' + gname = self._sanitize_group_name('%s%s%s' % (prefix, sep, bare_name)) + result_gname = self.inventory.add_group(gname) + self.inventory.add_host(host, result_gname) + + if raw_parent_name: + parent_name = self._sanitize_group_name(raw_parent_name) + self.inventory.add_group(parent_name) + self.inventory.add_child(parent_name, result_gname) + + else: + # exclude case of empty list and dictionary, because these are valid constructions + # simply no groups need to be constructed, but are still falsy + if strict and key not in ([], {}): + raise AnsibleParserError("No key or key resulted empty for %s in host %s, invalid entry" % (keyed.get('key'), host)) + else: + raise AnsibleParserError("Invalid keyed group entry, it must be a dictionary: %s " % keyed) diff --git a/lib/ansible/plugins/inventory/advanced_host_list.py b/lib/ansible/plugins/inventory/advanced_host_list.py new file mode 100644 index 0000000..1b5d868 --- /dev/null +++ b/lib/ansible/plugins/inventory/advanced_host_list.py @@ -0,0 +1,63 @@ +# Copyright (c) 2017 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 = ''' + name: advanced_host_list + version_added: "2.4" + short_description: Parses a 'host list' with ranges + description: + - Parses a host list string as a comma separated values of hosts and supports host ranges. + - This plugin only applies to inventory sources that are not paths and contain at least one comma. +''' + +EXAMPLES = ''' + # simple range + # ansible -i 'host[1:10],' -m ping + + # still supports w/o ranges also + # ansible-playbook -i 'localhost,' play.yml +''' + +import os + +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.module_utils._text import to_bytes, to_native, to_text +from ansible.plugins.inventory import BaseInventoryPlugin + + +class InventoryModule(BaseInventoryPlugin): + + NAME = 'advanced_host_list' + + def verify_file(self, host_list): + + valid = False + b_path = to_bytes(host_list, errors='surrogate_or_strict') + if not os.path.exists(b_path) and ',' in host_list: + valid = True + return valid + + def parse(self, inventory, loader, host_list, cache=True): + ''' parses the inventory file ''' + + super(InventoryModule, self).parse(inventory, loader, host_list) + + try: + for h in host_list.split(','): + h = h.strip() + if h: + try: + (hostnames, port) = self._expand_hostpattern(h) + except AnsibleError as e: + self.display.vvv("Unable to parse address from hostname, leaving unchanged: %s" % to_text(e)) + hostnames = [h] + port = None + + for host in hostnames: + if host not in self.inventory.hosts: + self.inventory.add_host(host, group='ungrouped', port=port) + except Exception as e: + raise AnsibleParserError("Invalid data from string, could not parse: %s" % to_native(e)) diff --git a/lib/ansible/plugins/inventory/auto.py b/lib/ansible/plugins/inventory/auto.py new file mode 100644 index 0000000..45941ca --- /dev/null +++ b/lib/ansible/plugins/inventory/auto.py @@ -0,0 +1,63 @@ +# Copyright (c) 2017 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 = ''' + name: auto + author: + - Matt Davis (@nitzmahone) + version_added: "2.5" + short_description: Loads and executes an inventory plugin specified in a YAML config + description: + - By enabling the C(auto) inventory plugin, any YAML inventory config file with a + C(plugin) key at its root will automatically cause the named plugin to be loaded and executed with that + config. This effectively provides automatic enabling of all installed/accessible inventory plugins. + - To disable this behavior, remove C(auto) from the C(INVENTORY_ENABLED) config element. +''' + +EXAMPLES = ''' +# This plugin is not intended for direct use; it is a fallback mechanism for automatic enabling of +# all installed inventory plugins. +''' + +from ansible.errors import AnsibleParserError +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.plugins.loader import inventory_loader + + +class InventoryModule(BaseInventoryPlugin): + + NAME = 'auto' + + def verify_file(self, path): + if not path.endswith('.yml') and not path.endswith('.yaml'): + return False + return super(InventoryModule, self).verify_file(path) + + def parse(self, inventory, loader, path, cache=True): + config_data = loader.load_from_file(path, cache=False) + + try: + plugin_name = config_data.get('plugin', None) + except AttributeError: + plugin_name = None + + if not plugin_name: + raise AnsibleParserError("no root 'plugin' key found, '{0}' is not a valid YAML inventory plugin config file".format(path)) + + plugin = inventory_loader.get(plugin_name) + + if not plugin: + raise AnsibleParserError("inventory config '{0}' specifies unknown plugin '{1}'".format(path, plugin_name)) + + if not plugin.verify_file(path): + raise AnsibleParserError("inventory source '{0}' could not be verified by inventory plugin '{1}'".format(path, plugin_name)) + + self.display.v("Using inventory plugin '{0}' to process inventory source '{1}'".format(plugin._load_name, path)) + plugin.parse(inventory, loader, path, cache=cache) + try: + plugin.update_cache_if_changed() + except AttributeError: + pass diff --git a/lib/ansible/plugins/inventory/constructed.py b/lib/ansible/plugins/inventory/constructed.py new file mode 100644 index 0000000..dd630c6 --- /dev/null +++ b/lib/ansible/plugins/inventory/constructed.py @@ -0,0 +1,177 @@ +# Copyright (c) 2017 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 = ''' + name: constructed + version_added: "2.4" + short_description: Uses Jinja2 to construct vars and groups based on existing inventory. + description: + - Uses a YAML configuration file with a valid YAML or C(.config) extension to define var expressions and group conditionals + - The Jinja2 conditionals that qualify a host for membership. + - The Jinja2 expressions are calculated and assigned to the variables + - Only variables already available from previous inventories or the fact cache can be used for templating. + - When I(strict) is False, failed expressions will be ignored (assumes vars were missing). + options: + plugin: + description: token that ensures this is a source file for the 'constructed' plugin. + required: True + choices: ['ansible.builtin.constructed', 'constructed'] + use_vars_plugins: + description: + - Normally, for performance reasons, vars plugins get executed after the inventory sources complete the base inventory, + this option allows for getting vars related to hosts/groups from those plugins. + - The host_group_vars (enabled by default) 'vars plugin' is the one responsible for reading host_vars/ and group_vars/ directories. + - This will execute all vars plugins, even those that are not supposed to execute at the 'inventory' stage. + See vars plugins docs for details on 'stage'. + required: false + default: false + type: boolean + version_added: '2.11' + extends_documentation_fragment: + - constructed +''' + +EXAMPLES = r''' + # inventory.config file in YAML format + plugin: ansible.builtin.constructed + strict: False + compose: + var_sum: var1 + var2 + + # this variable will only be set if I have a persistent fact cache enabled (and have non expired facts) + # `strict: False` will skip this instead of producing an error if it is missing facts. + server_type: "ansible_hostname | regex_replace ('(.{6})(.{2}).*', '\\2')" + groups: + # simple name matching + webservers: inventory_hostname.startswith('web') + + # using ec2 'tags' (assumes aws inventory) + development: "'devel' in (ec2_tags|list)" + + # using other host properties populated in inventory + private_only: not (public_dns_name is defined or ip_address is defined) + + # complex group membership + multi_group: (group_names | intersect(['alpha', 'beta', 'omega'])) | length >= 2 + + keyed_groups: + # this creates a group per distro (distro_CentOS, distro_Debian) and assigns the hosts that have matching values to it, + # using the default separator "_" + - prefix: distro + key: ansible_distribution + + # the following examples assume the first inventory is from the `aws_ec2` plugin + # this creates a group per ec2 architecture and assign hosts to the matching ones (arch_x86_64, arch_sparc, etc) + - prefix: arch + key: architecture + + # this creates a group per ec2 region like "us_west_1" + - prefix: "" + separator: "" + key: placement.region + + # this creates a common parent group for all ec2 availability zones + - key: placement.availability_zone + parent_group: all_ec2_zones +''' + +import os + +from ansible import constants as C +from ansible.errors import AnsibleParserError, AnsibleOptionsError +from ansible.inventory.helpers import get_group_vars +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable +from ansible.module_utils._text import to_native +from ansible.utils.vars import combine_vars +from ansible.vars.fact_cache import FactCache +from ansible.vars.plugins import get_vars_from_inventory_sources + + +class InventoryModule(BaseInventoryPlugin, Constructable): + """ constructs groups and vars using Jinja2 template expressions """ + + NAME = 'constructed' + + def __init__(self): + + super(InventoryModule, self).__init__() + + self._cache = FactCache() + + def verify_file(self, path): + + valid = False + if super(InventoryModule, self).verify_file(path): + file_name, ext = os.path.splitext(path) + + if not ext or ext in ['.config'] + C.YAML_FILENAME_EXTENSIONS: + valid = True + + return valid + + def get_all_host_vars(self, host, loader, sources): + ''' requires host object ''' + return combine_vars(self.host_groupvars(host, loader, sources), self.host_vars(host, loader, sources)) + + def host_groupvars(self, host, loader, sources): + ''' requires host object ''' + gvars = get_group_vars(host.get_groups()) + + if self.get_option('use_vars_plugins'): + gvars = combine_vars(gvars, get_vars_from_inventory_sources(loader, sources, host.get_groups(), 'all')) + + return gvars + + def host_vars(self, host, loader, sources): + ''' requires host object ''' + hvars = host.get_vars() + + if self.get_option('use_vars_plugins'): + hvars = combine_vars(hvars, get_vars_from_inventory_sources(loader, sources, [host], 'all')) + + return hvars + + def parse(self, inventory, loader, path, cache=False): + ''' parses the inventory file ''' + + super(InventoryModule, self).parse(inventory, loader, path, cache=cache) + + self._read_config_data(path) + + sources = [] + try: + sources = inventory.processed_sources + except AttributeError: + if self.get_option('use_vars_plugins'): + raise AnsibleOptionsError("The option use_vars_plugins requires ansible >= 2.11.") + + strict = self.get_option('strict') + fact_cache = FactCache() + try: + # Go over hosts (less var copies) + for host in inventory.hosts: + + # get available variables to templar + hostvars = self.get_all_host_vars(inventory.hosts[host], loader, sources) + if host in fact_cache: # adds facts if cache is active + hostvars = combine_vars(hostvars, fact_cache[host]) + + # create composite vars + self._set_composite_vars(self.get_option('compose'), hostvars, host, strict=strict) + + # refetch host vars in case new ones have been created above + hostvars = self.get_all_host_vars(inventory.hosts[host], loader, sources) + if host in self._cache: # adds facts if cache is active + hostvars = combine_vars(hostvars, self._cache[host]) + + # constructed groups based on conditionals + self._add_host_to_composed_groups(self.get_option('groups'), hostvars, host, strict=strict, fetch_hostvars=False) + + # constructed groups based variable values + self._add_host_to_keyed_groups(self.get_option('keyed_groups'), hostvars, host, strict=strict, fetch_hostvars=False) + + except Exception as e: + raise AnsibleParserError("failed to parse %s: %s " % (to_native(path), to_native(e)), orig_exc=e) diff --git a/lib/ansible/plugins/inventory/generator.py b/lib/ansible/plugins/inventory/generator.py new file mode 100644 index 0000000..1955f36 --- /dev/null +++ b/lib/ansible/plugins/inventory/generator.py @@ -0,0 +1,135 @@ +# Copyright (c) 2017 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 = ''' + name: generator + version_added: "2.6" + short_description: Uses Jinja2 to construct hosts and groups from patterns + description: + - Uses a YAML configuration file with a valid YAML or C(.config) extension to define var expressions and group conditionals + - Create a template pattern that describes each host, and then use independent configuration layers + - Every element of every layer is combined to create a host for every layer combination + - Parent groups can be defined with reference to hosts and other groups using the same template variables + options: + plugin: + description: token that ensures this is a source file for the 'generator' plugin. + required: True + choices: ['ansible.builtin.generator', 'generator'] + hosts: + description: + - The C(name) key is a template used to generate + hostnames based on the C(layers) option. Each variable in the name is expanded to create a + cartesian product of all possible layer combinations. + - The C(parents) are a list of parent groups that the host belongs to. Each C(parent) item + contains a C(name) key, again expanded from the template, and an optional C(parents) key + that lists its parents. + - Parents can also contain C(vars), which is a dictionary of vars that + is then always set for that variable. This can provide easy access to the group name. E.g + set an C(application) variable that is set to the value of the C(application) layer name. + layers: + description: + - A dictionary of layers, with the key being the layer name, used as a variable name in the C(host) + C(name) and C(parents) keys. Each layer value is a list of possible values for that layer. +''' + +EXAMPLES = ''' + # inventory.config file in YAML format + # remember to enable this inventory plugin in the ansible.cfg before using + # View the output using `ansible-inventory -i inventory.config --list` + plugin: ansible.builtin.generator + hosts: + name: "{{ operation }}_{{ application }}_{{ environment }}_runner" + parents: + - name: "{{ operation }}_{{ application }}_{{ environment }}" + parents: + - name: "{{ operation }}_{{ application }}" + parents: + - name: "{{ operation }}" + - name: "{{ application }}" + - name: "{{ application }}_{{ environment }}" + parents: + - name: "{{ application }}" + vars: + application: "{{ application }}" + - name: "{{ environment }}" + vars: + environment: "{{ environment }}" + - name: runner + layers: + operation: + - build + - launch + environment: + - dev + - test + - prod + application: + - web + - api +''' + +import os + +from itertools import product + +from ansible import constants as C +from ansible.errors import AnsibleParserError +from ansible.plugins.inventory import BaseInventoryPlugin + + +class InventoryModule(BaseInventoryPlugin): + """ constructs groups and vars using Jinja2 template expressions """ + + NAME = 'generator' + + def __init__(self): + + super(InventoryModule, self).__init__() + + def verify_file(self, path): + + valid = False + if super(InventoryModule, self).verify_file(path): + file_name, ext = os.path.splitext(path) + + if not ext or ext in ['.config'] + C.YAML_FILENAME_EXTENSIONS: + valid = True + + return valid + + def template(self, pattern, variables): + self.templar.available_variables = variables + return self.templar.do_template(pattern) + + def add_parents(self, inventory, child, parents, template_vars): + for parent in parents: + try: + groupname = self.template(parent['name'], template_vars) + except (AttributeError, ValueError): + raise AnsibleParserError("Element %s has a parent with no name element" % child['name']) + if groupname not in inventory.groups: + inventory.add_group(groupname) + group = inventory.groups[groupname] + for (k, v) in parent.get('vars', {}).items(): + group.set_variable(k, self.template(v, template_vars)) + inventory.add_child(groupname, child) + self.add_parents(inventory, groupname, parent.get('parents', []), template_vars) + + def parse(self, inventory, loader, path, cache=False): + ''' parses the inventory file ''' + + super(InventoryModule, self).parse(inventory, loader, path, cache=cache) + + config = self._read_config_data(path) + + template_inputs = product(*config['layers'].values()) + for item in template_inputs: + template_vars = dict() + for i, key in enumerate(config['layers'].keys()): + template_vars[key] = item[i] + host = self.template(config['hosts']['name'], template_vars) + inventory.add_host(host) + self.add_parents(inventory, host, config['hosts'].get('parents', []), template_vars) diff --git a/lib/ansible/plugins/inventory/host_list.py b/lib/ansible/plugins/inventory/host_list.py new file mode 100644 index 0000000..eee8516 --- /dev/null +++ b/lib/ansible/plugins/inventory/host_list.py @@ -0,0 +1,66 @@ +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' + name: host_list + version_added: "2.4" + short_description: Parses a 'host list' string + description: + - Parses a host list string as a comma separated values of hosts + - This plugin only applies to inventory strings that are not paths and contain a comma. +''' + +EXAMPLES = r''' + # define 2 hosts in command line + # ansible -i '10.10.2.6, 10.10.2.4' -m ping all + + # DNS resolvable names + # ansible -i 'host1.example.com, host2' -m user -a 'name=me state=absent' all + + # just use localhost + # ansible-playbook -i 'localhost,' play.yml -c local +''' + +import os + +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.module_utils._text import to_bytes, to_native, to_text +from ansible.parsing.utils.addresses import parse_address +from ansible.plugins.inventory import BaseInventoryPlugin + + +class InventoryModule(BaseInventoryPlugin): + + NAME = 'host_list' + + def verify_file(self, host_list): + + valid = False + b_path = to_bytes(host_list, errors='surrogate_or_strict') + if not os.path.exists(b_path) and ',' in host_list: + valid = True + return valid + + def parse(self, inventory, loader, host_list, cache=True): + ''' parses the inventory file ''' + + super(InventoryModule, self).parse(inventory, loader, host_list) + + try: + for h in host_list.split(','): + h = h.strip() + if h: + try: + (host, port) = parse_address(h, allow_ranges=False) + except AnsibleError as e: + self.display.vvv("Unable to parse address from hostname, leaving unchanged: %s" % to_text(e)) + host = h + port = None + + if host not in self.inventory.hosts: + self.inventory.add_host(host, group='ungrouped', port=port) + except Exception as e: + raise AnsibleParserError("Invalid data from string, could not parse: %s" % to_native(e)) diff --git a/lib/ansible/plugins/inventory/ini.py b/lib/ansible/plugins/inventory/ini.py new file mode 100644 index 0000000..b9955cd --- /dev/null +++ b/lib/ansible/plugins/inventory/ini.py @@ -0,0 +1,393 @@ +# Copyright (c) 2017 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 = ''' + name: ini + version_added: "2.4" + short_description: Uses an Ansible INI file as inventory source. + description: + - INI file based inventory, sections are groups or group related with special C(:modifiers). + - Entries in sections C([group_1]) are hosts, members of the group. + - Hosts can have variables defined inline as key/value pairs separated by C(=). + - The C(children) modifier indicates that the section contains groups. + - The C(vars) modifier indicates that the section contains variables assigned to members of the group. + - Anything found outside a section is considered an 'ungrouped' host. + - Values passed in the INI format using the C(key=value) syntax are interpreted differently depending on where they are declared within your inventory. + - When declared inline with the host, INI values are processed by Python's ast.literal_eval function + (U(https://docs.python.org/3/library/ast.html#ast.literal_eval)) and interpreted as Python literal structures + (strings, numbers, tuples, lists, dicts, booleans, None). If you want a number to be treated as a string, you must quote it. + Host lines accept multiple C(key=value) parameters per line. + Therefore they need a way to indicate that a space is part of a value rather than a separator. + - When declared in a C(:vars) section, INI values are interpreted as strings. For example C(var=FALSE) would create a string equal to C(FALSE). + Unlike host lines, C(:vars) sections accept only a single entry per line, so everything after the C(=) must be the value for the entry. + - Do not rely on types set during definition, always make sure you specify type with a filter when needed when consuming the variable. + - See the Examples for proper quoting to prevent changes to variable type. + notes: + - Enabled in configuration by default. + - Consider switching to YAML format for inventory sources to avoid confusion on the actual type of a variable. + The YAML inventory plugin processes variable values consistently and correctly. +''' + +EXAMPLES = '''# fmt: ini +# Example 1 +[web] +host1 +host2 ansible_port=222 # defined inline, interpreted as an integer + +[web:vars] +http_port=8080 # all members of 'web' will inherit these +myvar=23 # defined in a :vars section, interpreted as a string + +[web:children] # child groups will automatically add their hosts to parent group +apache +nginx + +[apache] +tomcat1 +tomcat2 myvar=34 # host specific vars override group vars +tomcat3 mysecret="'03#pa33w0rd'" # proper quoting to prevent value changes + +[nginx] +jenkins1 + +[nginx:vars] +has_java = True # vars in child groups override same in parent + +[all:vars] +has_java = False # 'all' is 'top' parent + +# Example 2 +host1 # this is 'ungrouped' + +# both hosts have same IP but diff ports, also 'ungrouped' +host2 ansible_host=127.0.0.1 ansible_port=44 +host3 ansible_host=127.0.0.1 ansible_port=45 + +[g1] +host4 + +[g2] +host4 # same host as above, but member of 2 groups, will inherit vars from both + # inventory hostnames are unique +''' + +import ast +import re + +from ansible.inventory.group import to_safe_group_name +from ansible.plugins.inventory import BaseFileInventoryPlugin + +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.module_utils._text import to_bytes, to_text +from ansible.utils.shlex import shlex_split + + +class InventoryModule(BaseFileInventoryPlugin): + """ + Takes an INI-format inventory file and builds a list of groups and subgroups + with their associated hosts and variable settings. + """ + NAME = 'ini' + _COMMENT_MARKERS = frozenset((u';', u'#')) + b_COMMENT_MARKERS = frozenset((b';', b'#')) + + def __init__(self): + + super(InventoryModule, self).__init__() + + self.patterns = {} + self._filename = None + + def parse(self, inventory, loader, path, cache=True): + + super(InventoryModule, self).parse(inventory, loader, path) + + self._filename = path + + try: + # Read in the hosts, groups, and variables defined in the inventory file. + if self.loader: + (b_data, private) = self.loader._get_file_contents(path) + else: + b_path = to_bytes(path, errors='surrogate_or_strict') + with open(b_path, 'rb') as fh: + b_data = fh.read() + + try: + # Faster to do to_text once on a long string than many + # times on smaller strings + data = to_text(b_data, errors='surrogate_or_strict').splitlines() + except UnicodeError: + # Handle non-utf8 in comment lines: https://github.com/ansible/ansible/issues/17593 + data = [] + for line in b_data.splitlines(): + if line and line[0] in self.b_COMMENT_MARKERS: + # Replace is okay for comment lines + # data.append(to_text(line, errors='surrogate_then_replace')) + # Currently we only need these lines for accurate lineno in errors + data.append(u'') + else: + # Non-comment lines still have to be valid uf-8 + data.append(to_text(line, errors='surrogate_or_strict')) + + self._parse(path, data) + except Exception as e: + raise AnsibleParserError(e) + + def _raise_error(self, message): + raise AnsibleError("%s:%d: " % (self._filename, self.lineno) + message) + + def _parse(self, path, lines): + ''' + Populates self.groups from the given array of lines. Raises an error on + any parse failure. + ''' + + self._compile_patterns() + + # We behave as though the first line of the inventory is '[ungrouped]', + # and begin to look for host definitions. We make a single pass through + # each line of the inventory, building up self.groups and adding hosts, + # subgroups, and setting variables as we go. + + pending_declarations = {} + groupname = 'ungrouped' + state = 'hosts' + self.lineno = 0 + for line in lines: + self.lineno += 1 + + line = line.strip() + # Skip empty lines and comments + if not line or line[0] in self._COMMENT_MARKERS: + continue + + # Is this a [section] header? That tells us what group we're parsing + # definitions for, and what kind of definitions to expect. + + m = self.patterns['section'].match(line) + if m: + (groupname, state) = m.groups() + + groupname = to_safe_group_name(groupname) + + state = state or 'hosts' + if state not in ['hosts', 'children', 'vars']: + title = ":".join(m.groups()) + self._raise_error("Section [%s] has unknown type: %s" % (title, state)) + + # If we haven't seen this group before, we add a new Group. + if groupname not in self.inventory.groups: + # Either [groupname] or [groupname:children] is sufficient to declare a group, + # but [groupname:vars] is allowed only if the # group is declared elsewhere. + # We add the group anyway, but make a note in pending_declarations to check at the end. + # + # It's possible that a group is previously pending due to being defined as a child + # group, in that case we simply pass so that the logic below to process pending + # declarations will take the appropriate action for a pending child group instead of + # incorrectly handling it as a var state pending declaration + if state == 'vars' and groupname not in pending_declarations: + pending_declarations[groupname] = dict(line=self.lineno, state=state, name=groupname) + + self.inventory.add_group(groupname) + + # When we see a declaration that we've been waiting for, we process and delete. + if groupname in pending_declarations and state != 'vars': + if pending_declarations[groupname]['state'] == 'children': + self._add_pending_children(groupname, pending_declarations) + elif pending_declarations[groupname]['state'] == 'vars': + del pending_declarations[groupname] + + continue + elif line.startswith('[') and line.endswith(']'): + self._raise_error("Invalid section entry: '%s'. Please make sure that there are no spaces" % line + " " + + "in the section entry, and that there are no other invalid characters") + + # It's not a section, so the current state tells us what kind of + # definition it must be. The individual parsers will raise an + # error if we feed them something they can't digest. + + # [groupname] contains host definitions that must be added to + # the current group. + if state == 'hosts': + hosts, port, variables = self._parse_host_definition(line) + self._populate_host_vars(hosts, variables, groupname, port) + + # [groupname:vars] contains variable definitions that must be + # applied to the current group. + elif state == 'vars': + (k, v) = self._parse_variable_definition(line) + self.inventory.set_variable(groupname, k, v) + + # [groupname:children] contains subgroup names that must be + # added as children of the current group. The subgroup names + # must themselves be declared as groups, but as before, they + # may only be declared later. + elif state == 'children': + child = self._parse_group_name(line) + if child not in self.inventory.groups: + if child not in pending_declarations: + pending_declarations[child] = dict(line=self.lineno, state=state, name=child, parents=[groupname]) + else: + pending_declarations[child]['parents'].append(groupname) + else: + self.inventory.add_child(groupname, child) + else: + # This can happen only if the state checker accepts a state that isn't handled above. + self._raise_error("Entered unhandled state: %s" % (state)) + + # Any entries in pending_declarations not removed by a group declaration above mean that there was an unresolved reference. + # We report only the first such error here. + for g in pending_declarations: + decl = pending_declarations[g] + if decl['state'] == 'vars': + raise AnsibleError("%s:%d: Section [%s:vars] not valid for undefined group: %s" % (path, decl['line'], decl['name'], decl['name'])) + elif decl['state'] == 'children': + raise AnsibleError("%s:%d: Section [%s:children] includes undefined group: %s" % (path, decl['line'], decl['parents'].pop(), decl['name'])) + + def _add_pending_children(self, group, pending): + for parent in pending[group]['parents']: + self.inventory.add_child(parent, group) + if parent in pending and pending[parent]['state'] == 'children': + self._add_pending_children(parent, pending) + del pending[group] + + def _parse_group_name(self, line): + ''' + Takes a single line and tries to parse it as a group name. Returns the + group name if successful, or raises an error. + ''' + + m = self.patterns['groupname'].match(line) + if m: + return m.group(1) + + self._raise_error("Expected group name, got: %s" % (line)) + + def _parse_variable_definition(self, line): + ''' + Takes a string and tries to parse it as a variable definition. Returns + the key and value if successful, or raises an error. + ''' + + # TODO: We parse variable assignments as a key (anything to the left of + # an '='"), an '=', and a value (anything left) and leave the value to + # _parse_value to sort out. We should be more systematic here about + # defining what is acceptable, how quotes work, and so on. + + if '=' in line: + (k, v) = [e.strip() for e in line.split("=", 1)] + return (k, self._parse_value(v)) + + self._raise_error("Expected key=value, got: %s" % (line)) + + def _parse_host_definition(self, line): + ''' + Takes a single line and tries to parse it as a host definition. Returns + a list of Hosts if successful, or raises an error. + ''' + + # A host definition comprises (1) a non-whitespace hostname or range, + # optionally followed by (2) a series of key="some value" assignments. + # We ignore any trailing whitespace and/or comments. For example, here + # are a series of host definitions in a group: + # + # [groupname] + # alpha + # beta:2345 user=admin # we'll tell shlex + # gamma sudo=True user=root # to ignore comments + + try: + tokens = shlex_split(line, comments=True) + except ValueError as e: + self._raise_error("Error parsing host definition '%s': %s" % (line, e)) + + (hostnames, port) = self._expand_hostpattern(tokens[0]) + + # Try to process anything remaining as a series of key=value pairs. + variables = {} + for t in tokens[1:]: + if '=' not in t: + self._raise_error("Expected key=value host variable assignment, got: %s" % (t)) + (k, v) = t.split('=', 1) + variables[k] = self._parse_value(v) + + return hostnames, port, variables + + def _expand_hostpattern(self, hostpattern): + ''' + do some extra checks over normal processing + ''' + # specification? + + hostnames, port = super(InventoryModule, self)._expand_hostpattern(hostpattern) + + if hostpattern.strip().endswith(':') and port is None: + raise AnsibleParserError("Invalid host pattern '%s' supplied, ending in ':' is not allowed, this character is reserved to provide a port." % + hostpattern) + for pattern in hostnames: + # some YAML parsing prevention checks + if pattern.strip() == '---': + raise AnsibleParserError("Invalid host pattern '%s' supplied, '---' is normally a sign this is a YAML file." % hostpattern) + + return (hostnames, port) + + @staticmethod + def _parse_value(v): + ''' + Attempt to transform the string value from an ini file into a basic python object + (int, dict, list, unicode string, etc). + ''' + try: + v = ast.literal_eval(v) + # Using explicit exceptions. + # Likely a string that literal_eval does not like. We wil then just set it. + except ValueError: + # For some reason this was thought to be malformed. + pass + except SyntaxError: + # Is this a hash with an equals at the end? + pass + return to_text(v, nonstring='passthru', errors='surrogate_or_strict') + + def _compile_patterns(self): + ''' + Compiles the regular expressions required to parse the inventory and + stores them in self.patterns. + ''' + + # Section names are square-bracketed expressions at the beginning of a + # line, comprising (1) a group name optionally followed by (2) a tag + # that specifies the contents of the section. We ignore any trailing + # whitespace and/or comments. For example: + # + # [groupname] + # [somegroup:vars] + # [naughty:children] # only get coal in their stockings + + self.patterns['section'] = re.compile( + to_text(r'''^\[ + ([^:\]\s]+) # group name (see groupname below) + (?::(\w+))? # optional : and tag name + \] + \s* # ignore trailing whitespace + (?:\#.*)? # and/or a comment till the + $ # end of the line + ''', errors='surrogate_or_strict'), re.X + ) + + # FIXME: What are the real restrictions on group names, or rather, what + # should they be? At the moment, they must be non-empty sequences of non + # whitespace characters excluding ':' and ']', but we should define more + # precise rules in order to support better diagnostics. + + self.patterns['groupname'] = re.compile( + to_text(r'''^ + ([^:\]\s]+) + \s* # ignore trailing whitespace + (?:\#.*)? # and/or a comment till the + $ # end of the line + ''', errors='surrogate_or_strict'), re.X + ) diff --git a/lib/ansible/plugins/inventory/script.py b/lib/ansible/plugins/inventory/script.py new file mode 100644 index 0000000..4ffd8e1 --- /dev/null +++ b/lib/ansible/plugins/inventory/script.py @@ -0,0 +1,196 @@ +# Copyright (c) 2012-2014, Michael DeHaan <michael.dehaan@gmail.com> +# Copyright (c) 2017 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 = ''' + name: script + version_added: "2.4" + short_description: Executes an inventory script that returns JSON + options: + always_show_stderr: + description: Toggle display of stderr even when script was successful + version_added: "2.5.1" + default: True + type: boolean + ini: + - section: inventory_plugin_script + key: always_show_stderr + env: + - name: ANSIBLE_INVENTORY_PLUGIN_SCRIPT_STDERR + description: + - The source provided must be an executable that returns Ansible inventory JSON + - The source must accept C(--list) and C(--host <hostname>) as arguments. + C(--host) will only be used if no C(_meta) key is present. + This is a performance optimization as the script would be called per host otherwise. + notes: + - Enabled in configuration by default. + - The plugin does not cache results because external inventory scripts are responsible for their own caching. +''' + +import os +import subprocess + +from collections.abc import Mapping + +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.module_utils.basic import json_dict_bytes_to_unicode +from ansible.module_utils._text import to_native, to_text +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.utils.display import Display + +display = Display() + + +class InventoryModule(BaseInventoryPlugin): + ''' Host inventory parser for ansible using external inventory scripts. ''' + + NAME = 'script' + + 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 ''' + + valid = super(InventoryModule, self).verify_file(path) + + if valid: + # not only accessible, file must be executable and/or have shebang + shebang_present = False + try: + with open(path, 'rb') as inv_file: + initial_chars = inv_file.read(2) + if initial_chars.startswith(b'#!'): + shebang_present = True + except Exception: + pass + + if not os.access(path, os.X_OK) and not shebang_present: + valid = False + + return valid + + def parse(self, inventory, loader, path, cache=None): + + super(InventoryModule, self).parse(inventory, loader, path) + self.set_options() + + # Support inventory scripts that are not prefixed with some + # path information but happen to be in the current working + # directory when '.' is not in PATH. + cmd = [path, "--list"] + + try: + try: + sp = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except OSError as e: + raise AnsibleParserError("problem running %s (%s)" % (' '.join(cmd), to_native(e))) + (stdout, stderr) = sp.communicate() + + path = to_native(path) + err = to_native(stderr or "") + + if err and not err.endswith('\n'): + err += '\n' + + if sp.returncode != 0: + raise AnsibleError("Inventory script (%s) had an execution error: %s " % (path, err)) + + # make sure script output is unicode so that json loader will output unicode strings itself + try: + data = to_text(stdout, errors="strict") + except Exception as e: + raise AnsibleError("Inventory {0} contained characters that cannot be interpreted as UTF-8: {1}".format(path, to_native(e))) + + try: + processed = self.loader.load(data, json_only=True) + except Exception as e: + raise AnsibleError("failed to parse executable inventory script results from {0}: {1}\n{2}".format(path, to_native(e), err)) + + # if no other errors happened and you want to force displaying stderr, do so now + if stderr and self.get_option('always_show_stderr'): + self.display.error(msg=to_text(err)) + + if not isinstance(processed, Mapping): + raise AnsibleError("failed to parse executable inventory script results from {0}: needs to be a json dict\n{1}".format(path, err)) + + group = None + data_from_meta = None + + # A "_meta" subelement may contain a variable "hostvars" which contains a hash for each host + # if this "hostvars" exists at all then do not call --host for each # host. + # This is for efficiency and scripts should still return data + # if called with --host for backwards compat with 1.2 and earlier. + for (group, gdata) in processed.items(): + if group == '_meta': + if 'hostvars' in gdata: + data_from_meta = gdata['hostvars'] + else: + self._parse_group(group, gdata) + + for host in self._hosts: + got = {} + if data_from_meta is None: + got = self.get_host_variables(path, host) + else: + try: + got = data_from_meta.get(host, {}) + except AttributeError as e: + raise AnsibleError("Improperly formatted host information for %s: %s" % (host, to_native(e)), orig_exc=e) + + self._populate_host_vars([host], got) + + except Exception as e: + raise AnsibleParserError(to_native(e)) + + def _parse_group(self, group, data): + + group = self.inventory.add_group(group) + + if not isinstance(data, dict): + data = {'hosts': data} + # is not those subkeys, then simplified syntax, host with vars + elif not any(k in data for k in ('hosts', 'vars', 'children')): + data = {'hosts': [group], 'vars': data} + + if 'hosts' in data: + if not isinstance(data['hosts'], list): + raise AnsibleError("You defined a group '%s' with bad data for the host list:\n %s" % (group, data)) + + for hostname in data['hosts']: + self._hosts.add(hostname) + self.inventory.add_host(hostname, group) + + if 'vars' in data: + if not isinstance(data['vars'], dict): + raise AnsibleError("You defined a group '%s' with bad data for variables:\n %s" % (group, data)) + + for k, v in data['vars'].items(): + self.inventory.set_variable(group, k, v) + + if group != '_meta' and isinstance(data, dict) and 'children' in data: + for child_name in data['children']: + child_name = self.inventory.add_group(child_name) + self.inventory.add_child(group, child_name) + + def get_host_variables(self, path, host): + """ Runs <script> --host <hostname>, to determine additional host variables """ + + cmd = [path, "--host", host] + try: + sp = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except OSError as e: + raise AnsibleError("problem running %s (%s)" % (' '.join(cmd), e)) + (out, err) = sp.communicate() + if out.strip() == '': + return {} + try: + return json_dict_bytes_to_unicode(self.loader.load(out, file_name=path)) + except ValueError: + raise AnsibleError("could not parse post variable response: %s, %s" % (cmd, out)) diff --git a/lib/ansible/plugins/inventory/toml.py b/lib/ansible/plugins/inventory/toml.py new file mode 100644 index 0000000..f68b34a --- /dev/null +++ b/lib/ansible/plugins/inventory/toml.py @@ -0,0 +1,298 @@ +# Copyright (c) 2018 Matt Martz <matt@sivel.net> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' + name: toml + version_added: "2.8" + short_description: Uses a specific TOML file as an inventory source. + description: + - TOML based inventory format + - File MUST have a valid '.toml' file extension + notes: + - > + Requires one of the following python libraries: 'toml', 'tomli', or 'tomllib' +''' + +EXAMPLES = r'''# fmt: toml +# Example 1 +[all.vars] +has_java = false + +[web] +children = [ + "apache", + "nginx" +] +vars = { http_port = 8080, myvar = 23 } + +[web.hosts] +host1 = {} +host2 = { ansible_port = 222 } + +[apache.hosts] +tomcat1 = {} +tomcat2 = { myvar = 34 } +tomcat3 = { mysecret = "03#pa33w0rd" } + +[nginx.hosts] +jenkins1 = {} + +[nginx.vars] +has_java = true + +# Example 2 +[all.vars] +has_java = false + +[web] +children = [ + "apache", + "nginx" +] + +[web.vars] +http_port = 8080 +myvar = 23 + +[web.hosts.host1] +[web.hosts.host2] +ansible_port = 222 + +[apache.hosts.tomcat1] + +[apache.hosts.tomcat2] +myvar = 34 + +[apache.hosts.tomcat3] +mysecret = "03#pa33w0rd" + +[nginx.hosts.jenkins1] + +[nginx.vars] +has_java = true + +# Example 3 +[ungrouped.hosts] +host1 = {} +host2 = { ansible_host = "127.0.0.1", ansible_port = 44 } +host3 = { ansible_host = "127.0.0.1", ansible_port = 45 } + +[g1.hosts] +host4 = {} + +[g2.hosts] +host4 = {} +''' + +import os +import typing as t + +from collections.abc import MutableMapping, MutableSequence +from functools import partial + +from ansible.errors import AnsibleFileNotFound, AnsibleParserError, AnsibleRuntimeError +from ansible.module_utils._text import to_bytes, to_native, to_text +from ansible.module_utils.six import string_types, text_type +from ansible.parsing.yaml.objects import AnsibleSequence, AnsibleUnicode +from ansible.plugins.inventory import BaseFileInventoryPlugin +from ansible.utils.display import Display +from ansible.utils.unsafe_proxy import AnsibleUnsafeBytes, AnsibleUnsafeText + +HAS_TOML = False +try: + import toml + HAS_TOML = True +except ImportError: + pass + +HAS_TOMLIW = False +try: + import tomli_w # type: ignore[import] + HAS_TOMLIW = True +except ImportError: + pass + +HAS_TOMLLIB = False +try: + import tomllib # type: ignore[import] + HAS_TOMLLIB = True +except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] + HAS_TOMLLIB = True + except ImportError: + pass + +display = Display() + + +# dumps +if HAS_TOML and hasattr(toml, 'TomlEncoder'): + # toml>=0.10.0 + class AnsibleTomlEncoder(toml.TomlEncoder): + def __init__(self, *args, **kwargs): + super(AnsibleTomlEncoder, self).__init__(*args, **kwargs) + # Map our custom YAML object types to dump_funcs from ``toml`` + self.dump_funcs.update({ + AnsibleSequence: self.dump_funcs.get(list), + AnsibleUnicode: self.dump_funcs.get(str), + AnsibleUnsafeBytes: self.dump_funcs.get(str), + AnsibleUnsafeText: self.dump_funcs.get(str), + }) + toml_dumps = partial(toml.dumps, encoder=AnsibleTomlEncoder()) # type: t.Callable[[t.Any], str] +else: + # toml<0.10.0 + # tomli-w + def toml_dumps(data): # type: (t.Any) -> str + if HAS_TOML: + return toml.dumps(convert_yaml_objects_to_native(data)) + elif HAS_TOMLIW: + return tomli_w.dumps(convert_yaml_objects_to_native(data)) + raise AnsibleRuntimeError( + 'The python "toml" or "tomli-w" library is required when using the TOML output format' + ) + +# loads +if HAS_TOML: + # prefer toml if installed, since it supports both encoding and decoding + toml_loads = toml.loads # type: ignore[assignment] + TOMLDecodeError = toml.TomlDecodeError # type: t.Any +elif HAS_TOMLLIB: + toml_loads = tomllib.loads # type: ignore[assignment] + TOMLDecodeError = tomllib.TOMLDecodeError # type: t.Any # type: ignore[no-redef] + + +def convert_yaml_objects_to_native(obj): + """Older versions of the ``toml`` python library, and tomllib, don't have + a pluggable way to tell the encoder about custom types, so we need to + ensure objects that we pass are native types. + + Used with: + - ``toml<0.10.0`` where ``toml.TomlEncoder`` is missing + - ``tomli`` or ``tomllib`` + + This function recurses an object and ensures we cast any of the types from + ``ansible.parsing.yaml.objects`` into their native types, effectively cleansing + the data before we hand it over to the toml library. + + This function doesn't directly check for the types from ``ansible.parsing.yaml.objects`` + but instead checks for the types those objects inherit from, to offer more flexibility. + """ + if isinstance(obj, dict): + return dict((k, convert_yaml_objects_to_native(v)) for k, v in obj.items()) + elif isinstance(obj, list): + return [convert_yaml_objects_to_native(v) for v in obj] + elif isinstance(obj, text_type): + return text_type(obj) + else: + return obj + + +class InventoryModule(BaseFileInventoryPlugin): + NAME = 'toml' + + def _parse_group(self, group, group_data): + if group_data is not None and not isinstance(group_data, MutableMapping): + self.display.warning("Skipping '%s' as this is not a valid group definition" % group) + return + + group = self.inventory.add_group(group) + if group_data is None: + return + + for key, data in group_data.items(): + if key == 'vars': + if not isinstance(data, MutableMapping): + raise AnsibleParserError( + 'Invalid "vars" entry for "%s" group, requires a dict, found "%s" instead.' % + (group, type(data)) + ) + for var, value in data.items(): + self.inventory.set_variable(group, var, value) + + elif key == 'children': + if not isinstance(data, MutableSequence): + raise AnsibleParserError( + 'Invalid "children" entry for "%s" group, requires a list, found "%s" instead.' % + (group, type(data)) + ) + for subgroup in data: + self._parse_group(subgroup, {}) + self.inventory.add_child(group, subgroup) + + elif key == 'hosts': + if not isinstance(data, MutableMapping): + raise AnsibleParserError( + 'Invalid "hosts" entry for "%s" group, requires a dict, found "%s" instead.' % + (group, type(data)) + ) + for host_pattern, value in data.items(): + hosts, port = self._expand_hostpattern(host_pattern) + self._populate_host_vars(hosts, value, group, port) + else: + self.display.warning( + 'Skipping unexpected key "%s" in group "%s", only "vars", "children" and "hosts" are valid' % + (key, group) + ) + + def _load_file(self, file_name): + if not file_name or not isinstance(file_name, string_types): + raise AnsibleParserError("Invalid filename: '%s'" % to_native(file_name)) + + b_file_name = to_bytes(self.loader.path_dwim(file_name)) + if not self.loader.path_exists(b_file_name): + raise AnsibleFileNotFound("Unable to retrieve file contents", file_name=file_name) + + try: + (b_data, private) = self.loader._get_file_contents(file_name) + return toml_loads(to_text(b_data, errors='surrogate_or_strict')) + except TOMLDecodeError as e: + raise AnsibleParserError( + 'TOML file (%s) is invalid: %s' % (file_name, to_native(e)), + orig_exc=e + ) + except (IOError, OSError) as e: + raise AnsibleParserError( + "An error occurred while trying to read the file '%s': %s" % (file_name, to_native(e)), + orig_exc=e + ) + except Exception as e: + raise AnsibleParserError( + "An unexpected error occurred while parsing the file '%s': %s" % (file_name, to_native(e)), + orig_exc=e + ) + + def parse(self, inventory, loader, path, cache=True): + ''' parses the inventory file ''' + if not HAS_TOMLLIB and not HAS_TOML: + # tomllib works here too, but we don't call it out in the error, + # since you either have it or not as part of cpython stdlib >= 3.11 + raise AnsibleParserError( + 'The TOML inventory plugin requires the python "toml", or "tomli" library' + ) + + super(InventoryModule, self).parse(inventory, loader, path) + self.set_options() + + try: + data = self._load_file(path) + except Exception as e: + raise AnsibleParserError(e) + + if not data: + raise AnsibleParserError('Parsed empty TOML file') + elif data.get('plugin'): + raise AnsibleParserError('Plugin configuration TOML file, not TOML inventory') + + for group_name in data: + self._parse_group(group_name, data[group_name]) + + def verify_file(self, path): + if super(InventoryModule, self).verify_file(path): + file_name, ext = os.path.splitext(path) + if ext == '.toml': + return True + return False diff --git a/lib/ansible/plugins/inventory/yaml.py b/lib/ansible/plugins/inventory/yaml.py new file mode 100644 index 0000000..9d5812f --- /dev/null +++ b/lib/ansible/plugins/inventory/yaml.py @@ -0,0 +1,183 @@ +# Copyright (c) 2017 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 = ''' + name: yaml + version_added: "2.4" + short_description: Uses a specific YAML file as an inventory source. + description: + - "YAML-based inventory, should start with the C(all) group and contain hosts/vars/children entries." + - Host entries can have sub-entries defined, which will be treated as variables. + - Vars entries are normal group vars. + - "Children are 'child groups', which can also have their own vars/hosts/children and so on." + - File MUST have a valid extension, defined in configuration. + notes: + - If you want to set vars for the C(all) group inside the inventory file, the C(all) group must be the first entry in the file. + - Enabled in configuration by default. + options: + yaml_extensions: + description: list of 'valid' extensions for files containing YAML + type: list + elements: string + default: ['.yaml', '.yml', '.json'] + env: + - name: ANSIBLE_YAML_FILENAME_EXT + - name: ANSIBLE_INVENTORY_PLUGIN_EXTS + ini: + - key: yaml_valid_extensions + section: defaults + - section: inventory_plugin_yaml + key: yaml_valid_extensions + +''' +EXAMPLES = ''' +all: # keys must be unique, i.e. only one 'hosts' per group + hosts: + test1: + test2: + host_var: value + vars: + group_all_var: value + children: # key order does not matter, indentation does + other_group: + children: + group_x: + hosts: + test5 # Note that one machine will work without a colon + #group_x: + # hosts: + # test5 # But this won't + # test7 # + group_y: + hosts: + test6: # So always use a colon + vars: + g2_var2: value3 + hosts: + test4: + ansible_host: 127.0.0.1 + last_group: + hosts: + test1 # same host as above, additional group membership + vars: + group_last_var: value +''' + +import os + +from collections.abc import MutableMapping + +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.module_utils.six import string_types +from ansible.module_utils._text import to_native, to_text +from ansible.plugins.inventory import BaseFileInventoryPlugin + +NoneType = type(None) + + +class InventoryModule(BaseFileInventoryPlugin): + + NAME = 'yaml' + + def __init__(self): + + super(InventoryModule, self).__init__() + + def verify_file(self, path): + + valid = False + if super(InventoryModule, self).verify_file(path): + file_name, ext = os.path.splitext(path) + if not ext or ext in self.get_option('yaml_extensions'): + valid = True + return valid + + def parse(self, inventory, loader, path, cache=True): + ''' parses the inventory file ''' + + super(InventoryModule, self).parse(inventory, loader, path) + self.set_options() + + try: + data = self.loader.load_from_file(path, cache=False) + except Exception as e: + raise AnsibleParserError(e) + + if not data: + raise AnsibleParserError('Parsed empty YAML file') + elif not isinstance(data, MutableMapping): + raise AnsibleParserError('YAML inventory has invalid structure, it should be a dictionary, got: %s' % type(data)) + elif data.get('plugin'): + raise AnsibleParserError('Plugin configuration YAML file, not YAML inventory') + + # We expect top level keys to correspond to groups, iterate over them + # to get host, vars and subgroups (which we iterate over recursivelly) + if isinstance(data, MutableMapping): + for group_name in data: + self._parse_group(group_name, data[group_name]) + else: + raise AnsibleParserError("Invalid data from file, expected dictionary and got:\n\n%s" % to_native(data)) + + def _parse_group(self, group, group_data): + + if isinstance(group_data, (MutableMapping, NoneType)): # type: ignore[misc] + + try: + group = self.inventory.add_group(group) + except AnsibleError as e: + raise AnsibleParserError("Unable to add group %s: %s" % (group, to_text(e))) + + if group_data is not None: + # make sure they are dicts + for section in ['vars', 'children', 'hosts']: + if section in group_data: + # convert strings to dicts as these are allowed + if isinstance(group_data[section], string_types): + group_data[section] = {group_data[section]: None} + + if not isinstance(group_data[section], (MutableMapping, NoneType)): # type: ignore[misc] + raise AnsibleParserError('Invalid "%s" entry for "%s" group, requires a dictionary, found "%s" instead.' % + (section, group, type(group_data[section]))) + + for key in group_data: + + if not isinstance(group_data[key], (MutableMapping, NoneType)): # type: ignore[misc] + self.display.warning('Skipping key (%s) in group (%s) as it is not a mapping, it is a %s' % (key, group, type(group_data[key]))) + continue + + if isinstance(group_data[key], NoneType): # type: ignore[misc] + self.display.vvv('Skipping empty key (%s) in group (%s)' % (key, group)) + elif key == 'vars': + for var in group_data[key]: + self.inventory.set_variable(group, var, group_data[key][var]) + elif key == 'children': + for subgroup in group_data[key]: + subgroup = self._parse_group(subgroup, group_data[key][subgroup]) + self.inventory.add_child(group, subgroup) + + elif key == 'hosts': + for host_pattern in group_data[key]: + hosts, port = self._parse_host(host_pattern) + self._populate_host_vars(hosts, group_data[key][host_pattern] or {}, group, port) + else: + self.display.warning('Skipping unexpected key (%s) in group (%s), only "vars", "children" and "hosts" are valid' % (key, group)) + + else: + self.display.warning("Skipping '%s' as this is not a valid group definition" % group) + + return group + + def _parse_host(self, host_pattern): + ''' + Each host key can be a pattern, try to process it and add variables as needed + ''' + try: + (hostnames, port) = self._expand_hostpattern(host_pattern) + except TypeError: + raise AnsibleParserError( + f"Host pattern {host_pattern} must be a string. Enclose integers/floats in quotation marks." + ) + return hostnames, port |