Skip to content

Commit 5ab4faa

Browse files
mrm9084Copilot
andauthored
App Configuration Provider - Key Vault Refresh (Azure#41882)
* Sync refresh changes * Key Vault Refresh * adding tests and fixing sync refresh * Updating Async * Fixed Async Tests * Updated tests and change log * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Fixing merge issue * Updating comments * Updating secret refresh * Update _azureappconfigurationproviderasync.py * Fixing Optional Endpoint * fix mypy issue * fixing async test * mixing merge * fixing test after merge * Update testcase.py * Secret Provider Base * removing unused imports * updating exception * updating resolve key vault references * Review comments * fixing tests * tox updates * Updating Tests * Updating Async to be the same as sync * Fixing formatting * fixing tox and unneeded "" * fixing tox items * fix cspell + tests recording * Update test_async_secret_provider.py * Post Merge updates * Move cache to shared code * removed unneeded disabled * Update Secret Provider * Updating usage * Update assets.json * Updated to make secret refresh update dictionary * removing _secret_version_cache * Update assets.json * Update _secret_provider_base.py --------- Co-authored-by: Copilot <[email protected]>
1 parent b277c60 commit 5ab4faa

26 files changed

+1768
-164
lines changed

sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44

55
### Features Added
66

7+
* 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.
8+
* Added support for async `on_refresh_success`.
9+
710
### Breaking Changes
811

912
### Bugs Fixed
1013

14+
* Fixed a bug where feature flags were using the configuration refresh timer instead of the feature flag refresh timer.
15+
1116
### Other Changes
1217

1318
## 2.2.0 (2025-08-08)

sdk/appconfiguration/azure-appconfiguration-provider/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "python",
44
"TagPrefix": "python/appconfiguration/azure-appconfiguration-provider",
5-
"Tag": "python/appconfiguration/azure-appconfiguration-provider_c68d337f0e"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_b13d43b82a"
66
}

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py

