diff --git a/changelog/62446.added b/changelog/62446.added new file mode 100644 index 00000000000..86ad064ee97 --- /dev/null +++ b/changelog/62446.added @@ -0,0 +1 @@ +Add ability to provide conditions which convert normal state actions to no-op when true diff --git a/conf/minion b/conf/minion index 959cadae296..b6119aac9d8 100644 --- a/conf/minion +++ b/conf/minion @@ -600,6 +600,14 @@ # - require # - require_in +# If set, this parameter expects a dictionary of state module names as keys +# and list of conditions which must be satisfied in order to run any functions +# in that state module. +# +#global_state_conditions: +# "*": ["G@global_noop:false"] +# service: ["not G@virtual_subtype:chroot"] + ##### File Directory Settings ##### ########################################## # The Salt Minion can redirect all file server operations to a local directory, diff --git a/doc/ref/configuration/minion.rst b/doc/ref/configuration/minion.rst index cf9778ba5c7..7eaca6cfb6f 100644 --- a/doc/ref/configuration/minion.rst +++ b/doc/ref/configuration/minion.rst @@ -2472,6 +2472,21 @@ default configuration set up at install time. snapper_states_config: root +``global_state_conditions`` +------------------------- + +Default: ``None`` + +If set, this parameter expects a dictionary of state module names as keys and +list of conditions which must be satisfied in order to run any functions in that +state module. + +.. code-block:: yaml + + global_state_conditions: + "*": ["G@global_noop:false"] + service: ["not G@virtual_subtype:chroot"] + File Directory Settings ======================= diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 9e9a1124c18..c32362e269f 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -955,6 +955,7 @@ VALID_OPTS = immutabletypes.freeze( # client via the Salt API "netapi_allow_raw_shell": bool, "disabled_requisites": (str, list), + "global_state_conditions": dict, # Feature flag config "features": dict, "fips_mode": bool, @@ -1273,6 +1274,7 @@ DEFAULT_MINION_OPTS = immutabletypes.freeze( "schedule": {}, "ssh_merge_pillar": True, "disabled_requisites": [], + "global_state_conditions": None, "reactor_niceness": None, "fips_mode": False, } diff --git a/salt/state.py b/salt/state.py index 22f791e4d9c..ba0d7d10e76 100644 --- a/salt/state.py +++ b/salt/state.py @@ -787,6 +787,36 @@ class State: self.inject_globals = {} self.mocked = mocked + def _match_global_state_conditions(self, full, state, name): + """ + Return ``None`` if global state conditions are met. Otherwise, pass a + return dictionary which effectively creates a no-op outcome. + + This operation is "explicit allow", in that ANY state and condition + combination which matches will allow the state to be run. + """ + matches = [] + ret = None + ret_dict = { + "name": name, + "comment": "Failed to meet global state conditions. State not called.", + "changes": {}, + "result": None, + } + if isinstance(self.opts.get("global_state_conditions"), dict): + for state_match, conditions in self.opts["global_state_conditions"].items(): + if state_match in ["*", full, state]: + if isinstance(conditions, str): + conditions = [conditions] + if isinstance(conditions, list): + matches.extend( + self.functions["match.compound"](condition) + for condition in conditions + ) + if matches and not any(matches): + ret = ret_dict + return ret + def _gather_pillar(self): """ Whenever a state run starts, gather the pillar data fresh @@ -2046,7 +2076,11 @@ class State: instance.format_slots(cdata) tag = _gen_tag(low) try: - ret = instance.states[cdata["full"]](*cdata["args"], **cdata["kwargs"]) + ret = instance._match_global_state_conditions( + cdata["full"], low["state"], name + ) + if not ret: + ret = instance.states[cdata["full"]](*cdata["args"], **cdata["kwargs"]) except Exception as exc: # pylint: disable=broad-except log.debug( "An exception occurred in this state: %s", @@ -2305,9 +2339,13 @@ class State: ret = self.call_parallel(cdata, low) else: self.format_slots(cdata) - ret = self.states[cdata["full"]]( - *cdata["args"], **cdata["kwargs"] + ret = self._match_global_state_conditions( + cdata["full"], low["state"], low["name"] ) + if not ret: + ret = self.states[cdata["full"]]( + *cdata["args"], **cdata["kwargs"] + ) self.states.inject_globals = {} if ( "check_cmd" in low diff --git a/tests/pytests/unit/state/test_global_state_conditions.py b/tests/pytests/unit/state/test_global_state_conditions.py new file mode 100644 index 00000000000..54e6d995362 --- /dev/null +++ b/tests/pytests/unit/state/test_global_state_conditions.py @@ -0,0 +1,65 @@ +import logging + +import pytest + +import salt.config +import salt.state + +log = logging.getLogger(__name__) + + +@pytest.fixture +def minion_config(): + cfg = salt.config.DEFAULT_MINION_OPTS.copy() + cfg["file_client"] = "local" + cfg["id"] = "foo01" + return cfg + + +def test_global_state_conditions_unconfigured(minion_config): + state_obj = salt.state.State(minion_config) + ret = state_obj._match_global_state_conditions( + "test.succeed_with_changes", "test", "mytest" + ) + assert ret is None + + +@pytest.mark.parametrize("condition", [["foo01"], "foo01"]) +def test_global_state_conditions_match(minion_config, condition): + minion_config["global_state_conditions"] = { + "test": condition, + } + state_obj = salt.state.State(minion_config) + ret = state_obj._match_global_state_conditions( + "test.succeed_with_changes", "test", "mytest" + ) + assert ret is None + + +def test_global_state_conditions_no_match(minion_config): + minion_config["global_state_conditions"] = { + "test.succeed_with_changes": ["bar01"], + } + state_obj = salt.state.State(minion_config) + ret = state_obj._match_global_state_conditions( + "test.succeed_with_changes", "test", "mytest" + ) + assert ret == { + "changes": {}, + "comment": "Failed to meet global state conditions. State not called.", + "name": "mytest", + "result": None, + } + + +def test_global_state_conditions_match_one_of_many(minion_config): + minion_config["global_state_conditions"] = { + "test.succeed_with_changes": ["bar01"], + "test": ["baz01"], + "*": ["foo01"], + } + state_obj = salt.state.State(minion_config) + ret = state_obj._match_global_state_conditions( + "test.succeed_with_changes", "test", "mytest" + ) + assert ret is None