Skip to content

Commit fcf3395

Browse files
Daniel A. Wozniakdwoz
authored andcommitted
test(resources): pin pillar+discovery contract with unit and integration coverage
Adds tests for the discovery side of the resources framework, which had thin coverage relative to targeting. Three test buckets: Unit (tests/pytests/unit/test_minion_resources.py, 7 new tests): - One type's discover() raising leaves the other types' results intact - Type listed in pillar with no <type>.discover function is skipped with a warning, others still discovered - discover() returning None or [] drops the type from the result - discover() returning a tuple or generator is coerced to a list - discover() returning ids NOT in pillar is stored verbatim (pins the current "discover is authoritative" contract; will need updating when union-with-override lands) Integration: - A synthetic dynamic_test resource type, source injected via temp_file into the master's file_roots and synced down to the minion via saltutil.sync_resources. Its discover() reads from a top-level pillar key rather than the standard resources subtree, exposing the "discover-only" code path no shipping type exercises today. - test_dynamic_discovery.py (4 tests): ids not in pillar register, discover overrides pillar.resource_ids, refresh picks up additions, removed ids disappear from master. - test_custom_pillar_key.py (4 tests): non-default resource_pillar_key end-to-end — discovery, targeting, no default-key pollution. - test_dummy_resource.py: pillar_addition_at_runtime test as the inverse of the stale-cache removal test. Smoke list updated with the two new integration files. Locks current behavior down so the declarative-resources work can change the discover→pillar merge contract deliberately rather than silently.
1 parent ab5e1c0 commit fcf3395

6 files changed

Lines changed: 741 additions & 0 deletions

File tree

tests/pytests/integration/resources/conftest.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
MINION_ID = "resources-minion"
1717
MINION_ID_2 = "resources-minion-2"
18+
MINION_ID_DYN = "resources-minion-dyn"
1819

1920
# Dummy resource IDs that the minion manages in every test in this package.
2021
DUMMY_RESOURCES = ["dummy-01", "dummy-02", "dummy-03"]
@@ -137,6 +138,252 @@ def salt_call_cli(salt_minion):
137138
return salt_minion.salt_call_cli(timeout=60)
138139

139140

