Skip to content

Commit bd79674

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 1fced3c commit bd79674

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
@@ -561,19 +561,25 @@ def _discover_resources(self):
561561
``discover(opts)``; the return value is a dict of
562562
``{resource_type: [resource_id, ...]}``.
563563
564-
If pillar does not specify any resources, any resources already
565-
present in ``opts["resources"]`` (e.g. set directly in the minion
566-
config file) are preserved as-is. This supports testing and simple
567-
deployments where pillar-driven discovery is not needed.
564+
If the pillar contains no ``resources`` key at all, any resources
565+
already present in ``opts["resources"]`` (e.g. set directly in the
566+
minion config file) are preserved as-is. This supports testing and
567+
simple deployments where pillar-driven discovery is not needed.
568+
569+
If the pillar *does* contain a ``resources`` key (even if its value is
570+
empty / all entries removed), that is treated as an authoritative
571+
declaration and the result will reflect only what the pillar says.
572+
This ensures that removing a resource type from the pillar and running
573+
sync_all actually clears it at runtime.
568574
569575
Called from :meth:`gen_modules` after pillar is compiled and before
570576
the per-type execution-module loaders are created.
571577
"""
572-
pillar_resources = self.opts.get("pillar", {}).get("resources", {})
573-
if not pillar_resources:
574-
# No pillar resources — preserve whatever is already in opts
575-
# (set directly in the minion config or from a previous discovery).
578+
pillar = self.opts.get("pillar", {})
579+
if "resources" not in pillar:
580+
# Pillar has no opinion — preserve whatever is already in opts.
576581
return {k: list(v) for k, v in self.opts.get("resources", {}).items()}
582+
pillar_resources = pillar.get("resources") or {}
577583

578584
# A minimal resource loader is sufficient here — discover() only reads
579585
# from the opts dict passed to it and does not need other dunders.
@@ -3361,10 +3367,12 @@ async def _register_resources_with_master(self):
33613367
``minion_resources`` cache bank is up-to-date. This allows
33623368
:class:`salt.utils.minions.CkMinions` to include resource IDs when
33633369
expanding glob / non-compound targets (e.g. ``salt '*' test.ping``).
3370+
3371+
An empty resource dict is sent deliberately when the minion has no
3372+
resources — this clears any stale entries left by a previous
3373+
registration (e.g. after a resource type is removed from the pillar).
33643374
"""
33653375
resources = self.opts.get("resources", {})
3366-
if not resources:
3367-
return
33683376
load = {
33693377
"cmd": "_register_resources",
33703378
"id": self.opts["id"],
@@ -3480,6 +3488,12 @@ async def pillar_refresh(self, force_refresh=False, clean_cache=False):
34803488
)
34813489
self.opts["pillar"] = new_pillar
34823490
self.functions.pack["__pillar__"] = self.opts["pillar"]
3491+
# Re-discover resources now that pillar has changed. Must
3492+
# happen *after* opts["pillar"] is updated so that
3493+
# _discover_resources sees the new resource declarations (or
3494+
# their absence when a type is removed from the pillar).
3495+
self.opts["resources"] = self._discover_resources()
3496+
await self._register_resources_with_master()
34833497
finally:
34843498
async_pillar.destroy()
34853499
self.matchers_refresh()
@@ -3699,6 +3713,9 @@ async def handle_event(self, package):
36993713
_minion.beacons_refresh()
37003714
elif tag.startswith("matchers_refresh"):
37013715
_minion.matchers_refresh()
3716+
elif tag.startswith("resource_refresh"):
3717+
_minion.opts["resources"] = _minion._discover_resources()
3718+
_minion.io_loop.create_task(_minion._register_resources_with_master())
37023719
elif tag.startswith("manage_schedule"):
37033720
_minion.manage_schedule(tag, data)
37043721
elif tag.startswith("manage_beacons"):
@@ -4461,10 +4478,12 @@ def _resolve_resource_targets(self, load):
44614478
Return the list of per-resource dicts ``{"id": ..., "type": ...}`` that
44624479
the target expression matches against ``opts["resources"]``.
44634480
4464-
For glob/grain/etc. targets (non-resource-aware), returns all managed
4465-
resources so that ``salt '*' test.ping`` also runs against resources.
4481+
For wildcard glob targets (e.g. ``salt '*'``), returns all managed
4482+
resources so that the command also runs against resources.
44664483
For compound T@ targets, returns only the matched resources.
4467-
For compound expressions with no T@ terms, returns an empty list.
4484+
For specific-name glob targets (e.g. ``salt 'minion'``), grain, list,
4485+
pillar, or compound expressions with no T@ terms, returns an empty list
4486+
— the operator is targeting the minion itself, not its resources.
44684487
Internal/plumbing functions (see ``_NO_RESOURCE_FUNS``) are never
44694488
dispatched to resources.
44704489
"""
@@ -4500,8 +4519,19 @@ def _resolve_resource_targets(self, load):
45004519
targets.append({"id": rid, "type": pattern})
45014520
return targets
45024521

4503-
# Non-compound targets (glob, grain, list, etc.) apply to the minion
4504-
# AND to all managed resources.
4522+
# For glob targets, only dispatch to resources when the pattern
4523+
# contains a wildcard. A bare name like ``salt 'minion' test.ping``
4524+
# targets the minion itself; it should not implicitly run against its
4525+
# resources. ``salt '*' test.ping`` or ``salt 'web*' test.ping``
4526+
# opts in to resource dispatch.
4527+
if (
4528+
tgt_type == "glob"
4529+
and isinstance(tgt, str)
4530+
and not any(c in tgt for c in ("*", "?", "["))
4531+
):
4532+
return []
4533+
4534+
# Wildcard glob — dispatch to all managed resources.
45054535
all_resources = []
45064536
for rtype, rids in resources.items():
45074537
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)