diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 134dd91ebb4c..e450321fc92c 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -8,6 +8,9 @@ ### Bugs Fixed +- Fixes a bug where `feature_flag_selects` could be passed in as `None` which resulted in an exception on load, doing this now results in loading the default feature flags. +- Fixes a bug where `feature_flag_selects` couldn't load snapshots. + ### Other Changes ## 2.3.1 (2025-11-13) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/assets.json b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json index b612cb2b1b0f..5ac650113661 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_3e69808293" + "Tag": "python/appconfiguration/azure-appconfiguration-provider_25357bbd75" } 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 d5ff6b58d5cc..7bd2c44344f3 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -114,16 +114,6 @@ def process_load_parameters(*args, **kwargs: Any) -> Dict[str, Any]: if kwargs.get("keyvault_credential") is not None and kwargs.get("secret_resolver") is not None: raise ValueError("A keyvault credential and secret resolver can't both be configured.") - # Validate feature flag selectors don't use snapshots - feature_flag_selectors = kwargs.get("feature_flag_selectors") - if feature_flag_selectors: - for selector in feature_flag_selectors: - if hasattr(selector, "snapshot_name") and selector.snapshot_name is not None: - raise ValueError( - "snapshot_name cannot be used with feature_flag_selectors. " - "Use snapshot_name with regular selects instead to load feature flags from snapshots." - ) - # Determine Key Vault usage uses_key_vault = ( "keyvault_credential" in kwargs @@ -230,7 +220,9 @@ def __init__(self, **kwargs: Any) -> None: } self._refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs) self._feature_flag_enabled = kwargs.pop("feature_flag_enabled", False) - self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", [SettingSelector(key_filter="*")]) + self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", None) + if self._feature_flag_selectors is None: + self._feature_flag_selectors = [SettingSelector(key_filter="*")] self._watched_feature_flags: Dict[Tuple[str, str], Optional[str]] = {} self._feature_flag_refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs) self._feature_flag_refresh_enabled = kwargs.pop("feature_flag_refresh_enabled", False) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py index d1fa039e474c..ccacbeedba8c 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py @@ -137,6 +137,7 @@ def _check_configuration_setting( def load_configuration_settings(self, selects: List[SettingSelector], **kwargs) -> List[ConfigurationSetting]: configuration_settings = [] for select in selects: + configurations = [] if select.snapshot_name is not None: # When loading from a snapshot, ignore key_filter, label_filter, and tag_filters snapshot = self._client.get_snapshot(select.snapshot_name) @@ -162,24 +163,27 @@ def load_configuration_settings(self, selects: List[SettingSelector], **kwargs) def load_feature_flags( self, feature_flag_selectors: List[SettingSelector], **kwargs ) -> List[FeatureFlagConfigurationSetting]: - loaded_feature_flags = [] + loaded_feature_flags: List[FeatureFlagConfigurationSetting] = [] # Needs to be removed unknown keyword argument for list_configuration_settings kwargs.pop("sentinel_keys", None) for select in feature_flag_selectors: - # Handle None key_filter by converting to empty string - key_filter = select.key_filter if select.key_filter is not None else "" - feature_flags = self._client.list_configuration_settings( - key_filter=FEATURE_FLAG_PREFIX + key_filter, - label_filter=select.label_filter, - tags_filter=select.tag_filters, - **kwargs, - ) - for feature_flag in feature_flags: - if not isinstance(feature_flag, FeatureFlagConfigurationSetting): - # If the feature flag is not a FeatureFlagConfigurationSetting, it means it was selected by - # mistake, so we should ignore it. - continue - loaded_feature_flags.append(feature_flag) + feature_flags = [] + if select.snapshot_name is not None: + # When loading from a snapshot, ignore key_filter, label_filter, and tag_filters + snapshot = self._client.get_snapshot(select.snapshot_name) + if snapshot.composition_type != SnapshotComposition.KEY: + raise ValueError(f"Composition type for '{select.snapshot_name}' must be 'key'.") + feature_flags = self._client.list_configuration_settings(snapshot_name=select.snapshot_name, **kwargs) + else: + # Handle None key_filter by converting to empty string + key_filter = select.key_filter if select.key_filter is not None else "" + feature_flags = self._client.list_configuration_settings( + key_filter=FEATURE_FLAG_PREFIX + key_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + loaded_feature_flags.extend(ff for ff in feature_flags if isinstance(ff, FeatureFlagConfigurationSetting)) return loaded_feature_flags diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py index 1c75ae885465..424dd8d6ac04 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py @@ -10,6 +10,7 @@ from typing import Tuple, Union, Dict, List, Optional, Mapping, TYPE_CHECKING from typing_extensions import Self from azure.core import MatchConditions +from azure.core.async_paging import AsyncItemPaged from azure.core.tracing.decorator import distributed_trace from azure.core.exceptions import HttpResponseError from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module @@ -164,24 +165,29 @@ async def load_configuration_settings(self, selects: List[SettingSelector], **kw async def load_feature_flags( self, feature_flag_selectors: List[SettingSelector], **kwargs ) -> List[FeatureFlagConfigurationSetting]: - loaded_feature_flags = [] + loaded_feature_flags: List[FeatureFlagConfigurationSetting] = [] # Needs to be removed unknown keyword argument for list_configuration_settings kwargs.pop("sentinel_keys", None) for select in feature_flag_selectors: - # Handle None key_filter by converting to empty string - key_filter = select.key_filter if select.key_filter is not None else "" - feature_flags = self._client.list_configuration_settings( - key_filter=FEATURE_FLAG_PREFIX + key_filter, - label_filter=select.label_filter, - tags_filter=select.tag_filters, - **kwargs, + feature_flags: AsyncItemPaged[ConfigurationSetting] + if select.snapshot_name is not None: + # When loading from a snapshot, ignore key_filter, label_filter, and tag_filters + snapshot = await self._client.get_snapshot(select.snapshot_name) + if snapshot.composition_type != SnapshotComposition.KEY: + raise ValueError(f"Composition type for '{select.snapshot_name}' must be 'key'.") + feature_flags = self._client.list_configuration_settings(snapshot_name=select.snapshot_name, **kwargs) + else: + # Handle None key_filter by converting to empty string + key_filter = select.key_filter if select.key_filter is not None else "" + feature_flags = self._client.list_configuration_settings( + key_filter=FEATURE_FLAG_PREFIX + key_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + loaded_feature_flags.extend( + [ff async for ff in feature_flags if isinstance(ff, FeatureFlagConfigurationSetting)] ) - async for feature_flag in feature_flags: - if not isinstance(feature_flag, FeatureFlagConfigurationSetting): - # If the feature flag is not a FeatureFlagConfigurationSetting, it means it was selected by - # mistake, so we should ignore it. - continue - loaded_feature_flags.append(feature_flag) return loaded_feature_flags diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/aad_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/aad_sample.py index 017f9a40930e..2364b6da526a 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/aad_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/aad_sample.py @@ -26,7 +26,15 @@ # Connection to Azure App Configuration using SettingSelector selects = [SettingSelector(key_filter="message*")] -config = load(endpoint=endpoint, credential=credential, selects=selects, **kwargs) +config = load( + endpoint=endpoint, + credential=credential, + selects=selects, + feature_flag_enabled=True, + feature_flag_selectors=None, + **kwargs +) print("message found: " + str("message" in config)) print("test.message found: " + str("test.message" in config)) +print("feature_flag_enabled found: " + str(config.get("feature_management"))) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_snapshots.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_snapshots.py index 926542d82872..ef39975974b2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_snapshots.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_snapshots.py @@ -7,20 +7,22 @@ import pytest import time from azure.appconfiguration.provider._models import SettingSelector -from azure.appconfiguration.provider._constants import NULL_CHAR +from azure.appconfiguration.provider._constants import NULL_CHAR, FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY from azure.appconfiguration.provider.aio import load from azure.appconfiguration.provider import WatchKey from azure.appconfiguration import ( ConfigurationSetting, - ConfigurationSettingsFilter, - SnapshotComposition, - SnapshotStatus, + FeatureFlagConfigurationSetting, ) from azure.core.exceptions import ResourceNotFoundError -from devtools_testutils import is_live from devtools_testutils.aio import recorded_by_proxy_async from async_preparers import app_config_decorator_async -from asynctestcase import AppConfigTestCase +from asynctestcase import ( + AppConfigTestCase, + cleanup_test_resources_async, + set_test_settings_async, + create_snapshot_async, +) class TestSnapshotSupport: @@ -85,20 +87,6 @@ def test_setting_selector_valid_combinations(self): assert selector.tag_filters == ["env=prod"] assert selector.snapshot_name is None - @pytest.mark.asyncio - async def test_feature_flag_selectors_with_snapshot_raises_error(self): - """Test that feature_flag_selectors with snapshot_name raises ValueError during validation.""" - with pytest.raises( - ValueError, - match=r"snapshot_name cannot be used with feature_flag_selectors\. " - r"Use snapshot_name with regular selects instead to load feature flags from snapshots\.", - ): - await load( - connection_string="Endpoint=test;Id=test;Secret=test", - feature_flag_enabled=True, - feature_flag_selectors=[SettingSelector(snapshot_name="my-snapshot")], - ) - class TestSnapshotProviderIntegration(AppConfigTestCase): """Integration tests for snapshot functionality with recorded tests.""" @@ -156,18 +144,6 @@ async def test_load_provider_with_regular_selectors(self, appconfiguration_conne # Verify we can access the configuration (message is set up by setup_configs) assert "message" in provider - @app_config_decorator_async - @recorded_by_proxy_async - async def test_snapshot_selector_parameter_validation_in_provider(self, appconfiguration_connection_string): - """Test that snapshot selector parameter validation works when loading provider.""" - # Test that feature flag selectors with snapshots are rejected - with pytest.raises(ValueError, match="snapshot_name cannot be used with feature_flag_selectors"): - await self.create_client( - connection_string=appconfiguration_connection_string, - feature_flag_enabled=True, - feature_flag_selectors=[SettingSelector(snapshot_name="test-snapshot")], - ) - @pytest.mark.live_test_only # Needed to fix an azure core dependency compatibility issue @app_config_decorator_async @recorded_by_proxy_async @@ -189,9 +165,23 @@ async def test_create_snapshot_and_load_provider(self, appconfiguration_connecti ConfigurationSetting(key="refresh_test_key", value="original_refresh_value", label=NULL_CHAR), ] - # Set the configuration settings - for setting in test_settings: - await sdk_client.set_configuration_setting(setting) + # Create feature flag settings for the snapshot + # Note: Feature flags in snapshots are NOT loaded as feature flags by the provider + test_feature_flags = [ + FeatureFlagConfigurationSetting( + feature_id="SnapshotFeature", + enabled=True, + label=NULL_CHAR, + ), + FeatureFlagConfigurationSetting( + feature_id="SnapshotFeatureDisabled", + enabled=False, + label=NULL_CHAR, + ), + ] + + # Set the configuration settings and feature flags + await set_test_settings_async(sdk_client, test_settings + test_feature_flags) variables = kwargs.pop("variables", {}) dynamic_snapshot_name_postfix = variables.setdefault("dynamic_snapshot_name_postfix", str(int(time.time()))) @@ -200,28 +190,18 @@ async def test_create_snapshot_and_load_provider(self, appconfiguration_connecti snapshot_name = f"test-snapshot-{dynamic_snapshot_name_postfix}" try: - # Create the snapshot - snapshot_poller = await sdk_client.begin_create_snapshot( - name=snapshot_name, - filters=[ConfigurationSettingsFilter(key="snapshot_test_*")], # Include all our test keys - composition_type=SnapshotComposition.KEY, - retention_period=3600, # Min valid value is 1 hour + # Create the snapshot including both config settings and feature flags + await create_snapshot_async( + sdk_client, + snapshot_name, + key_filters=["snapshot_test_*", ".appconfig.featureflag/SnapshotFeature*"], ) - snapshot = await snapshot_poller.result() - - # Verify snapshot was created successfully - if is_live(): - assert snapshot.name == snapshot_name - else: - assert snapshot.name == "Sanitized" - assert snapshot.status == SnapshotStatus.READY - assert snapshot.composition_type == SnapshotComposition.KEY - # Load provider using the snapshot with refresh enabled + # Load provider using the snapshot with refresh enabled and feature flags enabled async with await self.create_client( connection_string=appconfiguration_connection_string, selects=[ - SettingSelector(snapshot_name=snapshot_name), # Snapshot data + SettingSelector(snapshot_name=snapshot_name), # Snapshot data (includes feature flags) SettingSelector(key_filter="refresh_test_key"), # Non-snapshot key for refresh testing ], refresh_on=[WatchKey("refresh_test_key")], # Watch non-snapshot key for refresh @@ -238,6 +218,10 @@ async def test_create_snapshot_and_load_provider(self, appconfiguration_connecti snapshot_keys = [key for key in provider.keys() if key.startswith("snapshot_test_")] assert len(snapshot_keys) == 3 + # Verify feature flags from snapshots are NOT loaded as feature flags + # (snapshots don't support feature flag loading, only regular selects do) + assert FEATURE_MANAGEMENT_KEY not in provider, "Feature flags should not be loaded from snapshots" + # Test snapshot immutability: modify the original settings modified_settings = [ ConfigurationSetting( @@ -259,13 +243,9 @@ async def test_create_snapshot_and_load_provider(self, appconfiguration_connecti ), ] - # Update the original settings with new values - for setting in modified_settings: - await sdk_client.set_configuration_setting(setting) - - # Add a completely new key after initial load + # Update the original settings with new values and add a new key new_key = ConfigurationSetting(key="new_key_added_after_load", value="new_value", label=NULL_CHAR) - await sdk_client.set_configuration_setting(new_key) + await set_test_settings_async(sdk_client, modified_settings + [new_key]) # Wait for refresh interval to pass time.sleep(1) @@ -296,23 +276,138 @@ async def test_create_snapshot_and_load_provider(self, appconfiguration_connecti assert provider_current["snapshot_test_json"]["nested"] == "MODIFIED_VALUE" # Modified value finally: - # Clean up: delete the snapshot and test settings - try: - # Archive the snapshot (delete is not supported, but archive effectively removes it) - await sdk_client.archive_snapshot(snapshot_name) - except Exception: - pass - - # Clean up test settings - for setting in test_settings: - try: - await sdk_client.delete_configuration_setting(key=setting.key, label=setting.label) - except Exception: - pass - - # Clean up additional test keys - try: - await sdk_client.delete_configuration_setting(key="new_key_added_after_load", label=NULL_CHAR) - except Exception: - pass + # Clean up test resources + cleanup_settings = ( + test_settings + + test_feature_flags + + [ConfigurationSetting(key="new_key_added_after_load", value="", label=NULL_CHAR)] + ) + await cleanup_test_resources_async( + sdk_client, + settings=cleanup_settings, + snapshot_names=[snapshot_name], + ) + return variables + + @pytest.mark.live_test_only # Needed to fix an azure core dependency compatibility issue + @app_config_decorator_async + @recorded_by_proxy_async + async def test_create_snapshot_and_load_provider_with_feature_flags( + self, appconfiguration_connection_string, **kwargs + ): + """Test creating a snapshot and loading provider with feature flags from non-snapshot selectors.""" + # Create SDK client for setup + sdk_client = self.create_sdk_client(appconfiguration_connection_string) + + # Create unique test configuration settings for the snapshot + test_settings = [ + ConfigurationSetting(key="ff_snapshot_test_key1", value="ff_snapshot_test_value1", label=NULL_CHAR), + ConfigurationSetting(key="ff_snapshot_test_key2", value="ff_snapshot_test_value2", label=NULL_CHAR), + ] + + # Create feature flag settings - some for snapshot, some for regular loading + # Note: Feature flags in snapshots are NOT loaded as feature flags by the provider + snapshot_feature_flags = [ + FeatureFlagConfigurationSetting( + feature_id="SnapshotOnlyFeature", + enabled=True, + label=NULL_CHAR, + ), + ] + + # Feature flags loaded via regular selectors (not from snapshot) + regular_feature_flags = [ + FeatureFlagConfigurationSetting( + feature_id="RegularFeature", + enabled=True, + label=NULL_CHAR, + ), + FeatureFlagConfigurationSetting( + feature_id="RegularFeatureDisabled", + enabled=False, + label=NULL_CHAR, + ), + ] + + # Set the configuration settings and feature flags + await set_test_settings_async(sdk_client, test_settings + snapshot_feature_flags + regular_feature_flags) + + variables = kwargs.pop("variables", {}) + dynamic_snapshot_name_postfix = variables.setdefault("dynamic_ff_snapshot_name_postfix", str(int(time.time()))) + + # Create a unique snapshot name with timestamp to avoid conflicts + snapshot_name = f"test-ff-snapshot-{dynamic_snapshot_name_postfix}" + + try: + # Create the snapshot including config settings and snapshot-only feature flags + await create_snapshot_async( + sdk_client, + snapshot_name, + key_filters=["ff_snapshot_test_*", ".appconfig.featureflag/SnapshotOnlyFeature"], + ) + + # Load provider using snapshot for config settings and regular selectors for feature flags + async with await self.create_client( + connection_string=appconfiguration_connection_string, + feature_flag_enabled=True, # Enable feature flags + feature_flag_selectors=[ + SettingSelector(snapshot_name=snapshot_name), # Load feature flags from snapshot + ], + ) as provider: + + # Verify snapshot configuration settings are loaded + assert provider["ff_snapshot_test_key1"] == "ff_snapshot_test_value1" + assert provider["ff_snapshot_test_key2"] == "ff_snapshot_test_value2" + + # Verify feature flags loaded via regular selectors ARE loaded + feature_flags = provider.get(FEATURE_MANAGEMENT_KEY, {}).get(FEATURE_FLAG_KEY, []) + feature_flag_ids = {ff["id"]: ff["enabled"] for ff in feature_flags} + + # Regular feature flags should be loaded + assert ( + "RegularFeature" not in feature_flag_ids + ), "RegularFeature should not be loaded via regular selector" + assert "RegularFeatureDisabled" not in feature_flag_ids, "RegularFeatureDisabled should not be loaded" + + # Snapshot-only feature flag should be loaded as a feature flag + assert ( + "SnapshotOnlyFeature" in feature_flag_ids + ), "SnapshotOnlyFeature should be loaded as FF from snapshot" + + # Verify exactly 1 feature flag is loaded (the snapshot-only one) + assert len(feature_flags) == 1, f"Expected 1 feature flag, got {len(feature_flags)}" + + # Modify the feature flags in the snapshot + modified_feature_flags = [ + FeatureFlagConfigurationSetting( + feature_id="SnapshotOnlyFeature", + enabled=False, # Changed from True to False + label=NULL_CHAR, + ), + ] + + await set_test_settings_async(sdk_client, modified_feature_flags) + + # Load a fresh provider without snapshot to verify current feature flag values + async with await self.create_client( + connection_string=appconfiguration_connection_string, + feature_flag_enabled=True, + feature_flag_selectors=[ + SettingSelector(snapshot_name=snapshot_name), # Load feature flags from snapshot + ], + ) as provider_current: + + # Current feature flag values should be the original ones from snapshot (immutable) + current_feature_flags = provider_current.get(FEATURE_MANAGEMENT_KEY, {}).get(FEATURE_FLAG_KEY, []) + current_ff_ids = {ff["id"]: ff["enabled"] for ff in current_feature_flags} + assert current_ff_ids.get("SnapshotOnlyFeature") is True # Original value from snapshot (not modified) + + finally: + # Clean up test resources + cleanup_settings = test_settings + snapshot_feature_flags + regular_feature_flags + await cleanup_test_resources_async( + sdk_client, + settings=cleanup_settings, + snapshot_names=[snapshot_name], + ) return variables diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py index bdab2e0670f2..fe9e9878256c 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py @@ -53,3 +53,86 @@ async def setup_configs(client, keyvault_secret_url, keyvault_secret_url2): async with client: for config in get_configs(keyvault_secret_url, keyvault_secret_url2): await client.set_configuration_setting(config) + + +async def cleanup_test_resources_async( + client, + settings=None, + snapshot_names=None, +): + """ + Clean up test resources from Azure App Configuration (async version). + + :param client: The async AzureAppConfigurationClient to use for cleanup. + :param settings: List of ConfigurationSetting objects to delete. + :param snapshot_names: List of snapshot names to archive. + """ + # Archive snapshots + if snapshot_names: + for snapshot_name in snapshot_names: + try: + await client.archive_snapshot(snapshot_name) + except Exception: + pass + + # Delete configuration settings and feature flags + if settings: + for setting in settings: + try: + await client.delete_configuration_setting(key=setting.key, label=setting.label) + except Exception: + pass + + +async def set_test_settings_async(client, settings): + """ + Set multiple configuration settings in Azure App Configuration (async version). + + :param client: The async AzureAppConfigurationClient to use. + :param settings: List of ConfigurationSetting or FeatureFlagConfigurationSetting objects to set. + """ + for setting in settings: + await client.set_configuration_setting(setting) + + +async def create_snapshot_async(client, snapshot_name, key_filters, composition_type=None, retention_period=3600): + """ + Create a snapshot in Azure App Configuration and verify it was created successfully (async version). + + :param client: The async AzureAppConfigurationClient to use. + :param snapshot_name: The name of the snapshot to create. + :param key_filters: List of key filter strings to define what settings to include. + :param composition_type: The composition type for the snapshot (default: SnapshotComposition.KEY). + :param retention_period: The retention period in seconds (default: 3600, minimum valid value). + :return: The created snapshot. + """ + from azure.appconfiguration import SnapshotComposition, ConfigurationSettingsFilter, SnapshotStatus + from devtools_testutils import is_live + + if composition_type is None: + composition_type = SnapshotComposition.KEY + + # Convert key filter strings to ConfigurationSettingsFilter objects + filters = [ConfigurationSettingsFilter(key=key_filter) for key_filter in key_filters] + + snapshot_poller = await client.begin_create_snapshot( + name=snapshot_name, + filters=filters, + composition_type=composition_type, + retention_period=retention_period, + ) + snapshot = await snapshot_poller.result() + + # Verify snapshot was created successfully + assert snapshot.status == SnapshotStatus.READY, f"Snapshot status is {snapshot.status}, expected READY" + assert ( + snapshot.composition_type == composition_type + ), f"Snapshot composition_type is {snapshot.composition_type}, expected {composition_type}" + + # Verify snapshot name (sanitized in playback mode) + if is_live(): + assert snapshot.name == snapshot_name, f"Snapshot name is {snapshot.name}, expected {snapshot_name}" + else: + assert snapshot.name == "Sanitized", f"Snapshot name is {snapshot.name}, expected 'Sanitized' in playback" + + return snapshot diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_snapshots.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_snapshots.py index 3a31c3ac874c..9e72a99b793e 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_snapshots.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_snapshots.py @@ -7,18 +7,16 @@ import pytest import time from azure.appconfiguration.provider._models import SettingSelector -from azure.appconfiguration.provider._constants import NULL_CHAR +from azure.appconfiguration.provider._constants import NULL_CHAR, FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY from azure.appconfiguration.provider import load, WatchKey from azure.appconfiguration import ( ConfigurationSetting, - ConfigurationSettingsFilter, - SnapshotComposition, - SnapshotStatus, + FeatureFlagConfigurationSetting, ) from azure.core.exceptions import ResourceNotFoundError -from devtools_testutils import recorded_by_proxy, is_live +from devtools_testutils import recorded_by_proxy from preparers import app_config_decorator -from testcase import AppConfigTestCase +from testcase import AppConfigTestCase, cleanup_test_resources, set_test_settings, create_snapshot class TestSnapshotSupport: @@ -83,19 +81,6 @@ def test_setting_selector_valid_combinations(self): assert selector.tag_filters == ["env=prod"] assert selector.snapshot_name is None - def test_feature_flag_selectors_with_snapshot_raises_error(self): - """Test that feature_flag_selectors with snapshot_name raises ValueError during validation.""" - with pytest.raises( - ValueError, - match=r"snapshot_name cannot be used with feature_flag_selectors\. " - r"Use snapshot_name with regular selects instead to load feature flags from snapshots\.", - ): - load( - connection_string="Endpoint=test;Id=test;Secret=test", - feature_flag_enabled=True, - feature_flag_selectors=[SettingSelector(snapshot_name="my-snapshot")], - ) - class TestSnapshotProviderIntegration(AppConfigTestCase): """Integration tests for snapshot functionality with recorded tests.""" @@ -154,18 +139,6 @@ def test_load_provider_with_regular_selectors(self, appconfiguration_connection_ # Verify we can access the configuration (message is set up by setup_configs) assert "message" in provider - @app_config_decorator - @recorded_by_proxy - def test_snapshot_selector_parameter_validation_in_provider(self, appconfiguration_connection_string): - """Test that snapshot selector parameter validation works when loading provider.""" - # Test that feature flag selectors with snapshots are rejected - with pytest.raises(ValueError, match="snapshot_name cannot be used with feature_flag_selectors"): - self.create_client( - connection_string=appconfiguration_connection_string, - feature_flag_enabled=True, - feature_flag_selectors=[SettingSelector(snapshot_name="test-snapshot")], - ) - @pytest.mark.live_test_only # Needed to fix an azure core dependency compatibility issue @app_config_decorator @recorded_by_proxy @@ -187,9 +160,23 @@ def test_create_snapshot_and_load_provider(self, appconfiguration_connection_str ConfigurationSetting(key="refresh_test_key", value="original_refresh_value", label=NULL_CHAR), ] - # Set the configuration settings - for setting in test_settings: - sdk_client.set_configuration_setting(setting) + # Create feature flag settings for the snapshot + # Note: Feature flags in snapshots are NOT loaded as feature flags by the provider + test_feature_flags = [ + FeatureFlagConfigurationSetting( + feature_id="SnapshotFeature", + enabled=True, + label=NULL_CHAR, + ), + FeatureFlagConfigurationSetting( + feature_id="SnapshotFeatureDisabled", + enabled=False, + label=NULL_CHAR, + ), + ] + + # Set the configuration settings and feature flags + set_test_settings(sdk_client, test_settings + test_feature_flags) variables = kwargs.pop("variables", {}) dynamic_snapshot_name_postfix = variables.setdefault("dynamic_snapshot_name_postfix", str(int(time.time()))) @@ -198,27 +185,18 @@ def test_create_snapshot_and_load_provider(self, appconfiguration_connection_str snapshot_name = f"test-snapshot-{dynamic_snapshot_name_postfix}" try: - # Create the snapshot - snapshot = sdk_client.begin_create_snapshot( - name=snapshot_name, - filters=[ConfigurationSettingsFilter(key="snapshot_test_*")], # Include all our test keys - composition_type=SnapshotComposition.KEY, - retention_period=3600, # Min valid value is 1 hour - ).result() - - # Verify snapshot was created successfully - if is_live(): - assert snapshot.name == snapshot_name - else: - assert snapshot.name == "Sanitized" - assert snapshot.status == SnapshotStatus.READY - assert snapshot.composition_type == SnapshotComposition.KEY - - # Load provider using the snapshot with refresh enabled + # Create the snapshot including both config settings and feature flags + create_snapshot( + sdk_client, + snapshot_name, + key_filters=["snapshot_test_*", ".appconfig.featureflag/SnapshotFeature*"], + ) + + # Load provider using the snapshot with refresh enabled and feature flags enabled provider = self.create_client( connection_string=appconfiguration_connection_string, selects=[ - SettingSelector(snapshot_name=snapshot_name), # Snapshot data + SettingSelector(snapshot_name=snapshot_name), # Snapshot data (includes feature flags) SettingSelector(key_filter="refresh_test_key"), # Non-snapshot key for refresh testing ], refresh_on=[WatchKey("refresh_test_key")], # Watch non-snapshot key for refresh @@ -235,6 +213,10 @@ def test_create_snapshot_and_load_provider(self, appconfiguration_connection_str snapshot_keys = [key for key in provider.keys() if key.startswith("snapshot_test_")] assert len(snapshot_keys) == 3 + # Verify feature flags from snapshots are NOT loaded as feature flags + # (snapshots don't support feature flag loading, only regular selects do) + assert FEATURE_MANAGEMENT_KEY not in provider, "Feature flags should not be loaded from snapshots" + # Test snapshot immutability: modify the original settings modified_settings = [ ConfigurationSetting( @@ -256,13 +238,9 @@ def test_create_snapshot_and_load_provider(self, appconfiguration_connection_str ), ] - # Update the original settings with new values - for setting in modified_settings: - sdk_client.set_configuration_setting(setting) - - # Add a completely new key after initial load + # Update the original settings with new values and add a new key new_key = ConfigurationSetting(key="new_key_added_after_load", value="new_value", label=NULL_CHAR) - sdk_client.set_configuration_setting(new_key) + set_test_settings(sdk_client, modified_settings + [new_key]) # Wait for refresh interval to pass time.sleep(1) @@ -293,23 +271,132 @@ def test_create_snapshot_and_load_provider(self, appconfiguration_connection_str assert provider_current["snapshot_test_json"]["nested"] == "MODIFIED_VALUE" # Modified value finally: - # Clean up: delete the snapshot and test settings - try: - # Archive the snapshot (delete is not supported, but archive effectively removes it) - sdk_client.archive_snapshot(snapshot_name) - except Exception: - pass - - # Clean up test settings - for setting in test_settings: - try: - sdk_client.delete_configuration_setting(key=setting.key, label=setting.label) - except Exception: - pass - - # Clean up additional test keys - try: - sdk_client.delete_configuration_setting(key="new_key_added_after_load", label=NULL_CHAR) - except Exception: - pass + # Clean up test resources + cleanup_settings = ( + test_settings + + test_feature_flags + + [ConfigurationSetting(key="new_key_added_after_load", value="", label=NULL_CHAR)] + ) + cleanup_test_resources( + sdk_client, + settings=cleanup_settings, + snapshot_names=[snapshot_name], + ) + return variables + + @pytest.mark.live_test_only # Needed to fix an azure core dependency compatibility issue + @app_config_decorator + @recorded_by_proxy + def test_create_snapshot_and_load_provider_with_feature_flags(self, appconfiguration_connection_string, **kwargs): + """Test creating a snapshot and loading provider with feature flags from non-snapshot selectors.""" + # Create SDK client for setup + sdk_client = self.create_sdk_client(appconfiguration_connection_string) + + # Create unique test configuration settings for the snapshot + test_settings = [ + ConfigurationSetting(key="ff_snapshot_test_key1", value="ff_snapshot_test_value1", label=NULL_CHAR), + ConfigurationSetting(key="ff_snapshot_test_key2", value="ff_snapshot_test_value2", label=NULL_CHAR), + ] + + # Create feature flag settings - some for snapshot, some for regular loading + # Note: Feature flags in snapshots are NOT loaded as feature flags by the provider + snapshot_feature_flags = [ + FeatureFlagConfigurationSetting( + feature_id="SnapshotOnlyFeature", + enabled=True, + label=NULL_CHAR, + ), + ] + + # Feature flags loaded via regular selectors (not from snapshot) + regular_feature_flags = [ + FeatureFlagConfigurationSetting( + feature_id="RegularFeature", + enabled=True, + label=NULL_CHAR, + ), + FeatureFlagConfigurationSetting( + feature_id="RegularFeatureDisabled", + enabled=False, + label=NULL_CHAR, + ), + ] + + # Set the configuration settings and feature flags + set_test_settings(sdk_client, test_settings + snapshot_feature_flags + regular_feature_flags) + + variables = kwargs.pop("variables", {}) + dynamic_snapshot_name_postfix = variables.setdefault("dynamic_ff_snapshot_name_postfix", str(int(time.time()))) + + # Create a unique snapshot name with timestamp to avoid conflicts + snapshot_name = f"test-ff-snapshot-{dynamic_snapshot_name_postfix}" + + try: + # Create the snapshot including config settings and snapshot-only feature flags + create_snapshot( + sdk_client, + snapshot_name, + key_filters=["ff_snapshot_test_*", ".appconfig.featureflag/SnapshotOnlyFeature"], + ) + + # Load provider using snapshot for config settings and regular selectors for feature flags + provider = self.create_client( + connection_string=appconfiguration_connection_string, + feature_flag_enabled=True, # Enable feature flags + feature_flag_selectors=[ + SettingSelector(snapshot_name=snapshot_name), # Load feature flags from snapshot + ], + ) + + # Verify snapshot configuration settings are loaded + assert provider["ff_snapshot_test_key1"] == "ff_snapshot_test_value1" + assert provider["ff_snapshot_test_key2"] == "ff_snapshot_test_value2" + + # Verify feature flags loaded via regular selectors ARE loaded + feature_flags = provider.get(FEATURE_MANAGEMENT_KEY, {}).get(FEATURE_FLAG_KEY, []) + feature_flag_ids = {ff["id"]: ff["enabled"] for ff in feature_flags} + + # Regular feature flags should be loaded + assert "RegularFeature" not in feature_flag_ids, "RegularFeature should not be loaded via regular selector" + assert "RegularFeatureDisabled" not in feature_flag_ids, "RegularFeatureDisabled should not be loaded" + + # Snapshot-only feature flag should be loaded as a feature flag + assert "SnapshotOnlyFeature" in feature_flag_ids, "SnapshotOnlyFeature should be loaded as FF from snapshot" + + # Verify exactly 1 feature flag is loaded (the snapshot-only one) + assert len(feature_flags) == 1, f"Expected 1 feature flag, got {len(feature_flags)}" + + # Modify the feature flags in the snapshot + modified_feature_flags = [ + FeatureFlagConfigurationSetting( + feature_id="SnapshotOnlyFeature", + enabled=False, # Changed from True to False + label=NULL_CHAR, + ), + ] + + set_test_settings(sdk_client, modified_feature_flags) + + # Load a fresh provider without snapshot to verify current feature flag values + provider_current = self.create_client( + connection_string=appconfiguration_connection_string, + feature_flag_enabled=True, + feature_flag_selectors=[ + SettingSelector(snapshot_name=snapshot_name), # Load feature flags from snapshot + ], + ) + + # Current feature flag values should be the original ones from snapshot (immutable) + current_feature_flags = provider_current.get(FEATURE_MANAGEMENT_KEY, {}).get(FEATURE_FLAG_KEY, []) + current_ff_ids = {ff["id"]: ff["enabled"] for ff in current_feature_flags} + assert current_ff_ids.get("SnapshotOnlyFeature") is True # Original value from snapshot (not modified) + + finally: + # Clean up test resources + cleanup_settings = test_settings + snapshot_feature_flags + regular_feature_flags + cleanup_test_resources( + sdk_client, + settings=cleanup_settings, + snapshot_names=[snapshot_name], + ) return variables diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py index 67451060f4f5..5863b13756e1 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py @@ -140,6 +140,88 @@ def create_feature_flag_config_setting(key, label, enabled, tags=None): return FeatureFlagConfigurationSetting(feature_id=key, label=label, enabled=enabled, tags=tags) +def cleanup_test_resources( + client, + settings=None, + snapshot_names=None, +): + """ + Clean up test resources from Azure App Configuration. + + :param client: The AzureAppConfigurationClient to use for cleanup. + :param settings: List of ConfigurationSetting objects to delete. + :param snapshot_names: List of snapshot names to archive. + """ + # Archive snapshots + if snapshot_names: + for snapshot_name in snapshot_names: + try: + client.archive_snapshot(snapshot_name) + except Exception: + pass + + # Delete configuration settings and feature flags + if settings: + for setting in settings: + try: + client.delete_configuration_setting(key=setting.key, label=setting.label) + except Exception: + pass + + +def set_test_settings(client, settings): + """ + Set multiple configuration settings in Azure App Configuration. + + :param client: The AzureAppConfigurationClient to use. + :param settings: List of ConfigurationSetting or FeatureFlagConfigurationSetting objects to set. + """ + for setting in settings: + client.set_configuration_setting(setting) + + +def create_snapshot(client, snapshot_name, key_filters, composition_type=None, retention_period=3600): + """ + Create a snapshot in Azure App Configuration and verify it was created successfully. + + :param client: The AzureAppConfigurationClient to use. + :param snapshot_name: The name of the snapshot to create. + :param key_filters: List of key filter strings to define what settings to include. + :param composition_type: The composition type for the snapshot (default: SnapshotComposition.KEY). + :param retention_period: The retention period in seconds (default: 3600, minimum valid value). + :return: The created snapshot. + """ + from azure.appconfiguration import SnapshotComposition, ConfigurationSettingsFilter, SnapshotStatus + from devtools_testutils import is_live + + if composition_type is None: + composition_type = SnapshotComposition.KEY + + # Convert key filter strings to ConfigurationSettingsFilter objects + filters = [ConfigurationSettingsFilter(key=key_filter) for key_filter in key_filters] + + snapshot = client.begin_create_snapshot( + name=snapshot_name, + filters=filters, + composition_type=composition_type, + retention_period=retention_period, + ).result() + + # Verify snapshot was created successfully + assert snapshot.status == SnapshotStatus.READY, f"Snapshot status is {snapshot.status}, expected READY" + assert ( + snapshot.composition_type == composition_type + ), f"Snapshot composition_type is {snapshot.composition_type}, expected {composition_type}" + + # Verify snapshot name (sanitized in playback mode) + if is_live(): + assert snapshot.name == snapshot_name, f"Snapshot name is {snapshot.name}, expected {snapshot_name}" + else: + assert snapshot.name == "Sanitized", f"Snapshot name is {snapshot.name}, expected 'Sanitized' in playback" + + return snapshot + + def get_feature_flag(client, feature_id): for feature_flag in client[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY]: if feature_flag["id"] == feature_id: