Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 26 commits into from
Mar 6, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/togggle_invalid_group_chars.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- add toggle to allow user to override invalid group character filter
12 changes: 12 additions & 0 deletions lib/ansible/config/base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,18 @@ INTERPRETER_PYTHON_FALLBACK:
- python
# FUTURE: add inventory override once we're sure it can't be abused by a rogue target
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a customer has any non "-" invalid characters in their inventory groups this won't provide backwards compatibility for the ec2/azure inventory plugins with the scripts, since the scripts would have replaced those non "-" invalid characters with underscores. Tags with invalid characters or IPv6 are possible examples.

The gce script doesn't sanitize, so a blanket toggle like this would keep the gce inventory plugin groups compatible

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the default is false, but will give users a deprecation notice now, once we toggle to 'true' it will 'autotransform' with a warning for those plugins that don't already do the transform themselves

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like you are assuming that some scripts like ec2.py sanitize group names. It has a setting for sanitizing group names, yes. But that doesn't mean that it works. I talked about this here:

#40581 (comment)

I can file a bug if you'd like. I have found the behavior to be highly reproducible, and have dived in a little what values it has in code. It appears that self.replace_dash_in_groups does take the "True" value (meaning it should sanitize), but I think that either the regex was wrong, or the method to_safe was never called.

Copy link
Member Author

@bcoca bcoca Feb 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, assuming the opposite, those using the script plugin will now get a deprecation warning when trying to pass invalid group names

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@s-hertel I can safely say that in the ec2.py script, to_safe is not called on the relevant group names, and that the group name us-east-2 is returned with either replace_dash_in_groups = True or replace_dash_in_groups = False (with the config loaded correctly, that is ruled out). I can get you more information on this if you want.

Copy link
Contributor

@s-hertel s-hertel Mar 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlanCoding I'm not sure if you're understanding me. EC2 script always sanitizes, just to varying degrees. Here's my test case. Try adding a bad character in a tag. For instance, host has tags: {'bad:idea': 'here:too'}. Using the script on devel that creates the group tag_bad_idea_here_too. Those : are replaced by _ (entirely regardless of replace_dash_in_groups). A filter is being used for bad characters, just not necessarily -. So now if you port to using the plugin, you're not going to be able to replicate that behavior while also creating groups with some invalid characters (i.e. -) because it's all or nothing, no partial sanitization (the group will become tag_bad:idea:here:too if you're using the toggle to create groups like us-east-1). Anyway, I don't use it and ripping off the bandaid is probably good. I'm not going to fight for it, just pointing out the deficiency and to have something to point to later if users run into the corner case while trying to replicate the behavior of the script via the plugin. Hopefully no one is doing that but... ¯_ツ_/¯

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related, a global solution like this also poses problems for combining different plugins per playbook run as you may want some plugins to filter and not others.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlanCoding Ah, btw, replace_dash_in_groups is a tag specific option. Poorly named, doesn't work on general groups but works great for tags.

- 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:
name: Controls whether invalid attributes for a task result in errors instead of warnings
default: True
Expand Down
4 changes: 4 additions & 0 deletions lib/ansible/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
__metaclass__ = type

import os
import re

from ast import literal_eval
from jinja2 import Template
Expand Down Expand Up @@ -117,6 +118,9 @@ def __getitem__(self, y):
VAULT_VERSION_MIN = 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
# the magic variable mapping dictionary below is used to translate
# host/inventory variables to fields in the PlayContext
Expand Down
16 changes: 12 additions & 4 deletions lib/ansible/inventory/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,21 +156,25 @@ def get_host(self, hostname):
return matching_host

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 not isinstance(group, string_types):
raise AnsibleError("Invalid group name supplied, expected a string but got %s for %s" % (type(group), group))
if group not in self.groups:
g = Group(group)
self.groups[group] = g
self._groups_dict_cache = {}
display.debug("Added group %s to inventory" % group)
if g.name not in self.groups:
self.groups[g.name] = g
self._groups_dict_cache = {}
display.debug("Added group %s to inventory" % group)
group = g.name
else:
display.debug("group %s already in inventory" % group)
else:
raise AnsibleError("Invalid empty/false group name provided: %s" % group)

return group

def remove_group(self, group):

if group in self.groups:
Expand All @@ -188,6 +192,8 @@ def add_host(self, host, group=None, port=None):
if host:
if not isinstance(host, string_types):
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
if group:
if group in self.groups:
Expand Down Expand Up @@ -223,6 +229,8 @@ def add_host(self, host, group=None, port=None):
else:
raise AnsibleError("Invalid empty host name provided: %s" % host)

return host

def remove_host(self, host):

if host.name in self.hosts:
Expand Down
32 changes: 26 additions & 6 deletions lib/ansible/inventory/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,31 @@
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

from itertools import chain

from ansible import constants as C
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:
Expand All @@ -30,7 +52,7 @@ class Group:
def __init__(self, name=None):

self.depth = 0
self.name = name
self.name = to_safe_group_name(name)
self.hosts = []
self._hosts = None
self.vars = {}
Expand Down Expand Up @@ -148,9 +170,7 @@ def add_child_group(self, group):
start_ancestors = group.get_ancestors()
new_ancestors = self.get_ancestors()
if group in new_ancestors:
raise AnsibleError(
"Adding group '%s' as child to '%s' creates a recursive "
"dependency loop." % (group.name, self.name))
raise AnsibleError("Adding group '%s' as child to '%s' creates a recursive dependency loop." % (to_native(group.name), to_native(self.name)))
new_ancestors.add(self)
new_ancestors.difference_update(start_ancestors)

