diff --git a/.gitignore b/.gitignore index 6b55b1898828..9c86cae6f2db 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ Pipfile.lock /.?env/ /bin/ /etc/ +# Allow repo-local Salt dev configs (parent must be un-ignored first). +!/etc/ +!/etc/salt/ +!/etc/salt/** /include/ /lib/ /lib64/ diff --git a/doc/ref/grains/all/index.rst b/doc/ref/grains/all/index.rst index b68833476d2a..1a78fc952964 100644 --- a/doc/ref/grains/all/index.rst +++ b/doc/ref/grains/all/index.rst @@ -19,4 +19,5 @@ grains modules opts package pending_reboot + resources rest_sample diff --git a/doc/ref/grains/all/salt.grains.resources.rst b/doc/ref/grains/all/salt.grains.resources.rst new file mode 100644 index 000000000000..5bc9bfb76fbf --- /dev/null +++ b/doc/ref/grains/all/salt.grains.resources.rst @@ -0,0 +1,5 @@ +salt.grains.resources +===================== + +.. automodule:: salt.grains.resources + :members: diff --git a/doc/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst index de751f76a1f3..1464ea551440 100644 --- a/doc/ref/modules/all/index.rst +++ b/doc/ref/modules/all/index.rst @@ -67,6 +67,7 @@ execution modules dpkg_lowpkg dummyproxy_pkg dummyproxy_service + dummyresource_test environ etcd_mod ethtool @@ -215,6 +216,10 @@ execution modules ssh_pkg ssh_pki ssh_service + sshresource_cmd + sshresource_pkg + sshresource_state + sshresource_test state status supervisord diff --git a/doc/ref/modules/all/salt.modules.dummyresource_test.rst b/doc/ref/modules/all/salt.modules.dummyresource_test.rst new file mode 100644 index 000000000000..a78e2d44a089 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.dummyresource_test.rst @@ -0,0 +1,6 @@ +salt.modules.dummyresource_test +=============================== + +.. automodule:: salt.modules.dummyresource_test + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.sshresource_cmd.rst b/doc/ref/modules/all/salt.modules.sshresource_cmd.rst new file mode 100644 index 000000000000..c8f4af6bb243 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.sshresource_cmd.rst @@ -0,0 +1,6 @@ +salt.modules.sshresource_cmd +============================ + +.. automodule:: salt.modules.sshresource_cmd + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.sshresource_pkg.rst b/doc/ref/modules/all/salt.modules.sshresource_pkg.rst new file mode 100644 index 000000000000..6d9ce028a6be --- /dev/null +++ b/doc/ref/modules/all/salt.modules.sshresource_pkg.rst @@ -0,0 +1,6 @@ +salt.modules.sshresource_pkg +============================ + +.. automodule:: salt.modules.sshresource_pkg + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.sshresource_state.rst b/doc/ref/modules/all/salt.modules.sshresource_state.rst new file mode 100644 index 000000000000..e9faad2df2fd --- /dev/null +++ b/doc/ref/modules/all/salt.modules.sshresource_state.rst @@ -0,0 +1,6 @@ +salt.modules.sshresource_state +============================== + +.. automodule:: salt.modules.sshresource_state + :members: + :undoc-members: diff --git a/doc/ref/modules/all/salt.modules.sshresource_test.rst b/doc/ref/modules/all/salt.modules.sshresource_test.rst new file mode 100644 index 000000000000..af51226c5a0f --- /dev/null +++ b/doc/ref/modules/all/salt.modules.sshresource_test.rst @@ -0,0 +1,6 @@ +salt.modules.sshresource_test +============================= + +.. automodule:: salt.modules.sshresource_test + :members: + :undoc-members: diff --git a/doc/ref/runners/all/index.rst b/doc/ref/runners/all/index.rst index 3addfae5656d..a4a3794e97b9 100644 --- a/doc/ref/runners/all/index.rst +++ b/doc/ref/runners/all/index.rst @@ -20,6 +20,7 @@ runner modules fileserver git_pillar http + index jobs manage match @@ -30,6 +31,7 @@ runner modules pki queue reactor + resource salt saltutil sdb diff --git a/doc/ref/runners/all/salt.runners.index.rst b/doc/ref/runners/all/salt.runners.index.rst new file mode 100644 index 000000000000..8336ea91aa18 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.index.rst @@ -0,0 +1,9 @@ +.. _all-salt.runners.index: + +================== +salt.runners.index +================== + +.. automodule:: salt.runners.index + :members: + :undoc-members: diff --git a/doc/ref/runners/all/salt.runners.resource.rst b/doc/ref/runners/all/salt.runners.resource.rst new file mode 100644 index 000000000000..31f04d366708 --- /dev/null +++ b/doc/ref/runners/all/salt.runners.resource.rst @@ -0,0 +1,9 @@ +.. _all-salt.runners.resource: + +===================== +salt.runners.resource +===================== + +.. automodule:: salt.runners.resource + :members: + :undoc-members: diff --git a/doc/topics/performance/pki_index.rst b/doc/topics/performance/pki_index.rst index 03f3391866fa..c53488a9555f 100644 --- a/doc/topics/performance/pki_index.rst +++ b/doc/topics/performance/pki_index.rst @@ -40,13 +40,17 @@ using these options: Monitoring and Management ========================= -You can check the status of your PKI index or force a manual rebuild using the -:ref:`PKI runner `: +Check status or rebuild the minion-key mmap index with the +:ref:`index runner ` (name ``pki``): .. code-block:: bash # Check index status and load factor - salt-run pki.status + salt-run index.status name=pki # Manually rebuild the index from the filesystem - salt-run pki.rebuild_index + salt-run index.compact name=pki + +The legacy :ref:`pki runner ` (``salt-run pki.status`` / +``salt-run pki.rebuild_index``) still works but is deprecated and forwards to +the same implementation. diff --git a/doc/topics/releases/3009.0.md b/doc/topics/releases/3009.0.md new file mode 100644 index 000000000000..95ed93c9f718 --- /dev/null +++ b/doc/topics/releases/3009.0.md @@ -0,0 +1,19 @@ +(release-3009.0)= +# Salt 3009.0 release notes + + + + + + + +## Changelog diff --git a/doc/topics/releases/index.rst b/doc/topics/releases/index.rst index 59dfb2914b83..f6404b6f8feb 100644 --- a/doc/topics/releases/index.rst +++ b/doc/topics/releases/index.rst @@ -20,6 +20,7 @@ Upcoming release :glob: 3008.* + 3009.* See `Install a release candidate `_ for more information about installing an RC when one is available. diff --git a/doc/topics/releases/templates/3009.0.md.template b/doc/topics/releases/templates/3009.0.md.template new file mode 100644 index 000000000000..9df09f0899e9 --- /dev/null +++ b/doc/topics/releases/templates/3009.0.md.template @@ -0,0 +1,14 @@ +(release-3009.0)= +# Salt 3009.0 release notes{{ unreleased }} +{{ warning }} + + + + +## Changelog +{{ changelog }} diff --git a/doc/topics/targeting/index.rst b/doc/topics/targeting/index.rst index ad4cd717cd52..b03db696a45b 100644 --- a/doc/topics/targeting/index.rst +++ b/doc/topics/targeting/index.rst @@ -111,6 +111,7 @@ There are many ways to target individual minions or groups of minions in Salt: nodegroups batch range + resources Loadable Matchers diff --git a/doc/topics/targeting/resources.rst b/doc/topics/targeting/resources.rst new file mode 100644 index 000000000000..28066f641c11 --- /dev/null +++ b/doc/topics/targeting/resources.rst @@ -0,0 +1,177 @@ +.. _targeting-resources: + +================================ +Targeting Salt Resources +================================ + +.. versionadded:: 3009.0 + +A *Salt resource* is something a minion manages on behalf of the master — +an SSH host, a virtual appliance, an external API endpoint — addressed +by an id of the operator's choosing. Resources extend Salt's targeting +system: every targeting expression that selects minions can also select +resources. + +This page is the targeting reference. For the data model and registration +plumbing see :py:mod:`salt.utils.resource_registry`. + + +Targeting forms +=============== + +Every form below treats resources alongside minions: a single command +returns one entry per matched id, whether that id belongs to a minion +or a resource. + +Glob and exact-id +----------------- + +A wildcard glob automatically expands to include every resource managed +by every matched minion:: + + salt '*' test.ping + +A specific bare id matches a resource directly:: + + salt 'web-01' test.ping + +A specific minion id targets only the minion (not its resources):: + + salt 'minion-1' test.ping + + +Compound ``T@`` (resource type) +------------------------------- + +``T@`` matches every resource of the given type:: + + salt -C 'T@ssh' state.apply + +``T@:`` targets exactly one resource:: + + salt -C 'T@ssh:web-01' test.ping + + +Grain-based ``-G`` / ``G@`` +--------------------------- + +A resource carries its own grains, produced by the ``grains`` function +in the resource's connection module (e.g. +:func:`salt.resource.dummy.grains`). The master records each minion's +per-resource grain dicts in the ``resource_grains`` cache bank when the +minion registers, and ``salt -G`` matches against that bank in addition +to the per-minion grain bank:: + + salt -G 'environment:prod' test.ping + +Compound ``G@`` works the same way and supports the full boolean +algebra (``and``, ``or``, ``not``, parens):: + + salt -C 'G@environment:prod and G@role:web' state.apply + salt -C 'T@ssh and not G@environment:staging' test.ping + +The boolean form is evaluated **per resource**, so a compound matches a +resource iff that resource's identity and grains satisfy the entire +expression. + + +PCRE grain ``-P`` / ``P@`` +-------------------------- + +Identical semantics to ``-G`` / ``G@`` but values are regex patterns:: + + salt -P 'environment:^production-.*' test.ping + salt -C 'P@environment:^production-.*' state.apply + + +List ``-L`` +----------- + +A bare resource id appearing in a list expression matches:: + + salt -L 'web-01,web-02,db-01' test.ping + + +Pillar ``-I`` / ``I@`` +---------------------- + +.. note:: + + Pillar-based targeting of resources is **not** wired up. Resources + do not carry per-resource pillar data today. ``-I`` and ``I@`` only + match minions; resources are skipped silently. This is tracked as + future work — see the gap notes in + :py:mod:`salt.utils.resource_registry`. + + +How master and minion split the work +==================================== + +Master side +----------- + +The master's ``CkMinions`` augments grain matches with resource ids +read from the ``resource_grains`` cache bank. The augment runs for +``-G``, ``-P``, and any ``G@`` / ``P@`` term inside a compound. The +matched bare resource ids are added to the response wait set so the +master accepts the corresponding returns. + +Minion side +----------- + +When a publish arrives, the minion's ``_resolve_resource_targets`` +walks every locally managed resource and decides, **per resource**, +whether the targeting expression matches. For glob / list / ``T@`` +this is a string match; for ``G@`` / ``P@`` the minion uses the +grains it cached during its last registration; for compound, the +minion evaluates the full boolean expression against each resource's +identity and grains. + +Each matched resource gets its own job dispatch with ``__grains__`` +swapped to the resource's grain dict (so ``salt 'web-01' grains.items`` +returns ``web-01``'s grains, not the managing minion's). + + +Freshness and refresh +===================== + +The master's ``resource_grains`` bank is updated only when a minion +re-registers via ``_register_resources_with_master``. Triggers that +re-register are: + +* Minion start / reconnect (``tune_in``); +* A ``saltutil.refresh_pillar`` (the minion's pillar refresh handler + re-discovers resources before re-registering); and +* The ``resource_refresh`` event on the minion event bus. + +A per-resource ``.grains_refresh()`` invocation does **not** +auto-propagate to the master. To force the master's view to refresh +without waiting for a pillar refresh, fire the ``resource_refresh`` +event for the relevant minion:: + + salt-run resource.refresh minion=resources-minion + +That runner publishes ``minion//resource_refresh`` on the master +event bus; the minion's handler re-runs resource discovery and +re-publishes its full grain set. + + +Operator inspection +=================== + +Two read-only runners expose what the master sees: + +.. code-block:: bash + + # Show every SRN currently in the resource_grains bank with a + # one-line summary (top-level grain keys + count). + salt-run resource.list_grains + + # Show the full grain dict for one resource. + salt-run resource.show_grains type=ssh id=web-01 + +When ``salt -G ':' test.ping`` returns less than expected, +``resource.list_grains`` is the first place to check: if a resource +isn't in the bank, the master will not match it, and the resource needs +a ``saltutil.refresh_pillar`` (or a ``resource.refresh``) on its +managing minion. diff --git a/salt/channel/server.py b/salt/channel/server.py index 3bb97c052f90..b1a6ead2dc60 100644 --- a/salt/channel/server.py +++ b/salt/channel/server.py @@ -1185,15 +1185,12 @@ def __init__(self, opts, transport, worker_pools): (pathlib.Path(self.opts["cachedir"]) / "sessions").mkdir(exist_ok=True) self.sessions = {} - # Same key cache / minion bookkeeping as ReqServerChannel so clear-text - # _auth can run inline when IPC pool clients are not yet connected - # (functional tests and bootstrap scenarios). - self.cache = salt.cache.Cache(opts, driver=self.opts["keys.cache_driver"]) - if self.opts["con_cache"]: - self.cache_cli = CacheCli(self.opts) - else: - self.cache_cli = False - self.ckminions = salt.utils.minions.CkMinions(self.opts) + # Defer CacheCli/CkMinions construction: ``salt.cache.Cache`` holds locks and + # breaks pickling ``PoolRoutingChannel`` into ``MWorker`` on Windows (spawn). + # Workers delegate to per-pool ``ReqServerChannel`` and never need this state. + self.cache = None + self.cache_cli = False + self.ckminions = None # Build routing table for command-based routing self._build_routing_table() @@ -1203,6 +1200,17 @@ def __init__(self, opts, transport, worker_pools): list(worker_pools.keys()), ) + def _ensure_auth_support(self): + """Lazily init key-cache state needed for inline clear-text ``_auth`` only.""" + if self.cache is not None: + return + self.cache = salt.cache.Cache(self.opts, driver=self.opts["keys.cache_driver"]) + if self.opts["con_cache"]: + self.cache_cli = CacheCli(self.opts) + else: + self.cache_cli = False + self.ckminions = salt.utils.minions.CkMinions(self.opts) + def _build_routing_table(self): """ Build command-to-pool routing table from configuration. @@ -1408,6 +1416,9 @@ def post_fork(self, payload_handler, io_loop, **kwargs): self.io_loop = io_loop + # Routing process only (not pool workers): needs cache-backed auth helpers. + self._ensure_auth_support() + # Setup master infrastructure (same as ReqServerChannel) if ( self.opts.get("pub_server_niceness") @@ -1505,6 +1516,7 @@ async def _handle_clear_auth_local(self, payload, version): Run clear-text ``_auth`` the same way :meth:`ReqServerChannel.handle_message` does, without forwarding to a worker pool (no IPC client yet). """ + self._ensure_auth_support() proxy = self._req_channel_auth_delegate() try: payload = ReqServerChannel._decode_payload(proxy, payload, version) @@ -1600,6 +1612,15 @@ async def handle_and_route_message(self, payload): ) return "bad load" + # Clear-text ``_auth`` is handled locally like legacy ReqServerChannel so + # bootstrap sign-in does not rely on pool IPC (flaky on Windows with pooled routing). + if ( + payload.get("enc") == "clear" + and isinstance(payload.get("load"), dict) + and payload["load"].get("cmd") == "_auth" + ): + return await self._handle_clear_auth_local(payload, version) + try: # Simple command-based routing from our routing table load = payload.get("load", {}) @@ -1668,12 +1689,6 @@ async def handle_and_route_message(self, payload): return {"enc": "clear", "load": {"ret": False, "cluster_retry": True}} if pool_name not in self.pool_clients: - if ( - payload.get("enc") == "clear" - and isinstance(payload.get("load"), dict) - and payload["load"].get("cmd") == "_auth" - ): - return await self._handle_clear_auth_local(payload, version) log.error( "No client available for pool '%s'. Available: %s", pool_name, diff --git a/salt/client/__init__.py b/salt/client/__init__.py index 0daa8fe1a711..ab5777671a0c 100644 --- a/salt/client/__init__.py +++ b/salt/client/__init__.py @@ -38,6 +38,7 @@ import salt.utils.minions import salt.utils.network import salt.utils.platform +import salt.utils.resources import salt.utils.stringutils import salt.utils.user import salt.utils.verify @@ -63,6 +64,158 @@ log = logging.getLogger(__name__) +def _resource_ids_from_minion_grains_cache(opts, minion_id): + """ + Return bare resource IDs last synced for ``minion_id`` in the master's + minion grains cache (``salt_resources``), or [] if unavailable. + + Used when the mmap resource registry no longer lists that minion (e.g. it + just went offline) but the operator still needs per-resource missing lines. + """ + if not opts.get("minion_data_cache"): + return [] + try: + cache = salt.cache.factory(opts) + if not cache.contains("grains", minion_id): + return [] + grains = cache.fetch("grains", minion_id) or {} + except Exception as exc: # pylint: disable=broad-except + log.debug( + "Grains cache read for minion %s failed while expanding missing returns: %s", + minion_id, + exc, + exc_info=True, + ) + return [] + return salt.utils.resources.bare_resource_ids_from_decl( + grains.get("salt_resources") + ) + + +def _resource_ids_from_minion_pillar_cache(opts, minion_id): + """ + Return bare resource IDs from the minion's cached pillar subtree under + :func:`~salt.utils.resources.resource_pillar_key` (default ``resources``). + + ``salt_resources`` is often absent from grains even when pillar (and thus + ``resource_ids``) was synced to the master — this path closes that gap. + """ + if not opts.get("minion_data_cache"): + return [] + try: + cache = salt.cache.factory(opts) + if not cache.contains("pillar", minion_id): + return [] + pillar = cache.fetch("pillar", minion_id) or {} + except Exception as exc: # pylint: disable=broad-except + log.debug( + "Pillar cache read for minion %s failed while expanding missing returns: %s", + minion_id, + exc, + exc_info=True, + ) + return [] + key = salt.utils.resources.resource_pillar_key(opts) + subtree = pillar.get(key) + if not isinstance(subtree, dict): + return [] + return salt.utils.resources.bare_resource_ids_from_decl(subtree) + + +def _job_ret_display_id(data): + """ + Key for one job return in CLI / nested job-return events. + + Resource jobs keep ``id`` as the managing minion (signing / transport) and + set ``resource_id`` to the bare resource id. Some masters instead rewrite + ``id`` to the resource id and omit ``resource_id``. + """ + if not isinstance(data, dict): + return None + rid = data.get("resource_id") + if rid is not None and rid != "": + return rid + return data.get("id") + + +def _iter_failed_missing_returns(opts, found, missing_root_ids): + """ + Yield ``{id: {"failed\": True}}`` for each missing target, and for each + managing minion also yield its managed resource IDs when those resources + did not send a return. + + Resource IDs are taken from the master's resource registry when present, + then merged with IDs from :conf_master:`minion_data_cache` **grains** + (``salt_resources``) and **pillar** (``resources`` / ``resource_pillar_key``), + so offline minions still expand to their last-known resource rows. + """ + ck = salt.utils.minions.CkMinions(opts) + reported = set() + missing_set = set(missing_root_ids) + try: + pki_minions = ck._pki_minions() + except Exception as exc: # pylint: disable=broad-except + log.debug( + "Could not list PKI minions while expanding missing returns: %s", + exc, + exc_info=True, + ) + pki_minions = set() + # Glob targets augmented with resource IDs sort those IDs before the + # managing minion; handle PKI minions first so pillar/grains expansion is + # not skipped after bare resource rows were already marked reported. + minion_first = [m for m in sorted(missing_set) if m in pki_minions] + remainder = sorted(missing_set - set(minion_first)) + + def _emit_missing_for_minion(mid): + if mid in reported: + return + yield {mid: {"failed": True}} + reported.add(mid) + by_type = None + try: + by_type = ck.registry.get_resources_for_minion(mid) + except Exception as exc: # pylint: disable=broad-except + log.debug( + "Could not read resource registry for minion %s: %s", + mid, + exc, + exc_info=True, + ) + rid_order = [] + seen_rid = set() + if by_type: + for rids in by_type.values(): + if not isinstance(rids, (list, tuple)): + continue + for rid in rids: + if rid in seen_rid: + continue + rid_order.append(rid) + seen_rid.add(rid) + for rid in _resource_ids_from_minion_grains_cache(opts, mid): + if rid in seen_rid: + continue + rid_order.append(rid) + seen_rid.add(rid) + for rid in _resource_ids_from_minion_pillar_cache(opts, mid): + if rid in seen_rid: + continue + rid_order.append(rid) + seen_rid.add(rid) + for rid in rid_order: + if rid in reported or rid in found: + continue + yield {rid: {"failed": True}} + reported.add(rid) + + for mid in minion_first: + yield from _emit_missing_for_minion(mid) + + for mid in remainder: + yield from _emit_missing_for_minion(mid) + + def get_local_client( c_path=os.path.join(syspaths.CONFIG_DIR, "master"), mopts=None, @@ -1207,21 +1360,34 @@ def get_iter_returns( if "return" not in raw["data"]: log.warning("Malformed event return: %s", raw["tag"]) continue + display_id = _job_ret_display_id(raw["data"]) + if display_id is None: + log.warning("Malformed job return (no id): %s", raw["tag"]) + continue + # Drop duplicate events for the same logical target (same JID + + # resource id or minion id). External caches can replay returns. + if display_id in found: + log.debug( + "Skipping duplicate return for jid %s from %s", + jid, + display_id, + ) + continue if kwargs.get("raw", False): - found.add(raw["data"]["id"]) + found.add(display_id) yield raw else: - found.add(raw["data"]["id"]) - ret = {raw["data"]["id"]: {"ret": raw["data"]["return"]}} + found.add(display_id) + ret = {display_id: {"ret": raw["data"]["return"]}} if "out" in raw["data"]: - ret[raw["data"]["id"]]["out"] = raw["data"]["out"] + ret[display_id]["out"] = raw["data"]["out"] if "retcode" in raw["data"]: - ret[raw["data"]["id"]]["retcode"] = raw["data"]["retcode"] + ret[display_id]["retcode"] = raw["data"]["retcode"] if "jid" in raw["data"]: - ret[raw["data"]["id"]]["jid"] = raw["data"]["jid"] + ret[display_id]["jid"] = raw["data"]["jid"] if kwargs.get("_cmd_meta", False): - ret[raw["data"]["id"]].update(raw["data"]) - log.debug("jid %s return from %s", jid, raw["data"]["id"]) + ret[display_id].update(raw["data"]) + log.debug("jid %s return from %s", jid, display_id) yield ret # if we have all of the returns (and we aren't a syndic), no need for anything fancy @@ -1261,8 +1427,19 @@ def get_iter_returns( # re-do the ping if time.time() > timeout_at and minions_running: # since this is a new ping, no one has responded yet - jinfo = self.gather_job_info( - jid, list(minions - found), "list", **kwargs + # Only send gather_job_info to IDs that are accepted minions. + # Resource IDs (e.g. "dummy-01") are not PKI keys; sending + # saltutil.find_job to them as a list target would fail and + # print a misleading "No minions matched" message. + pending = minions - found + accepted_minions = set( + salt.utils.minions.CkMinions(self.opts)._pki_minions() + ) + minion_pending = list(pending & accepted_minions) + jinfo = ( + self.gather_job_info(jid, minion_pending, "list", **kwargs) + if minion_pending + else {} ) minions_running = False # if we weren't assigned any jid that means the master thinks @@ -1368,8 +1545,7 @@ def get_iter_returns( self.event.unsubscribe(jid) if expect_minions: - for minion in list(minions - found): - yield {minion: {"failed": True}} + yield from _iter_failed_missing_returns(self.opts, found, minions - found) # Filter out any minions marked as missing for which we received # returns (prevents false events sent due to higher-level masters not @@ -1378,8 +1554,7 @@ def get_iter_returns( # Report on missing minions if missing: - for minion in missing: - yield {minion: {"failed": True}} + yield from _iter_failed_missing_returns(self.opts, found, missing) def get_returns(self, jid, minions, timeout=None): """ @@ -1587,11 +1762,14 @@ def get_cli_static_event_returns( if "minions" in raw.get("data", {}): minions.update(raw["data"]["minions"]) continue - found.add(raw["id"]) - ret[raw["id"]] = {"ret": raw["return"]} - ret[raw["id"]]["success"] = raw.get("success", False) + display_id = _job_ret_display_id(raw) + if display_id is None: + continue + found.add(display_id) + ret[display_id] = {"ret": raw["return"]} + ret[display_id]["success"] = raw.get("success", False) if "out" in raw: - ret[raw["id"]]["out"] = raw["out"] + ret[display_id]["out"] = raw["out"] if len(found.intersection(minions)) >= len(minions): # All minions have returned, break out of the loop break @@ -1609,8 +1787,14 @@ def get_cli_static_event_returns( ): if len(found) < len(minions): fail = sorted(list(minions.difference(found))) - for minion in fail: - ret[minion] = { + for fid in ( + k + for chunk in _iter_failed_missing_returns( + self.opts, found, fail + ) + for k in chunk + ): + ret[fid] = { "out": "no_return", "ret": "Minion did not return", } @@ -1658,8 +1842,10 @@ def get_cli_event_returns( # (gtmanfred) expect_minions is popped here in case it is passed from a client # call. If this is not popped, then it would be passed twice to # get_iter_returns. + # Default True: ``salt`` must still emit per-target timeout rows (and + # resource-id expansion for missing managers) even without ``-v``. expect_minions=( - kwargs.pop("expect_minions", False) or verbose or show_timeout + kwargs.pop("expect_minions", True) or verbose or show_timeout ), **kwargs, ): @@ -1674,7 +1860,11 @@ def get_cli_event_returns( } # replace the return structure for missing minions for id_, min_ret in ret.items(): - if min_ret.get("failed") is True: + # Do not use ``is True``; some payloads deserialize ``failed`` as a + # non-singleton truthy value, which would skip this branch, hit the + # generic ``yield`` below without a ``ret`` field, and make the salt + # CLI drop the row on :func:`~salt.cli.salt.Salt._format_ret` KeyError. + if min_ret.get("failed"): if connected_minions is None: connected_minions = salt.utils.minions.CkMinions( self.opts @@ -1752,15 +1942,20 @@ def get_event_iter_returns(self, jid, minions, timeout=None): try: # There might be two jobs for the same minion, so we have to check for the jid if jid == raw["jid"]: - found.add(raw["id"]) - ret = {raw["id"]: {"ret": raw["return"]}} + display_id = _job_ret_display_id(raw) + if display_id is None: + continue + if display_id in found: + continue + found.add(display_id) + ret = {display_id: {"ret": raw["return"]}} else: continue except KeyError: # Ignore other erroneous messages continue if "out" in raw: - ret[raw["id"]]["out"] = raw["out"] + ret[display_id]["out"] = raw["out"] yield ret time.sleep(0.02) diff --git a/salt/client/netapi.py b/salt/client/netapi.py index 27029af85a3e..2aefeb126f71 100644 --- a/salt/client/netapi.py +++ b/salt/client/netapi.py @@ -2,6 +2,7 @@ The main entry point for salt-api """ +import asyncio import logging import signal @@ -63,7 +64,7 @@ def run(self): # No custom signal handling was added, install our own signal.signal(signal.SIGTERM, self._handle_signals) - self.process_manager.run() + asyncio.run(self.process_manager.run()) def _handle_signals(self, signum, sigframe): # escalate the signals to the process manager diff --git a/salt/client/ssh/__init__.py b/salt/client/ssh/__init__.py index 1a2f41c2d11f..d924dc80e7e1 100644 --- a/salt/client/ssh/__init__.py +++ b/salt/client/ssh/__init__.py @@ -257,6 +257,18 @@ def _ssh_cli_process_exit_code(retcode): tar --strip-components=1 -xf "$RELENV_TAR" -C "{THIN_DIR}" fi +# BUG-WORKAROUND: salt-ssh relenv path never writes the minion config that +# Single.__init__ builds in self.minion_config. The non-relenv (salt-thin) +# path embeds it in SSH_PY_SHIM via OPTIONS.config, which the Python shim +# writes to thin_dir/minion. The relenv shim has no equivalent, so salt-call +# falls back to system defaults (/var/cache/salt, /var/log/salt) and fails for +# any unprivileged user. Writing it here replicates the salt-thin behaviour. +# See: https://github.com/saltstack/salt (file as issue against salt-ssh relenv) +mkdir -p "{THIN_DIR}/running_data/pki" +cat > "{THIN_DIR}/minion" << 'SALT_MINION_CONF_EOF' +__SALT_MINION_CONFIG__ +SALT_MINION_CONF_EOF + # Check if Python binary is executable if [ ! -x "$SALT_CALL_BIN" ]; then echo "ERROR: salt-call binary not found or not executable at $SALT_CALL_BIN" >&2 @@ -293,9 +305,6 @@ def _ssh_cli_process_exit_code(retcode): echo "{RSTR}" echo "{RSTR}" >&2 -# Debug: Show the actual command being executed -echo "SALT_CALL_CMD: $SALT_CALL_BIN --retcode-passthrough --local --metadata --out=json -lquiet -c {THIN_DIR} -- {ARGS}" >&2 - exec $SUDO "$SALT_CALL_BIN" --retcode-passthrough --local --metadata --out=json -lquiet -c "{THIN_DIR}" -- {ARGS} EOF """.split( @@ -1248,40 +1257,26 @@ def __init__( self.arch = arch.strip() if self.opts.get("relenv"): - # Check if OS/arch already detected and cached in opts - if "relenv_kernel" in opts and "relenv_os_arch" in opts: - kernel = opts["relenv_kernel"] - os_arch = opts["relenv_os_arch"] - log.warning(f"RELENV: Reusing cached OS/arch: {kernel}/{os_arch}") + if thin: + # Caller pre-resolved the relenv tarball path — skip the SSH + # round-trip that detect_os_arch() would otherwise make during + # __init__. This is important when Single is created inside a + # minion job worker where every extra SSH connection adds latency + # and can cause hangs. + self.thin = thin else: - # First Single instance - detect and cache OS/arch in opts before assigning to self.opts kernel, os_arch = self.detect_os_arch() - opts["relenv_kernel"] = kernel - opts["relenv_os_arch"] = os_arch - log.warning(f"RELENV: Detected and cached OS/arch: {kernel}/{os_arch}") - - log.info( - "RELENV: About to call gen_relenv() to download/generate tarball..." - ) - self.thin = salt.utils.relenv.gen_relenv( - self.opts["cachedir"], kernel=kernel, os_arch=os_arch - ) - log.info( - "RELENV: gen_relenv() completed successfully, tarball path: %s", - self.thin, - ) + self.thin = salt.utils.relenv.gen_relenv( + opts["cachedir"], kernel=kernel, os_arch=os_arch + ) # Add file_roots and related config to minion config # (required for slsutil functions and other fileserver operations) - # Thin does this in _run_wfunc_thin() at lines 1498-1507 - # NOTE: Now that we transfer config via SCP instead of embedding in command line, - # we CAN add __master_opts__ without hitting ARG_MAX limits self.minion_opts["file_roots"] = self.opts["file_roots"] self.minion_opts["pillar_roots"] = self.opts["pillar_roots"] - self.minion_opts["ext_pillar"] = self.opts["ext_pillar"] - # For relenv, we need to override extension_modules to point to where the shim - # extracts the tarball on the remote system. The wrapper system will copy this - # to opts_pkg["extension_modules"] which is used by salt-call. + self.minion_opts["ext_pillar"] = self.opts.get("ext_pillar", []) + # For relenv, override extension_modules to point to where the shim + # extracts the tarball on the remote system. self.minion_opts["extension_modules"] = ( f"{self.thin_dir}/running_data/var/cache/salt/minion/extmods" ) @@ -1289,18 +1284,7 @@ def __init__( self.minion_opts["__master_opts__"] = self.context["master_opts"] # Re-serialize the minion config after updating relenv-specific paths - # This ensures the config file sent to the remote system has the correct extension_modules path self.minion_config = salt.serializers.yaml.serialize(self.minion_opts) - log.debug( - "RELENV: Re-serialized minion config with extension_modules=%s", - self.minion_opts["extension_modules"], - ) - - # NOTE: We no longer pre-compile pillar for relenv here. - # Both thin and relenv now use the wrapper system (_run_wfunc_thin()) - # which compiles pillar dynamically, ensuring correct behavior with pillar overrides: - # - 1x compilation without pillar overrides - # - 2x compilation with pillar overrides (re-compiled in wrapper modules) else: self.thin = thin if thin else salt.utils.thin.thin_path(opts["cachedir"]) @@ -1923,22 +1907,16 @@ def _cmd_str(self): and isinstance(self.argv[0], str) and " " in self.argv[0] ): - # Split the string into shell words argv_to_use = shlex.split(self.argv[0]) else: argv_to_use = self.argv quoted_args = " ".join(shlex.quote(str(arg)) for arg in argv_to_use) - log.debug( - "RELENV: Building shim with argv=%s, argv_to_use=%s, quoted_args=%s", - self.argv, - argv_to_use, - quoted_args, - ) # Note: Config is sent separately via SCP in cmd_block() to avoid ARG_MAX issues - # The shim expects the config file to already exist at {THIN_DIR}/minion - return SSH_SH_SHIM_RELENV.format( + # Use .replace() for minion_config — it is YAML flow-style and + # may contain literal { } which would break .format(). + shim = SSH_SH_SHIM_RELENV.format( DEBUG=debug, SUDO=sudo, SUDO_USER=sudo_user or "", @@ -1948,6 +1926,7 @@ def _cmd_str(self): ARGS=quoted_args, EXT_MODS_VERSION=self.mods.get("version", ""), ) + return shim.replace("__SALT_MINION_CONFIG__", self.minion_config) thin_code_digest, thin_sum = salt.utils.thin.thin_sum(cachedir, "sha1") arg_str = ''' diff --git a/salt/config/__init__.py b/salt/config/__init__.py index a2bce15e92e6..9b3cb97f1fa9 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -479,6 +479,8 @@ def _gather_buffer_space(): "return_retry_tries": int, # Configures amount of retries for Syndic to Master of Masters "syndic_retries": int, + # Top-level pillar key for per-type resource configuration (default: resources) + "resource_pillar_key": str, # Specify one or more returners in which all events will be sent to. Requires that the returners # in question have an event_return(event) function! "event_return": (list, str), @@ -1300,6 +1302,7 @@ def _gather_buffer_space(): "return_retry_timer": 5, "return_retry_timer_max": 10, "return_retry_tries": 3, + "resource_pillar_key": "resources", "syndic_retries": 3, "random_reauth_delay": 10, "winrepo_source_dir": "salt://win/repo-ng/", diff --git a/salt/grains/resources.py b/salt/grains/resources.py new file mode 100644 index 000000000000..468498bd11a0 --- /dev/null +++ b/salt/grains/resources.py @@ -0,0 +1,28 @@ +""" +Expose the resource IDs managed by this minion as a grain. + +The grain ``salt_resources`` mirrors the ``resources:`` section of the minion +configuration so that the master's grains cache records which resources each +minion manages. This enables grain-based targeting (``G@salt_resources``) and +gives operators a human-readable view of resource topology via ``grains.items``. + +Example output:: + + salt_resources: + dummy: + - dummy-01 + - dummy-02 + - dummy-03 +""" + +import logging + +log = logging.getLogger(__name__) + + +def resources(): + """Return the resource IDs managed by this minion, keyed by resource type.""" + managed = __opts__.get("resources", {}) + if not managed: + return {} + return {"salt_resources": managed} diff --git a/salt/loader/__init__.py b/salt/loader/__init__.py index 283f53224413..3317b9890e6a 100644 --- a/salt/loader/__init__.py +++ b/salt/loader/__init__.py @@ -63,6 +63,7 @@ str(SALT_BASE_PATH / "output"), str(SALT_BASE_PATH / "pillar"), str(SALT_BASE_PATH / "proxy"), + str(SALT_BASE_PATH / "resource"), str(SALT_BASE_PATH / "queues"), str(SALT_BASE_PATH / "renderers"), str(SALT_BASE_PATH / "returners"), @@ -515,6 +516,92 @@ def proxy( ) +def resource( + opts, + functions=None, + utils=None, + context=None, + loaded_base_name=None, +): + """ + Load the resource connection modules (``salt/resource/*.py``). + + Returns a LazyLoader whose functions are accessible via the + ``__resource_funcs__`` dunder injected into resource execution modules. + Analogous to :func:`proxy` for proxy minions. + + :param dict opts: The Salt options dictionary. + :param LazyLoader functions: A LazyLoader returned from :func:`minion_mods`. + :param LazyLoader utils: A LazyLoader returned from :func:`utils`. + :param dict context: Shared loader context dictionary. + :param str loaded_base_name: Module namespace prefix for this loader. + """ + return LazyLoader( + _module_dirs(opts, "resource"), + opts, + tag="resource", + pack={ + "__salt__": functions, + "__utils__": utils, + "__context__": context, + "__resource__": {}, + }, + extra_module_dirs=utils.module_dirs if utils else None, + pack_self="__resource_funcs__", + loaded_base_name=loaded_base_name, + ) + + +def resource_modules( + opts, + resource_type, + resource_funcs=None, + utils=None, + context=None, + loaded_base_name=None, +): + """ + Load execution modules for a specific resource type. + + Creates an isolated :class:`LazyLoader` whose opts contain + ``resource_type``, allowing execution modules to gate their + ``__virtual__`` on that value — the same mechanism proxy modules use + with ``proxytype``. A minion managing N resource types holds N of + these loaders simultaneously (one per type, not one per device). + + :param dict opts: The Salt options dictionary. A copy is made and + ``resource_type`` is injected before passing to the loader. + :param str resource_type: The resource type string (e.g. ``"dummy"``). + :param LazyLoader resource_funcs: The resource connection loader returned + by :func:`resource`, injected as ``__resource_funcs__``. + :param LazyLoader utils: A LazyLoader returned from :func:`utils`. + :param dict context: Shared loader context dictionary. + :param str loaded_base_name: Module namespace prefix for this loader. + """ + resource_opts = dict(opts) + resource_opts["resource_type"] = resource_type + + return LazyLoader( + _module_dirs(resource_opts, "modules", "module"), + resource_opts, + tag="module", + pack={ + "__context__": context, + "__utils__": utils, + "__resource_funcs__": resource_funcs, + "__opts__": resource_opts, + # Empty sentinel so LazyLoader creates a NamedLoaderContext for + # __resource__ on every loaded module. The NamedLoaderContext + # reads from resource_ctxvar, which _thread_return sets per-call + # before dispatching — giving each resource job its own identity. + "__resource__": {}, + }, + extra_module_dirs=utils.module_dirs if utils else None, + loaded_base_name=loaded_base_name, + pack_self="__salt__", + ) + + def returners( opts, functions, whitelist=None, context=None, proxy=None, loaded_base_name=None ): diff --git a/salt/loader/context.py b/salt/loader/context.py index 40b608de6d4c..0859df9132be 100644 --- a/salt/loader/context.py +++ b/salt/loader/context.py @@ -19,6 +19,14 @@ loader_ctxvar = contextvars.ContextVar(DEFAULT_CTX_VAR) +# Per-call resource context. Set via resource_ctxvar.set() in +# _thread_return before executing the job. contextvars are per-thread: each +# new thread inherits a copy of the parent's context, and set() only mutates +# the current thread's copy. LazyLoader.run() calls copy_context() fresh on +# every invocation, so the snapshot it passes to _last_context.run() already +# contains the value we set here — completely isolated from other threads. +resource_ctxvar = contextvars.ContextVar("__resource__", default={}) + @contextlib.contextmanager def loader_context(loader): @@ -68,6 +76,13 @@ def value(self): """ The value of the current for this context """ + # __resource__ is served from resource_ctxvar, which is set + # per-thread in _thread_return before the job function executes. + # LazyLoader.run() snapshots the thread context via copy_context() + # on every call, so each _run_as invocation sees the value that was + # current when the function was invoked — no pack mutation needed. + if self.name == "__resource__": + return resource_ctxvar.get() loader = self.loader() if loader is None: return self.default diff --git a/salt/master.py b/salt/master.py index 8da8c9b1ea28..761aaafdc0c9 100644 --- a/salt/master.py +++ b/salt/master.py @@ -51,6 +51,7 @@ import salt.utils.minions import salt.utils.platform import salt.utils.process +import salt.utils.resource_registry import salt.utils.schedule import salt.utils.ssdp import salt.utils.stringutils @@ -1726,6 +1727,7 @@ class AESFuncs(TransportMethods): "_mine", "_mine_delete", "_mine_flush", + "_register_resources", "_file_recv", "_pillar", "_minion_event", @@ -2018,6 +2020,88 @@ def _mine_flush(self, load): else: return self.masterapi._mine_flush(load, skip_verify=True) + def _register_resources(self, load): + """ + Update the resource registry for a minion. Called by the minion on + startup via ``cmd: "_register_resources"`` so that the master knows + which resource IDs each minion manages. + + Delegates to :func:`salt.utils.minions.update_resource_index`, which + is a thin shim over + :meth:`salt.utils.resource_registry.ResourceRegistry.register_minion`. + The registry is an mmap-backed primary with in-process derived + ``by_type`` / ``by_minion`` views; this master worker sees the new + entries on its next read (its version cache is invalidated + on-write) and other worker processes pick up the writes on their + next throttled staleness check against the primary file — the + ``st_mtime_ns`` bump on every put/delete (see + :meth:`MmapCache._touch_mtime`) makes cross-process mutations + visible without a compaction. + """ + load = self.__verify_load(load, ("id", "resources")) + if load is False: + return {} + # The mmap resource registry is independent of minion pillar/grains disk + # cache (:conf_master:`minion_data_cache`). Registration must always run + # when minions report inventory; otherwise bare-id / T@ targeting breaks + # silently while still returning success to the minion. + n_put, n_del = salt.utils.minions.update_resource_index( + self.opts, load["id"], load["resources"] + ) + log.debug( + "Registered resources for minion '%s': %s (put=%d, deleted=%d)", + load["id"], + list(load["resources"].keys()), + n_put, + n_del, + ) + # Persist per-resource grains in the ``resource_grains`` cache bank + # so ``salt -G ':' …`` can match resources alongside + # minions. Stale entries (resource removed from this minion since + # last registration) are flushed first so a shrinking inventory + # doesn't leave ghost entries. + if self.opts.get("minion_data_cache", False): + resource_grains = load.get("resource_grains") or {} + try: + cache = self.masterapi.cache + current_srns = set(resource_grains.keys()) + # Walk existing entries and drop ones tied to this minion + # that aren't in the new payload. Owner identification piggy + # backs on the registry: each SRN is owned by exactly one + # minion at a time. + for srn in list( + cache.list(salt.utils.resource_registry.RESOURCE_GRAINS_BANK) or [] + ): + rtype, _, rid = srn.partition(":") + if not rid: + continue + if srn in current_srns: + continue + owners = self.ckminions.registry.get_managing_minions_for_srn( + rtype, rid + ) + if load["id"] in owners or not owners: + try: + cache.flush( + salt.utils.resource_registry.RESOURCE_GRAINS_BANK, srn + ) + except Exception as exc: # pylint: disable=broad-except + log.debug("resource_grains flush %s failed: %s", srn, exc) + for srn, gdict in resource_grains.items(): + if isinstance(gdict, dict): + cache.store( + salt.utils.resource_registry.RESOURCE_GRAINS_BANK, + srn, + gdict, + ) + except Exception as exc: # pylint: disable=broad-except + log.warning( + "Failed to persist resource_grains for minion '%s': %s", + load["id"], + exc, + ) + return True + def _file_recv(self, load): """ Allows minions to send files to the master, files are sent to the @@ -2219,6 +2303,13 @@ def _return(self, load): ) load["sig"] = sig + # Transport security uses load["id"] (the minion's authenticated ID) for + # the channel check above. For resource returns the minion embeds the + # resource ID separately so we can remap here, after authentication, so + # the event and job cache are keyed by the resource ID instead. + if "resource_id" in load: + load["id"] = load.pop("resource_id") + try: salt.utils.job.store_job( self.opts, load, event=self.event, mminion=self.mminion @@ -2701,7 +2792,10 @@ async def publish(self, clear_load): delimiter = extra.get("delimiter", DEFAULT_TARGET_DELIM) _res = self.ckminions.check_minions( - clear_load["tgt"], clear_load.get("tgt_type", "glob"), delimiter + clear_load["tgt"], + clear_load.get("tgt_type", "glob"), + delimiter, + fun=clear_load.get("fun"), ) minions = _res.get("minions", list()) missing = _res.get("missing", list()) diff --git a/salt/matchers/compound_match.py b/salt/matchers/compound_match.py index 04da7281e3ee..5438a4470f3c 100644 --- a/salt/matchers/compound_match.py +++ b/salt/matchers/compound_match.py @@ -50,6 +50,8 @@ def match(tgt, opts=None, minion_id=None): "N": None, # Nodegroups should already be expanded "S": "ipcidr", "E": "pcre", + "T": "resource", + "M": "managing_minion", } if HAS_RANGE: ref["R"] = "range" diff --git a/salt/matchers/managing_minion_match.py b/salt/matchers/managing_minion_match.py new file mode 100644 index 000000000000..f18f2c03b906 --- /dev/null +++ b/salt/matchers/managing_minion_match.py @@ -0,0 +1,41 @@ +""" +Minion-side matcher for the ``M@`` managing-minion targeting engine. + +A ``M@`` expression targets a minion directly by its ID, as the entity +*responsible for* a set of resources — rather than targeting the resources +themselves. It is most useful in compound expressions where you want to +constrain a resource target to those owned by a specific minion: + +.. code-block:: text + + salt -C 'M@vcenter-1 and T@vcf_host' + +That expression matches all ``vcf_host`` resources managed by the minion +whose ID is ``vcenter-1``. On its own ``M@vcenter-1`` is equivalent to +``L@vcenter-1``, but pairing it with ``T@`` is its primary use-case. +""" + +import logging + +log = logging.getLogger(__name__) + + +def match(tgt, opts=None, minion_id=None): + """ + Return ``True`` if this minion's ID equals ``tgt``. + + ``tgt`` is the minion ID given after the ``M@`` prefix. The match is + always an exact equality check — no globbing or regex. + + :param str tgt: The minion ID to match against. + :param dict opts: Salt opts dict; defaults to ``__opts__``. + :param str minion_id: The minion ID to evaluate; defaults to ``opts["id"]``. + :rtype: bool + """ + if opts is None: + opts = __opts__ # pylint: disable=undefined-variable + if minion_id is None: + minion_id = opts.get("id", "") + result = minion_id == tgt + log.debug("managing_minion_match: M@%s => %s (id=%s)", tgt, result, minion_id) + return result diff --git a/salt/matchers/resource_match.py b/salt/matchers/resource_match.py new file mode 100644 index 000000000000..cb6ddc7bb378 --- /dev/null +++ b/salt/matchers/resource_match.py @@ -0,0 +1,56 @@ +""" +Minion-side matcher for the ``T@`` resource targeting engine. + +A ``T@`` expression targets Salt Resources managed by this minion. The +pattern is either a bare resource type or a full Salt Resource Name (SRN): + +.. code-block:: text + + T@vcf_host # any resource of this type + T@vcf_host:esxi-01 # one specific resource by SRN + +This matcher is evaluated on the minion. It reads from ``opts["resources"]``, +which is populated when the minion loads its resource modules — analogous to +how ``grain_match`` reads from ``opts["grains"]``. No cache or registry +lookup is performed. +""" + +import logging + +log = logging.getLogger(__name__) + + +def match(tgt, opts=None, minion_id=None): + """ + Return ``True`` if this minion manages at least one resource that matches + the ``T@`` pattern ``tgt``. + + ``tgt`` is the portion of the ``T@`` expression after the ``@``. It is + either a bare resource type (``vcf_host``) or a full SRN + (``vcf_host:esxi-01``). When a bare type is given, every resource of that + type in ``opts["resources"]`` satisfies the match. When a full SRN is + given, only an exact match against a resource ID in ``opts["resources"]`` + satisfies it. + + The structure of ``opts["resources"]`` is populated by the resource module + loader at minion startup, analogous to ``opts["grains"]``. + + :param str tgt: The T@ pattern — a resource type or a full SRN. + :param dict opts: Salt opts dict; defaults to ``__opts__``. + :param str minion_id: The minion ID to evaluate; defaults to ``opts["id"]``. + :rtype: bool + """ + if opts is None: + opts = __opts__ # pylint: disable=undefined-variable + resources = opts.get("resources", {}) + if not resources: + return False + + if ":" in tgt: + resource_type, resource_id = tgt.split(":", 1) + result = resource_id in resources.get(resource_type, []) + else: + result = bool(resources.get(tgt)) + + log.debug("resource_match: T@%s => %s (resources=%s)", tgt, result, list(resources)) + return result diff --git a/salt/minion.py b/salt/minion.py index 06b18f574276..6d44ff2ac923 100644 --- a/salt/minion.py +++ b/salt/minion.py @@ -59,6 +59,7 @@ import salt.utils.network import salt.utils.platform import salt.utils.process +import salt.utils.resources import salt.utils.schedule import salt.utils.ssdp import salt.utils.state @@ -463,6 +464,11 @@ def gen_modules(self, initial_load=False, context=None): pillarenv=self.opts.get("pillarenv"), ).compile_pillar() + # Populate opts["resources"] from pillar now that pillar is available. + # Must happen before the resource loader loop below so that per-type + # execution module loaders are created for the correct set of types. + self.opts["resources"] = self._discover_resources() + self.utils = salt.loader.utils(self.opts, context=context) self.functions = salt.loader.minion_mods( self.opts, utils=self.utils, context=context @@ -474,6 +480,59 @@ def gen_modules(self, initial_load=False, context=None): self.proxy = salt.loader.proxy( self.opts, functions=self.functions, returners=self.returners ) + # Load resource connection modules (salt/resource/*.py) and build + # one execution-module loader per managed resource type. + self.resource_funcs = salt.loader.resource( + self.opts, + functions=self.functions, + utils=self.utils, + context=context, + ) + self.resource_funcs.pack["__salt__"] = self.functions + # Build resource_loaders into a local dict before assigning to + # self.resource_loaders. Without this, the previous pattern: + # + # self.resource_loaders = {} ← exposes empty dict + # for ...: self.resource_loaders[t] = … + # + # creates a window where a concurrent thread (multiprocessing: False) + # calling gen_modules() can read resource_loaders.get(type) == None + # and fail with "No resource loader available". A single dict + # assignment is atomic in CPython, so the old loaders remain visible + # until the new complete set is ready. + _new_resource_loaders = {} + for resource_type in self.opts.get("resources", {}): + rtype_base = ( + f"{self.opts.get('loaded_base_name', 'salt.loaded.int')}" + f".resource.{resource_type}" + ) + _new_resource_loaders[resource_type] = salt.loader.resource_modules( + self.opts, + resource_type, + resource_funcs=self.resource_funcs, + utils=self.utils, + context=context, + loaded_base_name=rtype_base, + ) + self.resource_loaders = _new_resource_loaders + + # Call init() on each resource type so that __context__ is populated + # before any per-resource operations (grains, ping, etc.) are dispatched. + # Mirrors how proxy.init() is called during proxy-minion startup. + for resource_type in self.opts.get("resources", {}): + init_fn = f"{resource_type}.init" + if init_fn in self.resource_funcs: + try: + self.resource_funcs[init_fn](self.opts) + log.debug("Initialized resource type '%s'", resource_type) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Failed to initialize resource type '%s': %s", + resource_type, + exc, + exc_info=True, + ) + # TODO: remove self.function_errors = {} # Keep the funcs clean self.states = salt.loader.states( @@ -493,6 +552,63 @@ def gen_modules(self, initial_load=False, context=None): self.opts, functions=self.functions, proxy=self.proxy, context=context ) + def _discover_resources(self): + """ + Build ``opts["resources"]`` by calling each resource type's + ``discover(opts)`` function. + + Resource types are read from the pillar subtree at + ``opts["pillar"][opts["resource_pillar_key"]]`` (default key + ``"resources"``, configurable via minion option ``resource_pillar_key``). + A temporary resource loader is used to call each type's + ``discover(opts)``; the return value is a dict of + ``{resource_type: [resource_id, ...]}``. + + If the merged pillar contains no key by that name, that is treated the + same as an empty mapping: no pillar-declared resource types, so + discovery returns an empty dict (no stale IDs left in + ``opts["resources"]``). + + If the pillar *does* contain that key (even if its value is empty / + all entries removed), that is an authoritative declaration and the + result reflects only what the pillar says (via ``discover()`` per + type). + + Called from :meth:`gen_modules` after pillar is compiled and before + the per-type execution-module loaders are created. + """ + pillar_resources = salt.utils.resources.pillar_resources_tree(self.opts) + + # A minimal resource loader is sufficient here — discover() only reads + # from the opts dict passed to it and does not need other dunders. + discovery_loader = salt.loader.resource(self.opts) + discovered = {} + for resource_type in pillar_resources: + discover_fn = f"{resource_type}.discover" + if discover_fn not in discovery_loader: + log.warning( + "No resource module found for type '%s'; skipping discovery.", + resource_type, + ) + continue + try: + ids = discovery_loader[discover_fn](self.opts) + if ids: + discovered[resource_type] = list(ids) + log.debug( + "Discovered %d resource(s) of type '%s': %s", + len(ids), + resource_type, + ids, + ) + except Exception as exc: # pylint: disable=broad-except + log.warning( + "Resource discovery failed for type '%s': %s", + resource_type, + exc, + ) + return discovered + @staticmethod def process_schedule(minion, loop_interval): try: @@ -1559,6 +1675,11 @@ async def _post_master_init(self, master): ) self.opts["pillar"] = await async_pillar.compile_pillar() async_pillar.destroy() + # _setup_core uses _load_modules only — unlike gen_modules it does not + # run _discover_resources(). tune_in schedules _register_resources_with_master + # right after connect; without this, the master registry gets {} until an + # async pillar refresh completes (easy to miss with saltutil.sync_all). + self.opts["resources"] = self._discover_resources() if not self.ready: self._setup_core() @@ -1571,6 +1692,7 @@ async def _post_master_init(self, master): self.function_errors, self.executors, ) = self._load_modules() + self.opts["resources"] = self._discover_resources() if hasattr(self, "schedule"): self.schedule.functions = self.functions self.schedule.returners = self.returners @@ -1935,7 +2057,13 @@ async def _handle_decoded_payload_impl(self, data): # Check bypass flag early to prevent deduplication of queued jobs bypass_check = data.get("__ignore_process_count_max", False) if self.jid_queue is not None: - if data["jid"] in self.jid_queue: + if data.get("resource_job"): + # Resource jobs intentionally share the parent job's JID so + # that returns are filed under the same job ID. Skip the + # deduplication gate entirely — each resource is a distinct + # execution even though the JID is the same. + pass + elif data["jid"] in self.jid_queue: if not bypass_check: return else: @@ -2459,12 +2587,18 @@ def _target(cls, minion_instance, opts, data, connected, creds_map): return Minion._thread_return(minion_instance, opts, data) def _execute_job_function( - self, function_name, function_args, executors, opts, data + self, function_name, function_args, executors, opts, data, functions=None ): """ Executes a function within a job given it's name, the args and the executors. It also checks if the function is allowed to run if 'blackout mode' is enabled. + + ``functions`` defaults to ``self.functions`` but callers may pass a + different loader (e.g. a per-resource-type loader) to route execution + to the correct module set. """ + if functions is None: + functions = self.functions minion_blackout_violation = False if self.connected and self.opts["pillar"].get("minion_blackout", False): whitelist = self.opts["pillar"].get("minion_blackout_whitelist", []) @@ -2489,14 +2623,14 @@ def _execute_job_function( "saltutil.refresh_pillar allowed in blackout mode." ) - if function_name in self.functions: - func = self.functions[function_name] + if function_name in functions: + func = functions[function_name] args, kwargs = load_args_and_kwargs(func, function_args, data) else: - # only run if function_name is not in minion_instance.functions and allow_missing_funcs is True + # only run if function_name is not in functions and allow_missing_funcs is True func = function_name args, kwargs = function_args, data - self.functions.pack["__context__"]["retcode"] = 0 + functions.pack["__context__"]["retcode"] = 0 if isinstance(executors, str): executors = [executors] @@ -2560,13 +2694,53 @@ def _thread_return(cls, minion_instance, opts, data): if f"{executor}.allow_missing_func" in minion_instance.executors ] ) + # Resolve which execution-module loader to use. For resource + # jobs we use the per-type loader so that resource-specific + # execution modules (e.g. dummyresource_test.py) take + # precedence over the managing minion's own modules. + # Unknown functions for a resource type fail loudly rather than + # silently falling through to execute on the managing minion. + resource_target = data.get("resource_target") + if resource_target: + resource_type = resource_target["type"] + functions_to_use = minion_instance.resource_loaders.get(resource_type) + if functions_to_use is None: + ret["return"] = ( + f"No resource loader available for type '{resource_type}'. " + "Ensure the resource module exists and the minion is " + "configured to manage resources of this type." + ) + ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC + else: + # Set the per-call resource context via resource_ctxvar. + # contextvars are per-thread, so this value is invisible + # to other threads. LazyLoader.run() calls copy_context() + # fresh on every invocation, capturing this value in the + # snapshot before _run_as executes — fully isolated from + # concurrent resource jobs sharing the same loader object. + import salt.loader.context as _loader_ctx + + _loader_ctx.resource_ctxvar.set(resource_target) + grains_fn = f"{resource_type}.grains" + if grains_fn in minion_instance.resource_funcs: + functions_to_use.pack["__grains__"] = ( + minion_instance.resource_funcs[grains_fn]() + ) + else: + functions_to_use = minion_instance.functions if ( - function_name in minion_instance.functions - or allow_missing_funcs is True + ret.get("retcode") is None + and functions_to_use is not None + and (function_name in functions_to_use or allow_missing_funcs is True) ): try: return_data = minion_instance._execute_job_function( - function_name, function_args, executors, opts, data + function_name, + function_args, + executors, + opts, + data, + functions=functions_to_use, ) log.info( "Job %s execution finished, return_data: %s", @@ -2594,7 +2768,7 @@ def _thread_return(cls, minion_instance, opts, data): else: ret["return"] = return_data - retcode = minion_instance.functions.pack["__context__"].get( + retcode = functions_to_use.pack["__context__"].get( "retcode", salt.defaults.exitcodes.EX_OK ) if retcode == salt.defaults.exitcodes.EX_OK: @@ -2652,14 +2826,10 @@ def _thread_return(cls, minion_instance, opts, data): ret["out"] = "nested" ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC except TypeError as exc: - # XXX: This can ba extreemly missleading when something outside of a - # execution module call raises a TypeError. Make this it's own - # type of exception when we start validating state and - # execution argument module inputs. msg = "Passed invalid arguments to {}: {}\n{}".format( function_name, exc, - minion_instance.functions[function_name].__doc__ or "", + functions_to_use[function_name].__doc__ or "", ) log.warning(msg, exc_info_on_loglevel=logging.DEBUG) ret["return"] = msg @@ -2674,6 +2844,20 @@ def _thread_return(cls, minion_instance, opts, data): ret["return"] = f"{msg}: {traceback.format_exc()}" ret["out"] = "nested" ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC + elif resource_target: + if functions_to_use is not None: + # Resource type has a loader but function is not implemented. + # Fail loudly rather than silently falling through to the + # managing minion — the caller explicitly targeted a resource. + ret["return"] = ( + f"Function '{function_name}' is not supported for " + f"resource type '{resource_type}'. Implement it in a " + f"'{resource_type}resource_*' execution module." + ) + ret["success"] = False + ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC + ret["out"] = "nested" + # else: no-loader case already populated ret above else: docs = minion_instance.functions["sys.doc"](f"{function_name}*") if docs: @@ -2694,6 +2878,129 @@ def _thread_return(cls, minion_instance, opts, data): ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC ret["out"] = "nested" + # ------------------------------------------------------------------- + # Merge-mode: for state functions the managing minion runs each + # resource's function inline here and folds the results into its own + # state dict. The operator then sees ONE combined block + ONE Summary + # section instead of a separate block per resource. + # ------------------------------------------------------------------- + if ( + not data.get("resource_target") + and data.get("fun") in cls._MERGE_RESOURCE_FUNS + and data.get("resource_targets") + and isinstance(ret.get("return"), dict) + ): + import salt.loader.context as _loader_ctx # noqa: PLC0415 + + _prefix_state_key = minion_instance._prefix_resource_state_key + + run_num_base = ( + max( + ( + v.get("__run_num__", 0) + for v in ret["return"].values() + if isinstance(v, dict) + ), + default=0, + ) + + 1 + ) + + for resource in data["resource_targets"]: + rid = resource["id"] + rtype = resource["type"] + resource_loader = getattr( + minion_instance, "resource_loaders", {} + ).get(rtype) + if resource_loader is None: + ret["return"][f"no_|-{rid}_|-{rid}_|-None"] = { + "result": False, + "comment": ( + f"No resource loader for type '{rtype}'. " + "Ensure the resource module exists." + ), + "name": rid, + "changes": {}, + "__run_num__": run_num_base, + } + run_num_base += 1 + if ret.get("retcode") == salt.defaults.exitcodes.EX_OK: + ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC + continue + + if function_name not in resource_loader: + # Function not implemented for this resource type — + # same message the separate-job path would return. + resource_return = ( + f"Function '{function_name}' is not supported for " + f"resource type '{rtype}'. Implement it in a " + f"'{rtype}resource_*' execution module." + ) + else: + token = _loader_ctx.resource_ctxvar.set(resource) + try: + resource_return = minion_instance._execute_job_function( + function_name, + function_args, + executors, + opts, + data, + functions=resource_loader, + ) + except Exception as exc: # pylint: disable=broad-except + log.error( + "Inline resource execution for '%s' raised: %s", + rid, + exc, + exc_info=True, + ) + resource_return = ( + f"ERROR running {function_name} for '{rid}': {exc}" + ) + finally: + _loader_ctx.resource_ctxvar.reset(token) + + if isinstance(resource_return, dict): + for state_id, state_val in resource_return.items(): + if isinstance(state_val, dict): + entry = dict(state_val) + entry["__run_num__"] = run_num_base + else: + entry = { + "result": True, + "comment": str(state_val), + "name": f"[{rid}]", + "changes": {}, + "__run_num__": run_num_base, + } + run_num_base += 1 + ret["return"][_prefix_state_key(state_id, rid)] = entry + r_retcode = resource_loader.pack["__context__"].get( + "retcode", 0 + ) + if ( + r_retcode + and ret.get("retcode") == salt.defaults.exitcodes.EX_OK + ): + ret["retcode"] = r_retcode + else: + # String result means the resource couldn't fulfill the + # operation (e.g. "not supported" for dummy resources). + # Mark as False — the state was NOT applied, so reporting + # True would silently mask unactioned resources. + ret["return"][f"no_|-{rid}_|-{rid}_|-None"] = { + "result": False, + "comment": str(resource_return), + "name": rid, + "changes": {}, + "__run_num__": run_num_base, + } + run_num_base += 1 + if ret.get("retcode") == salt.defaults.exitcodes.EX_OK: + ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC + + ret["success"] = ret.get("retcode") == salt.defaults.exitcodes.EX_OK + if isinstance(ret["return"], dict) and ret["return"].get("__no_return__"): # This is used to suppress the return for queued jobs # The job will be executed later and will return then @@ -2707,6 +3014,11 @@ def _thread_return(cls, minion_instance, opts, data): ret["jid"] = data["jid"] ret["fun"] = data["fun"] ret["fun_args"] = data["arg"] + if data.get("resource_target"): + log.info( + "resource_target in _thread_return: %s", data["resource_target"] + ) + ret["resource_id"] = data["resource_target"]["id"] if "user" in data: ret["user"] = data["user"] if "master_id" in data: @@ -3215,6 +3527,123 @@ async def _fire_master_minion_start(self): include_startup_grains=include_grains, ) + def _collect_resource_grains(self): + """ + Render per-resource grain dicts for every resource this minion manages. + + For each ``(resource_type, resource_id)`` pair, sets the per-call + :data:`salt.loader.context.resource_ctxvar` and invokes + ``resource_funcs[f"{type}.grains"]()``. Returns a mapping keyed by + composite SRN ``":"`` → grain dict, suitable for shipping + to the master in :meth:`_register_resources_with_master`. + + Resource types without a ``grains`` callable are skipped silently. + Per-resource grain failures are logged and skipped; a single broken + resource never blocks registration of the others. + + :rtype: dict[str, dict] + """ + import salt.loader.context as _loader_ctx # noqa: PLC0415 + + resource_grains = {} + resources = self.opts.get("resources", {}) + for rtype, rids in (resources or {}).items(): + grains_fn = f"{rtype}.grains" + if grains_fn not in getattr(self, "resource_funcs", {}): + continue + for rid in rids or (): + target = {"id": rid, "type": rtype} + tok = _loader_ctx.resource_ctxvar.set(target) + try: + gdict = self.resource_funcs[grains_fn]() + except Exception as exc: # pylint: disable=broad-except + log.warning( + "Failed to collect grains for resource %s:%s: %s", + rtype, + rid, + exc, + ) + gdict = None + finally: + _loader_ctx.resource_ctxvar.reset(tok) + if isinstance(gdict, dict): + resource_grains[f"{rtype}:{rid}"] = gdict + return resource_grains + + async def _register_resources_with_master(self): + """ + Send this minion's resource list to the master for registry population. + + Called on startup (and reconnect) so that the master's + ``minion_resources`` cache bank is up-to-date. This allows + :class:`salt.utils.minions.CkMinions` to include resource IDs when + expanding glob / non-compound targets (e.g. ``salt '*' test.ping``). + + Also ships a per-resource ``resource_grains`` mapping so the master + can populate its ``resource_grains`` cache bank for ``-G`` / + ``salt -G`` grain-based targeting of resources. + + **Freshness model.** The ``resource_grains`` snapshot is taken at + the moment of this call by :meth:`_collect_resource_grains`. A + per-resource ``.grains_refresh()`` invocation that mutates + the underlying state does **not** automatically propagate to the + master — the master's view is refreshed only when this method runs + again. The triggers that re-run it are: + + * minion start / reconnect (``tune_in``); + * the ``resource_refresh`` event on the minion event bus (see + ``manage_event_iter``); and + * a successful ``saltutil.refresh_pillar`` / ``module_refresh`` + (because :meth:`_post_master_init` re-discovers resources). + + Operators who need the master to see fresh resource grains after + an out-of-band state change should fire ``resource_refresh`` or + run ``salt-call saltutil.refresh_pillar``. + + An empty resource dict is sent deliberately when the minion has no + resources — this clears any stale entries left by a previous + registration (e.g. after a resource type is removed from the pillar). + """ + resources = self.opts.get("resources", {}) + if resources and not getattr(self, "resource_funcs", None): + # ``resource_funcs`` is the loader for ``salt/resource/.py`` + # connection modules. Unlike ``functions``/``returners`` (built + # by :meth:`_setup_core` during :meth:`_post_master_init`), the + # resource loaders are normally materialised lazily on the + # **first job dispatch** by :meth:`_thread_return.gen_modules`. + # Resource registration runs **before** any job has been + # dispatched, so without this eager call ``resource_funcs`` is + # an empty :class:`LazyLoader` and + # :meth:`_collect_resource_grains` finds no ``.grains`` + # callables to invoke — the master ends up with an empty + # ``resource_grains`` bank and ``salt -G ...`` silently fails + # to match resources. The cost is paid once per registration; + # subsequent calls hit the populated loader and skip this + # branch. + try: + self.gen_modules() + except Exception as exc: # pylint: disable=broad-except + log.warning( + "Failed to gen_modules before resource grain collection: %s", + exc, + ) + resource_grains = self._collect_resource_grains() if resources else {} + # Cache locally so :meth:`_resolve_resource_targets` can resolve + # ``tgt_type == "grain"`` without re-rendering. + self._resource_grains_cache = resource_grains + load = { + "cmd": "_register_resources", + "id": self.opts["id"], + "resources": resources, + "resource_grains": resource_grains, + "tok": self.tok, + } + try: + await self._send_req_async_main(load, timeout=self._return_retry_timer()) + log.debug("Registered resources with master: %s", list(resources.keys())) + except Exception as err: # pylint: disable=broad-except + log.warning("Unable to register resources with master: %s", err) + def module_refresh(self, force_refresh=False, notify=False): """ Refresh the functions and returners. @@ -3322,6 +3751,12 @@ async def pillar_refresh(self, force_refresh=False, clean_cache=False): ) self.opts["pillar"] = new_pillar self.functions.pack["__pillar__"] = self.opts["pillar"] + # Re-discover resources now that pillar has changed. Must + # happen *after* opts["pillar"] is updated so that + # _discover_resources sees the new resource declarations (or + # their absence when a type is removed from the pillar). + self.opts["resources"] = self._discover_resources() + await self._register_resources_with_master() finally: async_pillar.destroy() self.matchers_refresh() @@ -3552,6 +3987,9 @@ async def handle_event(self, package): _minion.beacons_refresh() elif tag.startswith("matchers_refresh"): _minion.matchers_refresh() + elif tag.startswith("resource_refresh"): + _minion.opts["resources"] = _minion._discover_resources() + _minion.io_loop.create_task(_minion._register_resources_with_master()) elif tag.startswith("manage_schedule"): _minion.manage_schedule(tag, data) elif tag.startswith("manage_beacons"): @@ -3669,6 +4107,7 @@ async def handle_event(self, package): self.schedule.functions = self.functions self.pub_channel.on_recv(self._handle_payload) await self._fire_master_minion_start() + await self._register_resources_with_master() log.info("Minion is ready to receive requests!") # update scheduled job to run with the new master addr @@ -4127,6 +4566,7 @@ def tune_in(self, start=True): self.sync_connect_master() if self.connected: self.io_loop.create_task(self._fire_master_minion_start()) + self.io_loop.create_task(self._register_resources_with_master()) log.info("Minion is ready to receive requests!") # Make sure to gracefully handle SIGUSR1 @@ -4209,7 +4649,26 @@ def ping_timeout_handler(*_): async def _handle_payload(self, payload): if payload is not None and payload["enc"] == "aes": if self._target_load(payload["load"]): - await self._handle_decoded_payload(payload["load"]) + load = payload["load"] + + if load.get("minion_is_target", True): + await self._handle_decoded_payload(load) + + # For merge-mode functions (state.apply etc.) resources are + # executed inline inside _thread_return and folded into the + # managing minion's own response. Dispatching them as + # separate jobs would send duplicate responses the master is + # no longer waiting for. + if load.get("fun") not in self._MERGE_RESOURCE_FUNS: + for resource in load.get("resource_targets", []): + resource_load = dict(load) + resource_load["resource_target"] = resource + # Flag so _handle_decoded_payload_impl can bypass JID + # deduplication — resource jobs share the parent JID by + # design but are independent executions. + resource_load["resource_job"] = True + await self._handle_decoded_payload(resource_load) + elif self.opts["zmq_filtering"]: # In the filtering enabled case, we'd like to know when minion sees something it shouldn't log.trace( @@ -4245,16 +4704,300 @@ def _target_load(self, load): return False if load["tgt_type"] in ("grain", "grain_pcre", "pillar"): delimiter = load.get("delimiter", DEFAULT_TARGET_DELIM) - if not match_func(load["tgt"], delimiter=delimiter): - return False - elif not match_func(load["tgt"]): - return False + minion_matches = match_func(load["tgt"], delimiter=delimiter) + else: + minion_matches = match_func(load["tgt"]) else: - if not self.matchers["glob_match.match"](load["tgt"]): - return False + minion_matches = self.matchers["glob_match.match"](load["tgt"]) + + resource_targets = self._resolve_resource_targets(load) + load["resource_targets"] = resource_targets + load["minion_is_target"] = bool( + minion_matches + ) and not self._is_pure_resource_target(load) + if not load["minion_is_target"] and not resource_targets: + return False return True + def _is_pure_resource_target(self, load): + """ + Return True when the target expression contains only T@/M@ engines with + no glob/grain/pillar/list terms that would match the minion itself. + """ + tgt = load.get("tgt", "") + tgt_type = load.get("tgt_type", "glob") + if tgt_type != "compound": + return False + words = tgt.split() if isinstance(tgt, str) else list(tgt) + opers = {"and", "or", "not", "(", ")"} + return all( + w in opers or w.startswith("T@") or w.startswith("M@") for w in words + ) + + # Functions that are internal Salt plumbing and should never be dispatched + # to managed resources. Resources don't participate in job-status queries, + # module refreshes, or other minion-only housekeeping calls. + _NO_RESOURCE_FUNS = frozenset( + { + "saltutil.find_job", + "saltutil.running", + "saltutil.is_running", + "saltutil.kill_job", + "saltutil.signal_job", + "saltutil.term_job", + "saltutil.refresh_grains", + "saltutil.sync_all", + "saltutil.sync_grains", + "saltutil.sync_modules", + "sys.reload_modules", + } + ) + + # Functions where resource results are merged into the managing minion's + # own response rather than dispatched as independent jobs. This produces + # ONE combined block + Summary section per managing minion instead of + # separate blocks per resource, matching how any other minion looks. + _MERGE_RESOURCE_FUNS = frozenset( + { + "state.apply", + "state.highstate", + "state.sls", + "state.sls_id", + "state.single", + } + ) + + @staticmethod + def _prefix_resource_state_key(sid, rid): + """Re-label the ID/name components of a state result key with rid. + + Key format: {module}_|-{id}_|-{name}_|-{function} + Only comps[1] and comps[2] (id and name) are prefixed so the + highstate formatter still reads {comps[0]}.{comps[3]} correctly, + preserving ``Function: pkg.installed`` while showing ``ID: node1 curl``. + """ + parts = sid.split("_|-", 3) + if len(parts) == 4: + parts[1] = f"{rid} {parts[1]}" + parts[2] = f"{rid} {parts[2]}" + return "_|-".join(parts) + return f"no_|-{rid}_|-{rid}_|-None" + + def _resource_term_matches(self, term, rtype, rid, gdict, resources): + """ + Evaluate a single compound-expression term against one resource. + + Used by :meth:`_resource_matches_compound` to render each term in + a compound expression to ``True``/``False`` before evaluating the + boolean combinators. + + Supported engines (everything else returns ``False`` because it + targets minions, not resources): + + * ``T@type[:id]`` — resource-type / resource-id match. + * ``G@key:value`` — per-resource grain ``subdict_match``. + * ``P@key:regex`` — per-resource grain regex match. + * ``L@a,b,c`` — bare-id list membership. + * ``E@regex`` — bare-id regex match. + """ + if term.startswith("T@"): + pattern = term[2:] + if ":" in pattern: + t, _, r = pattern.partition(":") + if not r: + return rtype == t and rid in resources.get(t, []) + return rtype == t and rid == r and rid in resources.get(t, []) + return rtype == pattern and rid in resources.get(pattern, []) + if term.startswith("G@"): + try: + return bool( + salt.utils.data.subdict_match( + gdict, + term[2:], + delimiter=DEFAULT_TARGET_DELIM, + regex_match=False, + ) + ) + except Exception: # pylint: disable=broad-except + return False + if term.startswith("P@"): + try: + return bool( + salt.utils.data.subdict_match( + gdict, + term[2:], + delimiter=DEFAULT_TARGET_DELIM, + regex_match=True, + ) + ) + except Exception: # pylint: disable=broad-except + return False + if term.startswith("L@"): + return rid in [t.strip() for t in term[2:].split(",") if t.strip()] + if term.startswith("E@"): + try: + import re as _re # noqa: PLC0415 + + return bool(_re.match(term[2:], rid)) + except _re.error: + return False + # M@, I@, J@, S@, N@, R@, plain glob — these target minions or + # operate on data resources don't carry. Treated as non-matching + # so that compounds like ``G@a:1 and not S@10/8`` evaluate against + # a resource purely on its grains (the ``S@`` term contributes + # ``False`` and ``not False`` is ``True``, leaving the grain term + # to decide). + return False + + def _resource_matches_compound(self, tgt, srn, gdict, resources): + """ + Evaluate a full compound expression against one resource. + + Tokenises ``tgt``, replaces every non-operator token with the + ``True``/``False`` literal produced by :meth:`_resource_term_matches`, + and evaluates the resulting Python boolean expression. The eval + environment is restricted to no builtins and no locals, so only + operator semantics (``and``, ``or``, ``not``, ``(``, ``)``) are + available — there is no path for tokens to inject arbitrary Python. + + :returns: ``True`` if the compound expression matches this + resource, ``False`` otherwise (including malformed input). + """ + rtype, _, rid = srn.partition(":") + if not rid: + return False + words = tgt.split() if isinstance(tgt, str) else list(tgt) + if not words: + return False + parts = [] + for word in words: + if word in ("and", "or", "not", "(", ")"): + parts.append(word) + continue + matched = self._resource_term_matches(word, rtype, rid, gdict, resources) + parts.append("True" if matched else "False") + expression = " ".join(parts).strip() + if not expression: + return False + try: + return bool( + eval(expression, {"__builtins__": {}}, {}) # pylint: disable=eval-used + ) + except Exception: # pylint: disable=broad-except + return False + + def _resolve_resource_targets(self, load): + """ + Return the list of per-resource dicts ``{"id": ..., "type": ...}`` that + the target expression matches against ``opts["resources"]``. + + For wildcard glob targets (e.g. ``salt '*'``), returns all managed + resources so that the command also runs against resources. + For compound T@ targets, returns only the matched resources. + For list targets, returns resources whose bare id appears in the list. + For an exact glob with no wildcards, returns a single resource if ``tgt`` + is a bare id managed by this minion (``salt ``). + For specific-name glob targets that are not resource ids (e.g. + ``salt 'minion'``), grain, pillar, or compound expressions with no T@ + terms, returns an empty list — the operator is targeting the minion + itself, not its resources. + Internal/plumbing functions (see ``_NO_RESOURCE_FUNS``) are never + dispatched to resources. + """ + resources = self.opts.get("resources", {}) + if not resources: + return [] + + if load.get("fun") in self._NO_RESOURCE_FUNS: + return [] + + tgt = load.get("tgt", "") + tgt_type = load.get("tgt_type", "glob") + + if tgt_type == "compound": + # Per-resource boolean evaluation of the compound expression. + # Each resource is treated as an independent target whose + # identity (``type``, ``id``) and grain dict drive the truth + # value of every term; ``and`` / ``or`` / ``not`` / parens are + # evaluated using Python's boolean operators after rendering + # each term to ``True``/``False``. + rg = getattr(self, "_resource_grains_cache", None) + if rg is None: + rg = self._collect_resource_grains() + self._resource_grains_cache = rg + # Include every managed resource even if it has no grain entry + # — so plain ``T@type`` / ``T@type:id`` compounds still match + # for resources whose connection module exposes no ``grains``. + all_srns = dict(rg) + for rtype, rids in (resources or {}).items(): + for rid in rids or (): + all_srns.setdefault(f"{rtype}:{rid}", {}) + targets = [] + for srn, gdict in all_srns.items(): + if self._resource_matches_compound(tgt, srn, gdict, resources): + rtype, _, rid = srn.partition(":") + if rid: + targets.append({"id": rid, "type": rtype}) + return targets + + if tgt_type == "list": + tokens = ( + [t.strip() for t in tgt.split(",") if t.strip()] + if isinstance(tgt, str) + else [str(t).strip() for t in tgt if str(t).strip()] + ) + targets = [] + for token in tokens: + for rtype, rids in resources.items(): + if token in rids: + targets.append({"id": token, "type": rtype}) + return targets + + if tgt_type in ("grain", "grain_pcre"): + # Match each managed resource's per-resource grains against + # ``tgt`` (a ``key:value`` expression). Uses the cache populated + # by :meth:`_register_resources_with_master`; refreshes on miss + # so a never-registered minion still resolves its own targets. + rg = getattr(self, "_resource_grains_cache", None) + if rg is None: + rg = self._collect_resource_grains() + self._resource_grains_cache = rg + targets = [] + for srn, gdict in rg.items(): + if salt.utils.data.subdict_match( + gdict, + tgt, + delimiter=DEFAULT_TARGET_DELIM, + regex_match=(tgt_type == "grain_pcre"), + ): + rtype, _, rid = srn.partition(":") + if rid: + targets.append({"id": rid, "type": rtype}) + return targets + + # For glob targets, only dispatch to resources when the pattern + # contains a wildcard. A bare name like ``salt 'minion' test.ping`` + # targets the minion itself; it should not implicitly run against its + # resources. ``salt '*' test.ping`` or ``salt 'web*' test.ping`` + # opts in to resource dispatch. + if ( + tgt_type == "glob" + and isinstance(tgt, str) + and not any(c in tgt for c in ("*", "?", "[")) + ): + for rtype, rids in resources.items(): + if tgt in rids: + return [{"id": tgt, "type": rtype}] + return [] + + # Wildcard glob — dispatch to all managed resources. + all_resources = [] + for rtype, rids in resources.items(): + for rid in rids: + all_resources.append({"id": rid, "type": rtype}) + return all_resources + def destroy(self): """ Tear down the minion diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index b8d48949f0bb..f57f863e6e1a 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -79,6 +79,9 @@ # Overwriting the cmd python module makes debugging modules with pdb a bit # harder so let's do it this way instead. def __virtual__(): + # Yield to resource-type override modules (e.g. sshresource_cmd.py). + if __opts__.get("resource_type"): # pylint: disable=undefined-variable + return False, "cmd: not loaded in resource-type loaders" return __virtualname__ diff --git a/salt/modules/dummyresource_test.py b/salt/modules/dummyresource_test.py new file mode 100644 index 000000000000..524292939f11 --- /dev/null +++ b/salt/modules/dummyresource_test.py @@ -0,0 +1,50 @@ +""" +Provide the ``test`` execution module for the dummy resource type. + +This is the resource analogue of ``salt/modules/dummyproxy_test.py``. + +Because this module is loaded into an isolated per-type +:func:`salt.loader.resource_modules` loader (``opts["resource_type"]`` is +set to ``"dummy"`` for that loader), it takes priority over the standard +``salt/modules/test.py`` for all calls dispatched to dummy resources. + +Unlike proxy Pattern B modules that must handle *two* contexts at call time +(resource vs. managing minion), this module is **only ever invoked for +resource jobs**: the managing minion's own jobs continue to use the standard +execution modules loaded in the regular ``self.functions`` loader. +""" + +import logging + +log = logging.getLogger(__name__) + +__virtualname__ = "test" + + +def __virtual__(): + """ + Load only when this loader is scoped to the ``dummy`` resource type. + """ + if __opts__.get("resource_type") == "dummy": + return __virtualname__ + return ( + False, + "dummyresource_test: only loads in a dummy-resource-type loader.", + ) + + +def ping(): + """ + Return ``True`` if the targeted dummy resource is responsive. + + Delegates to :func:`salt.resource.dummy.ping` via ``__resource_funcs__`` + so the result reflects the actual state of the resource rather than the + managing minion. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@dummy:dummy-01' test.ping + """ + return __resource_funcs__["dummy.ping"]() # pylint: disable=undefined-variable diff --git a/salt/modules/saltutil.py b/salt/modules/saltutil.py index 486c21d02b65..eaccc9db230e 100644 --- a/salt/modules/saltutil.py +++ b/salt/modules/saltutil.py @@ -400,6 +400,69 @@ def refresh_grains(**kwargs): return True +def refresh_resources(): + """ + Signal the minion to re-discover its managed resources from current pillar + data and re-register them with the master. + + This fires a ``resource_refresh`` event on the minion bus. The minion + handles the event by calling ``_discover_resources()`` (using the current + ``opts["pillar"]``) and then re-registering the result with the master's + ``minion_resources`` cache. + + CLI Example: + + .. code-block:: bash + + salt '*' saltutil.refresh_resources + """ + try: + return __salt__["event.fire"]({}, "resource_refresh") + except KeyError: + return False + + +def sync_resources( + saltenv=None, refresh=True, extmod_whitelist=None, extmod_blacklist=None +): + """ + Sync custom resource-type modules from ``salt://_resources`` to the minion + and signal the minion to re-discover its managed resources from pillar data + and re-register them with the master. + + saltenv + The fileserver environment from which to sync. To sync from more than + one environment, pass a comma-separated list. + + If not passed, then all environments configured in the :ref:`top files + ` will be checked for resource modules to sync. If no top + files are found, then the ``base`` environment will be synced. + + refresh : True + If ``True``, signal the minion to re-discover its managed resources + and re-register them with the master. This refresh will be performed + even if no new resource modules are synced. Set to ``False`` to + prevent this refresh. + + extmod_whitelist : None + comma-separated list of modules to sync + + extmod_blacklist : None + comma-separated list of modules to blacklist + + CLI Example: + + .. code-block:: bash + + salt '*' saltutil.sync_resources + salt '*' saltutil.sync_resources saltenv=base,dev + """ + ret = _sync("resources", saltenv, extmod_whitelist, extmod_blacklist) + if refresh: + refresh_resources() + return ret + + def sync_grains( saltenv=None, refresh=True, @@ -1208,6 +1271,9 @@ def sync_all( saltenv, False, extmod_whitelist, extmod_blacklist ) ret["matchers"] = sync_matchers(saltenv, False, extmod_whitelist, extmod_blacklist) + ret["resources"] = sync_resources( + saltenv, False, extmod_whitelist, extmod_blacklist + ) if __opts__["file_client"] == "local": ret["pillar"] = sync_pillar(saltenv, False, extmod_whitelist, extmod_blacklist) ret["wrapper"] = sync_wrapper( diff --git a/salt/modules/sshresource_cmd.py b/salt/modules/sshresource_cmd.py new file mode 100644 index 000000000000..eed91d20aa9e --- /dev/null +++ b/salt/modules/sshresource_cmd.py @@ -0,0 +1,132 @@ +""" +Execution module override for the ``ssh`` resource type. + +This module is loaded into the per-type execution-module loader whenever the +``resource_type`` in opts is ``"ssh"``. It shadows the standard +``salt.modules.cmdmod`` and ``salt.modules.test`` functions for jobs that +are dispatched to SSH resources, delegating the actual work to +:mod:`salt.resource.ssh` via ``__resource_funcs__``. + +Because this loader is **only ever used for resource jobs**, there is no need +for the call-time proxy-style guard (``if salt.utils.platform.is_proxy()``). +The managing minion's own jobs continue to use the standard execution modules +loaded in the regular ``self.functions`` loader. + +Usage +----- +Any execution module function that should behave differently when targeting +an SSH resource can be implemented here. Functions not defined in this +module fall through to the standard execution modules in the resource loader. + +Example +------- + +.. code-block:: bash + + # Ping an SSH resource + salt -C 'T@ssh:web-01' test.ping + + # Run a shell command on an SSH resource + salt -C 'T@ssh:web-01' cmd.run 'uptime' + salt -C 'T@ssh' cmd.run 'df -h' +""" + +import logging + +# __resource_funcs__ is injected by the per-type loader at runtime. +# pylint: disable=undefined-variable + +log = logging.getLogger(__name__) + +__virtualname__ = "cmd" + + +def __virtual__(): + """ + Load only when this execution-module loader is scoped to the ``ssh`` + resource type. + """ + if __opts__.get("resource_type") == "ssh": # pylint: disable=undefined-variable + return __virtualname__ + return False, "sshresource_cmd: only loads in an ssh-resource-type loader." + + +# --------------------------------------------------------------------------- +# cmd.* surface +# --------------------------------------------------------------------------- + + +def run( + cmd, + timeout=None, + **kwargs, +): + """ + Execute a shell command on the targeted SSH resource and return its + standard output. + + This is the SSH-resource equivalent of :func:`salt.modules.cmdmod.run`. + The command is executed directly on the remote host via the SSH Shell + transport — no Salt thin deployment required. + + :param str cmd: The shell command to run on the remote host. + :param int timeout: Optional SSH connection timeout in seconds for this + call. Overrides the per-resource ``timeout`` configured in Pillar. + :rtype: str — stdout from the remote command + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:web-01' cmd.run 'uptime' + salt -C 'T@ssh' cmd.run 'df -h' timeout=60 + """ + result = __resource_funcs__["ssh.cmd_run"]( + cmd, timeout=timeout + ) # pylint: disable=undefined-variable + return result.get("stdout", "") + + +def run_all(cmd, timeout=None, **kwargs): + """ + Execute a shell command on the targeted SSH resource and return a dict + containing ``stdout``, ``stderr``, and ``retcode``. + + This mirrors :func:`salt.modules.cmdmod.run_all` for SSH resources. + + :param str cmd: The shell command to run on the remote host. + :param int timeout: Optional SSH connection timeout in seconds for this + call. + :rtype: dict + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:web-01' cmd.run_all 'uptime' + """ + return __resource_funcs__["ssh.cmd_run"]( + cmd, timeout=timeout + ) # pylint: disable=undefined-variable + + +def retcode(cmd, timeout=None, **kwargs): + """ + Execute a shell command on the targeted SSH resource and return only the + exit code. + + :param str cmd: The shell command to run on the remote host. + :param int timeout: Optional SSH connection timeout in seconds for this + call. + :rtype: int + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:web-01' cmd.retcode 'test -f /etc/salt/minion' + """ + result = __resource_funcs__["ssh.cmd_run"]( + cmd, timeout=timeout + ) # pylint: disable=undefined-variable + return result.get("retcode", 1) diff --git a/salt/modules/sshresource_pkg.py b/salt/modules/sshresource_pkg.py new file mode 100644 index 000000000000..3415a11e3052 --- /dev/null +++ b/salt/modules/sshresource_pkg.py @@ -0,0 +1,184 @@ +""" +Execution module override for the ``ssh`` resource type — ``pkg.*`` surface. + +Implements package management against SSH resources by running the +appropriate package-manager commands on the remote host via the SSH Shell +transport. Mirrors the interface of ``salt.modules.aptpkg`` / +``salt.modules.yumpkg`` for the functions most commonly called by state +modules (``pkg.installed``, ``pkg.removed``, etc.). + +The managing minion detects the remote OS family from the resource grains +and dispatches to the correct package-manager command set at call time. +""" + +import logging + +# __resource_funcs__ is injected by the per-type loader at runtime. +# pylint: disable=undefined-variable + +log = logging.getLogger(__name__) + +__virtualname__ = "pkg" + + +def __virtual__(): + if __opts__.get("resource_type") == "ssh": # pylint: disable=undefined-variable + return __virtualname__ + return False, "sshresource_pkg: only loads in an ssh-resource-type loader." + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _run(cmd, timeout=None): + """Run a shell command on the remote resource, return (stdout, retcode).""" + result = __resource_funcs__["ssh.cmd_run"]( + cmd, timeout=timeout + ) # pylint: disable=undefined-variable + return result.get("stdout", ""), result.get("retcode", 1) + + +def _pkg_manager(): + """ + Return the package-manager command appropriate for the remote OS. + + Inspects the ``os_family`` grain so we can support both Debian/Ubuntu + (``apt-get``) and RedHat/CentOS (``yum`` / ``dnf``) targets. + """ + grains = ( + __grains__ if isinstance(__grains__, dict) else __grains__.value() + ) # pylint: disable=undefined-variable + os_family = grains.get("os_family", "").lower() + if os_family in ("debian", "ubuntu"): + return "apt-get" + if os_family in ("redhat", "centos", "fedora", "suse"): + return "yum" + # Fallback: try apt-get then yum + return "apt-get" + + +# --------------------------------------------------------------------------- +# pkg.* surface +# --------------------------------------------------------------------------- + + +def install(name=None, pkgs=None, sources=None, **kwargs): + """ + Install one or more packages on the SSH resource. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:node1' pkg.install curl + salt -C 'T@ssh:node1' pkg.install pkgs='[curl, git]' + """ + pkg_mgr = _pkg_manager() + if pkgs: + names = " ".join(pkgs if isinstance(pkgs, list) else [pkgs]) + elif name: + names = name + else: + return {} + + env = "DEBIAN_FRONTEND=noninteractive " if "apt" in pkg_mgr else "" + cmd = f"{env}{pkg_mgr} install -y {names}" + stdout, retcode = _run(cmd, timeout=kwargs.get("timeout")) + + if retcode != 0: + log.warning("pkg.install failed for %s: %s", names, stdout) + return {"result": False, "comment": stdout} + return {"result": True, "comment": stdout} + + +def remove(name=None, pkgs=None, **kwargs): + """ + Remove one or more packages from the SSH resource. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:node1' pkg.remove curl + """ + pkg_mgr = _pkg_manager() + if pkgs: + names = " ".join(pkgs if isinstance(pkgs, list) else [pkgs]) + elif name: + names = name + else: + return {} + + cmd = f"{pkg_mgr} remove -y {names}" + stdout, retcode = _run(cmd, timeout=kwargs.get("timeout")) + + if retcode != 0: + log.warning("pkg.remove failed for %s: %s", names, stdout) + return {"result": False, "comment": stdout} + return {"result": True, "comment": stdout} + + +def version(*names, **kwargs): + """ + Return the installed version of the given package(s). + + Returns a string for a single package or a dict for multiple packages. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:node1' pkg.version curl + """ + grains = ( + __grains__ if isinstance(__grains__, dict) else __grains__.value() + ) # pylint: disable=undefined-variable + os_family = grains.get("os_family", "").lower() + + versions = {} + for name in names: + if os_family in ("debian", "ubuntu"): + stdout, retcode = _run( + f"dpkg-query -W -f='${{Version}}' {name} 2>/dev/null" + ) + else: + stdout, retcode = _run( + f"rpm -q --queryformat '%{{VERSION}}' {name} 2>/dev/null" + ) + versions[name] = stdout.strip() if retcode == 0 else "" + + if len(names) == 1: + return versions[names[0]] + return versions + + +def list_pkgs(**kwargs): + """ + List all installed packages on the SSH resource. + + Returns a dict of ``{name: version}``. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:node1' pkg.list_pkgs + """ + grains = ( + __grains__ if isinstance(__grains__, dict) else __grains__.value() + ) # pylint: disable=undefined-variable + os_family = grains.get("os_family", "").lower() + + if os_family in ("debian", "ubuntu"): + stdout, _ = _run("dpkg-query -W -f='${Package} ${Version}\\n'") + else: + stdout, _ = _run("rpm -qa --queryformat '%{NAME} %{VERSION}-%{RELEASE}\\n'") + + pkgs = {} + for line in stdout.splitlines(): + parts = line.strip().split(None, 1) + if len(parts) == 2: + pkgs[parts[0]] = parts[1] + return pkgs diff --git a/salt/modules/sshresource_state.py b/salt/modules/sshresource_state.py new file mode 100644 index 000000000000..45254871f24f --- /dev/null +++ b/salt/modules/sshresource_state.py @@ -0,0 +1,507 @@ +""" +State module for the ``ssh`` resource type. + +Implements ``state.highstate``, ``state.sls``, and ``state.apply`` for SSH +resources by replicating the salt-ssh state-execution pipeline on the +managing minion: + +1. **Compile** — ``SSHHighState`` reads state and pillar files from the + master via the minion's ``RemoteClient``. The resource ID is used as + the top-file target, so only states mapped to that ID are compiled. + +2. **Package** — ``prep_trans_tar`` bundles the compiled low state, all + referenced ``salt://`` files, and the rendered pillar into a transport + tar (``salt_state.tgz``). + +3. **Execute** — The tar is SCP'd to the remote host's ``thin_dir`` and + ``state.pkg`` is invoked via the salt-thin bundle, returning structured + JSON results. + +This mirrors what ``salt-ssh state.highstate`` does when invoked from the +master, but runs from the managing minion's process so the salt-ssh +initiator is the minion, not the master. +""" + +import logging +import os +import uuid + +import salt.client.ssh +import salt.client.ssh.shell +import salt.client.ssh.state +import salt.client.ssh.wrapper +import salt.defaults.exitcodes +import salt.fileclient +import salt.utils.hashutils +import salt.utils.network +import salt.utils.state +from salt.client.ssh.wrapper.state import ( + _cleanup_slsmod_low_data, + _merge_extra_filerefs, +) +from salt.resource.ssh import CONTEXT_KEY + +log = logging.getLogger(__name__) +log.info("sshresource_state: module imported, __name__=%s", __name__) + +__virtualname__ = "state" +__func_alias__ = {"apply_": "apply"} + + +def __virtual__(): + if __opts__.get("resource_type") == "ssh": # pylint: disable=undefined-variable + log.info( + "sshresource_state: LOADING for ssh resource type (opts id=%s)", + __opts__.get("id"), + ) # pylint: disable=undefined-variable + return __virtualname__ + return False, "sshresource_state: only loads in an ssh-resource-type loader." + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _resource_id(): + return __resource__["id"] # pylint: disable=undefined-variable + + +def _host_cfg(): + resource_id = _resource_id() + return __context__[CONTEXT_KEY]["hosts"].get( + resource_id, {} + ) # pylint: disable=undefined-variable + + +def _relenv_path(): + """ + Return the path to a pre-built relenv tarball if one exists locally, otherwise + return ``None`` to let ``Single.__init__`` detect the remote arch and download + the correct tarball itself. + + The tarball is generated by ``salt.utils.relenv.gen_relenv``, which normalises + the arch to ``x86_64`` or ``arm64`` before building the cache path, so the + lookup uses those canonical names. + """ + cachedir = __opts__.get("cachedir", "") # pylint: disable=undefined-variable + for arch in ("x86_64", "arm64"): + path = os.path.join(cachedir, "relenv", "linux", arch, "salt-relenv.tar.xz") + if os.path.exists(path): + return path + return None + + +def _target_opts(): + """ + Build a copy of ``__opts__`` suitable for ``SSHHighState`` and ``Single``. + + * Sets ``id`` to the resource ID so the top file matches the right host. + * Injects ``_ssh_version`` and host-key policy from the resource config. + * ``thin_dir`` is populated later as a side-effect of ``Single.__init__``. + """ + resource_id = _resource_id() + cfg = _host_cfg() + opts = dict(__opts__) # pylint: disable=undefined-variable + opts["id"] = resource_id + opts.pop("resource_type", None) + opts["_ssh_version"] = ( + __context__.get(CONTEXT_KEY, {}).get( + "_ssh_version" + ) # pylint: disable=undefined-variable + or salt.client.ssh.ssh_version() + ) + opts["no_host_keys"] = cfg.get("no_host_keys", opts.get("no_host_keys", False)) + opts["ignore_host_keys"] = cfg.get( + "ignore_host_keys", opts.get("ignore_host_keys", False) + ) + if "known_hosts_file" in cfg: + opts["known_hosts_file"] = cfg["known_hosts_file"] + opts["relenv"] = True + return opts + + +def _connection_kwargs(): + """Return SSH connection kwargs for ``Single`` from the resource config.""" + cfg = _host_cfg() + return { + "host": cfg["host"], + "user": cfg.get("user", "root"), + "port": cfg.get("port", 22), + "passwd": cfg.get("passwd"), + "priv": cfg.get("priv"), + "priv_passwd": cfg.get("priv_passwd"), + "timeout": cfg.get("timeout", 60), + "sudo": cfg.get("sudo", False), + "tty": cfg.get("tty", False), + "identities_only": cfg.get("identities_only", False), + "ssh_options": cfg.get("ssh_options"), + "keepalive": cfg.get("keepalive", True), + "keepalive_interval": cfg.get("keepalive_interval", 60), + "keepalive_count_max": cfg.get("keepalive_count_max", 3), + } + + +def _thin_dir(): + """ + Return the remote working directory for the salt-thin bundle. + + Mirrors the logic in ``salt.resource.ssh._thin_dir``: uses the per-host + ``thin_dir`` config key when set, otherwise builds a path under ``/tmp/`` + (always world-writable) to avoid ``/var/tmp/`` which may be root-only. + """ + cfg = _host_cfg() + if "thin_dir" in cfg: + return cfg["thin_dir"] + fqdn_uuid = uuid.uuid3(uuid.NAMESPACE_DNS, salt.utils.network.get_fqhostname()).hex[ + :6 + ] + return "/tmp/.{}_{}_salt".format(cfg.get("user", "root"), fqdn_uuid) + + +def _seed_thin_dir(opts): + """ + Compute ``thin_dir`` and write it into *opts* so that ``SSHHighState`` + and ``prep_trans_tar`` use a consistent, writable path. + """ + thin = _thin_dir() + opts["thin_dir"] = thin + return thin + + +def _get_initial_pillar(opts): + """ + Return the managing minion's rendered pillar for state compilation. + + Passing a non-None, non-empty value as ``initial_pillar`` to ``SSHHighState`` + causes ``State.__init__`` to skip ``_gather_pillar()`` (which would otherwise + try to compile pillar for the resource ID as a regular minion). We use the + managing minion's own pillar — it contains the resource configuration anyway + and avoids a spurious pillar-compile for an unknown minion ID. + + Returns ``None`` only as a last resort so the caller can decide how to handle + missing pillar. + """ + raw = __opts__.get("pillar") # pylint: disable=undefined-variable + if raw is None: + return None + try: + val = raw.value() + except AttributeError: + val = raw + # An empty dict is falsy in state.py's `if initial_pillar` check, which + # would re-trigger _gather_pillar. Return None explicitly so callers know + # there is no cached pillar rather than silently skipping the right path. + return val if isinstance(val, dict) and val else None + + +def _file_client(): + """ + Return a file client suitable for ``SSHHighState`` state compilation. + + Uses the master opts cached during ``ssh.init()`` to create an + ``FSClient`` — a local-filesystem file client identical to the one the + salt-ssh master uses. This avoids creating a new authenticated network + channel from inside a minion job thread (which has tornado IO-loop + complications). + + Falls back to a ``RemoteClient`` if no cached master opts are available + (e.g. on first run before a full restart). + """ + master_opts = __context__.get(CONTEXT_KEY, {}).get( + "master_opts" + ) # pylint: disable=undefined-variable + log.debug( + "sshresource_state._file_client: master_opts cached=%s, file_roots=%s", + master_opts is not None, + (master_opts or {}).get("file_roots"), + ) + if master_opts: + mo = dict(master_opts) + mo.setdefault( + "cachedir", __opts__.get("cachedir", "") + ) # pylint: disable=undefined-variable + return salt.fileclient.FSClient(mo) + log.warning( + "sshresource_state: no cached master opts in context, " + "falling back to RemoteClient for file access" + ) + return salt.fileclient.get_file_client( + __opts__ + ) # pylint: disable=undefined-variable + + +# --------------------------------------------------------------------------- +# Public state functions +# --------------------------------------------------------------------------- + + +def highstate(test=None, **kwargs): + """ + Apply the highstate to the targeted SSH resource. + + Compiles the highstate on the managing minion using the resource ID as the + top-file target, packages all state files into a transport tar, SCPs the + tar to the remote host, and runs ``state.pkg`` via the salt-thin bundle. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:node1' state.highstate + salt -C 'T@ssh:node1' state.highstate test=True + """ + opts = _target_opts() + _seed_thin_dir(opts) + + initial_pillar = _get_initial_pillar(opts) + pillar_override = kwargs.get("pillar") + extra_filerefs = kwargs.get("extra_filerefs", "") + + opts = salt.utils.state.get_sls_opts(opts, **kwargs) + if test is None: + test = opts.get("test", False) + opts["test"] = test + + file_client = _file_client() + log.debug( + "sshresource_state.highstate: file_client=%s initial_pillar_type=%s", + type(file_client).__name__, + type(initial_pillar).__name__, + ) + log.debug( + "sshresource_state.highstate: file_client.envs()=%s", + file_client.envs(), + ) + # SSHHighState.__exit__ calls file_client.destroy(), so no separate finally needed. + with salt.client.ssh.state.SSHHighState( + opts, + pillar_override, + __salt__, # pylint: disable=undefined-variable + file_client, + initial_pillar=initial_pillar, + ) as st_: + try: + pillar = st_.opts["pillar"].value() + except AttributeError: + pillar = st_.opts["pillar"] + + st_.push_active() + chunks_or_errors = st_.compile_low_chunks() + log.debug( + "sshresource_state.highstate: compile_low_chunks returned %s", + chunks_or_errors, + ) + + for chunk in chunks_or_errors: + if not isinstance(chunk, dict): + return chunks_or_errors + + if not chunks_or_errors: + # Top file has no match for this resource ID — no SSH round-trip needed. + # Return a state dict using the same key format salt uses for a regular + # minion's "No Top file" entry so the merged output is consistent. + rid = _resource_id() + return { + "no_|-states_|-states_|-None": { + "result": False, + "comment": ( + f"No Top file or master_tops data matches found for" + f" resource '{rid}'." + ), + "name": "states", + "changes": {}, + "__run_num__": 0, + } + } + + file_refs = salt.client.ssh.state.lowstate_file_refs( + chunks_or_errors, + _merge_extra_filerefs( + extra_filerefs, + opts.get("extra_filerefs", ""), + ), + ) + _cleanup_slsmod_low_data(chunks_or_errors) + trans_tar = salt.client.ssh.state.prep_trans_tar( + file_client, + chunks_or_errors, + file_refs, + pillar, + _resource_id(), + ) + + return _exec_state_pkg(opts, trans_tar, test) + + +def sls(mods, saltenv="base", test=None, **kwargs): + """ + Apply one or more state SLS files to the targeted SSH resource. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:node1' state.sls node1 + salt -C 'T@ssh:node1' state.sls node1,common test=True + """ + opts = _target_opts() + _seed_thin_dir(opts) + + initial_pillar = _get_initial_pillar(opts) + pillar_override = kwargs.get("pillar") + extra_filerefs = kwargs.get("extra_filerefs", "") + + opts = salt.utils.state.get_sls_opts(opts, **kwargs) + if test is None: + test = opts.get("test", False) + opts["test"] = test + + if isinstance(mods, str): + mods = [m.strip() for m in mods.split(",") if m.strip()] + + file_client = _file_client() + with salt.client.ssh.state.SSHHighState( + opts, + pillar_override, + __salt__, # pylint: disable=undefined-variable + file_client, + initial_pillar=initial_pillar, + ) as st_: + try: + pillar = st_.opts["pillar"].value() + except AttributeError: + pillar = st_.opts["pillar"] + + st_.push_active() + high_data, errors = st_.render_highstate({saltenv: mods}) + if kwargs.get("exclude"): + exclude = kwargs["exclude"] + if isinstance(exclude, str): + exclude = exclude.split(",") + high_data.setdefault("__exclude__", []).extend(exclude) + + high_data, ext_errors = st_.state.reconcile_extend(high_data) + errors += ext_errors + errors += st_.state.verify_high(high_data) + if errors: + return errors + + high_data, req_in_errors = st_.state.requisite_in(high_data) + errors += req_in_errors + high_data = st_.state.apply_exclude(high_data) + if errors: + return errors + + chunks, errors = st_.state.compile_high_data(high_data) + if errors: + return errors + + file_refs = salt.client.ssh.state.lowstate_file_refs( + chunks, + _merge_extra_filerefs( + extra_filerefs, + opts.get("extra_filerefs", ""), + ), + ) + _cleanup_slsmod_low_data(chunks) + trans_tar = salt.client.ssh.state.prep_trans_tar( + file_client, + chunks, + file_refs, + pillar, + _resource_id(), + ) + + return _exec_state_pkg(opts, trans_tar, test) + + +def apply_(mods=None, **kwargs): + """ + Apply states to the SSH resource — ``state.highstate`` if no mods are + given, ``state.sls`` otherwise. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:node1' state.apply + salt -C 'T@ssh:node1' state.apply node1 + """ + if mods: + return sls(mods, **kwargs) + return highstate(**kwargs) + + +# --------------------------------------------------------------------------- +# Shared execution helper +# --------------------------------------------------------------------------- + + +def _exec_state_pkg(opts, trans_tar, test): + """ + SCP ``trans_tar`` to the remote host and run ``state.pkg`` via the + salt-thin bundle. Cleans up the local tar file regardless of outcome. + + Returns the state result dict directly (what the minion dispatcher + expects) rather than the full ``{"local": {"return": ...}}`` envelope. + + A fresh file client is created here so that ``Single.cmd_block()`` can call + ``mod_data(fsclient)`` to scan for extension modules. (``cmd_block`` was + updated in the relenv improvements merge to regenerate ext-mods before every + remote execution.) + """ + fsclient = _file_client() + try: + trans_tar_sum = salt.utils.hashutils.get_hash(trans_tar, opts["hash_type"]) + single = salt.client.ssh.Single( + opts, + "state.pkg", # placeholder; argv is updated after __init__ rewrites thin_dir + _resource_id(), + thin=_relenv_path(), + thin_dir=opts["thin_dir"], + fsclient=fsclient, + **_connection_kwargs(), + ) + # Single.__init__ may rename thin_dir (e.g. _salt → _salt_relenv) and + # writes the result back into opts["thin_dir"]. Build the real argv only now. + cmd = "state.pkg {thin_dir}/salt_state.tgz test={test} pkg_sum={pkg_sum} hash_type={hash_type}".format( + thin_dir=opts["thin_dir"], + test=test, + pkg_sum=trans_tar_sum, + hash_type=opts["hash_type"], + ) + single.argv = [cmd] + single.shell.send(trans_tar, "{}/salt_state.tgz".format(opts["thin_dir"])) + stdout, stderr, retcode = single.cmd_block() + finally: + try: + os.remove(trans_tar) + except OSError: + pass + + # parse_ret raises SSHCommandExecutionError on any non-zero retcode, even + # when the remote ran states and produced a valid result dict (e.g. some + # states failed → retcode 2). Catch that case and surface the result dict + # normally so operators see the full state tree rather than raw JSON. + try: + envelope = salt.client.ssh.wrapper.parse_ret(stdout, stderr, retcode) + except salt.client.ssh.wrapper.SSHCommandExecutionError as exc: + local = (exc.parsed or {}).get("local", {}) + if isinstance(local.get("return"), dict): + ret = local["return"] + __context__["retcode"] = local.get( # pylint: disable=undefined-variable + "retcode", salt.defaults.exitcodes.EX_STATE_FAILURE + ) + return ret + raise + + if isinstance(envelope, dict) and "return" in envelope: + ret = envelope["return"] + remote_retcode = envelope.get("retcode", 0) + if remote_retcode: + __context__["retcode"] = ( # pylint: disable=undefined-variable + remote_retcode + ) + return ret + return envelope diff --git a/salt/modules/sshresource_test.py b/salt/modules/sshresource_test.py new file mode 100644 index 000000000000..bd1c3d43a363 --- /dev/null +++ b/salt/modules/sshresource_test.py @@ -0,0 +1,45 @@ +""" +Provide the ``test`` execution module for the ``ssh`` resource type. + +This is the SSH-resource analogue of ``salt/modules/dummyresource_test.py``. +It is loaded into the per-type execution-module loader when +``opts["resource_type"]`` is ``"ssh"``, causing it to shadow the standard +``salt.modules.test`` for all jobs dispatched to SSH resources. + +The managing minion's own jobs continue to use the standard ``test`` module +loaded in the regular ``self.functions`` loader — this module is never +invoked for managing-minion jobs. +""" + +import logging + +log = logging.getLogger(__name__) + +__virtualname__ = "test" + + +def __virtual__(): + """ + Load only when this loader is scoped to the ``ssh`` resource type. + """ + if __opts__.get("resource_type") == "ssh": # pylint: disable=undefined-variable + return __virtualname__ + return False, "sshresource_test: only loads in an ssh-resource-type loader." + + +def ping(): + """ + Return ``True`` if the targeted SSH resource is reachable. + + Delegates to :func:`salt.resource.ssh.ping` via ``__resource_funcs__`` + so the result reflects actual SSH connectivity to the remote host rather + than the liveness of the managing minion. + + CLI Example: + + .. code-block:: bash + + salt -C 'T@ssh:web-01' test.ping + salt -C 'T@ssh' test.ping + """ + return __resource_funcs__["ssh.ping"]() # pylint: disable=undefined-variable diff --git a/salt/modules/state.py b/salt/modules/state.py index 7b4437a28aad..98a80035f328 100644 --- a/salt/modules/state.py +++ b/salt/modules/state.py @@ -73,6 +73,12 @@ def __virtual__(): """ Set the virtualname """ + # Resource-type loaders (resource_modules) use per-type override modules + # such as sshresource_state.py. Returning False here yields the "state" + # virtualname slot to those overrides so they are dispatched correctly + # when jobs are targeted at resources. + if __opts__.get("resource_type"): # pylint: disable=undefined-variable + return False, "state: not loaded in resource-type loaders" # Update global namespace with functions that are cloned in this module global _orchestrate _orchestrate = salt.utils.functools.namespaced_function(_orchestrate, globals()) diff --git a/salt/modules/test.py b/salt/modules/test.py index 3cd9d7d5ea43..71e5bde8e018 100644 --- a/salt/modules/test.py +++ b/salt/modules/test.py @@ -33,6 +33,13 @@ log = logging.getLogger(__name__) +def __virtual__(): + # Yield to resource-type override modules (e.g. sshresource_test.py). + if __opts__.get("resource_type"): # pylint: disable=undefined-variable + return False, "test: not loaded in resource-type loaders" + return True + + @depends("non_existantmodulename") def missing_func(): return "foo" diff --git a/salt/resource/__init__.py b/salt/resource/__init__.py new file mode 100644 index 000000000000..57971937bd90 --- /dev/null +++ b/salt/resource/__init__.py @@ -0,0 +1,3 @@ +""" +salt.resource package +""" diff --git a/salt/resource/dummy.py b/salt/resource/dummy.py new file mode 100644 index 000000000000..4cf56361343f --- /dev/null +++ b/salt/resource/dummy.py @@ -0,0 +1,370 @@ +""" +Dummy resource module for testing the Salt resource subsystem. + +This module implements the ``dummy`` resource type. It is the resource +analogue of ``salt.proxy.dummy`` — a self-contained, file-backed +implementation that exercises the full resource lifecycle without requiring +any real managed devices. + +Unlike a proxy module, a resource module is loaded **once per resource type +per minion**. A single instance of this module handles all ``dummy`` +resources managed by the minion. The current resource context is conveyed +via the ``__resource__`` dunder rather than as a function parameter, keeping +the interface consistent with all other Salt module systems. + +Configuration (via Pillar):: + + resources: + dummy: + resource_ids: + - dummy-01 + - dummy-02 + - dummy-03 +""" + +import copy +import logging +import os +import pprint +from contextlib import contextmanager + +import salt.utils.files +import salt.utils.msgpack +import salt.utils.resources + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + Always available — no external dependencies required. + """ + log.debug("dummy resource __virtual__() called...") + return True + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _resource_id(): + """ + Return the ID of the resource currently being operated on. + + The execution layer sets ``__resource__`` before every per-resource + dispatch. All per-resource functions call this rather than accepting + an ID parameter. + """ + return __resource__["id"] # pylint: disable=undefined-variable + + +def _initial_state(resource_id): + return { + "id": resource_id, + "services": {"apache": "running", "ntp": "running", "samba": "stopped"}, + "packages": { + "coreutils": "1.0", + "apache": "2.4", + "tinc": "1.4", + "redbull": "999.99", + }, + } + + +def _save_state(opts, resource_id, details): + cachefile = os.path.join(opts["cachedir"], f"dummy-resource-{resource_id}.cache") + with salt.utils.files.fopen(cachefile, "wb") as pck: + pck.write(salt.utils.msgpack.packb(details, use_bin_type=True)) + log.warning( + "Dummy Resource Saved State(%s):\n%s", cachefile, pprint.pformat(details) + ) + + +def _load_state(opts, resource_id): + cachefile = os.path.join(opts["cachedir"], f"dummy-resource-{resource_id}.cache") + try: + with salt.utils.files.fopen(cachefile, "rb") as pck: + state = salt.utils.msgpack.unpackb(pck.read(), raw=False) + except FileNotFoundError: + state = _initial_state(resource_id) + _save_state(opts, resource_id, state) + except Exception as exc: # pylint: disable=broad-except + log.exception("Failed to load state: %s", exc, exc_info=True) + state = _initial_state(resource_id) + _save_state(opts, resource_id, state) + log.warning( + "Dummy Resource Loaded State(%s):\n%s", cachefile, pprint.pformat(state) + ) + return state + + +@contextmanager +def _loaded_state(opts, resource_id): + state = _load_state(opts, resource_id) + original = copy.deepcopy(state) + try: + yield state + finally: + if state != original: + _save_state(opts, resource_id, state) + + +# --------------------------------------------------------------------------- +# Required resource interface +# --------------------------------------------------------------------------- + + +def init(opts): + """ + Initialize the dummy resource type for this minion. + + Called once when the resource type is loaded, before any per-resource + operations are performed. Reads the resource type configuration from the + ``dummy`` entry under the pillar subtree selected by ``resource_pillar_key`` + (see :func:`salt.utils.resources.pillar_resources_tree`) and sets up shared type-level + state in ``__context__["dummy_resource"]``. + + :param dict opts: The Salt opts dict. + """ + resource_ids = ( + salt.utils.resources.pillar_resources_tree(opts) + .get("dummy", {}) + .get("resource_ids", []) + ) + __context__["dummy_resource"] = { + "initialized": True, + "resource_ids": resource_ids, + } + log.debug("dummy resource init() called, managing: %s", resource_ids) + + +def initialized(): + """ + Return ``True`` if ``init()`` has been called successfully for this + resource type. + + Checked by the loader before dispatching per-resource operations, in the + same way ``salt.proxy.dummy.initialized()`` is used today. + + :rtype: bool + """ + return __context__.get("dummy_resource", {}).get("initialized", False) + + +def discover(opts): + """ + Return the list of resource IDs of type ``dummy`` that this minion + manages. + + Called by ``saltutil.refresh_resources`` to populate the master's + Resource Registry. For the dummy module the list of IDs is read from + ``resource_ids`` under the ``dummy`` type in the configured resource pillar + subtree. + + Returns a list of bare resource IDs (not full SRNs) — e.g. + ``["dummy-01", "dummy-02"]``. + + :param dict opts: The Salt opts dict. + :rtype: list[str] + """ + resource_ids = ( + salt.utils.resources.pillar_resources_tree(opts) + .get("dummy", {}) + .get("resource_ids", []) + ) + log.debug("dummy resource discover() returning: %s", resource_ids) + return resource_ids + + +def grains(): + """ + Return the grains dict for the current resource. + + The current resource context is available via ``__resource__``. Each + dummy resource reports a small set of static grains for use in targeting + and state execution. + + :rtype: dict + """ + resource_id = _resource_id() + with _loaded_state( + __opts__, resource_id + ) as state: # pylint: disable=undefined-variable + state["grains_cache"] = { # pylint: disable=unsupported-assignment-operation + "dummy_grain_1": "one", + "dummy_grain_2": "two", + "dummy_grain_3": "three", + "resource_id": resource_id, + } + return state["grains_cache"] + + +def grains_refresh(): + """ + Invalidate the cached grains for the current resource and return a + freshly generated grains dict. + + :rtype: dict + """ + resource_id = _resource_id() + with _loaded_state( + __opts__, resource_id + ) as state: # pylint: disable=undefined-variable + state.pop("grains_cache", None) + return grains() + + +def ping(): + """ + Return ``True`` if the current resource is reachable and responsive. + + For the dummy module this always returns ``True``; no real connection + is made. + """ + resource_id = _resource_id() + log.debug("dummy resource ping() called for %s", resource_id) + return True + + +def shutdown(opts): + """ + Tear down the dummy resource type. + + Called when the minion shuts down or the resource type is unloaded. + Cleans up shared type-level state from ``__context__``. + + :param dict opts: The Salt opts dict. + """ + log.debug("dummy resource shutdown() called...") + __context__.pop("dummy_resource", None) + + +# --------------------------------------------------------------------------- +# Per-resource operations (mirrors salt.proxy.dummy for testing parity) +# --------------------------------------------------------------------------- + + +def service_start(name): + """ + Start a "service" on the current dummy resource. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + state["services"][name] = "running" + return "running" + + +def service_stop(name): + """ + Stop a "service" on the current dummy resource. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + state["services"][name] = "stopped" + return "stopped" + + +def service_restart(name): + """ + Restart a "service" on the current dummy resource. + """ + return True + + +def service_list(): + """ + List "services" on the current dummy resource. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + return list(state["services"]) + + +def service_status(name): + """ + Return the status of a service on the current dummy resource. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + if state["services"][name] == "running": + return {"comment": "running"} + return {"comment": "stopped"} + + +def package_list(): + """ + List "packages" installed on the current dummy resource. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + return state["packages"] + + +def package_install(name, **kwargs): + """ + Install a "package" on the current dummy resource. + """ + version = kwargs.get("version", "1.0") + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + state["packages"][name] = version + return {name: version} + + +def package_remove(name): + """ + Remove a "package" from the current dummy resource. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + state["packages"].pop(name) + return state["packages"] + + +def package_status(name): + """ + Return the installation status of a package on the current dummy resource. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + if name in state["packages"]: + return {name: state["packages"][name]} + + +def upgrade(): + """ + "Upgrade" all packages on the current dummy resource. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + for pkg in state["packages"]: + state["packages"][pkg] = str(float(state["packages"][pkg]) + 1.0) + return state["packages"] + + +def uptodate(): + """ + Report whether packages on the current dummy resource are up to date. + """ + with _loaded_state( + __opts__, _resource_id() + ) as state: # pylint: disable=undefined-variable + return state["packages"] + + +def test_from_state(): + """ + Test function so we have something to call from a state. + """ + log.debug("test_from_state called for resource %s", _resource_id()) + return "testvalue" diff --git a/salt/resource/ssh.py b/salt/resource/ssh.py new file mode 100644 index 000000000000..6d48681a5548 --- /dev/null +++ b/salt/resource/ssh.py @@ -0,0 +1,521 @@ +""" +SSH resource module — exposes remote Linux/Unix machines as Salt Resources +using the salt-ssh Shell transport layer. + +Each ``ssh`` resource maps to one remote host reachable via SSH. Because +resources share a single loader per type, a minion managing 500 SSH hosts +uses one loader rather than 500 proxy processes, each with its own key pair. + +This module uses :class:`salt.client.ssh.shell.Shell` for raw command +execution (``cmd_run``, ``ping``) and :class:`salt.client.ssh.Single` with +the salt-thin bundle for grain collection (``grains.items``), giving the same +complete, accurate grain set that ``salt-ssh`` provides. + +Configuration (via Pillar; top-level key defaults to ``resources``, overridable +with minion option ``resource_pillar_key``):: + + resources: + ssh: + hosts: + web-01: + host: 192.168.1.10 + user: root + priv: /etc/salt/ssh_keys/web-01 + web-02: + host: 192.168.1.11 + user: admin + passwd: secretpassword + no_host_keys: true + +Per-host connection parameters: + +``host`` + Hostname or IP address of the remote machine (required). +``user`` + SSH login user (default: ``root``). +``port`` + SSH port (default: ``22``). +``priv`` + Path to the SSH private key file. Mutually exclusive with ``passwd`` + but both may be specified; when ``priv`` is set Salt uses key-based + option strings even if ``passwd`` is also set. +``passwd`` + SSH password. Prefer key-based authentication for production. +``priv_passwd`` + Passphrase protecting the private key. +``sudo`` + Run commands as root via sudo (default: ``False``). +``timeout`` + SSH connection timeout in seconds (default: ``30``). +``identities_only`` + Pass ``-o IdentitiesOnly=yes`` to prevent the SSH agent from offering + unrelated keys (default: ``False``). +``no_host_keys`` + Disable host key checking entirely — sets both + ``StrictHostKeyChecking=no`` and ``UserKnownHostsFile=/dev/null`` + (default: ``False``). +``ignore_host_keys`` + Pass ``-o StrictHostKeyChecking=no`` without discarding the + known-hosts database (default: ``False``). +``known_hosts_file`` + Path to a custom ``known_hosts`` file for this host. +``ssh_options`` + List of additional ``-o Key=Value`` options passed verbatim to the + ``ssh`` binary. +``keepalive`` + Enable TCP keepalives (default: ``True``). +``keepalive_interval`` + ``ServerAliveInterval`` in seconds (default: from Salt opts or ``60``). +``keepalive_count_max`` + ``ServerAliveCountMax`` (default: from Salt opts or ``3``). +""" + +import logging +import os +import uuid + +import salt.client.ssh +import salt.client.ssh.shell +import salt.config +import salt.fileclient +import salt.utils.json +import salt.utils.network +import salt.utils.path +import salt.utils.resources + +log = logging.getLogger(__name__) + +CONTEXT_KEY = "ssh_resource" + + +# --------------------------------------------------------------------------- +# Module availability +# --------------------------------------------------------------------------- + + +def __virtual__(): + """ + Only load when the ``ssh`` binary is present on the minion's PATH. + """ + if not salt.utils.path.which("ssh"): + return False, "ssh binary not found on PATH" + return True + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _resource_id(): + """Return the ID of the resource currently being operated on.""" + return __resource__["id"] # pylint: disable=undefined-variable + + +def _host_cfg(resource_id): + """Return the Pillar-sourced connection config dict for *resource_id*.""" + return __context__[CONTEXT_KEY]["hosts"].get( + resource_id, {} + ) # pylint: disable=undefined-variable + + +def _shell_opts(cfg): + """ + Build a merged opts dict for :class:`~salt.client.ssh.shell.Shell`. + + ``Shell`` reads ``ignore_host_keys``, ``no_host_keys``, + ``known_hosts_file``, and ``_ssh_version`` out of its opts dict rather + than out of constructor kwargs. This helper layers per-host overrides on + top of ``__opts__`` so each Shell instance honours its resource's config. + """ + merged = dict(__opts__) # pylint: disable=undefined-variable + for key in ("ignore_host_keys", "no_host_keys", "known_hosts_file"): + if key in cfg: + merged[key] = cfg[key] + # Ensure _ssh_version is always present. _passwd_opts() accesses it via + # [] without a default and would raise KeyError without this guard. + if "_ssh_version" not in merged: + cached = __context__.get(CONTEXT_KEY, {}).get( + "_ssh_version" + ) # pylint: disable=undefined-variable + merged["_ssh_version"] = ( + cached if cached is not None else salt.client.ssh.ssh_version() + ) + return merged + + +def _make_shell(resource_id, cfg_override=None): + """ + Return a :class:`~salt.client.ssh.shell.Shell` instance for *resource_id*. + + :param str resource_id: The bare resource ID. + :param dict cfg_override: Optional dict of per-call overrides (e.g. + ``{"timeout": 5}``). Values are layered on top of the stored host + config; the stored config is not mutated. + """ + cfg = _host_cfg(resource_id) + if cfg_override: + cfg = dict(cfg) + cfg.update(cfg_override) + + return salt.client.ssh.shell.Shell( + _shell_opts(cfg), + host=cfg["host"], + user=cfg.get("user", "root"), + port=cfg.get("port", 22), + passwd=cfg.get("passwd"), + priv=cfg.get("priv"), + priv_passwd=cfg.get("priv_passwd"), + timeout=cfg.get("timeout", 30), + sudo=cfg.get("sudo", False), + tty=cfg.get("tty", False), + identities_only=cfg.get("identities_only", False), + ssh_options=cfg.get("ssh_options"), + keepalive=cfg.get("keepalive", True), + keepalive_interval=cfg.get("keepalive_interval", 60), + keepalive_count_max=cfg.get("keepalive_count_max", 3), + ) + + +def _thin_dir(cfg): + """ + Return the remote working directory for the salt-thin bundle. + + Uses the per-host ``thin_dir`` config key when provided. Otherwise + computes a path under ``/tmp/`` (always world-writable) using the same + ``.__salt`` naming convention as Salt's DEFAULT_THIN_DIR, + but avoiding ``/var/tmp/`` which may be root-only on some systems. + """ + if "thin_dir" in cfg: + return cfg["thin_dir"] + fqdn_uuid = uuid.uuid3(uuid.NAMESPACE_DNS, salt.utils.network.get_fqhostname()).hex[ + :6 + ] + return "/tmp/.{}_{}_salt".format(cfg.get("user", "root"), fqdn_uuid) + + +def _relenv_path(): + """ + Return the path to a pre-built relenv tarball if one exists locally, otherwise + ``None`` so ``Single.__init__`` can detect the remote arch and fetch the right + tarball (same strategy as :func:`salt.modules.sshresource_state._relenv_path`). + + Pre-resolving an existing local path avoids an extra SSH round-trip during + ``Single`` construction when ``Single`` was instantiated inside a minion job + worker (where ``detect_os_arch()`` hung or added latency). + """ + cachedir = __opts__.get("cachedir", "") # pylint: disable=undefined-variable + for arch in ("x86_64", "arm64"): + path = os.path.join(cachedir, "relenv", "linux", arch, "salt-relenv.tar.xz") + if os.path.exists(path): + return path + return None + + +def _file_client(): + """ + Return a file client for ``Single.cmd_block()`` to use when regenerating + extension modules via ``mod_data(fsclient)``. + + Uses the master opts cached during :func:`init` to build an ``FSClient`` + (local-filesystem, no network channel) — the same approach used by + ``sshresource_state._file_client()``. Falls back to a ``RemoteClient`` + when no cached master opts are available. + """ + master_opts = __context__.get( + CONTEXT_KEY, {} + ).get( # pylint: disable=undefined-variable + "master_opts" + ) + if master_opts: + mo = dict(master_opts) + mo.setdefault( + "cachedir", __opts__.get("cachedir", "") + ) # pylint: disable=undefined-variable + return salt.fileclient.FSClient(mo) + log.warning( + "ssh resource: no cached master opts in context, " + "falling back to RemoteClient for fsclient" + ) + return salt.fileclient.get_file_client( + __opts__ + ) # pylint: disable=undefined-variable + + +def _make_single(resource_id, argv): + """ + Return a :class:`~salt.client.ssh.Single` instance for *resource_id* + configured to run *argv* via the salt-thin bundle. + + We call :meth:`~salt.client.ssh.Single.cmd_block` directly rather than + :meth:`~salt.client.ssh.Single.run` to stay on the thin-bundle code path + and avoid the wrapper-function path that requires a master file client. + """ + cfg = _host_cfg(resource_id) + ctx = __context__.get(CONTEXT_KEY, {}) # pylint: disable=undefined-variable + + single_opts = dict(__opts__) # pylint: disable=undefined-variable + single_opts["no_host_keys"] = cfg.get( + "no_host_keys", single_opts.get("no_host_keys", False) + ) + single_opts["ignore_host_keys"] = cfg.get( + "ignore_host_keys", single_opts.get("ignore_host_keys", False) + ) + if "known_hosts_file" in cfg: + single_opts["known_hosts_file"] = cfg["known_hosts_file"] + single_opts["_ssh_version"] = ( + ctx.get("_ssh_version") or salt.client.ssh.ssh_version() + ) + + single_opts["relenv"] = True + return salt.client.ssh.Single( + single_opts, + argv, + resource_id, + thin=_relenv_path(), + fsclient=_file_client(), + host=cfg["host"], + user=cfg.get("user", "root"), + port=cfg.get("port", 22), + passwd=cfg.get("passwd"), + priv=cfg.get("priv"), + priv_passwd=cfg.get("priv_passwd"), + timeout=cfg.get("timeout", 30), + sudo=cfg.get("sudo", False), + tty=cfg.get("tty", False), + identities_only=cfg.get("identities_only", False), + ssh_options=cfg.get("ssh_options"), + keepalive=cfg.get("keepalive", True), + keepalive_interval=cfg.get("keepalive_interval", 60), + keepalive_count_max=cfg.get("keepalive_count_max", 3), + thin_dir=_thin_dir(cfg), + ) + + +# --------------------------------------------------------------------------- +# Required resource interface +# --------------------------------------------------------------------------- + + +def init(opts): + """ + Initialize the ``ssh`` resource type for this minion. + + Called once when the resource type is loaded, before any per-resource + operations are dispatched. Reads host configs from the ``ssh`` entry under + the pillar subtree selected by ``resource_pillar_key`` (see + :func:`salt.utils.resources.pillar_resources_tree`), caches them in + ``__context__["ssh_resource"]``, and pre-resolves the SSH binary version + so that :func:`_shell_opts` never has to run a subprocess during a job. + + :param dict opts: The Salt opts dict. + """ + resource_cfg = salt.utils.resources.pillar_resources_tree(opts).get("ssh", {}) + hosts = resource_cfg.get("hosts", {}) + __context__[CONTEXT_KEY] = { # pylint: disable=undefined-variable + "initialized": True, + "hosts": hosts, + "_ssh_version": salt.client.ssh.ssh_version(), + } + + # Cache master opts so sshresource_state can build an FSClient for state + # compilation without creating a new network channel inside a job thread. + # We read the master config from disk (same conf dir as the minion) to get + # the full config with all defaults, rather than the partial dict returned + # by RemoteClient.master_opts() which omits keys like fileserver_backend. + try: + conf_dir = os.path.dirname(opts.get("conf_file", "")) + master_conf = os.path.join(conf_dir, "master") + if os.path.isfile(master_conf): + master_opts = salt.config.master_config(master_conf) + # roots.FSChan expects cachedir; minimal or test master configs may omit it. + master_opts.setdefault("cachedir", opts.get("cachedir", "")) + __context__[CONTEXT_KEY][ + "master_opts" + ] = master_opts # pylint: disable=undefined-variable + log.debug("ssh resource init: loaded master opts from %s", master_conf) + else: + # Fall back to RemoteClient if we can't find the master config on disk. + file_client = salt.fileclient.get_file_client(opts) + master_opts = file_client.master_opts() + if isinstance(master_opts, dict) and master_opts: + master_opts.setdefault("fileserver_backend", ["roots"]) + master_opts.setdefault("cachedir", opts.get("cachedir", "")) + __context__[CONTEXT_KEY][ + "master_opts" + ] = master_opts # pylint: disable=undefined-variable + file_client.destroy() + except Exception as exc: # pylint: disable=broad-except + log.warning("ssh resource init: failed to load master opts: %s", exc) + + log.debug("ssh resource init() called, managing: %s", list(hosts)) + + +def initialized(): + """ + Return ``True`` if :func:`init` has completed successfully. + + :rtype: bool + """ + return __context__.get(CONTEXT_KEY, {}).get( + "initialized", False + ) # pylint: disable=undefined-variable + + +def discover(opts): + """ + Return the list of SSH resource IDs managed by this minion. + + The list is the set of keys under ``hosts`` for the ``ssh`` type under the + configured resource pillar subtree. Adding or removing a + host from that Pillar key and running ``saltutil.refresh_resources`` + updates the Master's Resource Registry without any process restart. + + :param dict opts: The Salt opts dict. + :rtype: list[str] + """ + hosts = ( + salt.utils.resources.pillar_resources_tree(opts).get("ssh", {}).get("hosts", {}) + ) + resource_ids = list(hosts) + log.debug("ssh resource discover() returning: %s", resource_ids) + return resource_ids + + +def grains(): + """ + Return full Salt grains for the current SSH resource. + + Runs ``grains.items`` on the remote host via the salt-thin bundle + (the same mechanism used by ``salt-ssh``), giving us the complete, + accurate grain set rather than a hand-crafted subset. + + Results are cached in ``__context__`` per resource ID. Call + :func:`grains_refresh` to force re-collection. + + :rtype: dict + """ + resource_id = _resource_id() + + ctx = __context__.get(CONTEXT_KEY, {}) # pylint: disable=undefined-variable + cached = ctx.get("grains", {}).get(resource_id) + if cached is not None: + return cached + + cfg = _host_cfg(resource_id) + single = _make_single(resource_id, ["grains.items"]) + stdout, stderr, retcode = single.cmd_block() + + if retcode != 0 or stdout.startswith("ERROR"): + log.warning( + "ssh resource grains: grains.items failed for %s (rc=%d): %s", + resource_id, + retcode, + stderr or stdout, + ) + return { + "resource_type": "ssh", + "resource_id": resource_id, + "host": cfg.get("host", ""), + } + + try: + parsed = salt.utils.json.loads(stdout) + # thin bundle wraps result as {"local": {"jid": "...", "return": {...}}} + data = parsed.get("local", {}).get("return", parsed) + except Exception as exc: # pylint: disable=broad-except + log.warning( + "ssh resource grains: failed to parse output for %s: %s", resource_id, exc + ) + return { + "resource_type": "ssh", + "resource_id": resource_id, + "host": cfg.get("host", ""), + } + + data["resource_type"] = "ssh" + data["resource_id"] = resource_id + + ctx.setdefault("grains", {})[resource_id] = data + return data + + +def grains_refresh(): + """ + Invalidate the grains cache for the current SSH resource and re-collect. + + :rtype: dict + """ + resource_id = _resource_id() + ctx = __context__.get(CONTEXT_KEY, {}) # pylint: disable=undefined-variable + ctx.get("grains", {}).pop(resource_id, None) + return grains() + + +def ping(): + """ + Return ``True`` if the current SSH resource is reachable via SSH. + + Runs ``echo ping`` on the remote host. A zero exit code and the + expected output indicate that the SSH connection is healthy. + """ + resource_id = _resource_id() + try: + shell = _make_shell(resource_id, cfg_override={"timeout": 10}) + stdout, _stderr, retcode = shell.exec_cmd("echo ping") + return retcode == 0 and "ping" in stdout + except Exception as exc: # pylint: disable=broad-except + log.warning("ssh resource ping() failed for %s: %s", resource_id, exc) + return False + + +def shutdown(opts): + """ + Tear down the ``ssh`` resource type. + + Called when the minion shuts down or the resource type is unloaded. + Clears shared type-level state from ``__context__``. + + :param dict opts: The Salt opts dict. + """ + log.debug("ssh resource shutdown() called") + __context__.pop(CONTEXT_KEY, None) # pylint: disable=undefined-variable + + +# --------------------------------------------------------------------------- +# Per-resource operations +# --------------------------------------------------------------------------- + + +def cmd_run(cmd, timeout=None): + """ + Execute a shell command on the current SSH resource. + + This is the primary building block for execution modules that target + SSH resources — analogous to ``__proxy__["ssh_sample.cmd"]()`` in the + proxy model. Execution module overrides for the ``ssh`` resource type + delegate their work here. + + Returns a dict with keys: + + * ``stdout`` — standard output from the remote command + * ``stderr`` — standard error from the remote command + * ``retcode`` — exit code (0 on success) + + :param str cmd: The shell command to run on the remote host. + :param int timeout: Optional per-call SSH timeout in seconds. When + provided, overrides the connection-level ``timeout`` for this + call only. + :rtype: dict + + CLI Example (via resource execution module): + + .. code-block:: bash + + salt -C 'T@ssh:web-01' ssh_cmd.run 'uptime' + """ + resource_id = _resource_id() + override = {"timeout": timeout} if timeout is not None else None + shell = _make_shell(resource_id, override) + stdout, stderr, retcode = shell.exec_cmd(cmd) + return {"stdout": stdout, "stderr": stderr, "retcode": retcode} diff --git a/salt/runners/index.py b/salt/runners/index.py new file mode 100644 index 000000000000..32b97a078a9e --- /dev/null +++ b/salt/runners/index.py @@ -0,0 +1,224 @@ +""" +Salt runner for on-demand maintenance of mmap-backed master indexes. + +.. versionadded:: 3009.0 + +Each index uses :class:`~salt.utils.mmap_cache.MmapCache` compaction +(``atomic_rebuild`` with sorted placement) where applicable. The **pki** index +is rebuilt from the PKI directory layout; the **resources** index is compacted +from its existing primary contents (tombstone reclamation / load relief). + +CLI examples: + +.. code-block:: bash + + salt-run index.status name=pki + salt-run index.compact name=pki + salt-run index.compact name=resources dry_run=True +""" + +import logging + +from salt.exceptions import SaltInvocationError + +log = logging.getLogger(__name__) + +# Aliases map user-facing names to canonical handlers. +_INDEX_ALIASES = { + "pki": "pki", + "minion_keys": "pki", + "keys": "pki", + "resources": "resources", + "resource_registry": "resources", +} + +_CANONICAL = ("pki", "resources") + + +def _canonical_index(name): + if name is None or (isinstance(name, str) and not name.strip()): + raise SaltInvocationError( + "Missing index name. Example: salt-run index.compact name=pki. " + f"Valid names: {', '.join(_CANONICAL)} (see ``index.list_indexes``)." + ) + key = str(name).strip().lower().replace("-", "_") + canonical = _INDEX_ALIASES.get(key, key) + if canonical not in _CANONICAL: + raise SaltInvocationError( + f"Unknown index {name!r}. Expected one of: {', '.join(_CANONICAL)} " + "(aliases: minion_keys, keys → pki; resource_registry → resources)." + ) + return canonical + + +def list_indexes(): + """ + Return supported canonical index names and short descriptions. + + CLI Example: + + .. code-block:: bash + + salt-run index.list_indexes + """ + return { + "pki": "Minion authentication key mmap index (requires ``pki_index_enabled``).", + "resources": "Master resource registry SRN primary mmap under ``cachedir``.", + } + + +def _pki_index_stats(opts): + """ + Return ``MmapCache.get_stats()`` for the PKI ``keys`` bank, or ``None`` + if the index file does not exist yet (no ``rebuild`` has run). + """ + import os # pylint: disable=import-outside-toplevel + + import salt.utils.mmap_cache # pylint: disable=import-outside-toplevel + from salt.cache import mmap_key # pylint: disable=import-outside-toplevel + + if opts.get("cluster_id"): + cachedir = opts.get("cluster_pki_dir", "") + else: + cachedir = opts.get("pki_dir", "") + if not cachedir: + return None + index_path = os.path.join(cachedir, mmap_key._BANK_INDEX_NAME["keys"]) + if not os.path.exists(index_path): + return None + cache = salt.utils.mmap_cache.MmapCache( + path=index_path, + size=opts.get("mmap_key_size", mmap_key._DEFAULT_SIZE), + slot_size=opts.get("mmap_key_slot_size", mmap_key._DEFAULT_SLOT_SIZE), + key_size=opts.get("mmap_key_id_size", mmap_key._DEFAULT_ID_SIZE), + ) + try: + return cache.get_stats() + finally: + cache.close() + + +def _compact_pki(opts, dry_run): + from salt.cache import mmap_key # pylint: disable=import-outside-toplevel + + stats_before = _pki_index_stats(opts) + + if dry_run: + if not stats_before: + return ( + "PKI Index Status:\n" + " Index file not present (no migration has run yet).\n" + " Occupied: 0\n" + ) + + occ = stats_before["occupied"] + deleted = stats_before["deleted"] + denom = occ + deleted + pct_tombstones = (deleted / denom * 100) if denom > 0 else 0.0 + + return ( + f"PKI Index Status:\n" + f" Total slots: {stats_before['total']:,}\n" + f" Occupied: {occ:,}\n" + f" Deleted (tombstones): {deleted:,}\n" + f" Empty: {stats_before['empty']:,}\n" + f" Load factor: {stats_before['load_factor']:.1%}\n" + f" Tombstone ratio: {pct_tombstones:.1f}%\n" + f"\n" + f"Rebuild recommended: {'Yes' if pct_tombstones > 25 else 'No'}" + ) + + log.info("Starting PKI mmap index rebuild") + # ``mmap_key`` reads its module-level ``__opts__`` to size the MmapCache + # under ``store()``; since we're calling it from a runner (no loader + # injection on this import), point it at our opts before invoking. + mmap_key.__opts__ = opts + counts = mmap_key.rebuild_from_localfs(opts) + if counts is None: + return "PKI index rebuild failed. Check logs for details." + + total = sum(counts.values()) + deleted_before = stats_before["deleted"] if stats_before else 0 + return ( + f"PKI index rebuilt successfully.\n" + f" Keys: {total:,}\n" + f" accepted: {counts['accepted']:,}\n" + f" pending: {counts['pending']:,}\n" + f" rejected: {counts['rejected']:,}\n" + f" denied: {counts['denied']:,}\n" + f" Tombstones removed: {deleted_before:,}\n" + ) + + +def _compact_resources(opts, dry_run): + import salt.utils.resource_registry as rr # pylint: disable=import-outside-toplevel + + reg = rr.get_registry(opts) + + if dry_run: + try: + stats = reg.stats() + except Exception as exc: # pylint: disable=broad-except + log.exception("resource index status failed") + return f"Resource index stats unavailable: {exc}" + + primary = stats.get("primary") or {} + occ = primary.get("occupied", 0) + deleted = primary.get("deleted", 0) + total = primary.get("total", 0) or 1 + load_factor = primary.get("load_factor", (occ + deleted) / total) + denom = occ + deleted + pct_tombstones = (deleted / denom * 100) if denom > 0 else 0.0 + + return ( + f"Resource index status:\n" + f" Path: {stats.get('path')}\n" + f" Total slots: {primary.get('total', 0):,}\n" + f" Occupied: {occ:,}\n" + f" Deleted (tombstones): {deleted:,}\n" + f" Load factor: {load_factor:.1%}\n" + f" Tombstone ratio: {pct_tombstones:.1f}%\n" + f" Derived types: {stats.get('derived_by_type_count', 0)}\n" + f" Derived minions: {stats.get('derived_by_minion_count', 0)}\n" + ) + + log.info("Starting resource registry primary compaction") + before, after = reg.compact() + return ( + f"Resource index compacted successfully.\n" + f" Occupied slots: {before:,} -> {after:,}\n" + ) + + +def compact(name, dry_run=False): + """ + Rebuild or compact the named mmap-backed master index. + + :param str name: Index to operate on. See :func:`list_indexes`. + :param bool dry_run: When ``True``, print statistics only (no writes). + + CLI Examples: + + .. code-block:: bash + + salt-run index.compact name=pki + salt-run index.compact name=resources + salt-run index.compact name=pki dry_run=True + """ + which = _canonical_index(name) + if which == "pki": + return _compact_pki(__opts__, dry_run) + return _compact_resources(__opts__, dry_run) + + +def status(name): + """ + Show statistics for the named index (same as ``compact(..., dry_run=True)``). + + CLI Example: + + .. code-block:: bash + + salt-run index.status name=resources + """ + return compact(name, dry_run=True) diff --git a/salt/runners/resource.py b/salt/runners/resource.py new file mode 100644 index 000000000000..ea81c70e5c8b --- /dev/null +++ b/salt/runners/resource.py @@ -0,0 +1,144 @@ +""" +Operator-facing helpers for inspecting Salt Resources state on the master. + +.. versionadded:: 3009.0 + +The mmap-backed registry (``salt.utils.resource_registry``) is the master's +authority for *which* minions manage which resources. The +``resource_grains`` cache bank (populated by the master's +``_register_resources`` handler from each minion's +:meth:`salt.minion.Minion._collect_resource_grains` payload) is the source +of truth for the per-resource grain dicts that drive ``salt -G`` / +``salt -P`` / ``salt -C 'G@…'`` matching. + +This runner exposes thin read-only views over both so operators can debug +"why didn't ``salt -G ':' test.ping`` match my resource?" +without spelunking through pickled cache files by hand. + +CLI examples:: + + salt-run resource.show_grains type=dummy id=dummy-01 + salt-run resource.list_grains + salt-run resource.refresh minion=resources-minion +""" + +import logging + +import salt.utils.event +import salt.utils.resource_registry + +log = logging.getLogger(__name__) + + +def _resource_grains_cache(opts): + """Return the ``salt.cache`` handle for the ``resource_grains`` bank.""" + import salt.cache # pylint: disable=import-outside-toplevel + + return salt.cache.factory(opts) + + +def show_grains(type, id): # noqa: A002 — keep CLI-friendly param names + """ + Return the per-resource grain dict the master has cached for one + resource, or ``None`` if no entry exists. + + The resource is addressed by the same composite SRN the registry + uses internally: ``":"``. + + CLI Example: + + .. code-block:: bash + + salt-run resource.show_grains type=dummy id=dummy-01 + + :param str type: The resource type (e.g. ``"dummy"``, ``"ssh"``). + :param str id: The bare resource id (e.g. ``"dummy-01"``). + :rtype: dict or None + """ + if not type or not id: + return None + cache = _resource_grains_cache(__opts__) # pylint: disable=undefined-variable + bank = salt.utils.resource_registry.RESOURCE_GRAINS_BANK + try: + return cache.fetch(bank, f"{type}:{id}") + except Exception as exc: # pylint: disable=broad-except + log.warning("resource.show_grains(%s:%s) failed: %s", type, id, exc) + return None + + +def list_grains(): + """ + Return every SRN currently in the master's ``resource_grains`` cache + bank along with a short summary (top-level grain keys, count). + + Useful for sanity-checking that a minion's last + ``_register_resources`` actually landed. + + CLI Example: + + .. code-block:: bash + + salt-run resource.list_grains + """ + cache = _resource_grains_cache(__opts__) # pylint: disable=undefined-variable + bank = salt.utils.resource_registry.RESOURCE_GRAINS_BANK + try: + srns = list(cache.list(bank) or []) + except Exception as exc: # pylint: disable=broad-except + log.warning("resource.list_grains: cannot list bank: %s", exc) + return {} + summary = {} + for srn in srns: + try: + gdict = cache.fetch(bank, srn) or {} + except Exception: # pylint: disable=broad-except + continue + if not isinstance(gdict, dict): + continue + summary[srn] = { + "grain_keys": sorted(gdict.keys()), + "grain_count": len(gdict), + } + return summary + + +def refresh(minion): + """ + Ask one minion to re-render its per-resource grains and re-publish + them to the master's ``resource_grains`` bank. + + Fires the ``resource_refresh`` event at the minion via the master + event bus; the minion's event handler re-runs + ``_discover_resources`` + ``_register_resources_with_master``. + + Use this when a resource's underlying state changed out-of-band + (e.g. the operator updated metadata via the resource's connection + module) and you need the master's grain bank to reflect it without + waiting for the next pillar refresh. + + CLI Example: + + .. code-block:: bash + + salt-run resource.refresh minion=resources-minion + + :param str minion: Minion ID to send the event to. + :rtype: bool + """ + if not minion: + return False + with salt.utils.event.get_event( + "master", + sock_dir=__opts__["sock_dir"], # pylint: disable=undefined-variable + opts=__opts__, # pylint: disable=undefined-variable + listen=False, + ) as evt: + try: + evt.fire_event( + {"minion": minion}, + f"minion/{minion}/resource_refresh", + ) + return True + except Exception as exc: # pylint: disable=broad-except + log.warning("resource.refresh(%s) failed: %s", minion, exc) + return False diff --git a/salt/state.py b/salt/state.py index 3c1826b9917d..eb86f2d6a6fc 100644 --- a/salt/state.py +++ b/salt/state.py @@ -121,6 +121,10 @@ "__pub_ret", "__pub_pid", "__pub_tgt_type", + "__pub_resource_targets", + "__pub_minion_is_target", + "__pub_resource_target", + "__pub_resource_job", "__prereq__", "__prerequiring__", "__umask__", diff --git a/salt/states/saltutil.py b/salt/states/saltutil.py index ab951fc992ca..f325e0e5b0b0 100644 --- a/salt/states/saltutil.py +++ b/salt/states/saltutil.py @@ -323,6 +323,20 @@ def sync_tops(name, **kwargs): return _sync_single(name, "tops", **kwargs) +def sync_resources(name, **kwargs): + """ + Performs the same task as saltutil.sync_resources module + See :mod:`saltutil module for full list of options ` + + .. code-block:: yaml + + sync_everything: + saltutil.sync_resources: + - refresh: True + """ + return _sync_single(name, "resources", **kwargs) + + def sync_thorium(name, **kwargs): """ Performs the same task as saltutil.sync_thorium module diff --git a/salt/utils/minions.py b/salt/utils/minions.py index 0d493928445c..ed434c56bdd6 100644 --- a/salt/utils/minions.py +++ b/salt/utils/minions.py @@ -8,13 +8,14 @@ import re import salt.cache -import salt.key import salt.payload import salt.roster import salt.transport import salt.utils.data import salt.utils.files import salt.utils.network +import salt.utils.resource_registry +import salt.utils.resources import salt.utils.stringutils import salt.utils.versions from salt._compat import ipaddress @@ -34,10 +35,9 @@ TARGET_REX = re.compile( r"""(?x) ( - (?PG|P|I|J|L|N|S|E|R) # Possible target engines - (?P(?<=G|P|I|J).)? # Optional delimiter for specific engines - @)? # Engine+delimiter are separated by a '@' - # character and are optional for the target + (?PG|P|I|J|L|N|S|E|R|T|M) # Possible target engines + (?P(?<=G|P|I|J).)? # Optional delimiter (G/P/I/J only) + @)? # Engine+delimiter separated by '@', optional (?P.+)$""" # The pattern passed to the target engine ) @@ -195,6 +195,185 @@ def nodegroup_comp(nodegroup, nodegroups, skip=None, first_call=True): return ret +# --------------------------------------------------------------------------- +# Resource-index call-path +# --------------------------------------------------------------------------- +# +# The authoritative store is :class:`salt.utils.resource_registry.ResourceRegistry` +# (mmap-backed primary on disk, in-process derived by_type / by_minion views). +# This module only exposes thin helpers over the registry: +# +# * :func:`update_resource_index` — called by the master's +# ``_register_resources`` handler when a minion reports its resource set. +# * :meth:`CkMinions._check_resource_minions` and +# :meth:`CkMinions._augment_with_resources` — hot targeting paths. +# +# The legacy ``schema_version`` / ``_build_resource_index`` / ``_coerce_*`` +# helpers below are preserved as pure utilities because they're still used for +# constructing test fixtures and for one-shot migration scripts; they are not +# on any production code path. +# +# Single-writer invariant: only the master's AESFuncs process mutates the +# registry. All other master workers (and any runner) are readers. + +# Legacy bank name retained for any on-disk artefact still shipped as a blob +# (e.g. the ``resources`` bank referenced by :class:`ResourceRegistry` for +# topology lookups — distinct from the mmap primary). +_RESOURCE_INDEX_BANK = "resource_index" +_RESOURCE_INDEX_KEY = "index" + +# Persisted resource index schema: v2 uses composite SRN keys in ``by_id`` +# (``":"``) so the same bare ``id`` may exist under multiple types. +RESOURCE_INDEX_SCHEMA_VERSION = 2 + + +def resource_index_srn_key(resource_type, resource_id): + """ + Canonical cache / ``by_id`` key for a Salt resource (SRN without ``T@``). + + :param str resource_type: Resource type (e.g. ``"ssh"``). + :param str resource_id: Bare resource id (e.g. ``"web-01"``). + :rtype: str + """ + return salt.utils.resource_registry.resource_index_srn_key( + resource_type, resource_id + ) + + +def _empty_resource_index(): + return { + "schema_version": RESOURCE_INDEX_SCHEMA_VERSION, + "by_id": {}, + "by_type": {}, + "by_minion": {}, + } + + +def _coerce_resource_index_schema(index): + """ + Normalize a loaded index dict to :data:`RESOURCE_INDEX_SCHEMA_VERSION`. + + Older indexes used bare resource ids as ``by_id`` keys, which cannot + represent the same id under multiple types. Those are rebuilt from + ``by_minion``, which remains authoritative. + + Preserved as a pure utility for test fixtures and one-shot migration + from legacy on-disk blobs. Not on any production read path. + """ + if not isinstance(index, dict): + return _empty_resource_index() + if index.get("schema_version") == RESOURCE_INDEX_SCHEMA_VERSION: + return { + "schema_version": RESOURCE_INDEX_SCHEMA_VERSION, + "by_id": dict(index.get("by_id") or {}), + "by_type": dict(index.get("by_type") or {}), + "by_minion": dict(index.get("by_minion") or {}), + } + by_minion = dict(index.get("by_minion") or {}) + by_type = dict(index.get("by_type") or {}) + by_id = {} + for minion_id, resources in by_minion.items(): + if not isinstance(resources, dict): + continue + for rtype, rids in resources.items(): + if not isinstance(rids, list): + continue + for rid in rids: + by_id[resource_index_srn_key(rtype, rid)] = { + "minion": minion_id, + "type": rtype, + } + return { + "schema_version": RESOURCE_INDEX_SCHEMA_VERSION, + "by_id": by_id, + "by_type": by_type, + "by_minion": by_minion, + } + + +# Functions where resources run inline and their results are merged into the +# managing minion's own response. The operator sees ONE combined block + ONE +# Summary section instead of separate blocks per resource. +_MERGE_RESOURCE_FUNS = frozenset( + { + "state.apply", + "state.highstate", + "state.sls", + "state.sls_id", + "state.single", + } +) + + +def _build_resource_index(by_minion): + """ + Build the three-way flat index from a ``{minion_id: {rtype: [rid, ...]}}`` + mapping. Pure utility; used by test fixtures and migration scripts. + + Returns:: + + { + "schema_version": RESOURCE_INDEX_SCHEMA_VERSION, + "by_id": {":": {"minion": minion_id, "type": rtype}, ...}, + "by_type": {rtype: [rid, ...], ...}, + "by_minion": {minion_id: {rtype: [rid, ...]}, ...}, + } + """ + by_id = {} + by_type = {} + for minion_id, resources in by_minion.items(): + for rtype, rids in resources.items(): + if rtype not in by_type: + by_type[rtype] = [] + for rid in rids: + by_id[resource_index_srn_key(rtype, rid)] = { + "minion": minion_id, + "type": rtype, + } + if rid not in by_type[rtype]: + by_type[rtype].append(rid) + return { + "schema_version": RESOURCE_INDEX_SCHEMA_VERSION, + "by_id": by_id, + "by_type": by_type, + "by_minion": dict(by_minion), + } + + +def update_resource_index(opts, minion_id, resources): + """ + Register or update the full set of resources managed by ``minion_id``. + + Thin wrapper around :meth:`ResourceRegistry.register_minion` that wires + the per-process singleton. Called by the master's ``_register_resources`` + handler. + + :param dict opts: Salt opts (needs ``cachedir``). + :param str minion_id: The reporting minion. + :param dict resources: ``{resource_type: [resource_id, ...]}``. + :returns: ``(n_put, n_deleted)`` for logging. + """ + try: + registry = salt.utils.resource_registry.get_registry(opts) + except Exception: # pylint: disable=broad-except + log.error( + "Failed to open resource registry while registering %s; " + "resource targeting for this minion will be unavailable.", + minion_id, + exc_info=True, + ) + return (0, 0) + try: + return registry.register_minion(minion_id, resources or {}) + except Exception: # pylint: disable=broad-except + log.error( + "Failed to register resources for minion '%s'", + minion_id, + exc_info=True, + ) + return (0, 0) + + class CkMinions: """ Used to check what minions should respond from a target @@ -206,15 +385,35 @@ class CkMinions: """ def __init__(self, opts): + import salt.key # noqa: PLC0415 — module-level import pulls ``salt.key`` → ``masterapi`` + + # → ``minion`` before ``salt.config`` finishes loading (mixed trees). + self.opts = opts self.cache = salt.cache.factory(opts) self.key = salt.key.get_key(opts) + # ``self.registry`` is a lazy property — see :py:meth:`registry`. + # Eager instantiation forced :class:`MmapCache` (and thus ``xxhash``) + # to load at every ``CkMinions(opts)`` site, including paths that + # never target resources (e.g. master startup on a minion-only Salt + # install where ``xxhash`` is not bundled in the same site-packages + # the daemon resolves at runtime). # TODO: this is actually an *auth* check if self.opts.get("transport", "zeromq") in salt.transport.TRANSPORTS: self.acc = "minions" else: self.acc = "accepted" + @property + def registry(self): + """ + Process-wide singleton :class:`ResourceRegistry`, materialised on + first access. Shared with every other ``CkMinions`` / + ``AESFuncs`` collaborator in this process, so the derived-view + cache and the mmap handle are reused. + """ + return salt.utils.resource_registry.get_registry(self.opts) + def _check_nodegroup_minions(self, expr, greedy): # pylint: disable=unused-argument """ Return minions found by looking at nodegroups @@ -230,38 +429,88 @@ def _check_glob_minions( Return the minions found by looking via globs """ if minions: - matched = {"minions": fnmatch.filter(minions, expr), "missing": []} + result_minions = fnmatch.filter(minions, expr) else: - matched = self.key.glob_match(expr).get(self.key.ACC, []) + if hasattr(self.key, "glob_match"): + result_minions = list(self.key.glob_match(expr).get(self.key.ACC, [])) + else: + # Salt 3007 ``Key`` API — ``name_match`` / legacy layouts without ``glob_match``. + result_minions = fnmatch.filter(list(self._pki_minions()), expr) + + if ( + isinstance(expr, str) + and expr + and not any(c in expr for c in ("*", "?", "[")) + ): + try: + if expr not in result_minions and ( + self.registry.resolve_bare_resource_id(expr) + or salt.utils.resources.bare_resource_id_in_minion_data_cache( + self.opts, expr, cache=self.cache + ) + ): + result_minions = list(result_minions) + [expr] + except Exception: # pylint: disable=broad-except + log.debug( + "Glob match: bare resource id resolution failed for %r", + expr, + exc_info=True, + ) - return {"minions": matched, "missing": []} + return {"minions": result_minions, "missing": []} def _check_list_minions( self, expr, greedy, ignore_missing=False, minions=None ): # pylint: disable=unused-argument """ Return the minions found by looking via a list + + Tokens that are not accepted minion keys but match a registered bare + resource id (see :meth:`ResourceRegistry.resolve_bare_resource_id`) are + appended so ``salt -L `` resolves like ``T@type:``. """ if isinstance(expr, str): expr = [m for m in expr.split(",") if m] if minions: - return { - "minions": [x for x in expr if x in minions], - "missing": ( - [] if ignore_missing else [x for x in expr if x not in minions] - ), - } + matched = [x for x in expr if x in minions] + missing = [] if ignore_missing else [x for x in expr if x not in minions] else: - found = self.key.list_match(expr) - return { - "minions": found.get(self.key.ACC, []), - "missing": ( + if hasattr(self.key, "list_match"): + found = self.key.list_match(expr) + matched = found.get(self.key.ACC, []) + missing = ( [] if ignore_missing else [x for x in expr if x not in found.get(self.key.ACC, [])] - ), - } + ) + else: + pk = self._pki_minions() + matched = [x for x in expr if x in pk] + missing = [] if ignore_missing else [x for x in expr if x not in pk] + + acc = set(matched) + extra = [] + try: + for token in expr: + if token in acc: + continue + if self.registry.resolve_bare_resource_id( + token + ) or salt.utils.resources.bare_resource_id_in_minion_data_cache( + self.opts, token, cache=self.cache + ): + extra.append(token) + acc.add(token) + if not ignore_missing and token in missing: + missing.remove(token) + except Exception: # pylint: disable=broad-except + log.debug( + "List match: bare resource id resolution failed; continuing without extras", + exc_info=True, + ) + + return {"minions": matched + extra, "missing": missing} def _check_pcre_minions( self, expr, greedy, minions=None @@ -361,17 +610,80 @@ def _check_grain_minions(self, expr, delimiter, greedy, minions=None): """ Return the minions found by looking via grains """ - return self._check_cache_minions( + result = self._check_cache_minions( expr, delimiter, greedy, "grains", minions=minions ) + self._augment_grain_match_with_resource_grains( + result, expr, delimiter, regex_match=False + ) + return result def _check_grain_pcre_minions(self, expr, delimiter, greedy, minions=None): """ Return the minions found by looking via grains with PCRE """ - return self._check_cache_minions( + result = self._check_cache_minions( expr, delimiter, greedy, "grains", regex_match=True, minions=minions ) + self._augment_grain_match_with_resource_grains( + result, expr, delimiter, regex_match=True + ) + return result + + def _augment_grain_match_with_resource_grains( + self, result, expr, delimiter, regex_match + ): + """ + Append matching resource IDs to ``result["minions"]`` for grain + targeting. + + Per-resource grain dicts live in the ``resource_grains`` cache bank + (populated by the master's ``_register_resources`` handler from the + minion-side ``Minion._collect_resource_grains``). We walk the bank + in-place rather than going through :meth:`_check_cache_minions`, + because the SRN composite key (``":"``) needs to be split + back to a bare resource ID for the response wait-set. + + :meth:`_check_cache_minions` may return ``result["minions"]`` as + either a list or a ``set`` depending on which early-return branch + fired (e.g. an empty ``grains`` cache returns the upstream + ``set`` from ``_pki_minions``). We normalise to a list before + appending so the downstream consumers in + :meth:`_check_compound_minions` get the same shape regardless of + which branch was hit. + """ + if not self.opts.get("minion_data_cache", False): + return + bank = salt.utils.resource_registry.RESOURCE_GRAINS_BANK + try: + srns = list(self.cache.list(bank) or []) + except Exception: # pylint: disable=broad-except + return + if not srns: + return + existing = result.get("minions", []) + seen = set(existing) + # Normalise to list; preserve any existing order. + if not isinstance(existing, list): + result["minions"] = list(existing) + for srn in srns: + try: + gdict = self.cache.fetch(bank, srn) + except Exception: # pylint: disable=broad-except + continue + if not isinstance(gdict, dict): + continue + try: + if not salt.utils.data.subdict_match( + gdict, expr, delimiter=delimiter, regex_match=regex_match + ): + continue + except Exception: # pylint: disable=broad-except + continue + _rtype, _, rid = srn.partition(":") + if rid and rid not in seen: + result["minions"].append(rid) + seen.add(rid) def _check_pillar_minions(self, expr, delimiter, greedy, minions=None): """ @@ -526,6 +838,8 @@ def _deferred_minions(): "S": self._check_ipcidr_minions, "E": self._check_pcre_minions, "R": self._all_minions, + "T": self._check_resource_minions, + "M": self._check_managing_minion_minions, } if pillar_exact: ref["I"] = self._check_pillar_exact_minions @@ -722,8 +1036,122 @@ def _all_minions(self, expr=None, minions=None): return {"minions": minions, "missing": []} + def _check_resource_minions(self, expr, greedy, minions=None): + """ + Return the resource IDs that match the ``T@`` pattern ``expr``. + + ``expr`` is either a bare resource type (``dummy``) or a full SRN + (``dummy:dummy-01``). + + Unlike other ``_check_*_minions`` methods, the returned IDs are + **resource IDs**, not managing-minion IDs. This is intentional: the + CLI uses this list to know which return IDs to expect, and resource + returns are keyed by resource ID (remapped by the master's + ``_return`` handler after the transport security check passes). + + Job delivery is handled separately: the job is published with the + original ``T@`` target expression and ``tgt_type=compound``; managing + minions receive it via broadcast and filter locally with + ``resource_match.match()``. + + Backed by :class:`ResourceRegistry`: full-SRN lookups are O(1) on + the mmap primary; bare-type lookups are O(k) on the derived + ``by_type`` view. Reads during compaction are consistent because + the registry detects atomic swaps via inode (see + :data:`STALENESS_CHECK_INTERVAL`). + """ + parsed = salt.utils.resource_registry.parse_srn(expr) + resource_type = parsed["type"] + resource_id = parsed["id"] + + if resource_type is None: + return {"minions": [], "missing": []} + + if resource_id is not None: + # Full SRN: echo the resource ID back. The managing minion will + # filter locally via ``resource_match.match()``. We log if the + # SRN is not registered so operators can detect typos without + # blocking the job (minion might not have registered yet). + try: + known = self.registry.has_srn(resource_type, resource_id) + except Exception: # pylint: disable=broad-except + log.error( + "Resource registry lookup failed for T@%s", expr, exc_info=True + ) + known = False + if not known: + log.debug( + "T@%s not in resource registry; using resource ID from expression", + expr, + ) + return {"minions": [resource_id], "missing": []} + + # Bare type: return every registered resource id of this type. + try: + rids = self.registry.get_resource_ids_by_type(resource_type) + except Exception: # pylint: disable=broad-except + log.error("Resource registry lookup failed for T@%s", expr, exc_info=True) + rids = [] + if rids: + return {"minions": list(rids), "missing": []} + + log.warning( + "T@%s: resource registry has no entries of this type. " + "Restart or sync_all the managing minion to populate the registry.", + expr, + ) + return {"minions": [], "missing": []} + + def _augment_with_resources(self, minion_ids): + """ + Append the resource IDs managed by each matched minion to the list. + + Called by :meth:`check_minions` for wildcard glob targets so that + ``salt '*' test.ping`` also includes returns from managed resources. + + Per-minion lookups hit the in-process ``by_minion`` derived view + (O(1) dict access). If the registry is unavailable the method logs + an error and returns ``minion_ids`` unchanged so ordinary targeting + is never disrupted by a registry failure. + """ + result = list(minion_ids) + if not result: + return result + seen = set(result) + for minion_id in minion_ids: + try: + resources = self.registry.get_resources_for_minion(minion_id) + except Exception: # pylint: disable=broad-except + log.error( + "Failed to load resources for minion %s; resource IDs will " + "not be included in this target expansion.", + minion_id, + exc_info=True, + ) + return list(minion_ids) + for rids in resources.values(): + for rid in rids: + if rid not in seen: + result.append(rid) + seen.add(rid) + return result + + def _check_managing_minion_minions(self, expr, greedy, minions=None): + """ + Return the minion set for a ``M@`` managing-minion expression. + + ``expr`` is a minion ID glob. Returns any accepted minion whose ID + matches ``expr``. + """ + return self._check_glob_minions(expr, greedy, minions=minions) + def check_minions( - self, expr, tgt_type="glob", delimiter=DEFAULT_TARGET_DELIM, greedy=True + self, + expr, + tgt_type="glob", + delimiter=DEFAULT_TARGET_DELIM, + greedy=True, + fun=None, ): """ Check the passed regex against the available minions' public keys @@ -749,6 +1177,23 @@ def check_minions( # pylint: enable=not-callable else: _res = check_func(expr, greedy) # pylint: disable=not-callable + # For wildcard glob targets (e.g. ``salt '*'``), include resource + # IDs managed by matched minions so that the master keeps its + # response window open long enough to receive resource results. + # Specific name targets (e.g. ``salt 'minion'``) are intentionally + # NOT augmented — targeting a minion by name should not implicitly + # include its resources. + # Compound targets handle resource matching explicitly via T@/M@. + # Merge-mode functions (state.apply etc.) run resources inline on + # the managing minion and return ONE combined response, so the + # master must NOT add separate resource IDs to its wait-list. + if ( + tgt_type == "glob" + and isinstance(expr, str) + and any(c in expr for c in ("*", "?", "[")) + and not (isinstance(fun, str) and fun in _MERGE_RESOURCE_FUNS) + ): + _res["minions"] = self._augment_with_resources(_res["minions"]) _res["ssh_minions"] = False if self.opts.get("enable_ssh_minions", False) is True and isinstance( "tgt", str diff --git a/salt/utils/mmap_cache.py b/salt/utils/mmap_cache.py index 8346be3c334c..6fdf10292684 100644 --- a/salt/utils/mmap_cache.py +++ b/salt/utils/mmap_cache.py @@ -8,12 +8,21 @@ import threading import time -import xxhash - import salt.utils.files import salt.utils.platform import salt.utils.stringutils +# ``xxhash`` is the runtime hash for slot probing and value checksums, but it +# is a C extension that the salt-ssh thin payload, the Windows pkg test +# harness, and various salt-call entry points do not bundle. Importing this +# module from those contexts (via ``salt.utils.minions`` → +# ``salt.utils.resource_registry`` → here) must not raise, so the import is +# deferred to first ``MmapCache`` instantiation. The 6 ``xxhash.`` sites +# in this module reference the module-level ``xxhash`` global, which +# :func:`_ensure_xxhash` populates the first time someone constructs an +# ``MmapCache``. +xxhash = None + try: import fcntl except ImportError: @@ -26,6 +35,22 @@ log = logging.getLogger(__name__) + +def _ensure_xxhash(): + """ + Resolve the deferred ``xxhash`` import on first ``MmapCache`` use. + + Raises ``ImportError`` with a clear message when ``xxhash`` isn't + installed in this environment — surfaces only when something actually + constructs an :class:`MmapCache`, never on bare ``import``. + """ + global xxhash + if xxhash is None: + import xxhash as _xxhash_mod # pylint: disable=import-outside-toplevel + + xxhash = _xxhash_mod + + # Status constants for data slots EMPTY = 0 OCCUPIED = 1 @@ -177,6 +202,7 @@ def __init__( verify_checksums=True, max_segment_bytes=DEFAULT_MAX_SEGMENT_BYTES, ): + _ensure_xxhash() self.path = os.path.realpath(path) self.size = size self.key_size = key_size @@ -223,6 +249,14 @@ def __init__( self._length_off = 1 + key_size + _OFFSET_SIZE self._mtime_off = 1 + key_size + _OFFSET_SIZE + _LENGTH_SIZE + #: How often we're willing to ``os.stat`` the file to detect an + #: atomic-swap compaction. Set to 0 to stat on every ``open()``. + self._staleness_check_interval = staleness_check_interval + # ``None`` means no staleness timestamp yet — must not use ``0.0`` or + # ``(now - 0) < interval`` can spuriously throttle right after process + # start when ``time.monotonic()`` is still small (seen on macOS CI). + self._last_staleness_check = None + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -685,8 +719,23 @@ def open(self, write=False): Writers use ACCESS_WRITE and are expected to hold the lock. """ if self._mm: - current_id = self._get_cache_id() + # Check for staleness (Atomic Swap detection). need_writable = write and not self._mm_writable + now = time.monotonic() + interval = self._staleness_check_interval + # Only throttle if the existing mapping already satisfies the + # caller. A read-only mmap can never service a write — we must + # always tear it down and reopen ACCESS_WRITE, regardless of how + # recently we last stat()ed. + if ( + not need_writable + and interval + and self._last_staleness_check is not None + and (now - self._last_staleness_check) < interval + ): + return True + self._last_staleness_check = now + current_id = self._get_cache_id() if current_id != self._cache_id or need_writable: # Preserve the persistent lock fd: ``put`` / ``delete`` / # ``atomic_rebuild`` call ``open(write=True)`` *while holding* @@ -797,6 +846,9 @@ def _close_mmaps_and_fds(self): self._cache_id = None self._roster_invalidate() self._roster_slot_offsets.clear() + # Next ``open()`` must not inherit a pre-close timestamp or the first + # post-open throttle window can be wrong for a freshly mapped file. + self._last_staleness_check = None def close(self): """Close all mmaps and persistent fds (index, heap, roster, lock).""" diff --git a/salt/utils/relenv.py b/salt/utils/relenv.py index 4f2c4878d8fe..b2be0399167e 100644 --- a/salt/utils/relenv.py +++ b/salt/utils/relenv.py @@ -41,7 +41,11 @@ def gen_relenv( tarball_path = os.path.join(relenv_dir, "salt-relenv.tar.xz") - # Download the tarball if it doesn't exist or overwrite is True + # Download the tarball if it doesn't exist or overwrite is True. + # NOTE: get_tarball() always makes network requests to scrape the latest + # version number even when the tarball is already cached. Skip it when + # the file is present and overwrite is not requested — this avoids + # unnecessary latency and failures in air-gapped environments. if overwrite or not os.path.exists(tarball_path): # Check for shared test cache first (for integration tests) import shutil diff --git a/salt/utils/resource_registry.py b/salt/utils/resource_registry.py new file mode 100644 index 000000000000..0b90542c29ec --- /dev/null +++ b/salt/utils/resource_registry.py @@ -0,0 +1,1108 @@ +""" +Resource Registry — mmap-backed system of record for Salt Resources. + +The registry is the master-side authority for which minions manage which +resources. It powers the ``T@`` and ``M@`` targeting engines, the +``salt '*'`` wildcard augmentation, and any runner/API that needs to walk +the set of resources a minion owns. + +Architecture (Strategy 1 of ``mmap-compaction-design.md`` §"Secondaries"): + +* **Primary index** (``by_id``) is a :class:`~salt.utils.mmap_cache.MmapCache` + file on disk keyed by composite SRN (``"type:id"``) with a small JSON + payload (``{"m": , "t": }``). Reads and writes + are O(1) linear-probing hash lookups; compaction uses sorted placement + (``pack_sorted``) and completes in ~1 s for 1M entries. + +* **Secondary indexes** (``by_type`` and ``by_minion``) are derived views. + They live in-process only and are (re)materialised on first access after + the primary file is observed to have been atomically swapped (inode + change). Each master worker carries its own derived snapshot. + +* **Read consistency during compaction**: master worker processes that + handle ``_register_resources`` can all write (Salt's MWorker pool). + Cross-process visibility is provided by two signals: + + - ``st_ino`` — changes on :meth:`_ResourceIndexStore.compact`'s atomic + ``os.replace``. Triggers readers to close stale mmap handles. + - ``st_mtime_ns`` — bumped by every ``put``/``delete`` via + :meth:`MmapCache._touch_mtime`. Triggers readers to rebuild derived + views from the (updated in place) primary mmap. + + Together they form the ``content_version`` tuple watched by + :meth:`_ResourceIndexStore._current_version`. Readers with an open + mmap keep pointing at the pre-swap inode until the next staleness + check, so they never see a torn file. + +Cache-bank layout (complementary to this on-disk mmap index) is documented in +``resources-registry-design.md`` and consists of three ``salt.cache`` banks +(``grains``, ``pillar``, ``resources``) keyed by bare resource ID. + +The ``resource_grains`` cache bank +--------------------------------- + +Independent of the on-disk mmap registry above, the master also maintains a +``resource_grains`` :class:`salt.cache` bank (default driver: ``localfs``) +that backs grain-based targeting of resources (``salt -G``, ``salt -P``, +``salt -C 'G@…'``). + +Schema: + +* **Bank name**: ``"resource_grains"``. +* **Key**: composite SRN of the form ``":"`` — + same shape produced by :func:`resource_index_srn_key` and used by the + primary mmap index. Composite keying lets two resources share a bare id + across types without colliding (e.g. ``"dummy:web-01"`` and + ``"ssh:web-01"`` are distinct entries). +* **Value**: the per-resource grain dict returned by + ``resource_funcs[f"{type}.grains"]()`` on the managing minion — collected + by :meth:`salt.minion.Minion._collect_resource_grains` and shipped to the + master as part of the ``_register_resources`` payload. + +Lifecycle: + +* **Write**: master ``_register_resources`` handler stores entries on every + registration (intra-process visibility immediate, cross-process via the + filesystem-backed cache). +* **Flush**: when a minion re-registers with a smaller resource set, SRNs + that disappear from the payload are flushed *only if* the registry shows + they're no longer owned by anyone. Multi-minion safe by design. +* **Match**: :meth:`salt.utils.minions.CkMinions._augment_grain_match_with_resource_grains` + walks the bank for every grain/grain-pcre check and appends matched bare + resource ids to the response wait set. + +Freshness: the bank is refreshed only when the minion calls +``_register_resources_with_master``. Triggers for that are minion start, +the ``resource_refresh`` event, and ``saltutil.refresh_pillar``. A +per-resource ``.grains_refresh()`` invocation does **not** +auto-propagate to the master; the operator-level recipe is to fire +``resource_refresh`` on the minion event bus. +""" + +import json +import logging +import os +import threading +import time + +import salt.cache +import salt.utils.mmap_cache + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +RESOURCE_BANK = "resources" + +#: Cache bank holding per-resource grain dicts indexed by composite SRN +#: ``":"``. Populated by the master's ``_register_resources`` +#: handler from the load shipped by +#: :meth:`salt.minion.Minion._collect_resource_grains`. Consumed by +#: :meth:`salt.utils.minions.CkMinions._augment_grain_match_with_resource_grains` +#: to make ``salt -G ...`` / ``salt -P ...`` / ``salt -C 'G@...'`` match +#: resources alongside minions. See the module docstring for the full +#: lifecycle and freshness model. +RESOURCE_GRAINS_BANK = "resource_grains" + +#: Filename (relative to the cache dir) of the primary ``by_id`` mmap file. +PRIMARY_INDEX_FILENAME = "resource_index.by_id.mmap" + +#: Subdirectory under ``cachedir`` that holds the primary mmap file. +PRIMARY_INDEX_SUBDIR = "resources" + +#: Slot count for the primary. 2^21 gives ~2M slots / 256 MiB file at the +#: default slot size, comfortably holding 1M live entries at α ≈ 0.48. +DEFAULT_PRIMARY_CAPACITY = 1 << 21 + +#: Slot size in bytes. Payload budget is ``slot_size - 1`` (status byte), +#: of which the composite key ``"type:id"`` and the JSON value share the +#: space separated by a ``\0`` byte. +DEFAULT_SLOT_SIZE = 128 + +#: Minimum interval between ``os.stat`` staleness checks on the primary +#: mmap file. Without throttling, every ``get()`` would trigger a syscall. +STALENESS_CHECK_INTERVAL = 0.25 # seconds + +#: Soft budget for a full derived-index rebuild from a primary scan. +#: Logged as a warning if exceeded (not enforced). +DERIVED_REBUILD_BUDGET_SECONDS = 2.0 + +#: Default thresholds for automatic compaction — see :meth:`maybe_compact`. +DEFAULT_COMPACT_LOAD_FACTOR = 0.6 # (occupied + deleted) / total +DEFAULT_COMPACT_TOMBSTONE_RATIO = 0.2 # deleted / occupied + +#: Minimum seconds between two automatic compaction attempts. ``get_stats`` +#: requires a full scan of the mmap (O(num_slots)), so bounding the check +#: rate is a first-order performance concern on hot write paths. +DEFAULT_COMPACT_MIN_INTERVAL = 30.0 + +#: Persisted schema version for the in-cache fallback dict (legacy path). +RESOURCE_INDEX_SCHEMA_VERSION = 2 + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + + +def parse_srn(expression): + """ + Parse a ``T@`` pattern into its ``type`` and ``id`` components. + + A full Salt Resource Name (SRN) has the form ``:``. A bare + expression contains only a type with no colon. + + ``parse_srn("vcf_host")`` → ``{"type": "vcf_host", "id": None}`` + ``parse_srn("vcf_host:esxi-01")`` → ``{"type": "vcf_host", "id": "esxi-01"}`` + + :param str expression: A bare resource type or a full SRN. + :rtype: dict + """ + if not expression or not isinstance(expression, str): + return {"type": None, "id": None} + rtype, sep, rid = expression.partition(":") + if not sep: + return {"type": rtype or None, "id": None} + return {"type": rtype or None, "id": rid or None} + + +def resource_index_srn_key(resource_type, resource_id): + """ + Canonical composite key (``":"``) for the primary ``by_id`` index. + + Composite keys are required because bare resource IDs are not unique + across types (see commit ``b95bafd7627``). + + :param str resource_type: Resource type (e.g. ``"ssh"``). + :param str resource_id: Bare resource ID (e.g. ``"web-01"``). + :rtype: str + """ + return f"{resource_type}:{resource_id}" + + +def _encode_by_id_value(minion_id, resource_type): + """ + Encode a primary-index value. Kept compact so ``key + \\0 + value`` + fits within ``DEFAULT_SLOT_SIZE - 1``. + + Layout today: JSON ``{"m": minion_id, "t": resource_type}``. Short keys + ``m`` / ``t`` reduce payload size and leave headroom for future fields. + """ + return json.dumps({"m": minion_id, "t": resource_type}, separators=(",", ":")) + + +def _decode_by_id_value(raw): + """ + Decode a primary-index value into ``{"minion": , "type": }``. + + Accepts the value returned by :class:`~salt.utils.mmap_cache.MmapCache` + (``str`` for kv entries, ``True`` for set-member entries, or ``None``). + Returns ``None`` for malformed or empty values. + """ + if raw is None or raw is True: + return None + if not isinstance(raw, str): + return None + try: + doc = json.loads(raw) + except (TypeError, ValueError): + log.warning("resource_registry: malformed primary value %r", raw) + return None + if not isinstance(doc, dict): + return None + return {"minion": doc.get("m"), "type": doc.get("t")} + + +# --------------------------------------------------------------------------- +# Internal store: primary mmap + derived-view cache +# --------------------------------------------------------------------------- + + +class _ResourceIndexStore: + """ + Mmap-backed primary plus in-process derived indexes. + + Exists as a single collaborator for :class:`ResourceRegistry`. Not a + public API — callers should go through ``ResourceRegistry``. + + Lifecycle:: + + store = _ResourceIndexStore(path, capacity, slot_size) + store.put_many([(srn_key, minion_id, rtype), ...]) + blob = store.get(srn_key) + rids = store.rids_by_type("vcf_host") + resources = store.resources_by_minion("vcenter-1") + store.compact() + + Derived indexes (``by_type``, ``by_minion``) are recomputed from a full + scan of the primary whenever the primary's *content version* has changed + since the last rebuild. ``content_version`` is ``(st_ino, st_mtime_ns)`` + — writers bump the file's ``mtime`` on every ``put``/``delete`` (see + :meth:`MmapCache._touch_mtime`), so the signal catches both: + + * compactions (``os.replace`` → new inode), and + * in-place mutations from other worker processes (same inode, fresh mtime). + + Rebuilds are serialised under an internal lock so concurrent readers + share the cost. + """ + + def __init__( + self, + path, + capacity=DEFAULT_PRIMARY_CAPACITY, + slot_size=DEFAULT_SLOT_SIZE, + ): + """ + :param str path: Absolute path for the primary mmap file. + :param int capacity: Slot count (must be a power of two for best + distribution given the Adler-32 probing). + :param int slot_size: Per-slot byte width including the status byte. + """ + self._path = path + self._capacity = capacity + self._slot_size = slot_size + + self._primary = salt.utils.mmap_cache.MmapCache( + path, + size=capacity, + slot_size=slot_size, + staleness_check_interval=STALENESS_CHECK_INTERVAL, + ) + + # Derived views, keyed by the primary content_version they were + # built from (``(st_ino, st_mtime_ns)``). On version mismatch the + # views are discarded and rebuilt. + self._derived_lock = threading.Lock() + self._derived_version = None + self._by_type: dict = {} # {rtype: [rid, ...]} + self._by_minion: dict = {} # {minion_id: {rtype: [rid, ...]}} + + # Throttled staleness check: remember the last time we stat()ed the + # file and the version we saw, so hot read paths don't syscall per + # operation. See :meth:`_current_version`. + self._last_stat_time: float = 0.0 + self._last_version = None + # Local-write generation counter. ``MmapCache`` mutations don't bump + # the backing file's mtime (writes go through ``mmap.flush()`` which + # is not guaranteed to update file metadata), so the ``stat()``-based + # ``content_version`` alone misses intra-process writes. Bumping a + # local counter on every ``put``/``delete`` and folding it into the + # version tuple guarantees the next ``_ensure_derived_fresh`` call + # rebuilds. Cross-process detection still rides on the file stat. + self._write_generation: int = 0 + + def close(self): + """ + Release the primary mmap handle (tests and :func:`reset_registry`). + """ + self._primary.close() + + # ------------------------------------------------------------------ + # Primary: point ops + # ------------------------------------------------------------------ + + def get(self, srn_key): + """ + Return the decoded value dict for ``srn_key``, or ``None`` if absent. + + :param str srn_key: Composite key (``":"``). + :rtype: dict or None + """ + raw = self._primary.get(srn_key, default=None) + return _decode_by_id_value(raw) + + def put(self, srn_key, minion_id, resource_type): + """ + Insert or update a single primary entry. O(1) amortised. + + Cross-process visibility: after the underlying ``MmapCache.put`` + flushes via ``mmap.flush()`` — which on Linux does **not** advance + the backing file's ``st_mtime``/``st_size`` — we explicitly bump the + file's mtime via :func:`os.utime`. Other master workers stat() the + file on their throttled staleness check and rebuild their derived + view when they see the new mtime. + + Same-process visibility: in addition to the mtime bump, we + increment :py:attr:`_write_generation` and invalidate the + throttled stat cache so the next :meth:`_current_version` call + always returns a fresh tuple. + + :returns: ``True`` on success. + """ + blob = _encode_by_id_value(minion_id, resource_type) + ok = self._primary.put(srn_key, blob) + if ok: + self._invalidate_version_cache() + self._touch_primary_mtime() + return ok + + def delete(self, srn_key): + """ + Mark a primary entry as DELETED (tombstone). Compaction reclaims + the slot. O(1) amortised. + """ + ok = self._primary.delete(srn_key) + if ok: + self._invalidate_version_cache() + self._touch_primary_mtime() + return ok + + def _touch_primary_mtime(self): + """ + Force ``st_mtime_ns`` on the primary index to advance so other + master workers' throttled ``stat()`` picks up our write. + + ``mmap.flush()`` (used by :class:`MmapCache` after every mutation) + is implemented via ``msync`` on Linux and is not guaranteed to + update the backing file's metadata; an explicit ``utime`` is the + cheapest reliable cross-process change signal. + """ + try: + os.utime(self._path, None) + except OSError: + # File may not exist yet on the very first put if MmapCache + # creates it lazily; the next put will succeed. + pass + + def _invalidate_version_cache(self): + """ + Force the next :meth:`_current_version` call to re-stat the file + and produce a tuple distinct from any prior cached version. + + Called after this process writes so it sees its own writes + immediately; other processes rely on the throttled stat cycle. + """ + self._last_version = None + self._last_stat_time = 0.0 + # Bump the local generation so even a stat() that returns identical + # ``(st_ino, st_mtime_ns, st_size)`` produces a different version + # tuple — needed because ``mmap.flush()`` doesn't always advance the + # backing file's mtime. + self._write_generation += 1 + + # ------------------------------------------------------------------ + # Primary: bulk / write-path + # ------------------------------------------------------------------ + + def put_many(self, entries): + """ + Insert many ``(srn_key, minion_id, resource_type)`` tuples. + + Used by :meth:`ResourceRegistry.register_minion` after it has diff'd + the incoming resource set against the previous one for this minion. + Each put acquires its own file lock; for very large inputs prefer + :meth:`compact` feeding :meth:`~MmapCache.atomic_rebuild`. + """ + ok = True + for srn_key, minion_id, rtype in entries: + if not self.put(srn_key, minion_id, rtype): + ok = False + return ok + + def delete_many(self, srn_keys): + """ + Tombstone many primary entries. Idempotent: absent keys are + silently ignored. + """ + for k in srn_keys: + self.delete(k) + return True + + # ------------------------------------------------------------------ + # Compaction + # ------------------------------------------------------------------ + + def compact(self): + """ + Rebuild the primary into a fresh index+heap pair via + :meth:`MmapCache.atomic_rebuild`, then atomically swap. + + Readers with an existing mmap keep their pre-swap view until they + next call a method that triggers a staleness check. New readers + get the post-swap file directly. + + :returns: ``(occupied_before, occupied_after)`` for caller logging. + """ + before = self._primary.get_stats().get("occupied", 0) + # ``list_items`` is a full scan of OCCUPIED slots. It returns + # ``[(key, value), ...]`` where ``value`` is the JSON blob (str) + # or ``True`` for set-member entries. ``atomic_rebuild`` handles + # both shapes via :func:`MmapCache._normalize_iterator`. + items = self._primary.list_items() + ok = self._primary.atomic_rebuild(items) + if not ok: + log.error("resource_registry: atomic_rebuild failed for %s", self._path) + return before, before + # The swap invalidates any cached derived view: both inode and + # mtime change, so the next reader's ``_current_version`` will + # pick it up. Proactively invalidate here too, to avoid a stale + # hit until the throttled check fires. + with self._derived_lock: + self._derived_version = None + self._last_version = None + after = self._primary.get_stats().get("occupied", 0) + return before, after + + # ------------------------------------------------------------------ + # Derived views (rebuilt from primary scan) + # ------------------------------------------------------------------ + + def rids_by_type(self, resource_type): + """ + Return the list of resource IDs of ``resource_type`` across all + minions. + + :rtype: list[str] + """ + self._ensure_derived_fresh() + return list(self._by_type.get(resource_type, ())) + + def resource_types(self): + """ + Resource type names present in the derived primary view. + + :rtype: tuple[str, ...] + """ + self._ensure_derived_fresh() + return tuple(self._by_type.keys()) + + def resources_by_minion(self, minion_id): + """ + Return ``{resource_type: [resource_id, ...]}`` for one minion. + + :rtype: dict[str, list[str]] + """ + self._ensure_derived_fresh() + return { + rt: list(rids) for rt, rids in self._by_minion.get(minion_id, {}).items() + } + + def all_minions_by_type(self, resource_type): + """ + Return the set of minion IDs managing at least one resource of + ``resource_type``. + + :rtype: set[str] + """ + self._ensure_derived_fresh() + out = set() + for mid, by_rtype in self._by_minion.items(): + if resource_type in by_rtype: + out.add(mid) + return out + + # ------------------------------------------------------------------ + # Staleness / derived-rebuild plumbing + # ------------------------------------------------------------------ + + def _current_version(self): + """ + Return the current primary content version, stat()ing the file at + most once per ``STALENESS_CHECK_INTERVAL`` to keep hot read paths + cheap. + + ``content_version`` is ``(stat_tuple, write_generation)`` where + ``stat_tuple`` is ``(st_ino, st_mtime_ns, st_size)`` — sensitive + to atomic swaps (new inode) and most cross-process writes (new + mtime/size). ``write_generation`` is bumped on every local + ``put``/``delete``, covering the case where ``mmap.flush()`` does + not advance the backing file's mtime within stat() resolution. + + ``stat_tuple`` is ``None`` until the file exists. + """ + now = time.monotonic() + if ( + self._last_version is not None + and now - self._last_stat_time < STALENESS_CHECK_INTERVAL + ): + return self._last_version + try: + stat_tuple = self._primary._get_cache_id() + except OSError: + stat_tuple = None + version = (stat_tuple, self._write_generation) + self._last_version = version + self._last_stat_time = now + return version + + def _ensure_derived_fresh(self): + """ + Rebuild the derived ``by_type`` / ``by_minion`` views if the primary + has changed since the last rebuild. + + Freshness is driven by :meth:`_current_version`, which combines + the file ``stat()`` tuple (cross-process detection: new inode on + compaction, advancing mtime/size on most external writes) with a + local write generation counter (intra-process detection that does + not depend on ``mmap.flush()`` advancing file mtime). + + Uses double-checked locking so the common case is a single + throttled stat() comparison. + """ + current = self._current_version() + if current == self._derived_version: + return + with self._derived_lock: + if current == self._derived_version: + return + self._rebuild_derived(current) + + def _rebuild_derived(self, version): + """ + Walk every OCCUPIED slot in the primary, decode the value, and + rebuild the in-process derived dicts from scratch. O(N_slots). + + Callers must hold ``self._derived_lock``. + """ + t0 = time.perf_counter() + by_type: dict = {} + by_minion: dict = {} + + # list_items() tolerates a missing file (returns []); perfect for + # first use before any writes have happened. + for srn_key, raw in self._primary.list_items(): + decoded = _decode_by_id_value(raw) + if not decoded: + continue + mid = decoded.get("minion") + rtype = decoded.get("type") + if not mid or not rtype: + continue + # Trust the value's ``type`` field over the key split, but use + # the key to recover ``rid`` (so a stale value with a mutated + # ``t`` can't misplace entries in by_type). + _rtype_from_key, _, rid = srn_key.partition(":") + if not rid: + continue + by_type.setdefault(rtype, []).append(rid) + by_minion.setdefault(mid, {}).setdefault(rtype, []).append(rid) + + self._by_type = by_type + self._by_minion = by_minion + self._derived_version = version + elapsed = time.perf_counter() - t0 + if elapsed > DERIVED_REBUILD_BUDGET_SECONDS: + log.warning( + "resource_registry: derived-index rebuild took %.2fs " + "(budget %.2fs, %d types, %d minions)", + elapsed, + DERIVED_REBUILD_BUDGET_SECONDS, + len(by_type), + len(by_minion), + ) + else: + log.debug( + "resource_registry: derived-index rebuild %.3fs " + "(%d types, %d minions, version=%s)", + elapsed, + len(by_type), + len(by_minion), + version, + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +class ResourceRegistry: + """ + Master-side interface to the Salt Resource Registry. + + Instantiate with the Salt opts dict; the class opens its own + :class:`_ResourceIndexStore` on the master's cache directory. The + backing mmap file is created on first write. + + All read methods are O(1) or O(k) where *k* is the size of the answer + (e.g. number of rids of a type). Writes are O(r) where *r* is the + resource delta for one minion. Compaction is O(N log N) and runs in + the background; readers are not blocked. + """ + + def __init__(self, opts, store=None, cache=None): + """ + :param dict opts: The Salt opts dict. + :param _ResourceIndexStore store: Override the default store + (primarily for testing). + :param cache: Override the default ``salt.cache`` factory result + (primarily for testing). + """ + self._opts = opts + cache_dir = opts.get("cachedir") + if not cache_dir: + raise ValueError("ResourceRegistry requires opts['cachedir'] to be set") + index_path = os.path.join( + cache_dir, PRIMARY_INDEX_SUBDIR, PRIMARY_INDEX_FILENAME + ) + self._store = store or _ResourceIndexStore( + index_path, + capacity=opts.get( + "resource_index_primary_capacity", DEFAULT_PRIMARY_CAPACITY + ), + slot_size=opts.get("resource_index_primary_slot_size", DEFAULT_SLOT_SIZE), + ) + # ``salt.cache`` is the bank abstraction used for the topology blob + # (``resources`` bank) plus ``grains``/``pillar``. The registry + # only owns ``resources`` reads; the other banks are populated by + # the existing refresh paths. + self._cache = cache or salt.cache.factory(opts) + + # Automatic compaction policy (see :meth:`maybe_compact`). + self._compact_load_factor = float( + opts.get( + "resource_registry_compact_load_factor", + DEFAULT_COMPACT_LOAD_FACTOR, + ) + ) + self._compact_tombstone_ratio = float( + opts.get( + "resource_registry_compact_tombstone_ratio", + DEFAULT_COMPACT_TOMBSTONE_RATIO, + ) + ) + self._compact_min_interval = float( + opts.get( + "resource_registry_compact_min_interval", + DEFAULT_COMPACT_MIN_INTERVAL, + ) + ) + self._last_compact_check = 0.0 + + def close(self): + """ + Close the backing mmap so temp dirs can be removed and FDs are not + held until CPython GC (important for unit tests and registry resets). + """ + self._store.close() + + # ------------------------------------------------------------------ + # Read interface — used by the targeting layer + # ------------------------------------------------------------------ + + def get_resource(self, resource_id): + """ + Return the topology blob for a single resource from the ``resources`` + bank, or ``None`` if absent. + + :param str resource_id: Bare resource ID (e.g. ``"esxi-01"``). + :rtype: dict or None + """ + try: + return self._cache.fetch(RESOURCE_BANK, resource_id) + except Exception as exc: # pylint: disable=broad-except + log.warning( + "resource_registry: cache.fetch(%s, %s) failed: %s", + RESOURCE_BANK, + resource_id, + exc, + ) + return None + + def get_managing_minions_by_type(self, resource_type): + """ + Return the minion IDs managing at least one resource of + ``resource_type``. + + :rtype: dict (shape: ``{"minions": [...], "missing": []}``) + """ + minions = sorted(self._store.all_minions_by_type(resource_type)) + return {"minions": minions, "missing": []} + + def get_managing_minions_for_srn(self, resource_type, resource_id): + """ + Return the list of minion IDs that manage the resource identified + by the SRN ``(resource_type, resource_id)``, or an empty list. + + Preferred over :meth:`get_managing_minions_for_id` — composite + keys avoid cross-type collisions. + + :rtype: list[str] + """ + srn = resource_index_srn_key(resource_type, resource_id) + row = self._store.get(srn) + if not row: + return [] + mid = row.get("minion") + return [mid] if mid else [] + + def get_managing_minions_for_id(self, resource_id): + """ + Legacy: return managing minions for a bare resource ID by consulting + the topology blob in the ``resources`` cache bank. Ambiguous across + types — two resources of different types with the same bare id will + collide. + + Callers should migrate to :meth:`get_managing_minions_for_srn`. + + :rtype: list[str] + """ + blob = self.get_resource(resource_id) + if not blob: + return [] + managing = blob.get("managing_minions") + if not managing: + return [] + return list(managing) + + def get_resources_for_minion(self, minion_id): + """ + Return ``{resource_type: [resource_id, ...]}`` for ``minion_id``. + + :rtype: dict[str, list[str]] + """ + return self._store.resources_by_minion(minion_id) + + def has_resource_type(self, minion_id, resource_type): + """ + Return ``True`` if ``minion_id`` manages any resource of + ``resource_type``. + """ + return resource_type in self._store.resources_by_minion(minion_id) + + def has_resource(self, minion_id, resource_type, resource_id): + """ + Return ``True`` if ``minion_id`` manages the resource identified by + ``(resource_type, resource_id)``. + """ + srn = resource_index_srn_key(resource_type, resource_id) + row = self._store.get(srn) + return bool(row) and row.get("minion") == minion_id + + def has_srn(self, resource_type, resource_id): + """ + Return ``True`` if any minion currently manages this SRN. + + :rtype: bool + """ + srn = resource_index_srn_key(resource_type, resource_id) + return self._store.get(srn) is not None + + def resolve_bare_resource_id(self, resource_id): + """ + Return every ``(resource_type, resource_id)`` pair registered under the + bare id ``resource_id``. + + Used for list / exact-glob targeting (``salt -L web-01``) without a + ``T@type:`` prefix. Collisions across types return multiple pairs. + + :rtype: list[tuple[str, str]] + """ + if not resource_id or not isinstance(resource_id, str): + return [] + out = [] + try: + for rtype in self._store.resource_types(): + if resource_id in self._store.rids_by_type(rtype): + out.append((rtype, resource_id)) + except Exception as exc: # pylint: disable=broad-except + log.warning( + "resource_registry: resolve_bare_resource_id(%r) failed: %s", + resource_id, + exc, + ) + return out + + def get_resource_ids_by_type(self, resource_type): + """ + Return all resource IDs of ``resource_type`` across all managing + minions. + + :rtype: list[str] + """ + return self._store.rids_by_type(resource_type) + + # ------------------------------------------------------------------ + # Write interface — used by AESFuncs._register_resources and refresh + # ------------------------------------------------------------------ + + def register_minion(self, minion_id, resources): + """ + Register the full set of resources managed by ``minion_id``, + replacing any prior set. Per-minion delta is computed internally so + only changed SRN keys are written to the primary. + + :param str minion_id: The reporting minion. + :param dict resources: ``{resource_type: [resource_id, ...]}`` + representing the minion's current resource inventory. + :returns: ``(n_put, n_deleted)``. + """ + previous = self._store.resources_by_minion(minion_id) + + new_keys = set() + to_put = [] + for rtype, rids in (resources or {}).items(): + for rid in rids or (): + srn = resource_index_srn_key(rtype, rid) + new_keys.add(srn) + to_put.append((srn, minion_id, rtype)) + + to_delete = [] + for rtype, rids in previous.items(): + for rid in rids: + srn = resource_index_srn_key(rtype, rid) + if srn not in new_keys: + to_delete.append(srn) + + # Order matters only for correctness under concurrent reads: puts + # first so that a resource moving from one type to another (rare) + # is never transiently missing. + self._store.put_many(to_put) + self._store.delete_many(to_delete) + + # Opportunistic compaction on the write path. Time-throttled so + # the O(num_slots) ``get_stats`` call doesn't run more than once + # per :data:`DEFAULT_COMPACT_MIN_INTERVAL` seconds. + self.maybe_compact() + + return len(to_put), len(to_delete) + + def unregister_minion(self, minion_id): + """ + Tombstone every primary entry that maps to ``minion_id``. Used when + a minion is forcibly removed / decommissioned. + + :returns: Number of entries tombstoned. + """ + previous = self._store.resources_by_minion(minion_id) + to_delete = [ + resource_index_srn_key(rtype, rid) + for rtype, rids in previous.items() + for rid in rids + ] + self._store.delete_many(to_delete) + return len(to_delete) + + # ------------------------------------------------------------------ + # Maintenance + # ------------------------------------------------------------------ + + def compact(self): + """ + Force an out-of-band compaction of the primary mmap index. + + Safe to call concurrently with reads: readers continue to see the + pre-swap file until they next check staleness (which is rate + limited by :data:`STALENESS_CHECK_INTERVAL`). + + For automatic policy-driven compaction, prefer :meth:`maybe_compact`. + + :returns: ``(occupied_before, occupied_after)``. + """ + self._last_compact_check = time.monotonic() + return self._store.compact() + + def maybe_compact(self, force_check=False): + """ + Compact the primary if policy thresholds are exceeded. + + Thresholds (all configurable via opts, defaults in constants): + + * Load factor = ``(occupied + deleted) / total > compact_load_factor`` + (default 0.6). Prevents linear probing from degrading. + * Tombstone ratio = ``deleted / occupied > compact_tombstone_ratio`` + (default 0.2). Reclaims dead slots. + + Time-throttled to at most one stats read per + :data:`DEFAULT_COMPACT_MIN_INTERVAL` seconds, because + :meth:`MmapCache.get_stats` is O(num_slots). Pass + ``force_check=True`` to bypass the throttle (used by operator- + initiated runners). + + :returns: ``(compacted, stats_dict)``. ``compacted`` is ``True`` if + a compaction was triggered, ``False`` if thresholds were not + exceeded or the throttle deferred the check. + """ + now = time.monotonic() + if ( + not force_check + and (now - self._last_compact_check) < self._compact_min_interval + ): + return False, None + + self._last_compact_check = now + try: + stats = self._store._primary.get_stats() + except Exception: # pylint: disable=broad-except + log.error( + "resource_registry: maybe_compact stats read failed", + exc_info=True, + ) + return False, None + + occupied = stats.get("occupied", 0) + deleted = stats.get("deleted", 0) + total = stats.get("total", 0) or 1 + load_factor = stats.get("load_factor", (occupied + deleted) / total) + tombstone_ratio = (deleted / occupied) if occupied else 0.0 + + need = ( + load_factor > self._compact_load_factor + or tombstone_ratio > self._compact_tombstone_ratio + ) + if not need: + return False, stats + + log.info( + "resource_registry: auto-compact triggered " + "(load_factor=%.3f, tombstone_ratio=%.3f, occupied=%d, deleted=%d)", + load_factor, + tombstone_ratio, + occupied, + deleted, + ) + before, after = self._store.compact() + log.info( + "resource_registry: auto-compact finished (occupied %d -> %d)", + before, + after, + ) + return True, stats + + def stats(self): + """ + Return diagnostic counters for the primary mmap and the derived + views. Useful for the runner and for deciding when to compact. + + :rtype: dict + """ + primary = self._store._primary.get_stats() + return { + "primary": primary, + "derived_version": self._store._derived_version, + "derived_by_type_count": len(self._store._by_type), + "derived_by_minion_count": len(self._store._by_minion), + "path": self._store._path, + } + + +# --------------------------------------------------------------------------- +# Process-wide singleton +# --------------------------------------------------------------------------- +# +# All code paths on the master that touch the resource registry must share a +# single :class:`ResourceRegistry` instance so the derived-view cache, the +# throttled-staleness plumbing, and the mmap file handle are reused. The +# registry is inexpensive to construct but each fresh instance pays a derived +# rebuild on first read — re-instantiating per request would defeat the O(1) +# read path. + + +class _NullResourceRegistry: + """ + Read-only stand-in when ``opts['cachedir']`` is unset (minimal master + contexts, some unit tests). :class:`CkMinions` always wires the registry; + resource targeting then behaves as if no resources were registered. + """ + + def has_srn(self, resource_type, resource_id): + return False + + def resolve_bare_resource_id(self, resource_id): + return [] + + def get_resource_ids_by_type(self, resource_type): + return [] + + def get_resources_for_minion(self, minion_id): + return {} + + def register_minion(self, minion_id, resources): + return (0, 0) + + def stats(self): + return { + "primary": { + "occupied": 0, + "deleted": 0, + "total": 0, + "load_factor": 0.0, + }, + "derived_version": (0, 0), + "derived_by_type_count": 0, + "derived_by_minion_count": 0, + "path": None, + } + + def compact(self): + return (0, 0) + + def maybe_compact(self, force_check=False): + return False, None + + def close(self): + """No-op: null registry holds no mmap or cache handles.""" + + +_REGISTRY_LOCK = threading.Lock() +_REGISTRY_SINGLETON = None +_REGISTRY_CACHEDIR = None +_NULL_REGISTRY_SINGLETON = None + + +def get_registry(opts): + """ + Return the process-wide :class:`ResourceRegistry` singleton for ``opts``. + + The first caller in a process instantiates the registry; subsequent + callers get the same instance. If ``opts['cachedir']`` changes between + calls (primarily a testing concern) the singleton is rebuilt. + + When ``cachedir`` is missing, returns a shared :class:`_NullResourceRegistry` + so callers like :class:`~salt.utils.minions.CkMinions` can construct + without a master cache directory (e.g. pillar unit tests). + + :param dict opts: Salt opts dict. Normally includes ``cachedir``. + :rtype: ResourceRegistry or _NullResourceRegistry + """ + global _REGISTRY_SINGLETON, _REGISTRY_CACHEDIR, _NULL_REGISTRY_SINGLETON # pylint: disable=global-statement + cachedir = opts.get("cachedir") + if not cachedir: + if _NULL_REGISTRY_SINGLETON is None: + with _REGISTRY_LOCK: + if _NULL_REGISTRY_SINGLETON is None: + _NULL_REGISTRY_SINGLETON = _NullResourceRegistry() + return _NULL_REGISTRY_SINGLETON + if _REGISTRY_SINGLETON is not None and _REGISTRY_CACHEDIR == cachedir: + return _REGISTRY_SINGLETON + with _REGISTRY_LOCK: + if _REGISTRY_SINGLETON is not None and _REGISTRY_CACHEDIR == cachedir: + return _REGISTRY_SINGLETON + old = _REGISTRY_SINGLETON + if old is not None: + try: + old.close() + except Exception: # pylint: disable=broad-except + log.debug( + "resource_registry: error closing previous singleton", + exc_info=True, + ) + _REGISTRY_SINGLETON = ResourceRegistry(opts) + _REGISTRY_CACHEDIR = cachedir + return _REGISTRY_SINGLETON + + +def reset_registry(): + """ + Drop the process-wide singleton. Used by tests that need a fresh + registry per ``opts['cachedir']`` without relying on tmp_path varying. + + Always closes the previous registry's mmap so handles and disk space + are released promptly. + """ + global _REGISTRY_SINGLETON, _REGISTRY_CACHEDIR # pylint: disable=global-statement + with _REGISTRY_LOCK: + old = _REGISTRY_SINGLETON + _REGISTRY_SINGLETON = None + _REGISTRY_CACHEDIR = None + if old is not None: + try: + old.close() + except Exception: # pylint: disable=broad-except + log.debug( + "resource_registry: error closing singleton on reset", + exc_info=True, + ) diff --git a/salt/utils/resources.py b/salt/utils/resources.py new file mode 100644 index 000000000000..15e1f3d0668e --- /dev/null +++ b/salt/utils/resources.py @@ -0,0 +1,129 @@ +""" +Helpers for Salt resource minions: configurable pillar key and lookups. +""" + +import logging + +log = logging.getLogger(__name__) + + +def resource_pillar_key(opts): + """ + Return the top-level pillar key used for per-type resource configuration. + + Configured by minion option ``resource_pillar_key`` (default ``resources``). + Empty values are rejected with a warning and treated as ``"resources"``. + """ + key = opts.get("resource_pillar_key", "resources") + if not key: + log.warning( + "resource_pillar_key is empty; using default 'resources'. " + "Set resource_pillar_key to a non-empty string in the minion config." + ) + key = "resources" + return key + + +def pillar_resources_tree(opts): + """ + Return the merged pillar mapping under the configured resource pillar key. + + If the key is absent, returns ``{}`` (same as an empty declaration). + Non-dict values are treated as empty. + """ + key = resource_pillar_key(opts) + pillar = opts.get("pillar", {}) + if key not in pillar: + return {} + pr = pillar.get(key) + return pr if isinstance(pr, dict) else {} + + +def bare_resource_ids_from_decl(decl): + """ + Flatten ``salt_resources`` / pillar ``resources`` subtree mappings to bare IDs. + + Supports ``{rtype: [id, ...]}`` (post-discovery) and + ``{rtype: {"resource_ids": [...]}}`` (pillar-shaped) layouts. + """ + if not isinstance(decl, dict): + return [] + out = [] + for val in decl.values(): + if isinstance(val, list): + out.extend(val) + elif isinstance(val, dict): + ids = val.get("resource_ids") + if isinstance(ids, list): + out.extend(ids) + return out + + +def bare_resource_id_in_minion_data_cache(opts, resource_id, cache=None): + """ + Return ``True`` if ``resource_id`` appears in any cached minion pillar / + grains snapshot on the master. + + Used when the mmap resource registry has not yet recorded the ID (or was + cleared) but :conf_master:`minion_data_cache` still holds pillar/grains + from the last sync — so ``salt m2-dummy2 state.apply`` / ``test.ping`` + style targeting can still resolve. + + :param cache: + Optional :class:`salt.cache.Cache` from the caller (e.g. ``CkMinions`` + already constructs one). Pillar entries may live under a separate + backend when :conf_master:`pillar.cache_driver` is set; that driver is + always used for ``bank="pillar"`` reads. Grains use ``cache`` (or a + newly created default cache handle when ``cache`` is omitted). + """ + if not opts.get("minion_data_cache") or not resource_id: + return False + try: + import salt.cache + + grains_cache = cache if cache is not None else salt.cache.factory(opts) + pillar_driver = opts.get("pillar.cache_driver") + pillar_cache = ( + salt.cache.factory(opts, driver=pillar_driver) + if pillar_driver + else grains_cache + ) + rk = resource_pillar_key(opts) + try: + p_keys = pillar_cache.list("pillar") + except Exception: # pylint: disable=broad-except + p_keys = [] + for mid in p_keys: + try: + data = pillar_cache.fetch("pillar", mid) or {} + except Exception: # pylint: disable=broad-except + continue + if not isinstance(data, dict): + continue + subtree = data.get(rk) + ids = bare_resource_ids_from_decl(subtree) + if resource_id in ids: + return True + + try: + g_keys = grains_cache.list("grains") + except Exception: # pylint: disable=broad-except + g_keys = [] + for mid in g_keys: + try: + data = grains_cache.fetch("grains", mid) or {} + except Exception: # pylint: disable=broad-except + continue + if not isinstance(data, dict): + continue + ids = bare_resource_ids_from_decl(data.get("salt_resources")) + if resource_id in ids: + return True + except Exception as exc: # pylint: disable=broad-except + log.debug( + "bare_resource_id_in_minion_data_cache(%r) failed: %s", + resource_id, + exc, + exc_info=True, + ) + return False diff --git a/tests/conftest.py b/tests/conftest.py index 43c6f74c37e2..1fa2c689eeba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -141,6 +141,7 @@ def _remove_redundant_salt_utils_vault_py() -> None: from tests.support.pytest.helpers import * # pylint: disable=unused-wildcard-import,wildcard-import from tests.support.runtests import RUNTIME_VARS from tests.support.sminion import check_required_sminion_attributes, create_sminion +from tests.support.sshd_runtime import ensure_sshd_privilege_separation_directories os.environ["REPO_ROOT_DIR"] = str(CODE_DIR) @@ -1573,6 +1574,7 @@ def sshd_server(salt_factories, sshd_config_dir, salt_master, grains): sshd_config_dict=sshd_config_dict, config_dir=sshd_config_dir, ) + ensure_sshd_privilege_separation_directories(factory.config_dir / "sshd_config") with factory.started(): yield factory diff --git a/tests/integration/client/test_standard.py b/tests/integration/client/test_standard.py index 2a4e0285608a..1e738c5a4548 100644 --- a/tests/integration/client/test_standard.py +++ b/tests/integration/client/test_standard.py @@ -55,11 +55,23 @@ def test_cli(self): "test.ping", timeout=20, ) - num_ret = 0 for ret in cmd_iter: - num_ret += 1 - self.assertTrue(ret["minion"]) - assert num_ret == 0 + # If no minions match, ``cmd_cli`` yields a falsy pub payload (typically + # ``{}``) once — do not treat that as a minion return (and do not index + # ``ret["minion"]``: the minion id is ``footest``, not ``minion``). + if not ret: + continue + for minion_id, data in ret.items(): + self.assertEqual( + minion_id, + "footest", + msg="cmd_cli iterator should only carry footest for this target", + ) + self.assertNotEqual( + data.get("ret"), + True, + msg="minion without a daemon must not return a successful ping", + ) finally: os.unlink(key_file) diff --git a/tests/pytests/functional/states/pkgrepo/test_debian.py b/tests/pytests/functional/states/pkgrepo/test_debian.py index 07655d020eb6..03ba6bee7aab 100644 --- a/tests/pytests/functional/states/pkgrepo/test_debian.py +++ b/tests/pytests/functional/states/pkgrepo/test_debian.py @@ -13,7 +13,9 @@ log = logging.getLogger(__name__) pytestmark = [ - pytest.mark.timeout_unless_on_windows(120), + # apt / keyring work on GitHub-hosted runners often exceeds 120s; align with + # other destructive pkg-related functional suites (e.g. test_pkg.py uses 240+). + pytest.mark.timeout_unless_on_windows(300), pytest.mark.destructive_test, pytest.mark.skip_if_not_root, pytest.mark.slow_test, @@ -44,7 +46,11 @@ def test_adding_repo_file(pkgrepo, repo_uri, tmp_path): repo_file = str(tmp_path / "stable-binary.list") repo_content = f"deb{signedby} {repo_uri} stable main" ret = pkgrepo.managed( - name=repo_content, file=repo_file, clean_file=True, aptkey=aptkey + name=repo_content, + file=repo_file, + clean_file=True, + aptkey=aptkey, + refresh=False, ) with salt.utils.files.fopen(repo_file, "r") as fp: file_content = fp.read().strip() @@ -63,13 +69,17 @@ def test_adding_repo_file_arch(pkgrepo, repo_uri, tmp_path, subtests): signedby = " signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg" repo_file = str(tmp_path / "stable-binary.list") repo_content = f"deb [arch=amd64{signedby} ] {repo_uri} stable main" - ret = pkgrepo.managed(name=repo_content, file=repo_file, clean_file=True) + ret = pkgrepo.managed( + name=repo_content, file=repo_file, clean_file=True, refresh=False + ) with salt.utils.files.fopen(repo_file, "r") as fp: file_content = fp.read().strip() assert file_content == f"deb [arch=amd64{signedby}] {repo_uri} stable main" with subtests.test("With multiple archs"): repo_content = f"deb [arch=amd64,i386{signedby} ] {repo_uri} stable main" - pkgrepo.managed(name=repo_content, file=repo_file, clean_file=True) + pkgrepo.managed( + name=repo_content, file=repo_file, clean_file=True, refresh=False + ) with salt.utils.files.fopen(repo_file, "r") as fp: file_content = fp.read().strip() assert ( @@ -91,7 +101,9 @@ def test_adding_repo_file_cdrom(pkgrepo, tmp_path): signedby = " [signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg]" repo_file = str(tmp_path / "cdrom.list") repo_content = f"deb{signedby} cdrom:[Debian GNU/Linux 11.4.0 _Bullseye_ - Official amd64 NETINST 20220709-10:31]/ stable main" - ret = pkgrepo.managed(name=repo_content, file=repo_file, clean_file=True) + ret = pkgrepo.managed( + name=repo_content, file=repo_file, clean_file=True, refresh=False + ) with salt.utils.files.fopen(repo_file, "r") as fp: file_content = fp.read().strip() assert ( diff --git a/tests/pytests/integration/cluster/conftest.py b/tests/pytests/integration/cluster/conftest.py index c6706a7cbe0b..c044620e9ebf 100644 --- a/tests/pytests/integration/cluster/conftest.py +++ b/tests/pytests/integration/cluster/conftest.py @@ -187,7 +187,7 @@ def cluster_master_1(request, salt_factories, cluster_pki_path, cluster_cache_pa overrides=config_overrides, extra_cli_arguments_after_first_start_failure=["--log-level=info"], ) - with factory.started(start_timeout=120): + with factory.started(start_timeout=240): yield factory @@ -236,7 +236,7 @@ def cluster_master_2(salt_factories, cluster_master_1): overrides=config_overrides, extra_cli_arguments_after_first_start_failure=["--log-level=info"], ) - with factory.started(start_timeout=120): + with factory.started(start_timeout=240): yield factory @@ -285,7 +285,7 @@ def cluster_master_3(salt_factories, cluster_master_1): overrides=config_overrides, extra_cli_arguments_after_first_start_failure=["--log-level=info"], ) - with factory.started(start_timeout=120): + with factory.started(start_timeout=240): yield factory @@ -347,7 +347,7 @@ def cluster_master_4( overrides=config_overrides, extra_cli_arguments_after_first_start_failure=["--log-level=info"], ) - with factory.started(start_timeout=120): + with factory.started(start_timeout=240): yield factory @@ -378,5 +378,5 @@ def cluster_minion_1(cluster_master_1): overrides=config_overrides, extra_cli_arguments_after_first_start_failure=["--log-level=info"], ) - with factory.started(start_timeout=120): + with factory.started(start_timeout=240): yield factory diff --git a/tests/pytests/integration/cluster/test_basic_cluster.py b/tests/pytests/integration/cluster/test_basic_cluster.py index 1879d1c84259..638704903fc5 100644 --- a/tests/pytests/integration/cluster/test_basic_cluster.py +++ b/tests/pytests/integration/cluster/test_basic_cluster.py @@ -2,8 +2,17 @@ Cluster integration tests. """ +import pytest + import salt.utils.event +# Cluster bring-up + minion auth + a publish round-trip routinely brushes +# against the 90 s default test timeout on slow CI runners (Photon ARM64 +# fips, Ubuntu ARM64). Cluster tests pay the cost of Raft elections plus +# multi-master peer key exchange before any test work can run; give them +# headroom rather than masking the symptom by skipping. +pytestmark = [pytest.mark.timeout(240)] + def test_basic_cluster_setup( cluster_master_1, cluster_master_2, cluster_pki_path, cluster_cache_path diff --git a/tests/pytests/integration/cluster/test_raft_cluster.py b/tests/pytests/integration/cluster/test_raft_cluster.py index 118b232781fc..b4866799a067 100644 --- a/tests/pytests/integration/cluster/test_raft_cluster.py +++ b/tests/pytests/integration/cluster/test_raft_cluster.py @@ -24,7 +24,10 @@ ] # How long to wait for an election to complete across real processes. -_ELECTION_TIMEOUT = 30 # seconds +# Bumped from 30 → 60 because Photon ARM64 fips and Debian ARM64 runners +# regularly need more than 30 s for the Raft handshake to converge under +# load, producing flaky CI failures otherwise. +_ELECTION_TIMEOUT = 60 # seconds # --------------------------------------------------------------------------- @@ -146,6 +149,8 @@ def test_raft_service_started_on_all_masters( ) +@pytest.mark.timeout(240) +@pytest.mark.flaky(max_runs=3) def test_raft_re_election_after_leader_restart( cluster_master_1, cluster_master_2, cluster_master_3 ): diff --git a/tests/pytests/integration/modules/saltutil/test_modules.py b/tests/pytests/integration/modules/saltutil/test_modules.py index d35cb735f2e0..cba8eec6d9c1 100644 --- a/tests/pytests/integration/modules/saltutil/test_modules.py +++ b/tests/pytests/integration/modules/saltutil/test_modules.py @@ -48,6 +48,7 @@ def test_sync_all(salt_call_cli): "renderers": [], "log_handlers": [], "matchers": [], + "resources": [], "states": [], "sdb": [], "proxymodules": [], @@ -78,6 +79,7 @@ def test_sync_all_whitelist(salt_call_cli): "renderers": [], "log_handlers": [], "matchers": [], + "resources": [], "states": [], "sdb": [], "proxymodules": [], @@ -114,6 +116,7 @@ def test_sync_all_blacklist(salt_call_cli): "renderers": [], "log_handlers": [], "matchers": [], + "resources": [], "states": [], "sdb": [], "proxymodules": [], @@ -154,6 +157,7 @@ def test_sync_all_blacklist_and_whitelist(salt_call_cli): "renderers": [], "log_handlers": [], "matchers": [], + "resources": [], "states": [], "sdb": [], "proxymodules": [], diff --git a/tests/pytests/integration/modules/saltutil/test_wheel.py b/tests/pytests/integration/modules/saltutil/test_wheel.py index d0052fd78a5e..4f8e535919e0 100644 --- a/tests/pytests/integration/modules/saltutil/test_wheel.py +++ b/tests/pytests/integration/modules/saltutil/test_wheel.py @@ -47,6 +47,18 @@ def test_wheel_just_function(salt_call_cli, salt_minion, salt_sub_minion): """ Tests using the saltutil.wheel function when passing only a function. """ + # ``wheel.minions.connected`` / ``CkMinions.connected_ids`` match cached grains + # to TCP peers on the publish port; the module-scoped sub-minion can be slow to + # land in the grains cache on Windows CI. Warm grains on each minion via + # ``salt-call`` — ``saltutil.refresh_grains`` does not accept ``wait=`` (the ``salt`` + # CLI passes that through and the execution module rejects it). + for factory in (salt_minion, salt_sub_minion): + ret_grains = factory.salt_call_cli().run( + "saltutil.refresh_grains", refresh_pillar=False + ) + assert ret_grains.returncode == 0, ret_grains + assert ret_grains.data is True + # This test is flaky in CI, retry a few times import time diff --git a/tests/pytests/integration/modules/test_mac_sysctl.py b/tests/pytests/integration/modules/test_mac_sysctl.py index a71a96f85d6b..ec7169ef1c8a 100644 --- a/tests/pytests/integration/modules/test_mac_sysctl.py +++ b/tests/pytests/integration/modules/test_mac_sysctl.py @@ -104,7 +104,9 @@ def test_persist_new_file(salt_call_cli, assign_cmd, config_file): raise -def test_persist_already_set(salt_call_cli, config_file, setup_teardown_vars): +def test_persist_already_set( + salt_call_cli, assign_cmd, config_file, setup_teardown_vars +): """ Tests assigning a sysctl value that is already set in sysctl.conf file """ diff --git a/tests/pytests/integration/resources/__init__.py b/tests/pytests/integration/resources/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/pytests/integration/resources/conftest.py b/tests/pytests/integration/resources/conftest.py new file mode 100644 index 000000000000..6a5ba6928c76 --- /dev/null +++ b/tests/pytests/integration/resources/conftest.py @@ -0,0 +1,173 @@ +""" +Integration test fixtures for Salt Resources. + +Spins up a master and a minion whose dummy resources (dummy-01 … dummy-03) are +declared only in Pillar under ``resources:`` — not in the minion config file. +All tests in this package run against these two daemons. +""" + +import textwrap +import time + +import pytest + +from tests.conftest import FIPS_TESTRUN + +MINION_ID = "resources-minion" +MINION_ID_2 = "resources-minion-2" + +# Dummy resource IDs that the minion manages in every test in this package. +DUMMY_RESOURCES = ["dummy-01", "dummy-02", "dummy-03"] +# Disjoint resource set for the optional second minion. Tests that don't +# request ``salt_minion_2`` never see these. +DUMMY_RESOURCES_2 = ["dummy-04", "dummy-05"] + + +@pytest.fixture(scope="package") +def pillar_tree_dummy_resources(salt_master): + """ + Pillar declaring ``resources.dummy.resource_ids`` for both potential + test minions. The top file maps ``MINION_ID`` and (optionally) + ``MINION_ID_2``; minion 2's pillar entries are inert if + ``salt_minion_2`` is not requested by a test. + + Resource discovery reads this tree via ``pillar_resources_tree``; the + minion must not rely on a static ``resources:`` key in minion opts. + """ + top_file = textwrap.dedent( + f""" + base: + '{MINION_ID}': + - dummy_resources + '{MINION_ID_2}': + - dummy_resources_2 + """ + ) + pillar_sls = textwrap.dedent( + """ + resources: + dummy: + resource_ids: + - dummy-01 + - dummy-02 + - dummy-03 + """ + ) + pillar_sls_2 = textwrap.dedent( + """ + resources: + dummy: + resource_ids: + - dummy-04 + - dummy-05 + """ + ) + top_tempfile = salt_master.pillar_tree.base.temp_file("top.sls", top_file) + sls_tempfile = salt_master.pillar_tree.base.temp_file( + "dummy_resources.sls", pillar_sls + ) + sls_tempfile_2 = salt_master.pillar_tree.base.temp_file( + "dummy_resources_2.sls", pillar_sls_2 + ) + with top_tempfile, sls_tempfile, sls_tempfile_2: + yield + + +@pytest.fixture(scope="package") +def salt_master(request, salt_factories): + config_overrides = { + "interface": "127.0.0.1", + "transport": request.config.getoption("--transport"), + "fips_mode": FIPS_TESTRUN, + "publish_signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + } + factory = salt_factories.salt_master_daemon( + "resources-master", + overrides=config_overrides, + extra_cli_arguments_after_first_start_failure=["--log-level=info"], + ) + with factory.started(start_timeout=120): + yield factory + + +@pytest.fixture(scope="package") +def salt_minion(salt_master, pillar_tree_dummy_resources): + config_overrides = { + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1", + # Use threads (not processes) — this is the path our Race 1/Race 2 fixes + # target and the most common deployment mode for resource-managing minions. + "multiprocessing": False, + } + factory = salt_master.salt_minion_daemon( + MINION_ID, + overrides=config_overrides, + extra_cli_arguments_after_first_start_failure=["--log-level=info"], + ) + factory.after_terminate( + pytest.helpers.remove_stale_minion_key, salt_master, factory.id + ) + with factory.started(start_timeout=120): + salt_call_cli = factory.salt_call_cli() + ret = salt_call_cli.run("saltutil.refresh_pillar", wait=True, _timeout=120) + assert ret.returncode == 0, ret + assert ret.data is True, ret + ret = salt_call_cli.run("saltutil.sync_all", _timeout=120) + assert ret.returncode == 0, ret + # The minion fires _register_resources_with_master() as a background + # task on connect. Waiting briefly ensures the master cache is + # populated before tests run (typically completes in < 1 s, but the + # sync_all above already takes several seconds so this is a safety net). + time.sleep(3) + yield factory + + +@pytest.fixture(scope="package") +def salt_cli(salt_master): + assert salt_master.is_running() + return salt_master.salt_cli(timeout=60) + + +@pytest.fixture(scope="package") +def salt_call_cli(salt_minion): + assert salt_minion.is_running() + return salt_minion.salt_call_cli(timeout=60) + + +@pytest.fixture(scope="module") +def salt_minion_2(salt_master, pillar_tree_dummy_resources): + """ + Optional second minion managing :data:`DUMMY_RESOURCES_2`. + + Module-scoped so single-minion tests don't pay the bring-up cost. + Tests requesting this fixture get a minion whose pillar maps + ``dummy-04`` / ``dummy-05`` — disjoint from the package's primary + minion — so multi-minion master state can be exercised end-to-end. + """ + config_overrides = { + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1", + "multiprocessing": False, + } + factory = salt_master.salt_minion_daemon( + MINION_ID_2, + overrides=config_overrides, + extra_cli_arguments_after_first_start_failure=["--log-level=info"], + ) + factory.after_terminate( + pytest.helpers.remove_stale_minion_key, salt_master, factory.id + ) + with factory.started(start_timeout=240): + salt_call_cli = factory.salt_call_cli() + ret = salt_call_cli.run("saltutil.refresh_pillar", wait=True, _timeout=120) + assert ret.returncode == 0, ret + ret = salt_call_cli.run("saltutil.sync_all", _timeout=120) + assert ret.returncode == 0, ret + # Same wait as ``salt_minion``: lets the background + # ``_register_resources_with_master`` task land before tests run. + time.sleep(3) + yield factory diff --git a/tests/pytests/integration/resources/test_cli_offline_expands_resource_targets.py b/tests/pytests/integration/resources/test_cli_offline_expands_resource_targets.py new file mode 100644 index 000000000000..1e2d48d9b8a2 --- /dev/null +++ b/tests/pytests/integration/resources/test_cli_offline_expands_resource_targets.py @@ -0,0 +1,221 @@ +""" +Scenario: ``salt '*'`` against an offline managing minion must report that minion +*and* each configured resource id (from cached pillar), not only the minion row. + +This guards :func:`salt.client._iter_failed_missing_returns` pillar-cache expansion +used when the mmap registry no longer lists the minion. +""" + +import os +import textwrap +import time +import types + +import pytest + +import salt.cache +import salt.defaults.exitcodes +import salt.utils.files +from tests.conftest import FIPS_TESTRUN + +pytestmark = [pytest.mark.slow_test] + +_OFFLINE_MINION_ID = "offline-res-cli-minion" +_OFFLINE_DUMMY_IDS = ("off-cli-r1", "off-cli-r2", "off-cli-r3") + + +def _skip_if_cli_script_wrong_tree(salt_cli, master): + """ + ``saltfactories`` writes ``cli_salt.py`` under a shared ``/tmp/stsuite/scripts``. + If another worktree ran first, ``CODE_DIR`` can point at the wrong tree and + this scenario would exercise stock Salt instead of this checkout. + """ + try: + script_path = salt_cli.get_script_path() + except (AttributeError, FileNotFoundError, OSError): + return + try: + with salt.utils.files.fopen(script_path, "r", encoding="utf-8") as fh: + head = fh.read(1200) + except OSError: + return + code_dir = str(master.factories_manager.code_dir) + if code_dir not in head: + pytest.skip( + "Generated cli_salt.py does not embed this worktree's code_dir " + f"({code_dir!r} not found in {script_path!r}). Remove or regenerate " + "/tmp/stsuite/scripts (or run pytest with a fresh tmp root) so the " + "salt CLI subprocess loads the same sources as this test process." + ) + + +def _assert_target_reported_missing(data, key): + assert key in data, f"Expected {key!r} in CLI data keys {sorted(data)!r}" + val = data[key] + if val is True: + pytest.fail(f"Expected {key!r} to be missing/offline, got True") + if isinstance(val, str): + low = val.lower() + assert "did not return" in low or "no response" in low, val + elif isinstance(val, dict): + out = val.get("out") + if out == "no_return": + return + ret = val.get("ret", "") + if isinstance(ret, str) and "did not return" in ret.lower(): + return + pytest.fail(f"Unexpected dict shape for {key!r}: {val!r}") + else: + # e.g. False from some formatters + pass + + +@pytest.fixture(scope="module") +def offline_cli_stack(request, salt_factories): + """ + Dedicated master (``minion_data_cache: true``) + one minion with three dummy + resource ids in pillar only. + """ + config_overrides = { + "interface": "127.0.0.1", + "transport": request.config.getoption("--transport"), + "fips_mode": FIPS_TESTRUN, + "publish_signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + # Required so ``_resource_ids_from_minion_pillar_cache`` can resolve + # resource ids after the minion process is gone. + "minion_data_cache": True, + } + master = salt_factories.salt_master_daemon( + "offline-cli-exp-master", + overrides=config_overrides, + extra_cli_arguments_after_first_start_failure=["--log-level=info"], + ) + top = textwrap.dedent( + f""" + base: + '{_OFFLINE_MINION_ID}': + - offline_dummy_resources + """ + ) + pillar_sls = textwrap.dedent( + f""" + resources: + dummy: + resource_ids: + - {_OFFLINE_DUMMY_IDS[0]} + - {_OFFLINE_DUMMY_IDS[1]} + - {_OFFLINE_DUMMY_IDS[2]} + """ + ) + with master.started(start_timeout=120): + top_tf = master.pillar_tree.base.temp_file("top.sls", top) + sls_tf = master.pillar_tree.base.temp_file( + "offline_dummy_resources.sls", pillar_sls + ) + with top_tf, sls_tf: + minion = master.salt_minion_daemon( + _OFFLINE_MINION_ID, + overrides={ + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": ( + "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1" + ), + "signing_algorithm": ( + "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1" + ), + "multiprocessing": False, + }, + extra_cli_arguments_after_first_start_failure=["--log-level=info"], + ) + # Do not register remove_stale_minion_key on after_terminate: that runs + # in the same turn as minion.terminate() and would delete the accepted + # key before the offline ``salt '*'`` assertion (master would match zero + # minions). Remove the key after the yield teardown instead. + with minion.started(start_timeout=120): + call = minion.salt_call_cli() + ret = call.run("saltutil.refresh_pillar", wait=True, _timeout=120) + assert ret.returncode == 0, ret + ret = call.run("saltutil.sync_all", _timeout=120) + assert ret.returncode == 0, ret + # refresh_pillar uses AsyncPillar and does not write the master's + # minion pillar cache (PillarCache.store). pillar.items forces a + # sync compile on the master so offline expansion can read + # ``resources`` via ``_resource_ids_from_minion_pillar_cache``. + ret = call.run("pillar.items", _timeout=120) + assert ret.returncode == 0, ret + pdata = ret.data + if isinstance(pdata, dict) and _OFFLINE_MINION_ID in pdata: + inner = pdata.get(_OFFLINE_MINION_ID) + if isinstance(inner, dict): + pdata = inner + assert isinstance(pdata, dict), ret + assert "resources" in pdata, list(pdata.keys()) + # Ensure the master's minion pillar bank matches what the LocalClient + # reads during offline expansion (normalize cachedir like the salt CLI). + _cache_opts = dict(master.config) + _cache_opts["cachedir"] = os.path.abspath(_cache_opts["cachedir"]) + salt.cache.factory(_cache_opts).store( + "pillar", _OFFLINE_MINION_ID, pdata + ) + time.sleep(3) + + # Prime master cache (grains + pillar) while minion is alive. + ret = master.salt_cli(timeout=90).run( + "test.ping", minion_tgt=_OFFLINE_MINION_ID, _timeout=120 + ) + assert ret.returncode == 0, ret + + salt_cli = master.salt_cli(timeout=90) + yield types.SimpleNamespace( + master=master, + minion=minion, + salt_cli=salt_cli, + ) + pytest.helpers.remove_stale_minion_key(master, minion.id) + + +def test_wildcard_ping_then_offline_lists_minion_and_each_resource( + offline_cli_stack, +): + stack = offline_cli_stack + salt_cli = stack.salt_cli + minion = stack.minion + _skip_if_cli_script_wrong_tree(salt_cli, stack.master) + + # --- While minion is up: prove connectivity (glob may omit resource rows + # until the registry is warm; pillar + minion_data_cache are what matter + # for the offline expansion path). + ret0 = salt_cli.run("test.ping", minion_tgt=minion.id, _timeout=120) + assert ret0.returncode == 0, ret0 + assert ret0.data is True, ret0 + + ret = salt_cli.run( + "--timeout=25", + "test.ping", + minion_tgt="*", + _timeout=120, + ) + assert ret.returncode == 0, ret + data = ret.data + assert isinstance(data, dict), ret + assert minion.id in data, list(data) + assert data[minion.id] is True + + # --- Stop minion: same glob must surface minion + each resource as missing. + minion.terminate() + + ret2 = salt_cli.run( + "--timeout=25", + "test.ping", + minion_tgt="*", + _timeout=120, + ) + assert ret2.returncode == salt.defaults.exitcodes.EX_GENERIC, ret2 + data2 = ret2.data + assert isinstance(data2, dict), ret2 + + _assert_target_reported_missing(data2, minion.id) + for rid in _OFFLINE_DUMMY_IDS: + _assert_target_reported_missing(data2, rid) diff --git a/tests/pytests/integration/resources/test_dummy_resource.py b/tests/pytests/integration/resources/test_dummy_resource.py new file mode 100644 index 000000000000..8390ebba67bf --- /dev/null +++ b/tests/pytests/integration/resources/test_dummy_resource.py @@ -0,0 +1,347 @@ +""" +End-to-end integration tests for Salt Resources using the dummy resource type. + +These tests verify the full dispatch pipeline: + + salt CLI → master targeting (CkMinions) → minion (_resolve_resource_targets) + → resource loader → return → master re-key → CLI response + +The minion under test loads dummy resources from Pillar only (``resources.dummy`` +with ``resource_ids``) and uses ``multiprocessing: False``. + +The ``dummy`` resource module (``salt/resource/dummy.py``) and its execution +module (``salt/modules/dummyresource_test.py``) are pure-Python in-process +implementations that require no external services. + +Targeting forms exercised here: + +* **Glob wildcard** — ``salt '*' …`` or a pattern such as ``salt 'resources*' …`` + that matches the managing minion (minion + every managed resource). +* **Glob exact resource id** — ``salt '' …`` (no ``-L``, no wildcards). +* **List** — ``salt -L '' …`` (bare resource id). +* **Compound full SRN** — ``salt -C 'T@dummy:' …``. +* **Compound bare type** — ``salt -C 'T@dummy' …`` (all resources of that type). +""" + +import json + +import pytest + +from tests.pytests.integration.resources.conftest import DUMMY_RESOURCES + +pytestmark = [pytest.mark.slow_test] + + +def _salt_cli_json_dict(ret): + """ + Parse the salt CLI JSON object from ``ret``. + + Salt-factories unwraps single-key output when ``minion_tgt`` equals that + key (e.g. list / exact-glob targeting), in which case ``ret.data`` is not + a dict. + """ + if isinstance(ret.data, dict): + return ret.data + return json.loads(ret.stdout.strip()) + + +def test_minion_has_resources_configured(salt_minion, salt_call_cli): + """Sanity check: the minion must report its resource config before other tests run.""" + ret = salt_call_cli.run("config.get", "resources") + assert ret.returncode == 0, ret + data = ret.data + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + assert "dummy" in data, f"'dummy' missing from config.get resources: {data}" + dummy = data["dummy"] + # Pillar may surface as ``{"resource_ids": [...]}`` or a bare list of ids + # depending on merge/render path. + if isinstance(dummy, dict): + assert ( + "resource_ids" in dummy + ), f"Missing resource_ids under resources.dummy: {dummy!r}" + ids = dummy["resource_ids"] + else: + ids = dummy + assert isinstance( + ids, (list, tuple) + ), f"Unexpected resources.dummy shape: {dummy!r}" + assert set(ids) == set(DUMMY_RESOURCES), f"Unexpected resource IDs: {ids!r}" + + +def test_glob_wildcard_returns_minion_and_resources(salt_minion, salt_cli): + """ + ``salt '*' test.ping`` must return ``True`` for the managing minion *and* + for every resource it manages. + + This exercises the full pipeline: + - Master ``_augment_with_resources`` adds every dummy resource id to the + expected-minion set so the response window stays open. + - Minion ``_resolve_resource_targets`` dispatches two resource jobs. + - Each resource job returns via ``_thread_return`` with ``resource_id``. + - Master ``_return`` remaps ``resource_id`` → ``id`` before delivering. + """ + ret = salt_cli.run("test.ping", minion_tgt="*") + assert ret.returncode == 0, ret + + data = ret.data + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + + # The managing minion must respond. + assert ( + salt_minion.id in data + ), f"Managing minion '{salt_minion.id}' not in response: {list(data)}" + assert data[salt_minion.id] is True + + # Every configured resource must also respond. + for rid in DUMMY_RESOURCES: + assert rid in data, f"Resource '{rid}' missing from response: {list(data)}" + assert data[rid] is True, f"Resource '{rid}' returned non-True: {data[rid]}" + + +def test_glob_wildcard_minion_pattern_includes_resources(salt_minion, salt_cli): + """ + A glob with wildcards that matches only the managing minion must still + opt in to resource dispatch (same augmentation path as ``salt '*'``). + """ + ret = salt_cli.run("test.ping", minion_tgt="resources*") + assert ret.returncode == 0, ret + data = _salt_cli_json_dict(ret) + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + assert salt_minion.id in data, f"Minion missing from glob response: {list(data)}" + assert data[salt_minion.id] is True + for rid in DUMMY_RESOURCES: + assert rid in data, f"Resource {rid!r} missing: {list(data)}" + assert data[rid] is True + + +def test_T_at_full_srn_returns_only_that_resource(salt_minion, salt_cli): + """ + ``salt -C 'T@dummy:dummy-01' test.ping`` must return a response keyed to + ``dummy-01`` only — not to the managing minion or to dummy-02/dummy-03. + + This exercises the compound-match targeting path: + - Master ``_check_resource_minions`` resolves ``T@dummy:dummy-01`` to the + single resource ID ``dummy-01`` and the managing minion as the delivery + target. + - Minion ``_resolve_resource_targets`` (compound path) dispatches only to + dummy-01 because the T@ term matches exactly one resource. + """ + ret = salt_cli.run("-C", "test.ping", minion_tgt="T@dummy:dummy-01") + assert ret.returncode == 0, ret + + data = ret.data + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + assert data == { + "dummy-01": True + }, f"Expected only dummy-01 in response, got: {data}" + + +@pytest.mark.parametrize( + "case_label,cli_args,minion_tgt_tmpl", + [ + ("compound_full_srn", ("-C", "test.ping"), "T@dummy:__ID__"), + ("list_bare_resource_id", ("-L", "test.ping"), "__ID__"), + ("glob_exact_resource_id", ("test.ping",), "__ID__"), + ], +) +def test_single_resource_targeting_forms_among_three( + salt_minion, salt_cli, case_label, cli_args, minion_tgt_tmpl +): + """ + With three dummy resources, ``test.ping`` addressed to **one** resource must + return only that id using compound ``T@``, list ``-L``, or exact glob. + """ + target_id = DUMMY_RESOURCES[1] + tgt = minion_tgt_tmpl.replace("__ID__", target_id) + + ret = salt_cli.run(*cli_args, minion_tgt=tgt) + assert ret.returncode == 0, (case_label, ret) + + data = _salt_cli_json_dict(ret) + assert isinstance(data, dict), f"[{case_label}] expected dict, got: {data!r}" + assert data == {target_id: True}, f"[{case_label}] unexpected payload: {data}" + assert salt_minion.id not in data, case_label + for rid in DUMMY_RESOURCES: + if rid == target_id: + continue + assert rid not in data, f"[{case_label}] unexpected {rid!r} in {data}" + + +def test_T_at_bare_type_returns_all_resources_of_type(salt_minion, salt_cli): + """ + ``salt -C 'T@dummy' test.ping`` must return ``True`` for every dummy + resource (dummy-01 … dummy-03) without including the managing minion. + """ + ret = salt_cli.run("-C", "test.ping", minion_tgt="T@dummy") + assert ret.returncode == 0, ret + + data = ret.data + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + + for rid in DUMMY_RESOURCES: + assert ( + rid in data + ), f"Resource '{rid}' missing from T@dummy response: {list(data)}" + assert data[rid] is True + + # The managing minion should NOT be in the T@-only response. + assert ( + salt_minion.id not in data + ), "Managing minion unexpectedly included in T@dummy response" + + +def test_unknown_resource_function_fails_loudly(salt_minion, salt_cli): + """ + Calling a function that does not exist on a resource must return an error + string, not silently fall through to the managing minion's own module. + + This guards against the pre-resource behaviour where an unknown function + for a resource target would execute on the minion itself. + """ + ret = salt_cli.run("-C", "nonexistent.function", minion_tgt="T@dummy:dummy-01") + # The command fails (non-zero) because the function is unknown. + assert ret.returncode != 0 or ( + isinstance(ret.data, dict) + and isinstance(ret.data.get("dummy-01"), str) + and "nonexistent" in ret.data["dummy-01"].lower() + ), f"Expected error for unknown resource function, got: {ret.data!r}" + + +def test_grain_targeting_matches_resources(salt_minion, salt_cli): + """ + ``salt -G ':' test.ping`` must match resources whose own + grains satisfy the expression. Every dummy resource publishes + ``dummy_grain_1: one`` (see ``salt.resource.dummy.grains``), so the + response must include all three dummy resource IDs. + + End-to-end pipeline this exercises: + + * Minion ``_register_resources_with_master`` ships per-resource grain + dicts to the master alongside the registry payload. + * Master ``_register_resources`` writes them into the + ``resource_grains`` cache bank. + * Master ``CkMinions._check_grain_minions`` augments its result with + bare resource IDs from any ``resource_grains`` entry whose dict + satisfies the expression. + * Minion ``_resolve_resource_targets`` handles ``tgt_type == "grain"`` + by matching the expression against the cached per-resource grains + and dispatching to the matched resources. + """ + ret = salt_cli.run("-G", "test.ping", minion_tgt="dummy_grain_1:one") + assert ret.returncode == 0, ret + + data = ret.data + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + + for rid in DUMMY_RESOURCES: + assert ( + rid in data + ), f"Resource '{rid}' missing from grain-target response: {list(data)}" + assert data[rid] is True + + # The managing minion does NOT have ``dummy_grain_1`` in its own + # grains, so it must not appear in the response — the only way to land + # in this response is via the per-resource grain match. + assert salt_minion.id not in data, ( + f"Managing minion '{salt_minion.id}' unexpectedly matched " + f"a resource grain expression: {list(data)}" + ) + + +def test_grain_targeting_only_matching_resource(salt_minion, salt_cli): + """ + ``salt -G 'resource_id:dummy-02' test.ping`` matches only dummy-02 + because the per-resource ``resource_id`` grain is unique to that + resource (see ``salt.resource.dummy.grains``). + """ + ret = salt_cli.run("-G", "test.ping", minion_tgt="resource_id:dummy-02") + assert ret.returncode == 0, ret + + data = _salt_cli_json_dict(ret) + # Salt-factories may unwrap a single-key envelope when the response + # has only one entry; accept both shapes. + if "dummy-02" in data: + assert data["dummy-02"] is True + else: + # Unwrapped: ``data`` IS dummy-02's return value. + assert data is True or data == {}, f"Unexpected response shape: {data!r}" + + +def test_grains_items_returns_resource_grains_not_minion_grains(salt_minion, salt_cli): + """ + ``salt 'dummy-01' grains.items`` must return the dummy resource's grains + (produced by ``salt.resource.dummy.grains``), not the managing minion's + grains. This exercises the end-to-end grain-swap pipeline: + + * Master targeting matches the bare resource id ``dummy-01`` and + dispatches a job whose payload includes ``resource_target`` for the + ``dummy`` type. + * Minion ``_thread_return`` packs ``__grains__`` from + ``resource_funcs["dummy.grains"]()`` before the function runs. + * The function (``grains.items``) returns the resource grain dict. + * Master ``_return`` re-keys ``resource_id`` → response key ``dummy-01``. + """ + ret = salt_cli.run("grains.items", minion_tgt="dummy-01") + assert ret.returncode == 0, ret + + data = _salt_cli_json_dict(ret) + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + # Salt-factories unwraps the single-key envelope when ``minion_tgt`` is + # the only response key, so ``data`` may be either the grains dict itself + # or ``{"dummy-01": grains_dict}``. Accept both shapes. + grains = data.get("dummy-01") if "dummy-01" in data else data + assert isinstance( + grains, dict + ), f"Expected dict for dummy-01 grains, got: {grains!r}" + + # The resource grains must be present. + assert grains.get("dummy_grain_1") == "one" + assert grains.get("dummy_grain_2") == "two" + assert grains.get("dummy_grain_3") == "three" + assert grains.get("resource_id") == "dummy-01" + + # The managing minion's grains must NOT bleed through. ``os`` is a stock + # core grain on every supported Linux/macOS test target; if it appears + # the swap didn't take effect. + assert "os" not in grains, ( + "Managing minion's 'os' grain leaked into resource grains response — " + "the dispatch path is returning minion grains instead of resource grains" + ) + + +def test_grain_pcre_targeting_matches_resources(salt_minion, salt_cli): + """ + ``salt -P ':' test.ping`` must match resources whose own + grains satisfy the regex. ``resource_id`` for each dummy is + ``dummy-NN``; the regex ``^dummy-0[12]$`` selects exactly two. + """ + ret = salt_cli.run("-P", "test.ping", minion_tgt=r"resource_id:^dummy-0[12]$") + assert ret.returncode == 0, ret + + data = ret.data + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + assert set(data.keys()) == { + "dummy-01", + "dummy-02", + }, f"PCRE-grain target matched unexpected set: {list(data)}" + assert all(v is True for v in data.values()) + + +def test_compound_grain_targeting_matches_resources(salt_minion, salt_cli): + """ + ``salt -C 'G@:' test.ping`` must match resources via the + compound parser dispatching to the same per-resource grain match path. + """ + ret = salt_cli.run("-C", "test.ping", minion_tgt="G@dummy_grain_1:one") + assert ret.returncode == 0, ret + + data = ret.data + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + for rid in DUMMY_RESOURCES: + assert ( + rid in data + ), f"Resource {rid!r} missing from compound G@ response: {list(data)}" + assert data[rid] is True + assert ( + salt_minion.id not in data + ), f"Managing minion '{salt_minion.id}' must not match a resource grain" diff --git a/tests/pytests/integration/resources/test_multi_minion_grain_targeting.py b/tests/pytests/integration/resources/test_multi_minion_grain_targeting.py new file mode 100644 index 000000000000..a1d167e23f89 --- /dev/null +++ b/tests/pytests/integration/resources/test_multi_minion_grain_targeting.py @@ -0,0 +1,82 @@ +""" +Multi-minion integration coverage for grain targeting of resources. + +The single-minion ``test_dummy_resource.py`` suite verifies that one +minion's resources reach the master's ``resource_grains`` bank and that +``-G`` matches them. This module spins up a *second* minion (via the +``salt_minion_2`` package fixture) managing a disjoint set of dummy +resources and confirms: + +* The master's ``resource_grains`` bank holds entries from both minions. +* ``salt -G ':'`` matches resources from EITHER minion when + both have the matching grain (here: ``dummy_grain_1:one`` is the same + static value for every dummy resource on every minion). +* The managing minions themselves are excluded from the response — the + match comes purely from the per-resource grain bank. +""" + +import pytest + +from tests.pytests.integration.resources.conftest import ( + DUMMY_RESOURCES, + DUMMY_RESOURCES_2, + MINION_ID, + MINION_ID_2, +) + +pytestmark = [pytest.mark.slow_test, pytest.mark.timeout(240)] + + +def test_grain_targeting_matches_resources_across_two_minions( + salt_minion, salt_minion_2, salt_cli +): + """ + ``salt -G 'dummy_grain_1:one' test.ping`` must include the dummy + resources managed by **both** minions in a single response. Every + dummy resource statically reports ``dummy_grain_1: one`` (see + ``salt.resource.dummy.grains``), so the grain match should hit all + five resources (3 on minion-1 + 2 on minion-2) and exclude the + managing minions themselves. + """ + ret = salt_cli.run("-G", "test.ping", minion_tgt="dummy_grain_1:one") + assert ret.returncode == 0, ret + + data = ret.data + assert isinstance(data, dict), f"Expected dict, got: {data!r}" + + expected = set(DUMMY_RESOURCES) | set(DUMMY_RESOURCES_2) + for rid in expected: + assert rid in data, f"Resource {rid!r} missing from response: {list(data)}" + assert data[rid] is True + + assert ( + MINION_ID not in data + ), f"Managing minion '{MINION_ID}' must not match a resource grain" + assert ( + MINION_ID_2 not in data + ), f"Second minion '{MINION_ID_2}' must not match a resource grain" + + +def test_grain_targeting_unique_resource_id_picks_correct_minion( + salt_minion, salt_minion_2, salt_cli +): + """ + The ``resource_id`` grain is unique per resource. Targeting + ``resource_id:dummy-05`` must hit only the resource on minion-2; + minion-1 has no ``dummy-05``. This proves the master picks the + right SRN out of the union and dispatches to the owning minion. + """ + ret = salt_cli.run("-G", "test.ping", minion_tgt="resource_id:dummy-05") + assert ret.returncode == 0, ret + + # Salt-factories may unwrap a single-key envelope when only one id + # responds. Accept both shapes. + if isinstance(ret.data, dict): + assert ret.data.get("dummy-05") is True + # No minion-1-owned resource may appear. + for rid in DUMMY_RESOURCES: + assert ( + rid not in ret.data + ), f"Unexpected {rid!r} in response: {list(ret.data)}" + else: + assert ret.data is True diff --git a/tests/pytests/integration/resources_ssh/__init__.py b/tests/pytests/integration/resources_ssh/__init__.py new file mode 100644 index 000000000000..4be7ae16bed3 --- /dev/null +++ b/tests/pytests/integration/resources_ssh/__init__.py @@ -0,0 +1 @@ +# Integration tests for SSH Salt Resources (minion-side salt-ssh / relenv path). diff --git a/tests/pytests/integration/resources_ssh/conftest.py b/tests/pytests/integration/resources_ssh/conftest.py new file mode 100644 index 000000000000..6beb5722f813 --- /dev/null +++ b/tests/pytests/integration/resources_ssh/conftest.py @@ -0,0 +1,193 @@ +""" +Fixtures for SSH resource integration tests. + +Spins up the shared session master, a module-scoped sshd, Pillar declaring one +SSH resource (``ssh-int-01``) pointing at that sshd, and a minion that manages +it. This exercises :mod:`salt.resource.ssh` — including ``Single`` built from +minion opts, relenv, and ``cmd_block()`` — which plain salt-ssh integration +tests never touch (they run on the master only). +""" + +from __future__ import annotations + +import glob +import logging +import os +import pathlib +import platform +import shutil +import tempfile +import time + +import pytest + +# sshd usually lives in /usr/sbin, which is not always on a non-login PATH. +for _bindir in ("/usr/sbin", "/usr/local/sbin"): + if os.path.isdir(_bindir) and _bindir not in os.environ.get("PATH", ""): + os.environ["PATH"] = _bindir + os.pathsep + os.environ.get("PATH", "") + +import salt.utils.relenv +from tests.conftest import FIPS_TESTRUN +from tests.support.runtests import RUNTIME_VARS + +log = logging.getLogger(__name__) + +SSH_RESOURCE_ID = "ssh-int-01" +MINION_ID = "ssh-resources-minion" + + +def _detect_kernel_and_arch(): + kernel = platform.system().lower() + if kernel == "darwin": + kernel = "darwin" + elif kernel == "windows": + kernel = "windows" + else: + kernel = "linux" + + machine = platform.machine().lower() + if machine in ("amd64", "x86_64"): + os_arch = "x86_64" + elif machine in ("aarch64", "arm64"): + os_arch = "arm64" + else: + os_arch = machine + return kernel, os_arch + + +@pytest.fixture(scope="session") +def relenv_tarball_for_ssh_resource(): + """ + Pre-resolve a relenv tarball path for populating the minion cache. + + ``salt.resource.ssh._relenv_path`` looks under + ``/relenv/linux//salt-relenv.tar.xz`` for ``x86_64`` or ``arm64``. + """ + shared_cache = os.path.join(tempfile.gettempdir(), "salt_ssh_resource_int_relenv") + os.makedirs(shared_cache, exist_ok=True) + kernel, os_arch = _detect_kernel_and_arch() + + artifacts_glob = str( + pathlib.Path("/salt/artifacts").joinpath( + f"salt-*-onedir-{kernel}-{os_arch}.tar.xz" + ) + ) + for path in glob.glob(artifacts_glob): + if os.path.isfile(path): + log.info("Using CI artifact relenv tarball: %s", path) + return path + + try: + path = salt.utils.relenv.gen_relenv( + shared_cache, kernel=kernel, os_arch=os_arch + ) + if path and os.path.isfile(path): + log.info("Relenv tarball for SSH resource tests: %s", path) + return path + except (OSError, ValueError) as exc: + log.warning("Could not build/download relenv tarball: %s", exc) + return None + + +@pytest.fixture(scope="module") +def pillar_tree_ssh_resources( + salt_master, sshd_server, sshd_config_dir, known_hosts_file +): + """ + Pillar declaring ``resources.ssh.hosts`` for ``ssh-int-01`` → local sshd. + """ + port = sshd_server.listen_port + user = RUNTIME_VARS.RUNNING_TESTS_USER + priv = str(sshd_config_dir / "client_key") + + top_file = f""" + base: + '{MINION_ID}': + - ssh_resources_int + """ + + # Host blocks mirror roster-style auth; ignore_host_keys keeps the test simple. + ssh_pillar = f""" + resources: + ssh: + hosts: + {SSH_RESOURCE_ID}: + host: 127.0.0.1 + port: {port} + user: {user} + priv: {priv} + ignore_host_keys: true + timeout: 180 + """ + + top_tempfile = salt_master.pillar_tree.base.temp_file("top.sls", top_file) + pillar_tempfile = salt_master.pillar_tree.base.temp_file( + "ssh_resources_int.sls", ssh_pillar + ) + with top_tempfile, pillar_tempfile: + yield + + +@pytest.fixture(scope="module") +def salt_minion_ssh_resources( + salt_master, + pillar_tree_ssh_resources, + relenv_tarball_for_ssh_resource, +): + assert salt_master.is_running() + + config_overrides = { + "fips_mode": FIPS_TESTRUN, + "encryption_algorithm": "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1", + "signing_algorithm": "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1", + # Match resources/dummy integration: thread pool, resource race coverage. + "multiprocessing": False, + } + + factory = salt_master.salt_minion_daemon( + MINION_ID, + overrides=config_overrides, + extra_cli_arguments_after_first_start_failure=["--log-level=info"], + ) + factory.after_terminate( + pytest.helpers.remove_stale_minion_key, salt_master, factory.id + ) + + with factory.started(start_timeout=120): + cachedir = factory.config["cachedir"] + _kernel, os_arch = _detect_kernel_and_arch() + relenv_subdir = os.path.join(cachedir, "relenv", "linux", os_arch) + os.makedirs(relenv_subdir, exist_ok=True) + dest = os.path.join(relenv_subdir, "salt-relenv.tar.xz") + if relenv_tarball_for_ssh_resource and os.path.isfile( + relenv_tarball_for_ssh_resource + ): + shutil.copyfile(relenv_tarball_for_ssh_resource, dest) + log.info("Installed relenv tarball for minion at %s", dest) + else: + log.warning( + "No relenv tarball available — SSH resource tests that need relenv may fail" + ) + + salt_call_cli = factory.salt_call_cli() + ret = salt_call_cli.run("saltutil.refresh_pillar", wait=True, _timeout=120) + assert ret.returncode == 0, ret + assert ret.data is True, ret + + ret = salt_call_cli.run("saltutil.sync_all", _timeout=120) + assert ret.returncode == 0, ret + + time.sleep(3) + yield factory + + +@pytest.fixture(scope="module") +def salt_call_ssh_resource(salt_minion_ssh_resources): + assert salt_minion_ssh_resources.is_running() + return salt_minion_ssh_resources.salt_call_cli(timeout=120) + + +@pytest.fixture(scope="module") +def salt_cli_ssh_resource(salt_master): + assert salt_master.is_running() + return salt_master.salt_cli(timeout=120) diff --git a/tests/pytests/integration/resources_ssh/test_ssh_resource_integration.py b/tests/pytests/integration/resources_ssh/test_ssh_resource_integration.py new file mode 100644 index 000000000000..7b1b0e942727 --- /dev/null +++ b/tests/pytests/integration/resources_ssh/test_ssh_resource_integration.py @@ -0,0 +1,61 @@ +""" +End-to-end integration tests for the SSH resource type (``salt/resource/ssh.py``). + +Unlike :mod:`tests.pytests.integration.ssh`, which runs **salt-ssh on the master**, +these tests run the **managing minion** path: master publishes to ``T@ssh:…``, +the minion dispatches into the SSH resource loader, which builds +:class:`~salt.client.ssh.Single` from **minion** ``__opts__`` and runs +``cmd_block()`` — the code path that broke with missing ``ext_pillar`` / ``fsclient``. + +Requirements: + +* ``--ssh-tests`` (transports sshd + roster fixtures; see ``requires_sshd_server``). +* A relenv tarball available (CI artifact or downloaded via + :func:`salt.utils.relenv.gen_relenv`), copied into the minion cache by + ``conftest.py``. +""" + +import pytest + +from tests.pytests.integration.resources_ssh.conftest import SSH_RESOURCE_ID + +pytestmark = [ + pytest.mark.slow_test, + pytest.mark.requires_sshd_server, + pytest.mark.skip_on_windows(reason="SSH resource integration uses Unix sshd"), +] + + +def test_minion_pillar_lists_ssh_resource( + salt_minion_ssh_resources, salt_call_ssh_resource +): + """Pillar must expose ``resources.ssh.hosts`` for the SSH resource ID.""" + ret = salt_call_ssh_resource.run("pillar.get", "resources:ssh:hosts", _timeout=120) + assert ret.returncode == 0, ret + hosts = ret.data + assert isinstance(hosts, dict), hosts + assert SSH_RESOURCE_ID in hosts, f"missing {SSH_RESOURCE_ID!r}, got {list(hosts)}" + assert hosts[SSH_RESOURCE_ID].get("host") == "127.0.0.1" + + +def test_ssh_resource_T_at_test_ping( + salt_minion_ssh_resources, salt_cli_ssh_resource, relenv_tarball_for_ssh_resource +): + """ + ``salt --compound T@ssh:… test.ping`` runs ``sshresource_test.ping`` → + :func:`salt.resource.ssh.ping` (shell to the SSH resource). The minion + preloads ``ssh.grains`` for ``__grains__`` in the resource loader; that path + must have a usable FSClient (``master_opts`` including ``cachedir``). + """ + if not relenv_tarball_for_ssh_resource: + pytest.skip("No relenv tarball — cannot run SSH resource against relenv bundle") + + ret = salt_cli_ssh_resource.run( + "--compound", + "test.ping", + minion_tgt=f"T@ssh:{SSH_RESOURCE_ID}", + ) + assert ret.returncode == 0, ret + data = ret.data + assert isinstance(data, dict), data + assert data.get(SSH_RESOURCE_ID) is True, data diff --git a/tests/pytests/integration/ssh/test_cp.py b/tests/pytests/integration/ssh/test_cp.py index 7fb21a2d25cd..bd626171b82c 100644 --- a/tests/pytests/integration/ssh/test_cp.py +++ b/tests/pytests/integration/ssh/test_cp.py @@ -1,5 +1,6 @@ import hashlib import os +import shutil import time from pathlib import Path @@ -775,6 +776,31 @@ def _is_cached(salt_ssh_cli_parameterized, suffix, request, cachedir, master_cac assert ret.returncode == 0 assert ret.data remove.remove("extrn_files") + # ``cp.get_template`` caches the rendered output under a filename + # that embeds the URL query string when one is present (e.g. + # ``extrn_files/base/grail/scene33-saltenv=base``), while + # ``cp.is_cached`` strips the query before computing the cache + # path (it looks at ``extrn_files/base/grail/scene33``). Without + # the mirror below this fixture only worked because a sibling + # parametrize variant happened to leave a file at the canonical + # path; pytest test-group splits made that incidental. Mirror + # the rendered file to the canonical no-query path on both the + # minion and master sides so the test is order-independent. + if suffix: + canonical = cachedir / "extrn_files" / "base" / "grail" / "scene33" + cached_path = Path(ret.data) + if cached_path.exists() and cached_path != canonical: + canonical.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(cached_path, canonical) + master_cached = _convert( + cachedir, master_cachedir, cached_path, master=True + ) + master_canonical = _convert( + cachedir, master_cachedir, canonical, master=True + ) + if master_cached.exists() and master_cached != master_canonical: + master_canonical.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(master_cached, master_canonical) for basedir in remove: tgt = cachedir / basedir / "base" / "grail" / "scene33" tgt.unlink(missing_ok=True) diff --git a/tests/pytests/scenarios/cluster/conftest.py b/tests/pytests/scenarios/cluster/conftest.py index 0bfc2c206887..a0a3e544fedd 100644 --- a/tests/pytests/scenarios/cluster/conftest.py +++ b/tests/pytests/scenarios/cluster/conftest.py @@ -60,5 +60,5 @@ def cluster_minion_all( overrides=config_overrides, extra_cli_arguments_after_first_start_failure=["--log-level=info"], ) - with factory.started(start_timeout=120): + with factory.started(start_timeout=240): yield factory diff --git a/tests/pytests/scenarios/cluster/test_cluster.py b/tests/pytests/scenarios/cluster/test_cluster.py index 1e2f0d191f34..fdbea0e00464 100644 --- a/tests/pytests/scenarios/cluster/test_cluster.py +++ b/tests/pytests/scenarios/cluster/test_cluster.py @@ -11,6 +11,7 @@ import salt.crypt +@pytest.mark.flaky(max_runs=3) def test_cluster_key_rotation( cluster_master_1, cluster_master_2, diff --git a/tests/pytests/scenarios/reauth/conftest.py b/tests/pytests/scenarios/reauth/conftest.py index 339cee1d4317..1360bfbfd03e 100644 --- a/tests/pytests/scenarios/reauth/conftest.py +++ b/tests/pytests/scenarios/reauth/conftest.py @@ -26,6 +26,7 @@ def salt_master_factory(salt_factories): "sys.doc", "pillar.items", "runner.test.arg", + "_auth", "auth", ], }, diff --git a/tests/pytests/unit/client/ssh/test_ssh_classes.py b/tests/pytests/unit/client/ssh/test_ssh_classes.py index cabd4ff17224..34c48e004521 100644 --- a/tests/pytests/unit/client/ssh/test_ssh_classes.py +++ b/tests/pytests/unit/client/ssh/test_ssh_classes.py @@ -7,6 +7,22 @@ from salt.exceptions import SaltClientError, SaltSystemExit from tests.support.mock import MagicMock, patch +# Minimal opts that look like a Salt minion config. +# Intentionally omits ``ext_pillar`` — it is a master-only key +# (DEFAULT_MASTER_OPTS) and is absent from DEFAULT_MINION_OPTS. +# salt-ssh's Single.__init__ must not crash when constructed from +# minion opts (as it is when invoked by the SSH resource driver). +_MINION_OPTS = { + "relenv": True, + "cachedir": "/tmp", + "thin_dir": "/tmp/_salt_relenv_test", + "ssh_wipe": False, + "file_roots": {"base": ["/srv/salt"]}, + "pillar_roots": {"base": ["/srv/pillar"]}, + "module_dirs": [], + # ext_pillar is deliberately absent +} + pytestmark = [pytest.mark.skip_unless_on_linux(reason="Test ssh only run on Linux")] @@ -80,3 +96,33 @@ def test_ssh_class(): "salt-ssh could not be run because it could not generate keys." in err.value ) + + +def test_single_init_with_minion_opts_no_ext_pillar(): + """ + Single.__init__ must succeed when given minion opts that lack ``ext_pillar``. + + salt-ssh normally runs on the master, where opts always contain + ``ext_pillar: []`` (it is in DEFAULT_MASTER_OPTS). The SSH resource + driver builds Single from inside a minion process using ``dict(__opts__)``, + which produces minion opts. ``ext_pillar`` is absent from + DEFAULT_MINION_OPTS, so a direct ``opts["ext_pillar"]`` access raises + KeyError. The fix uses ``opts.get("ext_pillar", [])``; this test pins + that behaviour so the regression is immediately obvious if the .get() is + ever reverted. + """ + with patch("salt.loader.ssh_wrapper", return_value=MagicMock()), patch( + "salt.client.ssh.shell.gen_shell", return_value=MagicMock() + ): + single = dunder_ssh.Single( + _MINION_OPTS.copy(), + "test.ping", + "target-host", + host="192.0.2.1", + thin="/fake/salt-relenv.tar.xz", + thin_dir="/tmp/_salt_relenv_test", + ) + + assert ( + single.minion_opts["ext_pillar"] == [] + ), "ext_pillar should default to [] when absent from minion opts" diff --git a/tests/pytests/unit/client/test_netapi.py b/tests/pytests/unit/client/test_netapi.py index 2c99924c4074..67053bfffa97 100644 --- a/tests/pytests/unit/client/test_netapi.py +++ b/tests/pytests/unit/client/test_netapi.py @@ -1,7 +1,7 @@ import logging import salt.client.netapi -from tests.support.mock import Mock, patch +from tests.support.mock import AsyncMock, Mock, patch def test_run_log(caplog, master_opts): @@ -11,6 +11,7 @@ def test_run_log(caplog, master_opts): master_opts["rest_cherrypy"] = {"port": 8000} mock_process = Mock() mock_process.add_process.return_value = True + mock_process.return_value.run = AsyncMock() patch_process = patch.object(salt.utils.process, "ProcessManager", mock_process) with caplog.at_level(logging.INFO): with patch_process: diff --git a/tests/pytests/unit/loader/test_context.py b/tests/pytests/unit/loader/test_context.py index 64b36411f4b5..c720cc4c44ea 100644 --- a/tests/pytests/unit/loader/test_context.py +++ b/tests/pytests/unit/loader/test_context.py @@ -2,10 +2,14 @@ Tests for salt.loader.context """ +import contextvars import copy +import threading +import salt.loader import salt.loader.context import salt.loader.lazy +from tests.support.mock import patch def test_named_loader_context(): @@ -55,3 +59,134 @@ def test_named_loader_context_opts(): with salt.loader.context.loader_context(loader): assert "foo" in opts assert opts["foo"] == "bar" + + +# --------------------------------------------------------------------------- +# resource_ctxvar tests +# --------------------------------------------------------------------------- + + +def test_resource_ctxvar_default_is_empty_dict(): + """resource_ctxvar returns {} when nothing has been set in this context.""" + assert salt.loader.context.resource_ctxvar.get() == {} + + +def test_resource_ctxvar_set_and_get(): + """Setting resource_ctxvar is visible within the same thread.""" + target = {"id": "dummy-01", "type": "dummy"} + tok = salt.loader.context.resource_ctxvar.set(target) + try: + assert salt.loader.context.resource_ctxvar.get() is target + finally: + salt.loader.context.resource_ctxvar.reset(tok) + # After reset the default is restored. + assert salt.loader.context.resource_ctxvar.get() == {} + + +def test_resource_ctxvar_thread_isolation(): + """ + Each thread gets an independent copy of resource_ctxvar. + + This is the core property that fixes Race 1: Thread A setting + resource_ctxvar to target_A must be invisible to Thread B, which sets it + to target_B, even when both threads share the same LazyLoader object. + """ + target_a = {"id": "dummy-01", "type": "dummy"} + target_b = {"id": "dummy-02", "type": "dummy"} + results = {} + + def worker(name, target, barrier): + salt.loader.context.resource_ctxvar.set(target) + # Both threads arrive here before either reads, maximising the + # chance of interference if isolation is broken. + barrier.wait() + results[name] = salt.loader.context.resource_ctxvar.get() + + barrier = threading.Barrier(2) + t1 = threading.Thread(target=worker, args=("a", target_a, barrier)) + t2 = threading.Thread(target=worker, args=("b", target_b, barrier)) + t1.start() + t2.start() + t1.join() + t2.join() + + assert results["a"] is target_a + assert results["b"] is target_b + + +def test_resource_ctxvar_captured_by_copy_context(): + """ + copy_context() snapshots the current resource_ctxvar value. + + LazyLoader.run() calls copy_context() on every invocation, which is why + setting resource_ctxvar in _thread_return before the function executes is + sufficient for the value to be visible inside _run_as without any pack + mutation. + """ + target = {"id": "node1", "type": "ssh"} + tok = salt.loader.context.resource_ctxvar.set(target) + try: + ctx = contextvars.copy_context() + finally: + salt.loader.context.resource_ctxvar.reset(tok) + + # Outside the token the default is restored in *this* context. + assert salt.loader.context.resource_ctxvar.get() == {} + + # But the snapshot captured the value that was current at copy time. + seen = {} + ctx.run(lambda: seen.update({"val": salt.loader.context.resource_ctxvar.get()})) + assert seen["val"] is target + + +def test_named_loader_context_resource_bypasses_pack(): + """ + NamedLoaderContext.value() for __resource__ reads from resource_ctxvar, + not from the loader pack. + + This guarantees that concurrent threads using the same loader object each + see their own resource target regardless of what the shared pack contains. + """ + loader_context = salt.loader.context.LoaderContext() + named = loader_context.named_context("__resource__") + + # With no ctxvar set the default {} is returned even if there is no loader. + assert named.value() == {} + + # Set a target in the current context; the named context must reflect it. + target = {"id": "dummy-03", "type": "dummy"} + tok = salt.loader.context.resource_ctxvar.set(target) + try: + assert named.value() is target + assert named["id"] == "dummy-03" + finally: + salt.loader.context.resource_ctxvar.reset(tok) + + # After reset the default is restored. + assert named.value() == {} + + +def test_resource_modules_packs_resource_dunder(): + """ + salt.loader.resource_modules must include ``"__resource__"`` in its pack + so that LazyLoader creates a NamedLoaderContext for it on every loaded + module. Without this, ``sshresource_state._resource_id()`` raises + ``NameError: name '__resource__' is not defined``. + """ + opts = { + "optimization_order": [0, 1, 2], + "extension_modules": "", + "fileserver_backend": ["roots"], + } + with ( + patch("salt.loader._module_dirs", return_value=[]), + patch("salt.loader.lazy.LazyLoader.__init__", return_value=None) as patched, + ): + salt.loader.resource_modules(opts, "ssh") + assert patched.called, "LazyLoader.__init__ was never called" + _, call_kwargs = patched.call_args + pack = call_kwargs.get("pack", {}) + assert "__resource__" in pack, ( + "resource_modules pack is missing '__resource__'; " + "sshresource_state will raise NameError at runtime" + ) diff --git a/tests/pytests/unit/matchers/test_resource_matchers.py b/tests/pytests/unit/matchers/test_resource_matchers.py new file mode 100644 index 000000000000..68c123de2a14 --- /dev/null +++ b/tests/pytests/unit/matchers/test_resource_matchers.py @@ -0,0 +1,119 @@ +""" +Tests for the T@ (resource_match) and M@ (managing_minion_match) matchers. +""" + +import pytest + +import salt.matchers.managing_minion_match as managing_minion_match +import salt.matchers.resource_match as resource_match + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +RESOURCES = { + "dummy": ["dummy-01", "dummy-02", "dummy-03"], + "ssh": ["node1", "localhost"], +} + + +@pytest.fixture +def opts_with_resources(minion_opts): + minion_opts["id"] = "minion" + minion_opts["resources"] = RESOURCES + return minion_opts + + +@pytest.fixture +def opts_no_resources(minion_opts): + minion_opts["id"] = "minion" + minion_opts.pop("resources", None) + return minion_opts + + +# --------------------------------------------------------------------------- +# resource_match (T@) tests +# --------------------------------------------------------------------------- + + +def test_resource_match_bare_type_hits(opts_with_resources): + """T@dummy matches a minion that manages at least one dummy resource.""" + assert resource_match.match("dummy", opts=opts_with_resources) is True + + +def test_resource_match_bare_type_miss(opts_with_resources): + """T@vcf_host does not match when the minion has no vcf_host resources.""" + assert resource_match.match("vcf_host", opts=opts_with_resources) is False + + +def test_resource_match_full_srn_hits(opts_with_resources): + """T@dummy:dummy-01 matches when dummy-01 is in the dummy resource list.""" + assert resource_match.match("dummy:dummy-01", opts=opts_with_resources) is True + + +def test_resource_match_full_srn_miss_wrong_id(opts_with_resources): + """T@dummy:dummy-99 does not match — dummy-99 is not managed.""" + assert resource_match.match("dummy:dummy-99", opts=opts_with_resources) is False + + +def test_resource_match_full_srn_miss_wrong_type(opts_with_resources): + """T@vcf_host:dummy-01 does not match — type is wrong.""" + assert resource_match.match("vcf_host:dummy-01", opts=opts_with_resources) is False + + +def test_resource_match_ssh_type(opts_with_resources): + """T@ssh matches a minion that manages SSH resources.""" + assert resource_match.match("ssh", opts=opts_with_resources) is True + + +def test_resource_match_ssh_full_srn(opts_with_resources): + """T@ssh:node1 matches the specific SSH resource.""" + assert resource_match.match("ssh:node1", opts=opts_with_resources) is True + + +def test_resource_match_no_resources(opts_no_resources): + """T@dummy returns False when opts has no resources configured.""" + assert resource_match.match("dummy", opts=opts_no_resources) is False + + +def test_resource_match_empty_resources(minion_opts): + """T@dummy returns False when opts["resources"] is an empty dict.""" + minion_opts["resources"] = {} + assert resource_match.match("dummy", opts=minion_opts) is False + + +# --------------------------------------------------------------------------- +# managing_minion_match (M@) tests +# --------------------------------------------------------------------------- + + +def test_managing_minion_match_own_id(opts_with_resources): + """M@minion matches a minion with id='minion'.""" + assert managing_minion_match.match("minion", opts=opts_with_resources) is True + + +def test_managing_minion_match_different_id(opts_with_resources): + """M@other-minion does not match a minion with id='minion'.""" + assert ( + managing_minion_match.match("other-minion", opts=opts_with_resources) is False + ) + + +def test_managing_minion_match_empty_string(opts_with_resources): + """M@ with empty string does not match.""" + assert managing_minion_match.match("", opts=opts_with_resources) is False + + +def test_managing_minion_match_minion_id_kwarg(minion_opts): + """The minion_id kwarg overrides opts['id'] for the comparison.""" + minion_opts["id"] = "minion" + assert ( + managing_minion_match.match( + "override-id", opts=minion_opts, minion_id="override-id" + ) + is True + ) + assert ( + managing_minion_match.match("minion", opts=minion_opts, minion_id="override-id") + is False + ) diff --git a/tests/pytests/unit/modules/test_sshresource_state.py b/tests/pytests/unit/modules/test_sshresource_state.py new file mode 100644 index 000000000000..1d4c9febf0da --- /dev/null +++ b/tests/pytests/unit/modules/test_sshresource_state.py @@ -0,0 +1,361 @@ +""" +Unit tests for salt.modules.sshresource_state. + +Covers: +- highstate(): empty chunks → returns the 'no top file' state dict with + result=False rather than None/empty so the merge block displays it cleanly. +- _exec_state_pkg(): catches SSHCommandExecutionError and extracts the state + result dict from the exception's parsed data when it contains a valid return. + Re-raises the exception when the parsed data does not contain a valid state dict. +""" + +import pytest + +from tests.support.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_RESOURCE_ID = "node1" + +_VALID_STATE_RETURN = { + "pkg_|-curl_|-curl_|-installed": { + "result": False, + "comment": "Package curl is not installed", + "name": "curl", + "changes": {}, + "__run_num__": 0, + } +} + +_BASE_OPTS = { + "id": "minion", + "resource_type": "ssh", + "cachedir": "/tmp", + "hash_type": "sha256", + "thin_dir": "/tmp/.test_salt", + "test": False, + "pillar": {}, +} + +_BASE_RESOURCE = {"id": _RESOURCE_ID, "type": "ssh"} + + +# --------------------------------------------------------------------------- +# _relenv_path(): returns tarball or None +# --------------------------------------------------------------------------- + + +class TestRelenvPath: + """_relenv_path() returns the first existing tarball or None.""" + + def _run(self, existing_files=()): + import salt.modules.sshresource_state as mod + + opts = _BASE_OPTS.copy() + with patch.object(mod, "__opts__", opts, create=True), patch.object( + mod, "__resource__", dict(_BASE_RESOURCE), create=True + ), patch.object(mod, "__context__", {}, create=True), patch.object( + mod, "__salt__", {}, create=True + ), patch( + "os.path.exists", side_effect=lambda p: p in existing_files + ): + return mod._relenv_path() + + def test_returns_x86_64_when_present(self): + path = "/tmp/relenv/linux/x86_64/salt-relenv.tar.xz" + assert self._run(existing_files=(path,)) == path + + def test_returns_arm64_when_present(self): + path = "/tmp/relenv/linux/arm64/salt-relenv.tar.xz" + assert self._run(existing_files=(path,)) == path + + def test_returns_none_when_no_tarball(self): + assert self._run(existing_files=()) is None + + def test_prefers_x86_64_over_arm64(self): + x86 = "/tmp/relenv/linux/x86_64/salt-relenv.tar.xz" + arm = "/tmp/relenv/linux/arm64/salt-relenv.tar.xz" + assert self._run(existing_files=(x86, arm)) == x86 + + +def _make_ssh_error(parsed): + """Build a fake SSHCommandExecutionError with .parsed attribute.""" + import salt.client.ssh.wrapper + + return salt.client.ssh.wrapper.SSHCommandExecutionError( + "stdout", "stderr", 2, parsed=parsed + ) + + +# --------------------------------------------------------------------------- +# highstate(): empty chunks → no-top-file state dict +# --------------------------------------------------------------------------- + + +class TestHighstateEmptyChunks: + """highstate() with no top-file match must return a proper state dict.""" + + def _run_highstate(self): + import salt.modules.sshresource_state as mod + + opts = _BASE_OPTS.copy() + + mock_state = MagicMock() + mock_state.__enter__ = MagicMock(return_value=mock_state) + mock_state.__exit__ = MagicMock(return_value=False) + mock_state.opts = {"pillar": {}, "test": False} + mock_state.compile_low_chunks.return_value = [] # no chunks → no top file + + with patch.object(mod, "__opts__", opts, create=True), patch.object( + mod, "__resource__", dict(_BASE_RESOURCE), create=True + ), patch.object(mod, "__context__", {}, create=True), patch.object( + mod, "__salt__", {}, create=True + ), patch.object( + mod, "_target_opts", return_value=opts + ), patch.object( + mod, "_seed_thin_dir", return_value="/tmp/.test_salt" + ), patch.object( + mod, "_get_initial_pillar", return_value=None + ), patch.object( + mod, "_file_client", return_value=MagicMock() + ), patch( + "salt.client.ssh.state.SSHHighState", return_value=mock_state + ), patch( + "salt.utils.state.get_sls_opts", return_value=opts + ): + return mod.highstate() + + def test_returns_dict(self): + result = self._run_highstate() + assert isinstance(result, dict), f"Expected dict, got {type(result)}" + + def test_uses_no_state_key(self): + result = self._run_highstate() + assert "no_|-states_|-states_|-None" in result + + def test_result_is_false(self): + result = self._run_highstate() + entry = result["no_|-states_|-states_|-None"] + assert entry["result"] is False + + def test_comment_mentions_resource_id(self): + result = self._run_highstate() + comment = result["no_|-states_|-states_|-None"]["comment"] + assert _RESOURCE_ID in comment + + def test_changes_empty(self): + result = self._run_highstate() + assert result["no_|-states_|-states_|-None"]["changes"] == {} + + +# --------------------------------------------------------------------------- +# _exec_state_pkg(): SSHCommandExecutionError recovery +# --------------------------------------------------------------------------- + + +class TestExecStatePkg: + """_exec_state_pkg must recover valid state dicts from SSHCommandExecutionError.""" + + def _run(self, exc_parsed): + """ + Run _exec_state_pkg with a mocked Single that raises SSHCommandExecutionError. + Returns (result, context_dict). + """ + import salt.modules.sshresource_state as mod + + opts = _BASE_OPTS.copy() + context = {} + exc = _make_ssh_error(exc_parsed) + + with patch.object(mod, "__opts__", opts, create=True), patch.object( + mod, "__resource__", dict(_BASE_RESOURCE), create=True + ), patch.object(mod, "__context__", context, create=True), patch.object( + mod, "__salt__", {}, create=True + ), patch.object( + mod, "_resource_id", return_value=_RESOURCE_ID + ), patch.object( + mod, "_relenv_path", return_value="/tmp/relenv.tar.xz" + ), patch.object( + mod, "_file_client", return_value=MagicMock() + ), patch.object( + mod, "_connection_kwargs", return_value={} + ), patch( + "salt.utils.hashutils.get_hash", return_value="abc123" + ), patch( + "os.remove" + ), patch( + "salt.client.ssh.Single" + ) as mock_single_cls, patch( + "salt.client.ssh.wrapper.parse_ret", side_effect=exc + ): + mock_single = MagicMock() + mock_single.cmd_block.return_value = ('{"local": {}}', "", 2) + mock_single.shell = MagicMock() + mock_single_cls.return_value = mock_single + + result = mod._exec_state_pkg(opts, "/tmp/fake.tgz", False) + return result, context + + def test_extracts_state_dict_from_exception(self): + parsed = {"local": {"return": _VALID_STATE_RETURN, "retcode": 2}} + result, _ = self._run(parsed) + assert result == _VALID_STATE_RETURN + + def test_sets_retcode_from_exception(self): + parsed = {"local": {"return": _VALID_STATE_RETURN, "retcode": 2}} + _, context = self._run(parsed) + assert context.get("retcode") == 2 + + def test_reraises_when_local_missing(self): + import salt.client.ssh.wrapper + + with pytest.raises(salt.client.ssh.wrapper.SSHCommandExecutionError): + self._run({}) # no "local" key + + def test_reraises_when_return_not_dict(self): + import salt.client.ssh.wrapper + + parsed = {"local": {"return": "raw string output", "retcode": 1}} + with pytest.raises(salt.client.ssh.wrapper.SSHCommandExecutionError): + self._run(parsed) + + def test_reraises_when_parsed_is_none(self): + import salt.client.ssh.wrapper + import salt.modules.sshresource_state as mod + + opts = _BASE_OPTS.copy() + context = {} + + exc = salt.client.ssh.wrapper.SSHCommandExecutionError( + "stdout", "stderr", 1, parsed=None + ) + + with patch.object(mod, "__opts__", opts, create=True), patch.object( + mod, "__resource__", dict(_BASE_RESOURCE), create=True + ), patch.object(mod, "__context__", context, create=True), patch.object( + mod, "__salt__", {}, create=True + ), patch.object( + mod, "_resource_id", return_value=_RESOURCE_ID + ), patch.object( + mod, "_relenv_path", return_value="/tmp/relenv.tar.xz" + ), patch.object( + mod, "_file_client", return_value=MagicMock() + ), patch.object( + mod, "_connection_kwargs", return_value={} + ), patch( + "salt.utils.hashutils.get_hash", return_value="abc123" + ), patch( + "os.remove" + ), patch( + "salt.client.ssh.Single" + ) as mock_single_cls, patch( + "salt.client.ssh.wrapper.parse_ret", side_effect=exc + ): + mock_single = MagicMock() + mock_single.cmd_block.return_value = ("", "", 1) + mock_single.shell = MagicMock() + mock_single_cls.return_value = mock_single + + with pytest.raises(salt.client.ssh.wrapper.SSHCommandExecutionError): + mod._exec_state_pkg(opts, "/tmp/fake.tgz", False) + + +# --------------------------------------------------------------------------- +# _exec_state_pkg(): normal (non-exception) path +# --------------------------------------------------------------------------- + + +class TestExecStatePkgNormalPath: + """_exec_state_pkg must unwrap the envelope dict returned by parse_ret.""" + + def _run(self, envelope): + import salt.modules.sshresource_state as mod + + opts = _BASE_OPTS.copy() + context = {} + + with patch.object(mod, "__opts__", opts, create=True), patch.object( + mod, "__resource__", dict(_BASE_RESOURCE), create=True + ), patch.object(mod, "__context__", context, create=True), patch.object( + mod, "__salt__", {}, create=True + ), patch.object( + mod, "_resource_id", return_value=_RESOURCE_ID + ), patch.object( + mod, "_relenv_path", return_value="/tmp/relenv.tar.xz" + ), patch.object( + mod, "_file_client", return_value=MagicMock() + ), patch.object( + mod, "_connection_kwargs", return_value={} + ), patch( + "salt.utils.hashutils.get_hash", return_value="abc123" + ), patch( + "os.remove" + ), patch( + "salt.client.ssh.Single" + ) as mock_single_cls, patch( + "salt.client.ssh.wrapper.parse_ret", return_value=envelope + ): + mock_single = MagicMock() + mock_single.cmd_block.return_value = ("", "", 0) + mock_single.shell = MagicMock() + mock_single_cls.return_value = mock_single + + result = mod._exec_state_pkg(opts, "/tmp/fake.tgz", False) + return result, context + + def test_returns_state_dict_from_envelope(self): + envelope = {"return": _VALID_STATE_RETURN, "retcode": 0} + result, _ = self._run(envelope) + assert result == _VALID_STATE_RETURN + + def test_sets_retcode_on_non_zero_envelope(self): + envelope = {"return": _VALID_STATE_RETURN, "retcode": 2} + _, context = self._run(envelope) + assert context.get("retcode") == 2 + + def test_zero_retcode_does_not_set_context_retcode(self): + """A clean run (retcode 0) must not inject a non-zero retcode into context.""" + envelope = {"return": _VALID_STATE_RETURN, "retcode": 0} + _, context = self._run(envelope) + assert context.get("retcode", 0) == 0 + + def test_single_receives_fsclient(self): + """Single must be constructed with a fsclient so cmd_block can call mod_data.""" + import salt.modules.sshresource_state as mod + + opts = _BASE_OPTS.copy() + mock_fsclient = MagicMock() + + with patch.object(mod, "__opts__", opts, create=True), patch.object( + mod, "__resource__", dict(_BASE_RESOURCE), create=True + ), patch.object(mod, "__context__", {}, create=True), patch.object( + mod, "__salt__", {}, create=True + ), patch.object( + mod, "_resource_id", return_value=_RESOURCE_ID + ), patch.object( + mod, "_relenv_path", return_value="/tmp/relenv.tar.xz" + ), patch.object( + mod, "_file_client", return_value=mock_fsclient + ), patch.object( + mod, "_connection_kwargs", return_value={} + ), patch( + "salt.utils.hashutils.get_hash", return_value="abc123" + ), patch( + "os.remove" + ), patch( + "salt.client.ssh.Single" + ) as mock_single_cls, patch( + "salt.client.ssh.wrapper.parse_ret", + return_value={"return": _VALID_STATE_RETURN, "retcode": 0}, + ): + mock_single = MagicMock() + mock_single.cmd_block.return_value = ("", "", 0) + mock_single.shell = MagicMock() + mock_single_cls.return_value = mock_single + + mod._exec_state_pkg(opts, "/tmp/fake.tgz", False) + + _, kwargs = mock_single_cls.call_args + assert kwargs.get("fsclient") is mock_fsclient diff --git a/tests/pytests/unit/resources/__init__.py b/tests/pytests/unit/resources/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/pytests/unit/resources/test_dummy_resource_grains.py b/tests/pytests/unit/resources/test_dummy_resource_grains.py new file mode 100644 index 000000000000..2372946a1168 --- /dev/null +++ b/tests/pytests/unit/resources/test_dummy_resource_grains.py @@ -0,0 +1,99 @@ +""" +Unit tests for ``salt.resource.dummy``'s per-resource grain plugin. + +Each individual dummy resource has its own ``grains`` function (the resource +analogue of a minion grain plugin). The minion's ``_thread_return`` swaps +``__grains__`` for ``resource_funcs[f"{type}.grains"]()`` before running a +function for a resource target, so this module is what eventually populates +``__grains__`` for a dispatched ``grains.items`` call against a dummy resource. +""" + +import contextlib + +import pytest + +import salt.resource.dummy as dummy_mod +from tests.support.mock import patch + +_RESOURCE_ID = "dummy-01" + + +def _module_dunders(opts, resource_id): + """Inject the dunders the resource loader normally provides.""" + return [ + patch.object(dummy_mod, "__opts__", opts, create=True), + patch.object( + dummy_mod, "__resource__", {"id": resource_id, "type": "dummy"}, create=True + ), + ] + + +@pytest.fixture +def dummy_opts(tmp_path): + return {"cachedir": str(tmp_path)} + + +def test_grains_returns_expected_keys(dummy_opts): + """``grains()`` must return the resource's static grain set keyed by id.""" + with contextlib.ExitStack() as stack: + for p in _module_dunders(dummy_opts, _RESOURCE_ID): + stack.enter_context(p) + result = dummy_mod.grains() + assert result == { + "dummy_grain_1": "one", + "dummy_grain_2": "two", + "dummy_grain_3": "three", + "resource_id": _RESOURCE_ID, + } + + +def test_grains_resource_id_reflects_current_resource(dummy_opts): + """ + ``grains()`` reads the active resource id from ``__resource__`` (set by + ``_thread_return`` via ``resource_ctxvar``); two different resources must + see two different ``resource_id`` grain values. + """ + with contextlib.ExitStack() as stack: + for p in _module_dunders(dummy_opts, "dummy-02"): + stack.enter_context(p) + result = dummy_mod.grains() + assert result["resource_id"] == "dummy-02" + + +def test_grains_persists_to_state_cache(dummy_opts): + """ + ``grains()`` writes the rendered grain dict into the per-resource state + cache (``state["grains_cache"]``) so that ``grains_refresh`` has something + to invalidate. After one call the cachefile must contain the same dict. + """ + with contextlib.ExitStack() as stack: + for p in _module_dunders(dummy_opts, _RESOURCE_ID): + stack.enter_context(p) + dummy_mod.grains() + # Reach back into the state file via the module's own helper. + state = dummy_mod._load_state(dummy_opts, _RESOURCE_ID) + assert state.get("grains_cache") == { + "dummy_grain_1": "one", + "dummy_grain_2": "two", + "dummy_grain_3": "three", + "resource_id": _RESOURCE_ID, + } + + +def test_grains_refresh_invalidates_and_returns_fresh(dummy_opts): + """ + ``grains_refresh()`` must drop the cached entry and re-derive the grain + dict — the next ``grains()`` call sees a fresh dict, not a stale snapshot. + """ + with contextlib.ExitStack() as stack: + for p in _module_dunders(dummy_opts, _RESOURCE_ID): + stack.enter_context(p) + first = dummy_mod.grains() + # Mutate the cached state to a sentinel; if grains_refresh truly + # invalidates it, the sentinel must not survive. + state = dummy_mod._load_state(dummy_opts, _RESOURCE_ID) + state["grains_cache"] = {"stale": True} + dummy_mod._save_state(dummy_opts, _RESOURCE_ID, state) + refreshed = dummy_mod.grains_refresh() + assert refreshed == first, "grains_refresh must produce the canonical dict" + assert "stale" not in refreshed, "grains_refresh must clear the stale cache" diff --git a/tests/pytests/unit/resources/test_ssh_resource.py b/tests/pytests/unit/resources/test_ssh_resource.py new file mode 100644 index 000000000000..ed2a68b71876 --- /dev/null +++ b/tests/pytests/unit/resources/test_ssh_resource.py @@ -0,0 +1,115 @@ +""" +Unit tests for salt.resource.ssh. + +Covers the _make_single() helper which constructs a salt.client.ssh.Single +from inside a minion job thread — a code path that salt-ssh itself never +takes (salt-ssh always runs on the master). +""" + +import pytest + +from tests.support.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +_RESOURCE_ID = "node1" + +_BASE_OPTS = { + "id": "minion", + "cachedir": "/tmp", + "thin_dir": "/tmp/_salt_relenv_test", + "ssh_wipe": False, + "file_roots": {"base": ["/srv/salt"]}, + "pillar_roots": {"base": ["/srv/pillar"]}, + "module_dirs": [], + # ext_pillar intentionally absent — it is a master-only key +} + +_BASE_RESOURCE = {"id": _RESOURCE_ID, "type": "ssh"} + +_HOST_CFG = { + "host": "192.0.2.1", + "user": "root", + "port": 22, +} + + +def _patch_module(mod, extra_context=None): + """Return a stack of patches that give the module its dunder variables.""" + context = {"ssh_resource": {"master_opts": None, "_ssh_version": "8.0"}} + if extra_context: + context["ssh_resource"].update(extra_context) + return [ + patch.object(mod, "__opts__", _BASE_OPTS.copy(), create=True), + patch.object(mod, "__resource__", dict(_BASE_RESOURCE), create=True), + patch.object(mod, "__context__", context, create=True), + patch.object(mod, "__salt__", {}, create=True), + ] + + +# --------------------------------------------------------------------------- +# _make_single passes fsclient to Single +# --------------------------------------------------------------------------- + + +class TestMakeSingle: + """_make_single() must pass a fsclient to Single. + + Single.cmd_block() calls mod_data(self.fsclient) unconditionally + (added in the upstream relenv improvements merge). If fsclient is + None that call raises: + + AttributeError: 'NoneType' object has no attribute 'opts' + + The fix is for _make_single to call _file_client() and forward the + result as the fsclient= keyword argument to Single.__init__. + """ + + def test_single_receives_fsclient(self): + import contextlib + + import salt.resource.ssh as mod + + mock_fsclient = MagicMock() + mock_single_cls = MagicMock() + + fixed_patches = [ + patch.object(mod, "_host_cfg", return_value=_HOST_CFG), + patch.object(mod, "_relenv_path", return_value="/tmp/fake-relenv.tar.xz"), + patch.object(mod, "_file_client", return_value=mock_fsclient), + patch.object(mod, "_thin_dir", return_value="/tmp/_salt_relenv_test"), + patch("salt.client.ssh.Single", mock_single_cls), + patch("salt.client.ssh.ssh_version", return_value="8.0"), + ] + _patch_module(mod) + + with contextlib.ExitStack() as stack: + for p in fixed_patches: + stack.enter_context(p) + mod._make_single(_RESOURCE_ID, ["grains.items"]) + + _, kwargs = mock_single_cls.call_args + assert kwargs.get("fsclient") is mock_fsclient, ( + "_make_single must pass fsclient= to Single so that " + "cmd_block() can call mod_data(fsclient) without crashing" + ) + + def test_fsclient_none_would_crash(self): + """Confirm that omitting fsclient causes the crash this fix prevents. + + This test documents *why* the fix is needed: if fsclient is None, + mod_data() raises AttributeError on 'NoneType'.opts. + """ + + def fake_mod_data(fsclient): + if fsclient is None: + raise AttributeError("'NoneType' object has no attribute 'opts'") + return {} + + with patch("salt.client.ssh.mod_data", fake_mod_data): + with pytest.raises(AttributeError, match="NoneType.*opts"): + fake_mod_data(None) + + # With a real fsclient it works fine + assert fake_mod_data(MagicMock()) == {} diff --git a/tests/pytests/unit/runners/test_index.py b/tests/pytests/unit/runners/test_index.py new file mode 100644 index 000000000000..1e1c0033c30e --- /dev/null +++ b/tests/pytests/unit/runners/test_index.py @@ -0,0 +1,107 @@ +import pytest + +import salt.runners.index as index_runner +import salt.utils.resource_registry as rr +from salt.exceptions import SaltInvocationError +from tests.support.mock import patch + + +@pytest.fixture(autouse=True) +def configure_loader_modules(): + return {index_runner: {"__opts__": {}}} + + +class _FakeCache: + def __init__(self): + self.banks = {} + + def fetch(self, bank, key): + return self.banks.get(bank, {}).get(key) + + def store(self, bank, key, value): + self.banks.setdefault(bank, {})[key] = value + + +@pytest.fixture +def opts_pki(tmp_path): + pki_dir = tmp_path / "pki" + pki_dir.mkdir() + for subdir in ["minions", "minions_pre", "minions_rejected"]: + (pki_dir / subdir).mkdir() + + return { + "pki_dir": str(pki_dir), + "sock_dir": str(tmp_path / "sock"), + "cachedir": str(tmp_path / "cache"), + "pki_index_enabled": True, + "pki_index_size": 100, + "pki_index_slot_size": 64, + } + + +@pytest.fixture +def opts_resources(tmp_path): + return { + "cachedir": str(tmp_path / "cache"), + "sock_dir": str(tmp_path / "sock"), + } + + +def test_list_indexes(): + got = index_runner.list_indexes() + assert "pki" in got + assert "resources" in got + + +def test_compact_unknown_name(): + with pytest.raises(SaltInvocationError): + index_runner.compact("nosuchindex") + + +def test_compact_empty_name(): + with pytest.raises(SaltInvocationError): + index_runner.compact("") + + +def test_status_pki_empty(opts_pki): + with patch.dict(index_runner.__opts__, opts_pki): + result = index_runner.status("pki") + assert "PKI Index Status" in result + assert "Occupied: 0" in result + + +def test_compact_pki_rebuild(opts_pki, tmp_path): + pki_dir = tmp_path / "pki" + (pki_dir / "minions" / "minion1").write_text("fake_key_1") + (pki_dir / "minions" / "minion2").write_text("fake_key_2") + (pki_dir / "minions_pre" / "minion3").write_text("fake_key_3") + + with patch.dict(index_runner.__opts__, {**opts_pki, "pki_index_enabled": True}): + result = index_runner.compact("pki", dry_run=False) + assert "successfully" in result + assert "3" in result + + +def test_compact_pki_alias_keys(opts_pki, tmp_path): + (tmp_path / "pki" / "minions" / "m1").write_text("k") + with patch.dict(index_runner.__opts__, opts_pki): + result = index_runner.compact("keys") + assert "successfully" in result + + +def test_compact_resources_dry_run(opts_resources): + reg = rr.ResourceRegistry(opts_resources, cache=_FakeCache()) + with patch.dict(index_runner.__opts__, opts_resources): + with patch("salt.utils.resource_registry.get_registry", return_value=reg): + result = index_runner.status("resources") + assert "Resource index status" in result + assert "Path" in result + + +def test_compact_resources_after_register(opts_resources): + reg = rr.ResourceRegistry(opts_resources, cache=_FakeCache()) + with patch.dict(index_runner.__opts__, opts_resources): + with patch("salt.utils.resource_registry.get_registry", return_value=reg): + reg.register_minion("m1", {"ssh": ["a", "b"]}) + result = index_runner.compact("resources", dry_run=False) + assert "Resource index compacted" in result diff --git a/tests/pytests/unit/runners/test_pki.py b/tests/pytests/unit/runners/test_pki.py index c169a6cffcee..2f34854be19a 100644 --- a/tests/pytests/unit/runners/test_pki.py +++ b/tests/pytests/unit/runners/test_pki.py @@ -1,9 +1,18 @@ +import warnings + import pytest import salt.runners.pki from tests.support.mock import patch +@pytest.fixture(autouse=True) +def _silence_pki_runner_deprecation(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=DeprecationWarning) + yield + + @pytest.fixture def opts(tmp_path): pki_dir = tmp_path / "pki" diff --git a/tests/pytests/unit/runners/test_resource.py b/tests/pytests/unit/runners/test_resource.py new file mode 100644 index 000000000000..5d509ae6fe5b --- /dev/null +++ b/tests/pytests/unit/runners/test_resource.py @@ -0,0 +1,124 @@ +""" +Unit tests for ``salt-run resource.*`` operator helpers. +""" + +import pytest + +import salt.runners.resource as resource_runner +from tests.support.mock import MagicMock, patch + + +@pytest.fixture(autouse=True) +def configure_loader_modules(): + return {resource_runner: {"__opts__": {}}} + + +def _patched_cache(entries): + """Return a fake ``salt.cache.factory(...)`` returning ``entries``.""" + cache = MagicMock() + cache.list = MagicMock( + side_effect=lambda bank: ( + list(entries.keys()) if bank == "resource_grains" else [] + ) + ) + cache.fetch = MagicMock( + side_effect=lambda bank, key: ( + entries.get(key) if bank == "resource_grains" else None + ) + ) + return cache + + +def test_show_grains_returns_cached_dict(): + entries = {"dummy:dummy-01": {"env": "prod", "id": "dummy-01"}} + fake = _patched_cache(entries) + with patch.object(resource_runner, "_resource_grains_cache", return_value=fake): + result = resource_runner.show_grains(type="dummy", id="dummy-01") + assert result == {"env": "prod", "id": "dummy-01"} + + +def test_show_grains_returns_none_for_missing_srn(): + fake = _patched_cache({}) + with patch.object(resource_runner, "_resource_grains_cache", return_value=fake): + result = resource_runner.show_grains(type="dummy", id="ghost") + assert result is None + + +def test_show_grains_returns_none_for_empty_args(): + assert resource_runner.show_grains(type="", id="dummy-01") is None + assert resource_runner.show_grains(type="dummy", id="") is None + + +def test_show_grains_swallows_cache_errors(): + cache = MagicMock() + cache.fetch = MagicMock(side_effect=RuntimeError("boom")) + with patch.object(resource_runner, "_resource_grains_cache", return_value=cache): + assert resource_runner.show_grains(type="dummy", id="dummy-01") is None + + +def test_list_grains_summarises_every_entry(): + entries = { + "dummy:dummy-01": {"env": "prod", "role": "web"}, + "ssh:node1": {"env": "staging", "role": "db", "tier": "1"}, + } + fake = _patched_cache(entries) + with patch.object(resource_runner, "_resource_grains_cache", return_value=fake): + result = resource_runner.list_grains() + assert set(result.keys()) == {"dummy:dummy-01", "ssh:node1"} + assert result["dummy:dummy-01"] == { + "grain_keys": ["env", "role"], + "grain_count": 2, + } + assert result["ssh:node1"] == { + "grain_keys": ["env", "role", "tier"], + "grain_count": 3, + } + + +def test_list_grains_handles_empty_bank(): + fake = _patched_cache({}) + with patch.object(resource_runner, "_resource_grains_cache", return_value=fake): + assert resource_runner.list_grains() == {} + + +def test_list_grains_skips_non_dict_entries(): + entries = { + "dummy:bad": "not-a-dict", + "dummy:good": {"k": "v"}, + } + fake = _patched_cache(entries) + with patch.object(resource_runner, "_resource_grains_cache", return_value=fake): + result = resource_runner.list_grains() + assert "dummy:bad" not in result + assert result["dummy:good"]["grain_count"] == 1 + + +def test_refresh_fires_event_to_named_minion(): + fake_evt = MagicMock() + cm = MagicMock() + cm.__enter__ = MagicMock(return_value=fake_evt) + cm.__exit__ = MagicMock(return_value=False) + with patch("salt.utils.event.get_event", return_value=cm), patch.dict( + resource_runner.__opts__, {"sock_dir": "/tmp/sock"} + ): + result = resource_runner.refresh(minion="resources-minion") + assert result is True + fake_evt.fire_event.assert_called_once_with( + {"minion": "resources-minion"}, "minion/resources-minion/resource_refresh" + ) + + +def test_refresh_returns_false_on_empty_minion(): + assert resource_runner.refresh(minion="") is False + + +def test_refresh_swallows_event_errors(): + fake_evt = MagicMock() + fake_evt.fire_event.side_effect = RuntimeError("boom") + cm = MagicMock() + cm.__enter__ = MagicMock(return_value=fake_evt) + cm.__exit__ = MagicMock(return_value=False) + with patch("salt.utils.event.get_event", return_value=cm), patch.dict( + resource_runner.__opts__, {"sock_dir": "/tmp/sock"} + ): + assert resource_runner.refresh(minion="resources-minion") is False diff --git a/tests/pytests/unit/test_client.py b/tests/pytests/unit/test_client.py index c300448927af..b74aa1578945 100644 --- a/tests/pytests/unit/test_client.py +++ b/tests/pytests/unit/test_client.py @@ -58,6 +58,294 @@ def mock_cmd(tgt, fun, *args, **kwargs): assert target_called in (["minion1"], ["minion2"]) +def test_job_result_return_success(master_opts): + """ + Should return the `expected_return`, since there is a job with the right jid. + """ + minions = () + jid = "0815" + raw_return = {"id": "fake-id", "jid": jid, "data": "", "return": "fake-return"} + expected_return = {"fake-id": {"ret": "fake-return"}} + with client.LocalClient(mopts=master_opts) as local_client: + local_client.event.get_event = MagicMock(return_value=raw_return) + local_client.returners = MagicMock() + ret = local_client.get_event_iter_returns(jid, minions) + val = next(ret) + assert val == expected_return + + +def test_job_result_return_uses_resource_id_key(master_opts): + """ + Resource returns carry ``resource_id`` while ``id`` stays the managing minion; + the client must key CLI output by resource id. + """ + minions = () + jid = "0815" + raw_return = { + "id": "minion-1", + "resource_id": "m1-dummy1", + "jid": jid, + "return": True, + } + expected_return = {"m1-dummy1": {"ret": True}} + with client.LocalClient(mopts=master_opts) as local_client: + local_client.event.get_event = MagicMock(return_value=raw_return) + local_client.returners = MagicMock() + ret = local_client.get_event_iter_returns(jid, minions) + val = next(ret) + assert val == expected_return + + +def test_get_iter_returns_resource_id_display_key(master_opts): + jid = "20260101000000000001" + + def returns_iter(): + yield { + "tag": f"salt/job/{jid}/ret/m1-dummy1", + "data": { + "return": True, + "id": "minion-1", + "resource_id": "m1-dummy1", + "jid": jid, + }, + } + + with client.LocalClient(mopts=master_opts) as local_client: + local_client.returns_for_job = MagicMock(return_value=True) + local_client.get_returns_no_block = MagicMock(return_value=returns_iter()) + out = list( + local_client.get_iter_returns( + jid, {"minion-1", "m1-dummy1"}, gather_job_timeout=1 + ) + ) + assert out == [{"m1-dummy1": {"ret": True, "jid": jid}}] + + +def test_iter_failed_missing_returns_expands_managed_resources(master_opts): + from salt.client import _iter_failed_missing_returns + + class _FakeReg: + def get_resources_for_minion(self, mid): + if mid == "minion-1": + return {"dummy": ["r1", "r2"]} + return {} + + class _FakeCk: + registry = _FakeReg() + + with patch("salt.utils.minions.CkMinions", return_value=_FakeCk()): + keys = [ + next(iter(chunk)) + for chunk in _iter_failed_missing_returns(master_opts, set(), {"minion-1"}) + ] + assert keys == ["minion-1", "r1", "r2"] + + +def test_iter_failed_missing_returns_uses_grains_when_registry_empty(master_opts): + """ + Offline minions may disappear from the mmap registry before the CLI runs; + ``salt_resources`` in minion_data_cache must still drive expansion. + """ + from salt.client import _iter_failed_missing_returns + + class _FakeReg: + def get_resources_for_minion(self, mid): + return {} + + class _FakeCk: + registry = _FakeReg() + + class _FakeCache: + def contains(self, bank, key): + return bank == "grains" and key == "minion-3" + + def fetch(self, bank, key): + return { + "salt_resources": { + "dummy": ["m3-dummy1", "m3-dummy2", "m3-dummy3"], + } + } + + mopts = dict(master_opts) + mopts["minion_data_cache"] = True + + with patch("salt.utils.minions.CkMinions", return_value=_FakeCk()): + with patch("salt.cache.factory", return_value=_FakeCache()): + keys = [ + next(iter(chunk)) + for chunk in _iter_failed_missing_returns(mopts, set(), {"minion-3"}) + ] + assert keys == ["minion-3", "m3-dummy1", "m3-dummy2", "m3-dummy3"] + + +def test_iter_failed_missing_returns_uses_pillar_when_registry_and_grains_empty( + master_opts, +): + from salt.client import _iter_failed_missing_returns + + class _FakeReg: + def get_resources_for_minion(self, mid): + return {} + + class _FakeCk: + registry = _FakeReg() + + class _FakeCache: + def __init__(self): + self._grains = {} + self._pillar = { + "minion-3": { + "resources": { + "dummy": { + "resource_ids": [ + "m3-dummy1", + "m3-dummy2", + "m3-dummy3", + ], + }, + }, + } + } + + def contains(self, bank, key): + if bank == "grains": + return key in self._grains + if bank == "pillar": + return key in self._pillar + return False + + def fetch(self, bank, key): + if bank == "grains": + return self._grains.get(key, {}) + if bank == "pillar": + return self._pillar.get(key, {}) + return {} + + mopts = dict(master_opts) + mopts["minion_data_cache"] = True + + with patch("salt.utils.minions.CkMinions", return_value=_FakeCk()): + with patch("salt.cache.factory", return_value=_FakeCache()): + keys = [ + next(iter(chunk)) + for chunk in _iter_failed_missing_returns(mopts, set(), {"minion-3"}) + ] + assert keys == ["minion-3", "m3-dummy1", "m3-dummy2", "m3-dummy3"] + + +def test_iter_failed_missing_returns_skips_resources_already_found(master_opts): + from salt.client import _iter_failed_missing_returns + + class _FakeReg: + def get_resources_for_minion(self, mid): + if mid == "minion-1": + return {"dummy": ["r1", "r2"]} + return {} + + class _FakeCk: + registry = _FakeReg() + + with patch("salt.utils.minions.CkMinions", return_value=_FakeCk()): + keys = [ + next(iter(chunk)) + for chunk in _iter_failed_missing_returns(master_opts, {"r1"}, {"minion-1"}) + ] + assert keys == ["minion-1", "r2"] + + +def test_iter_failed_missing_returns_pki_minions_before_bare_resource_ids(master_opts): + """ + Glob wait-lists sort bare resource IDs before managing minions; PKI minions + must still be expanded first so pillar/registry rows are not skipped. + """ + from salt.client import _iter_failed_missing_returns + + class _FakeReg: + def get_resources_for_minion(self, mid): + return {} + + class _FakeCk: + registry = _FakeReg() + + def _pki_minions(self): + return {"z-minion"} + + class _FakeCache: + def contains(self, bank, key): + return bank == "pillar" and key == "z-minion" + + def fetch(self, bank, key): + if bank == "pillar" and key == "z-minion": + return { + "resources": { + "dummy": {"resource_ids": ["a-res", "b-res"]}, + }, + } + return {} + + mopts = dict(master_opts) + mopts["minion_data_cache"] = True + + with patch("salt.utils.minions.CkMinions", return_value=_FakeCk()): + with patch("salt.cache.factory", return_value=_FakeCache()): + keys = [ + next(iter(chunk)) + for chunk in _iter_failed_missing_returns( + mopts, + set(), + {"a-res", "z-minion", "b-res"}, + ) + ] + assert keys == ["z-minion", "a-res", "b-res"] + + +def test_get_iter_returns_dedupes_replayed_resource_return(master_opts): + jid = "20260101000000000002" + + def returns_iter(): + ev = { + "tag": f"salt/job/{jid}/ret/m1-dummy1", + "data": { + "return": True, + "id": "minion-1", + "resource_id": "m1-dummy1", + "jid": jid, + }, + } + yield ev + yield ev + + with client.LocalClient(mopts=master_opts) as local_client: + local_client.returns_for_job = MagicMock(return_value=True) + local_client.get_returns_no_block = MagicMock(return_value=returns_iter()) + out = list( + local_client.get_iter_returns(jid, {"m1-dummy1"}, gather_job_timeout=1) + ) + assert out == [{"m1-dummy1": {"ret": True, "jid": jid}}] + + +def test_job_result_return_failure(master_opts): + """ + We are _not_ getting a job return, because the jid is different. Instead we should + get a StopIteration exception. + """ + minions = () + jid = "0815" + raw_return = { + "id": "fake-id", + "jid": "0816", + "data": "", + "return": "fake-return", + } + with client.LocalClient(mopts=master_opts) as local_client: + local_client.event.get_event = MagicMock() + local_client.event.get_event.side_effect = [raw_return, None] + local_client.returners = MagicMock() + ret = local_client.get_event_iter_returns(jid, minions) + with pytest.raises(StopIteration): + next(ret) + + def test_cmd_subset_cli(master_opts): """ Test LocalClient.cmd_subset when cli=True diff --git a/tests/pytests/unit/test_master.py b/tests/pytests/unit/test_master.py index 7eb2038903ff..103ea72ea82b 100644 --- a/tests/pytests/unit/test_master.py +++ b/tests/pytests/unit/test_master.py @@ -1390,6 +1390,216 @@ def test_on_demand_not_allowed(not_allowed_funcs, tmp_path, caplog): ) +def test_register_resources_updates_resource_index_when_minion_data_cache_disabled( + master_opts, + tmp_path, +): + """ + Resource mmap registration must not depend on minion pillar/grains caching. + + Regression: ``minion_data_cache: False`` skipped ``update_resource_index`` + entirely while still returning success to the minion. + """ + import salt.utils.resource_registry + + salt.utils.resource_registry.reset_registry() + opts = master_opts.copy() + opts["cachedir"] = str(tmp_path) + opts["minion_data_cache"] = False + opts.setdefault("resource_index_primary_capacity", 4096) + opts.setdefault("resource_index_primary_slot_size", 128) + + aes_funcs = salt.master.AESFuncs(opts) + try: + load = {"id": "minion-2", "resources": {"dummy": ["m2-dummy2"]}} + with patch( + "salt.utils.minions.update_resource_index", return_value=(1, 0) + ) as ur: + aes_funcs._register_resources(load) + ur.assert_called_once_with(opts, "minion-2", {"dummy": ["m2-dummy2"]}) + finally: + aes_funcs.destroy() + salt.utils.resource_registry.reset_registry() + + +def _make_aes_funcs_for_resource_grains(master_opts, tmp_path): + """Helper: build an ``AESFuncs`` ready for ``resource_grains`` testing.""" + import salt.utils.resource_registry + + salt.utils.resource_registry.reset_registry() + opts = master_opts.copy() + opts["cachedir"] = str(tmp_path) + opts["minion_data_cache"] = True + opts.setdefault("resource_index_primary_capacity", 4096) + opts.setdefault("resource_index_primary_slot_size", 128) + return salt.master.AESFuncs(opts), opts + + +def test_register_resources_persists_resource_grains_to_cache(master_opts, tmp_path): + """ + Each ``resource_grains[srn]`` entry in the registration load is written + into the master's ``resource_grains`` cache bank so ``-G``/``-P`` + targeting can later match them. + """ + import salt.utils.resource_registry + + aes_funcs, opts = _make_aes_funcs_for_resource_grains(master_opts, tmp_path) + try: + load = { + "id": "minion-2", + "resources": {"dummy": ["m2-d1", "m2-d2"]}, + "resource_grains": { + "dummy:m2-d1": {"k": "v1", "resource_id": "m2-d1"}, + "dummy:m2-d2": {"k": "v2", "resource_id": "m2-d2"}, + }, + } + with patch("salt.utils.minions.update_resource_index", return_value=(2, 0)): + aes_funcs._register_resources(load) + cache = aes_funcs.masterapi.cache + stored_keys = sorted(cache.list("resource_grains") or []) + assert stored_keys == ["dummy:m2-d1", "dummy:m2-d2"] + assert cache.fetch("resource_grains", "dummy:m2-d1") == { + "k": "v1", + "resource_id": "m2-d1", + } + assert cache.fetch("resource_grains", "dummy:m2-d2") == { + "k": "v2", + "resource_id": "m2-d2", + } + finally: + aes_funcs.destroy() + salt.utils.resource_registry.reset_registry() + + +def test_register_resources_flushes_dropped_resource_grain_entry(master_opts, tmp_path): + """ + Re-registering with a smaller resource set must flush the dropped + SRN's grain entry from the ``resource_grains`` bank when the registry + confirms no other minion now manages it. + """ + import salt.utils.resource_registry + + aes_funcs, opts = _make_aes_funcs_for_resource_grains(master_opts, tmp_path) + try: + # First registration: minion owns m2-d1 and m2-d2. + load1 = { + "id": "minion-2", + "resources": {"dummy": ["m2-d1", "m2-d2"]}, + "resource_grains": { + "dummy:m2-d1": {"k": "v1"}, + "dummy:m2-d2": {"k": "v2"}, + }, + } + # Real ``update_resource_index`` so the registry actually tracks + # ownership for the flush owner-check. + aes_funcs._register_resources(load1) + cache = aes_funcs.masterapi.cache + assert sorted(cache.list("resource_grains") or []) == [ + "dummy:m2-d1", + "dummy:m2-d2", + ] + # Second registration: minion drops m2-d2. + load2 = { + "id": "minion-2", + "resources": {"dummy": ["m2-d1"]}, + "resource_grains": {"dummy:m2-d1": {"k": "v1-updated"}}, + } + aes_funcs._register_resources(load2) + # The flush must remove the orphaned SRN. + remaining = sorted(cache.list("resource_grains") or []) + assert remaining == ["dummy:m2-d1"] + # And the surviving entry must reflect the most recent payload. + assert cache.fetch("resource_grains", "dummy:m2-d1") == {"k": "v1-updated"} + finally: + aes_funcs.destroy() + salt.utils.resource_registry.reset_registry() + + +def test_register_resources_does_not_flush_srn_owned_by_other_minion( + master_opts, tmp_path +): + """ + Two minions managing different SRNs must not stomp on each other's + ``resource_grains`` entries during re-registration. When minion-A drops + a SRN that minion-B owns (rare but possible if the registry was + re-keyed), the flush must skip it. + """ + import salt.utils.resource_registry + + aes_funcs, opts = _make_aes_funcs_for_resource_grains(master_opts, tmp_path) + try: + # minion-A registers dummy:shared. + aes_funcs._register_resources( + { + "id": "minion-A", + "resources": {"dummy": ["shared"]}, + "resource_grains": {"dummy:shared": {"who": "A"}}, + } + ) + # minion-B claims dummy:shared (registry overwrites the SRN's owner). + aes_funcs._register_resources( + { + "id": "minion-B", + "resources": {"dummy": ["shared"]}, + "resource_grains": {"dummy:shared": {"who": "B"}}, + } + ) + cache = aes_funcs.masterapi.cache + assert cache.fetch("resource_grains", "dummy:shared") == {"who": "B"} + # minion-A re-registers with no resources. Its flush walk would + # consider dummy:shared "stale"; the owner check (registry says B + # owns it) must prevent the flush. + aes_funcs._register_resources( + { + "id": "minion-A", + "resources": {}, + "resource_grains": {}, + } + ) + assert cache.fetch("resource_grains", "dummy:shared") == {"who": "B"} + finally: + aes_funcs.destroy() + salt.utils.resource_registry.reset_registry() + + +def test_register_resources_resource_grains_visible_across_aes_funcs_instances( + master_opts, tmp_path +): + """ + The ``resource_grains`` bank lives on the filesystem (localfs cache) + so a second master worker (modelled by a fresh ``AESFuncs`` instance + under the same ``cachedir``) sees the entries that the first worker + wrote. Without this guarantee, multi-worker masters would silently + fail grain-based resource targeting on workers that didn't handle the + minion's registration. + """ + import salt.utils.resource_registry + + aes_funcs_a, opts = _make_aes_funcs_for_resource_grains(master_opts, tmp_path) + try: + aes_funcs_a._register_resources( + { + "id": "minion-2", + "resources": {"dummy": ["m2-d1"]}, + "resource_grains": {"dummy:m2-d1": {"env": "prod"}}, + } + ) + finally: + aes_funcs_a.destroy() + # Reset only the registry singleton — the localfs cache on disk is + # what we're verifying survives. + salt.utils.resource_registry.reset_registry() + + # Second worker reads the same on-disk cachedir. + aes_funcs_b = salt.master.AESFuncs(opts) + try: + cache_b = aes_funcs_b.masterapi.cache + assert cache_b.fetch("resource_grains", "dummy:m2-d1") == {"env": "prod"} + finally: + aes_funcs_b.destroy() + salt.utils.resource_registry.reset_registry() + + async def test_collect__auth_to_master_stats(): """ Check if master stats is collecting _auth calls while not calling neither _handle_aes nor _handle_clear @@ -1413,3 +1623,63 @@ async def test_collect__auth_to_master_stats(): assert mworker.stats["_auth"]["mean"] < 0.04 handle_aes_mock.assert_not_called() handle_clear_mock.assert_not_called() + + +def test_register_resources_concurrent_workers_no_data_loss(master_opts, tmp_path): + """ + Two simulated master workers concurrently registering different + minions must not stomp on each other's ``resource_grains`` entries. + Each worker writes the entry it owns; the flush owner-check defends + against the case where one worker's "drop stale" walk encounters an + SRN that another worker has just claimed. + """ + import threading + + import salt.utils.resource_registry + + salt.utils.resource_registry.reset_registry() + opts = master_opts.copy() + opts["cachedir"] = str(tmp_path) + opts["minion_data_cache"] = True + opts.setdefault("resource_index_primary_capacity", 4096) + opts.setdefault("resource_index_primary_slot_size", 128) + + # Two AESFuncs sharing the same on-disk cachedir. + aes_a = salt.master.AESFuncs(opts) + aes_b = salt.master.AESFuncs(opts) + try: + errs = [] + barrier = threading.Barrier(2) + + def _register(aes, minion_id, resource_id, grain_value): + try: + barrier.wait(timeout=10) + aes._register_resources( + { + "id": minion_id, + "resources": {"dummy": [resource_id]}, + "resource_grains": { + f"dummy:{resource_id}": {"who": grain_value} + }, + } + ) + except Exception as exc: # pylint: disable=broad-except + errs.append(exc) + + t1 = threading.Thread(target=_register, args=(aes_a, "minion-A", "rA", "A")) + t2 = threading.Thread(target=_register, args=(aes_b, "minion-B", "rB", "B")) + t1.start() + t2.start() + t1.join() + t2.join() + + assert not errs, errs + cache = aes_a.masterapi.cache + # Both entries must survive: neither worker's flush walk should + # have wiped the other's entry. + assert cache.fetch("resource_grains", "dummy:rA") == {"who": "A"} + assert cache.fetch("resource_grains", "dummy:rB") == {"who": "B"} + finally: + aes_a.destroy() + aes_b.destroy() + salt.utils.resource_registry.reset_registry() diff --git a/tests/pytests/unit/test_minion_resources.py b/tests/pytests/unit/test_minion_resources.py new file mode 100644 index 000000000000..49db0a3a74f5 --- /dev/null +++ b/tests/pytests/unit/test_minion_resources.py @@ -0,0 +1,1052 @@ +""" +Tests for resource dispatch logic in salt.minion. + +Covers: +- Minion._resolve_resource_targets(): what resource jobs each minion spawns +- gen_modules() atomic resource_loaders assignment: Race 2 fix +- resource_ctxvar injection in _thread_return: Race 1 fix +""" + +import threading + +import pytest + +import salt.loader.context +import salt.minion +from tests.support.mock import patch as mock_patch + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +RESOURCES = { + "dummy": ["dummy-01", "dummy-02", "dummy-03"], + "ssh": ["node1", "localhost"], +} + + +@pytest.fixture +def minion_with_resources(minion_opts): + """A Minion instance with resources configured, no real master connection.""" + minion_opts["resources"] = RESOURCES + minion_opts["multiprocessing"] = False + with mock_patch("salt.minion.Minion.gen_modules"): + with mock_patch("salt.minion.Minion.connect_master"): + m = salt.minion.Minion(minion_opts, load_grains=False) + return m + + +# --------------------------------------------------------------------------- +# _resolve_resource_targets tests +# --------------------------------------------------------------------------- + + +def test_resolve_resource_targets_glob_wildcard(minion_with_resources): + """ + A broad glob ('*') with no resource-aware tgt_type returns all managed + resources so that every resource job is dispatched. + """ + load = {"tgt": "*", "tgt_type": "glob", "fun": "test.ping", "arg": []} + targets = minion_with_resources._resolve_resource_targets(load) + ids = [t["id"] for t in targets] + assert set(ids) == {"dummy-01", "dummy-02", "dummy-03", "node1", "localhost"} + types = {t["type"] for t in targets} + assert types == {"dummy", "ssh"} + + +def test_resolve_resource_targets_glob_specific_minion(minion_with_resources): + """ + A specific name glob (no wildcard characters) must NOT dispatch to + resources when ``tgt`` is not a managed resource id. ``salt 'minion'`` + should only run on the minion itself, not on its managed resources. + """ + load = {"tgt": "minion", "tgt_type": "glob", "fun": "test.ping", "arg": []} + targets = minion_with_resources._resolve_resource_targets(load) + assert targets == [], "non-resource specific-name glob must not dispatch" + + +def test_resolve_resource_targets_glob_exact_managed_resource_id(minion_with_resources): + """Exact glob with no wildcards must dispatch when ``tgt`` is a resource id.""" + load = {"tgt": "dummy-02", "tgt_type": "glob", "fun": "test.ping", "arg": []} + targets = minion_with_resources._resolve_resource_targets(load) + assert targets == [{"id": "dummy-02", "type": "dummy"}] + + +def test_resolve_resource_targets_list_bare_ids(minion_with_resources): + load = { + "tgt": "dummy-02,node1", + "tgt_type": "list", + "fun": "test.ping", + "arg": [], + } + targets = minion_with_resources._resolve_resource_targets(load) + ids_types = {(t["id"], t["type"]) for t in targets} + assert ids_types == {("dummy-02", "dummy"), ("node1", "ssh")} + + +def test_resolve_resource_targets_compound_T_full_srn(minion_with_resources): + """T@dummy:dummy-01 in a compound expression returns exactly that resource.""" + load = { + "tgt": "T@dummy:dummy-01", + "tgt_type": "compound", + "fun": "test.ping", + "arg": [], + } + targets = minion_with_resources._resolve_resource_targets(load) + assert targets == [{"id": "dummy-01", "type": "dummy"}] + + +def test_resolve_resource_targets_compound_T_bare_type(minion_with_resources): + """T@dummy returns all dummy resources.""" + load = { + "tgt": "T@dummy", + "tgt_type": "compound", + "fun": "test.ping", + "arg": [], + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = [t["id"] for t in targets] + assert set(ids) == {"dummy-01", "dummy-02", "dummy-03"} + assert all(t["type"] == "dummy" for t in targets) + + +def test_resolve_resource_targets_compound_no_T(minion_with_resources): + """A compound expression with no T@ or M@ terms dispatches no resource jobs.""" + load = { + "tgt": "G@os:Debian", + "tgt_type": "compound", + "fun": "test.ping", + "arg": [], + } + targets = minion_with_resources._resolve_resource_targets(load) + assert targets == [] + + +def test_resolve_resource_targets_no_resources(minion_opts): + """A minion with no resources configured never dispatches resource jobs.""" + minion_opts.pop("resources", None) + with mock_patch("salt.minion.Minion.gen_modules"): + with mock_patch("salt.minion.Minion.connect_master"): + m = salt.minion.Minion(minion_opts, load_grains=False) + load = {"tgt": "*", "tgt_type": "glob", "fun": "test.ping", "arg": []} + assert m._resolve_resource_targets(load) == [] + + +def test_resolve_resource_targets_no_resource_funs(minion_with_resources): + """ + Internal Salt plumbing functions are never dispatched to resources, even + for a wildcard target. + """ + for fun in salt.minion.Minion._NO_RESOURCE_FUNS: + load = {"tgt": "*", "tgt_type": "glob", "fun": fun, "arg": []} + assert minion_with_resources._resolve_resource_targets(load) == [], fun + + +def test_resolve_resource_targets_T_with_trailing_colon(minion_with_resources): + """T@dummy: (trailing colon) is treated as a bare type, not a specific ID.""" + load = { + "tgt": "T@dummy:", + "tgt_type": "compound", + "fun": "test.ping", + "arg": [], + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + assert ids == {"dummy-01", "dummy-02", "dummy-03"} + + +# --------------------------------------------------------------------------- +# gen_modules() atomic resource_loaders assignment (Race 2 fix) +# --------------------------------------------------------------------------- + + +def test_gen_modules_resource_loaders_atomic_assignment(): + """ + The atomic build-then-assign pattern ensures self.resource_loaders is + never transiently empty between gen_modules() calls. + + We verify this by confirming that in the gen_modules source the actual + assignment ``self.resource_loaders = _new_resource_loaders`` appears, and + that no bare ``self.resource_loaders = {}`` statement (outside comments) + is present. + """ + import inspect + + source = inspect.getsource(salt.minion.MinionBase.gen_modules) + # The safe atomic-assign terminal statement must be present. + assert "self.resource_loaders = _new_resource_loaders" in source + # Verify no executable bare-clear line exists (comments are OK). + executable_lines = [ + ln for ln in source.splitlines() if not ln.lstrip().startswith("#") + ] + assert not any( + "self.resource_loaders = {}" in ln for ln in executable_lines + ), "Found bare self.resource_loaders = {} outside a comment — Race 2 regression" + + +# --------------------------------------------------------------------------- +# resource_ctxvar injection in _thread_return (Race 1 fix) +# --------------------------------------------------------------------------- + + +def test_resource_ctxvar_set_before_function_executes(): + """ + _thread_return sets resource_ctxvar to the resource_target dict before the + job function runs. This test simulates the critical section and confirms + the ctxvar carries the right value into a copy_context() snapshot — the + mechanism that makes the fix thread-safe. + """ + import contextvars + + target = {"id": "dummy-01", "type": "dummy"} + + # Simulate what _thread_return does. + tok = salt.loader.context.resource_ctxvar.set(target) + try: + # copy_context() is what LazyLoader.run() calls on every invocation. + ctx = contextvars.copy_context() + finally: + salt.loader.context.resource_ctxvar.reset(tok) + + # The copy captured the value; the current context is back to default. + assert salt.loader.context.resource_ctxvar.get() == {} + + # But inside the snapshot the target is visible — exactly as it will be + # inside _run_as when the module function reads __resource__. + seen = {} + ctx.run(lambda: seen.update({"val": salt.loader.context.resource_ctxvar.get()})) + assert seen["val"] is target + + +def test_resource_ctxvar_concurrent_threads_isolated(): + """ + Two threads setting resource_ctxvar concurrently never see each other's + values. This directly validates the fix for Race 1 (KeyError: 'id'). + """ + target_a = {"id": "dummy-01", "type": "dummy"} + target_b = {"id": "dummy-02", "type": "dummy"} + errors = [] + results = {} + barrier = threading.Barrier(2) + + def run_job(name, target): + try: + salt.loader.context.resource_ctxvar.set(target) + barrier.wait() # both threads set before either reads + val = salt.loader.context.resource_ctxvar.get() + if val is not target: + errors.append( + f"Thread {name}: expected {target['id']}, got {val.get('id')}" + ) + results[name] = val + except Exception as exc: # pylint: disable=broad-except + errors.append(str(exc)) + + t1 = threading.Thread(target=run_job, args=("a", target_a)) + t2 = threading.Thread(target=run_job, args=("b", target_b)) + t1.start() + t2.start() + t1.join() + t2.join() + + assert not errors, errors + assert results["a"] is target_a + assert results["b"] is target_b + + +# --------------------------------------------------------------------------- +# Per-resource grain swap in _thread_return +# --------------------------------------------------------------------------- + + +class _PackOnly: + """Minimal stand-in for a resource loader: exposes only ``.pack``.""" + + def __init__(self): + self.pack = {} + + +def _grain_swap(resource_target, resource_funcs, functions_to_use): + """ + Mirror the grain-swap branch of ``salt.minion.Minion._thread_return`` + (the four lines following ``resource_ctxvar.set(resource_target)``). + A regression in those lines should cause this helper to diverge from + the real method, which the source-inspection test below catches. + """ + resource_type = resource_target["type"] + grains_fn = f"{resource_type}.grains" + if grains_fn in resource_funcs: + functions_to_use.pack["__grains__"] = resource_funcs[grains_fn]() + + +def test_thread_return_grain_swap_packs_resource_grains(): + """ + When a resource job dispatches with ``resource_target`` and the resource + loader exposes ``.grains``, the swap must pack that function's + return value into ``functions_to_use.pack["__grains__"]`` so the job + sees the resource's grains, not the managing minion's. + """ + expected = { + "dummy_grain_1": "one", + "dummy_grain_2": "two", + "dummy_grain_3": "three", + "resource_id": "dummy-01", + } + resource_funcs = {"dummy.grains": lambda: expected} + functions_to_use = _PackOnly() + target = {"id": "dummy-01", "type": "dummy"} + _grain_swap(target, resource_funcs, functions_to_use) + assert functions_to_use.pack["__grains__"] is expected + + +def test_thread_return_grain_swap_skipped_without_grains_fn(): + """ + If the resource loader has no ``.grains`` callable, the swap must + leave ``functions_to_use.pack`` untouched — no ``__grains__`` key + appears, so the loader's pre-existing pack still drives the job. + """ + resource_funcs = {"dummy.ping": lambda: True} # no .grains + functions_to_use = _PackOnly() + target = {"id": "dummy-01", "type": "dummy"} + _grain_swap(target, resource_funcs, functions_to_use) + assert "__grains__" not in functions_to_use.pack + + +def test_thread_return_grain_swap_uses_resource_target_type(): + """ + Two resource types share one ``resource_funcs`` mapping; the swap must + pick the function keyed on ``resource_target["type"]``, not any global + default. Targeting an SSH resource must call ``ssh.grains``, not + ``dummy.grains`` even when both are registered. + """ + resource_funcs = { + "dummy.grains": lambda: {"who": "dummy"}, + "ssh.grains": lambda: {"who": "ssh"}, + } + functions_to_use = _PackOnly() + _grain_swap({"id": "node1", "type": "ssh"}, resource_funcs, functions_to_use) + assert functions_to_use.pack["__grains__"] == {"who": "ssh"} + + +def test_thread_return_grain_swap_source_inspection(): + """ + Catch a regression where someone removes the grain-swap from + ``_thread_return``. The local helper above mirrors those lines; if the + real method drops them, this test fails and the helper drifts out of + sync with reality. + """ + import inspect + + source = inspect.getsource(salt.minion.Minion._thread_return) + # All four anchor lines must be present in order, in the same scope as + # ``resource_ctxvar.set(resource_target)``. + assert "resource_ctxvar.set(resource_target)" in source + assert 'grains_fn = f"{resource_type}.grains"' in source + assert "if grains_fn in minion_instance.resource_funcs:" in source + assert 'functions_to_use.pack["__grains__"] =' in source + assert "minion_instance.resource_funcs[grains_fn]()" in source + + +# --------------------------------------------------------------------------- +# _discover_resources tests +# --------------------------------------------------------------------------- + + +def test_discover_resources_no_pillar_key_clears_like_empty(minion_with_resources): + """ + When the pillar contains no 'resources' key at all, _discover_resources + must behave like pillar['resources'] == {}: return {} and not preserve + stale opts["resources"]. + """ + minion_with_resources.opts["pillar"] = {} + result = minion_with_resources._discover_resources() + assert result == {} + + +def test_discover_resources_empty_pillar_key_clears_opts(minion_with_resources): + """ + When the pillar *does* contain a 'resources' key but its value is empty, + _discover_resources must return {} and NOT preserve the old opts resources. + This is the fix for the stale-cache bug: removing a resource type from the + pillar and running sync_all must clear it at runtime. + """ + minion_with_resources.opts["pillar"] = {"resources": {}} + result = minion_with_resources._discover_resources() + assert ( + result == {} + ), "_discover_resources must return {} when pillar['resources'] is empty" + + +# --------------------------------------------------------------------------- +# _register_resources_with_master tests +# --------------------------------------------------------------------------- + + +def test_register_resources_with_master_sends_empty_dict(minion_with_resources): + """ + _register_resources_with_master must send the registration even when + opts["resources"] is {}. Without this, removing resources from the pillar + and running sync_all leaves the master cache permanently stale. + """ + minion_with_resources.opts["resources"] = {} + sent_loads = [] + + async def fake_send(load, timeout=None): + sent_loads.append(load) + + import asyncio + + minion_with_resources.tok = b"test-tok" + with mock_patch.object( + minion_with_resources, + "_send_req_async_main", + side_effect=fake_send, + ): + asyncio.run(minion_with_resources._register_resources_with_master()) + + assert ( + len(sent_loads) == 1 + ), "_register_resources_with_master must always send a load, even for {}" + assert ( + sent_loads[0]["resources"] == {} + ), "An empty resource dict must be forwarded to the master to clear stale cache" + + +# --------------------------------------------------------------------------- +# _MERGE_RESOURCE_FUNS tests +# --------------------------------------------------------------------------- + + +def test_merge_resource_funs_contains_expected_state_functions(): + """All state-dispatch functions that should trigger merge mode are present.""" + expected = { + "state.apply", + "state.highstate", + "state.sls", + "state.sls_id", + "state.single", + } + assert expected <= salt.minion.Minion._MERGE_RESOURCE_FUNS + + +def test_merge_resource_funs_does_not_contain_test_ping(): + """test.ping must NOT be in _MERGE_RESOURCE_FUNS so it dispatches normally.""" + assert "test.ping" not in salt.minion.Minion._MERGE_RESOURCE_FUNS + + +def test_merge_resource_funs_is_frozenset(): + assert isinstance(salt.minion.Minion._MERGE_RESOURCE_FUNS, frozenset) + + +def test_merge_resource_funs_minions_and_minion_in_sync(): + """_MERGE_RESOURCE_FUNS must be identical in salt.minion and salt.utils.minions.""" + import salt.utils.minions as _minions_mod + + assert salt.minion.Minion._MERGE_RESOURCE_FUNS == _minions_mod._MERGE_RESOURCE_FUNS + + +# --------------------------------------------------------------------------- +# _prefix_resource_state_key tests +# --------------------------------------------------------------------------- + + +def test_prefix_resource_state_key_id_and_name_prefixed(): + """Both the id (comps[1]) and name (comps[2]) components gain the rid prefix.""" + key = "pkg_|-curl_|-curl_|-installed" + result = salt.minion.Minion._prefix_resource_state_key(key, "node1") + assert result == "pkg_|-node1 curl_|-node1 curl_|-installed" + + +def test_prefix_resource_state_key_preserves_module_and_function(): + """comps[0] (module) and comps[3] (function) are unchanged.""" + key = "pkg_|-curl_|-curl_|-installed" + result = salt.minion.Minion._prefix_resource_state_key(key, "node1") + parts = result.split("_|-") + assert parts[0] == "pkg" + assert parts[3] == "installed" + + +def test_prefix_resource_state_key_id_with_spaces(): + """Resource IDs containing spaces are handled correctly.""" + key = "service_|-nginx_|-nginx_|-running" + result = salt.minion.Minion._prefix_resource_state_key(key, "my host") + assert result == "service_|-my host nginx_|-my host nginx_|-running" + + +def test_prefix_resource_state_key_no_top_file_key(): + """The 'no_|-states_|-states_|-None' key used for empty-top returns is prefixed.""" + key = "no_|-states_|-states_|-None" + result = salt.minion.Minion._prefix_resource_state_key(key, "node1") + assert result == "no_|-node1 states_|-node1 states_|-None" + + +def test_prefix_resource_state_key_malformed_key_falls_back(): + """A key that cannot be split into 4 parts produces the fallback synthetic key.""" + result = salt.minion.Minion._prefix_resource_state_key("not-a-state-key", "node1") + assert result == "no_|-node1_|-node1_|-None" + + +def test_prefix_resource_state_key_three_part_key_falls_back(): + """Only three _|- separators → fallback.""" + key = "pkg_|-curl_|-curl" + result = salt.minion.Minion._prefix_resource_state_key(key, "node1") + assert result == "no_|-node1_|-node1_|-None" + + +# --------------------------------------------------------------------------- +# _handle_payload merge-mode guard (source inspection) +# --------------------------------------------------------------------------- + + +def test_handle_payload_skips_resource_dispatch_for_merge_funs(): + """ + _handle_payload must guard the separate resource-dispatch block with a + 'fun not in _MERGE_RESOURCE_FUNS' check. A missing guard would cause + duplicate responses for state.apply jobs. + """ + import inspect + + source = inspect.getsource(salt.minion.Minion._handle_payload) + assert "_MERGE_RESOURCE_FUNS" in source, ( + "_handle_payload must reference _MERGE_RESOURCE_FUNS to skip " + "redundant resource job dispatch for merge-mode functions" + ) + assert "resource_targets" in source + + +# --------------------------------------------------------------------------- +# Merge block helper: _merge_resource_into_ret logic +# --------------------------------------------------------------------------- + + +def _make_ret(return_val=None, retcode=0): + """Build a minimal ret dict as produced by _thread_return.""" + return { + "return": return_val if return_val is not None else {}, + "retcode": retcode, + "success": retcode == 0, + } + + +def _run_merge_block( + minion_instance, resource, resource_loader, function_name, resource_return +): + """ + Simulate the per-resource section of _thread_return's merge block. + + Drives the same if/elif/else branches: + - resource_loader is None → no-loader synthetic entry + - function_name not in resource_loader → unsupported string + - resource_return is a dict → prefix keys and merge + - resource_return is a str → synthetic entry with result False + """ + import salt.defaults.exitcodes + + ret = _make_ret() + run_num_base = 0 + rid = resource["id"] + rtype = resource["type"] + + if resource_loader is None: + ret["return"][f"no_|-{rid}_|-{rid}_|-None"] = { + "result": False, + "comment": f"No resource loader for type '{rtype}'. Ensure the resource module exists.", + "name": rid, + "changes": {}, + "__run_num__": run_num_base, + } + run_num_base += 1 + if ret.get("retcode") == salt.defaults.exitcodes.EX_OK: + ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC + elif function_name not in resource_loader: + resource_return = ( + f"Function '{function_name}' is not supported for resource type '{rtype}'. " + f"Implement it in a '{rtype}resource_*' execution module." + ) + ret["return"][f"no_|-{rid}_|-{rid}_|-None"] = { + "result": False, + "comment": str(resource_return), + "name": rid, + "changes": {}, + "__run_num__": run_num_base, + } + run_num_base += 1 + if ret.get("retcode") == salt.defaults.exitcodes.EX_OK: + ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC + elif isinstance(resource_return, dict): + for state_id, state_val in resource_return.items(): + entry = ( + dict(state_val) + if isinstance(state_val, dict) + else { + "result": True, + "comment": str(state_val), + "name": rid, + "changes": {}, + } + ) + entry["__run_num__"] = run_num_base + run_num_base += 1 + ret["return"][ + salt.minion.Minion._prefix_resource_state_key(state_id, rid) + ] = entry + if ret.get("retcode") == salt.defaults.exitcodes.EX_OK: + # retcode only updated when resource_loader context signals failure + pass + else: + ret["return"][f"no_|-{rid}_|-{rid}_|-None"] = { + "result": False, + "comment": str(resource_return), + "name": rid, + "changes": {}, + "__run_num__": run_num_base, + } + run_num_base += 1 + if ret.get("retcode") == salt.defaults.exitcodes.EX_OK: + ret["retcode"] = salt.defaults.exitcodes.EX_GENERIC + + ret["success"] = ret.get("retcode") == salt.defaults.exitcodes.EX_OK + return ret + + +class _FakeLoader(dict): + """Minimal stand-in for a resource loader (just a dict with a pack).""" + + def __init__(self, funs): + super().__init__(funs) + self.pack = {"__context__": {}} + + +def test_merge_block_no_loader_produces_false_entry(minion_with_resources): + resource = {"id": "dummy-01", "type": "dummy"} + ret = _run_merge_block(minion_with_resources, resource, None, "state.apply", None) + key = "no_|-dummy-01_|-dummy-01_|-None" + assert key in ret["return"] + assert ret["return"][key]["result"] is False + assert "No resource loader" in ret["return"][key]["comment"] + assert ret["retcode"] != 0 + assert ret["success"] is False + + +def test_merge_block_unsupported_function_produces_false_entry(minion_with_resources): + resource = {"id": "dummy-01", "type": "dummy"} + loader = _FakeLoader({}) # empty — function not present + ret = _run_merge_block(minion_with_resources, resource, loader, "state.apply", None) + key = "no_|-dummy-01_|-dummy-01_|-None" + assert key in ret["return"] + assert ret["return"][key]["result"] is False + assert "not supported" in ret["return"][key]["comment"] + assert ret["retcode"] != 0 + + +def test_merge_block_dict_return_prefixes_keys(minion_with_resources): + resource = {"id": "node1", "type": "ssh"} + loader = _FakeLoader({"state.apply": lambda: {}}) + resource_return = { + "pkg_|-curl_|-curl_|-installed": { + "result": True, + "comment": "Already installed", + "name": "curl", + "changes": {}, + } + } + ret = _run_merge_block( + minion_with_resources, resource, loader, "state.apply", resource_return + ) + assert "pkg_|-node1 curl_|-node1 curl_|-installed" in ret["return"] + assert ( + "pkg_|-curl_|-curl_|-installed" not in ret["return"] + ), "un-prefixed key must not appear" + + +def test_merge_block_string_return_produces_false_entry(minion_with_resources): + resource = {"id": "node1", "type": "ssh"} + loader = _FakeLoader({"state.apply": lambda: "some error"}) + ret = _run_merge_block( + minion_with_resources, + resource, + loader, + "state.apply", + "ERROR running state.apply", + ) + key = "no_|-node1_|-node1_|-None" + assert key in ret["return"] + assert ret["return"][key]["result"] is False + assert ret["retcode"] != 0 + assert ret["success"] is False + + +# --------------------------------------------------------------------------- +# _collect_resource_grains +# --------------------------------------------------------------------------- + + +def _patch_resource_funcs(minion, funcs_by_name): + """Replace ``minion.resource_funcs`` with a plain dict of callables.""" + minion.resource_funcs = funcs_by_name + + +def test_collect_resource_grains_returns_srn_keyed_dict(minion_with_resources): + """ + ``_collect_resource_grains`` walks ``opts["resources"]`` and packs each + resource's grains under the SRN composite key ``":"``. + """ + seen_targets = [] + + def grains_fn(): + target = salt.loader.context.resource_ctxvar.get() + seen_targets.append(target) + return {"who": target["id"]} + + _patch_resource_funcs(minion_with_resources, {"dummy.grains": grains_fn}) + result = minion_with_resources._collect_resource_grains() + # ssh.grains is absent → ssh resources skipped. + assert sorted(result.keys()) == [ + "dummy:dummy-01", + "dummy:dummy-02", + "dummy:dummy-03", + ] + assert result["dummy:dummy-01"] == {"who": "dummy-01"} + # The function saw the right resource_ctxvar each call. + assert {t["id"] for t in seen_targets} == {"dummy-01", "dummy-02", "dummy-03"} + + +def test_collect_resource_grains_skips_types_without_grains(minion_with_resources): + """ + Resource types whose loader has no ``.grains`` callable are + silently skipped — they don't appear in the result and don't raise. + """ + _patch_resource_funcs(minion_with_resources, {}) # nothing + assert minion_with_resources._collect_resource_grains() == {} + + +def test_collect_resource_grains_swallows_per_resource_failure(minion_with_resources): + """ + A resource whose ``grains()`` raises is logged and skipped — the rest of + the resources still produce entries. + """ + + def grains_fn(): + target = salt.loader.context.resource_ctxvar.get() + if target["id"] == "dummy-02": + raise RuntimeError("boom") + return {"who": target["id"]} + + _patch_resource_funcs(minion_with_resources, {"dummy.grains": grains_fn}) + result = minion_with_resources._collect_resource_grains() + assert "dummy:dummy-01" in result + assert "dummy:dummy-02" not in result, "broken resource must not block siblings" + assert "dummy:dummy-03" in result + + +def test_collect_resource_grains_skips_non_dict_returns(minion_with_resources): + """ + A ``grains()`` that returns something other than a dict (string, None, + etc.) is dropped — the resource_grains payload must only contain dicts. + """ + + def grains_fn(): + return "not a dict" + + _patch_resource_funcs(minion_with_resources, {"dummy.grains": grains_fn}) + assert minion_with_resources._collect_resource_grains() == {} + + +def test_collect_resource_grains_resets_ctxvar_on_failure(minion_with_resources): + """ + Even when ``grains()`` raises, ``resource_ctxvar`` must be reset to its + prior value — a leak would corrupt later jobs in the same thread. + """ + sentinel = {"id": "outer", "type": "outer"} + tok = salt.loader.context.resource_ctxvar.set(sentinel) + try: + + def grains_fn(): + raise RuntimeError("boom") + + _patch_resource_funcs(minion_with_resources, {"dummy.grains": grains_fn}) + minion_with_resources._collect_resource_grains() + assert salt.loader.context.resource_ctxvar.get() is sentinel + finally: + salt.loader.context.resource_ctxvar.reset(tok) + + +# --------------------------------------------------------------------------- +# _resolve_resource_targets — grain / grain_pcre branches +# --------------------------------------------------------------------------- + + +def test_resolve_resource_targets_grain_match(minion_with_resources): + """ + ``tgt_type == "grain"`` walks the cached resource grain dicts and + returns the matching ``{id, type}`` dicts. + """ + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"k": "v", "id": "dummy-01"}, + "dummy:dummy-02": {"k": "x", "id": "dummy-02"}, + "ssh:node1": {"k": "v", "id": "node1"}, + } + load = {"tgt": "k:v", "tgt_type": "grain", "fun": "test.ping", "arg": []} + targets = minion_with_resources._resolve_resource_targets(load) + ids_types = {(t["id"], t["type"]) for t in targets} + assert ids_types == {("dummy-01", "dummy"), ("node1", "ssh")} + + +def test_resolve_resource_targets_grain_no_match(minion_with_resources): + """A grain expression that no resource satisfies returns no targets.""" + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"k": "v"}, + } + load = {"tgt": "nope:nothing", "tgt_type": "grain", "fun": "test.ping"} + assert minion_with_resources._resolve_resource_targets(load) == [] + + +def test_resolve_resource_targets_grain_pcre_uses_regex(minion_with_resources): + """``tgt_type == "grain_pcre"`` enables regex matching on values.""" + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "production-east"}, + "dummy:dummy-02": {"env": "staging-east"}, + } + load = { + "tgt": "env:^production-.*", + "tgt_type": "grain_pcre", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + assert ids == {"dummy-01"} + + +def test_resolve_resource_targets_grain_lazy_collects_when_cache_missing( + minion_with_resources, +): + """ + If the grain cache has never been populated (e.g. registration hasn't + happened yet), the resolver falls back to ``_collect_resource_grains`` + so a freshly-started minion still acts on grain targets. + """ + minion_with_resources._resource_grains_cache = None + seen = [] + + def grains_fn(): + target = salt.loader.context.resource_ctxvar.get() + seen.append(target["id"]) + return {"freshly_loaded": True, "id": target["id"]} + + minion_with_resources.resource_funcs = {"dummy.grains": grains_fn} + load = { + "tgt": "freshly_loaded:True", + "tgt_type": "grain", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + assert ids == {"dummy-01", "dummy-02", "dummy-03"} + # Cache populated on the fly, persisted for the next call. + assert minion_with_resources._resource_grains_cache is not None + + +def test_resolve_resource_targets_compound_G_at_grain(minion_with_resources): + """ + A compound expression containing ``G@key:value`` must dispatch to every + managed resource whose own grains satisfy the term. Boolean operators + are intentionally ignored — the union of matches is dispatched and the + master's CkMinions arbitrates the final response wait set. + """ + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"environment": "prod"}, + "dummy:dummy-02": {"environment": "prod"}, + "dummy:dummy-03": {"environment": "staging"}, + "ssh:node1": {"environment": "prod"}, + } + load = { + "tgt": "G@environment:prod", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {(t["id"], t["type"]) for t in targets} + assert ids == { + ("dummy-01", "dummy"), + ("dummy-02", "dummy"), + ("node1", "ssh"), + } + + +def test_resolve_resource_targets_compound_P_at_grain_pcre(minion_with_resources): + """``P@key:regex`` in compound applies the regex against resource grains.""" + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "production-east"}, + "dummy:dummy-02": {"env": "production-west"}, + "dummy:dummy-03": {"env": "staging-east"}, + } + load = { + "tgt": "P@env:^production-.*", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + assert ids == {"dummy-01", "dummy-02"} + + +def test_resolve_resource_targets_compound_T_and_G_intersection(minion_with_resources): + """ + ``T@... and G@...`` is a true conjunction — only resources matched by + BOTH terms qualify. dummy-02 satisfies the T@ but not the grain; + dummy-01 satisfies the grain but not the T@. Neither satisfies both, + so the result is empty. + """ + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "prod"}, + "dummy:dummy-02": {"env": "staging"}, + "dummy:dummy-03": {"env": "staging"}, + } + load = { + "tgt": "T@dummy:dummy-02 and G@env:prod", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + assert targets == [], "AND must require both terms; got resources matching only one" + + +def test_resolve_resource_targets_compound_T_or_G_union(minion_with_resources): + """``T@... or G@...`` is a true disjunction — match either term.""" + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "prod"}, + "dummy:dummy-02": {"env": "staging"}, + "dummy:dummy-03": {"env": "staging"}, + } + load = { + "tgt": "T@dummy:dummy-02 or G@env:prod", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + assert ids == {"dummy-01", "dummy-02"} + + +def test_resolve_resource_targets_compound_G_and_G_intersection(minion_with_resources): + """Two G@ terms with ``and`` must intersect, not union.""" + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "prod", "role": "web"}, + "dummy:dummy-02": {"env": "prod", "role": "db"}, + "dummy:dummy-03": {"env": "staging", "role": "web"}, + } + load = { + "tgt": "G@env:prod and G@role:web", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + assert ids == {"dummy-01"}, f"AND of two grain terms must intersect; got {ids}" + + +def test_resolve_resource_targets_compound_not_negation(minion_with_resources): + """ + ``not G@…`` selects every resource whose grains do NOT satisfy the + term — including resources that have no entry for that grain key at + all. This mirrors how Salt's minion-side grain matching treats + missing grains as a non-match. + """ + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "prod"}, + "dummy:dummy-02": {"env": "staging"}, + "dummy:dummy-03": {"env": "staging"}, + # ssh resources from the fixture have no grain entry at all. + } + load = { + "tgt": "not G@env:prod", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + # dummy-02 / dummy-03 don't have env:prod; ssh resources have no + # ``env`` grain at all, so they also satisfy ``not env:prod``. + assert ids == {"dummy-02", "dummy-03", "node1", "localhost"} + + +def test_resolve_resource_targets_compound_T_and_not_G(minion_with_resources): + """ + ``T@type and not G@…`` — combine type filter with grain negation. + Useful for "all dummy resources except those with env:prod". + """ + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "prod"}, + "dummy:dummy-02": {"env": "staging"}, + "dummy:dummy-03": {"env": "staging"}, + } + load = { + "tgt": "T@dummy and not G@env:prod", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + assert ids == {"dummy-02", "dummy-03"} + + +def test_resolve_resource_targets_compound_parens_precedence(minion_with_resources): + """Parens override default left-to-right precedence.""" + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "prod", "tier": "1"}, + "dummy:dummy-02": {"env": "prod", "tier": "2"}, + "dummy:dummy-03": {"env": "staging", "tier": "1"}, + } + # ``(env:prod or env:staging) and tier:1`` → dummy-01 + dummy-03 only. + load = { + "tgt": "( G@env:prod or G@env:staging ) and G@tier:1", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + ids = {t["id"] for t in targets} + assert ids == {"dummy-01", "dummy-03"} + + +def test_resolve_resource_targets_compound_eval_safe_with_garbage( + minion_with_resources, +): + """ + A malformed compound term must not crash or expose the eval. The + helper renders unknown engines as ``False`` and any eval failure + swallows to ``False``. + """ + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "prod"}, + } + # ``Z@something`` is an unknown engine; ``__import__`` would be a + # malicious payload if eval were unrestricted. Both must render to + # False without raising. + load = { + "tgt": "Z@__import__('os').system('echo pwned')", + "tgt_type": "compound", + "fun": "test.ping", + } + assert minion_with_resources._resolve_resource_targets(load) == [] + + +def test_resolve_resource_targets_compound_T_works_without_grains( + minion_with_resources, +): + """ + Resources without any cached grain dict must still resolve via T@ + (the loader may not have a ``.grains`` callable yet). The + compound walk seeds an empty dict for SRNs missing from the grain + cache. + """ + # Grain cache is intentionally missing the ssh resources entirely. + minion_with_resources._resource_grains_cache = { + "dummy:dummy-01": {"env": "prod"}, + } + load = { + "tgt": "T@ssh:node1", + "tgt_type": "compound", + "fun": "test.ping", + } + targets = minion_with_resources._resolve_resource_targets(load) + assert targets == [{"id": "node1", "type": "ssh"}] diff --git a/tests/pytests/unit/utils/test_minions_resources.py b/tests/pytests/unit/utils/test_minions_resources.py new file mode 100644 index 000000000000..8f8160f6788e --- /dev/null +++ b/tests/pytests/unit/utils/test_minions_resources.py @@ -0,0 +1,971 @@ +""" +Tests for resource-aware targeting in :mod:`salt.utils.minions`. + +Covers: + +* End-to-end bare-ID targeting — :func:`update_resource_index` then + :meth:`CkMinions.check_minions` (mirrors master ``_register_resources`` + + ``salt `` glob/list). +* Concurrent registration vs targeting checks (multi-worker regression guard). + +* :func:`salt.utils.minions.resource_index_srn_key` — canonical SRN keys. +* :func:`salt.utils.minions._build_resource_index` / + :func:`salt.utils.minions._coerce_resource_index_schema` — pure utilities + used by migration scripts and test fixtures. +* :func:`salt.utils.minions.update_resource_index` — the master-side + register-a-minion shim over + :class:`salt.utils.resource_registry.ResourceRegistry`. +* :meth:`CkMinions._check_resource_minions` — T@ expression resolution. +* :meth:`CkMinions._augment_with_resources` — wildcard glob augmentation. +* :meth:`CkMinions.check_minions` merge-mode conditional logic. + +The registry is an mmap-backed singleton shared per-process. Tests use +:func:`salt.utils.resource_registry.reset_registry` to get a fresh +per-``cachedir`` instance. +""" + +import threading + +import pytest + +import salt.utils.minions +import salt.utils.resource_registry +from salt.utils.minions import ( + _MERGE_RESOURCE_FUNS, + RESOURCE_INDEX_SCHEMA_VERSION, + _build_resource_index, + _coerce_resource_index_schema, + resource_index_srn_key, + update_resource_index, +) +from tests.support.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +MINION_RESOURCES = { + "minion": { + "dummy": ["dummy-01", "dummy-02", "dummy-03"], + "ssh": ["node1", "localhost"], + } +} + +# Avoid production-sized primary mmap (~256 MiB) for every test; see +# ``test_resource_registry._TEST_PRIMARY_*``. +_TEST_PRIMARY_CAPACITY = 4096 +_TEST_PRIMARY_SLOT_SIZE = 128 + + +@pytest.fixture(autouse=True) +def reset_registry_singleton(): + """Drop the process-wide registry before and after each test.""" + salt.utils.resource_registry.reset_registry() + yield + salt.utils.resource_registry.reset_registry() + + +@pytest.fixture +def opts(master_opts, tmp_path): + """Master opts pointing at a per-test tmp cachedir.""" + master_opts["cachedir"] = str(tmp_path) + master_opts.setdefault("resource_index_primary_capacity", _TEST_PRIMARY_CAPACITY) + master_opts.setdefault("resource_index_primary_slot_size", _TEST_PRIMARY_SLOT_SIZE) + return master_opts + + +@pytest.fixture +def registry(opts): + """A fresh ResourceRegistry anchored on ``opts['cachedir']``.""" + return salt.utils.resource_registry.get_registry(opts) + + +@pytest.fixture +def populated_registry(opts): + """Registry pre-populated with :data:`MINION_RESOURCES`.""" + reg = salt.utils.resource_registry.get_registry(opts) + for minion_id, resources in MINION_RESOURCES.items(): + reg.register_minion(minion_id, resources) + return reg + + +@pytest.fixture +def ck(opts): + """CkMinions bound to the empty per-test registry.""" + return salt.utils.minions.CkMinions(opts) + + +@pytest.fixture +def ck_with_resources(opts, populated_registry): + """CkMinions with :data:`MINION_RESOURCES` pre-registered.""" + return salt.utils.minions.CkMinions(opts) + + +# --------------------------------------------------------------------------- +# Pure utility helpers +# --------------------------------------------------------------------------- + + +def test_resource_index_srn_key(): + assert resource_index_srn_key("dummy", "x") == "dummy:x" + + +def test_build_resource_index_by_id(): + index = _build_resource_index(MINION_RESOURCES) + assert index["by_id"]["dummy:dummy-01"] == {"minion": "minion", "type": "dummy"} + assert index["by_id"]["ssh:node1"] == {"minion": "minion", "type": "ssh"} + + +def test_build_resource_index_by_type(): + index = _build_resource_index(MINION_RESOURCES) + assert set(index["by_type"]["dummy"]) == {"dummy-01", "dummy-02", "dummy-03"} + assert set(index["by_type"]["ssh"]) == {"node1", "localhost"} + + +def test_build_resource_index_by_minion(): + index = _build_resource_index(MINION_RESOURCES) + assert index["by_minion"]["minion"]["dummy"] == [ + "dummy-01", + "dummy-02", + "dummy-03", + ] + + +def test_build_resource_index_empty(): + index = _build_resource_index({}) + assert index["schema_version"] == RESOURCE_INDEX_SCHEMA_VERSION + assert index["by_id"] == {} + assert index["by_type"] == {} + assert index["by_minion"] == {} + + +def test_coerce_resource_index_schema_legacy_rebuilds_by_id(): + legacy = { + "by_minion": { + "m1": {"ssh": ["n1"], "dummy": ["n1"]}, + }, + "by_type": {"ssh": ["n1"], "dummy": ["n1"]}, + "by_id": {"n1": {"minion": "m1", "type": "ssh"}}, + } + coerced = _coerce_resource_index_schema(legacy) + assert coerced["schema_version"] == 2 + assert coerced["by_id"]["ssh:n1"]["type"] == "ssh" + assert coerced["by_id"]["dummy:n1"]["type"] == "dummy" + + +# --------------------------------------------------------------------------- +# update_resource_index — the master-side register-a-minion shim +# --------------------------------------------------------------------------- + + +def test_update_resource_index_adds_minion(opts): + update_resource_index(opts, "minion-b", {"dummy": ["dummy-99"]}) + reg = salt.utils.resource_registry.get_registry(opts) + assert reg.has_resource("minion-b", "dummy", "dummy-99") + assert reg.get_resources_for_minion("minion-b") == {"dummy": ["dummy-99"]} + + +def test_update_resource_index_removes_minion(opts): + update_resource_index(opts, "minion", MINION_RESOURCES["minion"]) + update_resource_index(opts, "minion", {}) + reg = salt.utils.resource_registry.get_registry(opts) + assert reg.get_resources_for_minion("minion") == {} + assert reg.get_resource_ids_by_type("dummy") == [] + + +def test_update_resource_index_surgical_preserves_other_minions(opts): + update_resource_index(opts, "minion-a", {"dummy": ["dummy-01", "dummy-02"]}) + update_resource_index(opts, "minion-b", {"ssh": ["node1"]}) + update_resource_index(opts, "minion-a", {"dummy": ["dummy-02"]}) + + reg = salt.utils.resource_registry.get_registry(opts) + assert reg.has_resource("minion-b", "ssh", "node1") + assert reg.has_resource("minion-a", "dummy", "dummy-02") + assert not reg.has_srn("dummy", "dummy-01") + + +def test_update_resource_index_removes_empty_type(opts): + update_resource_index(opts, "minion", {"ssh": ["node1"]}) + update_resource_index(opts, "minion", {}) + reg = salt.utils.resource_registry.get_registry(opts) + assert reg.get_resource_ids_by_type("ssh") == [] + + +def test_update_resource_index_partial_type_removal(opts): + update_resource_index(opts, "minion", {"dummy": ["dummy-01", "dummy-02"]}) + update_resource_index(opts, "minion", {"dummy": ["dummy-02"]}) + reg = salt.utils.resource_registry.get_registry(opts) + rids = reg.get_resource_ids_by_type("dummy") + assert set(rids) == {"dummy-02"} + + +def test_update_resource_index_no_duplicate_by_type(opts): + update_resource_index(opts, "minion", {"dummy": ["dummy-01"]}) + update_resource_index(opts, "minion", {"dummy": ["dummy-01"]}) + reg = salt.utils.resource_registry.get_registry(opts) + rids = reg.get_resource_ids_by_type("dummy") + assert rids.count("dummy-01") == 1 + + +def test_update_resource_index_handles_none_resources(opts): + """A minion reporting ``resources: None`` must not crash — treat as empty.""" + update_resource_index(opts, "minion", {"dummy": ["dummy-01"]}) + update_resource_index(opts, "minion", None) + reg = salt.utils.resource_registry.get_registry(opts) + assert reg.get_resources_for_minion("minion") == {} + + +def test_update_resource_index_registry_error_is_swallowed(opts): + """A registry write failure must not blow up the master's handler.""" + with patch.object( + salt.utils.resource_registry.ResourceRegistry, + "register_minion", + side_effect=RuntimeError("registry down"), + ): + # Returns (0, 0) rather than raising. + result = update_resource_index(opts, "m", {"dummy": ["d"]}) + assert result == (0, 0) + + +# --------------------------------------------------------------------------- +# Composite (type, id) — same bare id under multiple resource types +# --------------------------------------------------------------------------- + + +SHARED_ID = "shared-01" +DUPLICATE_BARE_ID_RESOURCES = { + "minion-a": { + "dummy": [SHARED_ID], + "ssh": [SHARED_ID], + }, +} + + +def test_build_resource_index_duplicate_bare_id_two_types(): + """by_id must keep one entry per (type, id), not collapse on bare id.""" + index = _build_resource_index(DUPLICATE_BARE_ID_RESOURCES) + assert index["by_id"][resource_index_srn_key("dummy", SHARED_ID)] == { + "minion": "minion-a", + "type": "dummy", + } + assert index["by_id"][resource_index_srn_key("ssh", SHARED_ID)] == { + "minion": "minion-a", + "type": "ssh", + } + assert set(index["by_type"]["dummy"]) == {SHARED_ID} + assert set(index["by_type"]["ssh"]) == {SHARED_ID} + + +def test_update_resource_index_shared_bare_id_two_types(opts): + update_resource_index(opts, "minion-a", DUPLICATE_BARE_ID_RESOURCES["minion-a"]) + reg = salt.utils.resource_registry.get_registry(opts) + assert reg.has_resource("minion-a", "dummy", SHARED_ID) + assert reg.has_resource("minion-a", "ssh", SHARED_ID) + + # Drop only the ssh entry — dummy must be untouched. + update_resource_index(opts, "minion-a", {"dummy": [SHARED_ID]}) + assert reg.has_resource("minion-a", "dummy", SHARED_ID) + assert not reg.has_srn("ssh", SHARED_ID) + + +# --------------------------------------------------------------------------- +# CkMinions._check_resource_minions — T@ resolution +# --------------------------------------------------------------------------- + + +def test_check_resource_minions_full_srn(ck_with_resources): + result = ck_with_resources._check_resource_minions("dummy:dummy-01", greedy=True) + assert result == {"minions": ["dummy-01"], "missing": []} + + +def test_check_resource_minions_all_of_type(ck_with_resources): + result = ck_with_resources._check_resource_minions("dummy", greedy=True) + assert set(result["minions"]) == {"dummy-01", "dummy-02", "dummy-03"} + + +def test_check_resource_minions_fallback_unknown_srn(ck): + """Unregistered SRNs echo back the resource ID (managing minion filters locally).""" + result = ck._check_resource_minions("dummy:dummy-01", greedy=True) + assert result == {"minions": ["dummy-01"], "missing": []} + + +def test_check_resource_minions_empty_registry_bare_type(ck): + result = ck._check_resource_minions("dummy", greedy=True) + assert result == {"minions": [], "missing": []} + + +def test_check_resource_minions_trailing_colon(ck_with_resources): + """A trailing colon (``dummy:``) must be treated as a bare-type expression.""" + result = ck_with_resources._check_resource_minions("dummy:", greedy=True) + assert set(result["minions"]) == {"dummy-01", "dummy-02", "dummy-03"} + + +def test_check_resource_minions_full_srn_per_type_with_duplicate_bare_id(opts): + update_resource_index(opts, "minion-a", DUPLICATE_BARE_ID_RESOURCES["minion-a"]) + ck = salt.utils.minions.CkMinions(opts) + + assert ck._check_resource_minions(f"dummy:{SHARED_ID}", greedy=True) == { + "minions": [SHARED_ID], + "missing": [], + } + assert ck._check_resource_minions(f"ssh:{SHARED_ID}", greedy=True) == { + "minions": [SHARED_ID], + "missing": [], + } + + +def test_check_resource_minions_registry_error_returns_empty(ck): + with patch.object(ck.registry, "has_srn", side_effect=RuntimeError("boom")): + result = ck._check_resource_minions("dummy:dummy-01", greedy=True) + # SRN path echoes back the id even when the lookup fails. + assert result == {"minions": ["dummy-01"], "missing": []} + + +# --------------------------------------------------------------------------- +# CkMinions._augment_with_resources — wildcard glob augmentation +# --------------------------------------------------------------------------- + + +def test_augment_with_resources_adds_resource_ids(ck_with_resources): + result = ck_with_resources._augment_with_resources(["minion"]) + assert "dummy-01" in result + assert "node1" in result + assert "minion" in result + + +def test_augment_with_resources_no_duplication(ck_with_resources): + result = ck_with_resources._augment_with_resources(["minion"]) + assert result.count("minion") == 1 + + +def test_augment_with_resources_empty_registry(ck): + result = ck._augment_with_resources(["minion"]) + assert result == ["minion"] + + +def test_augment_with_resources_unmatched_minion(ck_with_resources): + result = ck_with_resources._augment_with_resources(["other-minion"]) + assert result == ["other-minion"] + + +def test_augment_with_resources_registry_error_returns_minion_ids(ck): + with patch.object( + ck.registry, + "get_resources_for_minion", + side_effect=RuntimeError("registry unavailable"), + ): + result = ck._augment_with_resources(["minion"]) + assert result == ["minion"] + + +# --------------------------------------------------------------------------- +# check_minions — integration with augmentation + merge-mode conditional +# --------------------------------------------------------------------------- + + +def test_check_minions_glob_wildcard_augmented(ck_with_resources): + with patch.object( + ck_with_resources, + "_check_glob_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions("*", tgt_type="glob") + assert "dummy-01" in result["minions"] + assert "node1" in result["minions"] + + +def test_check_minions_glob_specific_not_augmented(ck_with_resources): + with patch.object( + ck_with_resources, + "_check_glob_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions("minion", tgt_type="glob") + assert "dummy-01" not in result["minions"] + + +def test_check_minions_compound_not_augmented(ck_with_resources): + with patch.object( + ck_with_resources, + "_check_compound_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions( + "minion and G@os:Debian", tgt_type="compound" + ) + assert "dummy-01" not in result["minions"] + + +def test_augment_registry_error_does_not_break_check_minions(ck): + with patch.object( + ck.registry, + "get_resources_for_minion", + side_effect=RuntimeError("registry failure"), + ), patch.object( + ck, + "_check_glob_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck.check_minions("*", tgt_type="glob") + assert "minion" in result["minions"] + + +def test_check_minions_merge_fun_skips_augmentation(ck_with_resources): + """Merge-mode wildcard globs (e.g. state.apply) must NOT augment with rids.""" + with patch.object( + ck_with_resources, + "_check_glob_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions( + "*", tgt_type="glob", fun="state.apply" + ) + assert "dummy-01" not in result["minions"] + assert "node1" not in result["minions"] + assert "minion" in result["minions"] + + +def test_check_minions_merge_fun_all_merge_funs_skip(ck_with_resources): + for fun in _MERGE_RESOURCE_FUNS: + with patch.object( + ck_with_resources, + "_check_glob_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions("*", tgt_type="glob", fun=fun) + assert "dummy-01" not in result["minions"], f"augmented for {fun}" + + +def test_check_minions_list_fun_still_augments(ck_with_resources): + """Multifunction jobs pass ``fun`` as a list: must not TypeError.""" + with patch.object( + ck_with_resources, + "_check_glob_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions( + "*", tgt_type="glob", fun=["test.arg", "test.arg"] + ) + assert "dummy-01" in result["minions"] + assert "minion" in result["minions"] + + +def test_check_minions_non_merge_fun_still_augments(ck_with_resources): + with patch.object( + ck_with_resources, + "_check_glob_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions("*", tgt_type="glob", fun="test.ping") + assert "dummy-01" in result["minions"] + assert "node1" in result["minions"] + + +def test_check_minions_no_fun_still_augments(ck_with_resources): + with patch.object( + ck_with_resources, + "_check_glob_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions("*", tgt_type="glob") + assert "dummy-01" in result["minions"] + assert "node1" in result["minions"] + + +def test_check_minions_merge_fun_compound_not_affected(ck_with_resources): + """The merge-mode skip only applies to wildcard globs, not compound targets.""" + with patch.object( + ck_with_resources, + "_check_compound_minions", + return_value={"minions": ["minion"], "missing": []}, + ): + result = ck_with_resources.check_minions( + "G@os:Debian", tgt_type="compound", fun="test.ping" + ) + assert "dummy-01" not in result["minions"] + + +def test_registry_resolve_bare_resource_id(populated_registry): + assert populated_registry.resolve_bare_resource_id("dummy-02") == [ + ("dummy", "dummy-02") + ] + assert populated_registry.resolve_bare_resource_id("nosuch") == [] + + +def test_check_minions_list_includes_bare_registered_resource_id(ck_with_resources): + """``salt -L `` must resolve via the resource registry.""" + result = ck_with_resources.check_minions("dummy-02", tgt_type="list") + assert result["minions"] == ["dummy-02"] + assert result["missing"] == [] + + +def test_check_minions_glob_exact_bare_registered_resource_id(ck_with_resources): + result = ck_with_resources.check_minions("dummy-02", tgt_type="glob") + assert "dummy-02" in result["minions"] + + +def test_check_glob_minions_bare_resource_from_pillar_cache_empty_registry(opts): + """Bare glob matches cached pillar ``resources`` when mmap registry is cold.""" + opts["minion_data_cache"] = True + fake_cache = MagicMock() + + def fetch_side_effect(bank, mid): + if bank == "pillar" and mid == "m2": + return {"resources": {"dummy": {"resource_ids": ["m2-dummy2"]}}} + return {} + + fake_cache.list.side_effect = lambda bank: ["m2"] + fake_cache.fetch.side_effect = fetch_side_effect + + with patch("salt.cache.factory", return_value=fake_cache): + ck = salt.utils.minions.CkMinions(opts) + result = ck._check_glob_minions("m2-dummy2", greedy=True) + assert "m2-dummy2" in result["minions"] + + +def test_check_glob_minions_bare_resource_from_grains_cache_empty_registry(opts): + opts["minion_data_cache"] = True + fake_cache = MagicMock() + + def fetch_side_effect(bank, mid): + if bank == "grains" and mid == "m2": + return {"salt_resources": {"dummy": ["m2-dummy2"]}} + return {} + + fake_cache.list.side_effect = lambda bank: ["m2"] + fake_cache.fetch.side_effect = fetch_side_effect + + with patch("salt.cache.factory", return_value=fake_cache): + ck = salt.utils.minions.CkMinions(opts) + result = ck._check_glob_minions("m2-dummy2", greedy=True) + assert "m2-dummy2" in result["minions"] + + +def test_check_list_minions_bare_resource_from_cache_empty_registry(opts): + opts["minion_data_cache"] = True + fake_cache = MagicMock() + fake_cache.list.side_effect = lambda bank: ["m2"] + + def fetch_side_effect(bank, mid): + if bank == "pillar" and mid == "m2": + return {"resources": {"dummy": {"resource_ids": ["m2-dummy2"]}}} + return {} + + fake_cache.fetch.side_effect = fetch_side_effect + + with patch("salt.cache.factory", return_value=fake_cache): + ck = salt.utils.minions.CkMinions(opts) + result = ck._check_list_minions("m2-dummy2", greedy=True) + assert result["minions"] == ["m2-dummy2"] + assert result["missing"] == [] + + +# --------------------------------------------------------------------------- +# Bare resource ID targeting — master path + concurrency +# --------------------------------------------------------------------------- + + +def test_update_resource_index_then_check_minions_glob_bare_id(opts): + """ + Full stack path used by the master after ``_register_resources``: registry + mmap populated via :func:`update_resource_index`, then glob targeting must + include the bare resource token (e.g. ``salt m2-dummy2 test.ping``). + """ + update_resource_index(opts, "minion-2", {"dummy": ["m2-dummy2", "m2-dummy1"]}) + ck = salt.utils.minions.CkMinions(opts) + got = ck.check_minions("m2-dummy2", tgt_type="glob", fun="test.ping") + assert "m2-dummy2" in got["minions"] + + +def test_update_resource_index_then_check_minions_list_bare_id(opts): + """``salt -L m2-dummy2`` resolves when the id is registered.""" + update_resource_index(opts, "minion-2", {"dummy": ["m2-dummy2"]}) + ck = salt.utils.minions.CkMinions(opts) + got = ck.check_minions("m2-dummy2", tgt_type="list") + assert got["minions"] == ["m2-dummy2"] + assert got["missing"] == [] + + +def test_concurrent_update_resource_index_and_check_minions_glob(opts): + """ + Several threads alternating writes (as master workers do) with readers that + evaluate bare-ID glob targets must not raise (mmap ``ACCESS_READ`` vs + ``ACCESS_WRITE`` races previously blew up here). + """ + errs = [] + lock = threading.Lock() + + def worker(tag): + try: + for i in range(40): + mid = f"minion-{tag}-{i % 4}" + rid = f"res-{tag}-{i}" + update_resource_index(opts, mid, {"dummy": [rid]}) + ck = salt.utils.minions.CkMinions(opts) + ck.check_minions(rid, tgt_type="glob", fun="test.ping") + ck.check_minions(rid, tgt_type="list") + except Exception as exc: # pylint: disable=broad-except + with lock: + errs.append(exc) + + threads = [threading.Thread(target=worker, args=(t,)) for t in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert not errs, errs + + +# --------------------------------------------------------------------------- +# _augment_grain_match_with_resource_grains +# --------------------------------------------------------------------------- + + +def _build_ck_with_resource_grain_cache(opts, entries): + """ + Build a ``CkMinions`` whose ``self.cache`` returns ``entries`` for the + ``resource_grains`` bank. Other banks return empty so the standard + minion-grain match never matches. + """ + ck = salt.utils.minions.CkMinions(opts) + fake = MagicMock() + fake.list = MagicMock( + side_effect=lambda bank: ( + list(entries.keys()) if bank == "resource_grains" else [] + ) + ) + fake.fetch = MagicMock( + side_effect=lambda bank, key: ( + entries.get(key) if bank == "resource_grains" else None + ) + ) + ck.cache = fake + return ck + + +def test_augment_grain_match_adds_matched_resource_id(opts): + """ + A grain expression ``key:value`` that matches a per-resource grain dict + appends the bare resource id to ``result["minions"]``. + """ + opts["minion_data_cache"] = True + entries = { + "dummy:dummy-01": {"dummy_grain_1": "one", "resource_id": "dummy-01"}, + "dummy:dummy-02": {"dummy_grain_1": "one", "resource_id": "dummy-02"}, + "dummy:other": {"dummy_grain_1": "two", "resource_id": "other"}, + } + ck = _build_ck_with_resource_grain_cache(opts, entries) + result = {"minions": [], "missing": []} + ck._augment_grain_match_with_resource_grains( + result, "dummy_grain_1:one", ":", regex_match=False + ) + assert sorted(result["minions"]) == ["dummy-01", "dummy-02"] + + +def test_augment_grain_match_dedups_against_existing_minions(opts): + """An id already in ``result["minions"]`` must not be duplicated.""" + opts["minion_data_cache"] = True + entries = {"dummy:dummy-01": {"dummy_grain_1": "one"}} + ck = _build_ck_with_resource_grain_cache(opts, entries) + result = {"minions": ["dummy-01"], "missing": []} + ck._augment_grain_match_with_resource_grains( + result, "dummy_grain_1:one", ":", regex_match=False + ) + assert result["minions"] == ["dummy-01"] + + +def test_augment_grain_match_skips_when_minion_data_cache_disabled(opts): + """Disabled :conf_master:`minion_data_cache` short-circuits the augment.""" + opts["minion_data_cache"] = False + entries = {"dummy:dummy-01": {"dummy_grain_1": "one"}} + ck = _build_ck_with_resource_grain_cache(opts, entries) + result = {"minions": [], "missing": []} + ck._augment_grain_match_with_resource_grains( + result, "dummy_grain_1:one", ":", regex_match=False + ) + assert result["minions"] == [] + + +def test_augment_grain_match_handles_missing_bank(opts): + """ + When the ``resource_grains`` bank doesn't exist (no minion has registered + yet), the helper must not raise. + """ + opts["minion_data_cache"] = True + ck = salt.utils.minions.CkMinions(opts) + fake = MagicMock() + fake.list = MagicMock(side_effect=Exception("bank not found")) + ck.cache = fake + result = {"minions": [], "missing": []} + ck._augment_grain_match_with_resource_grains( + result, "any:thing", ":", regex_match=False + ) + assert result["minions"] == [] + + +def test_augment_grain_match_pcre_regex_match(opts): + """Regex form (``salt -P``) must apply ``regex_match=True`` to subdict_match.""" + opts["minion_data_cache"] = True + entries = { + "dummy:dummy-01": {"environment": "production-east"}, + "dummy:dummy-02": {"environment": "production-west"}, + "dummy:dummy-03": {"environment": "staging-east"}, + } + ck = _build_ck_with_resource_grain_cache(opts, entries) + result = {"minions": [], "missing": []} + ck._augment_grain_match_with_resource_grains( + result, "environment:^production-.*", ":", regex_match=True + ) + assert sorted(result["minions"]) == ["dummy-01", "dummy-02"] + + +def test_augment_grain_match_skips_non_dict_entries(opts): + """A corrupted cache entry that isn't a dict must not crash the helper.""" + opts["minion_data_cache"] = True + entries = { + "dummy:dummy-01": "not-a-dict", + "dummy:dummy-02": {"k": "v"}, + } + ck = _build_ck_with_resource_grain_cache(opts, entries) + result = {"minions": [], "missing": []} + ck._augment_grain_match_with_resource_grains(result, "k:v", ":", regex_match=False) + assert result["minions"] == ["dummy-02"] + + +def test_augment_grain_match_invalid_srn_skipped(opts): + """SRN keys without a ``:`` separator (corrupt or malformed) are skipped.""" + opts["minion_data_cache"] = True + entries = { + "no-colon-here": {"k": "v"}, + "dummy:valid": {"k": "v"}, + } + ck = _build_ck_with_resource_grain_cache(opts, entries) + result = {"minions": [], "missing": []} + ck._augment_grain_match_with_resource_grains(result, "k:v", ":", regex_match=False) + # The "no-colon-here" entry has rid="" after partition → skipped. + # Wait — partition(":") on "no-colon-here" returns ("no-colon-here", "", "") + # so rid is empty and the entry is skipped. + assert result["minions"] == ["valid"] + + +# --------------------------------------------------------------------------- +# Nodegroup expansion with G@ for resources +# --------------------------------------------------------------------------- + + +def test_nodegroup_with_grain_term_matches_resources(opts): + """ + A nodegroup whose definition includes a ``G@`` term must match + resources via the same augment path. ``_check_compound_minions`` + expands ``N@`` → the underlying compound expression, then + each ``G@`` term flows through ``_check_grain_minions`` → + ``_augment_grain_match_with_resource_grains``. + + We verify the wire-up by stuffing a nodegroup definition into opts + and confirming the resource id reaches the ``minions`` set in the + result. + """ + opts["minion_data_cache"] = True + opts["nodegroups"] = {"prod_nodes": "G@env:prod"} + + update_resource_index(opts, "minion-1", {"dummy": ["dummy-01", "dummy-02"]}) + + ck = salt.utils.minions.CkMinions(opts) + fake = MagicMock() + fake.list = MagicMock( + side_effect=lambda bank: ( + ["dummy:dummy-01", "dummy:dummy-02"] if bank == "resource_grains" else [] + ) + ) + fake.fetch = MagicMock( + side_effect=lambda bank, key: ( + { + "dummy:dummy-01": {"env": "prod"}, + "dummy:dummy-02": {"env": "staging"}, + }.get(key) + if bank == "resource_grains" + else None + ) + ) + ck.cache = fake + + got = ck.check_minions("N@prod_nodes", tgt_type="compound", fun="test.ping") + assert ( + "dummy-01" in got["minions"] + ), f"Nodegroup compound expansion lost the resource id: {got!r}" + assert ( + "dummy-02" not in got["minions"] + ), "Resource not matching the grain expression must not appear" + + +# --------------------------------------------------------------------------- +# Modest-scale perf: augment over many resources +# --------------------------------------------------------------------------- + + +@pytest.mark.slow_test +def test_augment_grain_match_handles_thousand_resources_in_under_a_second(opts): + """ + Sanity-check the ``-G`` augment path under a realistic resource count. + 1000 resource_grains entries × one grain key must scan and match in + well under a second on commodity hardware. This isn't a strict perf + contract — it's a regression guard that catches accidental O(N²) or + per-fetch-per-key blow-ups in the hot read path. + """ + import time + + opts["minion_data_cache"] = True + + n = 1000 + entries = { + f"dummy:r{i:04d}": {"env": "prod" if i % 2 else "staging"} for i in range(n) + } + # Match half the entries (env:prod for odd indices). + expected_match_count = n // 2 + + fake = MagicMock() + fake.list = MagicMock( + side_effect=lambda bank: ( + list(entries.keys()) if bank == "resource_grains" else [] + ) + ) + fake.fetch = MagicMock( + side_effect=lambda bank, key: ( + entries.get(key) if bank == "resource_grains" else None + ) + ) + ck = salt.utils.minions.CkMinions(opts) + ck.cache = fake + + result = {"minions": [], "missing": []} + start = time.perf_counter() + ck._augment_grain_match_with_resource_grains( + result, "env:prod", ":", regex_match=False + ) + elapsed = time.perf_counter() - start + + assert ( + len(result["minions"]) == expected_match_count + ), f"Expected {expected_match_count} matches, got {len(result['minions'])}" + # Generous budget — a 5× overshoot still indicates accidental + # quadratic scanning. Local dev runs comfortably finish in < 50 ms. + assert elapsed < 1.0, ( + f"_augment_grain_match_with_resource_grains over {n} entries " + f"took {elapsed:.3f}s — likely a perf regression" + ) + + +def _run_check_minions_grain_perf(opts, minion_count, resources_per_minion, budget): + """ + Drive :meth:`CkMinions.check_minions` against a synthetic fleet of + ``minion_count`` minions × ``resources_per_minion`` resources and return + the elapsed wall-clock seconds. Half of each population is tagged + ``env=prod`` so a ``-G env:prod`` query matches exactly half of both. + """ + import time + + opts["minion_data_cache"] = True + + minion_ids = [f"minion-{i:06d}" for i in range(minion_count)] + minion_grains = { + mid: {"env": "prod" if idx % 2 == 0 else "staging", "os": "Linux"} + for idx, mid in enumerate(minion_ids) + } + resource_grains = {} + for m_idx, mid in enumerate(minion_ids): + for r_idx in range(resources_per_minion): + srn = f"dummy:r-{m_idx:06d}-{r_idx:04d}" + resource_grains[srn] = { + "env": "prod" if r_idx % 2 == 0 else "staging", + "managed_by": mid, + } + + expected_minions = sum(1 for g in minion_grains.values() if g["env"] == "prod") + expected_resources = sum(1 for g in resource_grains.values() if g["env"] == "prod") + + fake = MagicMock() + + def _list(bank): + if bank == "grains": + return list(minion_grains) + if bank == "resource_grains": + return list(resource_grains) + return [] + + def _fetch(bank, key): + if bank == "grains": + return minion_grains.get(key) + if bank == "resource_grains": + return resource_grains.get(key) + return None + + fake.list = MagicMock(side_effect=_list) + fake.fetch = MagicMock(side_effect=_fetch) + + ck = salt.utils.minions.CkMinions(opts) + ck.cache = fake + # Bypass PKI listing — we are exercising cache-driven grain matching only. + with patch.object(ck, "_pki_minions", return_value=set(minion_ids)): + start = time.perf_counter() + result = ck.check_minions("env:prod", tgt_type="grain") + elapsed = time.perf_counter() - start + + matched = result["minions"] + matched_minions = [m for m in matched if m in minion_grains] + matched_resources = [m for m in matched if m not in minion_grains] + + msg = ( + f"\n[perf] check_minions(-G env:prod) over " + f"{minion_count} minions × {resources_per_minion} resources " + f"({len(resource_grains)} resource_grains entries): " + f"{elapsed * 1000:.1f} ms — matched " + f"{len(matched_minions)} minions + {len(matched_resources)} resources" + ) + # Always surface the timing on stdout so ``pytest -s`` shows it; the + # assertion failure path also embeds it for ``-v`` inspection. + print(msg) + + assert len(matched_minions) == expected_minions, ( + f"expected {expected_minions} minions matched, got " + f"{len(matched_minions)}: {sorted(matched_minions)[:5]}…" + ) + assert len(matched_resources) == expected_resources, ( + f"expected {expected_resources} resources matched, got " + f"{len(matched_resources)}" + ) + assert elapsed < budget, msg + return elapsed + + +@pytest.mark.slow_test +def test_check_minions_grain_target_100_minions_100_resources_each(opts): + """ + End-to-end :meth:`CkMinions.check_minions` timing for grain targeting: + 100 minions × 100 resources (10,000 entries) — small fleet baseline. + A ``-G env:prod`` query matches 50 minions + 5,000 resource IDs. + """ + # Local dev finishes in ~75 ms; CI ARM/FIPS ~3-5x slower; 5 s leaves + # headroom while still catching an accidental O(N²) regression. + _run_check_minions_grain_perf(opts, 100, 100, budget=5.0) + + +@pytest.mark.slow_test +def test_check_minions_grain_target_1000_minions_100_resources_each(opts): + """ + End-to-end :meth:`CkMinions.check_minions` timing for grain targeting: + 1,000 minions × 100 resources (100,000 entries) — large-fleet stress. + A ``-G env:prod`` query matches 500 minions + 50,000 resource IDs. + """ + _run_check_minions_grain_perf(opts, 1000, 100, budget=30.0) + + +@pytest.mark.slow_test +def test_check_minions_grain_target_10000_minions_100_resources_each(opts): + """ + End-to-end :meth:`CkMinions.check_minions` timing for grain targeting: + 10,000 minions × 100 resources (1,000,000 entries) — million-resource + stress test for the in-process scan path. + A ``-G env:prod`` query matches 5,000 minions + 500,000 resource IDs. + """ + _run_check_minions_grain_perf(opts, 10000, 100, budget=180.0) diff --git a/tests/pytests/unit/utils/test_mmap_cache.py b/tests/pytests/unit/utils/test_mmap_cache.py index 089233f51438..c3dee0f24f8d 100644 --- a/tests/pytests/unit/utils/test_mmap_cache.py +++ b/tests/pytests/unit/utils/test_mmap_cache.py @@ -3,6 +3,7 @@ """ import os +import threading import time import pytest @@ -46,6 +47,50 @@ def test_put_update(cache): assert cache.get("key1") == "val2" +def test_mmap_cache_write_after_read_only_open(cache_path): + """Writers must not reuse an ACCESS_READ mmap opened by a prior ``open(write=False)``.""" + cache = MmapCache(cache_path, size=_SIZE, slot_size=_SLOT_SIZE, key_size=_KEY_SIZE) + assert cache.put("k1", "v1") is True + cache.close() + + assert cache.open(write=False) is True + assert cache.get("k1") == "v1" + + assert cache.put("k2", "v2") is True + assert cache.get("k2") == "v2" + cache.close() + + +def test_mmap_cache_concurrent_list_and_put(cache_path): + """Concurrent ``list_items`` vs ``put`` must not leave a readonly mmap active for writers.""" + cache = MmapCache(cache_path, size=2000, slot_size=_SLOT_SIZE, key_size=_KEY_SIZE) + assert cache.put("seed", "x") is True + cache.close() + errs = [] + + def reader(): + try: + for _ in range(20): + cache.list_items() + except Exception as exc: # pylint: disable=broad-except + errs.append(exc) + + def writer(): + try: + for i in range(100): + assert cache.put(f"w{i}", "y"), i + except Exception as exc: # pylint: disable=broad-except + errs.append(exc) + + threads = [threading.Thread(target=reader), threading.Thread(target=writer)] + for t in threads: + t.start() + for t in threads: + t.join() + assert not errs, errs + cache.close() + + def test_read_open_then_write_reopens_writable_mmap(cache_path): """ Read-only ``open(write=False)`` must not leave a mmap that ``put`` reuses diff --git a/tests/pytests/unit/utils/test_resource_registry.py b/tests/pytests/unit/utils/test_resource_registry.py new file mode 100644 index 000000000000..c78cae80bba6 --- /dev/null +++ b/tests/pytests/unit/utils/test_resource_registry.py @@ -0,0 +1,378 @@ +""" +Unit tests for :mod:`salt.utils.resource_registry`. +""" + +import contextlib +import threading + +import pytest + +import salt.utils.resource_registry as rr + +# Production default mmap is ~256 MiB (2**21 slots × 128 B). Unit tests only +# need a tiny table; keeping this small avoids multi‑GiB churn across many +# tests (and false CI failures from /tmp exhaustion). +_TEST_PRIMARY_CAPACITY = 4096 +_TEST_PRIMARY_SLOT_SIZE = 128 + + +class _FakeCache: + """In-memory stand-in for ``salt.cache.factory`` output.""" + + def __init__(self): + self.banks = {} + + def fetch(self, bank, key): + return self.banks.get(bank, {}).get(key) + + def store(self, bank, key, value): + self.banks.setdefault(bank, {})[key] = value + + +@pytest.fixture +def registry(tmp_path): + opts = { + "cachedir": str(tmp_path), + "resource_index_primary_capacity": _TEST_PRIMARY_CAPACITY, + "resource_index_primary_slot_size": _TEST_PRIMARY_SLOT_SIZE, + } + cache = _FakeCache() + reg = rr.ResourceRegistry(opts, cache=cache) + try: + yield reg + finally: + reg.close() + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "expr, expected", + [ + ("vcf_host", {"type": "vcf_host", "id": None}), + ("vcf_host:esxi-01", {"type": "vcf_host", "id": "esxi-01"}), + ("", {"type": None, "id": None}), + (None, {"type": None, "id": None}), + (":orphan", {"type": None, "id": "orphan"}), + ("type_only:", {"type": "type_only", "id": None}), + ], +) +def test_parse_srn(expr, expected): + assert rr.parse_srn(expr) == expected + + +def test_resource_index_srn_key(): + assert rr.resource_index_srn_key("ssh", "web-01") == "ssh:web-01" + + +def test_encode_decode_by_id_roundtrip(): + blob = rr._encode_by_id_value("vcenter-1", "vcf_host") + out = rr._decode_by_id_value(blob) + assert out == {"minion": "vcenter-1", "type": "vcf_host"} + + +def test_decode_by_id_handles_garbage(): + assert rr._decode_by_id_value(None) is None + assert rr._decode_by_id_value(True) is None + assert rr._decode_by_id_value("not-json") is None + assert rr._decode_by_id_value("[1,2]") is None + + +# --------------------------------------------------------------------------- +# Registry end-to-end +# --------------------------------------------------------------------------- + + +def test_register_minion_and_read_back(registry): + registry.register_minion( + "vcenter-1", + { + "vcf_host": ["esxi-01", "esxi-02"], + "vm": ["vm-a"], + }, + ) + + assert registry.has_resource("vcenter-1", "vcf_host", "esxi-01") is True + assert registry.has_resource("vcenter-1", "vcf_host", "nope") is False + assert registry.has_resource_type("vcenter-1", "vm") is True + assert registry.has_resource_type("vcenter-1", "bogus") is False + + rs = registry.get_resources_for_minion("vcenter-1") + assert set(rs.keys()) == {"vcf_host", "vm"} + assert sorted(rs["vcf_host"]) == ["esxi-01", "esxi-02"] + + assert registry.get_managing_minions_for_srn("vcf_host", "esxi-01") == ["vcenter-1"] + assert registry.get_managing_minions_for_srn("vcf_host", "nope") == [] + + +def test_register_minion_diff_removes_stale(registry): + registry.register_minion("m1", {"ssh": ["a", "b", "c"], "vm": ["v1"]}) + # Now a smaller inventory: drop "b" and all vms. + registry.register_minion("m1", {"ssh": ["a", "c"]}) + + rs = registry.get_resources_for_minion("m1") + assert sorted(rs["ssh"]) == ["a", "c"] + assert "vm" not in rs + assert registry.has_resource("m1", "ssh", "b") is False + assert registry.has_resource("m1", "vm", "v1") is False + + +def test_cross_type_collisions_are_not_ambiguous(registry): + """Composite SRN keys: same bare id on different types resolves distinctly.""" + registry.register_minion("m1", {"ssh": ["web-01"]}) + registry.register_minion("m2", {"vm": ["web-01"]}) + + assert registry.get_managing_minions_for_srn("ssh", "web-01") == ["m1"] + assert registry.get_managing_minions_for_srn("vm", "web-01") == ["m2"] + + +def test_managing_minions_by_type(registry): + registry.register_minion("m1", {"ssh": ["a"]}) + registry.register_minion("m2", {"ssh": ["b"], "vm": ["v"]}) + registry.register_minion("m3", {"vm": ["v2"]}) + + got = registry.get_managing_minions_by_type("ssh") + assert got == {"minions": ["m1", "m2"], "missing": []} + + got = registry.get_managing_minions_by_type("vm") + assert got == {"minions": ["m2", "m3"], "missing": []} + + got = registry.get_managing_minions_by_type("bogus") + assert got == {"minions": [], "missing": []} + + +def test_unregister_minion(registry): + registry.register_minion("m1", {"ssh": ["a", "b"]}) + registry.register_minion("m2", {"ssh": ["c"]}) + + n = registry.unregister_minion("m1") + assert n == 2 + + assert registry.get_resources_for_minion("m1") == {} + assert registry.get_managing_minions_by_type("ssh") == { + "minions": ["m2"], + "missing": [], + } + + +def test_compact_preserves_contents(registry): + registry.register_minion("m1", {"ssh": [f"h{i}" for i in range(50)]}) + registry.register_minion("m2", {"vm": ["v1", "v2"]}) + + stats_before = registry.stats()["primary"] + assert stats_before["occupied"] == 52 + + before, after = registry.compact() + assert before == after == 52 + + # Reads still work after swap. + assert registry.has_resource("m1", "ssh", "h7") is True + assert registry.get_managing_minions_for_srn("vm", "v2") == ["m2"] + + +def test_compact_reclaims_tombstones(registry): + registry.register_minion("m1", {"ssh": [f"h{i}" for i in range(40)]}) + registry.register_minion("m1", {"ssh": [f"h{i}" for i in range(0, 40, 2)]}) + + stats_before = registry.stats()["primary"] + # 40 originals - 20 survivors = 20 deletions; 20 occupied + 20 deleted. + assert stats_before["occupied"] == 20 + assert stats_before["deleted"] == 20 + + before, after = registry.compact() + assert before == after == 20 + + stats_after = registry.stats()["primary"] + assert stats_after["occupied"] == 20 + assert stats_after["deleted"] == 0 + + +def test_get_resource_uses_resources_bank(registry): + # The ResourceRegistry uses the fake cache we injected; seed the bank. + fake_cache = registry._cache + fake_cache.store(rr.RESOURCE_BANK, "esxi-01", {"managing_minions": ["vcenter-1"]}) + assert registry.get_resource("esxi-01") == {"managing_minions": ["vcenter-1"]} + assert registry.get_resource("missing") is None + + +def test_get_managing_minions_for_id_legacy(registry): + registry._cache.store( + rr.RESOURCE_BANK, "shared-id", {"managing_minions": ["m1", "m2"]} + ) + assert registry.get_managing_minions_for_id("shared-id") == ["m1", "m2"] + assert registry.get_managing_minions_for_id("missing") == [] + + +def test_stats_shape(registry): + registry.register_minion("m1", {"ssh": ["a"]}) + s = registry.stats() + assert "primary" in s + assert "derived_version" in s + assert "derived_by_type_count" in s + assert "derived_by_minion_count" in s + assert "path" in s + assert s["primary"]["occupied"] == 1 + + +def test_missing_cachedir_raises(): + with pytest.raises(ValueError): + rr.ResourceRegistry({}, cache=_FakeCache()) + + +def test_get_registry_without_cachedir_is_inert(): + """Master helpers may construct CkMinions with partial opts (no cache dir).""" + reg = rr.get_registry({}) + assert reg.has_srn("ssh", "x") is False + assert reg.get_resources_for_minion("m") == {} + assert reg.register_minion("m", {"ssh": ["a"]}) == (0, 0) + + +# --------------------------------------------------------------------------- +# maybe_compact — policy-driven automatic compaction +# --------------------------------------------------------------------------- + + +def _make_registry(tmp_path, **overrides): + opts = { + "cachedir": str(tmp_path), + "resource_index_primary_capacity": _TEST_PRIMARY_CAPACITY, + "resource_index_primary_slot_size": _TEST_PRIMARY_SLOT_SIZE, + } + opts.update(overrides) + return rr.ResourceRegistry(opts, cache=_FakeCache()) + + +@contextlib.contextmanager +def _registry_session(tmp_path, **overrides): + """Construct a registry and always ``close()`` so mmap FDs are released.""" + reg = _make_registry(tmp_path, **overrides) + try: + yield reg + finally: + reg.close() + + +def test_maybe_compact_throttled_by_default(tmp_path): + with _registry_session(tmp_path) as reg: + # No writes yet, no file either. First call goes through the throttle + # (because _last_compact_check starts at 0). Second should be throttled. + first, _ = reg.maybe_compact() + second, _ = reg.maybe_compact() + assert first is False # no file / nothing above threshold + assert second is False # throttled + + +def test_maybe_compact_force_check_bypasses_throttle(tmp_path): + with _registry_session(tmp_path) as reg: + reg.register_minion("m1", {"ssh": ["a", "b", "c"]}) + # Force two back-to-back reads; both should run (no side-effect since + # nothing is above threshold, but the stats read happens). + _, s1 = reg.maybe_compact(force_check=True) + _, s2 = reg.maybe_compact(force_check=True) + assert s1 is not None + assert s2 is not None + + +def test_maybe_compact_triggers_on_tombstone_ratio(tmp_path): + # Very large interval during setup so register_minion's in-line + # maybe_compact is suppressed; we drive compaction explicitly. + with _registry_session( + tmp_path, + resource_registry_compact_min_interval=3600, + resource_registry_compact_tombstone_ratio=0.1, + ) as reg: + reg.register_minion("m1", {"ssh": [f"h{i}" for i in range(20)]}) + reg.register_minion("m1", {"ssh": [f"h{i}" for i in range(0, 20, 4)]}) + + stats_pre = reg.stats()["primary"] + assert stats_pre["deleted"] > 0 + + compacted, _ = reg.maybe_compact(force_check=True) + assert compacted is True + + stats_post = reg.stats()["primary"] + assert stats_post["deleted"] == 0 + assert reg.has_resource("m1", "ssh", "h0") is True + assert reg.has_resource("m1", "ssh", "h3") is False + + +def test_maybe_compact_triggers_on_load_factor(tmp_path): + with _registry_session( + tmp_path, + resource_index_primary_capacity=128, + resource_registry_compact_load_factor=0.1, + # Suppress the in-line compact so the test sees an un-compacted file. + resource_registry_compact_min_interval=3600, + ) as reg: + reg.register_minion("m1", {"ssh": [f"h{i}" for i in range(20)]}) + compacted, stats = reg.maybe_compact(force_check=True) + # Occupied/total = 20/128 = 0.156 which exceeds 0.1. + assert compacted is True + + +def test_maybe_compact_no_trigger_when_healthy(tmp_path): + with _registry_session( + tmp_path, + resource_registry_compact_min_interval=0, + ) as reg: + reg.register_minion("m1", {"ssh": ["a", "b"]}) + compacted, stats = reg.maybe_compact(force_check=True) + assert compacted is False + assert stats["occupied"] == 2 + assert stats["deleted"] == 0 + + +def test_register_minion_triggers_auto_compact(tmp_path): + """register_minion invokes maybe_compact inline; when thresholds are met + on the second call, the tombstones from the delta must be reclaimed.""" + with _registry_session( + tmp_path, + resource_registry_compact_min_interval=0, + resource_registry_compact_tombstone_ratio=0.1, + ) as reg: + reg.register_minion("m1", {"ssh": [f"h{i}" for i in range(20)]}) + # The second register_minion deletes 15 entries, tombstone_ratio + # becomes 15/5 = 3.0 >> 0.1, and the inline maybe_compact reclaims. + reg.register_minion("m1", {"ssh": [f"h{i}" for i in range(0, 20, 4)]}) + stats = reg.stats()["primary"] + assert stats["deleted"] == 0 + assert stats["occupied"] == 5 + + +def test_concurrent_register_minion_and_mmap_derived_reads(registry): + """ + Writers (:meth:`register_minion`) and readers (derived views / bare-id + resolution) run concurrently on the mmap primary — must stay crash-free. + """ + errs = [] + lock = threading.Lock() + + def writer(): + try: + for i in range(60): + registry.register_minion(f"w{i % 6}", {"dummy": [f"id-{i}", "anchor"]}) + except Exception as exc: # pylint: disable=broad-except + with lock: + errs.append(exc) + + def reader(): + try: + for _ in range(120): + registry.resolve_bare_resource_id("anchor") + registry.get_resource_ids_by_type("dummy") + registry.get_resources_for_minion("w0") + except Exception as exc: # pylint: disable=broad-except + with lock: + errs.append(exc) + + tw = threading.Thread(target=writer) + tr = threading.Thread(target=reader) + tw.start() + tr.start() + tw.join() + tr.join() + assert not errs, errs + assert registry.resolve_bare_resource_id("anchor") diff --git a/tests/pytests/unit/utils/test_resources.py b/tests/pytests/unit/utils/test_resources.py new file mode 100644 index 000000000000..0a9d2ba580ee --- /dev/null +++ b/tests/pytests/unit/utils/test_resources.py @@ -0,0 +1,95 @@ +""" +Tests for salt.utils.resources (configurable resource pillar key). +""" + +import logging + +import pytest + +import salt.utils.resources +from tests.support.mock import MagicMock, patch + + +def test_resource_pillar_key_default(): + assert salt.utils.resources.resource_pillar_key({}) == "resources" + assert salt.utils.resources.resource_pillar_key({"resource_pillar_key": "x"}) == "x" + + +@pytest.mark.parametrize("bad", ("", None)) +def test_resource_pillar_key_empty_warns_and_defaults(bad, caplog): + caplog.set_level(logging.WARNING) + assert ( + salt.utils.resources.resource_pillar_key({"resource_pillar_key": bad}) + == "resources" + ) + assert "resource_pillar_key is empty" in caplog.text + + +def test_pillar_resources_tree_default_key(): + opts = {"pillar": {"resources": {"ssh": {}}}} + assert salt.utils.resources.pillar_resources_tree(opts) == {"ssh": {}} + + +def test_pillar_resources_tree_custom_key(): + opts = {"resource_pillar_key": "my_res", "pillar": {"my_res": {"a": 1}}} + assert salt.utils.resources.pillar_resources_tree(opts) == {"a": 1} + + +def test_pillar_resources_tree_missing_key_same_as_empty(): + opts = {"pillar": {}} + assert salt.utils.resources.pillar_resources_tree(opts) == {} + + +def test_pillar_resources_tree_wrong_type(): + opts = {"pillar": {"resources": "bad"}} + assert salt.utils.resources.pillar_resources_tree(opts) == {} + + +def test_bare_resource_id_in_cache_pillar_separate_cache_driver(): + """Pillar bank follows ``pillar.cache_driver``, not only the default cache.""" + pillar_cache = MagicMock() + pillar_cache.list.return_value = ["minion-2"] + pillar_cache.fetch.return_value = { + "resources": {"dummy": {"resource_ids": ["m2-dummy2"]}} + } + + grains_cache = MagicMock() + grains_cache.list.return_value = [] + + def fake_factory(opts, **kwargs): + if kwargs.get("driver") == "pillar_redis": + return pillar_cache + return grains_cache + + opts = {"minion_data_cache": True, "pillar.cache_driver": "pillar_redis"} + with patch("salt.cache.factory", side_effect=fake_factory): + assert salt.utils.resources.bare_resource_id_in_minion_data_cache( + opts, "m2-dummy2" + ) + pillar_cache.list.assert_called_once_with("pillar") + # Short-circuit once pillar matches; grains are not scanned. + grains_cache.list.assert_not_called() + + +def test_bare_resource_id_in_cache_reuses_passed_cache_for_grains(): + shared = MagicMock() + + def list_side_effect(bank): + if bank == "pillar": + return [] + if bank == "grains": + return ["minion-2"] + return [] + + def fetch_side_effect(bank, mid): + if bank == "grains" and mid == "minion-2": + return {"salt_resources": {"dummy": ["m2-dummy2"]}} + return {} + + shared.list.side_effect = list_side_effect + shared.fetch.side_effect = fetch_side_effect + + opts = {"minion_data_cache": True} + assert salt.utils.resources.bare_resource_id_in_minion_data_cache( + opts, "m2-dummy2", cache=shared + ) diff --git a/tests/resources_smoke.txt b/tests/resources_smoke.txt new file mode 100644 index 000000000000..24db74af7d5e --- /dev/null +++ b/tests/resources_smoke.txt @@ -0,0 +1,31 @@ +# Smoke test list for the Salt resources framework. +# +# Usage: +# pytest $(grep -v '^#' tests/resources_smoke.txt) +# or: +# pytest @tests/resources_smoke.txt +# +# Lines starting with '#' are comments. Blank lines are ignored. + +# --- Unit --- +tests/pytests/unit/test_minion_resources.py +tests/pytests/unit/test_master.py +tests/pytests/unit/utils/test_minions_resources.py +tests/pytests/unit/utils/test_resource_registry.py +tests/pytests/unit/utils/test_resources.py +tests/pytests/unit/runners/test_resource.py +tests/pytests/unit/resources/test_dummy_resource_grains.py +tests/pytests/unit/resources/test_ssh_resource.py +tests/pytests/unit/matchers/test_resource_matchers.py +tests/pytests/unit/client/ssh/test_ssh_classes.py +tests/pytests/unit/modules/test_sshresource_state.py +tests/pytests/unit/loader/test_context.py + +# --- Integration (slow; require salt-factories master/minion) --- +tests/pytests/integration/resources/test_dummy_resource.py +tests/pytests/integration/resources/test_multi_minion_grain_targeting.py +tests/pytests/integration/resources/test_cli_offline_expands_resource_targets.py +tests/pytests/integration/resources_ssh/test_ssh_resource_integration.py + +# --- Functional --- +# (none yet) diff --git a/tests/smoke-tests-before-commit.txt b/tests/smoke-tests-before-commit.txt new file mode 100644 index 000000000000..5138bac4cc58 --- /dev/null +++ b/tests/smoke-tests-before-commit.txt @@ -0,0 +1,20 @@ +# Commands to run locally before pushing Salt changes that touch integration tests +# or master/minion behavior. From the repository root. Requires a working ZMQ +# stack; integration tests start real master/minion processes. +# +# Always run your project's formatter/linter gate if you use it, e.g.: +# pre-commit run --all-files + +# --- Client LocalClient / cmd_cli (footest, minion targeting) +python3 -m pytest tests/integration/client/test_standard.py -vv --run-slow + +# --- saltutil.wheel + minions.connected / grains cache warmup +python3 -m pytest tests/pytests/integration/modules/saltutil/test_wheel.py -vv --run-slow + +# --- If you modify resource registry / minions / mmap cache (uncomment as needed) +# python3 -m pytest tests/pytests/unit/utils/test_minions_resources.py -vv +# python3 -m pytest tests/pytests/unit/utils/test_resource_registry.py -vv +# python3 -m pytest tests/pytests/unit/utils/test_mmap_cache.py -vv + +# Windows and macOS: use the same pytest lines on those machines when validating +# CI parity (paths and Python may differ; onedir CI uses nox — see noxfile). diff --git a/tests/support/sshd_runtime.py b/tests/support/sshd_runtime.py new file mode 100644 index 000000000000..3feb5afac9c9 --- /dev/null +++ b/tests/support/sshd_runtime.py @@ -0,0 +1,99 @@ +""" +Helpers for test-suite ``sshd`` instances. + +Minimal container images (e.g. Ubuntu CI) often ship OpenSSH without creating the +runtime directory used for privilege separation (commonly ``/run/sshd``). If that +directory is missing, ``sshd`` exits immediately with: + + Missing privilege separation directory: /run/sshd + +which surfaces as :class:`~pytestshellutils.exceptions.FactoryNotStarted` in tests. +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import shutil +import subprocess +import sys + +log = logging.getLogger(__name__) + + +def ensure_sshd_privilege_separation_directories( + sshd_config_file: str | os.PathLike[str] | None = None, +) -> None: + """ + Ensure privilege-separation (and similar) directories exist before starting ``sshd``. + + * Prefer directories reported by ``sshd -T`` for the test config (portable across + distros / OpenSSH builds). + * If none are found, on Linux only, create ``/run/sshd`` — the usual expectation + on Debian/Ubuntu-family images when ``/run`` is tmpfs and the package postinst + did not run (typical in CI containers). + + Creating an unused empty directory is harmless; skipping non-Linux fallbacks + avoids touching paths that do not apply to macOS or Windows SSH test runs. + """ + if os.name == "nt": + return + + sshd = shutil.which("sshd") + if not sshd: + log.debug("sshd not in PATH; skipping privilege-separation directory setup") + return + + config_path: pathlib.Path | None = None + if sshd_config_file is not None: + p = pathlib.Path(sshd_config_file) + if p.is_file(): + config_path = p.resolve() + + cmd = [sshd, "-T"] + if config_path is not None: + cmd.extend(["-f", str(config_path)]) + + dirs: list[str] = [] + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if proc.returncode == 0: + for line in proc.stdout.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + key, val = parts[0].lower(), parts[1].strip() + if "privsep" not in key: + continue + if val in ("none", "yes", "no", "sandbox"): + continue + if val.startswith("/"): + dirs.append(val) + else: + log.debug( + "sshd -T failed (rc=%s): %s", + proc.returncode, + (proc.stderr or proc.stdout or "").strip()[:500], + ) + except OSError as exc: + log.debug("Could not query sshd -T: %s", exc) + + if not dirs and sys.platform.startswith("linux") and os.path.isdir("/run"): + dirs.append("/run/sshd") + + for d in dirs: + try: + os.makedirs(d, mode=0o755, exist_ok=True) + log.debug("Ensured sshd runtime directory exists: %s", d) + except OSError as exc: + log.debug("Could not create %s: %s", d, exc)