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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ pip install sap-cloud-sdk

The SDK automatically resolves configuration from multiple sources with the following priority:

1. **Kubernetes-mounted secrets**: `/etc/secrets/appfnd/<module>/<instance>/<field>`
1. **Kubernetes-mounted secrets**: `$SERVICE_BINDING_ROOT/<module>/<instance>/<field>`
- `SERVICE_BINDING_ROOT` defaults to `/etc/secrets/appfnd` when not set (follows the [servicebinding.io](https://servicebinding.io/spec/core/1.1.0/) spec). See the [Secret Resolver guide](../core/secret_resolver/user-guide.md) for details.

2. **Environment variables**: `CLOUD_SDK_CFG_<MODULE>_<INSTANCE>_<FIELD>`
- For instance names, hyphens (`"-"`) are replaced with underscores (`"_"`) for compatibility with system environment variables.
- You can see examples in our [env_integration_tests.example](.env_integration_tests.example)
Expand Down
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.14.2"
version = "0.14.3"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
7 changes: 5 additions & 2 deletions src/sap_cloud_sdk/aicore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
from typing import Optional

from sap_cloud_sdk.core.secret_resolver import resolve_base_mount
from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics
from sap_cloud_sdk.core.telemetry.module import Module
from sap_cloud_sdk.core.telemetry.operation import Operation
Expand All @@ -35,7 +36,8 @@ def _get_secret(
instance_name: Name of the aicore instance defined in app.yaml. Defaults to aicore-instance

"""
secrets_base_path = f"/etc/secrets/appfnd/aicore/{instance_name}"
resolved_base_path = resolve_base_mount()
secrets_base_path = f"{resolved_base_path}/aicore/{instance_name}"
secret_file_name = file_name if file_name else env_var_name
secret_file_path = os.path.join(secrets_base_path, secret_file_name)

Expand Down Expand Up @@ -70,7 +72,8 @@ def _get_aicore_base_url(instance_name: str = "aicore-instance") -> str:
Returns:
Base URL for AI Core service
"""
secrets_base_path = f"/etc/secrets/appfnd/aicore/{instance_name}"
resolved_base_path = resolve_base_mount()
secrets_base_path = f"{resolved_base_path}/aicore/{instance_name}"
serviceurls_file = os.path.join(secrets_base_path, "serviceurls")

# Try reading from serviceurls file
Expand Down
131 changes: 59 additions & 72 deletions src/sap_cloud_sdk/aicore/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,46 +69,6 @@ The function loads and configures these credentials:

---

## Configuration

### Cloud Mode (Mounted Secrets)

In Kubernetes environments, secrets are automatically loaded from:

```
/etc/secrets/appfnd/aicore/{instance_name}/
├── clientid # OAuth2 client ID
├── clientsecret # OAuth2 client secret
├── url # Authentication server URL
└── serviceurls # JSON file with AI_API_URL field
```

**serviceurls file format:**
```json
{
"AI_API_URL": "https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com"
}
```

### Environment Variable Fallback

If mounted secrets are not available, the function falls back to environment variables:

```bash
# Authentication credentials
export AICORE_CLIENT_ID="your-client-id"
export AICORE_CLIENT_SECRET="your-client-secret"

# Service endpoints
export AICORE_AUTH_URL="https://your-subdomain.authentication.eu10.hana.ondemand.com/oauth/token"
export AICORE_BASE_URL="https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com/v2"

# Optional: Resource group (defaults to "default")
export AICORE_RESOURCE_GROUP="my-resource-group"
```

---

## Usage with LiteLLM

After calling `set_aicore_config()`, LiteLLM automatically uses the configured AI Core credentials:
Expand Down Expand Up @@ -174,38 +134,6 @@ embedding_response = embedding(

---

## Multiple AI Core Instances

If you have multiple AI Core instances (e.g., development, staging, production), specify the instance name:

```python
from sap_cloud_sdk.aicore import set_aicore_config

# Development environment
set_aicore_config(instance_name="aicore-dev")

# Production environment
set_aicore_config(instance_name="aicore-prod")
```

Each instance should have its own mounted secrets or environment variables.

---

## URL Normalization

The function automatically normalizes URLs to ensure compatibility:

### Authentication URL
- **Input**: `https://subdomain.authentication.region.hana.ondemand.com`
- **Output**: `https://subdomain.authentication.region.hana.ondemand.com/oauth/token`

### Base URL
- **Input**: `https://api.ai.prod.region.aws.ml.hana.ondemand.com`
- **Output**: `https://api.ai.prod.region.aws.ml.hana.ondemand.com/v2`

---

## Complete Example

```python
Expand Down Expand Up @@ -365,6 +293,65 @@ set_aicore_config()

---

## Configuration

### Service Binding

- **Mount path**: `$SERVICE_BINDING_ROOT/aicore/{instance}/` (defaults to `/etc/secrets/appfnd/aicore/{instance}/`)
- **Required Keys**: `clientid`, `clientsecret`, `url` (auth server), `serviceurls` (JSON with `AI_API_URL`)
- **Env var fallback**: `CLOUD_SDK_CFG_AICORE_{INSTANCE}_{FIELD}` (uppercased, hyphens in instance replaced with `_`)

> **Note:** `SERVICE_BINDING_ROOT` defaults to `/etc/secrets/appfnd` when not set. See the [Secret Resolver guide](../core/secret_resolver/user-guide.md) for details.

#### Mounted Secrets (Kubernetes)

```
$SERVICE_BINDING_ROOT/aicore/{instance}/
├── clientid # OAuth2 client ID
├── clientsecret # OAuth2 client secret
├── url # Authentication server URL
└── serviceurls # JSON file with AI_API_URL field
```

#### Environment Variables

```bash
# Authentication credentials
export AICORE_CLIENT_ID="your-client-id"
export AICORE_CLIENT_SECRET="your-client-secret"

# Service endpoints
export AICORE_AUTH_URL="https://your-subdomain.authentication.eu10.hana.ondemand.com/oauth/token"
export AICORE_BASE_URL="https://aicore.example.com"

# Optional: Resource group (defaults to "default")
export AICORE_RESOURCE_GROUP="my-resource-group"
```

#### ServiceURLs JSON Schema

The `serviceurls` file must contain:

```json
{
"AI_API_URL": "https://aicore.example.com"
}
```

#### URL Normalization

This module automatically normalizes URLs to ensure compatibility:

##### Authentication URL
- **Input**: `https://subdomain.authentication.region.hana.ondemand.com`
- **Output**: `https://subdomain.authentication.region.hana.ondemand.com/oauth/token`

##### Base URL
- **Input**: `https://api.ai.prod.region.aws.ml.hana.ondemand.com`
- **Output**: `https://api.ai.prod.region.aws.ml.hana.ondemand.com/v2`

---

## Notes

- The `set_aicore_config()` function sets environment variables that persist for the lifetime of the Python process
Expand Down
6 changes: 5 additions & 1 deletion src/sap_cloud_sdk/core/auditlog/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ def _load_config_from_env() -> AuditLogConfig:
binding_data: BindingData = BindingData("", "")

read_from_mount_and_fallback_to_env_var(
"/etc/secrets/appfnd", "CLOUD_SDK_CFG", "auditlog", "default", binding_data
base_volume_mount="/etc/secrets/appfnd",
base_var_name="CLOUD_SDK_CFG",
module="auditlog",
instance="default",
target=binding_data,
)

binding_data.validate()
Expand Down
37 changes: 35 additions & 2 deletions src/sap_cloud_sdk/core/auditlog/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,39 @@ for failed in failed_messages:
print(f"Failed to log event: {failed.error}")
```

## Environment Configuration
## Configuration

The audit log module automatically detects the environment and configures itself accordingly, events are sent to the SAP Audit Log Service using OAuth2 authentication with automatic credential resolution from service bindings
### Service Binding

- **Mount path**: `$SERVICE_BINDING_ROOT/auditlog/default/` (defaults to `/etc/secrets/appfnd/auditlog/default/`)
- **Required Keys**: `url` (Audit Log service URL), `uaa` (JSON string with XSUAA credentials)
- **Env var fallback**: `CLOUD_SDK_CFG_AUDITLOG_DEFAULT_{FIELD}` (uppercased)

> **Note:** `SERVICE_BINDING_ROOT` defaults to `/etc/secrets/appfnd` when not set. See the [Secret Resolver guide](../secret_resolver/user-guide.md) for details.

#### Mounted Secrets (Kubernetes)

```
$SERVICE_BINDING_ROOT/auditlog/default/
├── url
└── uaa
```

#### Environment Variables

```bash
export CLOUD_SDK_CFG_AUDITLOG_DEFAULT_URL="https://auditlog.example.com"
export CLOUD_SDK_CFG_AUDITLOG_DEFAULT_UAA='{"clientid":"...","clientsecret":"...","url":"https://..."}'
```

#### UAA JSON Schema

The `uaa` key must contain a JSON string with the XSUAA credentials:

```json
{
"clientid": "sb-xxx",
"clientsecret": "xxx",
"url": "https://subdomain.authentication.region.hana.ondemand.com"
}
```
4 changes: 2 additions & 2 deletions src/sap_cloud_sdk/core/secret_resolver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ class MyConfig:
)
"""

from .resolver import read_from_mount_and_fallback_to_env_var
from .resolver import read_from_mount_and_fallback_to_env_var, resolve_base_mount

__all__ = ["read_from_mount_and_fallback_to_env_var"]
__all__ = ["read_from_mount_and_fallback_to_env_var", "resolve_base_mount"]
5 changes: 5 additions & 0 deletions src/sap_cloud_sdk/core/secret_resolver/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Constants for secret resolver module.
"""

BASE_MOUNT_PATH = "/etc/secrets/appfnd"
26 changes: 24 additions & 2 deletions src/sap_cloud_sdk/core/secret_resolver/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@
import os
from dataclasses import fields, is_dataclass
from typing import Any, Dict, Tuple
from .constants import BASE_MOUNT_PATH


def resolve_base_mount(base_volume_mount: str = BASE_MOUNT_PATH) -> str:
"""Resolve the base mount path for service binding discovery.

Checks the ``SERVICE_BINDING_ROOT`` environment variable first (as defined
by the `servicebinding.io <https://servicebinding.io/spec/core/1.1.0/>`_
specification). Falls back to ``base_volume_mount`` when the env var is
absent.

Args:
base_volume_mount: Default base path used when ``SERVICE_BINDING_ROOT``
is not set. Defaults to ``/etc/secrets/appfnd``.

Returns:
The effective base path for secret mount resolution.
"""
return os.environ.get("SERVICE_BINDING_ROOT", base_volume_mount)


def _validate_inputs(module: str, instance: str) -> None:
Expand Down Expand Up @@ -116,6 +135,8 @@ def read_from_mount_and_fallback_to_env_var(
Load secrets for a given module and instance into the provided dataclass instance `target`.
Fallback order:
1. Mounted volume path: {base_volume_mount}/{module}/{instance}/{field_key}
(``SERVICE_BINDING_ROOT`` env var overrides ``base_volume_mount`` — see
:func:`resolve_base_mount`)
2. Environment variables: {base_var_name}_{module}_{instance}_{field_key} (uppercased)

Raises:
Expand All @@ -126,12 +147,13 @@ def read_from_mount_and_fallback_to_env_var(
"""
_validate_inputs(module, instance)

resolved_base_path = resolve_base_mount(base_volume_mount)
errors: list[str] = []
normalized_module = module.replace("-", "_")
normalized_instance = instance.replace("-", "_")

try:
_load_from_mount(base_volume_mount, module, instance, target)
_load_from_mount(resolved_base_path, module, instance, target)
return
except Exception as e:
errors.append(f"mount failed: {e};")
Expand All @@ -144,7 +166,7 @@ def read_from_mount_and_fallback_to_env_var(

# Aggregate errors with actionable guidance for local dev and env fallback
prefix_upper = f"{base_var_name}_{normalized_module}_{normalized_instance}".upper()
mount_dir = os.path.join(base_volume_mount, module, instance) + "/"
mount_dir = os.path.join(resolved_base_path, module, instance) + "/"
guidance_parts: list[str] = []
guidance_parts.append("Secrets could not be loaded from mount or environment.")
guidance_parts.append("Options:")
Expand Down
20 changes: 16 additions & 4 deletions src/sap_cloud_sdk/core/secret_resolver/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ class DatabaseConfig:
config = DatabaseConfig()
read_from_mount_and_fallback_to_env_var(
base_volume_mount="/etc/secrets", # Base mount path
base_var_name="DB", # Environment variable prefix
module="database", # Module/service name
instance="primary", # Instance name
target=config # Target dataclass instance
base_var_name="DB", # Environment variable prefix
module="database", # Module/service name
instance="primary", # Instance name
target=config # Target dataclass instance
)

print(f"Database: {config.username}@{config.host}:{config.port}")
Expand All @@ -61,6 +61,18 @@ The Secret Resolver expects mounted secrets to follow this hierarchy:
└── password
```

### Base Path Resolution

By default, the resolver looks for secrets under `/etc/secrets/appfnd`. You can override this by setting the `SERVICE_BINDING_ROOT` environment variable, which follows the [servicebinding.io](https://servicebinding.io) specification used across SAP SDKs and Kubernetes-native tooling.

When `SERVICE_BINDING_ROOT` is set, it takes precedence over the default `/etc/secrets/appfnd` path:

```bash
export SERVICE_BINDING_ROOT=/bindings
```

With this set, the resolver looks for secrets at `$SERVICE_BINDING_ROOT/<module>/<instance>/<field>` instead of `/etc/secrets/appfnd/<module>/<instance>/<field>`.

Example for the above configuration:
```
/etc/secrets/appfnd
Expand Down
Loading
Loading