Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions eng/tools/azure-sdk-tools/azpysdk/samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
# Add your library + sample file if you do not want a particular sample to be run
IGNORED_SAMPLES = {
"azure-appconfiguration-provider": [
"async_connection_string_sample.py",
"connection_string_sample.py",
"key_vault_reference_customized_clients_sample.py",
"aad_sample.py",
"key_vault_reference_sample.py",
Expand Down
50 changes: 23 additions & 27 deletions sdk/appconfiguration/azure-appconfiguration-provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,53 @@ Using the provider enables loading sets of configurations from an Azure App Conf

## Getting started

### Get credentials

Use the [Azure CLI][azure_cli] snippet below to get the connection string from the Configuration Store.

```Powershell
az appconfig credential list --name <config-store-name>
```

Alternatively, get the connection string from the Azure Portal.

### Creating a provider

You can create a client with a connection string:
#### Microsoft Entra ID (recommended)

<!-- SNIPPET:connection_string_sample.create_provider_connection_string -->
Microsoft Entra ID authentication is recommended for connecting to Azure App Configuration.

<!-- SNIPPET:entra_id_sample.create_provider_entra_id -->

```python
import os
from azure.appconfiguration.provider import load
from azure.identity import DefaultAzureCredential

connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"]
endpoint = os.environ["APPCONFIGURATION_ENDPOINT_STRING"]
credential = DefaultAzureCredential()

# Connecting to Azure App Configuration using connection string
config = load(connection_string=connection_string, **kwargs)
# Connecting to Azure App Configuration using Entra ID
config = load(endpoint=endpoint, credential=credential, **kwargs)
```

<!-- END SNIPPET -->

or with Entra ID:
#### Connection string

<!-- SNIPPET:entra_id_sample.create_provider_entra_id -->
Use the [Azure CLI][azure_cli] snippet below to get the connection string from the Configuration Store:

```Powershell
az appconfig credential list --name <config-store-name>
```

You can also get the connection string from the Azure portal.

<!-- SNIPPET:connection_string_sample.create_provider_connection_string -->

```python
import os
from azure.appconfiguration.provider import load
from azure.identity import DefaultAzureCredential

endpoint = os.environ["APPCONFIGURATION_ENDPOINT_STRING"]
credential = DefaultAzureCredential()
connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"]

# Connecting to Azure App Configuration using Entra ID
config = load(endpoint=endpoint, credential=credential, **kwargs)
# Connecting to Azure App Configuration using connection string
config = load(connection_string=connection_string, **kwargs)
```

<!-- END SNIPPET -->

these providers will by default load all configurations with `(No Label)` from your configuration store into a dictionary of key/values.
These providers will by default load all configurations with `(No Label)` from your configuration store into a dictionary of key/values.

### Features

Expand Down Expand Up @@ -158,8 +158,6 @@ The provider can be configured to refresh configurations from the store on a set
import os
from azure.appconfiguration.provider import load, WatchKey

connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"]

config = load(
endpoint=endpoint,
credential=credential,
Expand Down Expand Up @@ -361,8 +359,6 @@ To enable refresh for feature flags you need to enable refresh. This will allow
import os
from azure.appconfiguration.provider import load, WatchKey

connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"]

config = load(
endpoint=endpoint,
credential=credential,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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_05f22217b9"
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ def my_callback_on_fail(_):
import os
from azure.appconfiguration.provider import load, WatchKey

connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"]

config = load(
endpoint=endpoint,
credential=credential,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ def my_callback_on_fail(_):
import os
from azure.appconfiguration.provider import load, WatchKey

connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"]

config = load(
endpoint=endpoint,
credential=credential,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,18 +462,21 @@ async def test_integration_with_keyvault(self, appconfiguration_keyvault_secret_
# Create a SecretProvider with the credential
secret_provider = SecretProvider(keyvault_credential=credential)

# Create a Key Vault reference
config = SecretReferenceConfigurationSetting(key="test-key", secret_id=appconfiguration_keyvault_secret_url)
try:
# Create a Key Vault reference
config = SecretReferenceConfigurationSetting(key="test-key", secret_id=appconfiguration_keyvault_secret_url)

# Resolve the reference
secret_value = await secret_provider.resolve_keyvault_reference(config)
# Resolve the reference
secret_value = await secret_provider.resolve_keyvault_reference(config)

# Verify a value was returned (we can't know the exact value)
self.assertIsNotNone(secret_value)
self.assertTrue(isinstance(secret_value, str))
# Verify a value was returned (we can't know the exact value)
self.assertIsNotNone(secret_value)
self.assertTrue(isinstance(secret_value, str))

# Verify the secret was cached
self.assertIn(appconfiguration_keyvault_secret_url, secret_provider._secret_cache)
# Verify the secret was cached
self.assertIn(appconfiguration_keyvault_secret_url, secret_provider._secret_cache)
finally:
await secret_provider.close()


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import logging
import os
import time

from devtools_testutils import (
add_general_regex_sanitizer,
add_general_string_sanitizer,
Expand All @@ -7,22 +10,71 @@
remove_batch_sanitizers,
add_remove_header_sanitizer,
add_uri_string_sanitizer,
get_credential,
is_live,
)
import pytest
from azure.appconfiguration import AzureAppConfigurationClient
from azure.identity import DefaultAzureCredential
from azure.core.exceptions import HttpResponseError
from testcase import setup_configs, cleanup_test_resources


_LOGGER = logging.getLogger(__name__)
_RBAC_PROPAGATION_TIMEOUT = 15 * 60 + 5
_MAX_RETRY_DELAY = 30

# autouse=True will trigger this fixture on each pytest run, even if it's not explicitly used by a test method

# Module-level storage for snapshot names created during session setup
snapshot_names = {}


def _wait_for_rbac_propagation(client, timeout=_RBAC_PROPAGATION_TIMEOUT):
deadline = time.monotonic() + timeout
retry_delay = 1

while True:
try:
next(client.list_configuration_settings(key_filter="__rbac_readiness_probe__"), None)
return
except HttpResponseError as error:
if error.status_code != 403:
raise

remaining_time = deadline - time.monotonic()
if remaining_time <= 0:
raise TimeoutError("App Configuration data-plane role assignment did not propagate in time.") from error

sleep_time = min(retry_delay, remaining_time)
_LOGGER.info(
"Waiting %.0f seconds for the App Configuration data-plane role assignment to propagate.",
sleep_time,
)
time.sleep(sleep_time)
retry_delay = min(retry_delay * 2, _MAX_RETRY_DELAY)


@pytest.fixture(scope="session", autouse=True)
def wait_for_data_plane_access():
if not is_live():
return

endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING")
if not endpoint:
pytest.fail("APPCONFIGURATION_ENDPOINT_STRING must be set when running live tests.")

client = AzureAppConfigurationClient(endpoint, get_credential())
try:
_wait_for_rbac_propagation(client)
finally:
client.close()


@pytest.fixture(scope="session", autouse=True)
def setup_app_config_keys():
def setup_app_config_keys(wait_for_data_plane_access):
"""Pre-populate App Configuration with test keys and snapshots once per session (live mode only)."""
del wait_for_data_plane_access

if not is_live():
yield
return
Expand All @@ -32,10 +84,10 @@ def setup_app_config_keys():
yield
return

credential = DefaultAzureCredential()
credential = get_credential()
client = AzureAppConfigurationClient(endpoint, credential)
keyvault_secret_url = os.environ.get("APPCONFIGURATION_KEY_VAULT_REFERENCE")
keyvault_secret_url2 = os.environ.get("APPCONFIGURATION_KEY_VAULT_REFERENCE2")
keyvault_secret_url = os.environ.get("APPCONFIGURATION_KEYVAULT_SECRET_URL")
keyvault_secret_url2 = os.environ.get("APPCONFIGURATION_KEYVAULT_SECRET_URL2")
snap_name, ff_snap_name = setup_configs(client, keyvault_secret_url, keyvault_secret_url2)

snapshot_names["snapshot"] = snap_name
Expand All @@ -48,6 +100,23 @@ def setup_app_config_keys():

@pytest.fixture(scope="session", autouse=True)
def add_sanitizers(test_proxy):
key_vault_references = (
(
os.environ.get(
"APPCONFIGURATION_KEYVAULT_SECRET_URL2",
"https://sanitized.vault.azure.net/secrets/fake-secret2/",
),
"https://sanitized.vault.azure.net/secrets/fake-secret2/",
),
(
os.environ.get(
"APPCONFIGURATION_KEYVAULT_SECRET_URL",
"https://sanitized.vault.azure.net/secrets/fake-secret/",
),
"https://sanitized.vault.azure.net/secrets/fake-secret/",
),
)

add_general_regex_sanitizer(
value="https://sanitized.azconfig.io",
regex=os.environ.get("APPCONFIGURATION_ENDPOINT_STRING", "https://sanitized.azconfig.io"),
Expand All @@ -57,13 +126,11 @@ def add_sanitizers(test_proxy):
regex=os.environ.get("APPCONFIGURATION_CONNECTION_STRING", "https://sanitized.azconfig.io"),
)
add_uri_string_sanitizer()
# Register the longer URL2 sanitizer FIRST to prevent URL1's sanitizer from partially matching within URL2
add_general_string_sanitizer(
value="https://sanitized.vault.azure.net/secrets/fake-secret/",
target=os.environ.get(
"APPCONFIGURATION_KEY_VAULT_REFERENCE", "https://sanitized.vault.azure.net/secrets/fake-secret/"
),
)
for target, value in key_vault_references:
target = target.rstrip("/") + "/"
value = value.rstrip("/") + "/"
add_uri_string_sanitizer(target=target, value=value)
add_general_string_sanitizer(target=target, value=value)
add_remove_header_sanitizer(headers="Correlation-Context")

add_general_regex_sanitizer(value="api-version=1970-01-01", regex="api-version=.+")
Expand All @@ -79,10 +146,10 @@ def add_sanitizers(test_proxy):

@pytest.fixture(autouse=True)
def no_startup_backoff(request, monkeypatch):
"""Skip startup backoff delays in all tests except those testing backoff directly."""
if request.fspath.basename == "test_startup_retry.py": # cspell:ignore fspath
"""Fail fast on playback mismatches instead of retrying until the startup timeout."""
if is_live() or request.fspath.basename == "test_startup_retry.py": # cspell:ignore fspath
return
monkeypatch.setattr(
"azure.appconfiguration.provider._azureappconfigurationprovider.get_startup_backoff",
lambda *args, **kwargs: (0, False),
lambda *args, **kwargs: (float("inf"), False),
)
Comment thread
mrm9084 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
from unittest.mock import MagicMock, patch

import pytest
from azure.core.exceptions import HttpResponseError

from conftest import _wait_for_rbac_propagation


def _http_error(status_code):
response = MagicMock()
response.status_code = status_code
response.reason = "Forbidden"
return HttpResponseError(response=response)


def test_wait_for_rbac_propagation_succeeds_immediately():
client = MagicMock()
client.list_configuration_settings.return_value = iter([])

with patch("conftest.time.sleep") as sleep:
_wait_for_rbac_propagation(client)

sleep.assert_not_called()


def test_wait_for_rbac_propagation_retries_forbidden_response():
client = MagicMock()
client.list_configuration_settings.side_effect = [_http_error(403), iter([])]

with patch("conftest.time.sleep") as sleep:
_wait_for_rbac_propagation(client)

sleep.assert_called_once_with(1)


def test_wait_for_rbac_propagation_does_not_retry_other_errors():
client = MagicMock()
client.list_configuration_settings.side_effect = _http_error(500)

with patch("conftest.time.sleep") as sleep, pytest.raises(HttpResponseError):
_wait_for_rbac_propagation(client)

sleep.assert_not_called()


def test_wait_for_rbac_propagation_times_out():
client = MagicMock()
client.list_configuration_settings.side_effect = _http_error(403)

with patch("conftest.time.monotonic", side_effect=[0, 2]), patch("conftest.time.sleep") as sleep, pytest.raises(
TimeoutError
):
_wait_for_rbac_propagation(client, timeout=1)

sleep.assert_not_called()
Loading