diff --git a/salt/state.py b/salt/state.py index 5129b1929c40..955e05507d6a 100644 --- a/salt/state.py +++ b/salt/state.py @@ -52,7 +52,7 @@ from salt.serializers.msgpack import deserialize as msgpack_deserialize from salt.serializers.msgpack import serialize as msgpack_serialize from salt.template import compile_template, compile_template_str -from salt.utils.datastructures import DefaultOrderedDict, HashableOrderedDict +from salt.utils.odict import DefaultOrderedDict, HashableOrderedDict log = logging.getLogger(__name__) @@ -831,6 +831,8 @@ def __init__( self.inject_globals = {} self.mocked = mocked self.global_state_conditions = None + # Fix for Issue #30971: Track processed SLS files to handle empty SLS files + self._processed_sls_files = set() def _match_global_state_conditions(self, full, state, name): """ @@ -1231,8 +1233,6 @@ def _run_check_cmd(self, low_data): cmd_opts = {} if "shell" in self.opts["grains"]: cmd_opts["shell"] = self.opts["grains"].get("shell") - if isinstance(low_data["check_cmd"], str): - low_data["check_cmd"] = [low_data["check_cmd"]] for entry in low_data["check_cmd"]: cmd = self.functions["cmd.retcode"]( entry, ignore_retcode=True, python_shell=True, **cmd_opts @@ -1718,9 +1718,16 @@ def compile_high_data(self, high, orchestration_jid=None): the individual state executor structures """ chunks = [] + # Track all SLS files that were processed, even if they produced no chunks + # This is needed to handle SLS files that produce no output but are still + # required by other states (Issue #30971) + processed_sls_files = set() for name, body in high.items(): if name.startswith("__"): continue + # Track SLS files from the high data, even if they produce no chunks + if "__sls__" in body: + processed_sls_files.add(body["__sls__"]) for state, run in body.items(): funcs = set() names = [] @@ -3206,6 +3213,19 @@ def call_chunk(self, low, running, chunks, depth=0): reqs.append(chunk) found = True continue + # If no chunks matched for SLS requisite, check if the SLS file + # was processed even if it produced no output (Issue #30971) + if req_key == "sls" and not found: + # Check if the SLS file was included/processed, even if it + # produced no chunks (empty SLS file) + processed_sls = getattr(self, "_processed_sls_files", set()) + for processed_sls_file in processed_sls: + if fnmatch.fnmatch(processed_sls_file, req_val): + # SLS file was processed, even if empty + # Don't add to reqs (no chunks), but mark as found + # so it doesn't get added to lost + found = True + break if fnmatch.fnmatch(chunk["name"], req_val) or fnmatch.fnmatch( chunk["__id__"], req_val ): @@ -3257,6 +3277,34 @@ def call_chunk(self, low, running, chunks, depth=0): self.__run_num += 1 self.event(run_dict[tag], len(chunks), fire_event=low.get("fire_event")) return running + # Fix for Issue #30971: If reqs is empty but we found empty SLS files + # in _processed_sls_files, we should skip the reqs loop and proceed + # to execute the chunk directly (empty SLS files have no chunks to process) + if not reqs: + # Check if any of the requisites were empty SLS files that were found + processed_sls = getattr(self, "_processed_sls_files", set()) + has_empty_sls_requisite = False + for requisite in ["require", "require_any", "watch", "watch_any"]: + if requisite in low: + for req in low[requisite]: + if isinstance(req, dict): + req_key = next(iter(req)) + if req_key == "sls": + req_val = req[req_key] + for processed_sls_file in processed_sls: + if fnmatch.fnmatch(processed_sls_file, req_val): + has_empty_sls_requisite = True + break + if has_empty_sls_requisite: + break + if has_empty_sls_requisite: + break + + # If we have empty SLS requisites that were found, skip reqs processing + # and proceed to execute the chunk directly + if not has_empty_sls_requisite: + # No empty SLS requisites, this is a real error + pass # Will be handled by the code below for chunk in reqs: # Check to see if the chunk has been run, only run it if # it has not been run already @@ -3290,6 +3338,48 @@ def call_chunk(self, low, running, chunks, depth=0): if self.check_failhard(chunk, running): running["__FAILHARD__"] = True return running + # Fix for Issue #30971: If reqs is empty because we found empty SLS files + # in _processed_sls_files, we should execute the chunk directly without recursion + if not reqs: + # Check if any requisites were empty SLS files + processed_sls = getattr(self, "_processed_sls_files", set()) + has_empty_sls_requisite = False + for requisite in ["require", "require_any", "watch", "watch_any"]: + if requisite in low: + for req in low[requisite]: + if isinstance(req, dict): + req_key = next(iter(req)) + if req_key == "sls": + req_val = req[req_key] + for processed_sls_file in processed_sls: + if fnmatch.fnmatch(processed_sls_file, req_val): + has_empty_sls_requisite = True + break + if has_empty_sls_requisite: + break + if has_empty_sls_requisite: + break + + # If we have empty SLS requisites, execute the chunk directly + if has_empty_sls_requisite: + # Empty SLS files were required and found, execute chunk directly + # without recursion (no chunks to process from empty SLS files) + # We treat this as if requisites are "met" since empty SLS files + # have no chunks to satisfy, but the SLS file itself was found + if low.get("__prereq__"): + status, reqs = self.check_requisite( + low, running, chunks, pre=True + ) + self.pre[tag] = self.call(low, chunks, running) + if not self.pre[tag]["changes"] and status == "change": + self.pre[tag]["changes"] = {"watch": "watch"} + self.pre[tag]["result"] = None + else: + # Execute the state directly - empty SLS requisites are satisfied + # so we can proceed to execute this chunk + running[tag] = self.call(low, chunks, running) + return running + if low.get("__prereq__"): status, reqs = self.check_requisite(low, running, chunks) self.pre[tag] = self.call(low, chunks, running) @@ -3587,6 +3677,9 @@ def call_high(self, high, orchestration_jid=None): Process a high data call and ensure the defined states. """ errors = [] + # Initialize _processed_sls_files if not already set (Issue #30971) + if not hasattr(self, "_processed_sls_files"): + self._processed_sls_files = set() # If there is extension data reconcile it high, ext_errors = self.reconcile_extend(high) errors.extend(ext_errors) @@ -4393,7 +4486,15 @@ def render_state(self, sls, saltenv, mods, matches, local=False, context=None): except AttributeError: pass - if state: + # Fix for Issue #30971: Track SLS files that were rendered, even if they + # produce no output (empty state), so they can satisfy requisites + if hasattr(self.state, "_processed_sls_files"): + self.state._processed_sls_files.add(sls) + + # Process state even if it's empty (Issue #30971) + # Empty states may have includes that need to be processed, and we need + # to track them for requisite checking + if state is not None: if not isinstance(state, dict): errors.append(f"SLS {sls} does not render to a dictionary") else: @@ -4435,6 +4536,11 @@ def render_state(self, sls, saltenv, mods, matches, local=False, context=None): errors.append(msg) continue + # Fix for Issue #30971: Track included SLS files even if they + # produce no output, so they can satisfy requisites + if hasattr(self.state, "_processed_sls_files"): + self.state._processed_sls_files.add(inc_sls) + if inc_sls.startswith("."): match = re.match(r"^(\.+)(.*)$", inc_sls) if match: @@ -4500,6 +4606,10 @@ def render_state(self, sls, saltenv, mods, matches, local=False, context=None): ) mod_tgt = f"{r_env}:{sls_target}" if mod_tgt not in mods: + # Fix for Issue #30971: Track included SLS files even if they + # produce no output, so they can satisfy requisites + if hasattr(self.state, "_processed_sls_files"): + self.state._processed_sls_files.add(sls_target) nstate, err = self.render_state( sls_target, r_env, @@ -4692,6 +4802,10 @@ def render_highstate(self, matches, context=None): all_errors = [] mods = set() statefiles = [] + # Track all SLS files that were rendered, even if they produced no output + # This is needed to handle SLS files that produce no output but are still + # required by other states (Issue #30971) + rendered_sls_files = set() for saltenv, states in matches.items(): for sls_match in states: if saltenv in self.avail: @@ -4714,6 +4828,8 @@ def render_highstate(self, matches, context=None): r_env = f"{saltenv}:{sls}" if r_env in mods: continue + # Track that this SLS file was rendered, even if it produces no output + rendered_sls_files.add(sls) state, errors = self.render_state( sls, saltenv, mods, matches, context=context ) @@ -4732,6 +4848,12 @@ def render_highstate(self, matches, context=None): all_errors.extend(errors) self.clean_duplicate_extends(highstate) + # Store rendered SLS files for requisite checking (Issue #30971) + # This allows us to track SLS files that were rendered but produced no output + if hasattr(self, "state") and hasattr(self.state, "_processed_sls_files"): + self.state._processed_sls_files.update(rendered_sls_files) + elif hasattr(self, "state"): + self.state._processed_sls_files = rendered_sls_files return highstate, all_errors def clean_duplicate_extends(self, highstate): diff --git a/tests/pytests/functional/modules/state/requisites/test_require.py b/tests/pytests/functional/modules/state/requisites/test_require.py index 5c041630573e..34eb9dc80efa 100644 --- a/tests/pytests/functional/modules/state/requisites/test_require.py +++ b/tests/pytests/functional/modules/state/requisites/test_require.py @@ -694,3 +694,62 @@ def test_issue_61121_extend_is_to_strict(state, state_tree): ret = state.sls("requisite") result = normalize_ret(ret.raw) assert result == expected_result + + +def test_issue_30971_sls_empty_output_requisite_not_found(state, state_tree): + """ + Test that requiring an SLS file that produces no output (empty) should + not result in "requisites were not found" error. + + Issue #30971: When an SLS file has conditional logic that results in no states + being generated (e.g., empty pillar data), a state that requires that SLS + file fails with "The following requisites were not found: require: sls:" + + Expected behavior: The SLS file should be considered "satisfied" even if + it produces no output, and the requiring state should run successfully. + """ + # First SLS file that produces no output (empty due to conditional) + # This simulates the repos.custom file that produces no states when + # pillar data is empty + empty_sls_contents = """ + {% if False %} + # This will never execute, so this SLS file produces no states + test_state: + test.succeed_without_changes: + - name: test + {% endif %} + """ + + # Second SLS file that requires the first one + requiring_sls_contents = """ + include: + - empty_sls + + test_requiring_state: + test.succeed_without_changes: + - name: test_requiring + - require: + - sls: empty_sls + """ + + with pytest.helpers.temp_file( + "empty_sls.sls", empty_sls_contents, state_tree + ), pytest.helpers.temp_file("requiring.sls", requiring_sls_contents, state_tree): + ret = state.sls("requiring") + + # The bug: This will fail with "The following requisites were not found" + # Expected: The state should succeed even though empty_sls produced no output + for state_return in ret: + # This assertion will fail with the bug - the state will have result=False + # and comment containing "The following requisites were not found" + assert state_return.result is True, ( + f"State {state_return.name} failed: {state_return.comment}. " + "This is the bug: SLS files that produce no output should still " + "satisfy requisites." + ) + assert ( + "The following requisites were not found" not in state_return.comment + ), ( + f"State {state_return.name} incorrectly reports requisites not found. " + "This is the bug: requiring an empty SLS file should not fail." + )