diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md index 54481723b4d3..5d36e017dc8a 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 2.5.1 (Unreleased) +## 2.6.0b1 (Unreleased) ### Features Added +- Feature flags created via the dedicated feature flag resource endpoint (`FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`) are now loaded automatically alongside key-value based feature flags whenever `feature_flag_enabled=True`. Both kinds are merged into the same `feature_management.feature_flags` list, with resource-based feature flags taking precedence over key-value based ones when they share the same name. No new `load()` options are required to opt in, and existing `feature_flag_selectors` filter both kinds. + ### Breaking Changes ### Bugs Fixed @@ -11,6 +13,7 @@ ### Other Changes - Bumped minimum dependency on `azure-core` to `>=1.31.0`. +- Bumped minimum dependency on `azure-appconfiguration` to `>=1.10.0b1` for `FeatureFlagClient`/`FeatureFlag` support. ## 2.5.0 (2026-05-22) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/README.md index 8500a5be6a22..7780b20300a2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/README.md @@ -377,6 +377,48 @@ config = load( +### Loading Feature Flags as Resources + +Feature flags can also be created using the dedicated feature flag resource endpoint (via `FeatureFlagClient`/`FeatureFlag` in `azure-appconfiguration`), instead of as classic key-value configuration settings. The provider loads both kinds side by side into the same `feature_management.feature_flags` list, with feature flag resources taking precedence over key-value based feature flags when they share the same name. No additional `load()` options are required to enable this — it happens automatically whenever `feature_flag_enabled=True`, using the same `feature_flag_selectors`. + + + +```python +from azure.appconfiguration.provider import load + +# Feature flags loaded from the feature flag resource endpoint are merged into the same +# feature_management.feature_flags list as key-value based feature flags. +config = load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, **kwargs) +feature_flags = config["feature_management"]["feature_flags"] +resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") +print(resource_beta["enabled"]) +``` + + + +The same `SettingSelector` used to filter key-value based feature flags also filters feature flag resources, by name, label, or tags. Note that selectors with a `snapshot_name` are not currently supported by the feature flag resource endpoint and are skipped when loading feature flag resources. + + + +```python +from azure.appconfiguration.provider import load, SettingSelector + +# The same SettingSelector used to filter key-value based feature flags also filters feature flag +# resources, by name/label/tags. +config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + **kwargs, +) +feature_flags = config["feature_management"]["feature_flags"] +resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") +print(resource_beta["enabled"]) +``` + + + ## JSON Content Type Configuration settings with a JSON content type (e.g., `application/json`) are automatically deserialized into their corresponding Python objects when loaded by the provider. @@ -469,6 +511,57 @@ This library uses the standard [logging](https://docs.python.org/3/library/loggi * **Configuration not refreshing** — Make sure you are calling `config.refresh()` periodically (e.g., before each request in a web app). The provider does not auto-refresh in the background. * **Startup failures** — If the store is unreachable during startup, the provider will retry until `startup_timeout` (default 100 seconds) is exceeded. Increase this value if your store is expected to have high latency. +## Testing + +(This content is for `azure-appconfiguration-provider` package developer only) + +The tests for this package are under the `tests/` directory and are split into two categories: + +* **Unit tests** (e.g. `tests/test_azureappconfigurationproviderbase.py`, `tests/test_configuration_client_manager.py`) — exercise internal logic in isolation using mocked clients. These do not require any App Configuration store, network access, or environment variables, and can be run at any time with no setup. +* **Integration tests** (e.g. `tests/test_provider.py`, `tests/test_provider_feature_flag_resources.py`, and their `tests/aio/` async equivalents) — exercise the provider end-to-end against an Azure App Configuration store. These tests are built on [`devtools_testutils`](https://github.com/Azure/azure-sdk-for-python/tree/main/eng/tools/azure-sdk-tools/devtools_testutils) and each test method is decorated with `@recorded_by_proxy` / `@recorded_by_proxy_async`, which route the test's HTTP traffic through the [test proxy](https://github.com/Azure/azure-sdk-tools/tree/main/tools/test-proxy) tool. + +### Live tests vs. recorded (playback) tests + +Whether an integration test makes a real network call or replays a recording is controlled entirely by the `AZURE_TEST_RUN_LIVE` environment variable, not by anything in this package's code: + +* `AZURE_TEST_RUN_LIVE=true` — Tests run in **live/record mode**. The test proxy forwards requests to the real endpoint configured via your environment variables (see below), and (unless `AZURE_SKIP_LIVE_RECORDING=true` is also set) records the request/response pairs as new recording files for use in future playback runs. +* `AZURE_TEST_RUN_LIVE` unset or `false` (the default, and what CI uses) — Tests run in **playback mode**. The test proxy replays the existing recordings instead of contacting the real service, so **no network calls are made** and no live App Configuration store is required. + +Recordings themselves are not stored directly in this repository — they live in the separate [`Azure/azure-sdk-assets`](https://github.com/Azure/azure-sdk-assets) repo, and this package's `assets.json` file pins the exact recordings revision (`Tag`) that CI uses. If you add or change integration tests, you need to generate new recordings and publish them: + +1. Run the affected tests with `AZURE_TEST_RUN_LIVE=true` (and without `AZURE_SKIP_LIVE_RECORDING`) so the test proxy records real interactions to local recording files. +2. From the repo root, push the new/updated recordings to the assets repo: + + ```bash + dotnet tool run test-proxy push -a sdk/appconfiguration/azure-appconfiguration-provider/assets.json + ``` + + This uploads the changed recordings and updates the `Tag` field in `assets.json`. +3. Commit the updated `assets.json` as part of your PR — this is what allows CI (which always runs in playback mode) to pick up the new recordings. + +Only re-record tests you added or intentionally changed; unrelated existing recordings don't need to be regenerated. + +### Environment variables for local testing + +To run the integration tests locally in live mode, create a `.env` file at the repository root (it is automatically loaded by `devtools_testutils`) with the following variables: + +``` +AZURE_TEST_RUN_LIVE=true +APPCONFIGURATION_CONNECTION_STRING= +APPCONFIGURATION_ENDPOINT_STRING=.azconfig.io> +APPCONFIGURATION_KEY_VAULT_REFERENCE= +APPCONFIGURATION_KEY_VAULT_REFERENCE2= +APPCONFIGURATION_KEYVAULT_SECRET_URL= +APPCONFIGURATION_KEYVAULT_SECRET_URL2= +``` + +Notes: + +* For key vault URI, you can create a secret in Azure Key Vault service. The key vault URI is the *Secret Identifier*, without the final version number. For example, if the secret identifier is `https://some_secret.vault.azure.net/secrets/fake-secret/30d8830ec5ed4a428d311292a826f452`, the key vault URI should be `https://some_secret.vault.azure.net/secrets/fake-secret/`. +* Authentication for Entra ID-based tests relies on your local Azure CLI login (`az login`); make sure you're signed in to the subscription that contains your App Configuration store. +* Add `AZURE_SKIP_LIVE_RECORDING=true` if you want to run tests live against the real store without generating/overwriting recording files (useful for a quick sanity check). +* Omit `AZURE_TEST_RUN_LIVE` (or set it to `false`) to run the same tests in playback mode against existing recordings — this does not require any of the App Configuration environment variables above. + ## Next steps Check out our Django and Flask examples to see how to use the provider in a web application. diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/assets.json b/sdk/appconfiguration/azure-appconfiguration-provider/assets.json index b9e5f6a80d69..ab4ac3e8fb87 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_34a63910b7" + "Tag": "python/appconfiguration/azure-appconfiguration-provider_fb87386e95" } 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 a3f8f826679c..6161477e40b2 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py @@ -17,6 +17,7 @@ ) from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, + FeatureFlag, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, ) @@ -107,6 +108,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f ) configuration_settings: List[ConfigurationSetting] = [] feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None + feature_flag_resources: Optional[List[FeatureFlag]] = None # Timer needs to be reset even if no refresh happened if time had passed configuration_refresh_attempted = False @@ -115,6 +117,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy() page_etags: List[List[str]] = [] feature_flag_page_etags: List[List[str]] = [] + feature_flag_resource_etags: List[List[str]] = [] try: if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh(): configuration_refresh_attempted = True @@ -148,6 +151,16 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f self._feature_flag_selectors, headers=headers, **kwargs ) + # Feature flag resources are loaded independently of the key-value based feature flags, using their + # own page-level etag state, since they are a separate resource type with a separate + # change-detection mechanism. + if not self._feature_flag_resource_etags or client.check_feature_flag_resource_etags( + self._feature_flag_selectors, self._feature_flag_resource_etags, headers=headers, **kwargs + ): + feature_flag_resources, feature_flag_resource_etags = client.load_feature_flag_resources( + self._feature_flag_selectors, headers=headers, **kwargs + ) + # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -157,7 +170,9 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f # Configuration Settings have been refreshed processed_settings = self._process_configurations(configuration_settings, client) - processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags) + processed_settings = self._process_feature_flags( + processed_settings, processed_feature_flags, feature_flags, feature_flag_resources + ) self._dict = processed_settings if settings_refreshed: self._page_etags = page_etags @@ -165,12 +180,14 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f self._watched_settings.update(updated_watched_settings) if feature_flags is not None: self._feature_flag_page_etags = feature_flag_page_etags + if feature_flag_resources is not None: + self._feature_flag_resource_etags = feature_flag_resource_etags # Reset timers at the same time as they should load from the same store. if configuration_refresh_attempted: self._refresh_timer.reset() if self._feature_flag_refresh_enabled and feature_flag_refresh_attempted: self._feature_flag_refresh_timer.reset() - if (settings_refreshed or feature_flags) and self._on_refresh_success: + if (settings_refreshed or feature_flags or feature_flag_resources) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -278,6 +295,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> processed_settings = self._process_configurations(configuration_settings, client) feature_flag_page_etags: List[List[str]] = [] + feature_flag_resource_etags: List[List[str]] = [] if self._feature_flag_enabled: feature_flags: List[FeatureFlagConfigurationSetting] feature_flags, feature_flag_page_etags = client.load_feature_flags( @@ -285,7 +303,14 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> headers=headers, **kwargs, ) - processed_settings = self._process_feature_flags(processed_settings, [], feature_flags) + feature_flag_resources, feature_flag_resource_etags = client.load_feature_flag_resources( + self._feature_flag_selectors, + headers=headers, + **kwargs, + ) + processed_settings = self._process_feature_flags( + processed_settings, [], feature_flags, feature_flag_resources + ) for (key, label), etag in self._watched_settings.items(): if not etag: try: @@ -310,6 +335,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) -> self._dict = processed_settings self._page_etags = page_etags self._feature_flag_page_etags = feature_flag_page_etags + self._feature_flag_resource_etags = feature_flag_resource_etags return True except AzureError as e: logger.warning("Failed to load configurations from endpoint %s.\n %s", client.endpoint, e.message) 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 e5b6240d74e6..d2cc796cdd5c 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationproviderbase.py @@ -25,6 +25,7 @@ from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, FeatureFlagConfigurationSetting, + FeatureFlag, ) from ._models import SettingSelector from ._constants import ( @@ -38,6 +39,10 @@ APP_CONFIG_AICC_MIME_PROFILE, FEATURE_MANAGEMENT_KEY, FEATURE_FLAG_KEY, + FEATURE_FLAG_ID_FIELD, + FEATURE_FLAG_NAME_FIELD, + FEATURE_FLAG_KV_REFERENCE_SEGMENT, + FEATURE_FLAG_RESOURCE_REFERENCE_SEGMENT, ) from ._refresh_timer import _RefreshTimer from ._request_tracing_context import _RequestTracingContext @@ -115,6 +120,16 @@ def __init__(self, **kwargs: Any) -> None: self._refresh_enabled = refresh_enabled self._page_etags: List[List[str]] = [] self._feature_flag_page_etags: List[List[str]] = [] + # Per-selector collection ETags for feature flags loaded from the feature flag resource endpoint. This is + # independent of the key-value based feature_flag_page_etags, since the resource endpoint is a separate + # resource type with its own change-detection mechanism. + self._feature_flag_resource_etags: List[List[str]] = [] + # Feature flags are loaded from two independent sources: the classic key-value store, and the newer + # dedicated feature flag resource endpoint. Each source's processed output is cached separately so that a + # refresh of one source does not require re-processing or discarding the other source's data. The two are + # merged (resource-based feature flags take precedence on identifier collision) whenever either changes. + self._processed_kv_feature_flags: List[Dict[str, Any]] = [] + self._processed_resource_feature_flags: List[Dict[str, Any]] = [] self._tracing_context = _RequestTracingContext(kwargs.pop("load_balancing_enabled", False)) self._update_lock = Lock() self._refresh_lock = Lock() @@ -132,7 +147,7 @@ def _update_ff_telemetry_metadata( self, endpoint: str, feature_flag: FeatureFlagConfigurationSetting, feature_flag_value: Dict ): """ - Add telemetry metadata to feature flag values. + Add telemetry metadata to feature flag values loaded from the classic key-value store. :param endpoint: The App Configuration endpoint URL. :type endpoint: str @@ -141,6 +156,61 @@ def _update_ff_telemetry_metadata( :param feature_flag_value: The feature flag value dictionary to update. :type feature_flag_value: Dict[str, Any] """ + self._update_ff_telemetry_metadata_common( + endpoint, + feature_flag.key, + feature_flag.label, + feature_flag.etag, + feature_flag_value, + FEATURE_FLAG_KV_REFERENCE_SEGMENT, + ) + + def _update_ff_resource_telemetry_metadata(self, endpoint: str, feature_flag: FeatureFlag, feature_flag_value: Dict): + """ + Add telemetry metadata to feature flag values loaded from the feature flag resource endpoint. + + :param endpoint: The App Configuration endpoint URL. + :type endpoint: str + :param feature_flag: The feature flag resource. + :type feature_flag: ~azure.appconfiguration.FeatureFlag + :param feature_flag_value: The feature flag value dictionary to update. + :type feature_flag_value: Dict[str, Any] + """ + self._update_ff_telemetry_metadata_common( + endpoint, + feature_flag.name, + feature_flag.label, + feature_flag.etag, + feature_flag_value, + FEATURE_FLAG_RESOURCE_REFERENCE_SEGMENT, + ) + + def _update_ff_telemetry_metadata_common( # pylint: disable=too-many-positional-arguments + self, + endpoint: str, + identifier: str, + label: Optional[str], + etag: Optional[str], + feature_flag_value: Dict, + reference_path_segment: str, + ): + """ + Add telemetry metadata to a feature flag value dictionary, regardless of which endpoint it was loaded from. + + :param endpoint: The App Configuration endpoint URL. + :type endpoint: str + :param identifier: The identifier of the feature flag (key for key-value based, name for resource-based). + :type identifier: str + :param label: The label of the feature flag. + :type label: Optional[str] + :param etag: The etag of the feature flag. + :type etag: Optional[str] + :param feature_flag_value: The feature flag value dictionary to update. + :type feature_flag_value: Dict[str, Any] + :param reference_path_segment: The path segment to use when building the feature flag reference URL, e.g. + "kv" for key-value based feature flags or "ff" for resource-based feature flags. + :type reference_path_segment: str + """ if TELEMETRY_KEY not in feature_flag_value: # Initialize telemetry dictionary if not present feature_flag_value[TELEMETRY_KEY] = {} @@ -148,15 +218,15 @@ def _update_ff_telemetry_metadata( # Update telemetry metadata for application insights/logging in feature management if METADATA_KEY not in feature_flag_value[TELEMETRY_KEY]: feature_flag_value[TELEMETRY_KEY][METADATA_KEY] = {} - feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ETAG_KEY] = feature_flag.etag + feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ETAG_KEY] = etag if feature_flag_value[TELEMETRY_KEY].get("enabled"): self._tracing_context.uses_telemetry = True if not endpoint.endswith("/"): endpoint += "/" - feature_flag_reference = f"{endpoint}kv/{feature_flag.key}" - if feature_flag.label and not feature_flag.label.isspace(): - feature_flag_reference += f"?label={feature_flag.label}" + feature_flag_reference = f"{endpoint}{reference_path_segment}/{identifier}" + if label and not label.isspace(): + feature_flag_reference += f"?label={label}" feature_flag_value[TELEMETRY_KEY][METADATA_KEY][FEATURE_FLAG_REFERENCE_KEY] = feature_flag_reference allocation_id = self._generate_allocation_id(feature_flag_value) @@ -240,10 +310,14 @@ def _generate_allocation_id(feature_flag_value: Dict[str, JSON]) -> Optional[str for v in sorted_variants: allocation_id += f"{base64.b64encode(v.get('name', '').encode()).decode()}," + # Key-value based feature flags store the variant value under "configuration_value". Feature + # flags loaded from the feature flag resource endpoint store it under "value" instead. if "configuration_value" in v: allocation_id += ( f"{json.dumps(v.get('configuration_value', ''), separators=(',', ':'), sort_keys=True)}" ) + elif "value" in v: + allocation_id += f"{json.dumps(v.get('value', ''), separators=(',', ':'), sort_keys=True)}" allocation_id += ";" if sorted_variants: allocation_id = allocation_id[:-1] @@ -369,17 +443,56 @@ def _process_feature_flags( processed_settings: Dict[str, Any], processed_feature_flags: List[Dict[str, Any]], feature_flags: Optional[List[FeatureFlagConfigurationSetting]], + feature_flag_resources: Optional[List[FeatureFlag]] = None, ) -> Dict[str, Any]: - if feature_flags: + if feature_flags or feature_flag_resources: # Reset feature flag usage self._tracing_context.reset_feature_filter_usage() - processed_feature_flags = [self._process_feature_flag(ff) for ff in feature_flags] + + if feature_flags: + self._processed_kv_feature_flags = [self._process_feature_flag(ff) for ff in feature_flags] + + if feature_flag_resources: + self._processed_resource_feature_flags = [ + self._process_feature_flag_resource(ff) for ff in feature_flag_resources + ] + + if feature_flags or feature_flag_resources: + processed_feature_flags = self._merge_feature_flags( + self._processed_kv_feature_flags, self._processed_resource_feature_flags + ) if self._feature_flag_enabled: processed_settings[FEATURE_MANAGEMENT_KEY] = {} processed_settings[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY] = processed_feature_flags return processed_settings + @staticmethod + def _merge_feature_flags( + kv_feature_flags: List[Dict[str, Any]], resource_feature_flags: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Merge feature flags loaded from the classic key-value store with feature flags loaded from the feature + flag resource endpoint. Feature flags are matched by their identifier (``id`` for key-value based feature + flags, ``name`` for resource-based feature flags). When both sources contain a feature flag with the same + identifier, the resource-based feature flag takes precedence. + + :param kv_feature_flags: The feature flags loaded from the classic key-value store. + :type kv_feature_flags: List[Dict[str, Any]] + :param resource_feature_flags: The feature flags loaded from the feature flag resource endpoint. + :type resource_feature_flags: List[Dict[str, Any]] + :return: The merged list of feature flags. + :rtype: List[Dict[str, Any]] + """ + merged: Dict[str, Dict[str, Any]] = {} + for ff in kv_feature_flags: + identifier = ff.get(FEATURE_FLAG_ID_FIELD) + merged[identifier] = ff + for ff in resource_feature_flags: + identifier = ff.get(FEATURE_FLAG_NAME_FIELD) + merged[identifier] = ff + return list(merged.values()) + def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) -> Dict[str, Any]: try: feature_flag_value = json.loads(feature_flag.value) @@ -390,6 +503,91 @@ def _process_feature_flag(self, feature_flag: FeatureFlagConfigurationSetting) - # Feature flag value is not a valid JSON return {} + def _process_feature_flag_resource(self, feature_flag: FeatureFlag) -> Dict[str, Any]: + """ + Convert a feature flag resource, loaded from the feature flag resource endpoint, into a dictionary using + the feature flag resource's native field names. + + :param feature_flag: The feature flag resource. + :type feature_flag: ~azure.appconfiguration.FeatureFlag + :return: The feature flag as a dictionary. + :rtype: Dict[str, Any] + """ + feature_flag_value: Dict[str, Any] = { + FEATURE_FLAG_NAME_FIELD: feature_flag.name, + "enabled": feature_flag.enabled, + } + if feature_flag.label and not feature_flag.label.isspace(): + feature_flag_value["label"] = feature_flag.label + if feature_flag.description: + feature_flag_value["description"] = feature_flag.description + + filter_names: List[Optional[str]] = [] + if feature_flag.conditions: + conditions_value: Dict[str, Any] = {} + if feature_flag.conditions.requirement_type: + conditions_value["requirement_type"] = feature_flag.conditions.requirement_type + if feature_flag.conditions.client_filters: + conditions_value["client_filters"] = [ + {"name": client_filter.name, "parameters": client_filter.parameters} + for client_filter in feature_flag.conditions.client_filters + ] + filter_names = [client_filter.name for client_filter in feature_flag.conditions.client_filters] + if conditions_value: + feature_flag_value["conditions"] = conditions_value + + if feature_flag.variants: + feature_flag_value["variants"] = [ + { + "name": variant.name, + "value": variant.value, + "content_type": variant.content_type, + "status_override": variant.status_override, + } + for variant in feature_flag.variants + ] + + if feature_flag.allocation: + allocation_value: Dict[str, Any] = {} + if feature_flag.allocation.default_when_disabled: + allocation_value["default_when_disabled"] = feature_flag.allocation.default_when_disabled + if feature_flag.allocation.default_when_enabled: + allocation_value["default_when_enabled"] = feature_flag.allocation.default_when_enabled + if feature_flag.allocation.percentile: + allocation_value["percentile"] = [ + { + "variant": percentile.variant, + "percentile_from": percentile.percentile_from, + "percentile_to": percentile.percentile_to, + } + for percentile in feature_flag.allocation.percentile + ] + if feature_flag.allocation.user: + allocation_value["user"] = [ + {"variant": user.variant, "users": user.users} for user in feature_flag.allocation.user + ] + if feature_flag.allocation.group: + allocation_value["group"] = [ + {"variant": group.variant, "groups": group.groups} for group in feature_flag.allocation.group + ] + if feature_flag.allocation.seed: + allocation_value["seed"] = feature_flag.allocation.seed + if allocation_value: + feature_flag_value["allocation"] = allocation_value + + if feature_flag.telemetry: + feature_flag_value["telemetry"] = { + "enabled": feature_flag.telemetry.enabled, + "metadata": dict(feature_flag.telemetry.metadata) if feature_flag.telemetry.metadata else {}, + } + + if feature_flag.tags: + feature_flag_value["tags"] = dict(feature_flag.tags) + + self._update_ff_resource_telemetry_metadata(self._origin_endpoint, feature_flag, feature_flag_value) + self._tracing_context.update_feature_filter_telemetry_by_names(filter_names) + return feature_flag_value + def _update_watched_settings( self, configuration_settings: List[ConfigurationSetting] ) -> Dict[Tuple[str, str], Optional[str]]: 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 651a55577222..056e375ab7de 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 @@ -17,6 +17,8 @@ ConfigurationSetting, AzureAppConfigurationClient, FeatureFlagConfigurationSetting, + FeatureFlag, + FeatureFlagClient, SnapshotComposition, ) from ._client_manager_base import ( @@ -35,6 +37,7 @@ @dataclass class _ConfigurationClientWrapper(_ConfigurationClientWrapperBase): _client: AzureAppConfigurationClient + _feature_flag_client: Optional[FeatureFlagClient] = None backoff_end_time: float = 0 failed_attempts: int = 0 LOGGER = getLogger(__name__) @@ -71,6 +74,14 @@ def from_credential( retry_backoff_max=retry_backoff_max, **kwargs, ), + FeatureFlagClient( + endpoint, + credential, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ), ) @classmethod @@ -98,6 +109,13 @@ def from_connection_string( retry_backoff_max=retry_backoff_max, **kwargs, ), + FeatureFlagClient.from_connection_string( + connection_string, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ), ) def _check_configuration_setting( @@ -282,6 +300,80 @@ def check_feature_flag_page_etags( return True return False + @distributed_trace + def load_feature_flag_resources( + self, feature_flag_selectors: List[SettingSelector], **kwargs + ) -> Tuple[List[FeatureFlag], List[List[str]]]: + """ + Loads feature flags from the feature flag resource endpoint using page-based iteration, collecting page + etags for each selector. Selectors with a ``snapshot_name`` are currently not supported by the feature flag + resource endpoint and are skipped. + + :param feature_flag_selectors: List of setting selectors to filter feature flags + :type feature_flag_selectors: List[SettingSelector] + :return: A tuple of (feature_flags, page_etags_per_selector) + :rtype: Tuple[List[~azure.appconfiguration.FeatureFlag], List[List[str]]] + """ + loaded_feature_flags: List[FeatureFlag] = [] + page_etags: List[List[str]] = [] + # Needs to be removed unknown keyword argument for the feature flag client + kwargs.pop("sentinel_keys", None) + if self._feature_flag_client is None: + return loaded_feature_flags, [[] for _ in feature_flag_selectors] + for select in feature_flag_selectors: + selector_etags: List[str] = [] + if select.snapshot_name is not None: + # Snapshots are not supported by the feature flag resource endpoint as of now + page_etags.append(selector_etags) + continue + feature_flags = self._feature_flag_client.list_feature_flags( + name_filter=select.key_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + iterator = feature_flags.by_page() + for page in iterator: + loaded_feature_flags.extend(page) + selector_etags.append(iterator.etag) + page_etags.append(selector_etags) + return loaded_feature_flags, page_etags + + @distributed_trace + def check_feature_flag_resource_etags( + self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs + ) -> bool: + """ + Checks if any feature flag resource page has changed using page etags. + + :param feature_flag_selectors: List of setting selectors for feature flags + :type feature_flag_selectors: List[SettingSelector] + :param page_etags: The page etags from the last load, one list per selector + :type page_etags: List[List[str]] + :return: True if any page has changed, False otherwise + :rtype: bool + """ + if self._feature_flag_client is None: + return False + for i, select in enumerate(feature_flag_selectors): + if select.snapshot_name is not None: + # Snapshots are not supported by the feature flag resource endpoint + continue + if i >= len(page_etags): + # Missing or stale etag state should trigger a refresh instead of failing. + return True + selector_etags = page_etags[i] + feature_flags = self._feature_flag_client.list_feature_flags( + name_filter=select.key_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + for _ in feature_flags.by_page(match_conditions=selector_etags): + # If any page is returned, it means that page has changed + return True + return False + @distributed_trace def get_updated_watched_settings( self, watched_settings: Mapping[Tuple[str, str], Optional[str]], headers: Dict[str, str], **kwargs @@ -362,13 +454,19 @@ def close(self) -> None: Closes the connection to Azure App Configuration. """ self._client.close() + if self._feature_flag_client is not None: + self._feature_flag_client.close() def __enter__(self): self._client.__enter__() + if self._feature_flag_client is not None: + self._feature_flag_client.__enter__() return self def __exit__(self, *args): self._client.__exit__(*args) + if self._feature_flag_client is not None: + self._feature_flag_client.__exit__(*args) def resolve_snapshot_reference(self, setting: ConfigurationSetting, **kwargs) -> List[ConfigurationSetting]: """ diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py index 3e68591bb46c..fc916cebff3a 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py @@ -15,6 +15,15 @@ ALLOCATION_ID_KEY = "AllocationId" ETAG_KEY = "ETag" +# Identifier field used by feature flags loaded from the classic key-value store. +FEATURE_FLAG_ID_FIELD = "id" +# Identifier field used by feature flags loaded from the dedicated feature flag resource endpoint. +FEATURE_FLAG_NAME_FIELD = "name" +# Path segment used to build the feature flag reference URL for feature flags loaded from the key-value store. +FEATURE_FLAG_KV_REFERENCE_SEGMENT = "kv" +# Path segment used to build the feature flag reference URL for feature flags loaded from the resource endpoint. +FEATURE_FLAG_RESOURCE_REFERENCE_SEGMENT = "ff" + # ------------------------------------------------------------------------ # Environment Variable Constants # ------------------------------------------------------------------------ diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py index bc308d0bf1ac..330a719613a6 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_request_tracing_context.py @@ -243,15 +243,26 @@ def update_feature_filter_telemetry(self, feature_flag) -> None: # Constants are already imported at module level if feature_flag.filters: - for filter in feature_flag.filters: - if filter.get("name") in PERCENTAGE_FILTER_NAMES: - self.feature_filter_usage[PERCENTAGE_FILTER_KEY] = True - elif filter.get("name") in TIME_WINDOW_FILTER_NAMES: - self.feature_filter_usage[TIME_WINDOW_FILTER_KEY] = True - elif filter.get("name") in TARGETING_FILTER_NAMES: - self.feature_filter_usage[TARGETING_FILTER_KEY] = True - else: - self.feature_filter_usage[CUSTOM_FILTER_KEY] = True + self.update_feature_filter_telemetry_by_names(filter.get("name") for filter in feature_flag.filters) + + def update_feature_filter_telemetry_by_names(self, filter_names) -> None: + """ + Track feature filter usage for App Configuration telemetry, given the filter names directly. Used for feature + flags that don't expose their filters as dictionaries, e.g. feature flags loaded from the feature flag + resource endpoint. + + :param filter_names: The names of the filters used by a feature flag. + :type filter_names: Iterable[Optional[str]] + """ + for name in filter_names: + if name in PERCENTAGE_FILTER_NAMES: + self.feature_filter_usage[PERCENTAGE_FILTER_KEY] = True + elif name in TIME_WINDOW_FILTER_NAMES: + self.feature_filter_usage[TIME_WINDOW_FILTER_KEY] = True + elif name in TARGETING_FILTER_NAMES: + self.feature_filter_usage[TARGETING_FILTER_KEY] = True + else: + self.feature_filter_usage[CUSTOM_FILTER_KEY] = True def reset_feature_filter_usage(self) -> None: """Reset the feature filter usage tracking.""" diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py index 41676d00c483..0f4ca7972c61 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_version.py @@ -4,4 +4,4 @@ # license information. # ------------------------------------------------------------------------- -VERSION = "2.5.1" +VERSION = "2.6.0b1" 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 abcb6233e9a1..c2bde9f89586 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 @@ -15,9 +15,10 @@ from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, FeatureFlagConfigurationSetting, + FeatureFlag, SnapshotComposition, ) -from azure.appconfiguration.aio import AzureAppConfigurationClient +from azure.appconfiguration.aio import AzureAppConfigurationClient, FeatureFlagClient from .._client_manager_base import ( _ConfigurationClientWrapperBase, ConfigurationClientManagerBase, @@ -37,6 +38,7 @@ @dataclass class _AsyncConfigurationClientWrapper(_ConfigurationClientWrapperBase): _client: AzureAppConfigurationClient + _feature_flag_client: Optional[FeatureFlagClient] = None backoff_end_time: float = 0 failed_attempts: int = 0 LOGGER = getLogger(__name__) @@ -73,6 +75,14 @@ def from_credential( retry_backoff_max=retry_backoff_max, **kwargs, ), + FeatureFlagClient( + endpoint, + credential, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ), ) @classmethod @@ -100,6 +110,13 @@ def from_connection_string( retry_backoff_max=retry_backoff_max, **kwargs, ), + FeatureFlagClient.from_connection_string( + connection_string, + user_agent=user_agent, + retry_total=retry_total, + retry_backoff_max=retry_backoff_max, + **kwargs, + ), ) async def _check_configuration_setting( @@ -284,6 +301,82 @@ async def check_feature_flag_page_etags( return True return False + @distributed_trace + @distributed_trace + async def load_feature_flag_resources( + self, feature_flag_selectors: List[SettingSelector], **kwargs + ) -> Tuple[List[FeatureFlag], List[List[str]]]: + """ + Loads feature flags from the feature flag resource endpoint using page-based iteration, collecting page + etags for each selector. Selectors with a ``snapshot_name`` are not supported by the feature flag resource + endpoint and are skipped. + + :param feature_flag_selectors: List of setting selectors to filter feature flags + :type feature_flag_selectors: List[SettingSelector] + :return: A tuple of (feature_flags, page_etags_per_selector) + :rtype: Tuple[List[~azure.appconfiguration.FeatureFlag], List[List[str]]] + """ + loaded_feature_flags: List[FeatureFlag] = [] + page_etags: List[List[str]] = [] + # Needs to be removed unknown keyword argument for the feature flag client + kwargs.pop("sentinel_keys", None) + if self._feature_flag_client is None: + return loaded_feature_flags, [[] for _ in feature_flag_selectors] + for select in feature_flag_selectors: + selector_etags: List[str] = [] + if select.snapshot_name is not None: + # Snapshots are not supported by the feature flag resource endpoint + page_etags.append(selector_etags) + continue + feature_flags = self._feature_flag_client.list_feature_flags( + name_filter=select.key_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + iterator = feature_flags.by_page() + async for page in iterator: + async for ff in page: + loaded_feature_flags.append(ff) + selector_etags.append(iterator.etag) # type: ignore[attr-defined] + page_etags.append(selector_etags) + return loaded_feature_flags, page_etags + + @distributed_trace + async def check_feature_flag_resource_etags( + self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs + ) -> bool: + """ + Checks if any feature flag resource page has changed using page etags. + + :param feature_flag_selectors: List of setting selectors for feature flags + :type feature_flag_selectors: List[SettingSelector] + :param page_etags: The page etags from the last load, one list per selector + :type page_etags: List[List[str]] + :return: True if any page has changed, False otherwise + :rtype: bool + """ + if self._feature_flag_client is None: + return False + for i, select in enumerate(feature_flag_selectors): + if select.snapshot_name is not None: + # Snapshots are not supported by the feature flag resource endpoint + continue + if i >= len(page_etags): + # Missing or stale etag state should trigger a refresh instead of failing. + return True + selector_etags = page_etags[i] + feature_flags = self._feature_flag_client.list_feature_flags( + name_filter=select.key_filter, + label_filter=select.label_filter, + tags_filter=select.tag_filters, + **kwargs, + ) + async for _ in feature_flags.by_page(match_conditions=selector_etags): # type: ignore[call-arg] + # If any page is returned, it means that page has changed + return True + return False + @distributed_trace async def get_updated_watched_settings( self, watched_settings: Mapping[Tuple[str, str], Optional[str]], headers: Dict[str, str], **kwargs @@ -364,13 +457,19 @@ async def close(self) -> None: Closes the connection to Azure App Configuration. """ await self._client.close() + if self._feature_flag_client is not None: + await self._feature_flag_client.close() async def __aenter__(self): await self._client.__aenter__() + if self._feature_flag_client is not None: + await self._feature_flag_client.__aenter__() return self async def __aexit__(self, *args): await self._client.__aexit__(*args) + if self._feature_flag_client is not None: + await self._feature_flag_client.__aexit__(*args) async def resolve_snapshot_reference(self, setting: ConfigurationSetting, **kwargs) -> List[ConfigurationSetting]: """ 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 458bd10bcce8..819102ad63ea 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 @@ -19,6 +19,7 @@ ) from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module ConfigurationSetting, + FeatureFlag, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, ) @@ -120,6 +121,7 @@ async def _attempt_refresh( ) configuration_settings: List[ConfigurationSetting] = [] feature_flags: Optional[List[FeatureFlagConfigurationSetting]] = None + feature_flag_resources: Optional[List[FeatureFlag]] = None # Timer needs to be reset even if no refresh happened if time had passed configuration_refresh_attempted = False @@ -128,6 +130,7 @@ async def _attempt_refresh( existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy() page_etags: List[List[str]] = [] feature_flag_page_etags: List[List[str]] = [] + feature_flag_resource_etags: List[List[str]] = [] try: if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh(): configuration_refresh_attempted = True @@ -160,6 +163,16 @@ async def _attempt_refresh( feature_flags, feature_flag_page_etags = await client.load_feature_flags( self._feature_flag_selectors, headers=headers, **kwargs ) + + # Feature flag resources are loaded independently of the key-value based feature flags, using their + # own page-level etag state, since they are a separate resource type with a separate + # change-detection mechanism. + if not self._feature_flag_resource_etags or await client.check_feature_flag_resource_etags( + self._feature_flag_selectors, self._feature_flag_resource_etags, headers=headers, **kwargs + ): + feature_flag_resources, feature_flag_resource_etags = await client.load_feature_flag_resources( + self._feature_flag_selectors, headers=headers, **kwargs + ) # Default to existing settings if no refresh occurred processed_settings = self._dict @@ -169,7 +182,9 @@ async def _attempt_refresh( # Configuration Settings have been refreshed processed_settings = await self._process_configurations(configuration_settings, client) - processed_settings = self._process_feature_flags(processed_settings, processed_feature_flags, feature_flags) + processed_settings = self._process_feature_flags( + processed_settings, processed_feature_flags, feature_flags, feature_flag_resources + ) self._dict = processed_settings if settings_refreshed: self._page_etags = page_etags @@ -177,12 +192,14 @@ async def _attempt_refresh( self._watched_settings.update(updated_watched_settings) if feature_flags is not None: self._feature_flag_page_etags = feature_flag_page_etags + if feature_flag_resources is not None: + self._feature_flag_resource_etags = feature_flag_resource_etags # Reset timers at the same time as they should load from the same store. if configuration_refresh_attempted: self._refresh_timer.reset() if self._feature_flag_refresh_enabled and feature_flag_refresh_attempted: self._feature_flag_refresh_timer.reset() - if (settings_refreshed or feature_flags) and self._on_refresh_success: + if (settings_refreshed or feature_flags or feature_flag_resources) and self._on_refresh_success: self._on_refresh_success() except AzureError as e: logger.warning("Failed to refresh configurations from endpoint %s", client.endpoint) @@ -290,6 +307,7 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A processed_settings = await self._process_configurations(configuration_settings, client) feature_flag_page_etags: List[List[str]] = [] + feature_flag_resource_etags: List[List[str]] = [] if self._feature_flag_enabled: feature_flags: List[FeatureFlagConfigurationSetting] feature_flags, feature_flag_page_etags = await client.load_feature_flags( @@ -297,7 +315,14 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A headers=headers, **kwargs, ) - processed_settings = self._process_feature_flags(processed_settings, [], feature_flags) + feature_flag_resources, feature_flag_resource_etags = await client.load_feature_flag_resources( + self._feature_flag_selectors, + headers=headers, + **kwargs, + ) + processed_settings = self._process_feature_flags( + processed_settings, [], feature_flags, feature_flag_resources + ) for (key, label), etag in self._watched_settings.items(): if not etag: try: @@ -324,6 +349,7 @@ async def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: A self._dict = processed_settings self._page_etags = page_etags self._feature_flag_page_etags = feature_flag_page_etags + self._feature_flag_resource_etags = feature_flag_resource_etags return True except AzureError as e: logger.warning("Failed to load configurations from endpoint %s.\n %s", client.endpoint, e.message) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md b/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md index 90b21ea17f95..e15d0f6b1d6e 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/README.md @@ -49,6 +49,8 @@ pip install azure.appconfiguration.provider | entra_id_sample.py | demos connecting to app configuration with Entra ID | | connection_string_sample.py | demos connecting to app configuration with a Connection String | | key_vault_reference_sample.py | demos resolving key vault references with App Configuration | +| feature_flag_resource_sample.py | demos loading feature flags created via the dedicated feature flag resource endpoint | +| async_feature_flag_resource_sample.py | async version of feature_flag_resource_sample.py | ## Next steps diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py new file mode 100644 index 000000000000..2b11ecf1ba1f --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_feature_flag_resource_sample.py @@ -0,0 +1,80 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +""" +FILE: async_feature_flag_resource_sample.py +DESCRIPTION: + This sample demonstrates loading feature flags that were created using the dedicated feature flag + resource endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value + based feature flags stored as configuration settings. The provider loads both kinds of feature + flags side by side into the same ``feature_management.feature_flags`` list, so no additional + ``load()`` options are required to opt in. This is the async version of feature_flag_resource_sample.py. +USAGE: python async_feature_flag_resource_sample.py + Set the environment variable APPCONFIGURATION_ENDPOINT_STRING with your App Configuration + connection endpoint before running the sample. +""" +import os +import asyncio +from sample_utilities import get_authority, get_credential, get_client_modifications +from azure.appconfiguration.aio import FeatureFlagClient +from azure.appconfiguration import FeatureFlag +from azure.appconfiguration.provider.aio import load +from azure.appconfiguration.provider import SettingSelector + + +async def main(): + endpoint = os.environ["APPCONFIGURATION_ENDPOINT_STRING"] + authority = get_authority(endpoint) + credential = get_credential(authority, is_async=True) + kwargs = get_client_modifications() + + # Creating a feature flag using the dedicated feature flag resource endpoint. This is a separate + # resource type from the classic key-value based feature flags, and is managed via FeatureFlagClient + # instead of AzureAppConfigurationClient. + feature_flag_client = FeatureFlagClient(endpoint, credential, **kwargs) + await feature_flag_client.set_feature_flag(FeatureFlag(name="ResourceBeta", enabled=True)) + + try: + # [START feature_flag_resource_loading_async] + from azure.appconfiguration.provider.aio import load + + # Feature flags loaded from the feature flag resource endpoint are merged into the same + # feature_management.feature_flags list as key-value based feature flags. + config = await load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, **kwargs) + feature_flags = config["feature_management"]["feature_flags"] + resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") + print(resource_beta["enabled"]) + + await config.close() + # [END feature_flag_resource_loading_async] + + # [START feature_flag_resource_selector_async] + from azure.appconfiguration.provider.aio import load + from azure.appconfiguration.provider import SettingSelector + + # The same SettingSelector used to filter key-value based feature flags also filters feature flag + # resources, by name/label/tags. + config = await load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + **kwargs, + ) + feature_flags = config["feature_management"]["feature_flags"] + resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") + print(resource_beta["enabled"]) + + await config.close() + # [END feature_flag_resource_selector_async] + finally: + # Cleaning up the feature flag resource created for this sample. + await feature_flag_client.delete_feature_flag("ResourceBeta") + await feature_flag_client.close() + await credential.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py new file mode 100644 index 000000000000..0545d534f1a6 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/feature_flag_resource_sample.py @@ -0,0 +1,65 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +""" +FILE: feature_flag_resource_sample.py +DESCRIPTION: + This sample demonstrates loading feature flags that were created using the dedicated feature flag + resource endpoint (via ``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value + based feature flags stored as configuration settings. The provider loads both kinds of feature + flags side by side into the same ``feature_management.feature_flags`` list, so no additional + ``load()`` options are required to opt in. +USAGE: python feature_flag_resource_sample.py + Set the environment variable APPCONFIGURATION_ENDPOINT_STRING with your App Configuration + connection endpoint before running the sample. +""" +import os +from sample_utilities import get_authority, get_credential, get_client_modifications +from azure.appconfiguration import FeatureFlag, FeatureFlagClient +from azure.appconfiguration.provider import load, SettingSelector + +endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING") +authority = get_authority(endpoint) +credential = get_credential(authority) +kwargs = get_client_modifications() + +# Creating a feature flag using the dedicated feature flag resource endpoint. This is a separate resource +# type from the classic key-value based feature flags, and is managed via FeatureFlagClient instead of +# AzureAppConfigurationClient. +feature_flag_client = FeatureFlagClient(endpoint, credential, **kwargs) +feature_flag_client.set_feature_flag(FeatureFlag(name="ResourceBeta", enabled=True)) + +try: + # [START feature_flag_resource_loading] + from azure.appconfiguration.provider import load + + # Feature flags loaded from the feature flag resource endpoint are merged into the same + # feature_management.feature_flags list as key-value based feature flags. + config = load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, **kwargs) + feature_flags = config["feature_management"]["feature_flags"] + resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") + print(resource_beta["enabled"]) + # [END feature_flag_resource_loading] + + # [START feature_flag_resource_selector] + from azure.appconfiguration.provider import load, SettingSelector + + # The same SettingSelector used to filter key-value based feature flags also filters feature flag + # resources, by name/label/tags. + config = load( + endpoint=endpoint, + credential=credential, + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Resource*")], + **kwargs, + ) + feature_flags = config["feature_management"]["feature_flags"] + resource_beta = next(flag for flag in feature_flags if flag.get("name") == "ResourceBeta") + print(resource_beta["enabled"]) + # [END feature_flag_resource_selector] +finally: + # Cleaning up the feature flag resource created for this sample. + feature_flag_client.delete_feature_flag("ResourceBeta") + feature_flag_client.close() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py index d756d6d66783..495014bbd097 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/setup.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/setup.py @@ -73,7 +73,7 @@ python_requires=">=3.6", install_requires=[ "azure-core>=1.31.0", - "azure-appconfiguration>=1.8.0", + "azure-appconfiguration>=1.10.0b1", "azure-keyvault-secrets>=4.3.0", "dnspython>=2.6.1", ], diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.py new file mode 100644 index 000000000000..538eddbcb1f7 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/aio/test_async_provider_feature_flag_resources.py @@ -0,0 +1,153 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +""" +Tests for loading feature flags from the dedicated feature flag resource endpoint +(``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value based +``FeatureFlagConfigurationSetting`` stored via ``AzureAppConfigurationClient`` (async version). +""" +import functools +from devtools_testutils import EnvironmentVariableLoader +from devtools_testutils.aio import recorded_by_proxy_async +from testcase import has_feature_flag, get_feature_flag +from asynctestcase import AppConfigTestCase +from test_constants import APPCONFIGURATION_ENDPOINT_STRING, FEATURE_MANAGEMENT_KEY +from azure.appconfiguration import FeatureFlag, FeatureFlagConfigurationSetting +from azure.appconfiguration.provider import SettingSelector +from azure.appconfiguration.provider._constants import NULL_CHAR + +AppConfigProviderPreparer = functools.partial( + EnvironmentVariableLoader, + "appconfiguration", + appconfiguration_endpoint_string=APPCONFIGURATION_ENDPOINT_STRING, +) + + +class TestAppConfigurationProviderFeatureFlagResources(AppConfigTestCase): + """Tests for the provider loading feature flags from the dedicated feature flag resource endpoint (async).""" + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_load_feature_flag_resource(self, appconfiguration_endpoint_string): + """A feature flag created via the feature flag resource endpoint should be loaded by the provider.""" + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceOnlyFeature", enabled=True) + await feature_flag_client.set_feature_flag(feature_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="ResourceOnlyFeature")], + ) as client: + assert FEATURE_MANAGEMENT_KEY in client + assert has_feature_flag(client, "ResourceOnlyFeature", enabled=True) + finally: + await feature_flag_client.delete_feature_flag("ResourceOnlyFeature") + await feature_flag_client.close() + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_load_feature_flag_resource_disabled(self, appconfiguration_endpoint_string): + """A disabled feature flag resource should be loaded with enabled set to False.""" + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceDisabledFeature", enabled=False) + await feature_flag_client.set_feature_flag(feature_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="ResourceDisabledFeature")], + ) as client: + assert has_feature_flag(client, "ResourceDisabledFeature", enabled=False) + finally: + await feature_flag_client.delete_feature_flag("ResourceDisabledFeature") + await feature_flag_client.close() + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_load_feature_flag_resource_with_label(self, appconfiguration_endpoint_string): + """A feature flag resource with a label should be loaded when the label filter matches.""" + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceLabeledFeature", enabled=True, label="test_label") + await feature_flag_client.set_feature_flag(feature_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[ + SettingSelector(key_filter="ResourceLabeledFeature", label_filter="test_label") + ], + ) as client: + assert has_feature_flag(client, "ResourceLabeledFeature", enabled=True) + finally: + await feature_flag_client.delete_feature_flag("ResourceLabeledFeature", label="test_label") + await feature_flag_client.close() + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_feature_flag_resource_selector_filters_by_name(self, appconfiguration_endpoint_string): + """The feature_flag_selectors key_filter should scope which feature flag resources are loaded.""" + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + included_flag = FeatureFlag(name="IncludedResourceFeature", enabled=True) + excluded_flag = FeatureFlag(name="ExcludedResourceFeature", enabled=True) + await feature_flag_client.set_feature_flag(included_flag) + await feature_flag_client.set_feature_flag(excluded_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Included*")], + ) as client: + assert has_feature_flag(client, "IncludedResourceFeature", enabled=True) + assert not has_feature_flag(client, "ExcludedResourceFeature") + finally: + await feature_flag_client.delete_feature_flag("IncludedResourceFeature") + await feature_flag_client.delete_feature_flag("ExcludedResourceFeature") + await feature_flag_client.close() + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy_async + async def test_feature_flag_resource_overrides_key_value(self, appconfiguration_endpoint_string): + """A feature flag resource should take precedence over a key-value based feature flag with the + same identifier when both are loaded.""" + appconfig_client = self.create_appconfig_client(appconfiguration_endpoint_string) + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + + kv_feature_flag = FeatureFlagConfigurationSetting(feature_id="OverlapFeature", enabled=False, label=NULL_CHAR) + await appconfig_client.set_configuration_setting(kv_feature_flag) + resource_feature_flag = FeatureFlag(name="OverlapFeature", enabled=True) + await feature_flag_client.set_feature_flag(resource_feature_flag) + + try: + async with await self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="OverlapFeature")], + ) as client: + # The resource-based feature flag (enabled=True) should win over the key-value based one + # (enabled=False) since they share the same identifier. + assert has_feature_flag(client, "OverlapFeature", enabled=True) + feature_flag = get_feature_flag(client, "OverlapFeature") + assert feature_flag is not None + assert "name" in feature_flag + finally: + await appconfig_client.delete_configuration_setting(key=kv_feature_flag.key, label=kv_feature_flag.label) + await feature_flag_client.delete_feature_flag("OverlapFeature") + await appconfig_client.close() + await feature_flag_client.close() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py index a433b81ce018..9a84248e5c4b 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/asynctestcase.py @@ -6,7 +6,7 @@ # -------------------------------------------------------------------------- from devtools_testutils import AzureRecordedTestCase from testcase import get_configs -from azure.appconfiguration.aio import AzureAppConfigurationClient +from azure.appconfiguration.aio import AzureAppConfigurationClient, FeatureFlagClient from azure.appconfiguration.provider import AzureAppConfigurationKeyVaultOptions from azure.appconfiguration.provider.aio import load @@ -35,6 +35,10 @@ def create_appconfig_client(self, appconfiguration_endpoint_string): cred = self.get_credential(AzureAppConfigurationClient, is_async=True) return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + def create_feature_flag_client(self, appconfiguration_endpoint_string): + cred = self.get_credential(FeatureFlagClient, is_async=True) + return FeatureFlagClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + async def setup_configs(client, keyvault_secret_url, keyvault_secret_url2): async with client: @@ -82,6 +86,24 @@ async def set_test_settings_async(client, settings): await client.set_configuration_setting(setting) +async def cleanup_feature_flag_resources_async(feature_flag_client, feature_flags): + """ + Delete feature flag resources created via the dedicated feature flag resource endpoint (async version). + + :param feature_flag_client: The async FeatureFlagClient to use for cleanup. + :param feature_flags: List of FeatureFlag objects (or (name, label) tuples) to delete. + """ + for feature_flag in feature_flags: + if isinstance(feature_flag, tuple): + name, label = feature_flag + else: + name, label = feature_flag.name, feature_flag.label + try: + await feature_flag_client.delete_feature_flag(name, label=label) + except Exception: # pylint: disable=broad-except + pass + + 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). diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py index 5073f2afad31..c4801f992526 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_azureappconfigurationproviderbase.py @@ -11,6 +11,17 @@ from typing import Dict, Any from azure.appconfiguration import FeatureFlagConfigurationSetting +from azure.appconfiguration import ( + FeatureFlag, + FeatureFlagAllocation, + FeatureFlagConditions, + FeatureFlagFilter, + FeatureFlagTelemetryConfiguration, + FeatureFlagVariantDefinition, + GroupAllocation, + PercentileAllocation, + UserAllocation, +) from azure.appconfiguration.provider._azureappconfigurationproviderbase import ( is_json_content_type, _build_watched_setting, @@ -398,3 +409,210 @@ def test_generate_allocation_id_truly_empty(self): result = AzureAppConfigurationProviderBase._generate_allocation_id(feature_flag_value) # This should return None because allocated_variants is empty and no seed self.assertIsNone(result) + + +class TestProcessFeatureFlagResource(unittest.TestCase): + """Test processing of feature flags loaded from the dedicated feature flag resource endpoint.""" + + def setUp(self): + self.provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + def test_process_feature_flag_resource_minimal(self): + """Test processing a minimal feature flag resource.""" + feature_flag = FeatureFlag(name="MyFeature", enabled=True) + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertEqual(result["name"], "MyFeature") + self.assertTrue(result["enabled"]) + self.assertNotIn("label", result) + self.assertNotIn("description", result) + self.assertNotIn("conditions", result) + self.assertNotIn("variants", result) + self.assertNotIn("allocation", result) + self.assertNotIn("tags", result) + # Telemetry metadata (ETag) is always attached during processing, even without an explicit + # telemetry configuration on the feature flag resource. + self.assertIn("telemetry", result) + self.assertNotIn("enabled", result["telemetry"]) + + def test_process_feature_flag_resource_with_label_and_description(self): + """Test processing a feature flag resource with label and description.""" + feature_flag = FeatureFlag(name="MyFeature", enabled=False, label="prod", description="A test feature") + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertEqual(result["name"], "MyFeature") + self.assertFalse(result["enabled"]) + self.assertEqual(result["label"], "prod") + self.assertEqual(result["description"], "A test feature") + + def test_process_feature_flag_resource_whitespace_label_omitted(self): + """Test that a whitespace-only label is not included in the processed output.""" + feature_flag = FeatureFlag(name="MyFeature", enabled=True, label=" ") + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertNotIn("label", result) + + def test_process_feature_flag_resource_with_conditions(self): + """Test processing a feature flag resource with conditions/client filters.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + conditions=FeatureFlagConditions( + requirement_type="All", + client_filters=[FeatureFlagFilter(name="Percentage", parameters={"Value": "50"})], + ), + ) + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertEqual(result["conditions"]["requirement_type"], "All") + self.assertEqual(len(result["conditions"]["client_filters"]), 1) + self.assertEqual(result["conditions"]["client_filters"][0]["name"], "Percentage") + self.assertEqual(result["conditions"]["client_filters"][0]["parameters"], {"Value": "50"}) + + def test_process_feature_flag_resource_with_variants_and_allocation(self): + """Test processing a feature flag resource with variants and allocation.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + variants=[ + FeatureFlagVariantDefinition(name="Control", value={"key": "control_value"}), + FeatureFlagVariantDefinition(name="Test", value={"key": "test_value"}, content_type="application/json"), + ], + allocation=FeatureFlagAllocation( + default_when_disabled="Control", + default_when_enabled="Test", + percentile=[PercentileAllocation(variant="Control", percentile_from=0, percentile_to=50)], + user=[UserAllocation(variant="Test", users=["user1"])], + group=[GroupAllocation(variant="Test", groups=["group1"])], + seed="1234", + ), + ) + + result = self.provider._process_feature_flag_resource(feature_flag) + + self.assertEqual(len(result["variants"]), 2) + self.assertEqual(result["variants"][0]["name"], "Control") + self.assertEqual(result["variants"][0]["value"], {"key": "control_value"}) + self.assertEqual(result["variants"][1]["content_type"], "application/json") + + allocation = result["allocation"] + self.assertEqual(allocation["default_when_disabled"], "Control") + self.assertEqual(allocation["default_when_enabled"], "Test") + self.assertEqual(allocation["percentile"], [{"variant": "Control", "percentile_from": 0, "percentile_to": 50}]) + self.assertEqual(allocation["user"], [{"variant": "Test", "users": ["user1"]}]) + self.assertEqual(allocation["group"], [{"variant": "Test", "groups": ["group1"]}]) + self.assertEqual(allocation["seed"], "1234") + + def test_process_feature_flag_resource_with_telemetry_and_tags(self): + """Test processing a feature flag resource with telemetry settings and tags.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + telemetry=FeatureFlagTelemetryConfiguration(enabled=True, metadata={"custom": "value"}), + tags={"team": "infra"}, + ) + + result = self.provider._process_feature_flag_resource(feature_flag) + + # Telemetry metadata gets ETag/FeatureFlagReference metadata appended by + # _update_ff_resource_telemetry_metadata as part of processing. + self.assertTrue(result["telemetry"]["enabled"]) + self.assertEqual(result["telemetry"]["metadata"]["custom"], "value") + self.assertEqual(result["tags"], {"team": "infra"}) + + def test_process_feature_flag_resource_updates_telemetry_metadata(self): + """Test that processing a feature flag resource adds ETag/FeatureFlagReference telemetry metadata.""" + feature_flag = FeatureFlag( + name="MyFeature", + enabled=True, + label="prod", + telemetry=FeatureFlagTelemetryConfiguration(enabled=True), + ) + feature_flag.etag = "resource_etag" + + result = self.provider._process_feature_flag_resource(feature_flag) + + metadata = result["telemetry"][METADATA_KEY] + self.assertEqual(metadata[ETAG_KEY], "resource_etag") + self.assertIn(FEATURE_FLAG_REFERENCE_KEY, metadata) + # The resource-based feature flag reference uses the "ff" path segment, not "kv". + self.assertIn("/ff/MyFeature", metadata[FEATURE_FLAG_REFERENCE_KEY]) + self.assertIn("?label=prod", metadata[FEATURE_FLAG_REFERENCE_KEY]) + + +class TestUpdateFfResourceTelemetryMetadata(unittest.TestCase): + """Test the _update_ff_resource_telemetry_metadata method.""" + + def setUp(self): + self.provider = AzureAppConfigurationProviderBase(endpoint="https://test.azconfig.io") + + def test_update_ff_resource_telemetry_metadata(self): + """Test resource-based feature flag telemetry processing uses the 'ff' reference segment.""" + feature_flag = FeatureFlag(name="test_feature", enabled=True, label="test_label") + feature_flag.etag = "test_etag" + + feature_flag_value: Dict[str, Any] = {TELEMETRY_KEY: {"enabled": True}} + endpoint = "https://test.azconfig.io" + + self.provider._update_ff_resource_telemetry_metadata(endpoint, feature_flag, feature_flag_value) + + metadata = feature_flag_value[TELEMETRY_KEY][METADATA_KEY] + self.assertEqual(metadata[ETAG_KEY], "test_etag") + self.assertIn(FEATURE_FLAG_REFERENCE_KEY, metadata) + self.assertIn("/ff/test_feature", metadata[FEATURE_FLAG_REFERENCE_KEY]) + self.assertIn("?label=test_label", metadata[FEATURE_FLAG_REFERENCE_KEY]) + + +class TestMergeFeatureFlags(unittest.TestCase): + """Test the _merge_feature_flags static method.""" + + def test_merge_no_overlap(self): + """Test merging when there is no identifier overlap between the two sources.""" + kv_flags = [{"id": "KvFeature", "enabled": True}] + resource_flags = [{"name": "ResourceFeature", "enabled": False}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, resource_flags) + + self.assertEqual(len(merged), 2) + self.assertIn({"id": "KvFeature", "enabled": True}, merged) + self.assertIn({"name": "ResourceFeature", "enabled": False}, merged) + + def test_merge_resource_takes_precedence_on_collision(self): + """Test that a resource-based feature flag overrides a key-value one with the same identifier.""" + kv_flags = [{"id": "SharedFeature", "enabled": False, "source": "kv"}] + resource_flags = [{"name": "SharedFeature", "enabled": True, "source": "resource"}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, resource_flags) + + self.assertEqual(len(merged), 1) + self.assertEqual(merged[0]["source"], "resource") + self.assertTrue(merged[0]["enabled"]) + + def test_merge_empty_lists(self): + """Test merging two empty lists returns an empty list.""" + merged = AzureAppConfigurationProviderBase._merge_feature_flags([], []) + self.assertEqual(merged, []) + + def test_merge_only_kv_flags(self): + """Test merging when only key-value based feature flags are present.""" + kv_flags = [{"id": "Feature1", "enabled": True}, {"id": "Feature2", "enabled": False}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags(kv_flags, []) + + self.assertEqual(len(merged), 2) + + def test_merge_only_resource_flags(self): + """Test merging when only resource-based feature flags are present.""" + resource_flags = [{"name": "Feature1", "enabled": True}, {"name": "Feature2", "enabled": False}] + + merged = AzureAppConfigurationProviderBase._merge_feature_flags([], resource_flags) + + self.assertEqual(len(merged), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py index 5edaab158bf4..85d13144c746 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_configuration_client_manager.py @@ -27,6 +27,22 @@ def __init__(self, endpoint, connection_string, credential, retry_total, retry_b self.retry_backoff = retry_backoff +class _FakePagedIterator: + """Mimics an ItemPaged page iterator, exposing a mutable ``etag`` reflecting the last-yielded page.""" + + def __init__(self, pages): + self._pages = iter(pages) + self.etag = None + + def __iter__(self): + return self + + def __next__(self): + page, etag = next(self._pages) + self.etag = etag + return page + + @pytest.mark.usefixtures("caplog") class TestConfigurationClientManager(unittest.TestCase): @@ -371,3 +387,163 @@ def test_check_page_etags_keys_first_then_snapshot(): mock_client.list_configuration_settings.assert_called_once_with( key_filter="app/*", label_filter="\0", tags_filter=None ) + + +def test_load_feature_flag_resources_no_feature_flag_client(): + """When no feature flag client is configured, no service calls are made.""" + mock_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client) + + selects = [SettingSelector(key_filter="app/*"), SettingSelector(key_filter="other/*")] + + feature_flags, page_etags = wrapper.load_feature_flag_resources(selects) + + assert feature_flags == [] + assert page_etags == [[], []] + + +def test_load_feature_flag_resources_skips_snapshot_selectors(): + """Selectors with a snapshot_name are not supported by the feature flag resource endpoint and are skipped.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [ + SettingSelector(snapshot_name="my-snapshot"), + SettingSelector(key_filter="app/*"), + ] + + flag1 = Mock(name="flag1") + mock_response = Mock() + mock_response.by_page.return_value = _FakePagedIterator([([flag1], "etag1")]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + feature_flags, page_etags = wrapper.load_feature_flag_resources(selects) + + assert feature_flags == [flag1] + assert page_etags == [[], ["etag1"]] + # Only the non-snapshot selector should trigger a service call + mock_feature_flag_client.list_feature_flags.assert_called_once_with( + name_filter="app/*", label_filter="\0", tags_filter=None + ) + + +def test_load_feature_flag_resources_multiple_pages(): + """Multiple pages should be aggregated and each page's etag collected.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [SettingSelector(key_filter="app/*")] + + flag1 = Mock(name="flag1") + flag2 = Mock(name="flag2") + + class FakeIterator: + """Mimics an ItemPaged iterator, exposing a mutable ``etag`` reflecting the last-yielded page.""" + + def __init__(self, pages): + self._pages = iter(pages) + self.etag = None + + def __iter__(self): + return self + + def __next__(self): + page, etag = next(self._pages) + self.etag = etag + return page + + mock_response = Mock() + mock_response.by_page.return_value = FakeIterator([([flag1], "etag1"), ([flag2], "etag2")]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + feature_flags, page_etags = wrapper.load_feature_flag_resources(selects) + + assert feature_flags == [flag1, flag2] + assert page_etags == [["etag1", "etag2"]] + + +def test_check_feature_flag_resource_etags_no_feature_flag_client(): + """When no feature flag client is configured, no changes are reported.""" + mock_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client) + + selects = [SettingSelector(key_filter="app/*")] + + result = wrapper.check_feature_flag_resource_etags(selects, [["etag1"]]) + + assert result is False + + +def test_check_feature_flag_resource_etags_no_change(): + """When the returned pages are empty, no changes are reported.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [SettingSelector(key_filter="app/*")] + page_etags = [["etag1"]] + + mock_response = Mock() + mock_response.by_page.return_value = iter([]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + + assert result is False + mock_feature_flag_client.list_feature_flags.assert_called_once_with( + name_filter="app/*", label_filter="\0", tags_filter=None + ) + mock_response.by_page.assert_called_once_with(match_conditions=["etag1"]) + + +def test_check_feature_flag_resource_etags_change_detected(): + """When a page is returned, a change should be reported.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [SettingSelector(key_filter="app/*")] + page_etags = [["etag1"]] + + mock_response = Mock() + mock_response.by_page.return_value = iter([[Mock()]]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + + result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + + assert result is True + + +def test_check_feature_flag_resource_etags_skips_snapshot_selectors(): + """Selectors with a snapshot_name are not supported and should be skipped without a service call.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [SettingSelector(snapshot_name="my-snapshot")] + page_etags = [[]] + + result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + + assert result is False + mock_feature_flag_client.list_feature_flags.assert_not_called() + + +def test_check_feature_flag_resource_etags_missing_page_etags_triggers_refresh(): + """Missing etag state for a selector should trigger a refresh instead of failing.""" + mock_client = Mock() + mock_feature_flag_client = Mock() + wrapper = _ConfigurationClientWrapper("https://fake.endpoint", mock_client, mock_feature_flag_client) + + selects = [SettingSelector(key_filter="app/*"), SettingSelector(key_filter="other/*")] + # Only one entry provided for two selectors; the first selector's page hasn't changed. + mock_response = Mock() + mock_response.by_page.return_value = iter([]) + mock_feature_flag_client.list_feature_flags.return_value = mock_response + page_etags = [["etag1"]] + + result = wrapper.check_feature_flag_resource_etags(selects, page_etags) + + assert result is True diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py new file mode 100644 index 000000000000..e9b82ec7457c --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_flag_resources.py @@ -0,0 +1,150 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +""" +Tests for loading feature flags from the dedicated feature flag resource endpoint +(``FeatureFlagClient``/``FeatureFlag``), as opposed to the classic key-value based +``FeatureFlagConfigurationSetting`` stored via ``AzureAppConfigurationClient``. +""" +import functools +from devtools_testutils import EnvironmentVariableLoader, recorded_by_proxy +from testcase import AppConfigTestCase, has_feature_flag, get_feature_flag +from test_constants import APPCONFIGURATION_ENDPOINT_STRING, FEATURE_MANAGEMENT_KEY +from azure.appconfiguration import FeatureFlag, FeatureFlagConfigurationSetting +from azure.appconfiguration.provider import SettingSelector +from azure.appconfiguration.provider._constants import NULL_CHAR + +AppConfigProviderPreparer = functools.partial( + EnvironmentVariableLoader, + "appconfiguration", + appconfiguration_endpoint_string=APPCONFIGURATION_ENDPOINT_STRING, +) + + +class TestAppConfigurationProviderFeatureFlagResources(AppConfigTestCase): + """Tests for the provider loading feature flags from the dedicated feature flag resource endpoint.""" + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_load_feature_flag_resource(self, appconfiguration_endpoint_string): + """A feature flag created via the feature flag resource endpoint should be loaded by the provider.""" + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceOnlyFeature", enabled=True) + feature_flag_client.set_feature_flag(feature_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="ResourceOnlyFeature")], + ) + + assert FEATURE_MANAGEMENT_KEY in client + assert has_feature_flag(client, "ResourceOnlyFeature", enabled=True) + finally: + feature_flag_client.delete_feature_flag("ResourceOnlyFeature") + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_load_feature_flag_resource_disabled(self, appconfiguration_endpoint_string): + """A disabled feature flag resource should be loaded with enabled set to False.""" + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceDisabledFeature", enabled=False) + feature_flag_client.set_feature_flag(feature_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="ResourceDisabledFeature")], + ) + + assert has_feature_flag(client, "ResourceDisabledFeature", enabled=False) + finally: + feature_flag_client.delete_feature_flag("ResourceDisabledFeature") + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_load_feature_flag_resource_with_label(self, appconfiguration_endpoint_string): + """A feature flag resource with a label should be loaded when the label filter matches.""" + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + feature_flag = FeatureFlag(name="ResourceLabeledFeature", enabled=True, label="test_label") + feature_flag_client.set_feature_flag(feature_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[ + SettingSelector(key_filter="ResourceLabeledFeature", label_filter="test_label") + ], + ) + + assert has_feature_flag(client, "ResourceLabeledFeature", enabled=True) + finally: + feature_flag_client.delete_feature_flag("ResourceLabeledFeature", label="test_label") + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_feature_flag_resource_selector_filters_by_name(self, appconfiguration_endpoint_string): + """The feature_flag_selectors key_filter should scope which feature flag resources are loaded.""" + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + included_flag = FeatureFlag(name="IncludedResourceFeature", enabled=True) + excluded_flag = FeatureFlag(name="ExcludedResourceFeature", enabled=True) + feature_flag_client.set_feature_flag(included_flag) + feature_flag_client.set_feature_flag(excluded_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="Included*")], + ) + + assert has_feature_flag(client, "IncludedResourceFeature", enabled=True) + assert not has_feature_flag(client, "ExcludedResourceFeature") + finally: + feature_flag_client.delete_feature_flag("IncludedResourceFeature") + feature_flag_client.delete_feature_flag("ExcludedResourceFeature") + + # method: load + @AppConfigProviderPreparer() + @recorded_by_proxy + def test_feature_flag_resource_overrides_key_value(self, appconfiguration_endpoint_string): + """A feature flag resource should take precedence over a key-value based feature flag with the + same identifier when both are loaded.""" + appconfig_client = self.create_appconfig_client(appconfiguration_endpoint_string) + feature_flag_client = self.create_feature_flag_client(appconfiguration_endpoint_string) + + kv_feature_flag = FeatureFlagConfigurationSetting(feature_id="OverlapFeature", enabled=False, label=NULL_CHAR) + appconfig_client.set_configuration_setting(kv_feature_flag) + resource_feature_flag = FeatureFlag(name="OverlapFeature", enabled=True) + feature_flag_client.set_feature_flag(resource_feature_flag) + + try: + client = self.create_client( + endpoint=appconfiguration_endpoint_string, + selects=[], + feature_flag_enabled=True, + feature_flag_selectors=[SettingSelector(key_filter="OverlapFeature")], + ) + + # The resource-based feature flag (enabled=True) should win over the key-value based one + # (enabled=False) since they share the same identifier. + assert has_feature_flag(client, "OverlapFeature", enabled=True) + feature_flag = get_feature_flag(client, "OverlapFeature") + assert feature_flag is not None + assert "name" in feature_flag + finally: + appconfig_client.delete_configuration_setting(key=kv_feature_flag.key, label=kv_feature_flag.label) + feature_flag_client.delete_feature_flag("OverlapFeature") diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py index 99074621b628..a2cdedb6310e 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py @@ -11,6 +11,8 @@ AzureAppConfigurationClient, ConfigurationSetting, ConfigurationSettingsFilter, + FeatureFlag, + FeatureFlagClient, FeatureFlagConfigurationSetting, SecretReferenceConfigurationSetting, SnapshotComposition, @@ -44,6 +46,10 @@ def create_appconfig_client(self, appconfiguration_endpoint_string): cred = self.get_credential(AzureAppConfigurationClient) return AzureAppConfigurationClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + def create_feature_flag_client(self, appconfiguration_endpoint_string): + cred = self.get_credential(FeatureFlagClient) + return FeatureFlagClient(appconfiguration_endpoint_string, cred, user_agent="SDK/Integration") + def setup_configs(client, keyvault_secret_url, keyvault_secret_url2): """Set up all test configs and create snapshots. Returns (snapshot_name, ff_snapshot_name).""" @@ -164,6 +170,38 @@ def create_feature_flag_config_setting(key, label, enabled, tags=None): return FeatureFlagConfigurationSetting(feature_id=key, label=label, enabled=enabled, tags=tags) +def create_feature_flag_resource(name, enabled, label=None, **kwargs): + """ + Create a FeatureFlag resource object for use with the dedicated feature flag resource endpoint + (``FeatureFlagClient``), as opposed to the classic key-value based ``FeatureFlagConfigurationSetting``. + + :param name: The name/identifier of the feature flag. + :param enabled: Whether the feature flag is enabled. + :param label: The label of the feature flag. + :return: A FeatureFlag resource object. + :rtype: ~azure.appconfiguration.FeatureFlag + """ + return FeatureFlag(name=name, enabled=enabled, label=label, **kwargs) + + +def cleanup_feature_flag_resources(feature_flag_client, feature_flags): + """ + Delete feature flag resources created via the dedicated feature flag resource endpoint. + + :param feature_flag_client: The FeatureFlagClient to use for cleanup. + :param feature_flags: List of FeatureFlag objects (or (name, label) tuples) to delete. + """ + for feature_flag in feature_flags: + if isinstance(feature_flag, tuple): + name, label = feature_flag + else: + name, label = feature_flag.name, feature_flag.label + try: + feature_flag_client.delete_feature_flag(name, label=label) + except Exception: # pylint: disable=broad-except + pass + + def cleanup_test_resources( client, settings=None, @@ -245,7 +283,7 @@ def create_snapshot(client, snapshot_name, key_filters, composition_type=None, r def get_feature_flag(client, feature_id): for feature_flag in client[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY]: - if feature_flag["id"] == feature_id: + if feature_flag.get("id", feature_flag.get("name")) == feature_id: return feature_flag return None