Lines changed: 21 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,17 @@
2222
SecretReferenceConfigurationSetting,
2323
)
2424
from azure.core.exceptions import AzureError, HttpResponseError
25-
from azure.keyvault.secrets import SecretClient, KeyVaultSecretIdentifier
2625
from ._models import AzureAppConfigurationKeyVaultOptions, SettingSelector
26+
from ._key_vault._secret_provider import SecretProvider
2727
from ._constants import (
2828
FEATURE_MANAGEMENT_KEY,
2929
FEATURE_FLAG_KEY,
3030
)
3131
from ._azureappconfigurationproviderbase import (
3232
AzureAppConfigurationProviderBase,
33+
update_correlation_context_header,
3334
delay_failure,
3435
sdk_allowed_kwargs,
35-
update_correlation_context_header,
3636
)
3737
from ._client_manager import ConfigurationClientManager, _ConfigurationClientWrapper as ConfigurationClient
3838
from ._user_agent import USER_AGENT
@@ -246,46 +246,6 @@ def _buildprovider(
246246
return AzureAppConfigurationProvider(**kwargs)
247247

248248

249-
def _resolve_keyvault_reference(
250-
config: "SecretReferenceConfigurationSetting", provider: "AzureAppConfigurationProvider"
251-
) -> str:
252-
# pylint:disable=protected-access
253-
if not (provider._keyvault_credential or provider._keyvault_client_configs or provider._secret_resolver):
254-
raise ValueError(
255-
"""
256-
Either a credential to Key Vault, custom Key Vault client, or a secret resolver must be set to resolve Key
257-
Vault references.
258-
"""
259-
)
260-
261-
if config.secret_id is None:
262-
raise ValueError("Key Vault reference must have a uri value.")
263-
264-
keyvault_identifier = KeyVaultSecretIdentifier(config.secret_id)
265-
266-
vault_url = keyvault_identifier.vault_url + "/"
267-
268-
# pylint:disable=protected-access
269-
referenced_client = provider._secret_clients.get(vault_url, None)
270-
271-
vault_config = provider._keyvault_client_configs.get(vault_url, {})
272-
credential = vault_config.pop("credential", provider._keyvault_credential)
273-
274-
if referenced_client is None and credential is not None:
275-
referenced_client = SecretClient(vault_url=vault_url, credential=credential, **vault_config)
276-
provider._secret_clients[vault_url] = referenced_client
277-
278-
if referenced_client:
279-
secret_value = referenced_client.get_secret(keyvault_identifier.name, version=keyvault_identifier.version).value
280-
if secret_value is not None:
281-
return secret_value
282-
283-
if provider._secret_resolver:
284-
return provider._secret_resolver(config.secret_id)
285-
286-
raise ValueError("No Secret Client found for Key Vault reference %s" % (vault_url))
287-
288-
289249
class AzureAppConfigurationProvider(AzureAppConfigurationProviderBase): # pylint: disable=too-many-instance-attributes
290250
"""
291251
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:
309269
max_backoff: int = min(kwargs.pop("max_backoff", 600), interval)
310270

311271
self._replica_client_manager = ConfigurationClientManager(
312-
connection_string=kwargs.pop("connection_string", None),
313-
endpoint=kwargs.pop("endpoint", None),
272+
connection_string=kwargs.pop("connection_string"),
273+
endpoint=kwargs.pop("endpoint"),
314274
credential=kwargs.pop("credential", None),
315275
user_agent=user_agent,
316276
retry_total=kwargs.pop("retry_total", 2),
@@ -321,7 +281,7 @@ def __init__(self, **kwargs: Any) -> None:
321281
load_balancing_enabled=kwargs.pop("load_balancing_enabled", False),
322282
**kwargs,
323283
)
324-
self._secret_clients: Dict[str, SecretClient] = {}
284+
self._secret_provider = SecretProvider(**kwargs)
325285
self._on_refresh_success: Optional[Callable] = kwargs.pop("on_refresh_success", None)
326286
self._on_refresh_error: Optional[Callable[[Exception], None]] = kwargs.pop("on_refresh_error", None)
327287
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
334294
replica_count,
335295
self._feature_flag_enabled,
336296
self._feature_filter_usage,
337-
self._uses_key_vault,
297+
self._secret_provider.uses_key_vault,
338298
self._uses_load_balancing,
339299
is_failover_request,
340300
self._uses_ai_configuration,
@@ -424,6 +384,11 @@ def refresh(self, **kwargs) -> None:
424384
exception: Optional[Exception] = None
425385
is_failover_request = False
426386
try:
387+
if (
388+
self._secret_provider.secret_refresh_timer
389+
and self._secret_provider.secret_refresh_timer.needs_refresh()
390+
):
391+
self._dict.update(self._secret_provider.refresh_secrets())
427392
self._replica_client_manager.refresh_clients()
428393
self._replica_client_manager.find_active_clients()
429394
replica_count = self._replica_client_manager.get_client_count() - 1
@@ -465,7 +430,7 @@ def _load_all(self, **kwargs):
465430
replica_count,
466431
self._feature_flag_enabled,
467432
self._feature_filter_usage,
468-
self._uses_key_vault,
433+
self._secret_provider.uses_key_vault,
469434
self._uses_load_balancing,
470435
is_failover_request,
471436
self._uses_ai_configuration,
@@ -520,9 +485,13 @@ def _load_all(self, **kwargs):
520485
raise exception
521486

522487
def _process_configurations(self, configuration_settings: List[ConfigurationSetting]) -> Dict[str, Any]:
488+
# configuration_settings can contain duplicate keys, but they are in priority order, i.e. later settings take
489+
# precedence. Only process the settings with the highest priority (i.e. the last one in the list).
490+
unique_settings = self._deduplicate_settings(configuration_settings)
491+
523492
configuration_settings_processed = {}
524493
feature_flags_processed = []
525-
for settings in configuration_settings:
494+
for settings in unique_settings.values():
526495
if self._configuration_mapper:
527496
# If a map function is provided, use it to process the configuration setting
528497
self._configuration_mapper(settings)
@@ -541,7 +510,7 @@ def _process_configurations(self, configuration_settings: List[ConfigurationSett
541510

542511
def _process_key_value(self, config: ConfigurationSetting) -> Any:
543512
if isinstance(config, SecretReferenceConfigurationSetting):
544-
return _resolve_keyvault_reference(config, self)
513+
return self._secret_provider.resolve_keyvault_reference(config)
545514
# Use the base class helper method for non-KeyVault processing
546515
return self._process_key_value_base(config)
547516

@@ -558,17 +527,14 @@ def close(self) -> None:
558527
"""
559528
Closes the connection to Azure App Configuration.
560529
"""
561-
for client in self._secret_clients.values():
562-
client.close()
530+
self._secret_provider.close()
563531
self._replica_client_manager.close()
564532

565533
def __enter__(self) -> "AzureAppConfigurationProvider":
566534
self._replica_client_manager.__enter__()
567-
for client in self._secret_clients.values():
568-
client.__enter__()
535+
self._secret_provider.__enter__()
569536
return self
570537

571538
def __exit__(self, *args) -> None:
572539
self._replica_client_manager.__exit__()
573-
for client in self._secret_clients.values():
574-
client.__exit__()
540+
self._secret_provider.__exit__()

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -290,20 +290,11 @@ def __init__(self, **kwargs: Any) -> None:
290290

291291
trim_prefixes: List[str] = kwargs.pop("trim_prefixes", [])
292292
self._trim_prefixes: List[str] = sorted(trim_prefixes, key=len, reverse=True)
293-
294293
refresh_on: List[Tuple[str, str]] = kwargs.pop("refresh_on", None) or []
295294
self._watched_settings: Dict[Tuple[str, str], Optional[str]] = {
296295
_build_watched_setting(s): None for s in refresh_on
297296
}
298297
self._refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs)
299-
self._keyvault_credential = kwargs.pop("keyvault_credential", None)
300-
self._secret_resolver = kwargs.pop("secret_resolver", None)
301-
self._keyvault_client_configs = kwargs.pop("keyvault_client_configs", {})
302-
self._uses_key_vault = (
303-
self._keyvault_credential is not None
304-
or (self._keyvault_client_configs is not None and len(self._keyvault_client_configs) > 0)
305-
or self._secret_resolver is not None
306-
)
307298
self._feature_flag_enabled = kwargs.pop("feature_flag_enabled", False)
308299
self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", [SettingSelector(key_filter="*")])
309300
self._watched_feature_flags: Dict[Tuple[str, str], Optional[str]] = {}
@@ -598,3 +589,18 @@ def _update_watched_feature_flags(
598589
for feature_flag in feature_flags:
599590
watched_feature_flags[(feature_flag.key, feature_flag.label)] = feature_flag.etag
600591
return watched_feature_flags
592+
593+
def _deduplicate_settings(
594+
self, configuration_settings: List[ConfigurationSetting]
595+
) -> Dict[str, ConfigurationSetting]:
596+
"""
597+
Deduplicates configuration settings by key.
598+
599+
:param List[ConfigurationSetting] configuration_settings: The list of configuration settings to deduplicate
600+
:return: A dictionary mapping keys to their unique configuration settings
601+
:rtype: Dict[str, ConfigurationSetting]
602+
"""
603+
unique_settings: Dict[str, ConfigurationSetting] = {}
604+
for settings in configuration_settings:
605+
unique_settings[settings.key] = settings
606+
return unique_settings
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
7+
from ._secret_provider import SecretProvider
8+
from ._secret_provider_base import _SecretProviderBase
9+
10+
__all__ = [
11+
"SecretProvider",
12+
"_SecretProviderBase",
13+
]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from typing import Mapping, Any, Dict
7+
from azure.appconfiguration import SecretReferenceConfigurationSetting # type:ignore # pylint:disable=no-name-in-module
8+
from azure.keyvault.secrets import SecretClient, KeyVaultSecretIdentifier
9+
from azure.core.exceptions import ServiceRequestError
10+
from ._secret_provider_base import _SecretProviderBase
11+
12+
JSON = Mapping[str, Any]
13+
14+
15+
class SecretProvider(_SecretProviderBase):
16+
17+
def __init__(self, **kwargs: Any) -> None:
18+
super().__init__(**kwargs)
19+
self._secret_clients: Dict[str, SecretClient] = {}
20+
self._keyvault_credential = kwargs.pop("keyvault_credential", None)
21+
self._secret_resolver = kwargs.pop("secret_resolver", None)
22+
self._keyvault_client_configs = kwargs.pop("keyvault_client_configs", {})
23+
24+
def resolve_keyvault_reference(self, config: SecretReferenceConfigurationSetting) -> str:
25+
keyvault_identifier, vault_url = self.resolve_keyvault_reference_base(config)
26+
if keyvault_identifier.source_id in self._secret_cache:
27+
_, _, value = self._secret_cache[keyvault_identifier.source_id]
28+
return value
29+
30+
return self.__get_secret_value(config.key, keyvault_identifier, vault_url)
31+
32+
def refresh_secrets(self) -> Dict[str, Any]:
33+
secrets = {}
34+
if self.secret_refresh_timer:
35+
original_cache = self._secret_cache.copy()
36+
self._secret_cache.clear()
37+
for source_id, (secret_identifier, key, _) in original_cache.items():
38+
value = self.__get_secret_value(key, secret_identifier, secret_identifier.vault_url + "/")
39+
self._secret_cache[source_id] = (
40+
secret_identifier,
41+
key,
42+
value,
43+
)
44+
secrets[key] = value
45+
self.secret_refresh_timer.reset()
46+
return secrets
47+
48+
def __get_secret_value(self, key: str, secret_identifier: KeyVaultSecretIdentifier, vault_url: str) -> str:
49+
referenced_client = self._secret_clients.get(vault_url, None)
50+
51+
vault_config = self._keyvault_client_configs.get(vault_url, {})
52+
credential = vault_config.pop("credential", self._keyvault_credential)
53+
54+
if referenced_client is None and credential is not None:
55+
referenced_client = SecretClient(vault_url=vault_url, credential=credential, **vault_config)
56+
self._secret_clients[vault_url] = referenced_client
57+
58+
secret_value = None
59+
60+
if referenced_client:
61+
try:
62+
secret_value = referenced_client.get_secret(
63+
secret_identifier.name, version=secret_identifier.version
64+
).value
65+
except ServiceRequestError as e:
66+
raise ValueError("Failed to retrieve secret from Key Vault") from e
67+
68+
if self._secret_resolver and secret_value is None:
69+
secret_value = self._secret_resolver(secret_identifier.source_id)
70+
71+
return self._cache_value(key, secret_identifier, secret_value)
72+
73+
def close(self) -> None:
74+
"""
75+
Closes the connection to Azure App Configuration.
76+
"""
77+
for client in self._secret_clients.values():
78+
client.close()
79+
80+
def __enter__(self) -> "SecretProvider":
81+
for client in self._secret_clients.values():
82+
client.__enter__()
83+
return self
84+
85+
def __exit__(self, *args) -> None:
86+
for client in self._secret_clients.values():
87+
client.__exit__()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from typing import (
7+
Mapping,
8+
Any,
9+
TypeVar,
10+
Optional,
11+
Dict,
12+
Tuple,
13+
)
14+
from azure.appconfiguration import SecretReferenceConfigurationSetting # type:ignore # pylint:disable=no-name-in-module
15+
from azure.keyvault.secrets import KeyVaultSecretIdentifier
16+
from .._azureappconfigurationproviderbase import _RefreshTimer
17+
18+
JSON = Mapping[str, Any]
19+
_T = TypeVar("_T")
20+
21+
22+
class _SecretProviderBase:
23+
24+
def __init__(self, **kwargs: Any) -> None:
25+
# [source_id, (KeyVaultSecretIdentifier, key, value)]
26+
self._secret_cache: Dict[str, Tuple[KeyVaultSecretIdentifier, str, str]] = {}
27+
self.uses_key_vault = (
28+
"keyvault_credential" in kwargs
29+
or ("keyvault_client_configs" in kwargs and len(kwargs.get("keyvault_client_configs", {})) > 0)
30+
or "secret_resolver" in kwargs
31+
)
32+
33+
if kwargs.get("secret_refresh_interval", 60) < 1:
34+
raise ValueError("Secret refresh interval must be greater than 1 second.")
35+
36+
self.secret_refresh_timer: Optional[_RefreshTimer] = (
37+
_RefreshTimer(refresh_interval=kwargs.pop("secret_refresh_interval", 60))
38+
if self.uses_key_vault and "secret_refresh_interval" in kwargs
39+
else None
40+
)
41+
42+
def _cache_value(self, key: str, keyvault_identifier: KeyVaultSecretIdentifier, secret_value: Any) -> str:
43+
if secret_value:
44+
self._secret_cache[keyvault_identifier.source_id] = (keyvault_identifier, key, secret_value)
45+
return secret_value
46+
47+
raise ValueError("No Secret Client found for Key Vault reference %s" % (keyvault_identifier.vault_url))
48+
49+
def resolve_keyvault_reference_base(
50+
self, config: SecretReferenceConfigurationSetting
51+
) -> Tuple[KeyVaultSecretIdentifier, str]:
52+
if not self.uses_key_vault:
53+
raise ValueError(
54+
"""
55+
Either a credential to Key Vault, custom Key Vault client, or a secret resolver must be set to resolve
56+
Key Vault references.
57+
"""
58+
)
59+
60+
if config.secret_id is None:
61+
raise ValueError("Key Vault reference must have a uri value.")
62+
63+
keyvault_identifier = KeyVaultSecretIdentifier(config.secret_id)
64+
65+
vault_url = keyvault_identifier.vault_url + "/"
66+
67+
return keyvault_identifier, vault_url

0 commit comments

Comments
 (0)