diff --git a/changelog/57357.fixed.md b/changelog/57357.fixed.md new file mode 100644 index 000000000000..4da722138887 --- /dev/null +++ b/changelog/57357.fixed.md @@ -0,0 +1 @@ +Fixed the ``salt`` CLI exiting 0 in batch mode when the target matched no minions; it now exits 2 ("No return received"), matching the non-batch behavior. diff --git a/salt/cli/salt.py b/salt/cli/salt.py index 9c28e5de3d62..29bd6a29aadb 100644 --- a/salt/cli/salt.py +++ b/salt/cli/salt.py @@ -292,6 +292,12 @@ def _run_batch(self): if job_retcode > retcode: # Exit with the highest retcode we find retcode = job_retcode + if not batch.minions: + # No minions matched the target. Mirror the non-batch CLI, + # which prints "No return received" and exits 2 rather than + # silently exiting 0 (#57357). + sys.stderr.write("ERROR: No return received\n") + sys.exit(2) sys.exit(retcode) def _print_errors_summary(self, errors): diff --git a/tests/pytests/unit/cli/test_salt.py b/tests/pytests/unit/cli/test_salt.py new file mode 100644 index 000000000000..8df2a2124b81 --- /dev/null +++ b/tests/pytests/unit/cli/test_salt.py @@ -0,0 +1,66 @@ +""" +Unit tests for the salt CLI (salt.cli.salt.SaltCMD). +""" + +import pytest + +from salt.cli.salt import SaltCMD +from tests.support.mock import MagicMock, patch + + +def _fake_saltcmd(): + """ + A stand-in SaltCMD self with just the attributes _run_batch's non-static + branch touches, so the method can be exercised without full CLI parsing. + """ + fake = MagicMock() + fake.config = {} + fake.options.eauth = "" + fake.options.static = False + fake.options.batch = "100%" + return fake + + +def _run_batch_exit_code(fake_batch): + fake = _fake_saltcmd() + with patch("salt.cli.batch.Batch", return_value=fake_batch): + with pytest.raises(SystemExit) as exc: + SaltCMD._run_batch(fake) + return exc.value.code + + +def test_run_batch_no_minions_exits_nonzero(): + """ + Regression test for #57357. + + When a batch run matches zero minions, ``batch.run()`` yields nothing. The + CLI must exit non-zero -- matching the non-batch path, which prints + "No return received" and exits 2 -- instead of silently exiting 0. Pins the + bug: before the fix the empty loop leaves ``retcode=0`` and the CLI exits 0. + """ + fake_batch = MagicMock() + fake_batch.run.return_value = iter([]) + fake_batch.minions = [] + assert _run_batch_exit_code(fake_batch) == 2 + + +def test_run_batch_matched_minions_uses_highest_job_retcode(): + """ + Inverse of #57357: when minions match, the exit code is the highest job + retcode seen, and the no-return path is not taken. + """ + fake_batch = MagicMock() + fake_batch.run.return_value = iter([({"m1": {}}, 0), ({"m2": {}}, 3)]) + fake_batch.minions = ["m1", "m2"] + assert _run_batch_exit_code(fake_batch) == 3 + + +def test_run_batch_matched_minions_success_exits_zero(): + """ + A successful batch that matched minions still exits 0 -- the fix must not + regress the normal path. + """ + fake_batch = MagicMock() + fake_batch.run.return_value = iter([({"m1": {}}, 0)]) + fake_batch.minions = ["m1"] + assert _run_batch_exit_code(fake_batch) == 0