diff --git a/plugins/lookup/deployment.py b/plugins/lookup/deployment.py index b74d65abac..3a2b8ff495 100644 --- a/plugins/lookup/deployment.py +++ b/plugins/lookup/deployment.py @@ -49,7 +49,8 @@ class LookupModule(LookupBase): return [cached] running = whitelist or groups - deployed = list(running) if runtime in _EPHEMERAL_RUNTIMES else list(groups) + ephemeral = runtime in _EPHEMERAL_RUNTIMES + deployed = list(running) if ephemeral else list(groups) result = { "whitelist": whitelist, "running": list(running), @@ -57,6 +58,7 @@ class LookupModule(LookupBase): "inventory": inventory, "deployed": deployed, "runtime": runtime, + "ephemeral": ephemeral, "all": list(list_invokable_app_ids()), } _STATUS_CACHE[key] = result diff --git a/plugins/lookup/mcp_servers.py b/plugins/lookup/mcp_servers.py index 8a67782b17..3457453907 100644 --- a/plugins/lookup/mcp_servers.py +++ b/plugins/lookup/mcp_servers.py @@ -28,11 +28,12 @@ per-tool filter can express, and a surface that withholds every tool it serves as mutating has nothing left to offer. Advertising either as a configured server would leave an agent wired to a provider it can never call. -Providers are narrowed to ``application_closure(deployment.whitelist)``, the same -set ``sys-service-loader`` preloads from. Inventory membership, ``group_names`` -and the raw whitelist all admit roles the run never deploys, and a client then -carries an endpoint nothing is serving. In compose the closure covers everything -present, so only swarm makes the difference visible. +On an ephemeral runtime (``deployment.ephemeral``) providers are narrowed to +``application_closure(deployment.whitelist)``, the same set ``sys-service-loader`` +preloads from: there inventory membership, ``group_names`` and the raw whitelist +all admit roles the run never deploys, and a client would carry an endpoint +nothing is serving. A persistent host keeps serving every provider an earlier run +deployed, so a partial run there narrows nothing and the client keeps them. ``credential_missing`` is the one authorized rejection that does not abort, because a provider deploying later in the same play has not written its secret @@ -290,15 +291,17 @@ def resolve_consumer_id(vars_: Mapping[str, Any], templar: Any) -> str: class LookupModule(LookupBase): def _deploy_closure(self, templar: Any, vars_: dict[str, Any]) -> set[str]: - """Roles this run deploys, or an empty set when that cannot be resolved.""" + """Roles an ephemeral run deploys; empty on a persistent host or when unresolvable.""" try: - whitelist = lookup_loader.get( + deployment = lookup_loader.get( "deployment", loader=self._loader, templar=templar - ).run([], variables=vars_)[0]["whitelist"] + ).run([], variables=vars_)[0] + if not deployment["ephemeral"]: + return set() return set( lookup_loader.get( "application_closure", loader=self._loader, templar=templar - ).run([whitelist], variables=vars_)[0] + ).run([deployment["whitelist"]], variables=vars_)[0] ) except (AnsibleError, KeyError, IndexError, TypeError): return set() diff --git a/tests/unit/python/plugins/lookup/test_deployment.py b/tests/unit/python/plugins/lookup/test_deployment.py index 8a206ae003..199b15e949 100644 --- a/tests/unit/python/plugins/lookup/test_deployment.py +++ b/tests/unit/python/plugins/lookup/test_deployment.py @@ -29,6 +29,7 @@ class TestDeploymentLookup(unittest.TestCase): "inventory", "deployed", "runtime", + "ephemeral", "all", }, ) @@ -225,6 +226,7 @@ class TestDeploymentLookup(unittest.TestCase): ) self.assertEqual(result[0]["deployed"], ["only-this"]) self.assertEqual(result[0]["runtime"], runtime) + self.assertTrue(result[0]["ephemeral"]) @patch("plugins.lookup.deployment.list_invokable_app_ids") def test_deployed_uses_groups_in_persistent_runtime(self, mock_list): @@ -243,6 +245,7 @@ class TestDeploymentLookup(unittest.TestCase): result[0]["deployed"], ["partial", "still-running", "also-running"], ) + self.assertFalse(result[0]["ephemeral"]) if __name__ == "__main__": diff --git a/tests/unit/python/plugins/lookup/test_mcp_servers.py b/tests/unit/python/plugins/lookup/test_mcp_servers.py index 6ef82cbe62..992d37813c 100644 --- a/tests/unit/python/plugins/lookup/test_mcp_servers.py +++ b/tests/unit/python/plugins/lookup/test_mcp_servers.py @@ -1,13 +1,14 @@ """Unit tests for ``plugins/lookup/mcp_servers.py``. -Only the pure builder and the credential resolver are exercised; the lookup -wrapper forwards ``roles_with_service``, ``applications`` and ``users`` into -them. +The pure builder, the credential resolver and the deploy closure are exercised; +the lookup wrapper forwards ``roles_with_service``, ``applications`` and ``users`` +into them. """ import importlib import unittest from typing import ClassVar +from unittest.mock import MagicMock, patch from ansible.errors import AnsibleError @@ -83,6 +84,34 @@ class TestSelectDeployed(unittest.TestCase): self.assertEqual([], plugin_module.select_deployed(None, {"web-app-hermes"})) +class TestDeployClosure(unittest.TestCase): + def closure(self, deployment): + def get(name, **kwargs): + plugin = MagicMock() + if name == "deployment": + plugin.run.return_value = [deployment] + else: + plugin.run.side_effect = lambda terms, variables=None: [ + {*terms[0], "svc-db-postgres"} + ] + return plugin + + with patch.object(plugin_module.lookup_loader, "get", side_effect=get): + return plugin_module.LookupModule()._deploy_closure(None, {}) + + def test_a_partial_run_on_a_persistent_host_narrows_nothing(self): + self.assertEqual( + set(), + self.closure({"ephemeral": False, "whitelist": ["web-opt-rdr-domains"]}), + ) + + def test_an_ephemeral_run_narrows_to_the_whitelist_closure(self): + self.assertEqual( + {"web-app-hermes", "svc-db-postgres"}, + self.closure({"ephemeral": True, "whitelist": ["web-app-hermes"]}), + ) + + class TestBuildMcpDiscovery(unittest.TestCase): def test_no_servers_yields_nothing(self): for servers in (None, []):