Allow grain and pillar delimiter to be specified in compound matcher syntax.

This commit is contained in:
Thayne Harbaugh 2015-04-23 23:26:49 -06:00
parent 4d57a2d7c0
commit 993373655a
4 changed files with 174 additions and 92 deletions

View file

@ -10,17 +10,19 @@ with CLI and :term:`top file` matching. To match using anything other than a
glob, prefix the match string with the appropriate letter from the table below,
followed by an ``@`` sign.
====== ==================== ===============================================================
Letter Match Type Example
====== ==================== ===============================================================
G Grains glob ``G@os:Ubuntu``
E PCRE Minion ID ``E@web\d+\.(dev|qa|prod)\.loc``
P Grains PCRE ``P@os:(RedHat|Fedora|CentOS)``
L List of minions ``L@minion1.example.com,minion3.domain.com or bl*.domain.com``
I Pillar glob ``I@pdata:foobar``
S Subnet/IP address ``S@192.168.1.0/24`` or ``S@192.168.1.100``
R Range cluster ``R@%foo.bar``
====== ==================== ===============================================================
====== ========= ==================== ==============================================================
Letter Delimiter Match Type Example
====== ========= ==================== ==============================================================
G x Grains glob ``G@os:Ubuntu``
E PCRE Minion ID ``E@web\d+\.(dev|qa|prod)\.loc``
P x Grains PCRE ``P@os:(RedHat|Fedora|CentOS)``
L List of minions ``L@minion1.example.com,minion3.domain.com or bl*.domain.com``
I x Pillar glob ``I@pdata:foobar``
J x Pillar PCRE ``J@pdata:^(foo|bar)$``
S Subnet/IP address ``S@192.168.1.0/24`` or ``S@192.168.1.100``
R Range cluster ``R@%foo.bar``
====== ========= ==================== ==============================================================
Matchers can be joined using boolean ``and``, ``or``, and ``not`` operators.
@ -60,3 +62,18 @@ Matches can be grouped together with parentheses to explicitly declare precedenc
Be certain to note that spaces are required between the parentheses and targets. Failing to obey this
rule may result in incorrect targeting!
Alternate Delimiters
--------------------
.. versionadded:: Beryllium
Some matchers allow an optional delimiter character specified between the
leading matcher character and the ``@`` pattern separator character. This
can be essential when the globbing or PCRE pattern may use the default
delimiter character ``:``. This avoids incorrect interpretation of the
pattern as part of the grain or pillar data structure traversal.
.. code-block:: bash
salt -C 'J|@foo|bar|^foo:bar$ or J!@gitrepo!https://github.com:example/project.git' test.ping

View file

@ -2311,55 +2311,72 @@ class Matcher(object):
if not isinstance(tgt, six.string_types):
log.debug('Compound target received that is not a string')
return False
log.debug('compound_match({0})'.format(tgt))
log.debug('compound_match: {0} ? {1}'.format(tgt, self.opts['id']))
ref = {'G': 'grain',
'P': 'grain_pcre',
'I': 'pillar',
'J': 'pillar_pcre',
'L': 'list',
'N': None, # Nodegroups should already be expanded
'S': 'ipcidr',
'E': 'pcre'}
if HAS_RANGE:
ref['R'] = 'range'
results = []
opers = ['and', 'or', 'not', '(', ')']
tokens = tgt.split()
for match in tokens:
# Try to match tokens from the compound target, first by using
# the 'G, X, I, J, L, S, E' matcher types, then by hostname glob.
if '@' in match and match[1] == '@':
comps = match.split('@', 1)
matcher = ref.get(comps[0])
if not matcher:
# If an unknown matcher is called at any time, fail out
log.error('Invalid matcher: {0}'.format(comps[0]))
return False
results.append(
str(getattr(self, '{0}_match'.format(matcher))(comps[1]))
)
elif match in opers:
# We didn't match a target, so append a boolean operator or
# subexpression
words = tgt.split()
for word in words:
target_info = salt.utils.minions.parse_target(word)
# Easy check first
if word in opers:
if results:
if results[-1] == '(' and match in ('and', 'or'):
log.error('Invalid beginning operator after "(": {0}'.format(match))
if results[-1] == '(' and word in ('and', 'or'):
log.error('Invalid beginning operator after "(": {0}'.format(word))
return False
if match == 'not':
if word == 'not':
if not results[-1] in ('and', 'or', '('):
results.append('and')
results.append(match)
results.append(word)
else:
# seq start with binary oper, fail
if match not in ['(', 'not']:
log.error('Invalid beginning operator: {0}'.format(match))
if word not in ['(', 'not']:
log.error('Invalid beginning operator: {0}'.format(word))
return False
results.append(match)
results.append(word)
elif target_info and target_info.get('engine'):
if 'N' == target_info['engine']:
# Nodegroups should already be expanded/resolved to other engines
log.error('Detected nodegroup expansion failure of "{0}"'.format(match))
return False
engine = ref.get(target_info['engine'])
if not engine:
# If an unknown engine is called at any time, fail out
log.error('Unrecognized target engine "{0}" for'
' target expression "{1}"'.format(
target_info['engine'],
word,
)
)
return False
engine_args = [target_info['pattern']]
engine_kwargs = {}
if target_info.get('delimiter'):
engine_kwargs['delimiter'] = target_info.get['delimiter']
results.append(
str(getattr(self, '{0}_match'.format(engine))(*engine_args, **engine_kwargs))
)
else:
# The match is not explicitly defined, evaluate it as a glob
results.append(str(self.glob_match(match)))
results.append(str(self.glob_match(word)))
results = ' '.join(results)
log.debug('compound_match "{0}" => "{1}"'.format(tgt, results))
log.debug('compound_match {0} ? "{1}" => "{2}"'.format(self.opts['id'], tgt, results))
try:
return eval(results) # pylint: disable=W0123
except Exception:

View file

@ -29,6 +29,24 @@ except ImportError:
log = logging.getLogger(__name__)
TARGET_REX = re.compile(
r'''(?x)
(
(?P<engine>G|P|I|J|L|N|S|E|R) # Possible target engines
(?P<delimiter>(?<=G|P|I|J).)? # Optional delimiter for specific engines
@)? # Engine+delimiter are separated by a '@'
# character and are optional for the target
(?P<pattern>.+)$''' # The pattern passed to the target engine
)
def parse_target(target_expression):
'''Parse `target_expressing` splitting it into `engine`, `delimiter`,
`pattern` - returns a dict'''
match = TARGET_REX.match(target_expression)
return match.groupdict() if match else None
def get_minion_data(minion, opts):
'''
@ -196,6 +214,7 @@ class CkMinions(object):
).get(search_type)
if not salt.utils.subdict_match(search_results,
expr,
delimiter=delimiter,
regex_match=regex_match,
exact_match=exact_match) and id_ in minions:
minions.remove(id_)
@ -342,7 +361,7 @@ class CkMinions(object):
'''
Return the minions found by looking via compound matcher
'''
log.debug('_check_compound_minions({0})'.format(expr))
log.debug('_check_compound_minions({0}, {1}, {2}, {3})'.format(expr, delimiter, greedy, pillar_exact))
minions = set(
os.listdir(os.path.join(self.opts['pki_dir'], self.acc))
)
@ -354,64 +373,48 @@ class CkMinions(object):
'I': self._check_pillar_minions,
'J': self._check_pillar_pcre_minions,
'L': self._check_list_minions,
'N': None, # nodegroups should already be expanded
'S': self._check_ipcidr_minions,
'E': self._check_pcre_minions,
'R': self._all_minions}
if pillar_exact:
ref['I'] = self._check_pillar_exact_minions
ref['J'] = self._check_pillar_exact_minions
results = []
unmatched = []
opers = ['and', 'or', 'not', '(', ')']
tokens = expr.split()
for match in tokens:
# Try to match tokens from the compound target, first by using
# the 'G, X, I, J, L, S, E' matcher types, then by hostname glob.
if '@' in match and match[1] == '@':
comps = match.split('@', 1)
matcher = ref.get(comps[0])
words = expr.split()
for word in words:
target_info = parse_target(word)
matcher_args = [comps[1]]
if comps[0] in ('G', 'P', 'I', 'J'):
matcher_args.append(delimiter)
matcher_args.append(True)
if not matcher:
# If an unknown matcher is called at any time, fail out
log.error('Unknown matcher: {0}'.format(comps[0]))
return []
results.append(str(set(matcher(*matcher_args))))
if unmatched and unmatched[-1] == '-':
results.append(')')
unmatched.pop()
elif match in opers:
# We didn't match a target, so append a boolean operator or
# subexpression
# Easy check first
if word in opers:
if results:
if results[-1] == '(' and match in ('and', 'or'):
log.error('Invalid beginning operator after "(": {0}'.format(match))
if results[-1] == '(' and word in ('and', 'or'):
log.error('Invalid beginning operator after "(": {0}'.format(word))
return []
if match == 'not':
if word == 'not':
if not results[-1] in ('&', '|', '('):
results.append('&')
results.append('(')
results.append(str(set(minions)))
results.append('-')
unmatched.append('-')
elif match == 'and':
elif word == 'and':
results.append('&')
elif match == 'or':
elif word == 'or':
results.append('|')
elif match == '(':
results.append(match)
unmatched.append(match)
elif match == ')':
elif word == '(':
results.append(word)
unmatched.append(word)
elif word == ')':
if not unmatched or unmatched[-1] != '(':
log.error('Invalid compound expr (unexpected '
'right parenthesis): {0}'
.format(expr))
return []
results.append(match)
results.append(word)
unmatched.pop()
if unmatched and unmatched[-1] == '-':
results.append(')')
@ -422,27 +425,55 @@ class CkMinions(object):
return []
else:
# seq start with oper, fail
if match == 'not':
if word == 'not':
results.append('(')
results.append(str(set(minions)))
results.append('-')
unmatched.append('-')
elif match == '(':
results.append(match)
unmatched.append(match)
elif word == '(':
results.append(word)
unmatched.append(word)
else:
log.error('Expression cannot begin with binary operator: {0}'.format(match))
log.error(
'Expression may begin with'
' binary operator: {0}'.format(word)
)
return []
else:
# The match is not explicitly defined, evaluate as a glob
elif target_info and target_info.get('engine'):
if 'N' == target_info['engine']:
# Nodegroups should already be expanded/resolved to other engines
log.error('Detected nodegroup expansion failure of "{0}"'.format(word))
return []
engine = ref.get(target_info['engine'])
if not engine:
# If an unknown engine is called at any time, fail out
log.error(
'Unrecognized target engine "{0}" for'
' target expression "{1}"'.format(
target_info['engine'],
word,
)
)
return []
engine_args = [target_info.get('pattern')]
if target_info['engine'] in ('G', 'P', 'I', 'J'):
engine_args.append(target_info.get('delimiter') or ':')
engine_args.append(True)
results.append(str(set(engine(*engine_args))))
if unmatched and unmatched[-1] == '-':
results.append(
str(set(self._check_glob_minions(match, True))))
results.append(')')
unmatched.pop()
else:
results.append(
str(set(self._check_glob_minions(match, True))))
else:
# The match is not explicitly defined, evaluate as a glob
results.append(str(set(self._check_glob_minions(word, True))))
if unmatched and unmatched[-1] == '-':
results.append(')')
unmatched.pop()
for token in unmatched:
results.append(')')
results = ' '.join(results)
@ -535,7 +566,8 @@ class CkMinions(object):
'L': 'list',
'S': 'ipcidr',
'E': 'pcre',
'N': 'node'}
'N': 'node',
None: 'glob'}
infinite = [
'node',
'ipcidr',
@ -545,13 +577,13 @@ class CkMinions(object):
infinite.append('grain')
infinite.append('grain_pcre')
if '@' in valid and valid[1] == '@':
comps = valid.split('@', 1)
v_matcher = ref.get(comps[0])
v_expr = comps[1]
else:
v_matcher = 'glob'
v_expr = valid
target_info = parse_target(valid)
if not target_info:
log.error('Failed to parse valid target "{0}"'.format(valid))
v_matcher = ref.get(target_info.get('engine'))
v_expr = target_info.get('pattern')
if v_matcher in infinite:
# We can't be sure what the subset is, only match the identical
# target

View file

@ -72,6 +72,22 @@ class MatchTest(integration.ShellCase, integration.ShellCaseCommonTestsMixIn):
data = self.run_salt("-C '* and ( not G@test_grain:cheese )' test.ping")
self.assertFalse(minion_in_returns('minion', data))
self.assertTrue(minion_in_returns('sub_minion', data))
time.sleep(2)
data = self.run_salt("-C 'G%@planets%merc*' test.ping")
self.assertTrue(minion_in_returns('minion', data))
self.assertFalse(minion_in_returns('sub_minion', data))
time.sleep(2)
data = self.run_salt("-C 'P%@planets%^(mercury|saturn)$' test.ping")
self.assertTrue(minion_in_returns('minion', data))
self.assertTrue(minion_in_returns('sub_minion', data))
time.sleep(2)
data = self.run_salt("-C 'I%@companions%three%sarah*' test.ping")
self.assertTrue(minion_in_returns('minion', data))
self.assertTrue(minion_in_returns('sub_minion', data))
time.sleep(2)
data = self.run_salt("-C 'J%@knights%^(Lancelot|Galahad)$' test.ping")
self.assertTrue(minion_in_returns('minion', data))
self.assertTrue(minion_in_returns('sub_minion', data))
def test_nodegroup(self):
'''