Add toggle to control invalid character substitution in group names (#52748)

* make add_group return proper name
* ensure central transform/check
* added 'silent' option to avoid spamming current users
  those already using the plugins were used to the transformations, so no need to alert them
* centralized valid var names
* dont display dupes
* comment on regex
* added regex tests
  ini and script will now warn about deprecation
* more complete errormsg
pull/52199/head
Brian Coca 6 years ago committed by GitHub
parent 9c54649449
commit d241794daa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,2 @@
minor_changes:
- add toggle to allow user to override invalid group character filter

@ -1439,6 +1439,18 @@ INTERPRETER_PYTHON_FALLBACK:
- python - python
# FUTURE: add inventory override once we're sure it can't be abused by a rogue target # FUTURE: add inventory override once we're sure it can't be abused by a rogue target
version_added: "2.8" version_added: "2.8"
TRANSFORM_INVALID_GROUP_CHARS:
name: Transform invalid characters in group names
default: False
description:
- Make ansible transform invalid characters in group names supplied by inventory sources.
- If 'false' it will allow for the group name but warn about the issue.
- When 'true' it will replace any invalid charachters with '_' (underscore).
env: [{name: ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS}]
ini:
- {key: force_valid_group_names, section: defaults}
type: bool
version_added: '2.8'
INVALID_TASK_ATTRIBUTE_FAILED: INVALID_TASK_ATTRIBUTE_FAILED:
name: Controls whether invalid attributes for a task result in errors instead of warnings name: Controls whether invalid attributes for a task result in errors instead of warnings
default: True default: True

@ -6,6 +6,7 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type __metaclass__ = type
import os import os
import re
from ast import literal_eval from ast import literal_eval
from jinja2 import Template from jinja2 import Template
@ -117,6 +118,9 @@ TREE_DIR = None
VAULT_VERSION_MIN = 1.0 VAULT_VERSION_MIN = 1.0
VAULT_VERSION_MAX = 1.0 VAULT_VERSION_MAX = 1.0
# This matches a string that cannot be used as a valid python variable name i.e 'not-valid', 'not!valid@either' '1_nor_This'
INVALID_VARIABLE_NAMES = re.compile(r'^[^a-zA-Z_]|[^a-zA-Z0-9_]')
# FIXME: remove once play_context mangling is removed # FIXME: remove once play_context mangling is removed
# the magic variable mapping dictionary below is used to translate # the magic variable mapping dictionary below is used to translate
# host/inventory variables to fields in the PlayContext # host/inventory variables to fields in the PlayContext

@ -156,21 +156,25 @@ class InventoryData(object):
return matching_host return matching_host
def add_group(self, group): def add_group(self, group):
''' adds a group to inventory if not there already ''' ''' adds a group to inventory if not there already, returns named actually used '''
if group: if group:
if not isinstance(group, string_types): if not isinstance(group, string_types):
raise AnsibleError("Invalid group name supplied, expected a string but got %s for %s" % (type(group), group)) raise AnsibleError("Invalid group name supplied, expected a string but got %s for %s" % (type(group), group))
if group not in self.groups: if group not in self.groups:
g = Group(group) g = Group(group)
self.groups[group] = g if g.name not in self.groups:
self._groups_dict_cache = {} self.groups[g.name] = g
display.debug("Added group %s to inventory" % group) self._groups_dict_cache = {}
display.debug("Added group %s to inventory" % group)
group = g.name
else: else:
display.debug("group %s already in inventory" % group) display.debug("group %s already in inventory" % group)
else: else:
raise AnsibleError("Invalid empty/false group name provided: %s" % group) raise AnsibleError("Invalid empty/false group name provided: %s" % group)
return group
def remove_group(self, group): def remove_group(self, group):
if group in self.groups: if group in self.groups:
@ -188,6 +192,8 @@ class InventoryData(object):
if host: if host:
if not isinstance(host, string_types): if not isinstance(host, string_types):
raise AnsibleError("Invalid host name supplied, expected a string but got %s for %s" % (type(host), host)) raise AnsibleError("Invalid host name supplied, expected a string but got %s for %s" % (type(host), host))
# TODO: add to_safe_host_name
g = None g = None
if group: if group:
if group in self.groups: if group in self.groups:
@ -223,6 +229,8 @@ class InventoryData(object):
else: else:
raise AnsibleError("Invalid empty host name provided: %s" % host) raise AnsibleError("Invalid empty host name provided: %s" % host)
return host
def remove_host(self, host): def remove_host(self, host):
if host.name in self.hosts: if host.name in self.hosts:

@ -17,9 +17,31 @@
from __future__ import (absolute_import, division, print_function) from __future__ import (absolute_import, division, print_function)
__metaclass__ = type __metaclass__ = type
from itertools import chain
from ansible import constants as C
from ansible.errors import AnsibleError from ansible.errors import AnsibleError
from ansible.module_utils._text import to_native, to_text
from itertools import chain from ansible.utils.display import Display
display = Display()
def to_safe_group_name(name, replacer="_", force=False, silent=False):
# Converts 'bad' characters in a string to underscores (or provided replacer) so they can be used as Ansible hosts or groups
if name: # when deserializing we might not have name yet
invalid_chars = C.INVALID_VARIABLE_NAMES.findall(name)
if invalid_chars:
msg = 'invalid character(s) "%s" in group name (%s)' % (to_text(set(invalid_chars)), to_text(name))
if C.TRANSFORM_INVALID_GROUP_CHARS or force:
name = C.INVALID_VARIABLE_NAMES.sub(replacer, name)
if not silent:
display.warning('Replacing ' + msg)
else:
display.deprecated('Ignoring ' + msg, version='2.12')
return name
class Group: class Group:
@ -30,7 +52,7 @@ class Group:
def __init__(self, name=None): def __init__(self, name=None):
self.depth = 0 self.depth = 0
self.name = name self.name = to_safe_group_name(name)
self.hosts = [] self.hosts = []
self._hosts = None self._hosts = None
self.vars = {} self.vars = {}
@ -148,9 +170,7 @@ class Group:
start_ancestors = group.get_ancestors() start_ancestors = group.get_ancestors()
new_ancestors = self.get_ancestors() new_ancestors = self.get_ancestors()
if group in new_ancestors: if group in new_ancestors:
raise AnsibleError( raise AnsibleError("Adding group '%s' as child to '%s' creates a recursive dependency loop." % (to_native(group.name), to_native(self.name)))
"Adding group '%s' as child to '%s' creates a recursive "
"dependency loop." % (group.name, self.name))
new_ancestors.add(self) new_ancestors.add(self)
new_ancestors.difference_update(start_ancestors) new_ancestors.difference_update(start_ancestors)
@ -188,7 +208,7 @@ class Group:
g.depth = depth g.depth = depth
unprocessed.update(g.child_groups) unprocessed.update(g.child_groups)
if depth - start_depth > len(seen): if depth - start_depth > len(seen):
raise AnsibleError("The group named '%s' has a recursive dependency loop." % self.name) raise AnsibleError("The group named '%s' has a recursive dependency loop." % to_native(self.name))
def add_host(self, host): def add_host(self, host):
if host.name not in self.host_names: if host.name not in self.host_names:

@ -21,10 +21,10 @@ __metaclass__ = type
import hashlib import hashlib
import os import os
import re
import string import string
from ansible.errors import AnsibleError, AnsibleParserError 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.parsing.utils.addresses import parse_address
from ansible.plugins import AnsiblePlugin from ansible.plugins import AnsiblePlugin
from ansible.plugins.cache import InventoryFileCacheModule from ansible.plugins.cache import InventoryFileCacheModule
@ -37,13 +37,11 @@ from ansible.utils.display import Display
display = Display() display = Display()
_SAFE_GROUP = re.compile("[^A-Za-z0-9_]")
# Helper methods # Helper methods
def to_safe_group_name(name): def to_safe_group_name(name):
''' Converts 'bad' characters in a string to underscores so they can be used as Ansible hosts or groups ''' # placeholder for backwards compat
return _SAFE_GROUP.sub("_", name) return original_safe(name, force=True, silent=True)
def detect_range(line=None): def detect_range(line=None):
@ -319,6 +317,7 @@ class Constructable(object):
self.templar.set_available_variables(variables) self.templar.set_available_variables(variables)
for group_name in groups: for group_name in groups:
conditional = "{%% if %s %%} True {%% else %%} False {%% endif %%}" % groups[group_name] conditional = "{%% if %s %%} True {%% else %%} False {%% endif %%}" % groups[group_name]
group_name = to_safe_group_name(group_name)
try: try:
result = boolean(self.templar.template(conditional)) result = boolean(self.templar.template(conditional))
except Exception as e: except Exception as e:
@ -327,8 +326,8 @@ class Constructable(object):
continue continue
if result: if result:
# ensure group exists # ensure group exists, use sanatized name
self.inventory.add_group(group_name) group_name = self.inventory.add_group(group_name)
# add host to group # add host to group
self.inventory.add_child(group_name, host) self.inventory.add_child(group_name, host)

@ -446,7 +446,7 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
def _populate(self, groups, hostnames): def _populate(self, groups, hostnames):
for group in groups: for group in groups:
self.inventory.add_group(group) group = self.inventory.add_group(group)
self._add_hosts(hosts=groups[group], group=group, hostnames=hostnames) self._add_hosts(hosts=groups[group], group=group, hostnames=hostnames)
self.inventory.add_child('all', group) self.inventory.add_child('all', group)

@ -60,14 +60,12 @@ password: secure
validate_certs: False validate_certs: False
''' '''
import re
from distutils.version import LooseVersion from distutils.version import LooseVersion
from ansible.errors import AnsibleError from ansible.errors import AnsibleError
from ansible.module_utils._text import to_bytes, to_native from ansible.module_utils._text import to_bytes, to_native
from ansible.module_utils.common._collections_compat import MutableMapping from ansible.module_utils.common._collections_compat import MutableMapping
from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable, to_safe_group_name
# 3rd party imports # 3rd party imports
try: try:
@ -185,14 +183,6 @@ class InventoryModule(BaseInventoryPlugin, Cacheable):
raise ValueError("More than one set of facts returned for '%s'" % host) raise ValueError("More than one set of facts returned for '%s'" % host)
return facts return facts
def to_safe(self, word):
'''Converts 'bad' characters in a string to underscores so they can be used as Ansible groups
#> ForemanInventory.to_safe("foo-bar baz")
'foo_barbaz'
'''
regex = r"[^A-Za-z0-9\_]"
return re.sub(regex, "_", word.replace(" ", ""))
def _populate(self): def _populate(self):
for host in self._get_hosts(): for host in self._get_hosts():
@ -203,8 +193,8 @@ class InventoryModule(BaseInventoryPlugin, Cacheable):
# create directly mapped groups # create directly mapped groups
group_name = host.get('hostgroup_title', host.get('hostgroup_name')) group_name = host.get('hostgroup_title', host.get('hostgroup_name'))
if group_name: if group_name:
group_name = self.to_safe('%s%s' % (self.get_option('group_prefix'), group_name.lower())) group_name = to_safe_group_name('%s%s' % (self.get_option('group_prefix'), group_name.lower().replace(" ", "")))
self.inventory.add_group(group_name) group_name = self.inventory.add_group(group_name)
self.inventory.add_child(group_name, host['name']) self.inventory.add_child(group_name, host['name'])
# set host vars from host info # set host vars from host info
@ -224,7 +214,8 @@ class InventoryModule(BaseInventoryPlugin, Cacheable):
try: try:
self.inventory.set_variable(host['name'], p['name'], p['value']) self.inventory.set_variable(host['name'], p['name'], p['value'])
except ValueError as e: except ValueError as e:
self.display.warning("Could not set parameter hostvar for %s, skipping %s: %s" % (host, p['name'], to_native(p['value']))) self.display.warning("Could not set hostvar %s to '%s' for the '%s' host, skipping: %s" %
(p['name'], to_native(p['value']), host, to_native(e)))
# set host vars from facts # set host vars from facts
if self.get_option('want_facts'): if self.get_option('want_facts'):

@ -77,6 +77,7 @@ EXAMPLES = '''
import ast import ast
import re import re
from ansible.inventory.group import to_safe_group_name
from ansible.plugins.inventory import BaseFileInventoryPlugin from ansible.plugins.inventory import BaseFileInventoryPlugin
from ansible.errors import AnsibleError, AnsibleParserError from ansible.errors import AnsibleError, AnsibleParserError
@ -171,6 +172,8 @@ class InventoryModule(BaseFileInventoryPlugin):
if m: if m:
(groupname, state) = m.groups() (groupname, state) = m.groups()
groupname = to_safe_group_name(groupname)
state = state or 'hosts' state = state or 'hosts'
if state not in ['hosts', 'children', 'vars']: if state not in ['hosts', 'children', 'vars']:
title = ":".join(m.groups()) title = ":".join(m.groups())

@ -153,7 +153,7 @@ class InventoryModule(BaseInventoryPlugin, Cacheable):
try: try:
got = data_from_meta.get(host, {}) got = data_from_meta.get(host, {})
except AttributeError as e: except AttributeError as e:
raise AnsibleError("Improperly formatted host information for %s: %s" % (host, to_native(e))) raise AnsibleError("Improperly formatted host information for %s: %s" % (host, to_native(e)), orig_exc=e)
self._populate_host_vars([host], got) self._populate_host_vars([host], got)
@ -162,7 +162,7 @@ class InventoryModule(BaseInventoryPlugin, Cacheable):
def _parse_group(self, group, data): def _parse_group(self, group, data):
self.inventory.add_group(group) group = self.inventory.add_group(group)
if not isinstance(data, dict): if not isinstance(data, dict):
data = {'hosts': data} data = {'hosts': data}
@ -187,7 +187,7 @@ class InventoryModule(BaseInventoryPlugin, Cacheable):
if group != '_meta' and isinstance(data, dict) and 'children' in data: if group != '_meta' and isinstance(data, dict) and 'children' in data:
for child_name in data['children']: for child_name in data['children']:
self.inventory.add_group(child_name) child_name = self.inventory.add_group(child_name)
self.inventory.add_child(group, child_name) self.inventory.add_child(group, child_name)
def get_host_variables(self, path, host): def get_host_variables(self, path, host):

@ -163,7 +163,7 @@ class InventoryModule(BaseFileInventoryPlugin):
self.display.warning("Skipping '%s' as this is not a valid group definition" % group) self.display.warning("Skipping '%s' as this is not a valid group definition" % group)
return return
self.inventory.add_group(group) group = self.inventory.add_group(group)
if group_data is None: if group_data is None:
return return

@ -107,7 +107,7 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
if group == 'all': if group == 'all':
continue continue
else: else:
self.inventory.add_group(group) group = self.inventory.add_group(group)
hosts = source_data[group].get('hosts', []) hosts = source_data[group].get('hosts', [])
for host in hosts: for host in hosts:
self._populate_host_vars([host], hostvars.get(host, {}), group) self._populate_host_vars([host], hostvars.get(host, {}), group)
@ -162,10 +162,10 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
elif k == 'Groups': elif k == 'Groups':
for group in v.split('/'): for group in v.split('/'):
if group: if group:
group = self.inventory.add_group(group)
self.inventory.add_child(group, current_host)
if group not in cacheable_results: if group not in cacheable_results:
cacheable_results[group] = {'hosts': []} cacheable_results[group] = {'hosts': []}
self.inventory.add_group(group)
self.inventory.add_child(group, current_host)
cacheable_results[group]['hosts'].append(current_host) cacheable_results[group]['hosts'].append(current_host)
continue continue

@ -124,7 +124,7 @@ class InventoryModule(BaseFileInventoryPlugin):
if isinstance(group_data, (MutableMapping, NoneType)): if isinstance(group_data, (MutableMapping, NoneType)):
try: try:
self.inventory.add_group(group) group = self.inventory.add_group(group)
except AnsibleError as e: except AnsibleError as e:
raise AnsibleParserError("Unable to add group %s: %s" % (group, to_text(e))) raise AnsibleParserError("Unable to add group %s: %s" % (group, to_text(e)))

@ -0,0 +1,27 @@
# Make coding more python3-ish
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
from units.compat import unittest
from ansible import constants as C
test_cases = (('not-valid', ['-'], 'not_valid'), ('not!valid@either', ['!', '@'], 'not_valid_either'), ('1_nor_This', ['1'], '__nor_This'))
class TestInvalidVars(unittest.TestCase):
def test_positive_matches(self):
for name, invalid, sanitized in test_cases:
self.assertEqual(C.INVALID_VARIABLE_NAMES.findall(name), invalid)
def test_negative_matches(self):
for name in ('this_is_valid', 'Also_1_valid', 'noproblem'):
self.assertEqual(C.INVALID_VARIABLE_NAMES.findall(name), [])
def test_get_setting(self):
for name, invalid, sanitized in test_cases:
self.assertEqual(C.INVALID_VARIABLE_NAMES.sub('_', name), sanitized)
Loading…
Cancel
Save