Expand Down Expand Up @@ -188,7 +208,7 @@ def _check_children_depth(self):
g.depth = depth
unprocessed.update(g.child_groups)
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):
if host.name not in self.host_names:
Expand Down
13 changes: 6 additions & 7 deletions lib/ansible/plugins/inventory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@

import hashlib
import os
import re
import string

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 InventoryFileCacheModule
Expand All @@ -37,13 +37,11 @@

display = Display()

_SAFE_GROUP = re.compile("[^A-Za-z0-9_]")


# Helper methods
def to_safe_group_name(name):
''' Converts 'bad' characters in a string to underscores so they can be used as Ansible hosts or groups '''
return _SAFE_GROUP.sub("_", name)
# placeholder for backwards compat
return original_safe(name, force=True, silent=True)


def detect_range(line=None):
Expand Down Expand Up @@ -319,6 +317,7 @@ def _add_host_to_composed_groups(self, groups, variables, host, strict=False):
self.templar.set_available_variables(variables)
for group_name in groups:
conditional = "{%% if %s %%} True {%% else %%} False {%% endif %%}" % groups[group_name]
group_name = to_safe_group_name(group_name)
try:
result = boolean(self.templar.template(conditional))
except Exception as e:
Expand All @@ -327,8 +326,8 @@ def _add_host_to_composed_groups(self, groups, variables, host, strict=False):
continue

if result:
# ensure group exists
self.inventory.add_group(group_name)
# ensure group exists, use sanatized name
group_name = self.inventory.add_group(group_name)
# add host to group
self.inventory.add_child(group_name, host)

Expand Down
2 changes: 1 addition & 1 deletion lib/ansible/plugins/inventory/aws_ec2.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ def _query(self, regions, filters, strict_permissions):

def _populate(self, groups, hostnames):
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.inventory.add_child('all', group)

Expand Down
19 changes: 5 additions & 14 deletions lib/ansible/plugins/inventory/foreman.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,12 @@
validate_certs: False
'''

import re

from distutils.version import LooseVersion

from ansible.errors import AnsibleError
from ansible.module_utils._text import to_bytes, to_native
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
try:
Expand Down Expand Up @@ -185,14 +183,6 @@ def _get_facts(self, host):
raise ValueError("More than one set of facts returned for '%s'" % host)
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):

for host in self._get_hosts():
Expand All @@ -203,8 +193,8 @@ def _populate(self):
# create directly mapped groups
group_name = host.get('hostgroup_title', host.get('hostgroup_name'))
if group_name:
group_name = self.to_safe('%s%s' % (self.get_option('group_prefix'), group_name.lower()))
self.inventory.add_group(group_name)
group_name = to_safe_group_name('%s%s' % (self.get_option('group_prefix'), group_name.lower().replace(" ", "")))
group_name = self.inventory.add_group(group_name)
self.inventory.add_child(group_name, host['name'])

# set host vars from host info
Expand All @@ -224,7 +214,8 @@ def _populate(self):
try:
self.inventory.set_variable(host['name'], p['name'], p['value'])
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
if self.get_option('want_facts'):
Expand Down
3 changes: 3 additions & 0 deletions lib/ansible/plugins/inventory/ini.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
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
Expand Down Expand Up @@ -171,6 +172,8 @@ def _parse(self, path, lines):
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())
Expand Down
6 changes: 3 additions & 3 deletions lib/ansible/plugins/inventory/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def parse(self, inventory, loader, path, cache=None):
try:
got = data_from_meta.get(host, {})
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)

Expand All @@ -162,7 +162,7 @@ def parse(self, inventory, loader, path, cache=None):

def _parse_group(self, group, data):

self.inventory.add_group(group)
group = self.inventory.add_group(group)

if not isinstance(data, dict):
data = {'hosts': data}
Expand All @@ -187,7 +187,7 @@ def _parse_group(self, group, data):

if group != '_meta' and isinstance(data, dict) and 'children' in data:
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)

def get_host_variables(self, path, host):
Expand Down
2 changes: 1 addition & 1 deletion lib/ansible/plugins/inventory/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def _parse_group(self, group, group_data):
self.display.warning("Skipping '%s' as this is not a valid group definition" % group)
return

self.inventory.add_group(group)
group = self.inventory.add_group(group)
if group_data is None:
return

Expand Down
6 changes: 3 additions & 3 deletions lib/ansible/plugins/inventory/virtualbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def _populate_from_cache(self, source_data):
if group == 'all':
continue
else:
self.inventory.add_group(group)
group = self.inventory.add_group(group)
hosts = source_data[group].get('hosts', [])
for host in hosts:
self._populate_host_vars([host], hostvars.get(host, {}), group)
Expand Down Expand Up @@ -162,10 +162,10 @@ def _populate_from_source(self, source_data, using_current_cache=False):
elif k == 'Groups':
for group in v.split('/'):
if group:
group = self.inventory.add_group(group)
self.inventory.add_child(group, current_host)
if group not in cacheable_results:
cacheable_results[group] = {'hosts': []}
self.inventory.add_group(group)
self.inventory.add_child(group, current_host)
cacheable_results[group]['hosts'].append(current_host)
continue

Expand Down
2 changes: 1 addition & 1 deletion lib/ansible/plugins/inventory/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def _parse_group(self, group, group_data):
if isinstance(group_data, (MutableMapping, NoneType)):

try:
self.inventory.add_group(group)
group = self.inventory.add_group(group)
except AnsibleError as e:
raise AnsibleParserError("Unable to add group %s: %s" % (group, to_text(e)))

Expand Down
27 changes: 27 additions & 0 deletions test/units/regex/test_invalid_var_names.py
Original file line number Diff line number Diff line change
@@ -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)