Skip to content

Commit f33607a

Browse files
feat(secret-resolver): add base path resolution with SERVICE_BINDING_ROOT support
1 parent 2376dc9 commit f33607a

6 files changed

Lines changed: 56 additions & 7 deletions

File tree

src/sap_cloud_sdk/aicore/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import os
1010
from typing import Optional
1111

12+
from sap_cloud_sdk.core.secret_resolver import resolve_base_mount
1213
from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics
1314
from sap_cloud_sdk.core.telemetry.module import Module
1415
from sap_cloud_sdk.core.telemetry.operation import Operation
@@ -35,7 +36,7 @@ def _get_secret(
3536
instance_name: Name of the aicore instance defined in app.yaml. Defaults to aicore-instance
3637
3738
"""
38-
secrets_base_path = f"/etc/secrets/appfnd/aicore/{instance_name}"
39+
secrets_base_path = f"{resolve_base_mount('/etc/secrets/appfnd')}/aicore/{instance_name}"
3940
secret_file_name = file_name if file_name else env_var_name
4041
secret_file_path = os.path.join(secrets_base_path, secret_file_name)
4142

@@ -70,7 +71,7 @@ def _get_aicore_base_url(instance_name: str = "aicore-instance") -> str:
7071
Returns:
7172
Base URL for AI Core service
7273
"""
73-
secrets_base_path = f"/etc/secrets/appfnd/aicore/{instance_name}"
74+
secrets_base_path = f"{resolve_base_mount('/etc/secrets/appfnd')}/aicore/{instance_name}"
7475
serviceurls_file = os.path.join(secrets_base_path, "serviceurls")
7576

7677
# Try reading from serviceurls file

src/sap_cloud_sdk/core/secret_resolver/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ class MyConfig:
2121
)
2222
"""
2323

24-
from .resolver import read_from_mount_and_fallback_to_env_var
24+
from .resolver import read_from_mount_and_fallback_to_env_var, resolve_base_mount
2525

26-
__all__ = ["read_from_mount_and_fallback_to_env_var"]
26+
__all__ = ["read_from_mount_and_fallback_to_env_var", "resolve_base_mount"]

src/sap_cloud_sdk/core/secret_resolver/resolver.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
from typing import Any, Dict, Tuple
88

99

10+
def resolve_base_mount(default: str) -> str:
11+
"""Return SERVICE_BINDING_ROOT if set, otherwise the provided default."""
12+
return os.environ.get("SERVICE_BINDING_ROOT", default)
13+
14+
1015
def _validate_inputs(module: str, instance: str) -> None:
1116
"""Validate module and instance inputs."""
1217
if not isinstance(module, str) or not module.strip():
@@ -126,12 +131,13 @@ def read_from_mount_and_fallback_to_env_var(
126131
"""
127132
_validate_inputs(module, instance)
128133

134+
resolved_base_path = resolve_base_mount(base_volume_mount)
129135
errors: list[str] = []
130136
normalized_module = module.replace("-", "_")
131137
normalized_instance = instance.replace("-", "_")
132138

133139
try:
134-
_load_from_mount(base_volume_mount, module, instance, target)
140+
_load_from_mount(resolved_base_path, module, instance, target)
135141
return
136142
except Exception as e:
137143
errors.append(f"mount failed: {e};")
@@ -144,7 +150,7 @@ def read_from_mount_and_fallback_to_env_var(
144150

145151
# Aggregate errors with actionable guidance for local dev and env fallback
146152
prefix_upper = f"{base_var_name}_{normalized_module}_{normalized_instance}".upper()
147-
mount_dir = os.path.join(base_volume_mount, module, instance) + "/"
153+
mount_dir = os.path.join(resolved_base_path, module, instance) + "/"
148154
guidance_parts: list[str] = []
149155
guidance_parts.append("Secrets could not be loaded from mount or environment.")
150156
guidance_parts.append("Options:")

src/sap_cloud_sdk/core/secret_resolver/user-guide.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ The Secret Resolver expects mounted secrets to follow this hierarchy:
6161
└── password
6262
```
6363

64+
### Base Path Resolution
65+
66+
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.
67+
68+
When `SERVICE_BINDING_ROOT` is set, it takes precedence over the default `/etc/secrets/appfnd` path:
69+
70+
```bash
71+
export SERVICE_BINDING_ROOT=/bindings
72+
```
73+
74+
With this set, the resolver looks for secrets at `$SERVICE_BINDING_ROOT/<module>/<instance>/<field>` instead of `/etc/secrets/appfnd/<module>/<instance>/<field>`.
75+
6476
Example for the above configuration:
6577
```
6678
/etc/secrets/appfnd

tests/core/unit/secret_resolver/unit/test_secret_resolver.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,33 @@ def test_env_instance_name_hyphen_normalization(self):
185185
assert config.username == "env_user_hyphen"
186186
assert config.password == "env_pass_hyphen"
187187
assert config.endpoint == "env_endpoint_hyphen"
188+
189+
@patch.dict(os.environ, {"SERVICE_BINDING_ROOT": "/custom/root"})
190+
@patch('os.path.isdir', return_value=True)
191+
@patch('os.stat')
192+
@patch('builtins.open', new_callable=mock_open)
193+
def test_service_binding_root_overrides_base_mount(self, mock_file, mock_stat, mock_isdir):
194+
mock_file.side_effect = [
195+
mock_open(read_data="u").return_value,
196+
mock_open(read_data="p").return_value,
197+
mock_open(read_data="e").return_value,
198+
]
199+
config = SampleConfig()
200+
read_from_mount_and_fallback_to_env_var("/etc/secrets/appfnd", "VAR", "module", "instance", config)
201+
first_call_path = mock_file.call_args_list[0][0][0]
202+
assert first_call_path.startswith("/custom/root")
203+
204+
@patch.dict(os.environ, {}, clear=True)
205+
@patch('os.path.isdir', return_value=True)
206+
@patch('os.stat')
207+
@patch('builtins.open', new_callable=mock_open)
208+
def test_default_base_mount_used_when_no_service_binding_root(self, mock_file, mock_stat, mock_isdir):
209+
mock_file.side_effect = [
210+
mock_open(read_data="u").return_value,
211+
mock_open(read_data="p").return_value,
212+
mock_open(read_data="e").return_value,
213+
]
214+
config = SampleConfig()
215+
read_from_mount_and_fallback_to_env_var("/etc/secrets/appfnd", "VAR", "module", "instance", config)
216+
first_call_path = mock_file.call_args_list[0][0][0]
217+
assert first_call_path.startswith("/etc/secrets/appfnd")

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)