diff options
Diffstat (limited to 'lib/ansible/inventory/manager.py')
-rw-r--r-- | lib/ansible/inventory/manager.py | 752 |
1 files changed, 752 insertions, 0 deletions
diff --git a/lib/ansible/inventory/manager.py b/lib/ansible/inventory/manager.py new file mode 100644 index 0000000..400bc6b --- /dev/null +++ b/lib/ansible/inventory/manager.py @@ -0,0 +1,752 @@ +# (c) 2012-2014, Michael DeHaan <michael.dehaan@gmail.com> +# +# 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 <http://www.gnu.org/licenses/>. + +############################################# +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import fnmatch +import os +import sys +import re +import itertools +import traceback + +from operator import attrgetter +from random import shuffle + +from ansible import constants as C +from ansible.errors import AnsibleError, AnsibleOptionsError, AnsibleParserError +from ansible.inventory.data import InventoryData +from ansible.module_utils.six import string_types +from ansible.module_utils._text import to_bytes, to_text +from ansible.parsing.utils.addresses import parse_address +from ansible.plugins.loader import inventory_loader +from ansible.utils.helpers import deduplicate_list +from ansible.utils.path import unfrackpath +from ansible.utils.display import Display +from ansible.utils.vars import combine_vars +from ansible.vars.plugins import get_vars_from_inventory_sources + +display = Display() + +IGNORED_ALWAYS = [br"^\.", b"^host_vars$", b"^group_vars$", b"^vars_plugins$"] +IGNORED_PATTERNS = [to_bytes(x) for x in C.INVENTORY_IGNORE_PATTERNS] +IGNORED_EXTS = [b'%s$' % to_bytes(re.escape(x)) for x in C.INVENTORY_IGNORE_EXTS] + +IGNORED = re.compile(b'|'.join(IGNORED_ALWAYS + IGNORED_PATTERNS + IGNORED_EXTS)) + +PATTERN_WITH_SUBSCRIPT = re.compile( + r'''^ + (.+) # A pattern expression ending with... + \[(?: # A [subscript] expression comprising: + (-?[0-9]+)| # A single positive or negative number + ([0-9]+)([:-]) # Or an x:y or x: range. + ([0-9]*) + )\] + $ + ''', re.X +) + + +def order_patterns(patterns): + ''' takes a list of patterns and reorders them by modifier to apply them consistently ''' + + # FIXME: this goes away if we apply patterns incrementally or by groups + pattern_regular = [] + pattern_intersection = [] + pattern_exclude = [] + for p in patterns: + if not p: + continue + + if p[0] == "!": + pattern_exclude.append(p) + elif p[0] == "&": + pattern_intersection.append(p) + else: + pattern_regular.append(p) + + # if no regular pattern was given, hence only exclude and/or intersection + # make that magically work + if pattern_regular == []: + pattern_regular = ['all'] + + # when applying the host selectors, run those without the "&" or "!" + # first, then the &s, then the !s. + return pattern_regular + pattern_intersection + pattern_exclude + + +def split_host_pattern(pattern): + """ + Takes a string containing host patterns separated by commas (or a list + thereof) and returns a list of single patterns (which may not contain + commas). Whitespace is ignored. + + Also accepts ':' as a separator for backwards compatibility, but it is + not recommended due to the conflict with IPv6 addresses and host ranges. + + Example: 'a,b[1], c[2:3] , d' -> ['a', 'b[1]', 'c[2:3]', 'd'] + """ + + if isinstance(pattern, list): + results = (split_host_pattern(p) for p in pattern) + # flatten the results + return list(itertools.chain.from_iterable(results)) + elif not isinstance(pattern, string_types): + pattern = to_text(pattern, errors='surrogate_or_strict') + + # If it's got commas in it, we'll treat it as a straightforward + # comma-separated list of patterns. + if u',' in pattern: + patterns = pattern.split(u',') + + # If it doesn't, it could still be a single pattern. This accounts for + # non-separator uses of colons: IPv6 addresses and [x:y] host ranges. + else: + try: + (base, port) = parse_address(pattern, allow_ranges=True) + patterns = [pattern] + except Exception: + # The only other case we accept is a ':'-separated list of patterns. + # This mishandles IPv6 addresses, and is retained only for backwards + # compatibility. + patterns = re.findall( + to_text(r'''(?: # We want to match something comprising: + [^\s:\[\]] # (anything other than whitespace or ':[]' + | # ...or... + \[[^\]]*\] # a single complete bracketed expression) + )+ # occurring once or more + '''), pattern, re.X + ) + + return [p.strip() for p in patterns if p.strip()] + + +class InventoryManager(object): + ''' Creates and manages inventory ''' + + def __init__(self, loader, sources=None, parse=True, cache=True): + + # base objects + self._loader = loader + self._inventory = InventoryData() + + # a list of host(names) to contain current inquiries to + self._restriction = None + self._subset = None + + # caches + self._hosts_patterns_cache = {} # resolved full patterns + self._pattern_cache = {} # resolved individual patterns + + # the inventory dirs, files, script paths or lists of hosts + if sources is None: + self._sources = [] + elif isinstance(sources, string_types): + self._sources = [sources] + else: + self._sources = sources + + # get to work! + if parse: + self.parse_sources(cache=cache) + + self._cached_dynamic_hosts = [] + self._cached_dynamic_grouping = [] + + @property + def localhost(self): + return self._inventory.get_host('localhost') + + @property + def groups(self): + return self._inventory.groups + + @property + def hosts(self): + return self._inventory.hosts + + def add_host(self, host, group=None, port=None): + return self._inventory.add_host(host, group, port) + + def add_group(self, group): + return self._inventory.add_group(group) + + def get_groups_dict(self): + return self._inventory.get_groups_dict() + + def reconcile_inventory(self): + self.clear_caches() + return self._inventory.reconcile_inventory() + + def get_host(self, hostname): + return self._inventory.get_host(hostname) + + def _fetch_inventory_plugins(self): + ''' sets up loaded inventory plugins for usage ''' + + display.vvvv('setting up inventory plugins') + + plugins = [] + for name in C.INVENTORY_ENABLED: + plugin = inventory_loader.get(name) + if plugin: + plugins.append(plugin) + else: + display.warning('Failed to load inventory plugin, skipping %s' % name) + + if not plugins: + raise AnsibleError("No inventory plugins available to generate inventory, make sure you have at least one enabled.") + + return plugins + + def parse_sources(self, cache=False): + ''' iterate over inventory sources and parse each one to populate it''' + + parsed = False + # allow for multiple inventory parsing + for source in self._sources: + + if source: + if ',' not in source: + source = unfrackpath(source, follow=False) + parse = self.parse_source(source, cache=cache) + if parse and not parsed: + parsed = True + + if parsed: + # do post processing + self._inventory.reconcile_inventory() + else: + if C.INVENTORY_UNPARSED_IS_FAILED: + raise AnsibleError("No inventory was parsed, please check your configuration and options.") + elif C.INVENTORY_UNPARSED_WARNING: + display.warning("No inventory was parsed, only implicit localhost is available") + + for group in self.groups.values(): + group.vars = combine_vars(group.vars, get_vars_from_inventory_sources(self._loader, self._sources, [group], 'inventory')) + for host in self.hosts.values(): + host.vars = combine_vars(host.vars, get_vars_from_inventory_sources(self._loader, self._sources, [host], 'inventory')) + + def parse_source(self, source, cache=False): + ''' Generate or update inventory for the source provided ''' + + parsed = False + failures = [] + display.debug(u'Examining possible inventory source: %s' % source) + + # use binary for path functions + b_source = to_bytes(source) + + # process directories as a collection of inventories + if os.path.isdir(b_source): + display.debug(u'Searching for inventory files in directory: %s' % source) + for i in sorted(os.listdir(b_source)): + + display.debug(u'Considering %s' % i) + # Skip hidden files and stuff we explicitly ignore + if IGNORED.search(i): + continue + + # recursively deal with directory entries + fullpath = to_text(os.path.join(b_source, i), errors='surrogate_or_strict') + parsed_this_one = self.parse_source(fullpath, cache=cache) + display.debug(u'parsed %s as %s' % (fullpath, parsed_this_one)) + if not parsed: + parsed = parsed_this_one + else: + # left with strings or files, let plugins figure it out + + # set so new hosts can use for inventory_file/dir vars + self._inventory.current_source = source + + # try source with each plugin + for plugin in self._fetch_inventory_plugins(): + + plugin_name = to_text(getattr(plugin, '_load_name', getattr(plugin, '_original_path', ''))) + display.debug(u'Attempting to use plugin %s (%s)' % (plugin_name, plugin._original_path)) + + # initialize and figure out if plugin wants to attempt parsing this file + try: + plugin_wants = bool(plugin.verify_file(source)) + except Exception: + plugin_wants = False + + if plugin_wants: + try: + # FIXME in case plugin fails 1/2 way we have partial inventory + plugin.parse(self._inventory, self._loader, source, cache=cache) + try: + plugin.update_cache_if_changed() + except AttributeError: + # some plugins might not implement caching + pass + parsed = True + display.vvv('Parsed %s inventory source with %s plugin' % (source, plugin_name)) + break + except AnsibleParserError as e: + display.debug('%s was not parsable by %s' % (source, plugin_name)) + tb = ''.join(traceback.format_tb(sys.exc_info()[2])) + failures.append({'src': source, 'plugin': plugin_name, 'exc': e, 'tb': tb}) + except Exception as e: + display.debug('%s failed while attempting to parse %s' % (plugin_name, source)) + tb = ''.join(traceback.format_tb(sys.exc_info()[2])) + failures.append({'src': source, 'plugin': plugin_name, 'exc': AnsibleError(e), 'tb': tb}) + else: + display.vvv("%s declined parsing %s as it did not pass its verify_file() method" % (plugin_name, source)) + + if parsed: + self._inventory.processed_sources.append(self._inventory.current_source) + else: + # only warn/error if NOT using the default or using it and the file is present + # TODO: handle 'non file' inventory and detect vs hardcode default + if source != '/etc/ansible/hosts' or os.path.exists(source): + + if failures: + # only if no plugin processed files should we show errors. + for fail in failures: + display.warning(u'\n* Failed to parse %s with %s plugin: %s' % (to_text(fail['src']), fail['plugin'], to_text(fail['exc']))) + if 'tb' in fail: + display.vvv(to_text(fail['tb'])) + + # final error/warning on inventory source failure + if C.INVENTORY_ANY_UNPARSED_IS_FAILED: + raise AnsibleError(u'Completely failed to parse inventory source %s' % (source)) + else: + display.warning("Unable to parse %s as an inventory source" % source) + + # clear up, jic + self._inventory.current_source = None + + return parsed + + def clear_caches(self): + ''' clear all caches ''' + self._hosts_patterns_cache = {} + self._pattern_cache = {} + + def refresh_inventory(self): + ''' recalculate inventory ''' + + self.clear_caches() + self._inventory = InventoryData() + self.parse_sources(cache=False) + for host in self._cached_dynamic_hosts: + self.add_dynamic_host(host, {'refresh': True}) + for host, result in self._cached_dynamic_grouping: + result['refresh'] = True + self.add_dynamic_group(host, result) + + def _match_list(self, items, pattern_str): + # compile patterns + try: + if not pattern_str[0] == '~': + pattern = re.compile(fnmatch.translate(pattern_str)) + else: + pattern = re.compile(pattern_str[1:]) + except Exception: + raise AnsibleError('Invalid host list pattern: %s' % pattern_str) + + # apply patterns + results = [] + for item in items: + if pattern.match(item): + results.append(item) + return results + + def get_hosts(self, pattern="all", ignore_limits=False, ignore_restrictions=False, order=None): + """ + Takes a pattern or list of patterns and returns a list of matching + inventory host names, taking into account any active restrictions + or applied subsets + """ + + hosts = [] + + # Check if pattern already computed + if isinstance(pattern, list): + pattern_list = pattern[:] + else: + pattern_list = [pattern] + + if pattern_list: + if not ignore_limits and self._subset: + pattern_list.extend(self._subset) + + if not ignore_restrictions and self._restriction: + pattern_list.extend(self._restriction) + + # This is only used as a hash key in the self._hosts_patterns_cache dict + # a tuple is faster than stringifying + pattern_hash = tuple(pattern_list) + + if pattern_hash not in self._hosts_patterns_cache: + + patterns = split_host_pattern(pattern) + hosts = self._evaluate_patterns(patterns) + + # mainly useful for hostvars[host] access + if not ignore_limits and self._subset: + # exclude hosts not in a subset, if defined + subset_uuids = set(s._uuid for s in self._evaluate_patterns(self._subset)) + hosts = [h for h in hosts if h._uuid in subset_uuids] + + if not ignore_restrictions and self._restriction: + # exclude hosts mentioned in any restriction (ex: failed hosts) + hosts = [h for h in hosts if h.name in self._restriction] + + self._hosts_patterns_cache[pattern_hash] = deduplicate_list(hosts) + + # sort hosts list if needed (should only happen when called from strategy) + if order in ['sorted', 'reverse_sorted']: + hosts = sorted(self._hosts_patterns_cache[pattern_hash][:], key=attrgetter('name'), reverse=(order == 'reverse_sorted')) + elif order == 'reverse_inventory': + hosts = self._hosts_patterns_cache[pattern_hash][::-1] + else: + hosts = self._hosts_patterns_cache[pattern_hash][:] + if order == 'shuffle': + shuffle(hosts) + elif order not in [None, 'inventory']: + raise AnsibleOptionsError("Invalid 'order' specified for inventory hosts: %s" % order) + + return hosts + + def _evaluate_patterns(self, patterns): + """ + Takes a list of patterns and returns a list of matching host names, + taking into account any negative and intersection patterns. + """ + + patterns = order_patterns(patterns) + hosts = [] + + for p in patterns: + # avoid resolving a pattern that is a plain host + if p in self._inventory.hosts: + hosts.append(self._inventory.get_host(p)) + else: + that = self._match_one_pattern(p) + if p[0] == "!": + that = set(that) + hosts = [h for h in hosts if h not in that] + elif p[0] == "&": + that = set(that) + hosts = [h for h in hosts if h in that] + else: + existing_hosts = set(y.name for y in hosts) + hosts.extend([h for h in that if h.name not in existing_hosts]) + return hosts + + def _match_one_pattern(self, pattern): + """ + Takes a single pattern and returns a list of matching host names. + Ignores intersection (&) and exclusion (!) specifiers. + + The pattern may be: + + 1. A regex starting with ~, e.g. '~[abc]*' + 2. A shell glob pattern with ?/*/[chars]/[!chars], e.g. 'foo*' + 3. An ordinary word that matches itself only, e.g. 'foo' + + The pattern is matched using the following rules: + + 1. If it's 'all', it matches all hosts in all groups. + 2. Otherwise, for each known group name: + (a) if it matches the group name, the results include all hosts + in the group or any of its children. + (b) otherwise, if it matches any hosts in the group, the results + include the matching hosts. + + This means that 'foo*' may match one or more groups (thus including all + hosts therein) but also hosts in other groups. + + The built-in groups 'all' and 'ungrouped' are special. No pattern can + match these group names (though 'all' behaves as though it matches, as + described above). The word 'ungrouped' can match a host of that name, + and patterns like 'ungr*' and 'al*' can match either hosts or groups + other than all and ungrouped. + + If the pattern matches one or more group names according to these rules, + it may have an optional range suffix to select a subset of the results. + This is allowed only if the pattern is not a regex, i.e. '~foo[1]' does + not work (the [1] is interpreted as part of the regex), but 'foo*[1]' + would work if 'foo*' matched the name of one or more groups. + + Duplicate matches are always eliminated from the results. + """ + + if pattern[0] in ("&", "!"): + pattern = pattern[1:] + + if pattern not in self._pattern_cache: + (expr, slice) = self._split_subscript(pattern) + hosts = self._enumerate_matches(expr) + try: + hosts = self._apply_subscript(hosts, slice) + except IndexError: + raise AnsibleError("No hosts matched the subscripted pattern '%s'" % pattern) + self._pattern_cache[pattern] = hosts + + return self._pattern_cache[pattern] + + def _split_subscript(self, pattern): + """ + Takes a pattern, checks if it has a subscript, and returns the pattern + without the subscript and a (start,end) tuple representing the given + subscript (or None if there is no subscript). + + Validates that the subscript is in the right syntax, but doesn't make + sure the actual indices make sense in context. + """ + + # Do not parse regexes for enumeration info + if pattern[0] == '~': + return (pattern, None) + + # We want a pattern followed by an integer or range subscript. + # (We can't be more restrictive about the expression because the + # fnmatch semantics permit [\[:\]] to occur.) + + subscript = None + m = PATTERN_WITH_SUBSCRIPT.match(pattern) + if m: + (pattern, idx, start, sep, end) = m.groups() + if idx: + subscript = (int(idx), None) + else: + if not end: + end = -1 + subscript = (int(start), int(end)) + if sep == '-': + display.warning("Use [x:y] inclusive subscripts instead of [x-y] which has been removed") + + return (pattern, subscript) + + def _apply_subscript(self, hosts, subscript): + """ + Takes a list of hosts and a (start,end) tuple and returns the subset of + hosts based on the subscript (which may be None to return all hosts). + """ + + if not hosts or not subscript: + return hosts + + (start, end) = subscript + + if end: + if end == -1: + end = len(hosts) - 1 + return hosts[start:end + 1] + else: + return [hosts[start]] + + def _enumerate_matches(self, pattern): + """ + Returns a list of host names matching the given pattern according to the + rules explained above in _match_one_pattern. + """ + + results = [] + # check if pattern matches group + matching_groups = self._match_list(self._inventory.groups, pattern) + if matching_groups: + for groupname in matching_groups: + results.extend(self._inventory.groups[groupname].get_hosts()) + + # check hosts if no groups matched or it is a regex/glob pattern + if not matching_groups or pattern[0] == '~' or any(special in pattern for special in ('.', '?', '*', '[')): + # pattern might match host + matching_hosts = self._match_list(self._inventory.hosts, pattern) + if matching_hosts: + for hostname in matching_hosts: + results.append(self._inventory.hosts[hostname]) + + if not results and pattern in C.LOCALHOST: + # get_host autocreates implicit when needed + implicit = self._inventory.get_host(pattern) + if implicit: + results.append(implicit) + + # Display warning if specified host pattern did not match any groups or hosts + if not results and not matching_groups and pattern != 'all': + msg = "Could not match supplied host pattern, ignoring: %s" % pattern + display.debug(msg) + if C.HOST_PATTERN_MISMATCH == 'warning': + display.warning(msg) + elif C.HOST_PATTERN_MISMATCH == 'error': + raise AnsibleError(msg) + # no need to write 'ignore' state + + return results + + def list_hosts(self, pattern="all"): + """ return a list of hostnames for a pattern """ + # FIXME: cache? + result = self.get_hosts(pattern) + + # allow implicit localhost if pattern matches and no other results + if len(result) == 0 and pattern in C.LOCALHOST: + result = [pattern] + + return result + + def list_groups(self): + # FIXME: cache? + return sorted(self._inventory.groups.keys()) + + def restrict_to_hosts(self, restriction): + """ + Restrict list operations to the hosts given in restriction. This is used + to batch serial operations in main playbook code, don't use this for other + reasons. + """ + if restriction is None: + return + elif not isinstance(restriction, list): + restriction = [restriction] + self._restriction = set(to_text(h.name) for h in restriction) + + def subset(self, subset_pattern): + """ + Limits inventory results to a subset of inventory that matches a given + pattern, such as to select a given geographic of numeric slice amongst + a previous 'hosts' selection that only select roles, or vice versa. + Corresponds to --limit parameter to ansible-playbook + """ + if subset_pattern is None: + self._subset = None + else: + subset_patterns = split_host_pattern(subset_pattern) + results = [] + # allow Unix style @filename data + for x in subset_patterns: + if not x: + continue + + if x[0] == "@": + b_limit_file = to_bytes(x[1:]) + if not os.path.exists(b_limit_file): + raise AnsibleError(u'Unable to find limit file %s' % b_limit_file) + if not os.path.isfile(b_limit_file): + raise AnsibleError(u'Limit starting with "@" must be a file, not a directory: %s' % b_limit_file) + with open(b_limit_file) as fd: + results.extend([to_text(l.strip()) for l in fd.read().split("\n")]) + else: + results.append(to_text(x)) + self._subset = results + + def remove_restriction(self): + """ Do not restrict list operations """ + self._restriction = None + + def clear_pattern_cache(self): + self._pattern_cache = {} + + def add_dynamic_host(self, host_info, result_item): + ''' + Helper function to add a new host to inventory based on a task result. + ''' + + changed = False + if not result_item.get('refresh'): + self._cached_dynamic_hosts.append(host_info) + + if host_info: + host_name = host_info.get('host_name') + + # Check if host in inventory, add if not + if host_name not in self.hosts: + self.add_host(host_name, 'all') + changed = True + new_host = self.hosts.get(host_name) + + # Set/update the vars for this host + new_host_vars = new_host.get_vars() + new_host_combined_vars = combine_vars(new_host_vars, host_info.get('host_vars', dict())) + if new_host_vars != new_host_combined_vars: + new_host.vars = new_host_combined_vars + changed = True + + new_groups = host_info.get('groups', []) + for group_name in new_groups: + if group_name not in self.groups: + group_name = self._inventory.add_group(group_name) + changed = True + new_group = self.groups[group_name] + if new_group.add_host(self.hosts[host_name]): + changed = True + + # reconcile inventory, ensures inventory rules are followed + if changed: + self.reconcile_inventory() + + result_item['changed'] = changed + + def add_dynamic_group(self, host, result_item): + ''' + Helper function to add a group (if it does not exist), and to assign the + specified host to that group. + ''' + + changed = False + + if not result_item.get('refresh'): + self._cached_dynamic_grouping.append((host, result_item)) + + # the host here is from the executor side, which means it was a + # serialized/cloned copy and we'll need to look up the proper + # host object from the master inventory + real_host = self.hosts.get(host.name) + if real_host is None: + if host.name == self.localhost.name: + real_host = self.localhost + elif not result_item.get('refresh'): + raise AnsibleError('%s cannot be matched in inventory' % host.name) + else: + # host was removed from inventory during refresh, we should not process + return + + group_name = result_item.get('add_group') + parent_group_names = result_item.get('parent_groups', []) + + if group_name not in self.groups: + group_name = self.add_group(group_name) + + for name in parent_group_names: + if name not in self.groups: + # create the new group and add it to inventory + self.add_group(name) + changed = True + + group = self._inventory.groups[group_name] + for parent_group_name in parent_group_names: + parent_group = self.groups[parent_group_name] + new = parent_group.add_child_group(group) + if new and not changed: + changed = True + + if real_host not in group.get_hosts(): + changed = group.add_host(real_host) + + if group not in real_host.get_groups(): + changed = real_host.add_group(group) + + if changed: + self.reconcile_inventory() + + result_item['changed'] = changed |