Skip to content
Closed
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ mocks/

# Generated files
PULL_REQUEST.md
RELEASE.md
RELEASE.md
2 changes: 1 addition & 1 deletion src/sap_cloud_sdk/agent_memory/py.typed
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# Marker file for PEP 561 to indicate the 'agent_memory' package is typed.
# Marker file for PEP 561 to indicate the 'agent_memory' package is typed.
2 changes: 0 additions & 2 deletions src/sap_cloud_sdk/agentgateway/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,5 +121,3 @@ class AgentGatewayClient:
**kwargs,
) -> str
```


3 changes: 3 additions & 0 deletions src/sap_cloud_sdk/core/auditlog_ng/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from sap_cloud_sdk.core.auditlog_ng.config import (
AuditLogNGConfig,
SCHEMA_URL,
endpoint_from_region,
)
from sap_cloud_sdk.core.auditlog_ng.exceptions import (
AuditLogNGError,
Expand Down Expand Up @@ -116,6 +117,8 @@ def create_client(
__all__ = [
# Factory function
"create_client",
# Helpers
"endpoint_from_region",
# Client
"AuditClient",
# Configuration
Expand Down
37 changes: 37 additions & 0 deletions src/sap_cloud_sdk/core/auditlog_ng/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,40 @@ def __post_init__(self) -> None:
raise ValueError("endpoint is required")
_validate_source_arg(self.deployment_id, "deployment_id")
_validate_source_arg(self.namespace, "namespace")


_ALS_NG_ENDPOINT_SUFFIX = ".als.services.cloud.sap:443"


def endpoint_from_region(region: str) -> str:
"""Derive the ALS NG gRPC endpoint from a SPII ``deploymentRegion`` value.

Maps ``assignedTenant.deploymentRegion`` (e.g. ``"us30"``) to the
corresponding ALS NG host:port (e.g. ``"us30.als.services.cloud.sap:443"``).

Args:
region: ``assignedTenant.deploymentRegion`` from the SPII payload.

Returns:
Endpoint string suitable for :class:`AuditLogNGConfig`.

Raises:
ValueError: If *region* is empty or contains invalid characters.

Example::

from sap_cloud_sdk.core.auditlog_ng import endpoint_from_region, create_client

region = spii_payload["assignedTenant"]["deploymentRegion"] # e.g. "us30"
client = create_client(
endpoint=endpoint_from_region(region),
deployment_id=spii_payload["assignedTenant"]["deploymentId"],
namespace=spii_payload["assignedTenant"]["applicationNamespace"],
cert_file="...",
key_file="...",
)
"""
if not region:
raise ValueError("region must not be empty")
_validate_source_arg(region, "region")
return f"{region}{_ALS_NG_ENDPOINT_SUFFIX}"
71 changes: 67 additions & 4 deletions src/sap_cloud_sdk/core/auditlog_ng/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
This module provides an OTLP/gRPC client for sending structured audit log events
to the SAP Audit Log Service (v3/NG). It supports mTLS, insecure mode for local
testing, and both binary protobuf and JSON serialization formats.

---

## Overview
Expand All @@ -19,7 +20,7 @@ The Auditlog NG client sends audit log events as OpenTelemetry (OTLP) LogRecords

### 1. Required Dependencies

```
```text
grpcio>=1.60.0
protobuf>=4.25.0
protovalidate>=0.13.0
Expand All @@ -40,7 +41,7 @@ All constructor parameters for `AuditClient`:

| Parameter | Type | Required | Default | Description |
|-----------------|---------|----------|----------------|-------------------------------------------------------------------------------------------------------|
| `endpoint` | `str` | ✅ Yes | — | OTLP gRPC endpoint of the Audit Log Service (`host:port`) |
| `endpoint` | `str` | ✅ Yes | — | OTLP gRPC endpoint of the Audit Log Service (`host:port`). When deploying on BTP, derive from the SPII payload: `endpoint_from_region(assignedTenant.deploymentRegion)` → e.g. `us30.als.services.cloud.sap:443`. |
| `deployment_id` | `str` | ✅ Yes | — | Deployment/region identifier. Validated: only `[a-zA-Z0-9._-/~]` allowed. Raises `ValueError` if invalid. |
| `namespace` | `str` | ✅ Yes | — | Audit log namespace (e.g. `sap.als`). Same character-set validation as `deployment_id`. |
| `cert_file` | `str` | ❌ No | `None` | Path to the mTLS client certificate file (PEM). Required together with `key_file` for mTLS. |
Expand Down Expand Up @@ -74,13 +75,25 @@ All constructor parameters for `AuditClient`:
### Step 1: Import the Client and Generated Protobuf

```python
from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig
from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig, endpoint_from_region
from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import auditevent_pb2 as pb
```

### Step 2: Initialize the Client

**With mTLS (production):**
**With SPII payload (BTP production):**

```python
client = create_client(
endpoint=endpoint_from_region(spii_payload["assignedTenant"]["deploymentRegion"]),
deployment_id=spii_payload["assignedTenant"]["deploymentId"],
namespace=spii_payload["assignedTenant"]["applicationNamespace"],
cert_file="/path/to/client-certificate_chain.pem",
key_file="/path/to/private-key.pem",
)
```

**With explicit endpoint (production):**

```python
client = create_client(
Expand Down Expand Up @@ -237,6 +250,56 @@ Events are validated against protobuf constraints using `protovalidate` before s

---

## BTP / SPII Integration

When running on BTP, the agent receives a SPII payload at tenant-assign time. Use `endpoint_from_region` to derive the ALS NG endpoint and store all required values via the Destination Service so they are available at emit time.

### SPII field mapping

| SPII field | Parameter |
| --- | --- |
| `assignedTenant.deploymentRegion` | `endpoint_from_region(region)` |
| `assignedTenant.deploymentId` | `deployment_id` |
| `assignedTenant.applicationNamespace` | `namespace` |
| `assignedTenant.applicationTenantId` | `event.common.tenant_id` (per event) |

### At SPII assign time

```python
from sap_cloud_sdk.core.auditlog_ng import endpoint_from_region
from sap_cloud_sdk.destination._models import Destination

region = spii_payload["assignedTenant"]["deploymentRegion"]
destination = Destination(
name="AuditLogV3_Destination",
type="TCP",
url=endpoint_from_region(region),
properties={
"deployment_id": spii_payload["assignedTenant"]["deploymentId"],
"namespace": spii_payload["assignedTenant"]["applicationNamespace"],
}
)
# persist via Destination Service client
```

### At emit time

```python
from sap_cloud_sdk.core.auditlog_ng import create_client

dest = await destination_client.get_destination("AuditLogV3_Destination")
with create_client(
endpoint=dest.url,
deployment_id=dest.properties["deployment_id"],
namespace=dest.properties["namespace"],
cert_file="/path/to/client-certificate_chain.pem",
key_file="/path/to/private-key.pem",
) as client:
client.send(event, "DataAccess")
```

---

## Running the Unit Tests

```bash
Expand Down
2 changes: 1 addition & 1 deletion tests/agent_memory/integration/agentmemory.feature
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ Feature: Agent Memory Service Integration (v1 API)
Scenario: Filter messages by metadata substring
Given a message exists with agent "test-agent" invoker "test-user" group "conv-filter" role "USER" content "filter-test-message" and metadata "filter-marker"
When I list messages filtered by metadata containing "filter-marker"
Then the result should contain the message with content "filter-test-message"
Then the result should contain the message with content "filter-test-message"
11 changes: 6 additions & 5 deletions tests/agent_memory/integration/test_agentmemory_bdd.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import pytest
from pytest_bdd import given, scenario, then, when

from sap_cloud_sdk.agent_memory import MessageRole
from sap_cloud_sdk.agent_memory.client import AgentMemoryClient

# -- Scenarios -----------------------------------------------------------------
Expand Down Expand Up @@ -176,7 +177,7 @@ def message_exists_list(context, agent_memory_client):
"test-agent",
"test-user",
"conv-list",
"USER",
MessageRole.USER,
"Listed message",
)

Expand All @@ -190,7 +191,7 @@ def message_exists_delete(context, agent_memory_client):
"test-agent",
"test-user",
"conv-del",
"USER",
MessageRole.USER,
"To be deleted",
)

Expand Down Expand Up @@ -258,7 +259,7 @@ def add_message(context):
"test-agent",
"test-user",
"conv-1",
"USER",
MessageRole.USER,
"Hello!",
)

Expand Down Expand Up @@ -352,7 +353,7 @@ def check_message_id(context):

@then('the message should have role "USER"')
def check_message_role(context):
assert context["message"].role == "USER"
assert context["message"].role == MessageRole.USER


@then('the message should have content "Hello!"')
Expand Down Expand Up @@ -458,7 +459,7 @@ def message_exists_filter(context, agent_memory_client):
"test-agent",
"test-user",
"conv-filter",
"USER",
MessageRole.USER,
"filter-test-message",
metadata={"tag": "filter-marker"},
)
Expand Down
2 changes: 1 addition & 1 deletion tests/core/unit/auditlog_ng/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def _make_config(**overrides: Unpack[ConfigKwargs]) -> AuditLogNGConfig:
"namespace": "namespace-123",
"insecure": True,
}
defaults.update(overrides) # ty: ignore[invalid-argument-type]
defaults.update(overrides)
return AuditLogNGConfig(**defaults)


Expand Down
25 changes: 25 additions & 0 deletions tests/core/unit/auditlog_ng/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
AuditLogNGConfig,
SCHEMA_URL,
_validate_source_arg,
endpoint_from_region,
)


Expand Down Expand Up @@ -106,3 +107,27 @@ def test_defaults_for_optional_fields(self):
assert config.key_file is None
assert config.ca_file is None
assert config.insecure is False


class TestEndpointFromRegion:

def test_standard_region(self):
assert endpoint_from_region("us30") == "us30.als.services.cloud.sap:443"

def test_eu_region(self):
assert endpoint_from_region("eu10") == "eu10.als.services.cloud.sap:443"

def test_region_with_dashes(self):
assert endpoint_from_region("ap21") == "ap21.als.services.cloud.sap:443"

def test_empty_region_raises(self):
with pytest.raises(ValueError, match="must not be empty"):
endpoint_from_region("")

def test_region_with_spaces_raises(self):
with pytest.raises(ValueError, match="region"):
endpoint_from_region("us 30")

def test_region_with_special_chars_raises(self):
with pytest.raises(ValueError, match="region"):
endpoint_from_region("us30@cloud")
3 changes: 1 addition & 2 deletions tests/dms/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ def dms_client():
client = create_client(instance="default")
return client
except Exception as e:
pytest.skip(f"DMS integration tests require credentials: {e}") # ty: ignore[invalid-argument-type, too-many-positional-arguments]

pytest.skip(f"DMS integration tests require credentials: {e}")

def _setup_cloud_mode():
"""Common setup for cloud mode integration tests."""
Expand Down
2 changes: 1 addition & 1 deletion tests/dms/integration/test_dms_bdd.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def select_version_repo(context: DMSTestContext, dms_client: DMSClient):
version_repo = r
break
if version_repo is None:
pytest.skip("No version-enabled repository available") # ty: ignore[invalid-argument-type, too-many-positional-arguments]
pytest.skip("No version-enabled repository available")
context.repo = version_repo
context.repo_id = version_repo.id

Expand Down
1 change: 0 additions & 1 deletion tests/extensibility/unit/test_ums_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,3 @@ def test_cache_hit_refreshes_lru_position(self):
assert ("tenant-new", "default") in transport._cache
finally:
patcher.stop()

1 change: 0 additions & 1 deletion tests/extensibility/unit/test_ums_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,4 +340,3 @@ def test_missing_page_info_stops_pagination(self):
def test_max_pages_constant(self):
"""Safety limit constant is 100."""
assert _MAX_PAGES == 100

1 change: 0 additions & 1 deletion tests/extensibility/unit/test_ums_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,4 +459,3 @@ def test_tools_key_missing(self):
}
result = _transform_ums_response(data, "default")
assert result.mcp_servers == []

Loading