diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 79cf50666b9a..f109419187a6 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -4,10 +4,15 @@ ### Features Added +* Added support for forced refresh of configurations when using Key Vault references. Adds `secret_refresh_interval` to the `AzureAppConfigurationProvider.load` method. This allows the provider to refresh Key Vault secrets at a specified interval. Is set to 60 seconds by default, and can only be set if using Key Vault references. +* Added support for async `on_refresh_success`. + ### Breaking Changes ### Bugs Fixed +* Fixed a bug where feature flags were using the configuration refresh timer instead of the feature flag refresh timer. + ### Other Changes ## 2.2.0 (2025-08-08) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/assets.json b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json index d4604a689a09..281fbf689a48 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/assets.json +++ b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/appconfiguration/azure-appconfiguration-provider", - "Tag": "python/appconfiguration/azure-appconfiguration-provider_c68d337f0e" + "Tag": "python/appconfiguration/azure-appconfiguration-provider_b13d43b82a" } diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py index 9077a5f27176..37155c810bce 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py @@ -22,17 +22,17 @@ SecretReferenceConfigurationSetting, ) from azure.core.exceptions import AzureError, HttpResponseError -from azure.keyvault.secrets import SecretClient, KeyVaultSecretIdentifier from ._models import AzureAppConfigurationKeyVaultOptions, SettingSelector +from ._key_vault._secret_provider import SecretProvider from ._constants import ( FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY, ) from ._azureappconfigurationproviderbase import ( AzureAppConfigurationProviderBase, + update_correlation_context_header, delay_failure, sdk_allowed_kwargs, - update_correlation_context_header, ) from ._client_manager import ConfigurationClientManager, _ConfigurationClientWrapper as ConfigurationClient from ._user_agent import USER_AGENT @@ -246,46 +246,6 @@ def _buildprovider( return AzureAppConfigurationProvider(**kwargs) -def _resolve_keyvault_reference( - config: "SecretReferenceConfigurationSetting", provider: "AzureAppConfigurationProvider" -) -> str: - # pylint:disable=protected-access - if not (provider._keyvault_credential or provider._keyvault_client_configs or provider._secret_resolver): - raise ValueError( - """ - Either a credential to Key Vault, custom Key Vault client, or a secret resolver must be set to resolve Key - Vault references. - """ - ) - - if config.secret_id is None: - raise ValueError("Key Vault reference must have a uri value.") - - keyvault_identifier = KeyVaultSecretIdentifier(config.secret_id) - - vault_url = keyvault_identifier.vault_url + "/" - - # pylint:disable=protected-access - referenced_client = provider._secret_clients.get(vault_url, None) - - vault_config = provider._keyvault_client_configs.get(vault_url, {}) - credential = vault_config.pop("credential", provider._keyvault_credential) - - if referenced_client is None and credential is not None: - referenced_client = SecretClient(vault_url=vault_url, credential=credential, **vault_config) - provider._secret_clients[vault_url] = referenced_client - - if referenced_client: - secret_value = referenced_client.get_secret(keyvault_identifier.name, version=keyvault_identifier.version).value - if secret_value is not None: - return secret_value - - if provider._secret_resolver: - return provider._secret_resolver(config.secret_id) - - raise ValueError("No Secret Client found for Key Vault reference %s" % (vault_url)) - - class AzureAppConfigurationProvider(AzureAppConfigurationProviderBase): # pylint: disable=too-many-instance-attributes """ Provides a dictionary-like interface to Azure App Configuration settings. Enables loading of sets of configuration @@ -309,8 +269,8 @@ def __init__(self, **kwargs: Any) -> None: max_backoff: int = min(kwargs.pop("max_backoff", 600), interval) self._replica_client_manager = ConfigurationClientManager( - connection_string=kwargs.pop("connection_string", None), - endpoint=kwargs.pop("endpoint", None), + connection_string=kwargs.pop("connection_string"), + endpoint=kwargs.pop("endpoint"), credential=kwargs.pop("credential", None), user_agent=user_agent, retry_total=kwargs.pop("retry_total", 2), @@ -321,7 +281,7 @@ def __init__(self, **kwargs: Any) -> None: load_balancing_enabled=kwargs.pop("load_balancing_enabled", False), **kwargs, ) - self._secret_clients: Dict[str, SecretClient] = {} + self._secret_provider = SecretProvider(**kwargs) self._on_refresh_success: Optional[Callable] = kwargs.pop("on_refresh_success", None) self._on_refresh_error: Optional[Callable[[Exception], None]] = kwargs.pop("on_refresh_error", None) self._configuration_mapper: Optional[Callable] = kwargs.pop("configuration_mapper", None) @@ -334,7 +294,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f replica_count, self._feature_flag_enabled, self._feature_filter_usage, - self._uses_key_vault, + self._secret_provider.uses_key_vault, self._uses_load_balancing, is_failover_request, self._uses_ai_configuration, @@ -424,6 +384,11 @@ def refresh(self, **kwargs) -> None: exception: Optional[Exception] = None is_failover_request = False try: + if ( + self._secret_provider.secret_refresh_timer + and self._secret_provider.secret_refresh_timer.needs_refresh() + ): + self._dict.update(self._secret_provider.refresh_secrets()) self._replica_client_manager.refresh_clients() self._replica_client_manager.find_active_clients() replica_count = self._replica_client_manager.get_client_count() - 1 @@ -465,7 +430,7 @@ def _load_all(self, **kwargs): replica_count, self._feature_flag_enabled, self._feature_filter_usage, - self._uses_key_vault, + self._secret_provider.uses_key_vault, self._uses_load_balancing, is_failover_request, self._uses_ai_configuration, @@ -520,9 +485,13 @@ def _load_all(self, **kwargs): raise exception def _process_configurations(self, configuration_settings: List[ConfigurationSetting]) -> Dict[str, Any]: + # configuration_settings can contain duplicate keys, but they are in priority order, i.e. later settings take + # precedence. Only process the settings with the highest priority (i.e. the last one in the list). + unique_settings = self._deduplicate_settings(configuration_settings) + configuration_settings_processed = {} feature_flags_processed = [] - for settings in configuration_settings: + for settings in unique_settings.values(): if self._configuration_mapper: # If a map function is provided, use it to process the configuration setting self._configuration_mapper(settings) @@ -541,7 +510,7 @@ def _process_configurations(self, configuration_settings: List[ConfigurationSett def _process_key_value(self, config: ConfigurationSetting) -> Any: if isinstance(config, SecretReferenceConfigurationSetting): - return _resolve_keyvault_reference(config, self) + return self._secret_provider.resolve_keyvault_reference(config) # Use the base class helper method for non-KeyVault processing return self._process_key_value_base(config) @@ -558,17 +527,14 @@ def close(self) -> None: """ Closes the connection to Azure App Configuration. """ - for client in self._secret_clients.values(): - client.close() + self._secret_provider.close() self._replica_client_manager.close() def __enter__(self) -> "AzureAppConfigurationProvider": self._replica_client_manager.__enter__() - for client in self._secret_clients.values(): - client.__enter__() + self._secret_provider.__enter__() return self def __exit__(self, *args) -> None: self._replica_client_manager.__exit__() - for client in self._secret_clients.values(): - client.__exit__() + self._secret_provider.__exit__() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py index 4a9fa189c4a7..8e569be299e8 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -290,20 +290,11 @@ def __init__(self, **kwargs: Any) -> None: trim_prefixes: List[str] = kwargs.pop("trim_prefixes", []) self._trim_prefixes: List[str] = sorted(trim_prefixes, key=len, reverse=True) - refresh_on: List[Tuple[str, str]] = kwargs.pop("refresh_on", None) or [] self._watched_settings: Dict[Tuple[str, str], Optional[str]] = { _build_watched_setting(s): None for s in refresh_on } self._refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs) - self._keyvault_credential = kwargs.pop("keyvault_credential", None) - self._secret_resolver = kwargs.pop("secret_resolver", None) - self._keyvault_client_configs = kwargs.pop("keyvault_client_configs", {}) - self._uses_key_vault = ( - self._keyvault_credential is not None - or (self._keyvault_client_configs is not None and len(self._keyvault_client_configs) > 0) - or self._secret_resolver is not None - ) self._feature_flag_enabled = kwargs.pop("feature_flag_enabled", False) self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", [SettingSelector(key_filter="*")]) self._watched_feature_flags: Dict[Tuple[str, str], Optional[str]] = {} @@ -598,3 +589,18 @@ def _update_watched_feature_flags( for feature_flag in feature_flags: watched_feature_flags[(feature_flag.key, feature_flag.label)] = feature_flag.etag return watched_feature_flags + + def _deduplicate_settings( + self, configuration_settings: List[ConfigurationSetting] + ) -> Dict[str, ConfigurationSetting]: + """ + Deduplicates configuration settings by key. + + :param List[ConfigurationSetting] configuration_settings: The list of configuration settings to deduplicate + :return: A dictionary mapping keys to their unique configuration settings + :rtype: Dict[str, ConfigurationSetting] + """ + unique_settings: Dict[str, ConfigurationSetting] = {} + for settings in configuration_settings: + unique_settings[settings.key] = settings + return unique_settings diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/__init__.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/__init__.py new file mode 100644 index 000000000000..56f3c6bd3029 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/__init__.py @@ -0,0 +1,13 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- + +from ._secret_provider import SecretProvider +from ._secret_provider_base import _SecretProviderBase + +__all__ = [ + "SecretProvider", + "_SecretProviderBase", +] diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider.py new file mode 100644 index 000000000000..d147236c1bf4 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider.py @@ -0,0 +1,87 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from typing import Mapping, Any, Dict +from azure.appconfiguration import SecretReferenceConfigurationSetting # type:ignore # pylint:disable=no-name-in-module +from azure.keyvault.secrets import SecretClient, KeyVaultSecretIdentifier +from azure.core.exceptions import ServiceRequestError +from ._secret_provider_base import _SecretProviderBase + +JSON = Mapping[str, Any] + + +class SecretProvider(_SecretProviderBase): + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._secret_clients: Dict[str, SecretClient] = {} + self._keyvault_credential = kwargs.pop("keyvault_credential", None) + self._secret_resolver = kwargs.pop("secret_resolver", None) + self._keyvault_client_configs = kwargs.pop("keyvault_client_configs", {}) + + def resolve_keyvault_reference(self, config: SecretReferenceConfigurationSetting) -> str: + keyvault_identifier, vault_url = self.resolve_keyvault_reference_base(config) + if keyvault_identifier.source_id in self._secret_cache: + _, _, value = self._secret_cache[keyvault_identifier.source_id] + return value + + return self.__get_secret_value(config.key, keyvault_identifier, vault_url) + + def refresh_secrets(self) -> Dict[str, Any]: + secrets = {} + if self.secret_refresh_timer: + original_cache = self._secret_cache.copy() + self._secret_cache.clear() + for source_id, (secret_identifier, key, _) in original_cache.items(): + value = self.__get_secret_value(key, secret_identifier, secret_identifier.vault_url + "/") + self._secret_cache[source_id] = ( + secret_identifier, + key, + value, + ) + secrets[key] = value + self.secret_refresh_timer.reset() + return secrets + + def __get_secret_value(self, key: str, secret_identifier: KeyVaultSecretIdentifier, vault_url: str) -> str: + referenced_client = self._secret_clients.get(vault_url, None) + + vault_config = self._keyvault_client_configs.get(vault_url, {}) + credential = vault_config.pop("credential", self._keyvault_credential) + + if referenced_client is None and credential is not None: + referenced_client = SecretClient(vault_url=vault_url, credential=credential, **vault_config) + self._secret_clients[vault_url] = referenced_client + + secret_value = None + + if referenced_client: + try: + secret_value = referenced_client.get_secret( + secret_identifier.name, version=secret_identifier.version + ).value + except ServiceRequestError as e: + raise ValueError("Failed to retrieve secret from Key Vault") from e + + if self._secret_resolver and secret_value is None: + secret_value = self._secret_resolver(secret_identifier.source_id) + + return self._cache_value(key, secret_identifier, secret_value) + + def close(self) -> None: + """ + Closes the connection to Azure App Configuration. + """ + for client in self._secret_clients.values(): + client.close() + + def __enter__(self) -> "SecretProvider": + for client in self._secret_clients.values(): + client.__enter__() + return self + + def __exit__(self, *args) -> None: + for client in self._secret_clients.values(): + client.__exit__() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider_base.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider_base.py new file mode 100644 index 000000000000..7b1abb9d97dc --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_key_vault/_secret_provider_base.py @@ -0,0 +1,67 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from typing import ( + Mapping, + Any, + TypeVar, + Optional, + Dict, + Tuple, +) +from azure.appconfiguration import SecretReferenceConfigurationSetting # type:ignore # pylint:disable=no-name-in-module +from azure.keyvault.secrets import KeyVaultSecretIdentifier +from .._azureappconfigurationproviderbase import _RefreshTimer + +JSON = Mapping[str, Any] +_T = TypeVar("_T") + + +class _SecretProviderBase: + + def __init__(self, **kwargs: Any) -> None: + # [source_id, (KeyVaultSecretIdentifier, key, value)] + self._secret_cache: Dict[str, Tuple[KeyVaultSecretIdentifier, str, str]] = {} + self.uses_key_vault = ( + "keyvault_credential" in kwargs + or ("keyvault_client_configs" in kwargs and len(kwargs.get("keyvault_client_configs", {})) > 0) + or "secret_resolver" in kwargs + ) + + if kwargs.get("secret_refresh_interval", 60) < 1: + raise ValueError("Secret refresh interval must be greater than 1 second.") + + self.secret_refresh_timer: Optional[_RefreshTimer] = ( + _RefreshTimer(refresh_interval=kwargs.pop("secret_refresh_interval", 60)) + if self.uses_key_vault and "secret_refresh_interval" in kwargs + else None + ) + + def _cache_value(self, key: str, keyvault_identifier: KeyVaultSecretIdentifier, secret_value: Any) -> str: + if secret_value: + self._secret_cache[keyvault_identifier.source_id] = (keyvault_identifier, key, secret_value) + return secret_value + + raise ValueError("No Secret Client found for Key Vault reference %s" % (keyvault_identifier.vault_url)) + + def resolve_keyvault_reference_base( + self, config: SecretReferenceConfigurationSetting + ) -> Tuple[KeyVaultSecretIdentifier, str]: + if not self.uses_key_vault: + raise ValueError( + """ + Either a credential to Key Vault, custom Key Vault client, or a secret resolver must be set to resolve + Key Vault references. + """ + ) + + if config.secret_id is None: + raise ValueError("Key Vault reference must have a uri value.") + + keyvault_identifier = KeyVaultSecretIdentifier(config.secret_id) + + vault_url = keyvault_identifier.vault_url + "/" + + return keyvault_identifier, vault_url diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py index 7f8d9a6b28a2..9121d37bbbfa 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py @@ -24,9 +24,8 @@ SecretReferenceConfigurationSetting, ) from azure.core.exceptions import AzureError, HttpResponseError -from azure.keyvault.secrets.aio import SecretClient -from azure.keyvault.secrets import KeyVaultSecretIdentifier from .._models import AzureAppConfigurationKeyVaultOptions, SettingSelector +from ._key_vault._async_secret_provider import SecretProvider from .._constants import ( FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY, @@ -252,54 +251,6 @@ async def _buildprovider( return AzureAppConfigurationProvider(**kwargs) -async def _resolve_keyvault_reference( - config: "SecretReferenceConfigurationSetting", provider: "AzureAppConfigurationProvider" -) -> str: - # pylint:disable=protected-access - if not (provider._keyvault_credential or provider._keyvault_client_configs or provider._secret_resolver): - raise ValueError( - """ - Either a credential to Key Vault, custom Key Vault client, or a secret resolver must be set to resolve Key - Vault references. - """ - ) - - if config.secret_id is None: - raise ValueError("Key Vault reference must have a uri value.") - - keyvault_identifier = KeyVaultSecretIdentifier(config.secret_id) - - vault_url = keyvault_identifier.vault_url + "/" - - # pylint:disable=protected-access - referenced_client = provider._secret_clients.get(vault_url, None) - - vault_config = provider._keyvault_client_configs.get(vault_url, {}) - credential = vault_config.pop("credential", provider._keyvault_credential) - - if referenced_client is None and credential is not None: - referenced_client = SecretClient(vault_url=vault_url, credential=credential, **vault_config) - provider._secret_clients[vault_url] = referenced_client - - if referenced_client: - secret_value = ( - await referenced_client.get_secret(keyvault_identifier.name, version=keyvault_identifier.version) - ).value - if secret_value is not None: - return secret_value - - if provider._secret_resolver: - resolved = provider._secret_resolver(config.secret_id) - try: - # Secret resolver was async - return await resolved - except TypeError: - # Secret resolver was sync - return resolved - - raise ValueError("No Secret Client found for Key Vault reference %s" % (vault_url)) - - class AzureAppConfigurationProvider(AzureAppConfigurationProviderBase): # pylint: disable=too-many-instance-attributes """ Provides a dictionary-like interface to Azure App Configuration settings. Enables loading of sets of configuration @@ -324,7 +275,7 @@ def __init__(self, **kwargs: Any) -> None: self._replica_client_manager = AsyncConfigurationClientManager( connection_string=kwargs.pop("connection_string", None), - endpoint=kwargs.pop("endpoint", None), + endpoint=kwargs.pop("endpoint"), credential=kwargs.pop("credential", None), user_agent=user_agent, retry_total=kwargs.pop("retry_total", 2), @@ -335,7 +286,7 @@ def __init__(self, **kwargs: Any) -> None: load_balancing_enabled=kwargs.pop("load_balancing_enabled", False), **kwargs, ) - self._secret_clients: Dict[str, SecretClient] = {} + self._secret_provider = SecretProvider(**kwargs) self._on_refresh_success: Optional[Callable] = kwargs.pop("on_refresh_success", None) self._on_refresh_error: Optional[Union[Callable[[Exception], Awaitable[None]], None]] = kwargs.pop( "on_refresh_error", None @@ -354,7 +305,7 @@ async def _attempt_refresh( replica_count, self._feature_flag_enabled, self._feature_filter_usage, - self._uses_key_vault, + self._secret_provider.uses_key_vault, self._uses_load_balancing, is_failover_request, self._uses_ai_configuration, @@ -444,6 +395,11 @@ async def refresh(self, **kwargs) -> None: exception: Optional[Exception] = None is_failover_request = False try: + if ( + self._secret_provider.secret_refresh_timer + and self._secret_provider.secret_refresh_timer.needs_refresh() + ): + self._dict.update(await self._secret_provider.refresh_secrets()) await self._replica_client_manager.refresh_clients() self._replica_client_manager.find_active_clients() replica_count = self._replica_client_manager.get_client_count() - 1 @@ -485,7 +441,7 @@ async def _load_all(self, **kwargs): replica_count, self._feature_flag_enabled, self._feature_filter_usage, - self._uses_key_vault, + self._secret_provider.uses_key_vault, self._uses_load_balancing, is_failover_request, self._uses_ai_configuration, @@ -544,9 +500,13 @@ async def _load_all(self, **kwargs): raise exception async def _process_configurations(self, configuration_settings: List[ConfigurationSetting]) -> Dict[str, Any]: + # configuration_settings can contain duplicate keys, but they are in priority order, i.e. later settings take + # precedence. Only process the settings with the highest priority (i.e. the last one in the list). + unique_settings = self._deduplicate_settings(configuration_settings) + configuration_settings_processed = {} feature_flags_processed = [] - for settings in configuration_settings: + for settings in unique_settings.values(): if self._configuration_mapper: await self._configuration_mapper(settings) if isinstance(settings, FeatureFlagConfigurationSetting): @@ -564,7 +524,7 @@ async def _process_configurations(self, configuration_settings: List[Configurati async def _process_key_value(self, config: ConfigurationSetting) -> Any: if isinstance(config, SecretReferenceConfigurationSetting): - return await _resolve_keyvault_reference(config, self) + return await self._secret_provider.resolve_keyvault_reference(config) # Use the base class helper method for non-KeyVault processing return self._process_key_value_base(config) @@ -581,17 +541,14 @@ async def close(self) -> None: """ Closes the connection to Azure App Configuration. """ - for client in self._secret_clients.values(): - await client.close() + await self._secret_provider.close() await self._replica_client_manager.close() async def __aenter__(self) -> "AzureAppConfigurationProvider": await self._replica_client_manager.__aenter__() - for client in self._secret_clients.values(): - await client.__aenter__() + await self._secret_provider.__aenter__() return self async def __aexit__(self, *args) -> None: await self._replica_client_manager.__aexit__(*args) - for client in self._secret_clients.values(): - await client.__aexit__() + await self._secret_provider.__aexit__(*args) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_key_vault/__init__.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_key_vault/__init__.py new file mode 100644 index 000000000000..96da02cf4e49 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_key_vault/__init__.py @@ -0,0 +1,9 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- + +from ._async_secret_provider import SecretProvider + +__all__ = ["SecretProvider"] diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_key_vault/_async_secret_provider.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_key_vault/_async_secret_provider.py new file mode 100644 index 000000000000..70c61840d087 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_key_vault/_async_secret_provider.py @@ -0,0 +1,93 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +import inspect +from typing import Mapping, Any, Dict +from azure.appconfiguration import SecretReferenceConfigurationSetting # type:ignore # pylint:disable=no-name-in-module +from azure.keyvault.secrets import KeyVaultSecretIdentifier +from azure.keyvault.secrets.aio import SecretClient +from azure.core.exceptions import ServiceRequestError +from ..._key_vault._secret_provider_base import _SecretProviderBase + +JSON = Mapping[str, Any] + + +class SecretProvider(_SecretProviderBase): + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._secret_clients: Dict[str, SecretClient] = {} + self._keyvault_credential = kwargs.pop("keyvault_credential", None) + self._secret_resolver = kwargs.pop("secret_resolver", None) + self._keyvault_client_configs = kwargs.pop("keyvault_client_configs", {}) + + async def resolve_keyvault_reference(self, config: SecretReferenceConfigurationSetting) -> str: + keyvault_identifier, vault_url = self.resolve_keyvault_reference_base(config) + if keyvault_identifier.source_id in self._secret_cache: + _, _, value = self._secret_cache[keyvault_identifier.source_id] + return value + + return await self.__get_secret_value(config.key, keyvault_identifier, vault_url) + + async def refresh_secrets(self) -> Dict[str, Any]: + secrets = {} + if self.secret_refresh_timer: + original_cache = self._secret_cache.copy() + self._secret_cache.clear() + for source_id, (secret_identifier, key, _) in original_cache.items(): + value = await self.__get_secret_value(key, secret_identifier, secret_identifier.vault_url + "/") + self._secret_cache[source_id] = ( + secret_identifier, + key, + value, + ) + secrets[key] = value + self.secret_refresh_timer.reset() + return secrets + + async def __get_secret_value(self, key: str, secret_identifier: KeyVaultSecretIdentifier, vault_url: str) -> str: + referenced_client = self._secret_clients.get(vault_url, None) + + vault_config = self._keyvault_client_configs.get(vault_url, {}) + credential = vault_config.pop("credential", self._keyvault_credential) + + if referenced_client is None and credential is not None: + referenced_client = SecretClient(vault_url=vault_url, credential=credential, **vault_config) + self._secret_clients[vault_url] = referenced_client + + secret_value = None + + if referenced_client: + try: + secret_value = ( + await referenced_client.get_secret(secret_identifier.name, version=secret_identifier.version) + ).value + except ServiceRequestError as e: + raise ValueError("Failed to retrieve secret from Key Vault") from e + + if self._secret_resolver and secret_value is None: + secret_value = self._secret_resolver(secret_identifier.source_id) + if inspect.isawaitable(secret_value): + # Secret resolver was async + # Need to ignore type, mypy doesn't like the callback could return `Never` + secret_value = await secret_value # type: ignore + + return self._cache_value(key, secret_identifier, secret_value) + + async def close(self) -> None: + """ + Closes the connection to Azure App Configuration. + """ + for client in self._secret_clients.values(): + await client.close() + + async def __aenter__(self) -> "SecretProvider": + for client in self._secret_clients.values(): + await client.__aenter__() + return self + + async def __aexit__(self, *args) -> None: + for client in self._secret_clients.values(): + await client.__aexit__() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/key_vault/test_async_secret_provider.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/key_vault/test_async_secret_provider.py new file mode 100644 index 000000000000..172ae3d2d615 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/key_vault/test_async_secret_provider.py @@ -0,0 +1,467 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import unittest +from unittest.mock import Mock, patch, AsyncMock +from azure.appconfiguration import SecretReferenceConfigurationSetting +from azure.appconfiguration.provider.aio._key_vault._async_secret_provider import SecretProvider +from azure.keyvault.secrets.aio import SecretClient +from devtools_testutils.aio import recorded_by_proxy_async +from async_preparers import app_config_decorator_async +from testcase import AppConfigTestCase + +TEST_SECRET_ID = "https://myvault.vault.azure.net/secrets/my_secret" + +TEST_SECRET_ID_VERSION = TEST_SECRET_ID + "/12345" + + +class TestSecretProviderAsync(AppConfigTestCase, unittest.IsolatedAsyncioTestCase): + + async def test_init_with_defaults(self): + """Test initialization of SecretProvider with default parameters.""" + secret_provider = SecretProvider() + + # Verify initialization with defaults + self.assertEqual(len(secret_provider._secret_clients), 0) + self.assertIsNone(secret_provider._keyvault_credential) + self.assertIsNone(secret_provider._secret_resolver) + self.assertEqual(secret_provider._keyvault_client_configs, {}) + self.assertFalse(secret_provider.uses_key_vault) + self.assertIsNone(secret_provider.secret_refresh_timer) + self.assertEqual(len(secret_provider._secret_cache), 0) + + async def test_init_with_keyvault_credential(self): + """Test initialization with a Key Vault credential.""" + mock_credential = Mock() + secret_provider = SecretProvider(keyvault_credential=mock_credential) + + # Verify initialization with a Key Vault credential + self.assertEqual(len(secret_provider._secret_clients), 0) + self.assertEqual(secret_provider._keyvault_credential, mock_credential) + self.assertIsNone(secret_provider._secret_resolver) + self.assertEqual(secret_provider._keyvault_client_configs, {}) + self.assertTrue(secret_provider.uses_key_vault) + + async def test_init_with_secret_resolver(self): + """Test initialization with a secret resolver.""" + mock_resolver = Mock() + secret_provider = SecretProvider(secret_resolver=mock_resolver) + + # Verify initialization with a secret resolver + self.assertEqual(len(secret_provider._secret_clients), 0) + self.assertIsNone(secret_provider._keyvault_credential) + self.assertEqual(secret_provider._secret_resolver, mock_resolver) + self.assertEqual(secret_provider._keyvault_client_configs, {}) + self.assertTrue(secret_provider.uses_key_vault) + + async def test_init_with_keyvault_client_configs(self): + """Test initialization with Key Vault client configurations.""" + client_configs = {"https://myvault.vault.azure.net/": {"retry_total": 3}} + secret_provider = SecretProvider(keyvault_client_configs=client_configs) + + # Verify initialization with Key Vault client configurations + self.assertEqual(len(secret_provider._secret_clients), 0) + self.assertIsNone(secret_provider._keyvault_credential) + self.assertIsNone(secret_provider._secret_resolver) + self.assertEqual(secret_provider._keyvault_client_configs, client_configs) + self.assertTrue(secret_provider.uses_key_vault) + + async def test_init_with_secret_refresh_interval(self): + """Test initialization with a secret refresh interval.""" + mock_credential = Mock() + refresh_interval = 30 + secret_provider = SecretProvider(keyvault_credential=mock_credential, secret_refresh_interval=refresh_interval) + + # Verify initialization with a secret refresh interval + self.assertIsNotNone(secret_provider.secret_refresh_timer) + self.assertTrue(secret_provider.uses_key_vault) + + async def test_resolve_keyvault_reference_with_cached_secret(self): + """Test resolving a Key Vault reference when the secret is in the cache.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID) + + # Create a SecretProvider with a mock credential + secret_provider = SecretProvider(keyvault_credential=Mock()) + key_vault_identifier, _ = secret_provider.resolve_keyvault_reference_base(config) + + # Add to cache + secret_provider._secret_cache[key_vault_identifier.source_id] = ( + key_vault_identifier, + "test-key", + "cached-secret-value", + ) + + # This should return the cached value without calling SecretClient + result = await secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "cached-secret-value") + + async def test_resolve_keyvault_reference_with_cached_secret_version(self): + """Test resolving a Key Vault reference when the secret is in the cache.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a SecretProvider with a mock credential + secret_provider = SecretProvider(keyvault_credential=Mock()) + key_vault_identifier, _ = secret_provider.resolve_keyvault_reference_base(config) + + # Add to cache + secret_provider._secret_cache[key_vault_identifier.source_id] = ( + key_vault_identifier, + "test-key", + "cached-secret-value", + ) + + # This should return the cached value without calling SecretClient + result = await secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "cached-secret-value") + + async def test_resolve_keyvault_reference_with_existing_client(self): + """Test resolving a Key Vault reference with an existing client.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a SecretProvider with a mock credential + mock_credential = Mock() + secret_provider = SecretProvider(keyvault_credential=mock_credential) + + # Create a mock SecretClient + mock_client = Mock() + mock_secret = Mock() + mock_secret.value = "secret-value" + # Set up the get_secret method to return an awaitable that resolves to mock_secret + mock_client.get_secret = AsyncMock(return_value=mock_secret) + + # Add the mock client to the secret_clients dictionary + vault_url = "https://myvault.vault.azure.net/" + secret_provider._secret_clients[vault_url] = mock_client + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + mock_base.return_value = (mock_id_instance, vault_url) + + result = await secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "secret-value") + mock_client.get_secret.assert_called_once_with(mock_id_instance.name, version=mock_id_instance.version) + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "secret-value") + + async def test_resolve_keyvault_reference_with_new_client(self): + """Test resolving a Key Vault reference by creating a new client.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a SecretProvider with a mock credential + mock_credential = Mock() + secret_provider = SecretProvider(keyvault_credential=mock_credential) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Mock SecretClient creation and get_secret method + with patch( + "azure.appconfiguration.provider.aio._key_vault._async_secret_provider.SecretClient" + ) as mock_client_class: + mock_client = Mock() + mock_secret = Mock() + mock_secret.value = "new-secret-value" + mock_client.get_secret = AsyncMock(return_value=mock_secret) + mock_client_class.return_value = mock_client + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + vault_url = "https://myvault.vault.azure.net/" + mock_base.return_value = (mock_id_instance, vault_url) + + result = await secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "new-secret-value") + mock_client_class.assert_called_once_with(vault_url=vault_url, credential=mock_credential) + mock_client.get_secret.assert_called_once_with( + mock_id_instance.name, version=mock_id_instance.version + ) + # Verify the client was cached + self.assertEqual(secret_provider._secret_clients[vault_url], mock_client) + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "new-secret-value") + + async def test_resolve_keyvault_reference_with_secret_resolver(self): + """Test resolving a Key Vault reference using a secret resolver.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a mock secret resolver + mock_resolver = Mock(return_value="resolved-secret-value") + + # Create a SecretProvider with the mock resolver + secret_provider = SecretProvider(secret_resolver=mock_resolver) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + vault_url = "https://myvault.vault.azure.net/" + mock_base.return_value = (mock_id_instance, vault_url) + + result = await secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "resolved-secret-value") + mock_resolver.assert_called_once_with(TEST_SECRET_ID_VERSION) + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "resolved-secret-value") + + async def test_resolve_keyvault_reference_with_async_secret_resolver(self): + """Test resolving a Key Vault reference using an async secret resolver.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a mock async secret resolver + async def async_resolver(secret_id): + return "async-resolved-secret-value" + + # Create a SecretProvider with the mock resolver + secret_provider = SecretProvider(secret_resolver=async_resolver) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + vault_url = "https://myvault.vault.azure.net/" + mock_base.return_value = (mock_id_instance, vault_url) + + result = await secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "async-resolved-secret-value") + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "async-resolved-secret-value") + + async def test_resolve_keyvault_reference_with_client_and_resolver_fallback(self): + """Test falling back to a secret resolver if the client fails to get the secret.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a mock credential and secret resolver + mock_credential = Mock() + mock_resolver = Mock(return_value="fallback-secret-value") + + # Create a SecretProvider with both credential and resolver + secret_provider = SecretProvider(keyvault_credential=mock_credential, secret_resolver=mock_resolver) + + # Create a mock SecretClient that returns None for get_secret + mock_client = Mock() + mock_secret = Mock() + mock_secret.value = None + mock_client.get_secret = AsyncMock(return_value=mock_secret) + + # Add the mock client to the secret_clients dictionary + vault_url = "https://myvault.vault.azure.net/" + secret_provider._secret_clients[vault_url] = mock_client + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + mock_base.return_value = (mock_id_instance, vault_url) + + result = await secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "fallback-secret-value") + mock_client.get_secret.assert_called_once_with(mock_id_instance.name, version=mock_id_instance.version) + mock_resolver.assert_called_once_with(TEST_SECRET_ID_VERSION) + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "fallback-secret-value") + + async def test_resolve_keyvault_reference_no_client_no_resolver(self): + """Test that an error is raised when no client or resolver can resolve the reference.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a SecretProvider with a credential but no clients or resolvers + mock_credential = Mock() + secret_provider = SecretProvider(keyvault_credential=mock_credential) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + mock_base.return_value = (mock_id_instance, "https://othervault.vault.azure.net/") + + # This should raise an error since we have no client for this vault URL + with self.assertRaises(ValueError): + await secret_provider.resolve_keyvault_reference(config) + await secret_provider.close() + + async def test_close(self): + """Test closing the SecretProvider.""" + # Create a SecretProvider with mock clients + secret_provider = SecretProvider() + + # Create mock clients + mock_client1 = Mock() + mock_client1.close = AsyncMock() + mock_client2 = Mock() + mock_client2.close = AsyncMock() + + # Add the mock clients to the secret_clients dictionary + secret_provider._secret_clients = { + "https://vault1.vault.azure.net/": mock_client1, + "https://vault2.vault.azure.net/": mock_client2, + } + + # Call close + await secret_provider.close() + + # Verify both clients were closed + mock_client1.close.assert_called_once() + mock_client2.close.assert_called_once() + + async def test_client_config_specific_credential(self): + """Test that client configuration can specify a specific credential.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create mock credentials + mock_default_credential = Mock(name="default_credential") + mock_specific_credential = Mock(name="specific_credential") + + # Create client configs with a specific credential + client_configs = { + "https://myvault.vault.azure.net/": {"credential": mock_specific_credential, "retry_total": 3} + } + + # Create a SecretProvider with default credential and client configs + secret_provider = SecretProvider( + keyvault_credential=mock_default_credential, keyvault_client_configs=client_configs + ) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Mock SecretClient creation and get_secret method + with patch( + "azure.appconfiguration.provider.aio._key_vault._async_secret_provider.SecretClient" + ) as mock_client_class: + mock_client = Mock() + mock_secret = Mock() + mock_secret.value = "secret-value" + mock_client.get_secret = AsyncMock(return_value=mock_secret) + mock_client_class.return_value = mock_client + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + vault_url = "https://myvault.vault.azure.net/" + mock_base.return_value = (mock_id_instance, vault_url) + + result = await secret_provider.resolve_keyvault_reference(config) + + # Verify the specific credential was used instead of the default + mock_client_class.assert_called_once_with( + vault_url=vault_url, credential=mock_specific_credential, retry_total=3 + ) + # Verify the result + self.assertEqual(result, "secret-value") + + @app_config_decorator_async + @recorded_by_proxy_async + async def test_integration_with_keyvault( + self, appconfiguration_endpoint_string, appconfiguration_keyvault_secret_url + ): + """Test integration with Key Vault.""" + if not appconfiguration_keyvault_secret_url: + self.skipTest("No Key Vault secret URL provided") + + # Get a credential + credential = self.get_credential(SecretClient, is_async=True) + + # Create a SecretProvider with the credential + secret_provider = SecretProvider(keyvault_credential=credential) + + # Create a Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=appconfiguration_keyvault_secret_url) + + # Resolve the reference + secret_value = await secret_provider.resolve_keyvault_reference(config) + + # Verify a value was returned (we can't know the exact value) + self.assertIsNotNone(secret_value) + self.assertTrue(isinstance(secret_value, str)) + + # Verify the secret was cached + self.assertIn(appconfiguration_keyvault_secret_url, secret_provider._secret_cache) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/key_vault/test_async_secret_refresh.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/key_vault/test_async_secret_refresh.py new file mode 100644 index 000000000000..169f8fab3f07 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/key_vault/test_async_secret_refresh.py @@ -0,0 +1,246 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import time +import asyncio +import unittest +from unittest.mock import Mock, patch +from devtools_testutils.aio import recorded_by_proxy_async +from azure.appconfiguration import SecretReferenceConfigurationSetting +from azure.appconfiguration.provider import SettingSelector, WatchKey +from devtools_testutils import recorded_by_proxy +from async_preparers import app_config_aad_decorator_async +from asynctestcase import AppConfigTestCase + + +class TestAsyncSecretRefresh(AppConfigTestCase, unittest.TestCase): + + @app_config_aad_decorator_async + @recorded_by_proxy_async + async def testsecret_refresh_timer( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that secrets are refreshed based on the secret_refresh_interval.""" + + # Create an async mock callback + async def async_callback(): + pass + + mock_callback = Mock(side_effect=async_callback) + + # Create client with key vault reference and secret refresh interval + client = await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_interval=999999, + secret_refresh_interval=1, + ) + + # Verify initial state + assert client["secret"] == "Very secret value" + assert mock_callback.call_count == 0 + + # Mock the refresh method to track calls + with patch.object(client, "refresh") as mock_refresh: + # Wait for the secret refresh interval to pass + await asyncio.sleep(2) + + await client.refresh() + + # Verify refresh was called + assert mock_refresh.call_count >= 1 + + # Wait again to ensure multiple refreshes + await asyncio.sleep(2) + await client.refresh() + + # Should have been called at least twice now + assert mock_refresh.call_count >= 2 + + @app_config_aad_decorator_async + @recorded_by_proxy_async + async def test_secret_refresh_with_updated_values( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that secrets are refreshed with updated values.""" + mock_callback = Mock() + + # Create client with the mock secret resolver + client = await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_on=[WatchKey("secret")], + refresh_interval=1, + secret_refresh_interval=1, # Using a short interval for testing + ) + + # Add a key vault reference to the client (this will use mock resolver) + appconfig_client = self.create_aad_sdk_client(appconfiguration_endpoint_string) + + # Get and modify a key vault reference setting + kv_setting = await appconfig_client.get_configuration_setting(key="secret", label="prod") + assert kv_setting is not None + + # Verify initial value from mock resolver + assert client["secret"] == "Very secret value" + assert kv_setting is not None + assert isinstance(kv_setting, SecretReferenceConfigurationSetting) + # Update the secret_id (which is the value for SecretReferenceConfigurationSetting) + kv_setting.secret_id = appconfiguration_keyvault_secret_url2 + await appconfig_client.set_configuration_setting(kv_setting) + + # Wait for the secret refresh interval to pass + await asyncio.sleep(2) + + # Access the value again to trigger refresh + await client.refresh() + + # Verify the value was updated + assert client["secret"] == "Very secret value 2" + assert mock_callback.call_count >= 1 + + @app_config_aad_decorator_async + @recorded_by_proxy_async + async def test_no_secret_refresh_without_timer( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that secrets are not refreshed if secret_refresh_interval is not set.""" + + # Create an async mock callback + async def async_callback(): + pass + + mock_callback = Mock(side_effect=async_callback) + + # Create client without specifying secret_refresh_interval + client = await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_interval=999999, + ) + + # Verify initial state + assert client["secret"] == "Very secret value" + + # Mock the refresh method to track calls + with patch("time.time") as mock_time: + # Make time.time() return increasing values to simulate passage of time + mock_time.side_effect = [time.time(), time.time() + 100] + + # Access the key vault reference - this shouldn't trigger an auto-refresh since + # we didn't set a secret_refresh_interval + await client.refresh() + + # Access it again to verify no auto-refresh due to secrets timer + await client.refresh() + + # The mock_time should have been called twice (for our side_effect setup) + # but there should be no automatic refresh caused by the secret timer + assert mock_time.call_count == 2 + + @app_config_aad_decorator_async + @recorded_by_proxy_async + async def test_secret_refresh_timer_triggers_refresh( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that the secret refresh timer triggers a refresh after the specified interval.""" + + # Create an async mock callback + async def async_callback(): + pass + + mock_callback = Mock(side_effect=async_callback) + + # Create client with key vault reference and separate refresh intervals + client = await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_interval=999999, + secret_refresh_interval=5, # Secret refresh interval is short + ) + + # Now patch the refresh method and secret_refresh_timer to control behavior + with patch.object(client, "refresh") as mock_refresh: + # Now patch the secret_refresh_timer to control its behavior + with patch.object(client._secret_provider, "secret_refresh_timer") as mock_timer: + # Make needs_refresh() return True to simulate timer expiration + mock_timer.needs_refresh.return_value = True + + # Access a key vault reference which should trigger refresh due to timer + await client.refresh() + + # Verify refresh was called + assert mock_refresh.call_count > 0 + + @app_config_aad_decorator_async + @recorded_by_proxy_async + async def test_secret_refresh_interval_parameter( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that secret_refresh_interval parameter is correctly passed and used.""" + + # Create an async mock callback + async def async_callback(): + pass + + mock_callback = Mock(side_effect=async_callback) + + # Create client with specific secret_refresh_interval + client = await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_interval=999999, + secret_refresh_interval=42, # Use a specific value we can check for + ) + + # Verify the secret refresh timer exists + assert client._secret_provider.secret_refresh_timer is not None + + # We can only verify that it exists, but can't directly access the internal refresh_interval + # as it's a protected attribute + + # Check with no refresh interval to ensure it's properly handled + client2 = await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + # No secret_refresh_interval specified + ) + + # Verify timer is created only when secret_refresh_interval is provided + assert client._secret_provider.secret_refresh_timer is not None + assert client2._secret_provider.secret_refresh_timer is None diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/async_preparers.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/async_preparers.py index ae9882aabd91..4c6c1698eebc 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/async_preparers.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/async_preparers.py @@ -15,6 +15,9 @@ async def wrapper(*args, **kwargs): appconfiguration_keyvault_secret_url = kwargs.pop("appconfiguration_keyvault_secret_url") kwargs["appconfiguration_keyvault_secret_url"] = appconfiguration_keyvault_secret_url + appconfiguration_keyvault_secret_url2 = kwargs.pop("appconfiguration_keyvault_secret_url2") + kwargs["appconfiguration_keyvault_secret_url2"] = appconfiguration_keyvault_secret_url2 + trimmed_kwargs = {k: v for k, v in kwargs.items()} trim_kwargs_from_test_function(func, trimmed_kwargs) @@ -32,6 +35,9 @@ async def wrapper(*args, **kwargs): appconfiguration_keyvault_secret_url = kwargs.pop("appconfiguration_keyvault_secret_url") kwargs["appconfiguration_keyvault_secret_url"] = appconfiguration_keyvault_secret_url + appconfiguration_keyvault_secret_url2 = kwargs.pop("appconfiguration_keyvault_secret_url2") + kwargs["appconfiguration_keyvault_secret_url2"] = appconfiguration_keyvault_secret_url2 + trimmed_kwargs = {k: v for k, v in kwargs.items()} trim_kwargs_from_test_function(func, trimmed_kwargs) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py index f15097610744..bdab2e0670f2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py @@ -9,7 +9,6 @@ from testcase import get_configs from azure.appconfiguration.provider.aio import load from azure.appconfiguration.provider import AzureAppConfigurationKeyVaultOptions -from test_constants import FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY class AppConfigTestCase(AzureRecordedTestCase): @@ -22,7 +21,7 @@ async def create_client(self, **kwargs): else: client = AzureAppConfigurationClient(kwargs["endpoint"], credential) - await setup_configs(client, kwargs.get("keyvault_secret_url")) + await setup_configs(client, kwargs.get("keyvault_secret_url"), kwargs.get("keyvault_secret_url2")) kwargs["user_agent"] = "SDK/Integration" if "endpoint" in kwargs: @@ -46,18 +45,11 @@ def create_sdk_client(appconfiguration_connection_string): ) def create_aad_sdk_client(self, appconfiguration_endpoint_string): - cred = self.get_credential(AzureAppConfigurationClient) + cred = self.get_credential(AzureAppConfigurationClient, is_async=True) return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") -async def setup_configs(client, keyvault_secret_url): +async def setup_configs(client, keyvault_secret_url, keyvault_secret_url2): async with client: - for config in get_configs(keyvault_secret_url): + for config in get_configs(keyvault_secret_url, keyvault_secret_url2): await client.set_configuration_setting(config) - - -def has_feature_flag(client, feature_id, enabled=False): - for feature_flag in client[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY]: - if feature_flag["id"] == feature_id: - return feature_flag["enabled"] == enabled - return False diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/key_vault/test_secret_provider.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/key_vault/test_secret_provider.py new file mode 100644 index 000000000000..50f589de2d4d --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/key_vault/test_secret_provider.py @@ -0,0 +1,420 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import unittest +from unittest.mock import Mock, patch +from azure.appconfiguration import SecretReferenceConfigurationSetting +from azure.appconfiguration.provider._key_vault._secret_provider import SecretProvider +from azure.keyvault.secrets import SecretClient +from devtools_testutils import recorded_by_proxy +from preparers import app_config_decorator_aad +from testcase import AppConfigTestCase + +TEST_SECRET_ID = "https://myvault.vault.azure.net/secrets/my_secret" + +TEST_SECRET_ID_VERSION = TEST_SECRET_ID + "/12345" + + +class TestSecretProvider(AppConfigTestCase, unittest.TestCase): + + def test_init_with_defaults(self): + """Test initialization of SecretProvider with default parameters.""" + secret_provider = SecretProvider() + + # Verify initialization with defaults + self.assertEqual(len(secret_provider._secret_clients), 0) + self.assertIsNone(secret_provider._keyvault_credential) + self.assertIsNone(secret_provider._secret_resolver) + self.assertEqual(secret_provider._keyvault_client_configs, {}) + self.assertFalse(secret_provider.uses_key_vault) + self.assertIsNone(secret_provider.secret_refresh_timer) + self.assertEqual(len(secret_provider._secret_cache), 0) + + def test_init_with_keyvault_credential(self): + """Test initialization with a Key Vault credential.""" + mock_credential = Mock() + secret_provider = SecretProvider(keyvault_credential=mock_credential) + + # Verify initialization with a Key Vault credential + self.assertEqual(len(secret_provider._secret_clients), 0) + self.assertEqual(secret_provider._keyvault_credential, mock_credential) + self.assertIsNone(secret_provider._secret_resolver) + self.assertEqual(secret_provider._keyvault_client_configs, {}) + self.assertTrue(secret_provider.uses_key_vault) + + def test_init_with_secret_resolver(self): + """Test initialization with a secret resolver.""" + mock_resolver = Mock() + secret_provider = SecretProvider(secret_resolver=mock_resolver) + + # Verify initialization with a secret resolver + self.assertEqual(len(secret_provider._secret_clients), 0) + self.assertIsNone(secret_provider._keyvault_credential) + self.assertEqual(secret_provider._secret_resolver, mock_resolver) + self.assertEqual(secret_provider._keyvault_client_configs, {}) + self.assertTrue(secret_provider.uses_key_vault) + + def test_init_with_keyvault_client_configs(self): + """Test initialization with Key Vault client configurations.""" + client_configs = {"https://myvault.vault.azure.net/": {"retry_total": 3}} + secret_provider = SecretProvider(keyvault_client_configs=client_configs) + + # Verify initialization with Key Vault client configurations + self.assertEqual(len(secret_provider._secret_clients), 0) + self.assertIsNone(secret_provider._keyvault_credential) + self.assertIsNone(secret_provider._secret_resolver) + self.assertEqual(secret_provider._keyvault_client_configs, client_configs) + self.assertTrue(secret_provider.uses_key_vault) + + def test_init_with_secret_refresh_interval(self): + """Test initialization with a secret refresh interval.""" + mock_credential = Mock() + refresh_interval = 30 + secret_provider = SecretProvider(keyvault_credential=mock_credential, secret_refresh_interval=refresh_interval) + + # Verify initialization with a secret refresh interval + self.assertIsNotNone(secret_provider.secret_refresh_timer) + self.assertTrue(secret_provider.uses_key_vault) + + def test_resolve_keyvault_reference_with_cached_secret(self): + """Test resolving a Key Vault reference when the secret is in the cache.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID) + + # Create a SecretProvider with a mock credential + secret_provider = SecretProvider(keyvault_credential=Mock()) + key_vault_identifier, _ = secret_provider.resolve_keyvault_reference_base(config) + + # Add to cache + secret_provider._secret_cache[key_vault_identifier.source_id] = ( + key_vault_identifier, + "test-key", + "cached-secret-value", + ) + + # This should return the cached value without calling SecretClient + result = secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "cached-secret-value") + + def test_resolve_keyvault_reference_with_cached_secret_version(self): + """Test resolving a Key Vault reference when the secret is in the cache.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a SecretProvider with a mock credential + secret_provider = SecretProvider(keyvault_credential=Mock()) + key_vault_identifier, _ = secret_provider.resolve_keyvault_reference_base(config) + + # Add to cache + secret_provider._secret_cache[key_vault_identifier.source_id] = ( + key_vault_identifier, + "test-key", + "cached-secret-value", + ) + + # This should return the cached value without calling SecretClient + result = secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "cached-secret-value") + + def test_resolve_keyvault_reference_with_existing_client(self): + """Test resolving a Key Vault reference with an existing client.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a SecretProvider with a mock credential + mock_credential = Mock() + secret_provider = SecretProvider(keyvault_credential=mock_credential) + + # Create a mock SecretClient + mock_client = Mock() + mock_secret = Mock() + mock_secret.value = "secret-value" + mock_client.get_secret.return_value = mock_secret + + # Add the mock client to the secret_clients dictionary + vault_url = "https://myvault.vault.azure.net/" + secret_provider._secret_clients[vault_url] = mock_client + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + mock_base.return_value = (mock_id_instance, vault_url) + + result = secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "secret-value") + mock_client.get_secret.assert_called_once_with(mock_id_instance.name, version=mock_id_instance.version) + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "secret-value") + + def test_resolve_keyvault_reference_with_new_client(self): + """Test resolving a Key Vault reference by creating a new client.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a SecretProvider with a mock credential + mock_credential = Mock() + secret_provider = SecretProvider(keyvault_credential=mock_credential) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Mock SecretClient creation and get_secret method + with patch("azure.appconfiguration.provider._key_vault._secret_provider.SecretClient") as mock_client_class: + mock_client = Mock() + mock_secret = Mock() + mock_secret.value = "new-secret-value" + mock_client.get_secret.return_value = mock_secret + mock_client_class.return_value = mock_client + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + vault_url = "https://myvault.vault.azure.net/" + mock_base.return_value = (mock_id_instance, vault_url) + + result = secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "new-secret-value") + mock_client_class.assert_called_once_with(vault_url=vault_url, credential=mock_credential) + mock_client.get_secret.assert_called_once_with( + mock_id_instance.name, version=mock_id_instance.version + ) + # Verify the client was cached + self.assertEqual(secret_provider._secret_clients[vault_url], mock_client) + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "new-secret-value") + + def test_resolve_keyvault_reference_with_secret_resolver(self): + """Test resolving a Key Vault reference using a secret resolver.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a mock secret resolver + mock_resolver = Mock(return_value="resolved-secret-value") + + # Create a SecretProvider with the mock resolver + secret_provider = SecretProvider(secret_resolver=mock_resolver) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + vault_url = "https://myvault.vault.azure.net/" + mock_base.return_value = (mock_id_instance, vault_url) + + result = secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "resolved-secret-value") + mock_resolver.assert_called_once_with(TEST_SECRET_ID_VERSION) + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "resolved-secret-value") + + def test_resolve_keyvault_reference_with_client_and_resolver_fallback(self): + """Test falling back to a secret resolver if the client fails to get the secret.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a mock credential and secret resolver + mock_credential = Mock() + mock_resolver = Mock(return_value="fallback-secret-value") + + # Create a SecretProvider with both credential and resolver + secret_provider = SecretProvider(keyvault_credential=mock_credential, secret_resolver=mock_resolver) + + # Create a mock SecretClient that returns None for get_secret + mock_client = Mock() + mock_client.get_secret.return_value.value = None + + # Add the mock client to the secret_clients dictionary + vault_url = "https://myvault.vault.azure.net/" + secret_provider._secret_clients[vault_url] = mock_client + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + mock_base.return_value = (mock_id_instance, vault_url) + + result = secret_provider.resolve_keyvault_reference(config) + + # Verify the result + self.assertEqual(result, "fallback-secret-value") + mock_client.get_secret.assert_called_once_with(mock_id_instance.name, version=mock_id_instance.version) + mock_resolver.assert_called_once_with(TEST_SECRET_ID_VERSION) + # Verify the secret was cached + _, _, value = secret_provider._secret_cache[TEST_SECRET_ID_VERSION] + self.assertEqual(value, "fallback-secret-value") + + def test_resolve_keyvault_reference_no_client_no_resolver(self): + """Test that an error is raised when no client or resolver can resolve the reference.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create a SecretProvider with a credential but no clients or resolvers + mock_credential = Mock() + secret_provider = SecretProvider(keyvault_credential=mock_credential) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + mock_base.return_value = (mock_id_instance, "https://othervault.vault.azure.net/") + + # This should raise an error since we have no client for this vault URL + with self.assertRaises(ValueError): + secret_provider.resolve_keyvault_reference(config) + + def test_close(self): + """Test closing the SecretProvider.""" + # Create a SecretProvider with mock clients + secret_provider = SecretProvider() + + # Create mock clients + mock_client1 = Mock() + mock_client2 = Mock() + + # Add the mock clients to the secret_clients dictionary + secret_provider._secret_clients = { + "https://vault1.vault.azure.net/": mock_client1, + "https://vault2.vault.azure.net/": mock_client2, + } + + # Call close + secret_provider.close() + + # Verify both clients were closed + mock_client1.close.assert_called_once() + mock_client2.close.assert_called_once() + + def test_client_config_specific_credential(self): + """Test that client configuration can specify a specific credential.""" + # Create a mock Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=TEST_SECRET_ID_VERSION) + + # Create mock credentials + mock_default_credential = Mock(name="default_credential") + mock_specific_credential = Mock(name="specific_credential") + + # Create client configs with a specific credential + client_configs = { + "https://myvault.vault.azure.net/": {"credential": mock_specific_credential, "retry_total": 3} + } + + # Create a SecretProvider with default credential and client configs + secret_provider = SecretProvider( + keyvault_credential=mock_default_credential, keyvault_client_configs=client_configs + ) + + # Setup key vault identifier mock + with patch("azure.keyvault.secrets.KeyVaultSecretIdentifier") as mock_kv_id: + mock_id_instance = Mock() + mock_id_instance._resource_id = TEST_SECRET_ID_VERSION + mock_id_instance.source_id = TEST_SECRET_ID_VERSION + mock_id_instance.name = "my_secret" + mock_id_instance.version = "12345" + mock_id_instance.vault_url = "https://myvault.vault.azure.net" + mock_kv_id.return_value = mock_id_instance + + # Mock SecretClient creation and get_secret method + with patch("azure.appconfiguration.provider._key_vault._secret_provider.SecretClient") as mock_client_class: + mock_client = Mock() + mock_secret = Mock() + mock_secret.value = "secret-value" + mock_client.get_secret.return_value = mock_secret + mock_client_class.return_value = mock_client + + # Call resolve_keyvault_reference + with patch.object(secret_provider, "resolve_keyvault_reference_base") as mock_base: + vault_url = "https://myvault.vault.azure.net/" + mock_base.return_value = (mock_id_instance, vault_url) + + result = secret_provider.resolve_keyvault_reference(config) + + # Verify the specific credential was used instead of the default + mock_client_class.assert_called_once_with( + vault_url=vault_url, credential=mock_specific_credential, retry_total=3 + ) + # Verify the result + self.assertEqual(result, "secret-value") + + @recorded_by_proxy + @app_config_decorator_aad + def test_integration_with_keyvault(self, appconfiguration_endpoint_string, appconfiguration_keyvault_secret_url): + """Test integration with Key Vault.""" + if not appconfiguration_keyvault_secret_url: + self.skipTest("No Key Vault secret URL provided") + + # Get a credential + credential = self.get_credential(SecretClient) + + # Create a SecretProvider with the credential + secret_provider = SecretProvider(keyvault_credential=credential) + + # Create a Key Vault reference + config = SecretReferenceConfigurationSetting(key="test-key", secret_id=appconfiguration_keyvault_secret_url) + + # Resolve the reference + secret_value = secret_provider.resolve_keyvault_reference(config) + + # Verify a value was returned (we can't know the exact value) + self.assertIsNotNone(secret_value) + self.assertTrue(isinstance(secret_value, str)) + + # Verify the secret was cached + self.assertIn(appconfiguration_keyvault_secret_url, secret_provider._secret_cache) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/key_vault/test_secret_refresh.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/key_vault/test_secret_refresh.py new file mode 100644 index 000000000000..5dd98cc7affc --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/key_vault/test_secret_refresh.py @@ -0,0 +1,223 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import time +import unittest +from unittest.mock import Mock, patch +from azure.appconfiguration import SecretReferenceConfigurationSetting +from azure.appconfiguration.provider import SettingSelector, WatchKey +from devtools_testutils import recorded_by_proxy +from preparers import app_config_decorator_aad +from testcase import AppConfigTestCase + + +class TestSecretRefresh(AppConfigTestCase, unittest.TestCase): + @recorded_by_proxy + @app_config_decorator_aad + def test_secret_refresh_timer( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that secrets are refreshed based on the secret_refresh_interval.""" + mock_callback = Mock() + + # Create client with key vault reference and secret refresh interval + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_interval=999999, + secret_refresh_interval=1, + ) + + # Verify initial state + assert client["secret"] == "Very secret value" + assert mock_callback.call_count == 0 + + # Mock the refresh method to track calls + with patch.object(client, "refresh") as mock_refresh: + # Wait for the secret refresh interval to pass + time.sleep(2) + + client.refresh() + + # Verify refresh was called + assert mock_refresh.call_count >= 1 + + # Wait again to ensure multiple refreshes + time.sleep(2) + client.refresh() + + # Should have been called at least twice now + assert mock_refresh.call_count >= 2 + + @recorded_by_proxy + @app_config_decorator_aad + def test_secret_refresh_with_updated_values( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that secrets are refreshed with updated values.""" + mock_callback = Mock() + + # Create client with the mock secret resolver + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_on=[WatchKey("secret")], + refresh_interval=1, + secret_refresh_interval=1, # Using a short interval for testing + ) + + # Add a key vault reference to the client (this will use mock resolver) + appconfig_client = self.create_aad_sdk_client(appconfiguration_endpoint_string) + + # Get and modify a key vault reference setting + kv_setting = appconfig_client.get_configuration_setting(key="secret", label="prod") + assert kv_setting is not None + + # Verify initial value from mock resolver + assert client["secret"] == "Very secret value" + assert kv_setting is not None + assert isinstance(kv_setting, SecretReferenceConfigurationSetting) + # Update the secret_id (which is the value for SecretReferenceConfigurationSetting) + kv_setting.secret_id = appconfiguration_keyvault_secret_url2 + appconfig_client.set_configuration_setting(kv_setting) + + # Wait for the secret refresh interval to pass + time.sleep(2) + + # Access the value again to trigger refresh + client.refresh() + + # Verify the value was updated + assert client["secret"] == "Very secret value 2" + assert mock_callback.call_count >= 1 + + @recorded_by_proxy + @app_config_decorator_aad + def test_no_secret_refresh_without_timer( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that secrets are not refreshed if secret_refresh_interval is not set.""" + mock_callback = Mock() + + # Create client without specifying secret_refresh_interval + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_interval=999999, + ) + + # Verify initial state + assert client["secret"] == "Very secret value" + + # Mock the refresh method to track calls + with patch("time.time") as mock_time: + # Make time.time() return increasing values to simulate passage of time + mock_time.side_effect = [time.time(), time.time() + 100] + + # Access the key vault reference - this shouldn't trigger an auto-refresh since + # we didn't set a secret_refresh_interval + client.refresh() + + # Access it again to verify no auto-refresh due to secrets timer + client.refresh() + + # The mock_time should have been called twice (for our side_effect setup) + # but there should be no automatic refresh caused by the secret timer + assert mock_time.call_count == 2 + + @recorded_by_proxy + @app_config_decorator_aad + def test_secret_refresh_timer_triggers_refresh( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that the secret refresh timer triggers a refresh after the specified interval.""" + mock_callback = Mock() + + # Create client with key vault reference and separate refresh intervals + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_interval=999999, + secret_refresh_interval=5, # Secret refresh interval is short + ) + + # Now patch the refresh method and secret_refresh_timer to control behavior + with patch.object(client, "refresh") as mock_refresh: + # Now patch the secret_refresh_timer to control its behavior + with patch.object(client._secret_provider, "secret_refresh_timer") as mock_timer: + # Make needs_refresh() return True to simulate timer expiration + mock_timer.needs_refresh.return_value = True + + # Access a key vault reference which should trigger refresh due to timer + client.refresh() + + # Verify refresh was called + assert mock_refresh.call_count > 0 + + @recorded_by_proxy + @app_config_decorator_aad + def test_secret_refresh_interval_parameter( + self, + appconfiguration_endpoint_string, + appconfiguration_keyvault_secret_url, + appconfiguration_keyvault_secret_url2, + ): + """Test that secret_refresh_interval parameter is correctly passed and used.""" + mock_callback = Mock() + + # Create client with specific secret_refresh_interval + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + refresh_interval=999999, + secret_refresh_interval=42, # Use a specific value we can check for + ) + + # Verify the secret refresh timer exists + assert client._secret_provider.secret_refresh_timer is not None + + # We can only verify that it exists, but can't directly access the internal refresh_interval + # as it's a protected attribute + + # Check with no refresh interval to ensure it's properly handled + client2 = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects={SettingSelector(key_filter="*", label_filter="prod")}, + keyvault_secret_url=appconfiguration_keyvault_secret_url, + keyvault_secret_url2=appconfiguration_keyvault_secret_url2, + on_refresh_success=mock_callback, + # No secret_refresh_interval specified + ) + + # Verify timer is created only when secret_refresh_interval is provided + assert client._secret_provider.secret_refresh_timer is not None + assert client2._secret_provider.secret_refresh_timer is None diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/preparers.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/preparers.py index d7512a12f5ce..ec0621a42203 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/preparers.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/preparers.py @@ -12,6 +12,8 @@ "appconfiguration", keyvault_secret_url="https://Sanitized.vault.azure.net/secrets/fake-secret/", appconfiguration_keyvault_secret_url="https://Sanitized.vault.azure.net/secrets/fake-secret/", + keyvault_secret_url2="https://Sanitized.vault.azure.net/secrets/fake-secret2/", + appconfiguration_keyvault_secret_url2="https://Sanitized.vault.azure.net/secrets/fake-secret2/", appconfiguration_connection_string="Endpoint=https://Sanitized.azconfig.io;Id=0-l4-s0:h5htBaY5Z1LwFz50bIQv;Secret=lamefakesecretlamefakesecretlamefakesecrett=", appconfiguration_endpoint_string="https://Sanitized.azconfig.io", ) @@ -26,6 +28,9 @@ def wrapper(*args, **kwargs): appconfiguration_keyvault_secret_url = kwargs.pop("appconfiguration_keyvault_secret_url") kwargs["appconfiguration_keyvault_secret_url"] = appconfiguration_keyvault_secret_url + appconfiguration_keyvault_secret_url2 = kwargs.pop("appconfiguration_keyvault_secret_url2") + kwargs["appconfiguration_keyvault_secret_url2"] = appconfiguration_keyvault_secret_url2 + trimmed_kwargs = {k: v for k, v in kwargs.items()} trim_kwargs_from_test_function(func, trimmed_kwargs) @@ -43,6 +48,9 @@ def wrapper(*args, **kwargs): appconfiguration_keyvault_secret_url = kwargs.pop("appconfiguration_keyvault_secret_url") kwargs["appconfiguration_keyvault_secret_url"] = appconfiguration_keyvault_secret_url + appconfiguration_keyvault_secret_url2 = kwargs.pop("appconfiguration_keyvault_secret_url2") + kwargs["appconfiguration_keyvault_secret_url2"] = appconfiguration_keyvault_secret_url2 + trimmed_kwargs = {k: v for k, v in kwargs.items()} trim_kwargs_from_test_function(func, trimmed_kwargs) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider.py index 7f7ecb57222d..707759d76fdf 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider.py @@ -4,16 +4,18 @@ # license information. # -------------------------------------------------------------------------- from azure.appconfiguration.provider import SettingSelector, AzureAppConfigurationKeyVaultOptions -from azure.appconfiguration.provider.aio import AzureAppConfigurationProvider from devtools_testutils.aio import recorded_by_proxy_async from async_preparers import app_config_decorator_async -from asynctestcase import AppConfigTestCase, has_feature_flag +from testcase import has_feature_flag +from asynctestcase import AppConfigTestCase from test_constants import FEATURE_MANAGEMENT_KEY from unittest.mock import MagicMock, patch -import asyncio from azure.appconfiguration.provider._azureappconfigurationproviderbase import ( update_correlation_context_header, ) +from azure.appconfiguration.provider.aio._azureappconfigurationproviderasync import ( + _buildprovider, +) class TestAppConfigurationProvider(AppConfigTestCase): @@ -130,7 +132,7 @@ async def test_process_key_value_content_type(self, appconfiguration_connection_ ] # Create the provider with the mocked client manager - provider = AzureAppConfigurationProvider(connection_string="mock_connection_string") + provider = await _buildprovider("=mock_connection_string;;", None, None) provider._replica_client_manager = mock_client_manager # Call the method to process key-value pairs diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_aad.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_aad.py index b7b692aec3b8..e223cc247c36 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_aad.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_aad.py @@ -6,7 +6,8 @@ from azure.appconfiguration.provider import SettingSelector, AzureAppConfigurationKeyVaultOptions from devtools_testutils.aio import recorded_by_proxy_async from async_preparers import app_config_decorator_async -from asynctestcase import AppConfigTestCase, has_feature_flag +from testcase import has_feature_flag +from asynctestcase import AppConfigTestCase from test_constants import FEATURE_MANAGEMENT_KEY diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_feature_management.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_feature_management.py index 69e157ddbd44..78905b2a8921 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_feature_management.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_feature_management.py @@ -8,7 +8,8 @@ from azure.appconfiguration.aio import AzureAppConfigurationClient from devtools_testutils.aio import recorded_by_proxy_async from async_preparers import app_config_decorator_async -from asynctestcase import AppConfigTestCase, setup_configs, has_feature_flag +from testcase import has_feature_flag +from asynctestcase import AppConfigTestCase, setup_configs from test_constants import FEATURE_MANAGEMENT_KEY @@ -31,7 +32,7 @@ async def test_load_only_feature_flags(self, appconfiguration_connection_string) @recorded_by_proxy_async async def test_select_feature_flags(self, appconfiguration_connection_string): client = AzureAppConfigurationClient.from_connection_string(appconfiguration_connection_string) - await setup_configs(client, None) + await setup_configs(client, None, None) async with await load( connection_string=appconfiguration_connection_string, diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_refresh.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_refresh.py index d67e1e007e9d..0fb2857f68c2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_refresh.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_refresh.py @@ -11,8 +11,10 @@ from azure.appconfiguration.provider import WatchKey from devtools_testutils.aio import recorded_by_proxy_async from async_preparers import app_config_decorator_async -from asynctestcase import AppConfigTestCase, has_feature_flag +from testcase import has_feature_flag +from asynctestcase import AppConfigTestCase from test_constants import FEATURE_MANAGEMENT_KEY +from unittest.mock import Mock try: # Python 3.7 does not support AsyncMock diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider.py index 45c1587e3e48..c885d6bd15b5 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider.py @@ -19,6 +19,7 @@ delay_failure, update_correlation_context_header, ) +from azure.appconfiguration.provider._azureappconfigurationprovider import _buildprovider def sleep(seconds): @@ -147,7 +148,7 @@ def test_process_key_value_content_type(self): ] # Create the provider with the mocked client manager - provider = AzureAppConfigurationProvider(connection_string="mock_connection_string") + provider = _buildprovider("=mock_connection_string;;", None, None) provider._replica_client_manager = mock_client_manager # Call the method to process key-value pairs diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_backoff.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_backoff.py index 442f0830c92d..ccd542ab21a2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_backoff.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_backoff.py @@ -21,12 +21,12 @@ def test_backoff(self, appconfiguration_connection_string, appconfiguration_keyv assert min_backoff == client._refresh_timer._calculate_backoff() attempts = 2 - client._refresh_timer.attempts = attempts + client._refresh_timer._attempts = attempts backoff = client._refresh_timer._calculate_backoff() assert backoff >= min_backoff and backoff <= (min_backoff * (1 << attempts)) attempts = 3 - client._refresh_timer.attempts = attempts + client._refresh_timer._attempts = attempts backoff = client._refresh_timer._calculate_backoff() assert backoff >= min_backoff and backoff <= (min_backoff * (1 << attempts)) @@ -42,12 +42,12 @@ def test_backoff_max_attempts(self, appconfiguration_connection_string, appconfi # When attempts is > 30 then it acts as if it was 30 attempts = 30 - client._refresh_timer.attempts = attempts + client._refresh_timer._attempts = attempts backoff = client._refresh_timer._calculate_backoff() assert backoff >= min_backoff and backoff <= (min_backoff * (1 << attempts)) attempts = 31 - client._refresh_timer.attempts = attempts + client._refresh_timer._attempts = attempts backoff = client._refresh_timer._calculate_backoff() assert backoff >= min_backoff and backoff <= (min_backoff * (1 << 30)) @@ -94,12 +94,12 @@ def test_backoff_invalid_attempts(self, appconfiguration_connection_string, appc # When attempts is < 1 then it acts as if it was 1 attempts = 0 - client._refresh_timer.attempts = attempts + client._refresh_timer._attempts = attempts backoff = client._refresh_timer._calculate_backoff() assert backoff == min_backoff attempts = -1 - client._refresh_timer.attempts = attempts + client._refresh_timer._attempts = attempts backoff = client._refresh_timer._calculate_backoff() assert backoff == min_backoff @@ -114,6 +114,6 @@ def test_backoff_missmatch_settings(self, appconfiguration_connection_string, ap ) # When attempts is < 1 then it acts as if it was 1 - client._refresh_timer.attempts = 0 + client._refresh_timer._attempts = 0 backoff = client._refresh_timer._calculate_backoff() assert backoff == min_backoff diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_management.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_management.py index 6775a7b62cd9..180fd7612a02 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_management.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_management.py @@ -31,7 +31,7 @@ def test_load_only_feature_flags(self, appconfiguration_connection_string): @app_config_decorator def test_select_feature_flags(self, appconfiguration_connection_string): client = AzureAppConfigurationClient.from_connection_string(appconfiguration_connection_string) - setup_configs(client, None) + setup_configs(client, None, None) client = load( connection_string=appconfiguration_connection_string, diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py index c090f2a809bf..67451060f4f5 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py @@ -5,7 +5,12 @@ # license information. # -------------------------------------------------------------------------- from devtools_testutils import AzureRecordedTestCase -from azure.appconfiguration import AzureAppConfigurationClient, ConfigurationSetting, FeatureFlagConfigurationSetting +from azure.appconfiguration import ( + AzureAppConfigurationClient, + ConfigurationSetting, + FeatureFlagConfigurationSetting, + SecretReferenceConfigurationSetting, +) from azure.appconfiguration.provider import load, AzureAppConfigurationKeyVaultOptions from azure.appconfiguration.provider._constants import NULL_CHAR from test_constants import FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY @@ -22,7 +27,7 @@ def create_client(self, **kwargs): else: client = AzureAppConfigurationClient(kwargs["endpoint"], credential) - setup_configs(client, kwargs.get("keyvault_secret_url")) + setup_configs(client, kwargs.get("keyvault_secret_url"), kwargs.get("keyvault_secret_url2")) kwargs["user_agent"] = "SDK/Integration" if "endpoint" in kwargs: @@ -50,12 +55,12 @@ def create_aad_sdk_client(self, appconfiguration_endpoint_string): return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") -def setup_configs(client, keyvault_secret_url): - for config in get_configs(keyvault_secret_url): +def setup_configs(client, keyvault_secret_url, keyvault_secret_url2): + for config in get_configs(keyvault_secret_url, keyvault_secret_url2): client.set_configuration_setting(config) -def get_configs(keyvault_secret_url): +def get_configs(keyvault_secret_url, keyvault_secret_url2): configs = [] configs.append(create_config_setting("message", NULL_CHAR, "hi")) configs.append(create_config_setting("message", "dev", "test")) @@ -102,11 +107,18 @@ def get_configs(keyvault_secret_url): configs.append(create_config_setting("null_tag", NULL_CHAR, "null tag", tags={"tag": None})) if keyvault_secret_url: configs.append( - create_config_setting( + create_secret_config_setting( "secret", "prod", - '{"uri":"' + keyvault_secret_url + '"}', - "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + keyvault_secret_url, + ) + ) + if keyvault_secret_url2: + configs.append( + create_secret_config_setting( + "secret2", + "prod", + keyvault_secret_url2, ) ) return configs @@ -116,6 +128,14 @@ def create_config_setting(key, label, value, content_type="text/plain", tags=Non return ConfigurationSetting(key=key, label=label, value=value, content_type=content_type, tags=tags) +def create_secret_config_setting(key, label, value): + return SecretReferenceConfigurationSetting( + key=key, + label=label, + secret_id=value, + ) + + def create_feature_flag_config_setting(key, label, enabled, tags=None): return FeatureFlagConfigurationSetting(feature_id=key, label=label, enabled=enabled, tags=tags) diff --git a/sdk/appconfiguration/test-resources.json b/sdk/appconfiguration/test-resources.json index 4769b7120403..f4feb12f19e3 100644 --- a/sdk/appconfiguration/test-resources.json +++ b/sdk/appconfiguration/test-resources.json @@ -121,6 +121,18 @@ "value": "Very secret value" } }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(parameters('baseName'), '/TestSecret2')]", + "apiVersion": "2016-10-01", + "location": "[parameters('location')]", + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', parameters('baseName'))]" + ], + "properties": { + "value": "Very secret value 2" + } + }, { "type": "Microsoft.AppConfiguration/configurationStores/keyValues", "apiVersion": "2020-07-01-preview",