diff --git a/src/sap_cloud_sdk/adms/client.py b/src/sap_cloud_sdk/adms/client.py index 22f3f759..50c29fb2 100644 --- a/src/sap_cloud_sdk/adms/client.py +++ b/src/sap_cloud_sdk/adms/client.py @@ -57,7 +57,7 @@ _DocumentRelationApi, ) from sap_cloud_sdk.adms._token_cache import TokenCache -from sap_cloud_sdk.adms.config import AdmsConfig, load_from_env_or_mount +from sap_cloud_sdk.adms.config import AdmsConfig, load_secrets # --------------------------------------------------------------------------- @@ -171,7 +171,7 @@ def create_client( raise ValueError( "instance must not be an empty string; omit it to use 'default'" ) - binding = config or load_from_env_or_mount(instance) + binding = config or load_secrets(instance) token_fetcher = IasTokenFetcher(config=binding, cache=token_cache) http = AdmsHttp(config=binding, token_fetcher=token_fetcher, user_jwt=user_jwt) return AdmsClient(http) @@ -206,7 +206,7 @@ def create_async_client( raise ValueError( "instance must not be an empty string; omit it to use 'default'" ) - binding = config or load_from_env_or_mount(instance) + binding = config or load_secrets(instance) token_fetcher = IasTokenFetcher(config=binding, cache=token_cache) http = AsyncAdmsHttp( config=binding, diff --git a/src/sap_cloud_sdk/adms/config.py b/src/sap_cloud_sdk/adms/config.py index f0108bd9..273f0604 100644 --- a/src/sap_cloud_sdk/adms/config.py +++ b/src/sap_cloud_sdk/adms/config.py @@ -18,10 +18,8 @@ from dataclasses import dataclass -from sap_cloud_sdk.core.secret_resolver.resolver import ( - read_from_mount_and_fallback_to_env_var, -) from sap_cloud_sdk.adms.exceptions import ConfigError +from sap_cloud_sdk.core.secret_resolver import get_resolver _DEFAULT_INSTANCE = "default" _SECRET_MOUNT_BASE = "/etc/secrets/appfnd" @@ -96,7 +94,7 @@ def to_config(self) -> AdmsConfig: ) -def load_from_env_or_mount(instance: str | None = None) -> AdmsConfig: +def load_secrets(instance: str | None = None) -> AdmsConfig: """Load ADMS configuration from a mounted secret volume or environment variables. Args: @@ -111,13 +109,8 @@ def load_from_env_or_mount(instance: str | None = None) -> AdmsConfig: instance = instance or _DEFAULT_INSTANCE raw = _BindingData() try: - read_from_mount_and_fallback_to_env_var( - base_volume_mount=_SECRET_MOUNT_BASE, - base_var_name=_ENV_VAR_BASE, - module="adms", - instance=instance, - target=raw, - ) + get_resolver().resolve(module="adms", instance=instance, target=raw) + except Exception as exc: raise ConfigError( f"failed to load ADMS binding for instance '{instance}': {exc}" diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 98195c88..e87cce45 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -15,7 +15,7 @@ from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory.client import AgentMemoryClient -from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig, _load_config_from_env +from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig, _load_secrets from sap_cloud_sdk.agent_memory.exceptions import ( AgentMemoryConfigError, AgentMemoryError, @@ -49,7 +49,7 @@ def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryC AgentMemoryConfigError: If configuration is missing or invalid. """ try: - resolved_config = config if config is not None else _load_config_from_env() + resolved_config = config if config is not None else _load_secrets() transport = HttpTransport(resolved_config) return AgentMemoryClient(transport) except AgentMemoryConfigError: diff --git a/src/sap_cloud_sdk/agent_memory/config.py b/src/sap_cloud_sdk/agent_memory/config.py index e2cbdfa7..3a3e269d 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -23,6 +23,7 @@ from typing import Optional from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError +from sap_cloud_sdk.core.secret_resolver import get_resolver @dataclass @@ -113,7 +114,7 @@ def extract_config(self) -> AgentMemoryConfig: raise AgentMemoryConfigError(f"Missing required field in uaa JSON: {e}") -def _load_config_from_env() -> AgentMemoryConfig: +def _load_secrets() -> AgentMemoryConfig: """Load Agent Memory configuration from a mounted volume or environment variables. Uses the secret resolver with fallback order: @@ -126,19 +127,12 @@ def _load_config_from_env() -> AgentMemoryConfig: Raises: AgentMemoryConfigError: If configuration cannot be loaded or is incomplete. """ - from sap_cloud_sdk.core.secret_resolver import ( - read_from_mount_and_fallback_to_env_var, - ) - try: binding = BindingData() - read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="hana-agent-memory", - instance="default", - target=binding, + get_resolver().resolve( + module="agent_memory", instance="default", target=binding ) + binding.validate() return binding.extract_config() except AgentMemoryConfigError: diff --git a/src/sap_cloud_sdk/core/auditlog/__init__.py b/src/sap_cloud_sdk/core/auditlog/__init__.py index 34bd8b6e..c06f97a3 100644 --- a/src/sap_cloud_sdk/core/auditlog/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog/__init__.py @@ -33,7 +33,7 @@ ChangeAttribute, DeletedAttribute, ) -from sap_cloud_sdk.core.auditlog.config import AuditLogConfig, _load_config_from_env +from sap_cloud_sdk.core.auditlog.config import AuditLogConfig, _load_secrets from sap_cloud_sdk.core.auditlog.exceptions import ( AuditLogError, ClientCreationError, @@ -67,7 +67,7 @@ def create_client( transport = HttpTransport(config) return AuditLogClient(transport, _telemetry_source=_telemetry_source) - transport = HttpTransport(_load_config_from_env()) + transport = HttpTransport(_load_secrets()) return AuditLogClient(transport, _telemetry_source=_telemetry_source) except Exception as e: diff --git a/src/sap_cloud_sdk/core/auditlog/config.py b/src/sap_cloud_sdk/core/auditlog/config.py index 745a497b..d3653fc3 100644 --- a/src/sap_cloud_sdk/core/auditlog/config.py +++ b/src/sap_cloud_sdk/core/auditlog/config.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from sap_cloud_sdk.core.auditlog.exceptions import ClientCreationError +from sap_cloud_sdk.core.secret_resolver import get_resolver @dataclass @@ -85,7 +86,7 @@ def extract_config(self) -> AuditLogConfig: raise ClientCreationError(f"Missing required field in UAA JSON: {e}") -def _load_config_from_env() -> AuditLogConfig: +def _load_secrets() -> AuditLogConfig: """Load audit log configuration from environment/mounts. Uses the secret resolver to load configuration from: @@ -100,20 +101,13 @@ def _load_config_from_env() -> AuditLogConfig: Raises: ClientCreationError: If loading or parsing fails """ - from sap_cloud_sdk.core.secret_resolver import ( - read_from_mount_and_fallback_to_env_var, - ) try: # Load raw config data using secret resolver binding_data: BindingData = BindingData("", "") - read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="auditlog", - instance="default", - target=binding_data, + get_resolver().resolve( + module="auditlog", instance="default", target=binding_data ) binding_data.validate() diff --git a/src/sap_cloud_sdk/core/data_anonymization/__init__.py b/src/sap_cloud_sdk/core/data_anonymization/__init__.py index 4f82731e..acf97e60 100644 --- a/src/sap_cloud_sdk/core/data_anonymization/__init__.py +++ b/src/sap_cloud_sdk/core/data_anonymization/__init__.py @@ -45,7 +45,7 @@ from sap_cloud_sdk.core.data_anonymization.client import DataAnonymizationClient from sap_cloud_sdk.core.data_anonymization.config import ( DataAnonymizationConfig, - _load_config_from_env, + _load_secrets, ) from sap_cloud_sdk.core.data_anonymization._http_transport import HttpTransport from sap_cloud_sdk.core.data_anonymization.models import ( @@ -101,7 +101,7 @@ def create_client( never includes configuration values or processed user content. """ try: - resolved = config if config is not None else _load_config_from_env(instance) + resolved = config if config is not None else _load_secrets(instance) transport = HttpTransport(resolved) return DataAnonymizationClient(transport, _telemetry_source=_telemetry_source) except Exception as e: diff --git a/src/sap_cloud_sdk/core/data_anonymization/config.py b/src/sap_cloud_sdk/core/data_anonymization/config.py index 3ecbb05f..bf1ea70f 100644 --- a/src/sap_cloud_sdk/core/data_anonymization/config.py +++ b/src/sap_cloud_sdk/core/data_anonymization/config.py @@ -22,6 +22,7 @@ from typing import Optional from sap_cloud_sdk.core.data_anonymization.exceptions import ClientCreationError +from sap_cloud_sdk.core.secret_resolver import get_resolver @dataclass @@ -111,7 +112,7 @@ def extract_config(self) -> DataAnonymizationConfig: ) -def _load_config_from_env(instance: str = "default") -> DataAnonymizationConfig: +def _load_secrets(instance: str = "default") -> DataAnonymizationConfig: """Load anonymization config from environment / mount path. Uses the secret resolver to read from: @@ -126,18 +127,11 @@ def _load_config_from_env(instance: str = "default") -> DataAnonymizationConfig: Raises: ClientCreationError: If loading or parsing fails. """ - from sap_cloud_sdk.core.secret_resolver import ( - read_from_mount_and_fallback_to_env_var, - ) try: binding = _BindingData("") - read_from_mount_and_fallback_to_env_var( - "/etc/secrets/appfnd", - "CLOUD_SDK_CFG", - "data-anonymization", - instance, - binding, + get_resolver().resolve( + module="data-anonymization", instance=instance, target=binding ) binding.validate() return binding.extract_config() diff --git a/src/sap_cloud_sdk/core/secret_resolver/__init__.py b/src/sap_cloud_sdk/core/secret_resolver/__init__.py index 79cf79ff..c151654f 100644 --- a/src/sap_cloud_sdk/core/secret_resolver/__init__.py +++ b/src/sap_cloud_sdk/core/secret_resolver/__init__.py @@ -1,26 +1,64 @@ """ -Secret resolver: load configuration/secrets from mounted files or environment variables +Secret resolver: load configuration/secrets from mounted files or environment variables. -Usage: - from dataclasses import dataclass, field - from sap_cloud_sdk.secret_resolver import read_from_mount_and_fallback_to_env_var +Built-in resolvers and chain builder:: - @dataclass - class MyConfig: - username: str = field(metadata={"secret": "username"}) - password: str = field(metadata={"secret": "password"}) - endpoint: str = "http://localhost" + from sap_cloud_sdk.core.secret_resolver import ( + MountResolver, + EnvVarResolver, + ChainedResolver, + ) + + # Build a chain explicitly + resolver = ChainedResolver([MountResolver(), EnvVarResolver()]) + resolver.resolve("destination", "default", binding) + +Legacy function-based API (still supported):: + + from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var - cfg = MyConfig() read_from_mount_and_fallback_to_env_var( base_volume_mount="/etc/secrets/appfnd", base_var_name="CLOUD_SDK_CFG", - module="objectstore", + module="destination", instance="default", - target=cfg + target=binding, ) """ -from .resolver import read_from_mount_and_fallback_to_env_var, resolve_base_mount +from sap_cloud_sdk.core.secret_resolver.resolver import ( + read_from_mount_and_fallback_to_env_var, +) +from sap_cloud_sdk.core.secret_resolver._resolvers import ( + Resolver, + ChainedResolver, +) + +from sap_cloud_sdk.core.secret_resolver.mount_resolver import ( + MountResolver, + resolve_base_mount, +) +from sap_cloud_sdk.core.secret_resolver.env_resolver import EnvVarResolver + +from sap_cloud_sdk.core.secret_resolver.sdk_config import ( + SdkConfig, + configure, + get_sdk_config, + get_resolver, +) -__all__ = ["read_from_mount_and_fallback_to_env_var", "resolve_base_mount"] +__all__ = [ + # Class-based API + "Resolver", + "MountResolver", + "EnvVarResolver", + "ChainedResolver", + # Global configuration + "SdkConfig", + "configure", + "get_sdk_config", + "get_resolver", + # Legacy function-based API + "read_from_mount_and_fallback_to_env_var", + "resolve_base_mount", +] diff --git a/src/sap_cloud_sdk/core/secret_resolver/_mapping.py b/src/sap_cloud_sdk/core/secret_resolver/_mapping.py new file mode 100644 index 00000000..f5316bed --- /dev/null +++ b/src/sap_cloud_sdk/core/secret_resolver/_mapping.py @@ -0,0 +1,32 @@ +"""Utilities for mapping dataclass fields to secret store keys.""" + +from typing import Any, Dict, Tuple +from dataclasses import fields, is_dataclass + + +def _get_field_map(target: Any) -> dict[str, tuple[str, type]]: + """ + Build a mapping from secret key -> (attribute_name, attribute_type) for a dataclass instance. + + Priority: + 1. Use field.metadata["secret"] if present as the key + 2. Fallback to the lowercase dataclass field name + Only string-typed fields are supported. + """ + if not is_dataclass(target) or isinstance(target, type): + raise TypeError("target must be a dataclass instance") + + mapping: Dict[str, Tuple[str, type]] = {} + for f in fields(target): + # Only support string fields for secrets (consistent with Go SDK) + # Allow plain 'str' annotations; reject others to keep behavior predictable + if f.type is not str: + raise TypeError( + f"target field '{f.name}' is not a string (only str fields are supported)" + ) + key = f.metadata.get("secret") if hasattr(f, "metadata") else None + if key and isinstance(key, str) and key.strip(): + mapping[key] = (f.name, f.type) + else: + mapping[f.name.lower()] = (f.name, f.type) + return mapping diff --git a/src/sap_cloud_sdk/core/secret_resolver/_resolvers.py b/src/sap_cloud_sdk/core/secret_resolver/_resolvers.py new file mode 100644 index 00000000..e74b6cfe --- /dev/null +++ b/src/sap_cloud_sdk/core/secret_resolver/_resolvers.py @@ -0,0 +1,92 @@ +"""BindingResolver protocol and built-in implementations. + +This module defines the core extensibility contract for secret resolution. +Each resolver encapsulates one binding source. Compose them into an ordered +chain via :class:`ChainedResolver` — the first resolver that succeeds wins. + +Protocol contract:: + + resolver.resolve(module, instance, target) + +- On success: populates ``target`` in-place, returns ``None`` +- On failure: raises any exception; the chain tries the next resolver +""" + +from __future__ import annotations + +from dataclasses import fields, is_dataclass +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class Resolver(Protocol): + """Contract for a single binding resolution strategy. + + A ``BindingResolver`` reads credentials from one source and populates + ``target`` in-place. Implementations raise on failure so that a + :class:`ChainedResolver` can try the next strategy. + + Any object implementing ``resolve`` with this signature satisfies the + protocol — no inheritance required. + """ + + def resolve(self, module: str, instance: str, target: Any) -> None: + """Populate ``target`` with credentials for ``module``/``instance``. + + Args: + module: Service module name (e.g. ``"destination"``). + instance: Instance identifier (e.g. ``"default"``). + target: Dataclass instance whose ``str`` fields will be set. + + Raises: + Any exception on failure; the caller determines how to handle it. + """ + ... + + +class ChainedResolver: + """Tries each resolver in order; returns on the first success. + + Collects failure messages from each resolver and raises a + :class:`RuntimeError` with an aggregated report when all resolvers fail. + + Args: + resolvers: Ordered list of :class:`BindingResolver` implementations to try. + base_var_name: Used only for the error guidance message. + """ + + def __init__( + self, + resolvers: list[Resolver], + base_var_name: str = "CLOUD_SDK_CFG", + ) -> None: + if not resolvers: + raise ValueError("resolvers list must not be empty") + self._resolvers = resolvers + self._base_var_name = base_var_name + + def resolve(self, module: str, instance: str, target: Any) -> None: + """Try each resolver in order; raise on total failure.""" + if not is_dataclass(target) or isinstance(target, type): + raise TypeError("target must be a dataclass instance") + for f in fields(target): + if f.type is not str and f.type != "str": + raise TypeError( + f"target field {f.name!r} is not a string (only str fields are supported)" + ) + + errors: list[str] = [] + for resolver in self._resolvers: + try: + resolver.resolve(module, instance, target) + return + except Exception as e: + label = type(resolver).__name__ + errors.append(f"{label} failed: {e}") + + raise RuntimeError( + f"module={module!r} instance={instance!r} failed to read secrets from all resolvers: " + f"{errors}. " + "Options: mount secrets under the service binding path, set environment variables " + f"like {self._base_var_name}_{module}_{instance}_ (uppercased), or set VCAP_SERVICES." + ) diff --git a/src/sap_cloud_sdk/core/secret_resolver/constants.py b/src/sap_cloud_sdk/core/secret_resolver/constants.py index 7d66cf40..165dbc79 100644 --- a/src/sap_cloud_sdk/core/secret_resolver/constants.py +++ b/src/sap_cloud_sdk/core/secret_resolver/constants.py @@ -3,3 +3,5 @@ """ BASE_MOUNT_PATH = "/etc/secrets/appfnd" +BASE_VAR_NAME = "CLOUD_SDK_CFG" +SERVICE_BINDING_ROOT = "SERVICE_BINDING_ROOT" diff --git a/src/sap_cloud_sdk/core/secret_resolver/env_resolver.py b/src/sap_cloud_sdk/core/secret_resolver/env_resolver.py new file mode 100644 index 00000000..4e42f0c8 --- /dev/null +++ b/src/sap_cloud_sdk/core/secret_resolver/env_resolver.py @@ -0,0 +1,47 @@ +"""Resolver that reads service binding secrets from environment variables.""" + +import os +from typing import Any + +from sap_cloud_sdk.core.secret_resolver._mapping import _get_field_map +from sap_cloud_sdk.core.secret_resolver.constants import BASE_VAR_NAME + + +class EnvVarResolver: + """Resolves bindings from environment variables. + + Reads variables named ``{base_var_name}_{module}_{instance}_{field_key}`` + (uppercased, hyphens in module/instance replaced with underscores). + + Args: + base_var_name: Env var name prefix. Defaults to ``"CLOUD_SDK_CFG"``. + """ + + def __init__(self, base_var_name: str = BASE_VAR_NAME) -> None: + self._base_var_name = base_var_name + + def resolve(self, module: str, instance: str, target: Any) -> None: + """Load secrets from environment variables.""" + normalized_module = module.replace("-", "_") + normalized_instance = instance.replace("-", "_") + _load_from_env( + self._base_var_name, normalized_module, normalized_instance, target + ) + + +def _load_from_env(base_var_name: str, module: str, instance: str, target: Any) -> None: + """ + Load secrets from environment variables with names: + {base_var_name}_{module}_{instance}_{field_key} (uppercased) + instance names have '-' replaced with '_' for env var compatibility. + """ + field_map = _get_field_map(target) + prefix = f"{base_var_name}_{module}_{instance}".upper() + + for key, (attr_name, _) in field_map.items(): + var_name = f"{prefix}_{key}".upper() + value = os.environ.get(var_name) + if value is None: + # Align with Go: error if env var not found + raise KeyError(f"env var not found: {var_name}") + setattr(target, attr_name, value) diff --git a/src/sap_cloud_sdk/core/secret_resolver/mount_resolver.py b/src/sap_cloud_sdk/core/secret_resolver/mount_resolver.py new file mode 100644 index 00000000..f24dde78 --- /dev/null +++ b/src/sap_cloud_sdk/core/secret_resolver/mount_resolver.py @@ -0,0 +1,93 @@ +"""Resolver that reads service binding secrets from a mounted volume path.""" + +import os +from typing import Any + +from sap_cloud_sdk.core.secret_resolver._mapping import _get_field_map +from sap_cloud_sdk.core.secret_resolver.constants import ( + BASE_MOUNT_PATH, + SERVICE_BINDING_ROOT, +) + + +class MountResolver: + """Resolves bindings from a mounted volume path. + + Reads secret files at ``{base_volume_mount}/{module}/{instance}/{field_key}``. + Respects the ``SERVICE_BINDING_ROOT`` environment variable (servicebinding.io + spec) as an override for ``base_volume_mount``. + + Args: + base_volume_mount: Base path for mounted secrets. Defaults to + ``/etc/secrets/appfnd``. + """ + + def __init__(self, base_volume_mount: str = BASE_MOUNT_PATH) -> None: + self._base_volume_mount = base_volume_mount + + def resolve(self, module: str, instance: str, target: Any) -> None: + """Load secrets from the mounted volume path.""" + effective_base = resolve_base_mount(self._base_volume_mount) + _load_from_mount(effective_base, module, instance, target) + + +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 `_ + 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 _load_from_mount( + base_volume_mount: str, module: str, instance: str, target: Any +) -> None: + """ + Load secrets from files at: + {base_volume_mount}/{module}/{instance}/{field_key} + + Sets string attributes directly on the dataclass instance. + """ + secret_dir = os.path.join(base_volume_mount, module, instance) + _validate_path(secret_dir) + + field_map = _get_field_map(target) + for key, (attr_name, _) in field_map.items(): + file_path = os.path.join(secret_dir, key) + try: + # Read entire file content as text; do not strip newlines to match Go behavior + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + except FileNotFoundError as e: + # Align with Go: surface precise file error + raise FileNotFoundError( + f"failed to read secret file {file_path}: {e}" + ) from e + except OSError as e: + raise OSError(f"failed to read secret file {file_path}: {e}") from e + + # Set target field (string only) + setattr(target, attr_name, content) + + +def _validate_path(path: str) -> None: + """Validate that the given path exists and is a directory.""" + try: + _st = os.stat(path) + except FileNotFoundError as e: + raise FileNotFoundError(f"path does not exist: {path}") from e + except OSError as e: + raise OSError(f"cannot access path {path}: {e}") from e + # If exists, ensure it's a directory + if not os.path.isdir(path): + raise NotADirectoryError(f"path is not a directory: {path}") diff --git a/src/sap_cloud_sdk/core/secret_resolver/resolver.py b/src/sap_cloud_sdk/core/secret_resolver/resolver.py index c76bbc74..a5433f6c 100644 --- a/src/sap_cloud_sdk/core/secret_resolver/resolver.py +++ b/src/sap_cloud_sdk/core/secret_resolver/resolver.py @@ -1,136 +1,14 @@ """Core secret resolver implementation.""" from __future__ import annotations - 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 `_ - 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: - """Validate module and instance inputs.""" - if not isinstance(module, str) or not module.strip(): - raise ValueError("module name cannot be empty") - if not isinstance(instance, str) or not instance.strip(): - raise ValueError("instance name cannot be empty") +from typing import Any - -def _validate_path(path: str) -> None: - """Validate that the given path exists and is a directory.""" - try: - _st = os.stat(path) - except FileNotFoundError as e: - raise FileNotFoundError(f"path does not exist: {path}") from e - except OSError as e: - raise OSError(f"cannot access path {path}: {e}") from e - # If exists, ensure it's a directory - if not os.path.isdir(path): - raise NotADirectoryError(f"path is not a directory: {path}") - - -def _get_field_map(target: Any) -> Dict[str, Tuple[str, type]]: - """ - Build a mapping from secret key -> (attribute_name, attribute_type) for a dataclass instance. - - Priority: - 1. Use field.metadata["secret"] if present as the key - 2. Fallback to the lowercase dataclass field name - Only string-typed fields are supported. - """ - if not is_dataclass(target) or isinstance(target, type): - raise TypeError("target must be a dataclass instance") - - mapping: Dict[str, Tuple[str, type]] = {} - for f in fields(target): - # Only support string fields for secrets (consistent with Go SDK) - # Allow plain 'str' annotations; reject others to keep behavior predictable - if f.type is not str: - raise TypeError( - f"target field '{f.name}' is not a string (only str fields are supported)" - ) - key = f.metadata.get("secret") if hasattr(f, "metadata") else None - if key and isinstance(key, str) and key.strip(): - mapping[key] = (f.name, f.type) - else: - mapping[f.name.lower()] = (f.name, f.type) - return mapping - - -def _load_from_path(secret_dir: str, target: Any) -> None: - """ - Load secrets from files directly in secret_dir into target dataclass. - - Reads each field key as a file name under secret_dir. Used for both the - servicebinding.io flat layout ($ROOT//) and the legacy - three-level layout via :func:`_load_from_mount`. - """ - _validate_path(secret_dir) - - field_map = _get_field_map(target) - for key, (attr_name, _) in field_map.items(): - file_path = os.path.join(secret_dir, key) - try: - # Read entire file content as text; do not strip newlines to match Go behavior - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - except FileNotFoundError as e: - # Align with Go: surface precise file error - raise FileNotFoundError( - f"failed to read secret file {file_path}: {e}" - ) from e - except OSError as e: - raise OSError(f"failed to read secret file {file_path}: {e}") from e - - # Set target field (string only) - setattr(target, attr_name, content) - - -def _load_from_mount( - base_volume_mount: str, module: str, instance: str, target: Any -) -> None: - """ - Load secrets from files at: - {base_volume_mount}/{module}/{instance}/{field_key} - """ - secret_dir = os.path.join(base_volume_mount, module, instance) - _load_from_path(secret_dir, target) - - -def _load_from_env(base_var_name: str, module: str, instance: str, target: Any) -> None: - """ - Load secrets from environment variables with names: - {base_var_name}_{module}_{instance}_{field_key} (uppercased) - instance names have '-' replaced with '_' for env var compatibility. - """ - field_map = _get_field_map(target) - prefix = f"{base_var_name}_{module}_{instance}".upper() - - for key, (attr_name, _) in field_map.items(): - var_name = f"{prefix}_{key}".upper() - value = os.environ.get(var_name) - if value is None: - # Align with Go: error if env var not found - raise KeyError(f"env var not found: {var_name}") - setattr(target, attr_name, value) +from sap_cloud_sdk.core.secret_resolver.mount_resolver import ( + resolve_base_mount, + _load_from_mount, +) +from sap_cloud_sdk.core.secret_resolver.env_resolver import _load_from_env def read_from_mount_and_fallback_to_env_var( @@ -142,21 +20,17 @@ def read_from_mount_and_fallback_to_env_var( ) -> None: """ Load secrets for a given module and instance into the provided dataclass instance `target`. - - Fallback order when ``SERVICE_BINDING_ROOT`` is set: - 1. Flat path: {SERVICE_BINDING_ROOT}/{module}/{field_key} (servicebinding.io spec) - 2. Full path: {SERVICE_BINDING_ROOT}/{module}/{instance}/{field_key} (legacy convention) - 3. Environment variables: {base_var_name}_{module}_{instance}_{field_key} (uppercased) - - Fallback order when ``SERVICE_BINDING_ROOT`` is **not** set: - 1. Full path: {base_volume_mount}/{module}/{instance}/{field_key} + 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: ValueError: If inputs are invalid or target is not a dataclass instance FileNotFoundError / NotADirectoryError / OSError: If mount path issues occur KeyError: If environment variables are missing on fallback - RuntimeError: If all strategies fail (aggregated error) + RuntimeError: If both mount and env var loading fail (aggregated error) """ _validate_inputs(module, instance) @@ -165,15 +39,6 @@ def read_from_mount_and_fallback_to_env_var( normalized_module = module.replace("-", "_") normalized_instance = instance.replace("-", "_") - # servicebinding.io: when SERVICE_BINDING_ROOT is explicitly set, try the flat path - # $ROOT// before the legacy $ROOT/// path. - if os.environ.get("SERVICE_BINDING_ROOT") is not None: - try: - _load_from_path(os.path.join(resolved_base_path, module), target) - return - except Exception as e: - errors.append(f"mount failed: {e};") - try: _load_from_mount(resolved_base_path, module, instance, target) return @@ -202,3 +67,11 @@ def read_from_mount_and_fallback_to_env_var( raise RuntimeError( f"module={module} instance={instance} failed to read secrets: {errors} {guidance}" ) + + +def _validate_inputs(module: str, instance: str) -> None: + """Validate module and instance inputs.""" + if not isinstance(module, str) or not module.strip(): + raise ValueError("module name cannot be empty") + if not isinstance(instance, str) or not instance.strip(): + raise ValueError("instance name cannot be empty") diff --git a/src/sap_cloud_sdk/core/secret_resolver/sdk_config.py b/src/sap_cloud_sdk/core/secret_resolver/sdk_config.py new file mode 100644 index 00000000..223a2cf6 --- /dev/null +++ b/src/sap_cloud_sdk/core/secret_resolver/sdk_config.py @@ -0,0 +1,94 @@ +"""Process-wide SDK configuration. + +Provides :class:`SdkConfig` and :func:`configure` so an application can set a +custom binding resolver chain once at startup, and every ``create_client()`` call +across all modules will use it automatically. + +Usage:: + + from sap_cloud_sdk.core.secret_resolver import ( + configure, SdkConfig, ChainedResolver, EnvVarResolver, + ) + from sap_cloud_sdk.core.secrets_resolver_extended import VcapResolver + + # Cloud Foundry: VCAP first, env vars as fallback + configure(SdkConfig( + resolver=ChainedResolver([VcapResolver(), EnvVarResolver()]) + )) + +If no configuration is set, all modules fall back to the default chain: +``ChainedResolver([MountResolver(), EnvVarResolver()])``, which is identical +to the previous ``read_from_mount_and_fallback_to_env_var()`` behaviour. +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from typing import Optional + +from sap_cloud_sdk.core.secret_resolver._resolvers import ( + Resolver, + ChainedResolver, +) + +from sap_cloud_sdk.core.secret_resolver.env_resolver import EnvVarResolver +from sap_cloud_sdk.core.secret_resolver.mount_resolver import MountResolver + +_lock = threading.Lock() +_sdk_config: Optional[SdkConfig] = None + + +@dataclass +class SdkConfig: + """Process-wide SDK configuration. + + Attributes: + resolver: The :class:`BindingResolver` that all ``create_client()`` calls + use when no explicit config is passed. ``None`` means each module uses + the default ``ChainedResolver([MountResolver(), EnvVarResolver()])`` chain. + """ + + resolver: Optional[Resolver] = None + + +def configure(config: SdkConfig) -> None: + """Set the process-wide SDK configuration. + + Thread-safe. Replaces any previously set configuration. Call once at + application startup before any ``create_client()`` is invoked. + + Args: + config: :class:`SdkConfig` instance to install. + """ + global _sdk_config + with _lock: + _sdk_config = config + + +def get_sdk_config() -> Optional[SdkConfig]: + """Return the current process-wide SDK configuration, or ``None`` if unset.""" + return _sdk_config + + +def _reset_sdk_config() -> None: + """Reset the global configuration to the unset state. + + Intended for test teardown only. Not part of the public API. + """ + global _sdk_config + with _lock: + _sdk_config = None + + +def get_resolver() -> Resolver: + """Return the resolver all modules should use for binding resolution. + + Returns the custom resolver set via :func:`configure` if one has been + installed; otherwise returns a fresh default + ``ChainedResolver([MountResolver(), EnvVarResolver()])``. + """ + cfg = get_sdk_config() + if cfg is not None and cfg.resolver is not None: + return cfg.resolver + return ChainedResolver([MountResolver(), EnvVarResolver()]) diff --git a/src/sap_cloud_sdk/core/secret_resolver/user-guide.md b/src/sap_cloud_sdk/core/secret_resolver/user-guide.md index aeea94b6..61c588b7 100644 --- a/src/sap_cloud_sdk/core/secret_resolver/user-guide.md +++ b/src/sap_cloud_sdk/core/secret_resolver/user-guide.md @@ -1,256 +1,179 @@ -# Secret Resolver User Guide +# Secret Resolver — User Guide -This module provides secure credential management by loading secrets from mounted volumes (Kubernetes-style) with fallback to environment variables. It supports type-safe configuration using dataclasses and follows Cloud patterns for secret resolution. - -The Secret Resolver is designed to work seamlessly in both Kubernetes environments with mounted secrets and with environment variables. - -## Import - -```python -from sap_cloud_sdk.secret_resolver import read_from_mount_and_fallback_to_env_var -``` +The `secret_resolver` module loads service-binding credentials (secrets) into +dataclass instances from two sources: **mounted volume files** and **environment +variables**. Resolvers are composable and the default chain can be replaced +process-wide for custom environments (e.g. Cloud Foundry VCAP). --- -## Getting Started +## Concepts -The Secret Resolver loads configuration into dataclass objects using a hierarchical approach: +### Target dataclass -1. **First**: Try to read from mounted volume paths (Kubernetes secrets) -2. **Fallback**: Use environment variables if mounted secrets are not available +All resolvers populate a **dataclass instance** whose fields are all `str`. +Each field maps to one secret key. By default the key is the lowercase field +name; you can override it with `field(metadata={"secret": "custom-key"})`. ```python -from dataclasses import dataclass -from sap_cloud_sdk.secret_resolver import read_from_mount_and_fallback_to_env_var +from dataclasses import dataclass, field @dataclass -class DatabaseConfig: - host: str = "" - port: str = "" - username: str = "" - password: str = "" - -# Load configuration -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 -) - -print(f"Database: {config.username}@{config.host}:{config.port}") +class DestinationBinding: + clientid: str = "" + clientsecret: str = "" + url: str = "" + # Override the lookup key to "token_service_url" + token_url: str = field(default="", metadata={"secret": "token_service_url"}) ``` +### module / instance + +Every resolver call takes a `module` (service category, e.g. `"destination"`) +and an `instance` (service instance name, e.g. `"default"`). These are used to +build the lookup path or variable name prefix. Hyphens in both are normalised +to underscores where required. + --- -## Configuration Patterns +## Resolver types -### Mount Path Structure +### `MountResolver` -The Secret Resolver expects mounted secrets to follow this hierarchy: +Reads secret files at: ``` -/etc/secrets/appfnd -└── / - └── / - ├── host - ├── port - ├── username - └── password +{base_volume_mount}/{module}/{instance}/{field_key} ``` -### 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: +```python +from sap_cloud_sdk.core.secret_resolver import MountResolver -```bash -export SERVICE_BINDING_ROOT=/bindings +resolver = MountResolver() # defaults to /etc/secrets/appfnd +resolver = MountResolver("/custom/mount/path") # explicit base path ``` -With this set, the resolver looks for secrets at `$SERVICE_BINDING_ROOT///` instead of `/etc/secrets/appfnd///`. +### `EnvVarResolver` + +Reads environment variables named: -Example for the above configuration: ``` -/etc/secrets/appfnd -└── database/ - └── primary/ - ├── host # Contains: "db.example.com" - ├── port # Contains: "5432" - ├── username # Contains: "app_user" - └── password # Contains: "secret123" +{BASE_VAR_NAME}_{MODULE}_{INSTANCE}_{FIELD_KEY} (all uppercased) ``` -### Environment Variable Fallback +Example: `CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID` -If mounted secrets are not available, the resolver falls back to environment variables using this pattern: +```python +from sap_cloud_sdk.core.secret_resolver import EnvVarResolver +resolver = EnvVarResolver() # base prefix: CLOUD_SDK_CFG +resolver = EnvVarResolver("MY_APP_SECRETS") # custom prefix ``` -___ -``` -- INSTANCE_NAME has `'-'` replaced with `'_'` for compatibility with system environment variable naming rules. +### `ChainedResolver` -For the example above: -```bash -export DB_DATABASE_PRIMARY_HOST="db.example.com" -export DB_DATABASE_PRIMARY_PORT="5432" -export DB_DATABASE_PRIMARY_USERNAME="app_user" -export DB_DATABASE_PRIMARY_PASSWORD="secret123" -``` +Tries each resolver in order and returns on the first success. Raises +`RuntimeError` with an aggregated report when all resolvers fail. ---- +```python +from sap_cloud_sdk.core.secret_resolver import ChainedResolver, MountResolver, EnvVarResolver -## Usage Examples +resolver = ChainedResolver([MountResolver(), EnvVarResolver()]) +resolver.resolve("destination", "default", binding) +``` -### ObjectStore Configuration +### Custom resolver -This is how the ObjectStore module uses the Secret Resolver internally: +Any object with a `resolve(module, instance, target)` method satisfies the +`Resolver` protocol — no inheritance needed. ```python -from dataclasses import dataclass -from sap_cloud_sdk.secret_resolver import read_from_mount_and_fallback_to_env_var - -@dataclass -class ObjectStoreConfig: - access_key_id: str = "" - secret_access_key: str = "" - bucket: str = "" - host: str = "" - -# Load ObjectStore credentials -config = ObjectStoreConfig() -read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets", - base_var_name="OBJECTSTORE", - module="objectstore", - instance="credentials", - target=config -) +class VaultResolver: + def resolve(self, module: str, instance: str, target: object) -> None: + # fetch from HashiCorp Vault, populate target fields + ... ``` -**Mounted secrets structure:** -``` -/etc/secrets/appfnd -└── objectstore/ - └── credentials/ - ├── access_key_id - ├── secret_access_key - ├── bucket - └── host -``` +--- -**Environment variable fallback:** -```bash -export OBJECTSTORE_OBJECTSTORE_CREDENTIALS_ACCESS_KEY_ID="AKIA..." -export OBJECTSTORE_OBJECTSTORE_CREDENTIALS_SECRET_ACCESS_KEY="secret" -export OBJECTSTORE_OBJECTSTORE_CREDENTIALS_BUCKET="my-bucket" -export OBJECTSTORE_OBJECTSTORE_CREDENTIALS_HOST="s3.amazonaws.com" -``` +## Process-wide configuration -### Database Configuration with Multiple Instances +Call `configure()` once at application startup to install a custom resolver +chain. All `create_client()` calls across every module will use it. ```python -from dataclasses import dataclass -from sap_cloud_sdk.secret_resolver import read_from_mount_and_fallback_to_env_var +from sap_cloud_sdk.core.secret_resolver import configure, SdkConfig, ChainedResolver, EnvVarResolver -@dataclass -class DatabaseConfig: - host: str = "" - port: str = "5432" # Default value - database: str = "" - username: str = "" - password: str = "" - ssl_mode: str = "require" # Default value - -# Load primary database config -primary_db = DatabaseConfig() -read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets", - base_var_name="APP", - module="database", - instance="primary", - target=primary_db -) +configure(SdkConfig( + resolver=ChainedResolver([VaultResolver(), EnvVarResolver()]) +)) +``` -# Load read replica config -replica_db = DatabaseConfig() -read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets", - base_var_name="APP", - module="database", - instance="replica", - target=replica_db -) +If `configure()` is never called, each module falls back to the default chain: +`ChainedResolver([MountResolver(), EnvVarResolver()])`. -print(f"Primary DB: {primary_db.username}@{primary_db.host}") -print(f"Replica DB: {replica_db.username}@{replica_db.host}") -``` +### Configuration API + +| Function | Description | +|---|---| +| `configure(config)` | Install a process-wide `SdkConfig`. Thread-safe. | +| `get_sdk_config()` | Return the current `SdkConfig`, or `None` if unset. | +| `get_resolver()` | Return the active resolver (custom or default chain). | +| `reset_sdk_config()` | Reset to unset state. Intended for test teardown only. | + +--- -### API Configuration +## Putting it together ```python -from dataclasses import dataclass -from sap_cloud_sdk.secret_resolver import read_from_mount_and_fallback_to_env_var +from dataclasses import dataclass, field +from sap_cloud_sdk.core.secret_resolver import ChainedResolver, MountResolver, EnvVarResolver @dataclass -class ApiConfig: - base_url: str = "" - api_key: str = "" - timeout: str = "30" - retries: str = "3" - -# Load external API configuration -api_config = ApiConfig() -read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets", - base_var_name="EXTERNAL", - module="payment", - instance="stripe", - target=api_config -) +class DestinationBinding: + clientid: str = "" + clientsecret: str = "" + url: str = "" + +binding = DestinationBinding() -# Convert string values to appropriate types -timeout_seconds = int(api_config.timeout) -max_retries = int(api_config.retries) +resolver = ChainedResolver([MountResolver(), EnvVarResolver()]) +resolver.resolve("destination", "my-instance", binding) + +print(binding.clientid) ``` --- -## Error Handling +## Legacy API -The Secret Resolver handles missing secrets gracefully by leaving default values unchanged: +The function-based API from earlier SDK versions is still supported: ```python -from dataclasses import dataclass -from sap_cloud_sdk.secret_resolver import read_from_mount_and_fallback_to_env_var - -@dataclass -class ServiceConfig: - api_key: str = "" - timeout: str = "30" # Default value - retries: str = "3" # Default value - debug: str = "false" # Default value +from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var -config = ServiceConfig() - -# This won't raise an error if secrets are missing read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets", - base_var_name="API", - module="external", - instance="service", - target=config + base_volume_mount="/etc/secrets/appfnd", + base_var_name="CLOUD_SDK_CFG", + module="destination", + instance="default", + target=binding, ) - -# Check if required values were loaded -if not config.api_key: - raise ValueError("API key is required but not found in secrets or environment") - -print(f"Loaded config: timeout={config.timeout}, retries={config.retries}") ``` +Prefer the class-based API for new code — it is more composable and supports +process-wide configuration. + --- + +## Error handling + +| Situation | Exception raised | +|---|---| +| Target is not a dataclass instance | `TypeError` | +| A target field is not `str` | `TypeError` | +| Mount directory does not exist | `FileNotFoundError` | +| Mount path is not a directory | `NotADirectoryError` | +| Expected env var is absent | `KeyError` | +| All resolvers in a chain fail | `RuntimeError` (aggregated message with guidance) | diff --git a/src/sap_cloud_sdk/destination/__init__.py b/src/sap_cloud_sdk/destination/__init__.py index d4e6b7e9..dee435f8 100644 --- a/src/sap_cloud_sdk/destination/__init__.py +++ b/src/sap_cloud_sdk/destination/__init__.py @@ -46,7 +46,7 @@ PaginationInfo, PagedResult, ) -from sap_cloud_sdk.destination.config import load_from_env_or_mount, DestinationConfig +from sap_cloud_sdk.destination.config import load_secrets, DestinationConfig from sap_cloud_sdk.destination._http import TokenProvider, DestinationHttp from sap_cloud_sdk.destination._destination_http_client import DestinationHttpClient from sap_cloud_sdk.destination.client import DestinationClient @@ -114,7 +114,7 @@ def create_client( return LocalDevDestinationClient() # Cloud mode via secret resolver or explicit config - binding = config or load_from_env_or_mount(instance) + binding = config or load_secrets(instance) tp = TokenProvider(binding) http = DestinationHttp(config=binding, token_provider=tp) @@ -155,7 +155,7 @@ def create_fragment_client( return LocalDevFragmentClient() # Use provided config or load from environment/mount (cloud mode) - binding = config or load_from_env_or_mount(instance) + binding = config or load_secrets(instance) tp = TokenProvider(binding) http = DestinationHttp(config=binding, token_provider=tp) @@ -196,7 +196,7 @@ def create_certificate_client( return LocalDevCertificateClient() # Use provided config or load from environment/mount (cloud mode) - binding = config or load_from_env_or_mount(instance) + binding = config or load_secrets(instance) tp = TokenProvider(binding) http = DestinationHttp(config=binding, token_provider=tp) diff --git a/src/sap_cloud_sdk/destination/config.py b/src/sap_cloud_sdk/destination/config.py index e0fce1c9..eb1db186 100644 --- a/src/sap_cloud_sdk/destination/config.py +++ b/src/sap_cloud_sdk/destination/config.py @@ -1,9 +1,10 @@ """Configuration and secret resolution for Destination Service. -Loads service binding secrets from a mounted volume with environment fallback, -then normalizes into a unified DestinationConfig model. +Loads service binding secrets via the SDK-wide resolver chain (mount with +environment fallback by default), then normalizes into a unified DestinationConfig +model. -Mount path convention: +Default mount path convention: /etc/secrets/appfnd/destination/{instance}/ Keys: - clientid @@ -12,33 +13,25 @@ - uri (destination service base) - identityzone -Env fallback convention (uppercased): - CLOUD_SDK_CFG_DESTINATION_destination_{instance}_{field_key} - e.g., CLOUD_SDK_CFG_DESTINATION_DESTINATION_DEFAULT_CLIENTID - CLOUD_SDK_CFG_DESTINATION_destination_{instance}_{field_key} - e.g., CLOUD_SDK_CFG_DESTINATION_DESTINATION_DEFAULT_CLIENTID - -Note: We use the common secret resolver with: - base_volume_mount="/etc/secrets/appfnd" - base_var_name="CLOUD_SDK_CFG" - module="destination" - instance="{instance}" -This results in env var names: CLOUD_SDK_CFG_DESTINATION_{INSTANCE}_{FIELD_KEY} -This results in env var names: CLOUD_SDK_CFG_DESTINATION_{INSTANCE}_{FIELD_KEY} +Default env fallback convention (uppercased): + CLOUD_SDK_CFG_DESTINATION_{INSTANCE}_{FIELD_KEY} + e.g., CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID + +The resolver chain can be customised process-wide via:: + + from sap_cloud_sdk.core.secret_resolver import configure, SdkConfig + configure(SdkConfig(resolver=MyCustomResolver())) """ from dataclasses import dataclass from typing import Optional import os -from sap_cloud_sdk.core.secret_resolver.resolver import ( - read_from_mount_and_fallback_to_env_var, -) +from sap_cloud_sdk.core.secret_resolver import get_resolver from sap_cloud_sdk.destination.exceptions import ConfigError from sap_cloud_sdk.destination._models import TransparentProxy _TRANSPARENT_PROXY_ENV_VAR = "APPFND_CONHOS_TRANSP_PROXY" -_TRANSPARENT_PROXY_ENV_VAR = "APPFND_CONHOS_TRANSP_PROXY" @dataclass @@ -113,7 +106,7 @@ def to_binding(self) -> DestinationConfig: ) -def load_from_env_or_mount(instance: Optional[str] = None) -> DestinationConfig: +def load_secrets(instance: Optional[str] = None) -> DestinationConfig: """Load Destination configuration from mount with env fallback and normalize. Args: @@ -129,15 +122,7 @@ def load_from_env_or_mount(instance: Optional[str] = None) -> DestinationConfig: binding = BindingData() try: - # 1) Try mount at /etc/secrets/appfnd/destination/{instance}/... - # 2) Fallback to env: CLOUD_SDK_CFG_DESTINATION_{INSTANCE}_{FIELD_KEY} - read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="destination", - instance=inst, - target=binding, - ) + get_resolver().resolve(module="destination", instance=inst, target=binding) binding.validate() return binding.to_binding() @@ -152,7 +137,6 @@ def load_from_env_or_mount(instance: Optional[str] = None) -> DestinationConfig: def load_transparent_proxy() -> Optional[TransparentProxy]: """Load transparent proxy configuration from environment variable. The environment variable APPFND_CONHOS_TRANSP_PROXY should be in the format: - The environment variable APPFND_CONHOS_TRANSP_PROXY should be in the format: {proxy_name}.{namespace} Returns: diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py index 8a396e16..1f53485f 100644 --- a/src/sap_cloud_sdk/dms/config.py +++ b/src/sap_cloud_sdk/dms/config.py @@ -3,11 +3,9 @@ from typing import Any, Dict, Optional from urllib.parse import urlparse -from sap_cloud_sdk.core.secret_resolver.resolver import ( - read_from_mount_and_fallback_to_env_var, -) from sap_cloud_sdk.destination.exceptions import ConfigError from sap_cloud_sdk.dms.model import DMSCredentials +from sap_cloud_sdk.core.secret_resolver import get_resolver @dataclass @@ -116,15 +114,7 @@ def load_sdm_config_from_env_or_mount(instance: Optional[str] = None) -> DMSCred ) # Initialize with empty values; will be populated by resolver try: - # 1) Try mount at /etc/secrets/appfnd/destination/{instance}/... - # 2) Fallback to env: CLOUD_SDK_CFG_SDM_{INSTANCE}_{FIELD_KEY} - read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="sdm", - instance=inst, - target=binding, - ) + get_resolver().resolve(module="sdm", instance=inst, target=binding) binding.validate() return binding.to_credentials() diff --git a/src/sap_cloud_sdk/objectstore/__init__.py b/src/sap_cloud_sdk/objectstore/__init__.py index 770c1ad8..2222e41d 100644 --- a/src/sap_cloud_sdk/objectstore/__init__.py +++ b/src/sap_cloud_sdk/objectstore/__init__.py @@ -19,7 +19,7 @@ ) from sap_cloud_sdk.objectstore._models import ObjectStoreBindingData, ObjectMetadata from sap_cloud_sdk.objectstore._s3 import ObjectStoreClient -from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var +from sap_cloud_sdk.core.secret_resolver import get_resolver def create_client( @@ -53,13 +53,8 @@ def create_client( # Cloud mode: use secret resolver to load configuration config = ObjectStoreBindingData() - read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="objectstore", - instance=instance, - target=config, - ) + get_resolver().resolve(module="objectstore", instance=instance, target=config) + return ObjectStoreClient(config, disable_ssl=disable_ssl) diff --git a/src/sap_cloud_sdk/print/__init__.py b/src/sap_cloud_sdk/print/__init__.py index 69397096..64d48ee9 100644 --- a/src/sap_cloud_sdk/print/__init__.py +++ b/src/sap_cloud_sdk/print/__init__.py @@ -34,7 +34,7 @@ PrintTask, PrintTaskMetadata, ) -from sap_cloud_sdk.print.config import load_from_env_or_mount, PrintConfig +from sap_cloud_sdk.print.config import load_secrets, PrintConfig from sap_cloud_sdk.print._http import PrintHttp, TokenProvider from sap_cloud_sdk.print.client import PrintClient from sap_cloud_sdk.print.exceptions import ( @@ -72,7 +72,7 @@ def create_client( ClientCreationError: If client creation fails. """ try: - binding = config or load_from_env_or_mount(instance) + binding = config or load_secrets(instance) tp = TokenProvider(binding) http = PrintHttp(config=binding, token_provider=tp) return PrintClient(http, _telemetry_source=_telemetry_source) diff --git a/src/sap_cloud_sdk/print/config.py b/src/sap_cloud_sdk/print/config.py index f3a5e996..4e9d44a2 100644 --- a/src/sap_cloud_sdk/print/config.py +++ b/src/sap_cloud_sdk/print/config.py @@ -19,10 +19,8 @@ import json import logging -from sap_cloud_sdk.core.secret_resolver.resolver import ( - read_from_mount_and_fallback_to_env_var, -) from sap_cloud_sdk.print.exceptions import ConfigError +from sap_cloud_sdk.core.secret_resolver import get_resolver logger = logging.getLogger(__name__) @@ -84,7 +82,7 @@ def to_config(self) -> PrintConfig: ) -def load_from_env_or_mount(instance: Optional[str] = None) -> PrintConfig: +def load_secrets(instance: Optional[str] = None) -> PrintConfig: """Load Print configuration from mount with env fallback and normalize. Args: @@ -100,13 +98,7 @@ def load_from_env_or_mount(instance: Optional[str] = None) -> PrintConfig: binding = _BindingData() try: - read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="print", - instance=inst, - target=binding, - ) + get_resolver().resolve(module="print", instance=inst, target=binding) binding.validate() return binding.to_config() diff --git a/tests/adms/integration/conftest.py b/tests/adms/integration/conftest.py index c691f19d..2c008090 100644 --- a/tests/adms/integration/conftest.py +++ b/tests/adms/integration/conftest.py @@ -24,7 +24,7 @@ AsyncAdmsClient, create_async_client, ) -from sap_cloud_sdk.adms.config import AdmsConfig, load_from_env_or_mount +from sap_cloud_sdk.adms.config import AdmsConfig, load_secrets from sap_cloud_sdk.adms.exceptions import ConfigError @@ -40,7 +40,7 @@ def adms_config() -> AdmsConfig: Skips the entire integration suite when required credentials are missing. """ try: - return load_from_env_or_mount("default") + return load_secrets("default") except ConfigError as exc: pytest.skip(f"ADMS integration tests skipped — missing config: {exc}") diff --git a/tests/adms/unit/test_client.py b/tests/adms/unit/test_client.py index f789428a..8a351416 100644 --- a/tests/adms/unit/test_client.py +++ b/tests/adms/unit/test_client.py @@ -144,7 +144,7 @@ def test_with_user_jwt_uses_new_http(self, mock_http): class TestCreateClientFactory: def test_raises_config_error_on_missing_binding(self): with patch( - "sap_cloud_sdk.adms.client.load_from_env_or_mount", + "sap_cloud_sdk.adms.client.load_secrets", side_effect=ConfigError("missing fields"), ): with pytest.raises(ConfigError, match="missing fields"): @@ -157,7 +157,7 @@ def test_unexpected_exception_propagates_as_is(self): "client creation failed". """ with patch( - "sap_cloud_sdk.adms.client.load_from_env_or_mount", + "sap_cloud_sdk.adms.client.load_secrets", side_effect=RuntimeError("unexpected"), ): with pytest.raises(RuntimeError, match="unexpected"): @@ -171,7 +171,7 @@ def test_returns_adms_client_on_success(self): client_secret="cs", ) with patch( - "sap_cloud_sdk.adms.client.load_from_env_or_mount", + "sap_cloud_sdk.adms.client.load_secrets", return_value=mock_config, ): client = create_client() @@ -185,7 +185,7 @@ def test_accepts_explicit_config(self): client_id="cid", client_secret="cs", ) - with patch("sap_cloud_sdk.adms.client.load_from_env_or_mount") as mock_load: + with patch("sap_cloud_sdk.adms.client.load_secrets") as mock_load: client = create_client(config=mock_config) mock_load.assert_not_called() @@ -199,7 +199,7 @@ def test_user_jwt_forwarded_to_http(self): client_secret="cs", ) with patch( - "sap_cloud_sdk.adms.client.load_from_env_or_mount", + "sap_cloud_sdk.adms.client.load_secrets", return_value=mock_config, ): client = create_client(user_jwt="user-jwt-123") @@ -445,7 +445,7 @@ async def test_context_manager(self, config): class TestCreateAsyncClient: def test_raises_config_error_when_no_binding(self): with patch( - "sap_cloud_sdk.adms.client.load_from_env_or_mount", + "sap_cloud_sdk.adms.client.load_secrets", side_effect=ConfigError("no binding"), ): with pytest.raises(ConfigError): @@ -453,14 +453,14 @@ def test_raises_config_error_when_no_binding(self): def test_returns_async_client(self, config): with patch( - "sap_cloud_sdk.adms.client.load_from_env_or_mount", + "sap_cloud_sdk.adms.client.load_secrets", return_value=config, ): client = create_async_client() assert isinstance(client, AsyncAdmsClient) def test_accepts_explicit_config(self, config): - with patch("sap_cloud_sdk.adms.client.load_from_env_or_mount") as mock_load: + with patch("sap_cloud_sdk.adms.client.load_secrets") as mock_load: client = create_async_client(config=config) mock_load.assert_not_called() assert isinstance(client, AsyncAdmsClient) diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index b3dbbdd3..6a2d0bc6 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -46,8 +46,8 @@ def test_uses_provided_config(self): def test_reads_env_when_no_config_provided(self, monkeypatch): """Factory falls back to environment variables when no config given.""" import json - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", "http://memory.example.com") - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", json.dumps({ + monkeypatch.setenv("CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_APPLICATION_URL", "http://memory.example.com") + monkeypatch.setenv("CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_UAA", json.dumps({ "url": "http://auth.example.com", "clientid": "client-id", "clientsecret": "client-secret", diff --git a/tests/agent_memory/unit/test_config.py b/tests/agent_memory/unit/test_config.py index d3e895a1..c7b8e69d 100644 --- a/tests/agent_memory/unit/test_config.py +++ b/tests/agent_memory/unit/test_config.py @@ -8,7 +8,7 @@ from sap_cloud_sdk.agent_memory.config import ( AgentMemoryConfig, BindingData, - _load_config_from_env, + _load_secrets, ) from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError @@ -18,7 +18,7 @@ "clientsecret": "my-secret", }) -_RESOLVER = "sap_cloud_sdk.core.secret_resolver.read_from_mount_and_fallback_to_env_var" +_RESOLVER = "sap_cloud_sdk.agent_memory.config.get_resolver" # ── AgentMemoryConfig ───────────────────────────────────────────────────────── @@ -128,16 +128,16 @@ def test_extract_config_raises_on_empty_uaa_object(self): # ── _load_config_from_env ───────────────────────────────────────────────────── -def _fill_binding(**kwargs) -> None: - target = kwargs["target"] +def _fill_binding(module, instance, target) -> None: target.application_url = "https://memory.example.com" target.uaa = _VALID_UAA class TestLoadConfigFromEnv: def test_success_via_resolver(self): - with patch(_RESOLVER, side_effect=_fill_binding): - config = _load_config_from_env() + with patch(_RESOLVER) as mock_get_resolver: + mock_get_resolver.return_value.resolve.side_effect = _fill_binding + config = _load_secrets() assert config.base_url == "https://memory.example.com" assert config.token_url == "https://auth.example.com/oauth/token" @@ -145,45 +145,36 @@ def test_success_via_resolver(self): assert config.client_secret == "my-secret" def test_calls_resolver_with_correct_arguments(self): - with patch(_RESOLVER, side_effect=_fill_binding) as mock_resolver: - _load_config_from_env() + with patch(_RESOLVER) as mock_get_resolver: + mock_get_resolver.return_value.resolve.side_effect = _fill_binding + _load_secrets() - mock_resolver.assert_called_once() - _, kwargs = mock_resolver.call_args - assert kwargs["base_volume_mount"] == "/etc/secrets/appfnd" - assert kwargs["base_var_name"] == "CLOUD_SDK_CFG" - assert kwargs["module"] == "hana-agent-memory" - assert kwargs["instance"] == "default" - - def test_falls_back_to_env_vars(self, monkeypatch): - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", "https://memory.example.com") - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", _VALID_UAA) - - # Let the real resolver run — mount will fail, env vars will succeed - with patch("os.stat", side_effect=FileNotFoundError("no mount")): - config = _load_config_from_env() - - assert config.base_url == "https://memory.example.com" - assert config.client_id == "my-client" + mock_get_resolver.return_value.resolve.assert_called_once_with( + module="agent_memory", instance="default", target=mock_get_resolver.return_value.resolve.call_args[1]["target"] + ) def test_raises_config_error_when_resolver_fails(self): - with patch(_RESOLVER, side_effect=RuntimeError("both sources failed")): + with patch(_RESOLVER) as mock_get_resolver: + mock_get_resolver.return_value.resolve.side_effect = RuntimeError("both sources failed") with pytest.raises(AgentMemoryConfigError, match="Failed to load Agent Memory configuration"): - _load_config_from_env() + _load_secrets() def test_raises_config_error_when_binding_incomplete(self): - def partial_fill(**kwargs): - kwargs["target"].application_url = "https://memory.example.com" + def partial_fill(module, instance, target): + target.application_url = "https://memory.example.com" # uaa remains empty → validate() raises - with patch(_RESOLVER, side_effect=partial_fill): + with patch(_RESOLVER) as mock_get_resolver: + mock_get_resolver.return_value.resolve.side_effect = partial_fill with pytest.raises(AgentMemoryConfigError, match="uaa"): - _load_config_from_env() + _load_secrets() - def test_raises_config_error_when_uaa_json_invalid(self, monkeypatch): - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", "https://memory.example.com") - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", "not-valid-json") + def test_raises_config_error_when_uaa_json_invalid(self): + def fill_invalid_uaa(module, instance, target): + target.application_url = "https://memory.example.com" + target.uaa = "not-valid-json" - with patch("os.stat", side_effect=FileNotFoundError("no mount")): + with patch(_RESOLVER) as mock_get_resolver: + mock_get_resolver.return_value.resolve.side_effect = fill_invalid_uaa with pytest.raises(AgentMemoryConfigError, match="Failed to parse uaa JSON"): - _load_config_from_env() + _load_secrets() diff --git a/tests/core/unit/auditlog/unit/test_config.py b/tests/core/unit/auditlog/unit/test_config.py index 63cc7975..d88cf7f7 100644 --- a/tests/core/unit/auditlog/unit/test_config.py +++ b/tests/core/unit/auditlog/unit/test_config.py @@ -7,7 +7,7 @@ from sap_cloud_sdk.core.auditlog.config import ( AuditLogConfig, BindingData, - _load_config_from_env + _load_secrets ) from sap_cloud_sdk.core.auditlog.exceptions import ClientCreationError @@ -194,62 +194,50 @@ def test_is_dataclass(self): class TestLoadConfigFromEnv: - @patch('sap_cloud_sdk.core.secret_resolver.read_from_mount_and_fallback_to_env_var') - def test_load_config_success(self, mock_read): - mock_binding = BindingData( - url="https://service.example.com", - uaa='{"clientid": "test", "clientsecret": "secret", "url": "oauth"}' - ) - - def mock_read_side_effect(*args, **kwargs): - binding_data: BindingData = kwargs["target"] - binding_data.url = mock_binding.url - binding_data.uaa = mock_binding.uaa + @patch('sap_cloud_sdk.core.auditlog.config.get_resolver') + def test_load_config_success(self, mock_get_resolver): + def fill_binding(module, instance, target): + target.url = "https://service.example.com" + target.uaa = '{"clientid": "test", "clientsecret": "secret", "url": "oauth"}' - mock_read.side_effect = mock_read_side_effect + mock_get_resolver.return_value.resolve.side_effect = fill_binding - config = _load_config_from_env() + config = _load_secrets() assert config.client_id == "test" assert config.client_secret == "secret" assert config.oauth_url == "oauth" assert config.service_url == "https://service.example.com" - mock_read.assert_called_once_with( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="auditlog", - instance="default", - target=mock_read.call_args.kwargs["target"] + mock_get_resolver.return_value.resolve.assert_called_once_with( + module="auditlog", instance="default", target=mock_get_resolver.return_value.resolve.call_args[1]["target"] ) - @patch('sap_cloud_sdk.core.secret_resolver.read_from_mount_and_fallback_to_env_var') - def test_load_config_validation_error(self, mock_read): - def mock_read_side_effect(*args, **kwargs): - binding_data: BindingData = kwargs["target"] - binding_data.url = "" - binding_data.uaa = "" + @patch('sap_cloud_sdk.core.auditlog.config.get_resolver') + def test_load_config_validation_error(self, mock_get_resolver): + def fill_binding(module, instance, target): + target.url = "" + target.uaa = "" - mock_read.side_effect = mock_read_side_effect + mock_get_resolver.return_value.resolve.side_effect = fill_binding with pytest.raises(ClientCreationError, match="Failed to load configuration"): - _load_config_from_env() + _load_secrets() - @patch('sap_cloud_sdk.core.secret_resolver.read_from_mount_and_fallback_to_env_var') - def test_load_config_read_exception(self, mock_read): - mock_read.side_effect = Exception("Mount read failed") + @patch('sap_cloud_sdk.core.auditlog.config.get_resolver') + def test_load_config_read_exception(self, mock_get_resolver): + mock_get_resolver.return_value.resolve.side_effect = Exception("Mount read failed") with pytest.raises(ClientCreationError, match="Failed to load configuration"): - _load_config_from_env() + _load_secrets() - @patch('sap_cloud_sdk.core.secret_resolver.read_from_mount_and_fallback_to_env_var') - def test_load_config_invalid_uaa(self, mock_read): - def mock_read_side_effect(*args, **kwargs): - binding_data: BindingData = kwargs["target"] - binding_data.url = "https://service.example.com" - binding_data.uaa = "invalid json" + @patch('sap_cloud_sdk.core.auditlog.config.get_resolver') + def test_load_config_invalid_uaa(self, mock_get_resolver): + def fill_binding(module, instance, target): + target.url = "https://service.example.com" + target.uaa = "invalid json" - mock_read.side_effect = mock_read_side_effect + mock_get_resolver.return_value.resolve.side_effect = fill_binding with pytest.raises(ClientCreationError, match="Failed to load configuration"): - _load_config_from_env() + _load_secrets() diff --git a/tests/core/unit/auditlog/unit/test_create_client.py b/tests/core/unit/auditlog/unit/test_create_client.py index f3b86a20..e85555db 100644 --- a/tests/core/unit/auditlog/unit/test_create_client.py +++ b/tests/core/unit/auditlog/unit/test_create_client.py @@ -11,7 +11,7 @@ class TestCreateClient: - @patch('sap_cloud_sdk.core.auditlog._load_config_from_env') + @patch('sap_cloud_sdk.core.auditlog._load_secrets') @patch('sap_cloud_sdk.core.auditlog.HttpTransport') def test_create_client_cloud_mode(self, mock_http_transport, mock_load_config): mock_config = AuditLogConfig( @@ -50,14 +50,14 @@ def test_create_client_with_custom_config(self, mock_http_transport): mock_http_transport.assert_called_once_with(custom_config) assert client._transport == mock_transport - @patch('sap_cloud_sdk.core.auditlog._load_config_from_env') + @patch('sap_cloud_sdk.core.auditlog._load_secrets') def test_create_client_config_loading_exception(self, mock_load_config): mock_load_config.side_effect = Exception("Config loading failed") with pytest.raises(ClientCreationError, match="Failed to create audit log client"): create_client() - @patch('sap_cloud_sdk.core.auditlog._load_config_from_env') + @patch('sap_cloud_sdk.core.auditlog._load_secrets') @patch('sap_cloud_sdk.core.auditlog.HttpTransport') def test_create_client_http_transport_exception(self, mock_http_transport, mock_load_config): mock_config = AuditLogConfig( @@ -87,7 +87,7 @@ def test_create_client_custom_config_transport_exception(self, mock_http_transpo with pytest.raises(ClientCreationError, match="Failed to create audit log client"): create_client(config=custom_config) - @patch('sap_cloud_sdk.core.auditlog._load_config_from_env') + @patch('sap_cloud_sdk.core.auditlog._load_secrets') @patch('sap_cloud_sdk.core.auditlog.HttpTransport') @patch('sap_cloud_sdk.core.auditlog.AuditLogClient') def test_create_client_client_creation_exception(self, mock_client_class, mock_http_transport, mock_load_config): diff --git a/tests/core/unit/data_anonymization/test_config.py b/tests/core/unit/data_anonymization/test_config.py index f4175ca0..ae9da5df 100644 --- a/tests/core/unit/data_anonymization/test_config.py +++ b/tests/core/unit/data_anonymization/test_config.py @@ -2,18 +2,17 @@ import base64 from dataclasses import is_dataclass -import importlib +from unittest.mock import patch + +import pytest from sap_cloud_sdk.core.data_anonymization.config import ( DataAnonymizationConfig, _BindingData, - _load_config_from_env, + _load_secrets, ) from sap_cloud_sdk.core.data_anonymization.exceptions import ClientCreationError - -secret_resolver = importlib.import_module("sap_cloud_sdk.core.secret_resolver") - CLIENT_CERT_BASE64 = base64.b64encode( b"-----BEGIN CERTIFICATE-----\nCERT\n-----END CERTIFICATE-----\n" ).decode("utf-8") @@ -125,41 +124,26 @@ def test_extract_config(self) -> None: class TestLoadConfigFromEnv: - def test_load_config_success(self, monkeypatch) -> None: - def fake_read(mount_path, env_var, service, instance, binding_data): - assert mount_path == "/etc/secrets/appfnd" - assert env_var == "CLOUD_SDK_CFG" - assert service == "data-anonymization" + def test_load_config_success(self) -> None: + def fake_resolve(module, instance, target): + assert module == "data-anonymization" assert instance == "custom-instance" - binding_data.url = "https://service.example.com" - binding_data.destination_name = "anon-destination" - - monkeypatch.setattr( - secret_resolver, - "read_from_mount_and_fallback_to_env_var", - fake_read, - ) + target.url = "https://service.example.com" + target.destination_name = "anon-destination" - config = _load_config_from_env("custom-instance") + with patch("sap_cloud_sdk.core.data_anonymization.config.get_resolver") as mock_get_resolver: + mock_get_resolver.return_value.resolve.side_effect = fake_resolve + config = _load_secrets("custom-instance") assert config.service_url == "https://service.example.com" assert config.destination_name == "anon-destination" - def test_load_config_failure_wraps_exception( - self, - monkeypatch, - ) -> None: - def fake_read(*args, **kwargs): - raise RuntimeError("read failed") - - monkeypatch.setattr( - secret_resolver, - "read_from_mount_and_fallback_to_env_var", - fake_read, - ) + def test_load_config_failure_wraps_exception(self) -> None: + with patch("sap_cloud_sdk.core.data_anonymization.config.get_resolver") as mock_get_resolver: + mock_get_resolver.return_value.resolve.side_effect = RuntimeError("read failed") - assert_raises( - ClientCreationError, - "Failed to load configuration", - _load_config_from_env, - ) + assert_raises( + ClientCreationError, + "Failed to load configuration", + _load_secrets, + ) diff --git a/tests/core/unit/data_anonymization/test_create_client.py b/tests/core/unit/data_anonymization/test_create_client.py index 8458ce43..5a4d4c60 100644 --- a/tests/core/unit/data_anonymization/test_create_client.py +++ b/tests/core/unit/data_anonymization/test_create_client.py @@ -46,7 +46,7 @@ def test_create_client_uses_loaded_config( monkeypatch.setattr( data_anonymization, - "_load_config_from_env", + "_load_secrets", lambda instance: config, ) monkeypatch.setattr(data_anonymization, "HttpTransport", lambda resolved: transport) @@ -78,7 +78,7 @@ def test_create_client_wraps_errors(self, monkeypatch) -> None: def fail(instance: str): raise RuntimeError("boom") - monkeypatch.setattr(data_anonymization, "_load_config_from_env", fail) + monkeypatch.setattr(data_anonymization, "_load_secrets", fail) assert_raises( ClientCreationError, @@ -118,7 +118,7 @@ def test_create_client_records_error_metric_on_failure(self, monkeypatch) -> Non def fail(instance: str): raise RuntimeError("boom") - monkeypatch.setattr(data_anonymization, "_load_config_from_env", fail) + monkeypatch.setattr(data_anonymization, "_load_secrets", fail) with patch( "sap_cloud_sdk.core.telemetry.metrics_decorator.record_error_metric" diff --git a/tests/core/unit/secret_resolver/unit/test_secret_resolver.py b/tests/core/unit/secret_resolver/unit/test_secret_resolver.py index f02445ff..baa2c257 100644 --- a/tests/core/unit/secret_resolver/unit/test_secret_resolver.py +++ b/tests/core/unit/secret_resolver/unit/test_secret_resolver.py @@ -2,10 +2,22 @@ import os from dataclasses import dataclass, field -from unittest.mock import patch, mock_open +from unittest.mock import patch, mock_open, MagicMock import pytest -from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var +from sap_cloud_sdk.core.secret_resolver import ( + read_from_mount_and_fallback_to_env_var, + MountResolver, + EnvVarResolver, + ChainedResolver, + Resolver, + SdkConfig, + configure, + get_sdk_config, + get_resolver, + resolve_base_mount, +) +from sap_cloud_sdk.core.secret_resolver.sdk_config import _reset_sdk_config @dataclass @@ -20,7 +32,11 @@ class NonStringConfig: count: int = 0 -class TestSecretResolver: +# --------------------------------------------------------------------------- +# Legacy function-based API +# --------------------------------------------------------------------------- + +class TestLegacyResolver: def test_validate_inputs_empty_module(self): config = SampleConfig() @@ -48,9 +64,8 @@ def test_load_from_mount_success(self, mock_file, mock_stat, mock_isdir): mock_file.side_effect = [ mock_open(read_data="test_user").return_value, mock_open(read_data="test_pass").return_value, - mock_open(read_data="test_endpoint").return_value + mock_open(read_data="test_endpoint").return_value, ] - config = SampleConfig() read_from_mount_and_fallback_to_env_var("/secrets", "VAR", "module", "instance", config) @@ -82,7 +97,7 @@ def test_validate_path_not_directory(self, mock_stat, mock_isdir): @patch.dict(os.environ, { "VAR_MODULE_INSTANCE_USER": "env_user", "VAR_MODULE_INSTANCE_PASSWORD": "env_pass", - "VAR_MODULE_INSTANCE_ENDPOINT": "env_endpoint" + "VAR_MODULE_INSTANCE_ENDPOINT": "env_endpoint", }) def test_load_from_env_success(self): config = SampleConfig() @@ -109,9 +124,8 @@ def test_mount_success_no_env_fallback(self, mock_file, mock_stat, mock_isdir): mock_file.side_effect = [ mock_open(read_data="mount_user").return_value, mock_open(read_data="mount_pass").return_value, - mock_open(read_data="mount_endpoint").return_value + mock_open(read_data="mount_endpoint").return_value, ] - config = SampleConfig() read_from_mount_and_fallback_to_env_var("/secrets", "VAR", "module", "instance", config) @@ -132,9 +146,8 @@ def test_preserves_newlines(self, mock_file, mock_stat, mock_isdir): mock_file.side_effect = [ mock_open(read_data="user\nwith\nnewlines").return_value, mock_open(read_data="pass").return_value, - mock_open(read_data="endpoint").return_value + mock_open(read_data="endpoint").return_value, ] - config = SampleConfig() read_from_mount_and_fallback_to_env_var("/secrets", "VAR", "module", "instance", config) @@ -160,9 +173,8 @@ def test_metadata_secret_priority(self, mock_file, mock_stat, mock_isdir): mock_file.side_effect = [ mock_open(read_data="metadata_user").return_value, mock_open(read_data="field_pass").return_value, - mock_open(read_data="field_endpoint").return_value + mock_open(read_data="field_endpoint").return_value, ] - config = SampleConfig() read_from_mount_and_fallback_to_env_var("/secrets", "VAR", "module", "instance", config) @@ -174,7 +186,6 @@ def test_metadata_secret_priority(self, mock_file, mock_stat, mock_isdir): "VAR_MODULE_MY_INSTANCE_ENDPOINT": "env_endpoint_hyphen", }) def test_env_instance_name_hyphen_normalization(self): - # Given instance name with hyphen, the resolver should replace '-' with '_' config = SampleConfig() with patch('os.path.isdir', return_value=False), \ patch('os.stat', side_effect=FileNotFoundError()): @@ -199,8 +210,8 @@ def test_service_binding_root_overrides_base_mount(self, mock_file, mock_stat, m config = SampleConfig() read_from_mount_and_fallback_to_env_var("/etc/secrets/appfnd", "VAR", "module", "instance", config) first_call_path = mock_file.call_args_list[0][0][0] - # With SERVICE_BINDING_ROOT set, flat path is tried first: $ROOT// - assert first_call_path == "/custom/root/module/user" + # SERVICE_BINDING_ROOT overrides the base; path is still $ROOT/// + assert first_call_path == "/custom/root/module/instance/user" @patch.dict(os.environ, {}, clear=True) @patch('os.path.isdir', return_value=True) @@ -215,43 +226,281 @@ def test_default_base_mount_used_when_no_service_binding_root(self, mock_file, m config = SampleConfig() read_from_mount_and_fallback_to_env_var("/etc/secrets/appfnd", "VAR", "module", "instance", config) first_call_path = mock_file.call_args_list[0][0][0] - # Without SERVICE_BINDING_ROOT, only the legacy $ROOT/// path is tried assert first_call_path == "/etc/secrets/appfnd/module/instance/user" - @patch.dict(os.environ, {"SERVICE_BINDING_ROOT": "/bindings"}) + +# --------------------------------------------------------------------------- +# resolve_base_mount +# --------------------------------------------------------------------------- + +class TestResolveBaseMount: + + @patch.dict(os.environ, {"SERVICE_BINDING_ROOT": "/from/env"}) + def test_returns_env_var_when_set(self): + assert resolve_base_mount("/default") == "/from/env" + + @patch.dict(os.environ, {}, clear=True) + def test_returns_default_when_env_var_absent(self): + assert resolve_base_mount("/my/default") == "/my/default" + + @patch.dict(os.environ, {}, clear=True) + def test_returns_module_constant_by_default(self): + from sap_cloud_sdk.core.secret_resolver.constants import BASE_MOUNT_PATH + assert resolve_base_mount() == BASE_MOUNT_PATH + + +# --------------------------------------------------------------------------- +# MountResolver +# --------------------------------------------------------------------------- + +class TestMountResolver: + @patch('os.path.isdir', return_value=True) @patch('os.stat') @patch('builtins.open', new_callable=mock_open) - def test_service_binding_root_flat_path_success(self, mock_file, mock_stat, mock_isdir): + def test_resolve_success(self, mock_file, mock_stat, mock_isdir): mock_file.side_effect = [ - mock_open(read_data="flat_user").return_value, - mock_open(read_data="flat_pass").return_value, - mock_open(read_data="flat_endpoint").return_value, + mock_open(read_data="u").return_value, + mock_open(read_data="p").return_value, + mock_open(read_data="e").return_value, ] config = SampleConfig() - read_from_mount_and_fallback_to_env_var("/etc/secrets/appfnd", "VAR", "module", "instance", config) + MountResolver(base_volume_mount="/secrets").resolve("mod", "inst", config) + + assert config.username == "u" + assert config.password == "p" + assert config.endpoint == "e" + + @patch('os.stat', side_effect=FileNotFoundError("not found")) + def test_resolve_raises_when_dir_missing(self, mock_stat): + config = SampleConfig() + with pytest.raises(FileNotFoundError): + MountResolver(base_volume_mount="/noexist").resolve("mod", "inst", config) + + @patch('os.path.isdir', return_value=False) + @patch('os.stat') + def test_resolve_raises_when_path_not_directory(self, mock_stat, mock_isdir): + config = SampleConfig() + with pytest.raises(NotADirectoryError): + MountResolver(base_volume_mount="/file").resolve("mod", "inst", config) + + @patch.dict(os.environ, {"SERVICE_BINDING_ROOT": "/override"}) + @patch('os.path.isdir', return_value=True) + @patch('os.stat') + @patch('builtins.open', new_callable=mock_open) + def test_service_binding_root_overrides_constructor_path(self, mock_file, mock_stat, mock_isdir): + mock_file.side_effect = [ + mock_open(read_data="x").return_value, + mock_open(read_data="y").return_value, + mock_open(read_data="z").return_value, + ] + config = SampleConfig() + MountResolver(base_volume_mount="/original").resolve("mod", "inst", config) first_call_path = mock_file.call_args_list[0][0][0] - # Flat path $ROOT// is tried first - assert first_call_path == "/bindings/module/user" - assert config.username == "flat_user" + assert first_call_path.startswith("/override/") - @patch.dict(os.environ, {"SERVICE_BINDING_ROOT": "/bindings"}) @patch('os.path.isdir', return_value=True) @patch('os.stat') - def test_service_binding_root_flat_fails_falls_back_to_module_instance(self, mock_stat, mock_isdir): - # Flat path: directory exists but field files are not there (old AppFND structure) - # Full path: files are present under // - flat_not_found = FileNotFoundError("flat file missing") - mock_file_calls = [ - flat_not_found, # flat: /user → not found - mock_open(read_data="legacy_user").return_value, # full: //user - mock_open(read_data="legacy_pass").return_value, # full: //password - mock_open(read_data="legacy_ep").return_value, # full: //endpoint + @patch('builtins.open', new_callable=mock_open) + def test_uses_default_base_mount_path(self, mock_file, mock_stat, mock_isdir): + from sap_cloud_sdk.core.secret_resolver.constants import BASE_MOUNT_PATH + mock_file.side_effect = [ + mock_open(read_data="u").return_value, + mock_open(read_data="p").return_value, + mock_open(read_data="e").return_value, ] - with patch('builtins.open', side_effect=mock_file_calls): - config = SampleConfig() - read_from_mount_and_fallback_to_env_var("/bindings", "VAR", "module", "instance", config) + config = SampleConfig() + with patch.dict(os.environ, {}, clear=True): + MountResolver().resolve("mod", "inst", config) + first_call_path = mock_file.call_args_list[0][0][0] + assert first_call_path.startswith(BASE_MOUNT_PATH) + + +# --------------------------------------------------------------------------- +# EnvVarResolver +# --------------------------------------------------------------------------- + +class TestEnvVarResolver: + + @patch.dict(os.environ, { + "CLOUD_SDK_CFG_MOD_INST_USER": "eu", + "CLOUD_SDK_CFG_MOD_INST_PASSWORD": "ep", + "CLOUD_SDK_CFG_MOD_INST_ENDPOINT": "ee", + }) + def test_resolve_success(self): + config = SampleConfig() + EnvVarResolver().resolve("mod", "inst", config) + + assert config.username == "eu" + assert config.password == "ep" + assert config.endpoint == "ee" + + @patch.dict(os.environ, { + "MYPREFIX_MOD_INST_USER": "cu", + "MYPREFIX_MOD_INST_PASSWORD": "cp", + "MYPREFIX_MOD_INST_ENDPOINT": "ce", + }) + def test_custom_base_var_name(self): + config = SampleConfig() + EnvVarResolver(base_var_name="MYPREFIX").resolve("mod", "inst", config) + + assert config.username == "cu" + + @patch.dict(os.environ, { + "CLOUD_SDK_CFG_MOD_MY_INST_USER": "hu", + "CLOUD_SDK_CFG_MOD_MY_INST_PASSWORD": "hp", + "CLOUD_SDK_CFG_MOD_MY_INST_ENDPOINT": "he", + }) + def test_hyphen_normalization_in_instance(self): + config = SampleConfig() + EnvVarResolver().resolve("mod", "my-inst", config) + + assert config.username == "hu" + + @patch.dict(os.environ, { + "CLOUD_SDK_CFG_MY_MOD_INST_USER": "mu", + "CLOUD_SDK_CFG_MY_MOD_INST_PASSWORD": "mp", + "CLOUD_SDK_CFG_MY_MOD_INST_ENDPOINT": "me", + }) + def test_hyphen_normalization_in_module(self): + config = SampleConfig() + EnvVarResolver().resolve("my-mod", "inst", config) + + assert config.username == "mu" + + @patch.dict(os.environ, {}, clear=True) + def test_resolve_raises_when_var_missing(self): + config = SampleConfig() + with pytest.raises(KeyError, match="env var not found"): + EnvVarResolver().resolve("mod", "inst", config) + + +# --------------------------------------------------------------------------- +# ChainedResolver +# --------------------------------------------------------------------------- + +class TestChainedResolver: + + def test_empty_resolvers_raises(self): + with pytest.raises(ValueError, match="resolvers list must not be empty"): + ChainedResolver([]) + + def test_non_dataclass_target_raises(self): + resolver = ChainedResolver([MountResolver()]) + with pytest.raises(TypeError, match="target must be a dataclass instance"): + resolver.resolve("mod", "inst", "not_a_dataclass") + + def test_non_string_field_raises(self): + resolver = ChainedResolver([MountResolver()]) + config = NonStringConfig() + with pytest.raises(TypeError, match="is not a string"): + resolver.resolve("mod", "inst", config) + + def test_first_resolver_succeeds(self): + first = MagicMock(spec=Resolver) + second = MagicMock(spec=Resolver) + chain = ChainedResolver([first, second]) + config = SampleConfig() + + chain.resolve("mod", "inst", config) + + first.resolve.assert_called_once_with("mod", "inst", config) + second.resolve.assert_not_called() + + def test_falls_back_to_second_on_first_failure(self): + first = MagicMock(spec=Resolver) + first.resolve.side_effect = RuntimeError("first failed") + second = MagicMock(spec=Resolver) + chain = ChainedResolver([first, second]) + config = SampleConfig() + + chain.resolve("mod", "inst", config) + + first.resolve.assert_called_once() + second.resolve.assert_called_once_with("mod", "inst", config) + + def test_raises_runtime_error_when_all_resolvers_fail(self): + first = MagicMock(spec=Resolver) + first.resolve.side_effect = RuntimeError("first failed") + second = MagicMock(spec=Resolver) + second.resolve.side_effect = KeyError("second failed") + chain = ChainedResolver([first, second]) + config = SampleConfig() + + with pytest.raises(RuntimeError, match="failed to read secrets from all resolvers"): + chain.resolve("mod", "inst", config) + + def test_aggregated_error_contains_all_failures(self): + first = MagicMock(spec=Resolver) + first.resolve.side_effect = RuntimeError("mount error") + second = MagicMock(spec=Resolver) + second.resolve.side_effect = KeyError("env error") + chain = ChainedResolver([first, second]) + config = SampleConfig() + + with pytest.raises(RuntimeError) as exc_info: + chain.resolve("mod", "inst", config) + + msg = str(exc_info.value) + assert "mount error" in msg + assert "env error" in msg + + def test_custom_base_var_name_appears_in_error(self): + resolver = MagicMock(spec=Resolver) + resolver.resolve.side_effect = RuntimeError("fail") + chain = ChainedResolver([resolver], base_var_name="MY_APP_CFG") + config = SampleConfig() + + with pytest.raises(RuntimeError, match="MY_APP_CFG"): + chain.resolve("mod", "inst", config) + + +# --------------------------------------------------------------------------- +# SdkConfig / configure / get_resolver / reset_sdk_config +# --------------------------------------------------------------------------- + +class TestSdkConfig: + + def teardown_method(self): + _reset_sdk_config() + + def test_get_sdk_config_is_none_by_default(self): + assert get_sdk_config() is None + + def test_configure_sets_global_config(self): + cfg = SdkConfig() + configure(cfg) + assert get_sdk_config() is cfg + + def test_reset_clears_config(self): + configure(SdkConfig()) + _reset_sdk_config() + assert get_sdk_config() is None + + def test_configure_replaces_previous_config(self): + first = SdkConfig() + second = SdkConfig() + configure(first) + configure(second) + assert get_sdk_config() is second + + def test_get_resolver_returns_default_chain_when_no_config(self): + resolver = get_resolver() + assert isinstance(resolver, ChainedResolver) + + def test_get_resolver_returns_default_chain_when_config_has_no_resolver(self): + configure(SdkConfig(resolver=None)) + resolver = get_resolver() + assert isinstance(resolver, ChainedResolver) + + def test_get_resolver_returns_custom_resolver_when_configured(self): + custom = MagicMock(spec=Resolver) + configure(SdkConfig(resolver=custom)) + assert get_resolver() is custom - assert config.username == "legacy_user" - assert config.password == "legacy_pass" - assert config.endpoint == "legacy_ep" + def test_get_resolver_default_chain_contains_mount_and_env(self): + resolver = get_resolver() + assert isinstance(resolver, ChainedResolver) + # Verify it contains MountResolver and EnvVarResolver by attempting resolution + # (the internal _resolvers list is private, so we check behaviour via the protocol) + assert hasattr(resolver, "resolve") diff --git a/tests/destination/unit/test_config.py b/tests/destination/unit/test_config.py index 1f4c740f..26b8d725 100644 --- a/tests/destination/unit/test_config.py +++ b/tests/destination/unit/test_config.py @@ -5,7 +5,7 @@ from sap_cloud_sdk.destination.config import ( BindingData, - load_from_env_or_mount, + load_secrets, load_transparent_proxy, _TRANSPARENT_PROXY_ENV_VAR, ) @@ -102,10 +102,11 @@ def test_to_binding_transforms_fields(self): class TestLoadFromEnvOrMount: - @patch("sap_cloud_sdk.destination.config.read_from_mount_and_fallback_to_env_var") - def test_load_success_default_instance(self, mock_read): - def fake_read_side_effect(*args, **kwargs): - target = kwargs.get("target") + @patch("sap_cloud_sdk.destination.config.get_resolver") + def test_load_success_default_instance(self, mock_get_resolver): + mock_resolver = MagicMock() + + def fake_resolve(module, instance, target): assert isinstance(target, BindingData) target.clientid = "cid" target.clientsecret = "csecret" @@ -113,9 +114,10 @@ def fake_read_side_effect(*args, **kwargs): target.uri = "https://destination.example.com" target.identityzone = "provider-zone" - mock_read.side_effect = fake_read_side_effect + mock_resolver.resolve.side_effect = fake_resolve + mock_get_resolver.return_value = mock_resolver - sb = load_from_env_or_mount() + sb = load_secrets() assert isinstance(sb, DestinationConfig) assert sb.url == "https://destination.example.com" assert sb.token_url == "https://auth.example.com/oauth/token" @@ -123,76 +125,73 @@ def fake_read_side_effect(*args, **kwargs): assert sb.client_secret == "csecret" assert sb.identityzone == "provider-zone" - # Verify resolver called with expected parameters - assert mock_read.call_count == 1 - _, kwargs = mock_read.call_args - assert kwargs["base_volume_mount"] == "/etc/secrets/appfnd" - assert kwargs["base_var_name"] == "CLOUD_SDK_CFG" - assert kwargs["module"] == "destination" - assert kwargs["instance"] == "default" - assert isinstance(kwargs["target"], BindingData) - - @patch("sap_cloud_sdk.destination.config.read_from_mount_and_fallback_to_env_var") - def test_load_success_custom_instance(self, mock_read): - def fake_read_side_effect(*args, **kwargs): - target = kwargs.get("target") - target.clientid = "cid" # ty: ignore[invalid-assignment] - target.clientsecret = "csecret" # ty: ignore[invalid-assignment] - target.url = "https://auth.example.com" # ty: ignore[invalid-assignment] - target.uri = "https://destination.example.com" # ty: ignore[invalid-assignment] - target.identityzone = "provider-zone" # ty: ignore[invalid-assignment] - - mock_read.side_effect = fake_read_side_effect - - sb = load_from_env_or_mount("custom") + mock_resolver.resolve.assert_called_once_with( + module="destination", instance="default", target=mock_resolver.resolve.call_args[1]["target"] + ) + + @patch("sap_cloud_sdk.destination.config.get_resolver") + def test_load_success_custom_instance(self, mock_get_resolver): + mock_resolver = MagicMock() + + def fake_resolve(module, instance, target): + target.clientid = "cid" + target.clientsecret = "csecret" + target.url = "https://auth.example.com" + target.uri = "https://destination.example.com" + target.identityzone = "provider-zone" + + mock_resolver.resolve.side_effect = fake_resolve + mock_get_resolver.return_value = mock_resolver + + sb = load_secrets("custom") assert isinstance(sb, DestinationConfig) - assert mock_read.call_args[1]["instance"] == "custom" + mock_resolver.resolve.assert_called_once_with( + module="destination", instance="custom", target=mock_resolver.resolve.call_args[1]["target"] + ) - @patch("sap_cloud_sdk.destination.config.read_from_mount_and_fallback_to_env_var") - def test_load_validation_error_propagates_as_config_error(self, mock_read): - def fake_read_side_effect(*args, **kwargs): - target = kwargs.get("target") - # Populate invalid data to trigger BindingData.validate failure - target.clientid = "" # ty: ignore[invalid-assignment] - target.clientsecret = "" # ty: ignore[invalid-assignment] - target.url = "" # ty: ignore[invalid-assignment] - target.uri = "" # ty: ignore[invalid-assignment] + @patch("sap_cloud_sdk.destination.config.get_resolver") + def test_load_validation_error_propagates_as_config_error(self, mock_get_resolver): + mock_resolver = MagicMock() - mock_read.side_effect = fake_read_side_effect + def fake_resolve(module, instance, target): + target.clientid = "" + target.clientsecret = "" + target.url = "" + target.uri = "" + + mock_resolver.resolve.side_effect = fake_resolve + mock_get_resolver.return_value = mock_resolver with pytest.raises(ConfigError, match="failed to load destination configuration"): - load_from_env_or_mount() + load_secrets() + + @patch("sap_cloud_sdk.destination.config.get_resolver") + def test_load_read_exception_wrapped(self, mock_get_resolver): + mock_resolver = MagicMock() + mock_resolver.resolve.side_effect = Exception("Mount read failed") + mock_get_resolver.return_value = mock_resolver - @patch("sap_cloud_sdk.destination.config.read_from_mount_and_fallback_to_env_var") - def test_load_read_exception_wrapped(self, mock_read): - mock_read.side_effect = Exception("Mount read failed") with pytest.raises(ConfigError, match="failed to load destination configuration"): - load_from_env_or_mount() - - @patch("sap_cloud_sdk.destination.config.read_from_mount_and_fallback_to_env_var") - def test_load_error_message_contains_guidance(self, mock_read): - # Simulate aggregated failure inside the resolver (mount + env fallback) with resolver-style message - mock_read.side_effect = RuntimeError( - "module=destination instance=default failed to read secrets: " - "['mount failed: path does not exist: /etc/secrets/appfnd/destination/default;', " - "\"env var failed: 'env var not found: CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID';\"] " - "Secrets could not be loaded from mount or environment. " - "Options: - If running locally and '/etc/secrets/appfnd' is not available, " - "- Provide environment variables like CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID. " - "- Alternatively, mount secrets under /etc/secrets/appfnd/destination/default/ with files for each required key." + load_secrets() + + @patch("sap_cloud_sdk.destination.config.get_resolver") + def test_load_error_message_contains_guidance(self, mock_get_resolver): + mock_resolver = MagicMock() + mock_resolver.resolve.side_effect = RuntimeError( + "module=destination instance=default failed to read secrets from all resolvers: " + "['MountResolver failed: path does not exist: /etc/secrets/appfnd/destination/default;', " + "\"EnvVarResolver failed: 'env var not found: CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID';\"] " + "Options: mount secrets under the service binding path, set environment variables " + "like CLOUD_SDK_CFG_destination_default_ (uppercased), or set VCAP_SERVICES." ) + mock_get_resolver.return_value = mock_resolver + with pytest.raises(ConfigError) as excinfo: - load_from_env_or_mount() + load_secrets() msg = str(excinfo.value) - # Central resolver provides generic actionable guidance assert "failed to load destination configuration" in msg - assert "Secrets could not be loaded from mount or environment." in msg - assert "Options:" in msg - # Generic env var prefix for default instance - assert "CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID" in msg assert "CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID" in msg - # Mount path for default instance should be referenced - assert "/etc/secrets/appfnd/destination/default/" in msg + assert "/etc/secrets/appfnd/destination/default" in msg class TestLoadTransparentProxy: diff --git a/tests/destination/unit/test_init.py b/tests/destination/unit/test_init.py index 6958d5f0..3d4491bf 100644 --- a/tests/destination/unit/test_init.py +++ b/tests/destination/unit/test_init.py @@ -43,7 +43,7 @@ def test_create_client_with_explicit_config(self, mock_http, mock_token_provider mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_client_cloud_mode_default(self, mock_http, mock_token_provider, mock_load_config): @@ -58,7 +58,7 @@ def test_create_client_cloud_mode_default(self, mock_http, mock_token_provider, mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_client_cloud_mode_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): @@ -71,7 +71,7 @@ def test_create_client_cloud_mode_with_instance_name(self, mock_http, mock_token mock_load_config.assert_called_once_with("custom-instance") @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") def test_create_client_config_error(self, mock_load_config): mock_load_config.side_effect = Exception("Config loading failed") with pytest.raises(ClientCreationError) as exc_info: @@ -80,7 +80,7 @@ def test_create_client_config_error(self, mock_load_config): assert "Config loading failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") def test_create_client_token_provider_error(self, mock_token_provider, mock_load_config): mock_load_config.return_value = Mock(spec=DestinationConfig) @@ -91,7 +91,7 @@ def test_create_client_token_provider_error(self, mock_token_provider, mock_load assert "Token provider failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_client_http_error(self, mock_http, mock_token_provider, mock_load_config): @@ -126,7 +126,7 @@ def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): mock_load_config.return_value = Mock(spec=DestinationConfig) @@ -140,7 +140,7 @@ class TestCreateFragmentClient: """Tests for create_fragment_client cloud mode.""" @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_fragment_client_default(self, mock_http, mock_token_provider, mock_load_config): @@ -173,7 +173,7 @@ def test_create_fragment_client_with_explicit_config(self, mock_http, mock_token mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_fragment_client_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): @@ -186,7 +186,7 @@ def test_create_fragment_client_with_instance_name(self, mock_http, mock_token_p mock_load_config.assert_called_once_with("custom-instance") @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") def test_create_fragment_client_config_error(self, mock_load_config): mock_load_config.side_effect = Exception("Config loading failed") with pytest.raises(ClientCreationError) as exc_info: @@ -195,7 +195,7 @@ def test_create_fragment_client_config_error(self, mock_load_config): assert "Config loading failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") def test_create_fragment_client_token_provider_error(self, mock_token_provider, mock_load_config): mock_load_config.return_value = Mock(spec=DestinationConfig) @@ -206,7 +206,7 @@ def test_create_fragment_client_token_provider_error(self, mock_token_provider, assert "Token provider failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_fragment_client_http_error(self, mock_http, mock_token_provider, mock_load_config): @@ -241,7 +241,7 @@ def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): mock_load_config.return_value = Mock(spec=DestinationConfig) @@ -273,7 +273,7 @@ def test_create_certificate_client_with_explicit_config(self, mock_http, mock_to mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_certificate_client_cloud_mode_default(self, mock_http, mock_token_provider, mock_load_config): @@ -288,7 +288,7 @@ def test_create_certificate_client_cloud_mode_default(self, mock_http, mock_toke mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_certificate_client_cloud_mode_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): @@ -301,7 +301,7 @@ def test_create_certificate_client_cloud_mode_with_instance_name(self, mock_http mock_load_config.assert_called_once_with("custom-instance") @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") def test_create_certificate_client_config_error(self, mock_load_config): mock_load_config.side_effect = Exception("Config loading failed") with pytest.raises(ClientCreationError) as exc_info: @@ -310,7 +310,7 @@ def test_create_certificate_client_config_error(self, mock_load_config): assert "Config loading failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") def test_create_certificate_client_token_provider_error(self, mock_token_provider, mock_load_config): mock_load_config.return_value = Mock(spec=DestinationConfig) @@ -321,7 +321,7 @@ def test_create_certificate_client_token_provider_error(self, mock_token_provide assert "Token provider failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_certificate_client_http_error(self, mock_http, mock_token_provider, mock_load_config): @@ -356,7 +356,7 @@ def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.load_secrets") @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): mock_load_config.return_value = Mock(spec=DestinationConfig) diff --git a/tests/objectstore/unit/test_create_client.py b/tests/objectstore/unit/test_create_client.py index 979abbc5..c2cc2e46 100644 --- a/tests/objectstore/unit/test_create_client.py +++ b/tests/objectstore/unit/test_create_client.py @@ -9,20 +9,20 @@ class TestCreateClient: - @patch('sap_cloud_sdk.objectstore.read_from_mount_and_fallback_to_env_var') + @patch('sap_cloud_sdk.objectstore.get_resolver') @patch('sap_cloud_sdk.objectstore.ObjectStoreClient') - def test_create_client_cloud_mode(self, mock_client_class, mock_resolver): + def test_create_client_cloud_mode(self, mock_client_class, mock_get_resolver): mock_client = Mock() mock_client_class.return_value = mock_client result = create_client("production", disable_ssl=True) - mock_resolver.assert_called_once() - call_args = mock_resolver.call_args - assert call_args[1]["module"] == "objectstore" - assert call_args[1]["instance"] == "production" - assert isinstance(call_args[1]["target"], ObjectStoreBindingData) - mock_client_class.assert_called_once_with(call_args[1]["target"], disable_ssl=True) + mock_get_resolver.return_value.resolve.assert_called_once() + call_kwargs = mock_get_resolver.return_value.resolve.call_args[1] + assert call_kwargs["module"] == "objectstore" + assert call_kwargs["instance"] == "production" + assert isinstance(call_kwargs["target"], ObjectStoreBindingData) + mock_client_class.assert_called_once_with(call_kwargs["target"], disable_ssl=True) assert result == mock_client def test_create_client_empty_instance_raises_error(self): diff --git a/tests/print/unit/test_config.py b/tests/print/unit/test_config.py index da636455..5d56ff77 100644 --- a/tests/print/unit/test_config.py +++ b/tests/print/unit/test_config.py @@ -2,11 +2,13 @@ import json import pytest -from unittest.mock import patch +from unittest.mock import patch, MagicMock -from sap_cloud_sdk.print.config import PrintConfig, load_from_env_or_mount +from sap_cloud_sdk.print.config import PrintConfig, load_secrets from sap_cloud_sdk.print.exceptions import ConfigError +_RESOLVER = "sap_cloud_sdk.print.config.get_resolver" + def _uaa_json( clientid="cid", clientsecret="csecret", url="https://auth.example.com" @@ -16,15 +18,15 @@ def _uaa_json( class TestLoadFromEnvOrMount: - @patch("sap_cloud_sdk.print.config.read_from_mount_and_fallback_to_env_var") - def test_returns_print_config(self, mock_resolver): - def fill_binding(*, target, **_): + @patch(_RESOLVER) + def test_returns_print_config(self, mock_get_resolver): + def fill_binding(module, instance, target): target.url = "https://api.eu10.print.services.sap" target.uaa = _uaa_json() - mock_resolver.side_effect = fill_binding + mock_get_resolver.return_value.resolve.side_effect = fill_binding - config = load_from_env_or_mount() + config = load_secrets() assert isinstance(config, PrintConfig) assert config.url == "https://api.eu10.print.services.sap" @@ -32,85 +34,83 @@ def fill_binding(*, target, **_): assert config.client_secret == "csecret" assert config.token_url == "https://auth.example.com/oauth/token" - @patch("sap_cloud_sdk.print.config.read_from_mount_and_fallback_to_env_var") - def test_uses_default_instance(self, mock_resolver): - calls = [] - - def capture(**kwargs): - calls.append(kwargs) - kwargs["target"].url = "https://api.eu10.print.services.sap" - kwargs["target"].uaa = _uaa_json() - - mock_resolver.side_effect = capture + @patch(_RESOLVER) + def test_uses_default_instance(self, mock_get_resolver): + def fill_binding(module, instance, target): + target.url = "https://api.eu10.print.services.sap" + target.uaa = _uaa_json() - load_from_env_or_mount() - assert calls[0]["instance"] == "default" + mock_get_resolver.return_value.resolve.side_effect = fill_binding - @patch("sap_cloud_sdk.print.config.read_from_mount_and_fallback_to_env_var") - def test_uses_provided_instance(self, mock_resolver): - calls = [] + load_secrets() + mock_get_resolver.return_value.resolve.assert_called_once_with( + module="print", instance="default", target=mock_get_resolver.return_value.resolve.call_args[1]["target"] + ) - def capture(**kwargs): - calls.append(kwargs) - kwargs["target"].url = "https://api.eu10.print.services.sap" - kwargs["target"].uaa = _uaa_json() + @patch(_RESOLVER) + def test_uses_provided_instance(self, mock_get_resolver): + def fill_binding(module, instance, target): + target.url = "https://api.eu10.print.services.sap" + target.uaa = _uaa_json() - mock_resolver.side_effect = capture + mock_get_resolver.return_value.resolve.side_effect = fill_binding - load_from_env_or_mount(instance="prod") - assert calls[0]["instance"] == "prod" + load_secrets(instance="prod") + mock_get_resolver.return_value.resolve.assert_called_once_with( + module="print", instance="prod", target=mock_get_resolver.return_value.resolve.call_args[1]["target"] + ) - @patch("sap_cloud_sdk.print.config.read_from_mount_and_fallback_to_env_var") - def test_missing_url_raises_config_error(self, mock_resolver): - def fill_binding(*, target, **_): + @patch(_RESOLVER) + def test_missing_url_raises_config_error(self, mock_get_resolver): + def fill_binding(module, instance, target): target.url = "" target.uaa = _uaa_json() - mock_resolver.side_effect = fill_binding + mock_get_resolver.return_value.resolve.side_effect = fill_binding with pytest.raises(ConfigError, match="failed to load print configuration"): - load_from_env_or_mount() + load_secrets() - @patch("sap_cloud_sdk.print.config.read_from_mount_and_fallback_to_env_var") - def test_invalid_uaa_json_raises_config_error(self, mock_resolver): - def fill_binding(*, target, **_): + @patch(_RESOLVER) + def test_invalid_uaa_json_raises_config_error(self, mock_get_resolver): + def fill_binding(module, instance, target): target.url = "https://api.eu10.print.services.sap" target.uaa = "not-valid-json" - mock_resolver.side_effect = fill_binding + mock_get_resolver.return_value.resolve.side_effect = fill_binding with pytest.raises(ConfigError): - load_from_env_or_mount() + load_secrets() - @patch("sap_cloud_sdk.print.config.read_from_mount_and_fallback_to_env_var") - def test_missing_clientid_raises_config_error(self, mock_resolver): - def fill_binding(*, target, **_): + @patch(_RESOLVER) + def test_missing_clientid_raises_config_error(self, mock_get_resolver): + def fill_binding(module, instance, target): target.url = "https://api.eu10.print.services.sap" target.uaa = _uaa_json(clientid="") - mock_resolver.side_effect = fill_binding + mock_get_resolver.return_value.resolve.side_effect = fill_binding with pytest.raises(ConfigError, match="clientid"): - load_from_env_or_mount() + load_secrets() - @patch("sap_cloud_sdk.print.config.read_from_mount_and_fallback_to_env_var") - def test_missing_clientsecret_raises_config_error(self, mock_resolver): - def fill_binding(*, target, **_): + @patch(_RESOLVER) + def test_missing_clientsecret_raises_config_error(self, mock_get_resolver): + def fill_binding(module, instance, target): target.url = "https://api.eu10.print.services.sap" target.uaa = _uaa_json(clientsecret="") - mock_resolver.side_effect = fill_binding + mock_get_resolver.return_value.resolve.side_effect = fill_binding with pytest.raises(ConfigError, match="clientsecret"): - load_from_env_or_mount() + load_secrets() - @patch("sap_cloud_sdk.print.config.read_from_mount_and_fallback_to_env_var") - def test_missing_uaa_url_raises_config_error(self, mock_resolver): - def fill_binding(*, target, **_): + @patch(_RESOLVER) + def test_missing_uaa_url_raises_config_error(self, mock_get_resolver): + def fill_binding(module, instance, target): target.url = "https://api.eu10.print.services.sap" target.uaa = _uaa_json(url="") - mock_resolver.side_effect = fill_binding + mock_get_resolver.return_value.resolve.side_effect = fill_binding with pytest.raises(ConfigError, match="uaa.url"): - load_from_env_or_mount() + load_secrets()