diff --git a/changelog/63278.added b/changelog/63278.added new file mode 100644 index 00000000000..5bbf1535cc2 --- /dev/null +++ b/changelog/63278.added @@ -0,0 +1 @@ +Added match runner diff --git a/doc/ref/runners/all/index.rst b/doc/ref/runners/all/index.rst index 3a9409b4e37..c4817d28703 100644 --- a/doc/ref/runners/all/index.rst +++ b/doc/ref/runners/all/index.rst @@ -30,6 +30,7 @@ runner modules launchd lxc manage + match mattermost mine nacl diff --git a/doc/ref/runners/all/salt.runners.match.rst b/doc/ref/runners/all/salt.runners.match.rst new file mode 100644 index 00000000000..113419e19f5 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.match.rst @@ -0,0 +1,5 @@ +salt.runners.match +=================== + +.. automodule:: salt.runners.match + :members: diff --git a/salt/runners/match.py b/salt/runners/match.py new file mode 100644 index 00000000000..9d9faa4fa66 --- /dev/null +++ b/salt/runners/match.py @@ -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 diff --git a/tests/pytests/integration/runners/test_match.py b/tests/pytests/integration/runners/test_match.py new file mode 100644 index 00000000000..e9d44d3cd42 --- /dev/null +++ b/tests/pytests/integration/runners/test_match.py @@ -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