Skip to content

Commit 6e99078

Browse files
committed
Fix #33437: Prevent None values from overwriting existing pillar data
When merging pillar data, if one pillar file is empty (e.g., due to conditional rendering that produces no output), None values were overwriting existing non-empty dictionaries. This fix adds a _filter_none_overwrites function that recursively removes None values that would overwrite existing non-empty dicts during the pillar merge process. - Added _filter_none_overwrites function to salt/pillar/__init__.py - Applied filter before merging pillar state - Added test case to test_pillar.py to verify the fix
1 parent bcafbec commit 6e99078

File tree

2 files changed

+147
-9
lines changed

2 files changed

+147
-9
lines changed

salt/pillar/__init__.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import sys
1111
import time
1212
import traceback
13-
from collections import OrderedDict
1413

1514
import salt.channel.client
1615
import salt.ext.tornado.gen
@@ -30,6 +29,7 @@
3029
# causes an UnboundLocalError. This should be investigated and fixed, but until
3130
# then, leave the import directly below this comment intact.
3231
from salt.utils.dictupdate import merge
32+
from salt.utils.odict import OrderedDict
3333
from salt.version import __version__
3434

3535
log = logging.getLogger(__name__)
@@ -1111,13 +1111,48 @@ def render_pillar(self, matches, errors=None):
11111111
", ".join([f"'{e}'" for e in errors]),
11121112
)
11131113
continue
1114-
pillar = merge(
1115-
pillar,
1116-
pstate,
1117-
self.merge_strategy,
1118-
self.opts.get("renderer", "yaml"),
1119-
self.opts.get("pillar_merge_lists", False),
1120-
)
1114+
# Fix for Issue #33437: When a pillar file produces a structure
1115+
# with None values (e.g., {'program': {'modules': None}} when
1116+
# conditional doesn't match), we need to filter out None values
1117+
# that would overwrite existing non-empty dicts before merging.
1118+
def _filter_none_overwrites(existing, new_data):
1119+
"""Recursively remove None values that would overwrite existing non-empty dicts"""
1120+
if not isinstance(new_data, dict):
1121+
return new_data
1122+
filtered = {}
1123+
for key, value in new_data.items():
1124+
existing_value = existing.get(key) if isinstance(existing, dict) else None
1125+
1126+
if value is None:
1127+
# If existing value is a non-empty dict, skip this None to preserve it
1128+
if isinstance(existing_value, dict) and existing_value:
1129+
continue # Skip None that would overwrite existing dict
1130+
# Otherwise, include None (it's a valid value)
1131+
filtered[key] = None
1132+
elif isinstance(value, dict):
1133+
if isinstance(existing_value, dict):
1134+
# Recursively filter nested dicts
1135+
filtered_value = _filter_none_overwrites(existing_value, value)
1136+
if filtered_value: # Only include if not empty after filtering
1137+
filtered[key] = filtered_value
1138+
else:
1139+
filtered[key] = value
1140+
else:
1141+
filtered[key] = value
1142+
return filtered
1143+
1144+
# Filter out None values that would overwrite existing non-empty dicts
1145+
if pstate:
1146+
filtered_pstate = _filter_none_overwrites(pillar, pstate)
1147+
if filtered_pstate:
1148+
pillar = merge(
1149+
pillar,
1150+
filtered_pstate,
1151+
self.merge_strategy,
1152+
self.opts.get("renderer", "yaml"),
1153+
self.opts.get("pillar_merge_lists", False),
1154+
)
1155+
# If pstate is empty or only contains None values that would overwrite, skip merging
11211156

11221157
return pillar, errors
11231158

tests/pytests/unit/pillar/test_pillar.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import logging
2-
from collections import OrderedDict
32
from pathlib import Path
43

54
import pytest
65

76
import salt.loader
87
import salt.pillar
98
import salt.utils.cache
9+
from salt.utils.odict import OrderedDict
1010
from tests.support.mock import MagicMock
1111

1212

