Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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_b7b24cef0b"
}
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
@@ -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,7 +84,7 @@ 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")
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_KEY_VAULT_REFERENCE2",
"https://sanitized.vault.azure.net/secrets/fake-secret2/",
),
"https://sanitized.vault.azure.net/secrets/fake-secret2/",
),
(
os.environ.get(
"APPCONFIGURATION_KEY_VAULT_REFERENCE",
"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)
Comment thread
mrm9084 marked this conversation as resolved.
add_remove_header_sanitizer(headers="Correlation-Context")

add_general_regex_sanitizer(value="api-version=1970-01-01", regex="api-version=.+")
Expand Down
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
Comment thread
mrm9084 marked this conversation as resolved.

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
Loading