141+
# ---------------------------------------------------------------------------
142+
# Synthetic ``dynamic_test`` resource type
143+
# ---------------------------------------------------------------------------
144+
#
145+
# A test-only resource type whose ``discover()`` reads its ids from a
146+
# top-level pillar key (``_dynamic_test_ids``) — NOT from the standard
147+
# ``resources:`` subtree. That gives us a way to exercise the
148+
# "discover is the sole source of ids; pillar's resources subtree only
149+
# enables the type" code path end-to-end. No shipping resource type does
150+
# this today; dummy and ssh both re-read pillar's resources subtree.
151+
#
152+
# The type's source is injected into the master's file_roots via
153+
# ``temp_file`` and synced down to the minion via
154+
# ``saltutil.sync_resources``. Nothing lands in the source tree.
155+
156+
DYNAMIC_TEST_RTYPE = "dynamic_test"
157+
158+
DYNAMIC_TEST_SOURCE = '''\
159+
"""
160+
Test-only resource type whose ``discover()`` reads ids from a top-level
161+
pillar key (``_dynamic_test_ids``) rather than the standard ``resources:``
162+
subtree. Used by ``test_dynamic_discovery.py`` to exercise the
163+
"discover is authoritative for ids" code path end-to-end.
164+
"""
165+
166+
import logging
167+
168+
log = logging.getLogger(__name__)
169+
170+
171+
def __virtual__():
172+
return True
173+
174+
175+
def init(opts):
176+
__context__["dynamic_test"] = {"initialized": True}
177+
178+
179+
def initialized():
180+
return __context__.get("dynamic_test", {}).get("initialized", False)
181+
182+
183+
def discover(opts):
184+
"""Return ids from the top-level ``_dynamic_test_ids`` pillar key.
185+
186+
NOT from ``opts["pillar"]["resources"]["dynamic_test"]`` — this is
187+
the whole point of the type.
188+
"""
189+
ids = opts.get("pillar", {}).get("_dynamic_test_ids", []) or []
190+
log.debug("dynamic_test discover() returning: %s", ids)
191+
return list(ids)
192+
193+
194+
def grains():
195+
return {"dynamic_test_id": __resource__["id"]}
196+
197+
198+
def ping():
199+
return True
200+
201+
202+
def shutdown(opts):
203+
__context__.pop("dynamic_test", None)
204+
'''
205+
206+
207+
@pytest.fixture(scope="module")
208+
def dynamic_test_type(salt_master):
209+
"""
210+
Place the synthetic dynamic_test resource type in the master's
211+
file_roots under ``_resources/dynamic_test/__init__.py`` so a minion
212+
can pull it down with ``saltutil.sync_resources``.
213+
214+
Module-scoped so the file is present for the whole minion lifetime.
215+
Tests don't mutate the type's body — only the ``_dynamic_test_ids``
216+
pillar key that the type's ``discover()`` reads.
217+
"""
218+
with salt_master.state_tree.base.temp_file(
219+
"_resources/dynamic_test/__init__.py", DYNAMIC_TEST_SOURCE
220+
) as path:
221+
yield path
222+
223+
224+
def sync_resources_and_refresh(salt_call_cli, timeout=120):
225+
"""Helper: sync resources and trigger re-discovery+re-registration.
226+
227+
Used by tests that mutate the dynamic_test ids file or pillar and
228+
need the master to pick up the change.
229+
"""
230+
ret = salt_call_cli.run("saltutil.sync_resources", _timeout=timeout)
231+
assert ret.returncode == 0, ret
232+
ret = salt_call_cli.run("saltutil.refresh_pillar", wait=True, _timeout=timeout)
233+
assert ret.returncode == 0, ret
234+
# Allow the background _register_resources_with_master to land.
235+
time.sleep(3)
236+
237+
238+
@pytest.fixture(scope="module")
239+
def pillar_tree_dynamic_resources(salt_master):
240+
"""
241+
Pillar for the dynamic_test minion: enables the ``dynamic_test``
242+
resource type in the standard ``resources:`` tree (no ids declared
243+
there) and seeds ``_dynamic_test_ids`` at the top level — that's
244+
what ``dynamic_test.discover()`` reads.
245+
246+
Initial id list is empty; tests use ``temp_file`` to replace the
247+
seed SLS on disk and then call ``saltutil.refresh_pillar``.
248+
"""
249+
top_file = textwrap.dedent(
250+
f"""
251+
base:
252+
'{MINION_ID_DYN}':
253+
- dynamic_resources
254+
"""
255+
)
256+
pillar_sls = textwrap.dedent(
257+
"""
258+
resources:
259+
dynamic_test: {}
260+
_dynamic_test_ids: []
261+
"""
262+
)
263+
with salt_master.pillar_tree.base.temp_file(
264+
"top.sls", top_file
265+
), salt_master.pillar_tree.base.temp_file("dynamic_resources.sls", pillar_sls):
266+
yield
267+
268+
269+
def write_dynamic_ids_pillar(salt_master, ids):
270+
"""Return a temp_file context manager that swaps the dynamic_test
271+
pillar to declare ``_dynamic_test_ids: ids``. Caller owns the
272+
context (use with ``with ...``) so cleanup restores the prior
273+
contents."""
274+
body = textwrap.dedent(
275+
f"""
276+
resources:
277+
dynamic_test: {{}}
278+
_dynamic_test_ids: {list(ids)!r}
279+
"""
280+
)
281+
return salt_master.pillar_tree.base.temp_file("dynamic_resources.sls", body)
282+
283+
284+
MINION_ID_CUSTOM_KEY = "resources-minion-custom-key"
285+
CUSTOM_PILLAR_KEY = "salt_resources"
286+
CUSTOM_KEY_DUMMY_RESOURCES = ["custom-01", "custom-02"]
287+
288+
289+
@pytest.fixture(scope="module")
290+
def pillar_tree_custom_key_resources(salt_master):
291+
"""
292+
Pillar for the custom-key minion: declares the dummy resources
293+
under a NON-default top-level key (``salt_resources``). Also adds an
294+
empty ``resources:`` block (the framework's default key) so we can
295+
assert the minion ignores the default when configured to use the
296+
alternate key.
297+
"""
298+
top_file = textwrap.dedent(
299+
f"""
300+
base:
301+
'{MINION_ID_CUSTOM_KEY}':
302+
- custom_key_resources
303+
"""
304+
)
305+
pillar_sls = textwrap.dedent(
306+
f"""
307+
resources: {{}}
308+
{CUSTOM_PILLAR_KEY}:
309+
dummy:
310+
resource_ids:
311+
- {CUSTOM_KEY_DUMMY_RESOURCES[0]}
312+
- {CUSTOM_KEY_DUMMY_RESOURCES[1]}
313+
"""
314+
)
315+
with salt_master.pillar_tree.base.temp_file(
316+
"top.sls", top_file
317+
), salt_master.pillar_tree.base.temp_file("custom_key_resources.sls", pillar_sls):
318+
yield
319+
320+
321+
@pytest.fixture(scope="module")
322+
def salt_minion_custom_pillar_key(salt_master, pillar_tree_custom_key_resources):
323+
"""
324+
Minion whose ``resource_pillar_key`` is overridden to
325+
``salt_resources``. Verifies discovery + registration + targeting
326+
work end-to-end against a non-default key.
327+
"""
328+
config_overrides = {
329+
"fips_mode": FIPS_TESTRUN,
330+
"encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1",
331+
"signing_algorithm": "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1",
332+
"multiprocessing": False,
333+
"resource_pillar_key": CUSTOM_PILLAR_KEY,
334+
}
335+
factory = salt_master.salt_minion_daemon(
336+
MINION_ID_CUSTOM_KEY,
337+
overrides=config_overrides,
338+
extra_cli_arguments_after_first_start_failure=["--log-level=info"],
339+
)
340+
factory.after_terminate(
341+
pytest.helpers.remove_stale_minion_key, salt_master, factory.id
342+
)
343+
with factory.started(start_timeout=240):
344+
salt_call_cli = factory.salt_call_cli()
345+
ret = salt_call_cli.run("saltutil.refresh_pillar", wait=True, _timeout=120)
346+
assert ret.returncode == 0, ret
347+
ret = salt_call_cli.run("saltutil.sync_all", _timeout=120)
348+
assert ret.returncode == 0, ret
349+
time.sleep(3)
350+
yield factory
351+
352+
353+
@pytest.fixture(scope="module")
354+
def salt_minion_dynamic(salt_master, pillar_tree_dynamic_resources, dynamic_test_type):
355+
"""A second minion configured for dynamic-discovery tests.
356+
357+
Module-scoped — startup is expensive (~10s). Tests rotate the
358+
dynamic ids in pillar between runs and call
359+
``sync_resources_and_refresh`` to propagate.
360+
"""
361+
config_overrides = {
362+
"fips_mode": FIPS_TESTRUN,
363+
"encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1",
364+
"signing_algorithm": "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1",
365+
"multiprocessing": False,
366+
}
367+
factory = salt_master.salt_minion_daemon(
368+
MINION_ID_DYN,
369+
overrides=config_overrides,
370+
extra_cli_arguments_after_first_start_failure=["--log-level=info"],
371+
)
372+
factory.after_terminate(
373+
pytest.helpers.remove_stale_minion_key, salt_master, factory.id
374+
)
375+
with factory.started(start_timeout=240):
376+
salt_call_cli = factory.salt_call_cli()
377+
# Pull the dynamic_test type down into the minion's extmods.
378+
ret = salt_call_cli.run("saltutil.sync_resources", _timeout=120)
379+
assert ret.returncode == 0, ret
380+
# Pillar refresh after sync so _discover_resources sees the new type.
381+
ret = salt_call_cli.run("saltutil.refresh_pillar", wait=True, _timeout=120)
382+
assert ret.returncode == 0, ret
383+
time.sleep(3)
384+
yield factory
385+
386+
140387
@pytest.fixture(scope="module")
141388
def salt_minion_2(salt_master, pillar_tree_dummy_resources):
142389
"""
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
End-to-end tests for the ``resource_pillar_key`` minion option.
3+
4+
By default the resources framework reads its declarations from
5+
``pillar["resources"]``. Operators can override that with the
6+
:conf_minion:`resource_pillar_key` config option — e.g. to avoid a
7+
collision with existing pillar that already uses ``resources`` for
8+
something else.
9+
10+
These tests exercise the full pipeline (discovery → registration →
11+
targeting) for a minion configured to read from ``salt_resources``
12+
instead. Companion to the unit coverage in
13+
``tests/pytests/unit/utils/test_resources.py``.
14+
"""
15+
16+
import json
17+
18+
import pytest
19+
20+
from tests.pytests.integration.resources.conftest import (
21+
CUSTOM_KEY_DUMMY_RESOURCES,
22+
CUSTOM_PILLAR_KEY,
23+
MINION_ID_CUSTOM_KEY,
24+
)
25+
26+
pytestmark = [pytest.mark.slow_test]
27+
28+
29+
def _parse_cli_dict(ret):
30+
"""Salt CLI sometimes returns the dict directly, sometimes unwrapped
31+
when a single resource matches. Re-derive a dict from stdout."""
32+
if isinstance(ret.data, dict):
33+
return ret.data
34+
return json.loads(ret.stdout.strip())
35+
36+
37+
def test_custom_pillar_key_discovers_resources(salt_minion_custom_pillar_key):
38+
"""
39+
The minion's ``opts["resources"]`` (populated by
40+
``_discover_resources``) must reflect the ids declared under the
41+
*custom* pillar key, not the default ``resources`` key.
42+
"""
43+
salt_call_cli = salt_minion_custom_pillar_key.salt_call_cli(timeout=60)
44+
ret = salt_call_cli.run("config.get", "resources")
45+
assert ret.returncode == 0, ret
46+
data = ret.data
47+
assert isinstance(data, dict), f"Expected dict, got: {data!r}"
48+
assert "dummy" in data, f"Custom-key discovery missed: {data!r}"
49+
dummy = data["dummy"]
50+
ids = dummy.get("resource_ids") if isinstance(dummy, dict) else dummy
51+
assert set(ids) == set(CUSTOM_KEY_DUMMY_RESOURCES), (
52+
f"Custom-key discovery returned wrong ids. "
53+
f"Expected {set(CUSTOM_KEY_DUMMY_RESOURCES)}, got {set(ids)}."
54+
)
55+
56+
57+
def test_custom_pillar_key_targets_resources(
58+
salt_minion_custom_pillar_key, salt_master
59+
):
60+
"""
61+
With the minion's resources registered under the custom key, the
62+
master must still target them by id (proves discovery →
63+
registration → targeting all honour the key).
64+
"""
65+
salt_cli = salt_master.salt_cli(timeout=60)
66+
ret = salt_cli.run("-C", "test.ping", minion_tgt="T@dummy")
67+
assert ret.returncode == 0, ret
68+
data = _parse_cli_dict(ret)
69+
for rid in CUSTOM_KEY_DUMMY_RESOURCES:
70+
assert rid in data, (
71+
f"Custom-key resource {rid!r} not in T@dummy response: " f"{list(data)}"
72+
)
73+
assert data[rid] is True
74+
75+
76+
def test_custom_pillar_key_default_key_does_not_pollute(
77+
salt_minion_custom_pillar_key,
78+
):
79+
"""
80+
The pillar also contains an empty ``resources:`` block (the
81+
framework's default key). The minion must NOT pick up those entries
82+
— only the configured key matters. Asserts the minion's discovered
83+
resources match exactly the ids under ``salt_resources``, with no
84+
extra entries from the default key.
85+
"""
86+
salt_call_cli = salt_minion_custom_pillar_key.salt_call_cli(timeout=60)
87+
ret = salt_call_cli.run("config.get", "resource_pillar_key")
88+
assert ret.returncode == 0, ret
89+
assert (
90+
ret.data == CUSTOM_PILLAR_KEY
91+
), f"Minion config reports wrong resource_pillar_key: {ret.data!r}"
92+
93+
ret = salt_call_cli.run("config.get", "resources")
94+
assert ret.returncode == 0, ret
95+
data = ret.data
96+
assert isinstance(data, dict), data
97+
# Only the custom-key dummy ids should appear; the default
98+
# ``resources: {}`` block must contribute nothing.
99+
assert set(data.keys()) == {
100+
"dummy"
101+
}, f"Default-key pollution: discovery picked up extra types {list(data)}"
102+
dummy = data["dummy"]
103+
ids = dummy.get("resource_ids") if isinstance(dummy, dict) else dummy
104+
assert set(ids) == set(CUSTOM_KEY_DUMMY_RESOURCES)
105+
106+
107+
def test_custom_pillar_key_minion_id_present(salt_minion_custom_pillar_key):
108+
"""Sanity: the custom-key minion's id is the expected fixture value."""
109+
assert salt_minion_custom_pillar_key.id == MINION_ID_CUSTOM_KEY

0 commit comments

Comments
 (0)