@@ -208,3 +208,106 @@ def test_remote_pillar_timeout(temp_salt_minion, tmp_path):
208208
msg = r"^Pillar timed out after \d{1,4} seconds$"
209209
with pytest.raises(salt.exceptions.SaltClientError):
210210
pillar.compile_pillar()
211+
212+
213+
def test_issue_33437_pillar_merge_empty_pillar_should_preserve_data(
214+
temp_salt_minion, tmp_path
215+
):
216+
"""
217+
Test that merging pillars when one is empty should preserve the non-empty data.
218+
219+
Issue #33437: When merging pillar data, if one pillar file is empty (e.g., due to
220+
conditional rendering that produces no output), None values were overwriting
221+
existing non-empty dictionaries. This test verifies that the fix preserves
222+
existing data when merging with empty/None values.
223+
"""
224+
# Create pillar roots
225+
pillar_base = tmp_path / "base"
226+
pillar_base.mkdir(parents=True)
227+
228+
# First pillar file with data
229+
common_pillar = pillar_base / "common.sls"
230+
common_pillar.write_text(
231+
"""
232+
program:
233+
modules:
234+
00-definitions.conf: null
235+
01-cleanup.conf: null
236+
"""
237+
)
238+
239+
# Second pillar file that may be empty (conditional rendering)
240+
modules_pillar = pillar_base / "modules.sls"
241+
modules_pillar.write_text(
242+
"""
243+
program:
244+
modules:
245+
{% if 'test' in grains.get('roles', []) %}
246+
10-dostuff.conf: null
247+
{% endif %}
248+
"""
249+
)
250+
251+
# Create top.sls
252+
top_sls = pillar_base / "top.sls"
253+
top_sls.write_text(
254+
"""
255+
base:
256+
'*':
257+
- common
258+
- modules
259+
"""
260+
)
261+
262+
opts = temp_salt_minion.config.copy()
263+
opts["pillar_roots"] = {"base": [str(pillar_base)]}
264+
265+
# Test case 1: Minion with 'test' role - modules.sls has content
266+
grains_with_role = salt.loader.grains(opts)
267+
grains_with_role["roles"] = ["test"]
268+
269+
pillar_with_role = salt.pillar.Pillar(
270+
opts, grains_with_role, temp_salt_minion.id, "base"
271+
)
272+
pillar_data_with_role = pillar_with_role.compile_pillar()
273+
274+
# Should have all three modules
275+
assert "program" in pillar_data_with_role
276+
assert "modules" in pillar_data_with_role["program"]
277+
modules_with_role = pillar_data_with_role["program"]["modules"]
278+
assert "00-definitions.conf" in modules_with_role
279+
assert "01-cleanup.conf" in modules_with_role
280+
assert "10-dostuff.conf" in modules_with_role
281+
282+
# Test case 2: Minion without 'test' role - modules.sls produces None for modules
283+
# THIS IS WHERE THE BUG OCCURRED - None was overwriting existing data
284+
grains_without_role = salt.loader.grains(opts)
285+
grains_without_role["roles"] = ["saltmaster"]
286+
287+
pillar_without_role = salt.pillar.Pillar(
288+
opts, grains_without_role, temp_salt_minion.id, "base"
289+
)
290+
pillar_data_without_role = pillar_without_role.compile_pillar()
291+
292+
# Expected: Should have the two modules from common.sls (None should not overwrite)
293+
assert "program" in pillar_data_without_role, (
294+
"Bug: When modules.sls produces None, the entire pillar becomes empty. "
295+
"Expected: program key should exist with data from common.sls"
296+
)
297+
assert "modules" in pillar_data_without_role["program"], (
298+
"Bug: modules key is missing. Expected: Should have modules from common.sls"
299+
)
300+
modules_without_role = pillar_data_without_role["program"]["modules"]
301+
assert modules_without_role is not None, (
302+
"Bug: modules is None. Expected: Should be a dict with data from common.sls"
303+
)
304+
assert "00-definitions.conf" in modules_without_role, (
305+
"Bug: Data from common.sls is lost when modules.sls produces None. "
306+
"Expected: 00-definitions.conf should be present"
307+
)
308+
assert "01-cleanup.conf" in modules_without_role, (
309+
"Bug: Data from common.sls is lost when modules.sls produces None. "
310+
"Expected: 01-cleanup.conf should be present"
311+
)
312+
# Should NOT have the conditional module
313+
assert "10-dostuff.conf" not in modules_without_role

0 commit comments

Comments
 (0)