Skip to content

Commit 6836d43

Browse files
committed
Fix stale resource cache, specific-name targeting, and add sync_resources
Three bugs fixed and one new feature added: 1. salt '*' test.ping was incorrectly dispatching to resources when targeting a specific minion by name (e.g. salt 'minion' test.ping). _resolve_resource_targets now only dispatches to resources for wildcard glob patterns (* ? [). The master-side _augment_with_resources guard is updated consistently. 2. Removing a resource type from the pillar and running sync_all left stale resource IDs in the master cache indefinitely, causing phantom entries in salt '*' output. Three co-operating fixes: - _discover_resources now distinguishes "no resources key in pillar" (preserve opts for config-file deployments) from "resources key present but empty" (authoritative removal — clear opts). - _register_resources_with_master no longer short-circuits on an empty resource dict; it always sends the registration so the master cache is overwritten with the current (possibly empty) state. - pillar_refresh re-discovers resources and re-registers with the master after successfully compiling a new pillar, so a sync_all picks up removals without requiring a minion restart. 3. _augment_with_resources had no error handling; a cache driver failure would propagate into check_minions and silently discard all PKI-based minion IDs. Cache errors are now caught, logged, and degraded gracefully. New: saltutil.sync_resources / refresh_resources — explicit resource sync modelled after sync_modules/refresh_modules. sync_all now includes ret["resources"] and fires a resource_refresh event that triggers _discover_resources + _register_resources_with_master on the minion.
1 parent bfcbe05 commit 6836d43

5 files changed

Lines changed: 245 additions & 59 deletions

File tree

salt/minion.py

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -556,19 +556,25 @@ def _discover_resources(self):
556556
``discover(opts)``; the return value is a dict of
557557
``{resource_type: [resource_id, ...]}``.
558558
559-
If pillar does not specify any resources, any resources already
560-
present in ``opts["resources"]`` (e.g. set directly in the minion
561-
config file) are preserved as-is. This supports testing and simple
562-
deployments where pillar-driven discovery is not needed.
559+
If the pillar contains no ``resources`` key at all, any resources
560+
already present in ``opts["resources"]`` (e.g. set directly in the
561+
minion config file) are preserved as-is. This supports testing and
562+
simple deployments where pillar-driven discovery is not needed.
563+
564+
If the pillar *does* contain a ``resources`` key (even if its value is
565+
empty / all entries removed), that is treated as an authoritative
566+
declaration and the result will reflect only what the pillar says.
567+
This ensures that removing a resource type from the pillar and running
568+
sync_all actually clears it at runtime.
563569
564570
Called from :meth:`gen_modules` after pillar is compiled and before
565571
the per-type execution-module loaders are created.
566572
"""
567-
pillar_resources = self.opts.get("pillar", {}).get("resources", {})
568-
if not pillar_resources:
569-
# No pillar resources — preserve whatever is already in opts
570-
# (set directly in the minion config or from a previous discovery).
573+
pillar = self.opts.get("pillar", {})
574+
if "resources" not in pillar:
575+
# Pillar has no opinion — preserve whatever is already in opts.
571576
return {k: list(v) for k, v in self.opts.get("resources", {}).items()}
577+
pillar_resources = pillar.get("resources") or {}
572578

573579
# A minimal resource loader is sufficient here — discover() only reads
574580
# from the opts dict passed to it and does not need other dunders.
@@ -3340,10 +3346,12 @@ async def _register_resources_with_master(self):
33403346
``minion_resources`` cache bank is up-to-date. This allows
33413347
:class:`salt.utils.minions.CkMinions` to include resource IDs when
33423348
expanding glob / non-compound targets (e.g. ``salt '*' test.ping``).
3349+
3350+
An empty resource dict is sent deliberately when the minion has no
3351+
resources — this clears any stale entries left by a previous
3352+
registration (e.g. after a resource type is removed from the pillar).
33433353
"""
33443354
resources = self.opts.get("resources", {})
3345-
if not resources:
3346-
return
33473355
load = {
33483356
"cmd": "_register_resources",
33493357
"id": self.opts["id"],
@@ -3459,6 +3467,12 @@ async def pillar_refresh(self, force_refresh=False, clean_cache=False):
34593467
)
34603468
self.opts["pillar"] = new_pillar
34613469
self.functions.pack["__pillar__"] = self.opts["pillar"]
3470+
# Re-discover resources now that pillar has changed. Must
3471+
# happen *after* opts["pillar"] is updated so that
3472+
# _discover_resources sees the new resource declarations (or
3473+
# their absence when a type is removed from the pillar).
3474+
self.opts["resources"] = self._discover_resources()
3475+
await self._register_resources_with_master()
34623476
finally:
34633477
async_pillar.destroy()
34643478
self.matchers_refresh()
@@ -3678,6 +3692,9 @@ async def handle_event(self, package):
36783692
_minion.beacons_refresh()
36793693
elif tag.startswith("matchers_refresh"):
36803694
_minion.matchers_refresh()
3695+
elif tag.startswith("resource_refresh"):
3696+
_minion.opts["resources"] = _minion._discover_resources()
3697+
_minion.io_loop.create_task(_minion._register_resources_with_master())
36813698
elif tag.startswith("manage_schedule"):
36823699
_minion.manage_schedule(tag, data)
36833700
elif tag.startswith("manage_beacons"):
@@ -4440,10 +4457,12 @@ def _resolve_resource_targets(self, load):
44404457
Return the list of per-resource dicts ``{"id": ..., "type": ...}`` that
44414458
the target expression matches against ``opts["resources"]``.
44424459
4443-
For glob/grain/etc. targets (non-resource-aware), returns all managed
4444-
resources so that ``salt '*' test.ping`` also runs against resources.
4460+
For wildcard glob targets (e.g. ``salt '*'``), returns all managed
4461+
resources so that the command also runs against resources.
44454462
For compound T@ targets, returns only the matched resources.
4446-
For compound expressions with no T@ terms, returns an empty list.
4463+
For specific-name glob targets (e.g. ``salt 'minion'``), grain, list,
4464+
pillar, or compound expressions with no T@ terms, returns an empty list
4465+
— the operator is targeting the minion itself, not its resources.
44474466
Internal/plumbing functions (see ``_NO_RESOURCE_FUNS``) are never
44484467
dispatched to resources.
44494468
"""
@@ -4479,8 +4498,19 @@ def _resolve_resource_targets(self, load):
44794498
targets.append({"id": rid, "type": pattern})
44804499
return targets
44814500

4482-
# Non-compound targets (glob, grain, list, etc.) apply to the minion
4483-
# AND to all managed resources.
4501+
# For glob targets, only dispatch to resources when the pattern
4502+
# contains a wildcard. A bare name like ``salt 'minion' test.ping``
4503+
# targets the minion itself; it should not implicitly run against its
4504+
# resources. ``salt '*' test.ping`` or ``salt 'web*' test.ping``
4505+
# opts in to resource dispatch.
4506+
if (
4507+
tgt_type == "glob"
4508+
and isinstance(tgt, str)
4509+
and not any(c in tgt for c in ("*", "?", "["))
4510+
):
4511+
return []
4512+
4513+
# Wildcard glob — dispatch to all managed resources.
44844514
all_resources = []
44854515
for rtype, rids in resources.items():
44864516
for rid in rids:

salt/modules/saltutil.py

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -402,52 +402,65 @@ def refresh_grains(**kwargs):
402402

403403
def refresh_resources():
404404
"""
405-
Re-register this minion's managed resources with the master.
405+
Signal the minion to re-discover its managed resources from current pillar
406+
data and re-register them with the master.
406407
407-
Sends the current ``opts["resources"]`` to the master so that the master's
408-
``minion_resources`` cache bank is up-to-date. This is the resource
409-
analogue of ``saltutil.refresh_grains`` — call it whenever the set of
410-
resources a minion manages may have changed, or after a minion restart to
411-
ensure the master's targeting cache reflects the current state.
408+
This fires a ``resource_refresh`` event on the minion bus. The minion
409+
handles the event by calling ``_discover_resources()`` (using the current
410+
``opts["pillar"]``) and then re-registering the result with the master's
411+
``minion_resources`` cache.
412412
413413
CLI Example:
414414
415415
.. code-block:: bash
416416
417417
salt '*' saltutil.refresh_resources
418418
"""
419-
resources = __opts__.get("resources", {})
420-
if not resources:
421-
return True
419+
try:
420+
return __salt__["event.fire"]({}, "resource_refresh")
421+
except KeyError:
422+
return False
422423

423-
masters = []
424-
if "master_uri_list" in __opts__:
425-
masters.extend(__opts__["master_uri_list"])
426-
else:
427-
masters.append(__opts__["master_uri"])
428424

429-
from salt.exceptions import SaltReqTimeoutError
425+
def sync_resources(
426+
saltenv=None, refresh=True, extmod_whitelist=None, extmod_blacklist=None
427+
):
428+
"""
429+
Sync custom resource-type modules from ``salt://_resources`` to the minion
430+
and signal the minion to re-discover its managed resources from pillar data
431+
and re-register them with the master.
430432
431-
for master in masters:
432-
with salt.channel.client.ReqChannel.factory(
433-
__opts__, master_uri=master
434-
) as channel:
435-
load = {
436-
"cmd": "_register_resources",
437-
"id": __opts__["id"],
438-
"resources": resources,
439-
"tok": channel.auth.gen_token(b"salt"),
440-
}
441-
try:
442-
channel.send(load, timeout=60)
443-
log.debug(
444-
"refresh_resources: registered %s with master %s",
445-
list(resources),
446-
master,
447-
)
448-
except SaltReqTimeoutError:
449-
log.warning("refresh_resources: timed out contacting master %s", master)
450-
return True
433+
saltenv
434+
The fileserver environment from which to sync. To sync from more than
435+
one environment, pass a comma-separated list.
436+
437+
If not passed, then all environments configured in the :ref:`top files
438+
<states-top>` will be checked for resource modules to sync. If no top
439+
files are found, then the ``base`` environment will be synced.
440+
441+
refresh : True
442+
If ``True``, signal the minion to re-discover its managed resources
443+
and re-register them with the master. This refresh will be performed
444+
even if no new resource modules are synced. Set to ``False`` to
445+
prevent this refresh.
446+
447+
extmod_whitelist : None
448+
comma-separated list of modules to sync
449+
450+
extmod_blacklist : None
451+
comma-separated list of modules to blacklist
452+
453+
CLI Example:
454+
455+
.. code-block:: bash
456+
457+
salt '*' saltutil.sync_resources
458+
salt '*' saltutil.sync_resources saltenv=base,dev
459+
"""
460+
ret = _sync("resources", saltenv, extmod_whitelist, extmod_blacklist)
461+
if refresh:
462+
refresh_resources()
463+
return ret
451464

452465

453466
def sync_grains(
@@ -1258,6 +1271,9 @@ def sync_all(
12581271
saltenv, False, extmod_whitelist, extmod_blacklist
12591272
)
12601273
ret["matchers"] = sync_matchers(saltenv, False, extmod_whitelist, extmod_blacklist)
1274+
ret["resources"] = sync_resources(
1275+
saltenv, False, extmod_whitelist, extmod_blacklist
1276+
)
12611277
if __opts__["file_client"] == "local":
12621278
ret["pillar"] = sync_pillar(saltenv, False, extmod_whitelist, extmod_blacklist)
12631279
ret["wrapper"] = sync_wrapper(

salt/utils/minions.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -798,18 +798,41 @@ def _augment_with_resources(self, minion_ids):
798798
IDs associated with the minions in ``minion_ids``.
799799
800800
This is called by :meth:`check_minions` for non-compound targets so
801-
that ``salt '*' test.ping`` or ``salt 'minion' test.ping`` also
802-
includes returns from the minion's managed resources.
801+
that ``salt '*' test.ping`` also includes returns from the minion's
802+
managed resources.
803+
804+
If the resource cache is unavailable for any reason the method logs
805+
an error and returns the original ``minion_ids`` unchanged so that
806+
ordinary minion targeting is never disrupted by a resource-cache
807+
failure.
803808
"""
804-
managed = self.cache.list("minion_resources") or []
809+
try:
810+
managed = self.cache.list("minion_resources") or []
811+
except Exception: # pylint: disable=broad-except
812+
log.error(
813+
"Failed to list minion_resources cache bank; "
814+
"resource IDs will not be included in this target expansion. "
815+
"Check cache driver configuration and permissions.",
816+
exc_info=True,
817+
)
818+
return list(minion_ids)
805819
if not managed:
806820
return list(minion_ids)
807821
result = list(minion_ids)
808822
minion_set = set(minion_ids)
809823
for minion_id in managed:
810824
if minion_id not in minion_set:
811825
continue
812-
resources = self.cache.fetch("minion_resources", minion_id) or {}
826+
try:
827+
resources = self.cache.fetch("minion_resources", minion_id) or {}
828+
except Exception: # pylint: disable=broad-except
829+
log.error(
830+
"Failed to fetch minion_resources for minion '%s'; "
831+
"skipping resource augmentation for this minion.",
832+
minion_id,
833+
exc_info=True,
834+
)
835+
continue
813836
for rids in resources.values():
814837
for rid in rids:
815838
if rid not in result:
@@ -861,9 +884,8 @@ def check_minions(
861884
# Compound targets handle resource matching explicitly via T@/M@.
862885
if (
863886
tgt_type == "glob"
864-
and tgt_type not in ("compound", "compound_pillar_exact")
865887
and isinstance(expr, str)
866-
and "*" in expr
888+
and any(c in expr for c in ("*", "?", "["))
867889
):
868890
_res["minions"] = self._augment_with_resources(_res["minions"])
869891
_res["ssh_minions"] = False

tests/pytests/unit/test_minion_resources.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,13 @@ def test_resolve_resource_targets_glob_wildcard(minion_with_resources):
5656

5757
def test_resolve_resource_targets_glob_specific_minion(minion_with_resources):
5858
"""
59-
A specific minion name glob still returns all managed resources — the
60-
minion decides what to dispatch based on the load, not the target string.
59+
A specific name glob (no wildcard characters) must NOT dispatch to
60+
resources. ``salt 'minion' test.ping`` should only run on the minion
61+
itself, not on its managed resources.
6162
"""
6263
load = {"tgt": "minion", "tgt_type": "glob", "fun": "test.ping", "arg": []}
6364
targets = minion_with_resources._resolve_resource_targets(load)
64-
assert len(targets) == 5
65+
assert targets == [], "specific-name glob must not dispatch to resources"
6566

6667

6768
def test_resolve_resource_targets_compound_T_full_srn(minion_with_resources):
@@ -232,3 +233,67 @@ def run_job(name, target):
232233
assert not errors, errors
233234
assert results["a"] is target_a
234235
assert results["b"] is target_b
236+
237+
238+
# ---------------------------------------------------------------------------
239+
# _discover_resources tests
240+
# ---------------------------------------------------------------------------
241+
242+
243+
def test_discover_resources_no_pillar_key_preserves_opts(minion_with_resources):
244+
"""
245+
When the pillar contains no 'resources' key at all, _discover_resources
246+
preserves whatever is already in opts["resources"].
247+
"""
248+
minion_with_resources.opts["pillar"] = {}
249+
result = minion_with_resources._discover_resources()
250+
assert result == {k: list(v) for k, v in RESOURCES.items()}
251+
252+
253+
def test_discover_resources_empty_pillar_key_clears_opts(minion_with_resources):
254+
"""
255+
When the pillar *does* contain a 'resources' key but its value is empty,
256+
_discover_resources must return {} and NOT preserve the old opts resources.
257+
This is the fix for the stale-cache bug: removing a resource type from the
258+
pillar and running sync_all must clear it at runtime.
259+
"""
260+
minion_with_resources.opts["pillar"] = {"resources": {}}
261+
result = minion_with_resources._discover_resources()
262+
assert (
263+
result == {}
264+
), "_discover_resources must return {} when pillar['resources'] is empty"
265+
266+
267+
# ---------------------------------------------------------------------------
268+
# _register_resources_with_master tests
269+
# ---------------------------------------------------------------------------
270+
271+
272+
def test_register_resources_with_master_sends_empty_dict(minion_with_resources):
273+
"""
274+
_register_resources_with_master must send the registration even when
275+
opts["resources"] is {}. Without this, removing resources from the pillar
276+
and running sync_all leaves the master cache permanently stale.
277+
"""
278+
minion_with_resources.opts["resources"] = {}
279+
sent_loads = []
280+
281+
async def fake_send(load, timeout=None):
282+
sent_loads.append(load)
283+
284+
import asyncio
285+
286+
minion_with_resources.tok = b"test-tok"
287+
with mock_patch.object(
288+
minion_with_resources,
289+
"_send_req_async_main",
290+
side_effect=fake_send,
291+
):
292+
asyncio.run(minion_with_resources._register_resources_with_master())
293+
294+
assert (
295+
len(sent_loads) == 1
296+
), "_register_resources_with_master must always send a load, even for {}"
297+
assert (
298+
sent_loads[0]["resources"] == {}
299+
), "An empty resource dict must be forwarded to the master to clear stale cache"

0 commit comments

Comments
 (0)