Add match runner

This commit is contained in:
jeanluc 2022-12-11 11:49:02 +01:00 committed by Megan Wilhite
parent 0cb3dc87e7
commit 0873ece26c
5 changed files with 372 additions and 0 deletions

1
changelog/63278.added Normal file
View file

@ -0,0 +1 @@
Added match runner

View file

@ -30,6 +30,7 @@ runner modules
launchd
lxc
manage
match
mattermost
mine
nacl

View file

@ -0,0 +1,5 @@
salt.runners.match
===================
.. automodule:: salt.runners.match
:members:

69
salt/runners/match.py Normal file
View file

@ -0,0 +1,69 @@
"""
Run matchers from the master context.
.. versionadded:: 3007.0
"""
import logging
import salt.utils.minions
import salt.utils.verify
from salt.defaults import DEFAULT_TARGET_DELIM
log = logging.getLogger(__name__)
def compound_matches(expr, minion_id):
"""
Check whether a minion is matched by a given compound match expression.
Pillar values will be matched literally only since this function is intended
for remote calling. This also applies to node groups defined on the master.
.. note::
If a module calls this runner from a minion, you will need to explicitly
allow the remote call. See :conf_master:`peer_run`.
.. note::
Custom matchers are not respected.
CLI Example:
.. code-block:: bash
salt-run match.compound_matches 'I@foo:bar and G@os:Deb* and not db*' myminion
expr
The :term:`Compound Matcher` expression to validate against.
minion_id
The minion ID of the minion to check the match for.
"""
try:
# Ensure that if the minion data cache is disabled, we always return
# False. This is because the matcher will return a list of all minions
# in that case (assumption is greedy).
if not __opts__.get("minion_data_cache", True):
log.warning(
"Minion data cache is disabled. Cannot evaluate compound matcher expression."
)
return False
# Ensure the passed minion ID is valid and exists.
if not salt.utils.verify.valid_id(__opts__, minion_id):
log.warning("Got invalid minion ID.")
return False
log.debug(f"Evaluating if minion '{minion_id}' is matched by '{expr}'.")
ckminions = salt.utils.minions.CkMinions(__opts__)
# Compound expressions are usually evaluated in greedy mode since you
# want to make sure the executing user has privileges to run a command on
# any possibly matching minion, including those with uncached data.
# This function has the opposite requirements, we want to make absolutely
# sure the minion is matched by the expression.
# Thus we do not include minions whose data has not been cached (greedy=False).
# Also, allow exact pillar matches only to make enumeration attacks harder.
minions = ckminions._check_compound_pillar_exact_minions(
expr, DEFAULT_TARGET_DELIM, greedy=False
)
return minion_id in minions["minions"]
except Exception: # pylint: disable=broad-except
pass
return False

View file

