Skip to content

Commit 9fb1c8b

Browse files
fix(secret-resolver): update secret resolver logic when set SERVICE_BINDING_ROOT (#121)
1 parent 63d7ccc commit 9fb1c8b

4 files changed

Lines changed: 78 additions & 16 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.21.0"
3+
version = "0.21.1"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"

src/sap_cloud_sdk/core/secret_resolver/resolver.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,14 @@ def _get_field_map(target: Any) -> Dict[str, Tuple[str, type]]:
7575
return mapping
7676

7777

78-
def _load_from_mount(
79-
base_volume_mount: str, module: str, instance: str, target: Any
80-
) -> None:
78+
def _load_from_path(secret_dir: str, target: Any) -> None:
8179
"""
82-
Load secrets from files at:
83-
{base_volume_mount}/{module}/{instance}/{field_key}
80+
Load secrets from files directly in secret_dir into target dataclass.
8481
85-
Sets string attributes directly on the dataclass instance.
82+
Reads each field key as a file name under secret_dir. Used for both the
83+
servicebinding.io flat layout ($ROOT/<module>/<field>) and the legacy
84+
three-level layout via :func:`_load_from_mount`.
8685
"""
87-
secret_dir = os.path.join(base_volume_mount, module, instance)
8886
_validate_path(secret_dir)
8987

9088
field_map = _get_field_map(target)
@@ -106,6 +104,17 @@ def _load_from_mount(
106104
setattr(target, attr_name, content)
107105

108106

107+
def _load_from_mount(
108+
base_volume_mount: str, module: str, instance: str, target: Any
109+
) -> None:
110+
"""
111+
Load secrets from files at:
112+
{base_volume_mount}/{module}/{instance}/{field_key}
113+
"""
114+
secret_dir = os.path.join(base_volume_mount, module, instance)
115+
_load_from_path(secret_dir, target)
116+
117+
109118
def _load_from_env(base_var_name: str, module: str, instance: str, target: Any) -> None:
110119
"""
111120
Load secrets from environment variables with names:
@@ -133,17 +142,21 @@ def read_from_mount_and_fallback_to_env_var(
133142
) -> None:
134143
"""
135144
Load secrets for a given module and instance into the provided dataclass instance `target`.
136-
Fallback order:
137-
1. Mounted volume path: {base_volume_mount}/{module}/{instance}/{field_key}
138-
(``SERVICE_BINDING_ROOT`` env var overrides ``base_volume_mount`` — see
139-
:func:`resolve_base_mount`)
145+
146+
Fallback order when ``SERVICE_BINDING_ROOT`` is set:
147+
1. Flat path: {SERVICE_BINDING_ROOT}/{module}/{field_key} (servicebinding.io spec)
148+
2. Full path: {SERVICE_BINDING_ROOT}/{module}/{instance}/{field_key} (legacy convention)
149+
3. Environment variables: {base_var_name}_{module}_{instance}_{field_key} (uppercased)
150+
151+
Fallback order when ``SERVICE_BINDING_ROOT`` is **not** set:
152+
1. Full path: {base_volume_mount}/{module}/{instance}/{field_key}
140153
2. Environment variables: {base_var_name}_{module}_{instance}_{field_key} (uppercased)
141154
142155
Raises:
143156
ValueError: If inputs are invalid or target is not a dataclass instance
144157
FileNotFoundError / NotADirectoryError / OSError: If mount path issues occur
145158
KeyError: If environment variables are missing on fallback
146-
RuntimeError: If both mount and env var loading fail (aggregated error)
159+
RuntimeError: If all strategies fail (aggregated error)
147160
"""
148161
_validate_inputs(module, instance)
149162

@@ -152,6 +165,15 @@ def read_from_mount_and_fallback_to_env_var(
152165
normalized_module = module.replace("-", "_")
153166
normalized_instance = instance.replace("-", "_")
154167

168+
# servicebinding.io: when SERVICE_BINDING_ROOT is explicitly set, try the flat path
169+
# $ROOT/<module>/<field> before the legacy $ROOT/<module>/<instance>/<field> path.
170+
if os.environ.get("SERVICE_BINDING_ROOT") is not None:
171+
try:
172+
_load_from_path(os.path.join(resolved_base_path, module), target)
173+
return
174+
except Exception as e:
175+
errors.append(f"mount failed: {e};")
176+
155177
try:
156178
_load_from_mount(resolved_base_path, module, instance, target)
157179
return

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ def test_service_binding_root_overrides_base_mount(self, mock_file, mock_stat, m
199199
config = SampleConfig()
200200
read_from_mount_and_fallback_to_env_var("/etc/secrets/appfnd", "VAR", "module", "instance", config)
201201
first_call_path = mock_file.call_args_list[0][0][0]
202-
assert first_call_path.startswith("/custom/root")
202+
# With SERVICE_BINDING_ROOT set, flat path is tried first: $ROOT/<module>/<field>
203+
assert first_call_path == "/custom/root/module/user"
203204

204205
@patch.dict(os.environ, {}, clear=True)
205206
@patch('os.path.isdir', return_value=True)
@@ -214,4 +215,43 @@ def test_default_base_mount_used_when_no_service_binding_root(self, mock_file, m
214215
config = SampleConfig()
215216
read_from_mount_and_fallback_to_env_var("/etc/secrets/appfnd", "VAR", "module", "instance", config)
216217
first_call_path = mock_file.call_args_list[0][0][0]
217-
assert first_call_path.startswith("/etc/secrets/appfnd")
218+
# Without SERVICE_BINDING_ROOT, only the legacy $ROOT/<module>/<instance>/<field> path is tried
219+
assert first_call_path == "/etc/secrets/appfnd/module/instance/user"
220+
221+
@patch.dict(os.environ, {"SERVICE_BINDING_ROOT": "/bindings"})
222+
@patch('os.path.isdir', return_value=True)
223+
@patch('os.stat')
224+
@patch('builtins.open', new_callable=mock_open)
225+
def test_service_binding_root_flat_path_success(self, mock_file, mock_stat, mock_isdir):
226+
mock_file.side_effect = [
227+
mock_open(read_data="flat_user").return_value,
228+
mock_open(read_data="flat_pass").return_value,
229+
mock_open(read_data="flat_endpoint").return_value,
230+
]
231+
config = SampleConfig()
232+
read_from_mount_and_fallback_to_env_var("/etc/secrets/appfnd", "VAR", "module", "instance", config)
233+
first_call_path = mock_file.call_args_list[0][0][0]
234+
# Flat path $ROOT/<module>/<field> is tried first
235+
assert first_call_path == "/bindings/module/user"
236+
assert config.username == "flat_user"
237+
238+
@patch.dict(os.environ, {"SERVICE_BINDING_ROOT": "/bindings"})
239+
@patch('os.path.isdir', return_value=True)
240+
@patch('os.stat')
241+
def test_service_binding_root_flat_fails_falls_back_to_module_instance(self, mock_stat, mock_isdir):
242+
# Flat path: directory exists but field files are not there (old AppFND structure)
243+
# Full path: files are present under <module>/<instance>/
244+
flat_not_found = FileNotFoundError("flat file missing")
245+
mock_file_calls = [
246+
flat_not_found, # flat: <module>/user → not found
247+
mock_open(read_data="legacy_user").return_value, # full: <module>/<instance>/user
248+
mock_open(read_data="legacy_pass").return_value, # full: <module>/<instance>/password
249+
mock_open(read_data="legacy_ep").return_value, # full: <module>/<instance>/endpoint
250+
]
251+
with patch('builtins.open', side_effect=mock_file_calls):
252+
config = SampleConfig()
253+
read_from_mount_and_fallback_to_env_var("/bindings", "VAR", "module", "instance", config)
254+
255+
assert config.username == "legacy_user"
256+
assert config.password == "legacy_pass"
257+
assert config.endpoint == "legacy_ep"

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)