summaryrefslogtreecommitdiffstats
path: root/lib/ansible/plugins/inventory/ini.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/ansible/plugins/inventory/ini.py')
-rw-r--r--lib/ansible/plugins/inventory/ini.py393
1 files changed, 393 insertions, 0 deletions
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
+ )