Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/68780.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fix `jobs.list_jobs` crashing with a `TypeError` when the job cache has a
`Target` value that is neither a string nor an iterable (e.g. `None` from a
failed or aborted job). Non-iterable targets are now treated as no target
so the remaining `search_target` filtering keeps working.
12 changes: 12 additions & 0 deletions salt/runners/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
A convenience system to manage jobs, both active and already run
"""

import collections.abc
import fnmatch
import logging
import os
Expand Down Expand Up @@ -334,6 +335,17 @@ def list_jobs(
targets = ret[item]["Target"]
if isinstance(targets, str):
targets = [targets]
elif not isinstance(targets, collections.abc.Iterable):
# Failed/aborted jobs can leave Target as None or some
# other non-iterable value in the cache; iterating would
# raise TypeError. Log once at debug level and treat as
# no target so the rest of list_jobs keeps working.
log.debug(
"list_jobs: ignoring non-iterable Target %r on jid %s",
targets,
item,
)
targets = []
for target in targets:
for key in salt.utils.args.split_input(search_target):
if fnmatch.fnmatch(target, key):
Expand Down
54 changes: 54 additions & 0 deletions tests/pytests/unit/runners/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,57 @@ def __init__(self, *args, **kwargs):
assert jobs.list_jobs(search_target="node-1-2.com") == returns["node-1-2.com"]

assert jobs.list_jobs(search_target="non-existant") == returns["non-existant"]


def test_list_jobs_with_non_iterable_target(caplog):
"""
Regression for #68780: failed/aborted jobs can leave Target as None or
some other non-iterable value in the job cache. list_jobs used to
iterate blindly and crash with TypeError; it must now skip those
entries and continue filtering the rest of the cache.
"""
mock_jobs_cache = {
# Valid entry with a real target - must still be matched.
"20260421000000000001": {
"Arguments": [],
"Function": "test.ping",
"StartTime": "2026, Apr 21 00:00:00.000001",
"Target": "node-1-1.com",
"Target-type": "glob",
"User": "root",
},
# Non-iterable Target (None is the real-world case from the bug
# report). Must not trip list_jobs, and must be logged at debug.
"20260421000000000002": {
"Arguments": [],
"Function": "test.ping",
"StartTime": "2026, Apr 21 00:00:00.000002",
"Target": None,
"Target-type": "glob",
"User": "root",
},
}

def return_mock_jobs():
return mock_jobs_cache

class MockMasterMinion:
returners = {"local_cache.get_jids": return_mock_jobs}

def __init__(self, *args, **kwargs):
pass

with patch.object(salt.minion, "MasterMinion", MockMasterMinion):
import logging

with caplog.at_level(logging.DEBUG, logger="salt.runners.jobs"):
# Must return the entry that has a valid target without raising
# or masking the match quietly.
result = jobs.list_jobs(search_target="node-1-1.com")

assert "20260421000000000001" in result
assert "20260421000000000002" not in result
# The skipped job id should appear in the debug log so operators
# can find and fix the malformed cache row instead of discovering
# it via a crash.
assert "20260421000000000002" in caplog.text