diff --git a/pyproject.toml b/pyproject.toml index 44a451a9..4f7042a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.42.0" +version = "0.43.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/extensibility/_ums_transport.py b/src/sap_cloud_sdk/extensibility/_ums_transport.py index cbacd083..82e8d2cd 100644 --- a/src/sap_cloud_sdk/extensibility/_ums_transport.py +++ b/src/sap_cloud_sdk/extensibility/_ums_transport.py @@ -22,7 +22,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional import httpx - from sap_cloud_sdk.core.telemetry import Module from sap_cloud_sdk.destination import ConsumptionLevel from sap_cloud_sdk.destination import create_client as create_destination_client @@ -41,6 +40,7 @@ OnFailure, ) from sap_cloud_sdk.extensibility.exceptions import TransportError +from sap_cloud_sdk.destination import ConsumptionOptions if TYPE_CHECKING: from sap_cloud_sdk.extensibility.config import ExtensibilityConfig @@ -52,8 +52,8 @@ # --------------------------------------------------------------------------- ENV_CONHOS_LANDSCAPE = "APPFND_CONHOS_LANDSCAPE" -ENV_UMS_DESTINATION_NAME = "APPFND_UMS_DESTINATION_NAME" -_UMS_DESTINATION_PREFIX = "sap-managed-runtime-ums-" +ENV_UMS_URL = "APPFND_CONHOS_UMS_URL" +_IAS_DESTINATION_PREFIX = "sap-managed-runtime-ias-" # --------------------------------------------------------------------------- # GraphQL query @@ -182,24 +182,23 @@ def _parse_method_safe(value: str) -> HTTPMethod: def _ums_destination_name(config_override: Optional[str] = None) -> Optional[str]: - """Construct the UMS destination name from configuration or environment. + """Construct the IAS destination name from configuration or environment. Resolution order: 1. **Config override** -- if ``config.destination_name`` is set, use it directly. - 2. **Explicit env var override** -- if ``APPFND_UMS_DESTINATION_NAME`` - is set, use its value directly. This is useful in subaccounts - where the UMS destination follows a non-standard naming convention. - 3. **Landscape-based construction** -- the destination name is built as - ``sap-managed-runtime-ums-{APPFND_CONHOS_LANDSCAPE}``. + 2. **Landscape-based construction** -- built as + ``sap-managed-runtime-ias-{APPFND_CONHOS_LANDSCAPE}``. + ``APPFND_CONHOS_UMS_URL`` must be set; a warning is logged and + ``None`` is returned if it is absent. Args: config_override: Optional destination name from :class:`ExtensibilityConfig`. Takes highest priority when set. Returns: - The resolved UMS destination name, or ``None`` if no configuration + The resolved destination name, or ``None`` if no configuration or environment variables are available to determine it. """ # 0. Config-level override takes highest priority @@ -210,31 +209,24 @@ def _ums_destination_name(config_override: Optional[str] = None) -> Optional[str ) return config_override - # 1. Explicit env var override takes precedence - override = os.environ.get(ENV_UMS_DESTINATION_NAME) - if override: - logger.debug( - "Using UMS destination name from %s: %s", - ENV_UMS_DESTINATION_NAME, - override, - ) - return override - # 2. Construct from landscape (existing logic) landscape = os.environ.get(ENV_CONHOS_LANDSCAPE) if not landscape: logger.warning( - "%s is not set; cannot construct UMS destination name. " - "Set %s or %s to configure the UMS destination name.", - ENV_CONHOS_LANDSCAPE, - ENV_UMS_DESTINATION_NAME, + "%s is not set; cannot construct UMS destination name.", ENV_CONHOS_LANDSCAPE, ) return None - destination_name = f"{_UMS_DESTINATION_PREFIX}{landscape}" + if not os.environ.get(ENV_UMS_URL): + logger.warning( + "%s is not set; cannot construct IAS destination name.", + ENV_UMS_URL, + ) + return None + destination_name = f"{_IAS_DESTINATION_PREFIX}{landscape}" logger.debug( - "Resolved UMS destination name from %s: %s", + "Resolved IAS destination name from %s: %s", ENV_CONHOS_LANDSCAPE, destination_name, ) @@ -428,17 +420,24 @@ def _transform_ums_response( class UmsTransport: """UMS GraphQL transport for the extensibility service. - Resolves the UMS destination via the Destination SDK, then sends - a GraphQL query to the UMS ``/graphql`` endpoint and transforms - the response into an :class:`ExtensionCapabilityImplementation`. + Resolves the UMS destination, then sends a GraphQL query to the UMS + ``/graphql`` endpoint and transforms the response into an + :class:`ExtensionCapabilityImplementation`. - The destination name is resolved in order: + **Destination name** is resolved in order: 1. ``config.destination_name`` (explicit config override). - 2. ``APPFND_UMS_DESTINATION_NAME`` environment variable. - 3. ``sap-managed-runtime-ums-{APPFND_CONHOS_LANDSCAPE}`` (constructed). + 2. Landscape-based construction: + + * ``sap-managed-runtime-ias-{APPFND_CONHOS_LANDSCAPE}`` (requires + ``APPFND_CONHOS_UMS_URL`` to be set; logs a warning and returns + ``None`` otherwise). - If none of the above are available, resolution fails with a warning. + **Base URL** is resolved from ``APPFND_CONHOS_UMS_URL``. A + :class:`TransportError` is raised if it is not set. + + In both cases the **mTLS certificate** is taken from the resolved + destination. Args: agent_ord_id: ORD ID of the agent. @@ -502,15 +501,6 @@ def get_extension_capability_implementation( TransportError: If destination resolution, HTTP communication, or response parsing fails. """ - # Guard: destination name must be resolved - if self._destination_name is None: - raise TransportError( - "UMS destination name could not be resolved. " - "Set the APPFND_UMS_DESTINATION_NAME or " - "APPFND_CONHOS_LANDSCAPE environment variable, or provide " - "a destination_name in ExtensibilityConfig." - ) - # 0. Cache lookup ------------------------------------------------ cache_key = (tenant, capability_id) all_edges: List[Dict[str, Any]] = [] @@ -541,10 +531,19 @@ def get_extension_capability_implementation( ) # 1. Resolve destination ----------------------------------------- + if self._destination_name is None: + raise TransportError( + "UMS destination name could not be resolved. " + "Set both APPFND_CONHOS_LANDSCAPE and APPFND_CONHOS_UMS_URL " + "to construct the IAS destination name, " + "or provide a destination_name in ExtensibilityConfig." + ) + try: dest = self._dest_client.get_destination( self._destination_name, level=ConsumptionLevel.PROVIDER_SUBACCOUNT, + options=ConsumptionOptions(skip_token_retrieval=True), ) except Exception as exc: raise TransportError( @@ -556,13 +555,20 @@ def get_extension_capability_implementation( f"Destination '{self._destination_name}' not found in Destination Service." ) - base_url = dest.url - if base_url is None: + # 2. Resolve base URL -------------------------------------------- + ums_url_override = os.environ.get(ENV_UMS_URL) + if not ums_url_override: + logger.warning( + "%s is not set; cannot resolve UMS base URL.", + ENV_UMS_URL, + ) raise TransportError( - f"Destination '{self._destination_name}' has no URL configured." + f"{ENV_UMS_URL} is not set; cannot resolve UMS base URL." ) + base_url = ums_url_override + logger.debug("Using UMS URL from %s: %s", ENV_UMS_URL, base_url) - # 2. Extract client certificate ---------------------------------- + # 3. Extract client certificate ---------------------------------- if not dest.certificates: raise TransportError( f"Destination '{self._destination_name}' has no " @@ -578,7 +584,7 @@ def get_extension_capability_implementation( f"Failed to decode client certificate '{cert.name}': {exc}" ) from exc - # 3. Build GraphQL request -------------------------------------- + # 4. Build GraphQL request -------------------------------------- url = f"{base_url.rstrip('/')}{_UMS_GRAPHQL_PATH}" agent_filter: dict[str, Any] = { @@ -599,7 +605,7 @@ def get_extension_capability_implementation( "X-Tenant": tenant, } - # 4. Send paginated requests with mTLS -------------------------- + # 5. Send paginated requests with mTLS -------------------------- all_edges = [] cursor: Optional[str] = None try: @@ -626,7 +632,7 @@ def get_extension_capability_implementation( headers=request_headers, ) - # 5. Parse response --------------------------------- + # 6. Parse response --------------------------------- try: response.raise_for_status() except httpx.HTTPStatusError as exc: @@ -674,7 +680,7 @@ def get_extension_capability_implementation( except Exception as exc: raise TransportError(f"HTTP request to UMS endpoint failed: {exc}") from exc - # 6. Populate cache ---------------------------------------------- + # 7. Populate cache ---------------------------------------------- now = time.monotonic() with self._cache_lock: @@ -693,7 +699,7 @@ def get_extension_capability_implementation( self._cache[cache_key] = (now, all_edges) - # 7. Transform ----------------------------------------------------------- + # 8. Transform ----------------------------------------------------------- combined_data: Dict[str, Any] = { "EXTHUB__ExtCapImplementationInstances": {"edges": all_edges}, } diff --git a/src/sap_cloud_sdk/extensibility/config.py b/src/sap_cloud_sdk/extensibility/config.py index c881492d..08745405 100644 --- a/src/sap_cloud_sdk/extensibility/config.py +++ b/src/sap_cloud_sdk/extensibility/config.py @@ -20,14 +20,10 @@ class ExtensibilityConfig: Attributes: destination_name: Optional override for the UMS destination name. - When ``None`` (the default), the destination name is resolved - automatically in order: - (1) ``APPFND_UMS_DESTINATION_NAME`` environment variable, - (2) ``sap-managed-runtime-ums-{APPFND_CONHOS_LANDSCAPE}``. - If neither is available, resolution fails with a warning. - Set this only when the destination follows a non-standard - naming convention that cannot be expressed via environment - variables. + When set, it is used directly, bypassing automatic resolution. + When ``None`` (the default), the destination name is constructed as + ``sap-managed-runtime-ias-{APPFND_CONHOS_LANDSCAPE}`` (requires + ``APPFND_CONHOS_UMS_URL`` to be set). destination_instance: Destination service instance name. When ``"default"``, resolves to the default destination service instance. Specify a name only if your deployment binds the destination service under a diff --git a/tests/extensibility/unit/test_ums_caching.py b/tests/extensibility/unit/test_ums_caching.py index fa1ee6ef..83f1cf95 100644 --- a/tests/extensibility/unit/test_ums_caching.py +++ b/tests/extensibility/unit/test_ums_caching.py @@ -9,6 +9,7 @@ _CACHE_TTL_SECONDS, _CACHE_MAX_SIZE, ENV_CONHOS_LANDSCAPE, + ENV_UMS_URL, ) from sap_cloud_sdk.extensibility.exceptions import TransportError @@ -29,6 +30,7 @@ class TestUmsTransportCache: @pytest.fixture(autouse=True) def _set_landscape_env(self, monkeypatch): monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + monkeypatch.setenv(ENV_UMS_URL, "https://ums.example.com") @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") def _make_transport(self, mock_dest_client, dest=None): diff --git a/tests/extensibility/unit/test_ums_pagination.py b/tests/extensibility/unit/test_ums_pagination.py index 39198bf6..35d06277 100644 --- a/tests/extensibility/unit/test_ums_pagination.py +++ b/tests/extensibility/unit/test_ums_pagination.py @@ -8,6 +8,7 @@ UmsTransport, _MAX_PAGES, ENV_CONHOS_LANDSCAPE, + ENV_UMS_URL, ) from sap_cloud_sdk.extensibility.exceptions import TransportError @@ -27,6 +28,7 @@ class TestUmsTransportPagination: @pytest.fixture(autouse=True) def _set_landscape_env(self, monkeypatch): monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + monkeypatch.setenv(ENV_UMS_URL, "https://ums.example.com") @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") def _make_transport(self, mock_dest_client, dest=None): diff --git a/tests/extensibility/unit/test_ums_transport.py b/tests/extensibility/unit/test_ums_transport.py index f725e348..7e4b449e 100644 --- a/tests/extensibility/unit/test_ums_transport.py +++ b/tests/extensibility/unit/test_ums_transport.py @@ -13,9 +13,8 @@ from sap_cloud_sdk.extensibility._ums_transport import ( UmsTransport, _ums_destination_name, - _UMS_DESTINATION_PREFIX, ENV_CONHOS_LANDSCAPE, - ENV_UMS_DESTINATION_NAME, + ENV_UMS_URL, _GRAPHQL_QUERY, ) from sap_cloud_sdk.extensibility.config import ExtensibilityConfig @@ -36,58 +35,38 @@ class TestUmsDestinationName: def test_constructs_from_landscape_env(self, monkeypatch): - monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") - assert _ums_destination_name() == "sap-managed-runtime-ums-exttest-dev-eu12" + monkeypatch.setenv(ENV_UMS_URL, "https://ums.example.com") + assert _ums_destination_name() == "sap-managed-runtime-ias-exttest-dev-eu12" def test_constructs_from_landscape_env_prod(self, monkeypatch): - monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "myagent-prod-eu10") - assert _ums_destination_name() == "sap-managed-runtime-ums-myagent-prod-eu10" + monkeypatch.setenv(ENV_UMS_URL, "https://ums.example.com") + assert _ums_destination_name() == "sap-managed-runtime-ias-myagent-prod-eu10" def test_returns_none_when_env_not_set(self, monkeypatch): - monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) monkeypatch.delenv(ENV_CONHOS_LANDSCAPE, raising=False) assert _ums_destination_name() is None def test_returns_none_when_env_empty(self, monkeypatch): - monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "") assert _ums_destination_name() is None - def test_prefix_constant(self): - assert _UMS_DESTINATION_PREFIX == "sap-managed-runtime-ums-" - - def test_override_env_takes_precedence(self, monkeypatch): - monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "ums-exttest-dev-eu12") - monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") - assert _ums_destination_name() == "ums-exttest-dev-eu12" - - def test_override_env_without_landscape(self, monkeypatch): - monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "my-custom-dest") - monkeypatch.delenv(ENV_CONHOS_LANDSCAPE, raising=False) - assert _ums_destination_name() == "my-custom-dest" - - def test_empty_override_falls_through_to_landscape(self, monkeypatch): - monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "") + def test_returns_none_when_ums_url_not_set(self, monkeypatch): monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") - assert _ums_destination_name() == "sap-managed-runtime-ums-exttest-dev-eu12" + monkeypatch.delenv(ENV_UMS_URL, raising=False) + assert _ums_destination_name() is None def test_config_override_takes_highest_priority(self, monkeypatch): - monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "env-override") monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") assert _ums_destination_name(config_override="config-dest") == "config-dest" - def test_config_override_none_falls_through(self, monkeypatch): - monkeypatch.setenv(ENV_UMS_DESTINATION_NAME, "env-override") - assert _ums_destination_name(config_override=None) == "env-override" - def test_config_override_empty_falls_through(self, monkeypatch): monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") - monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) + monkeypatch.setenv(ENV_UMS_URL, "https://ums.example.com") assert ( _ums_destination_name(config_override="") - == "sap-managed-runtime-ums-exttest-dev-eu12" + == "sap-managed-runtime-ias-exttest-dev-eu12" ) # --------------------------------------------------------------------------- @@ -98,12 +77,12 @@ def test_config_override_empty_falls_through(self, monkeypatch): class TestUmsTransportInit: @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") def test_valid_config(self, mock_dest_client, monkeypatch): - monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + monkeypatch.setenv(ENV_UMS_URL, "https://ums.example.com") config = _make_config() transport = UmsTransport(AGENT_ORD_ID, config) assert transport._config is config - assert transport._destination_name == "sap-managed-runtime-ums-exttest-dev-eu12" + assert transport._destination_name == "sap-managed-runtime-ias-exttest-dev-eu12" mock_dest_client.assert_called_once() assert mock_dest_client.call_args.kwargs["instance"] == "default" @@ -111,7 +90,6 @@ def test_valid_config(self, mock_dest_client, monkeypatch): def test_destination_name_none_when_env_not_set( self, mock_dest_client, monkeypatch ): - monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) monkeypatch.delenv(ENV_CONHOS_LANDSCAPE, raising=False) config = _make_config() transport = UmsTransport(AGENT_ORD_ID, config) @@ -136,6 +114,7 @@ class TestUmsTransportGetExtCapImpl: @pytest.fixture(autouse=True) def _set_landscape_env(self, monkeypatch): monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + monkeypatch.setenv(ENV_UMS_URL, "https://ums.example.com") @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") def _make_transport(self, mock_dest_client, dest=None): @@ -151,7 +130,6 @@ def test_raises_transport_error_when_destination_name_is_none( self, mock_dest_client, monkeypatch ): monkeypatch.delenv(ENV_CONHOS_LANDSCAPE, raising=False) - monkeypatch.delenv(ENV_UMS_DESTINATION_NAME, raising=False) config = _make_config() transport = UmsTransport(AGENT_ORD_ID, config) assert transport._destination_name is None @@ -196,7 +174,7 @@ def test_uses_resolved_destination_name(self): transport.get_extension_capability_implementation() dest_client.get_destination.assert_called_once_with( - "sap-managed-runtime-ums-exttest-dev-eu12", level=ANY + "sap-managed-runtime-ias-exttest-dev-eu12", level=ANY, options=ANY ) def test_sends_correct_graphql_query(self): @@ -277,11 +255,11 @@ def test_destination_resolution_failure(self): with pytest.raises(TransportError, match="Failed to resolve destination"): transport.get_extension_capability_implementation() - def test_destination_no_url(self): - dest = _make_dest(url=None) - transport, dest_client = self._make_transport(dest=dest) + def test_raises_when_ums_url_not_set(self, monkeypatch): + transport, dest_client = self._make_transport() + monkeypatch.delenv(ENV_UMS_URL, raising=False) - with pytest.raises(TransportError, match="has no URL configured"): + with pytest.raises(TransportError, match="APPFND_CONHOS_UMS_URL is not set"): transport.get_extension_capability_implementation() def test_http_request_failure(self): @@ -452,6 +430,7 @@ class TestUmsTransportTenant: @pytest.fixture(autouse=True) def _set_landscape_env(self, monkeypatch): monkeypatch.setenv(ENV_CONHOS_LANDSCAPE, "exttest-dev-eu12") + monkeypatch.setenv(ENV_UMS_URL, "https://ums.example.com") @patch("sap_cloud_sdk.extensibility._ums_transport.create_destination_client") def _make_transport(self, mock_dest_client, dest=None): @@ -477,9 +456,9 @@ def test_tenant_does_not_affect_destination_call(self): transport.get_extension_capability_implementation(tenant="my-subscriber") - # get_destination is called without any ConsumptionOptions + # get_destination is called with skip_token_retrieval option dest_client.get_destination.assert_called_once_with( - "sap-managed-runtime-ums-exttest-dev-eu12", level=ANY + "sap-managed-runtime-ias-exttest-dev-eu12", level=ANY, options=ANY ) def test_tenant_included_in_agent_filter(self):