diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py index 6dc7e45229eb..c3fed36e0bfa 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/conftest.py @@ -1,4 +1,7 @@ +import logging import os +import time + from devtools_testutils import ( add_general_regex_sanitizer, add_general_string_sanitizer, @@ -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 @@ -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") diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_readiness.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_readiness.py new file mode 100644 index 000000000000..5ecb25320f6e --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_readiness.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock, patch + +import pytest +from azure.core.exceptions import HttpResponseError + +from conftest import _wait_for_data_plane_access + + +def _http_error(status_code): + response = MagicMock() + response.status_code = status_code + response.reason = "Forbidden" + return HttpResponseError(response=response) + + +def test_wait_for_data_plane_access_succeeds_immediately(): + client = MagicMock() + client.list_configuration_settings.return_value = iter([]) + + with patch("conftest.time.sleep") as sleep: + _wait_for_data_plane_access(client) + + sleep.assert_not_called() + + +def test_wait_for_data_plane_access_retries_forbidden_response(): + client = MagicMock() + client.list_configuration_settings.side_effect = [_http_error(403), iter([])] + + with patch("conftest.time.sleep") as sleep: + _wait_for_data_plane_access(client) + + sleep.assert_called_once_with(1) + + +def test_wait_for_data_plane_access_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_data_plane_access(client) + + sleep.assert_not_called() + + +def test_wait_for_data_plane_access_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_data_plane_access(client, timeout=1) + + sleep.assert_not_called() diff --git a/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py b/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py index d0dcda865be9..5e012396c320 100644 --- a/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py +++ b/sdk/appconfiguration/azure-appconfiguration/tests/conftest.py @@ -23,9 +23,67 @@ # IN THE SOFTWARE. # # -------------------------------------------------------------------------- -import pytest +import logging import os -from devtools_testutils import add_general_regex_sanitizer, test_proxy, set_bodiless_matcher, remove_batch_sanitizers +import time + +import pytest +from azure.appconfiguration import AzureAppConfigurationClient +from azure.core.exceptions import HttpResponseError +from devtools_testutils import ( + add_general_regex_sanitizer, + get_credential, + is_live, + remove_batch_sanitizers, + set_bodiless_matcher, + test_proxy, +) + + +_LOGGER = logging.getLogger(__name__) +_RBAC_PROPAGATION_TIMEOUT = 15 * 60 + 5 +_MAX_RETRY_DELAY = 30 + + +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) diff --git a/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py b/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py new file mode 100644 index 000000000000..da0e9ec5a146 --- /dev/null +++ b/sdk/appconfiguration/azure-appconfiguration/tests/test_readiness.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock, patch + +import pytest +from azure.core.exceptions import HttpResponseError + +from conftest import _wait_for_data_plane_access + + +def _http_error(status_code): + response = MagicMock() + response.status_code = status_code + response.reason = "Forbidden" + return HttpResponseError(response=response) + + +def test_wait_for_data_plane_access_succeeds_immediately(): + client = MagicMock() + client.list_configuration_settings.return_value = iter([]) + + with patch("conftest.time.sleep") as sleep: + _wait_for_data_plane_access(client) + + sleep.assert_not_called() + + +def test_wait_for_data_plane_access_retries_forbidden_response(): + client = MagicMock() + client.list_configuration_settings.side_effect = [_http_error(403), iter([])] + + with patch("conftest.time.sleep") as sleep: + _wait_for_data_plane_access(client) + + sleep.assert_called_once_with(1) + + +def test_wait_for_data_plane_access_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_data_plane_access(client) + + sleep.assert_not_called() + + +def test_wait_for_data_plane_access_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_data_plane_access(client, timeout=1) + + sleep.assert_not_called() diff --git a/sdk/appconfiguration/test-resources.json b/sdk/appconfiguration/test-resources.json index 9de238e15de7..cb154369fdf7 100644 --- a/sdk/appconfiguration/test-resources.json +++ b/sdk/appconfiguration/test-resources.json @@ -57,6 +57,7 @@ }, "variables": { "roleDefinitionId": "[format('/subscriptions/{0}/providers/Microsoft.Authorization/roleDefinitions/5ae67dd6-50cb-40e7-96ff-dc2bfa4b606b', subscription().subscriptionId)]", + "roleAssignmentName": "[guid(resourceGroup().id, parameters('testApplicationOid'), variables('roleDefinitionId'))]", "uniqueAzConfigName": "[format('{0}-{1}', parameters('baseName'), parameters('azConfigPrefix'))]", "endpointValue": "[format('https://{0}-{1}{2}', parameters('baseName'), parameters('azConfigPrefix'), parameters('azConfigEndpointSuffix'))]", "azureKeyVaultUrl": "[format('https://{0}{1}/', parameters('baseName'), parameters('keyVaultEndpointSuffix'))]", @@ -66,21 +67,24 @@ "resources": [ { "type": "Microsoft.AppConfiguration/configurationStores", - "apiVersion": "2019-10-01", + "apiVersion": "2024-06-01", "name": "[variables('uniqueAzConfigName')]", "location": "[parameters('location')]", "sku": { "name": "[parameters('sku')]" }, "properties": { - "endpoint": "[variables('endpointValue')]", - "disableLocalAuth": true + "disableLocalAuth": true, + "dataPlaneProxy": { + "authenticationMode": "Pass-through", + "privateLinkDelegation": "Disabled" + } } }, { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2018-09-01-preview", - "name": "[guid(resourceGroup().id)]", + "name": "[variables('roleAssignmentName')]", "properties": { "roleDefinitionId": "[variables('roleDefinitionId')]", "principalId": "[parameters('testApplicationOid')]" @@ -133,70 +137,9 @@ "properties": { "value": "Very secret value 2" } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', 'message')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "hi" - } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', 'message$dev')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "test" - } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', 'my_json')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "{\"key\": \"value\"}", - "contentType": "application/json" - } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', 'test.trimmed')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "key" - } - }, - { - "type": "Microsoft.AppConfiguration/configurationStores/keyValues", - "apiVersion": "2020-07-01-preview", - "name": "[concat(variables('uniqueAzConfigName'), '/', '.appconfig.featureflag~2FAlpha')]", - "dependsOn": [ - "[variables('uniqueAzConfigName')]" - ], - "properties": { - "value": "{\"id\":\"Alpha\",\"description\":\"\",\"enabled\":false,\"conditions\":{\"client_filters\":[]}}", - "contentType": "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" - } } ], "outputs": { - "APPCONFIGURATION_CONNECTION_STRING": { - "type": "string", - "value": "[listKeys(resourceId('Microsoft.AppConfiguration/configurationStores',variables('uniqueAzConfigName')), '2019-02-01-preview').value[0].connectionString]" - }, "APPCONFIGURATION_ENDPOINT_STRING": { "type": "string", "value": "[variables('endpointValue')]"