@ -0,0 +1,296 @@
"""
Integration tests for the match runner
"""
import logging
import pytest
log = logging.getLogger(__name__)
pytestmark = [
pytest.mark.slow_test,
]
@pytest.fixture(scope="class")
def match_master_config():
return {
"open_mode": True,
"peer_run": {
"match-minion-bob": [
"match.compound_matches",
],
},
"nodegroups": {
"alice_eve": "I@name:ali*",
"alice": "L@match-minion-alice",
},
"minion_data_cache": True,
}
@pytest.fixture(scope="class", autouse=True)
def pillar_tree(match_salt_master, match_salt_minion_alice, match_salt_minion_eve):
top_file = f"""
base:
'{match_salt_minion_alice.id}':
- alice
'{match_salt_minion_eve.id}':
- eve
"""
alice_pillar_file = """
name: alice
"""
eve_pillar_file = """
name: alice_whoops_sorry_eve_hrhr
"""
top_tempfile = match_salt_master.pillar_tree.base.temp_file("top.sls", top_file)
alice_tempfile = match_salt_master.pillar_tree.base.temp_file(
"alice.sls", alice_pillar_file
)
eve_tempfile = match_salt_master.pillar_tree.base.temp_file(
"eve.sls", eve_pillar_file
)
with top_tempfile, alice_tempfile, eve_tempfile:
ret = match_salt_minion_alice.salt_call_cli().run(
"saltutil.refresh_pillar", wait=True
)
assert ret.returncode == 0
assert ret.data is True
ret = match_salt_minion_eve.salt_call_cli().run(
"saltutil.refresh_pillar", wait=True
)
assert ret.returncode == 0
assert ret.data is True
yield
@pytest.fixture(scope="class")
def match_salt_master(salt_factories, match_master_config):
factory = salt_factories.salt_master_daemon(
"match-master", defaults=match_master_config
)
with factory.started():
yield factory
@pytest.fixture(scope="class")
def match_salt_minion_alice(match_salt_master):
assert match_salt_master.is_running()
factory = match_salt_master.salt_minion_daemon(
"match-minion-alice",
defaults={"open_mode": True, "grains": {"role": "alice"}},
)
with factory.started():
# Sync All
salt_call_cli = factory.salt_call_cli()
ret = salt_call_cli.run("saltutil.sync_all", _timeout=120)
assert ret.returncode == 0, ret
yield factory
@pytest.fixture(scope="class")
def match_salt_minion_eve(match_salt_master):
assert match_salt_master.is_running()
factory = match_salt_master.salt_minion_daemon(
"match-minion-eve",
defaults={"open_mode": True, "grains": {"role": "eve"}},
)
with factory.started():
# Sync All
salt_call_cli = factory.salt_call_cli()
ret = salt_call_cli.run("saltutil.sync_all", _timeout=120)
assert ret.returncode == 0, ret
yield factory
@pytest.fixture(scope="class")
def match_salt_minion_bob(match_salt_master):
assert match_salt_master.is_running()
factory = match_salt_master.salt_minion_daemon(
"match-minion-bob",
defaults={"open_mode": True},
)
with factory.started():
# Sync All
salt_call_cli = factory.salt_call_cli()
ret = salt_call_cli.run("saltutil.sync_all", _timeout=120)
assert ret.returncode == 0, ret
yield factory
@pytest.fixture(scope="class")
def match_salt_call_cli(match_salt_minion_bob):
return match_salt_minion_bob.salt_call_cli()
@pytest.fixture(scope="class")
def match_salt_run_cli(match_salt_master):
return match_salt_master.salt_run_cli()
class TestMatchCompoundRunner:
@pytest.fixture
def alice_uncached(self, match_salt_minion_alice, match_salt_run_cli):
ret = match_salt_run_cli.run("cache.clear_all", "match-minion-alice")
assert ret.returncode == 0
yield
ret = match_salt_minion_alice.salt_call_cli().run("pillar.items")
@pytest.fixture
def eve_cached(self, match_salt_minion_eve):
ret = match_salt_minion_eve.salt_call_cli().run("pillar.items")
assert ret.returncode == 0
yield
@pytest.fixture
def alice_down(self, match_salt_minion_alice):
with match_salt_minion_alice.stopped():
yield
@pytest.mark.parametrize(
"expr,expected",
[
("G@role:alice", True),
("G@role:ali*", True),
(r"E@match\-minion\-(alice|bob)", True),
("P@role:^(alice|bob)$", True),
("L@match-minion-alice,match-minion-bob", True),
("I@name:alice", True),
("I@name:ali*", False),
("J@name:alice", True),
("J@name:^(alice|bob)$", False),
("N@alice", True),
("N@alice_eve", False),
("G@role:ali* and I@name:alice", True),
("G@role:ali* and I@name:ali*", False),
("G@role:ali* or I@name:ali*", True),
("G@role:ali* and not I@name:alice", False),
],
)
def test_match_compound_matches(self, match_salt_run_cli, expr, expected):
ret = match_salt_run_cli.run(
"match.compound_matches", expr, "match-minion-alice"
)
assert ret.returncode == 0
assert ret.data is expected
@pytest.mark.usefixtures("eve_cached")
def test_match_compound_matches_only_allows_exact_pillar_matching(
self, match_salt_run_cli
):
"""
This check is mostly redundant, but better check explicitly with the scenario
to prevent because it is security-critical.
"""
ret = match_salt_run_cli.run(
"match.compound_matches", "I@name:alic*", "match-minion-eve"
)
assert ret.returncode == 0
assert ret.data is False
@pytest.mark.parametrize(
"expr,expected",
[
("G@role:alice", False),
("G@role:ali*", False),
("I@name:alice", False),
("I@name:ali*", False),
("G@role:ali* and I@name:alice", False),
("L@match-minion-alice,match-minion-bob", True),
("L@match-minion-alice,match-minion-bob and G@role:alice", False),
("L@match-minion-alice,match-minion-bob and I@name:alice", False),
],
)
@pytest.mark.usefixtures("alice_uncached")
def test_match_compound_matches_with_uncached_minion_data(
self, match_salt_run_cli, expr, expected
):
ret = match_salt_run_cli.run(
"match.compound_matches", expr, "match-minion-alice"
)
assert ret.returncode == 0
assert ret.data is expected
@pytest.mark.parametrize(
"expr,expected",
[
("G@role:alice", True),
("G@role:ali*", True),
("I@name:alice", True),
("I@name:ali*", False),
("G@role:ali* and I@name:alice", True),
("L@match-minion-alice,match-minion-bob", True),
],
)
@pytest.mark.usefixtures("alice_down")
def test_match_compound_matches_when_minion_is_down(
self, match_salt_run_cli, expr, expected
):
ret = match_salt_run_cli.run(
"match.compound_matches", expr, "match-minion-alice"
)
assert ret.returncode == 0
assert ret.data is expected
@pytest.mark.parametrize(
"minion_id",
[
"hi\\there",
"my/minion",
"nonexistent",
],
)
def test_match_compound_matches_with_invalid_minion_id(
self, minion_id, match_salt_run_cli
):
ret = match_salt_run_cli.run("match.compound_matches", "*", minion_id)
assert ret.returncode == 0
assert ret.data is False
@pytest.mark.parametrize(
"expr,expected",
[
("G@role:alice", True),
("G@role:ali*", True),
("I@name:alice", True),
("I@name:ali*", False),
("G@role:ali* and I@name:alice", True),
("L@match-minion-alice,match-minion-bob", True),
],
)
def test_match_compound_matches_as_peer_run(
self, match_salt_call_cli, expr, expected
):
ret = match_salt_call_cli.run(
"publish.runner", "match.compound_matches", [expr, "match-minion-alice"]
)
assert ret.returncode == 0
assert ret.data is expected
class TestMatchCompoundRunnerWithoutMinionDataCache:
@pytest.fixture(scope="class")
def match_master_config(self):
return {
"open_mode": True,
"minion_data_cache": False,
}
@pytest.mark.parametrize(
"expr",
[
"G@role:alice",
"G@role:ali*",
"I@name:alice",
"I@name:ali*",
"G@role:ali* and I@name:alice",
],
)
def test_match_compound_matches(self, match_salt_run_cli, expr):
ret = match_salt_run_cli.run(
"match.compound_matches", expr, "match-minion-alice"
)
assert ret.returncode == 0
assert ret.data is False