From 58397b444e6b34b8046466b10a4437cde7a6eabe Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 03:44:59 -0400 Subject: [PATCH] Exit non-zero from batch mode when no minions match In batch mode the salt CLI computed its exit code by iterating batch.run(), which yields nothing when the target matches no minions (Batch.run returns early on an empty minion list). retcode stayed 0 and the CLI exited 0, while the non-batch path exits 2 ("No return received") for the same case (#57357). Mirror the non-batch behavior: when no minions matched, print "No return received" and exit 2. Fixes #57357 --- changelog/57357.fixed.md | 1 + salt/cli/salt.py | 6 +++ tests/pytests/unit/cli/test_salt.py | 66 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 changelog/57357.fixed.md create mode 100644 tests/pytests/unit/cli/test_salt.py 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