mirror of
https://github.com/thegeeklab/ansible-later.git
synced 2024-11-16 18:10:38 +00:00
397 lines
17 KiB
Python
397 lines
17 KiB
Python
|
# 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 = '''
|
||
|
inventory: 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 `: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 using the C(key=value) syntax are interpreted as Python literal structure (strings, numbers, tuples, lists, dicts,
|
||
|
booleans, None), alternatively as string. For example C(var=FALSE) would create a string equal to 'FALSE'. Do not rely on types set
|
||
|
during definition, always make sure you specify type with a filter when needed when consuming the variable.
|
||
|
notes:
|
||
|
- It takes the place of the previously hardcoded INI inventory.
|
||
|
- To function it requires being whitelisted in configuration.
|
||
|
- Variable values are processed by Python's ast.literal_eval function (U(https://docs.python.org/2/library/ast.html#ast.literal_eval))
|
||
|
which could cause the value to change in some cases. See the Examples for proper quoting to prevent changes. Another option would be
|
||
|
to use the yaml format for inventory source which processes the values correctly.
|
||
|
'''
|
||
|
|
||
|
EXAMPLES = '''
|
||
|
example1: |
|
||
|
# example cfg file
|
||
|
[web]
|
||
|
host1
|
||
|
host2 ansible_port=222
|
||
|
|
||
|
[web:vars]
|
||
|
http_port=8080 # all members of 'web' will inherit these
|
||
|
myvar=23
|
||
|
|
||
|
[web:children] # child groups will automatically add their hosts to partent 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
|
||
|
|
||
|
example2: |
|
||
|
# other example config
|
||
|
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.plugins.inventory import BaseFileInventoryPlugin, detect_range, expand_hostname_range
|
||
|
from ansible.parsing.utils.addresses import parse_address
|
||
|
|
||
|
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()
|
||
|
|
||
|
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):
|
||
|
'''
|
||
|
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)
|
||
|
|
||
|
@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
|
||
|
)
|