Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
108 changes: 57 additions & 51 deletions src/sap_cloud_sdk/extensibility/_ums_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]] = []
Expand Down Expand Up @@ -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(
Expand All @@ -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 "
Expand All @@ -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] = {
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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},
}
Expand Down
12 changes: 4 additions & 8 deletions src/sap_cloud_sdk/extensibility/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/extensibility/unit/test_ums_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
_CACHE_TTL_SECONDS,
_CACHE_MAX_SIZE,
ENV_CONHOS_LANDSCAPE,
ENV_UMS_URL,
)
from sap_cloud_sdk.extensibility.exceptions import TransportError

Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions tests/extensibility/unit/test_ums_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
UmsTransport,
_MAX_PAGES,
ENV_CONHOS_LANDSCAPE,
ENV_UMS_URL,
)
from sap_cloud_sdk.extensibility.exceptions import TransportError

Expand All @@ -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):
Expand Down
Loading
Loading