From b9a063ab71c091afaa46cf754d04883632db72e3 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 17 Sep 2026 14:22:45 -0400 Subject: [PATCH 1/2] Fix deployment authentication and safe post-configuration Use tenant-scoped Entra Cosmos access for managed-identity deployments without automatic firewall edits or silent key fallback. Scope hook environment lookups and POSIX role operations to the selected deployment. Consolidate Redis configuration, publish settings through the shared fenced OCC store, and create only missing Search indexes from application schemas. Include Windows/POSIX execution and concurrency regressions plus deployment documentation. --- application/single_app/config.py | 2 +- deployers/azure.yaml | 318 +++----------- deployers/bicep/cosmosDb-postDeployPerms.sh | 25 +- deployers/bicep/deployment_configuration.py | 207 +++++++++ deployers/bicep/deployment_cosmos.py | 85 ++++ deployers/bicep/postconfig.py | 63 +-- deployers/bicep/requirements.txt | 5 +- deployers/version.txt | 2 +- .../AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md | 35 +- docs/reference/deploy/azd-cli_deploy.md | 46 ++ .../test_azd_windows_hook_environment.py | 4 +- functional_tests/test_azd_windows_hooks.py | 25 +- .../test_deployment_configuration.py | 408 ++++++++++++++++++ .../test_deployment_cosmos_access.py | 115 +++++ .../test_log_credential_key_redaction.py | 4 +- .../test_postconfig_azurecli_credential.py | 19 +- ...st_postconfig_redis_cache_configuration.py | 25 +- 17 files changed, 1037 insertions(+), 351 deletions(-) create mode 100644 deployers/bicep/deployment_configuration.py create mode 100644 deployers/bicep/deployment_cosmos.py create mode 100644 functional_tests/test_deployment_configuration.py create mode 100644 functional_tests/test_deployment_cosmos_access.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 517716bc7..140c40b08 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.027" +VERSION = "0.261.028" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/deployers/azure.yaml b/deployers/azure.yaml index 3cd22d4fe..949fd5878 100644 --- a/deployers/azure.yaml +++ b/deployers/azure.yaml @@ -76,48 +76,6 @@ hooks: export var_videoIndexerAccountId=${var_videoIndexerAccountId} export var_speechServiceEndpoint=${var_speechServiceEndpoint} - is_mfa_error() { - printf '%s' "$1" | grep -qiE 'AADSTS50076|multi-factor authentication|claims challenge' - } - - print_manual_cosmos_firewall_message() { - runner_public_ip="$1" - echo "✗ ERROR: Azure CLI requires multi-factor authentication before it can update the Cosmos DB firewall." >&2 - echo " Manually add IP ${runner_public_ip} to Azure Portal -> Cosmos DB account '${var_cosmosDb_accountName}' -> Networking -> Firewall in resource group '${var_rgName}'." >&2 - echo " If you already added the IP manually, allow up to 30 minutes for the firewall change to propagate before rerunning 'azd provision' or 'azd up'." >&2 - } - - test_cosmos_runner_access() { - python3 -c "from azure.cosmos import CosmosClient; import os; client = CosmosClient(os.environ['var_cosmosDb_uri'], os.environ['var_cosmosDb_key']); next(client.list_databases(), None); print('ok')" >/dev/null 2>&1 - } - - runner_ip_matches_rules() { - current_rules="$1" - runner_public_ip="$2" - CURRENT_RULES="${current_rules}" RUNNER_PUBLIC_IP="${runner_public_ip}" python3 -c "import ipaddress, os; rules = [rule.strip() for rule in os.environ.get('CURRENT_RULES', '').split(',') if rule.strip()]; ip = ipaddress.ip_address(os.environ['RUNNER_PUBLIC_IP']); matched = any(ip in ipaddress.ip_network(rule, strict=False) if '/' in rule else ip == ipaddress.ip_address(rule) for rule in rules); raise SystemExit(0 if matched else 1)" - } - - wait_for_cosmos_runner_access() { - runner_public_ip="$1" - max_attempts=20 - attempt=1 - - while [ ${attempt} -le ${max_attempts} ]; do - if test_cosmos_runner_access; then - echo "✓ CosmosDB data-plane access is now available for deployment runner IP ${runner_public_ip}" - return 0 - fi - - echo " Waiting for CosmosDB firewall propagation for ${runner_public_ip} (${attempt}/${max_attempts})..." - sleep 30 - attempt=$((attempt + 1)) - done - - echo "✗ ERROR: CosmosDB firewall rule for ${runner_public_ip} is configured but data-plane access is still not available." >&2 - echo " Firewall changes can take up to 30 minutes to propagate. Wait and rerun 'azd provision' or 'azd up'." >&2 - return 1 - } - python_is_virtual_environment() { python3 -c "import sys; raise SystemExit(0 if sys.prefix != getattr(sys, 'base_prefix', sys.prefix) else 1)" } @@ -142,47 +100,6 @@ hooks: return 0 } - ensure_cosmos_runner_access() { - current_rules=$(az cosmosdb show \ - --name ${var_cosmosDb_accountName} \ - --resource-group ${var_rgName} \ - --subscription ${AZURE_SUBSCRIPTION_ID} \ - --query "properties.ipRules[].ipAddressOrRange" \ - -o tsv 2>/dev/null | tr '\n' ',' | sed 's/,$//') - - runner_public_ip=$(python3 -c "import urllib.request; print(urllib.request.urlopen('https://api.ipify.org', timeout=10).read().decode().strip())") - if [ -z "${runner_public_ip}" ]; then - echo "✗ ERROR: Failed to resolve the deployment runner public IP for Cosmos firewall access" >&2 - exit 1 - fi - - if runner_ip_matches_rules "${current_rules}" "${runner_public_ip}"; then - echo "✓ Deployment runner IP ${runner_public_ip} is already configured on the CosmosDB firewall" - wait_for_cosmos_runner_access "${runner_public_ip}" || exit 1 - return - fi - - updated_rules=$(python3 -c "existing = '${current_rules}'.strip(','); runner = '${runner_public_ip}'; rules = [rule.strip() for rule in existing.split(',') if rule.strip()]; rules.append(runner); deduped = []; [deduped.append(rule) for rule in rules if rule not in deduped]; print(','.join(deduped))") - update_output=$(az cosmosdb update \ - --name ${var_cosmosDb_accountName} \ - --resource-group ${var_rgName} \ - --subscription ${AZURE_SUBSCRIPTION_ID} \ - --ip-range-filter "${updated_rules}" 2>&1) - if [ $? -ne 0 ]; then - if is_mfa_error "${update_output}"; then - print_manual_cosmos_firewall_message "${runner_public_ip}" - echo " Checking whether CosmosDB access is already available without an Azure CLI firewall update..." >&2 - wait_for_cosmos_runner_access "${runner_public_ip}" || exit 1 - return - fi - echo "✗ ERROR: Failed to allow deployment runner IP ${runner_public_ip} through the CosmosDB firewall" >&2 - echo "${update_output}" >&2 - exit 1 - fi - echo "✓ Added deployment runner IP ${runner_public_ip} to the CosmosDB firewall" - wait_for_cosmos_runner_access "${runner_public_ip}" || exit 1 - } - if [ "${var_configureApplication}" = "true" ]; then echo "" echo "[1/4] Granting permissions to CosmosDB..." @@ -202,26 +119,9 @@ hooks: exit 1 fi - export var_cosmosDb_key=$(az cosmosdb keys list \ - --name ${var_cosmosDb_accountName} \ - --resource-group ${var_rgName} \ - --subscription ${AZURE_SUBSCRIPTION_ID} \ - --query primaryMasterKey \ - -o tsv) - if [ -z "${var_cosmosDb_key}" ]; then - echo "✗ ERROR: Failed to resolve Cosmos DB primary key for post-deployment configuration" >&2 - exit 1 - fi - - if test_cosmos_runner_access; then - echo "✓ Deployment runner already has CosmosDB data-plane access" - else - ensure_cosmos_runner_access - fi - echo "" echo "[3/4] Running post-deployment configuration..." - if .venv/bin/python3 ./bicep/postconfig.py; then + if python3 ./bicep/postconfig.py; then echo "✓ Post-deployment configuration completed" else echo "✗ ERROR: Post-deployment configuration failed" >&2 @@ -230,7 +130,7 @@ hooks: echo "" echo "[4/4] Restarting web service to apply settings..." - if az webapp restart --name ${var_webService} --resource-group ${var_rgName}; then + if az webapp restart --name "${var_webService}" --resource-group "${var_rgName}" --subscription "${AZURE_SUBSCRIPTION_ID}"; then echo "✓ Web service restarted successfully" else echo "✗ ERROR: Failed to restart web service" >&2 @@ -259,16 +159,18 @@ hooks: [string[]]$Names ) + if ([string]::IsNullOrWhiteSpace($env:AZURE_ENV_NAME)) { + throw 'AZURE_ENV_NAME is required; select the deployment environment explicitly.' + } foreach ($name in $Names) { - $currentValue = [Environment]::GetEnvironmentVariable($name, 'Process') - if (-not [string]::IsNullOrWhiteSpace($currentValue)) { - continue - } - - $resolvedValue = azd env get-value $name 2>$null + $lookupName = if ($name -eq 'var_subscriptionId') { 'AZURE_SUBSCRIPTION_ID' } else { $name } + $resolvedValue = azd env get-value $lookupName --environment $env:AZURE_ENV_NAME 2>$null if ($LASTEXITCODE -eq 0) { $resolvedText = ($resolvedValue | Out-String).Trim() [Environment]::SetEnvironmentVariable($name, $resolvedText, 'Process') + } else { + [Environment]::SetEnvironmentVariable($name, $null, 'Process') + throw "Unable to resolve deployment environment value: $name" } } } @@ -401,82 +303,6 @@ hooks: return $resolvedResourceGroup } - function Ensure-CosmosRunnerAccess { - param( - [string]$AccountName, - [string]$ResourceGroupName, - [string]$SubscriptionId - ) - - $currentRules = az cosmosdb show --name $AccountName --resource-group $ResourceGroupName --subscription $SubscriptionId --query "properties.ipRules[].ipAddressOrRange" -o tsv 2>$null - $runnerPublicIp = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 10).Trim() - if ([string]::IsNullOrWhiteSpace($runnerPublicIp)) { - throw 'Failed to resolve the deployment runner public IP for Cosmos DB firewall access.' - } - - $ruleList = @() - if (-not [string]::IsNullOrWhiteSpace($currentRules)) { - $ruleList = $currentRules -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - } - - if (Test-RunnerIpMatchesRules -Rules $ruleList -RunnerPublicIp $runnerPublicIp) { - Write-Host "✓ Deployment runner IP $runnerPublicIp is already configured on the CosmosDB firewall" - Wait-ForCosmosRunnerAccess -RunnerPublicIp $runnerPublicIp - return - } - - $updatedRules = @($ruleList + $runnerPublicIp | Select-Object -Unique) -join ',' - $updateOutput = az cosmosdb update --name $AccountName --resource-group $ResourceGroupName --subscription $SubscriptionId --ip-range-filter $updatedRules 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - if ($updateOutput -match 'AADSTS50076|multi-factor authentication|claims challenge') { - Write-Warning "Azure CLI requires multi-factor authentication before it can update the Cosmos DB firewall. Manually add IP $runnerPublicIp to Azure Portal -> Cosmos DB account '$AccountName' -> Networking -> Firewall in resource group '$ResourceGroupName'. Firewall changes can take up to 30 minutes to propagate, so wait before rerunning 'azd provision' or 'azd up'." - Write-Host " Checking whether CosmosDB access is already available without an Azure CLI firewall update..." - Wait-ForCosmosRunnerAccess -RunnerPublicIp $runnerPublicIp - return - } - - throw "Failed to allow deployment runner IP $runnerPublicIp through the Cosmos DB firewall.`n$updateOutput" - } - - Write-Host "✓ Added deployment runner IP $runnerPublicIp to the CosmosDB firewall" - Wait-ForCosmosRunnerAccess -RunnerPublicIp $runnerPublicIp - } - - function Test-CosmosRunnerAccess { - $accessCheckOutput = python -c "from azure.cosmos import CosmosClient; import os; client = CosmosClient(os.environ['var_cosmosDb_uri'], os.environ['var_cosmosDb_key']); next(client.list_databases(), None); print('ok')" 2>&1 | Out-String - return $LASTEXITCODE -eq 0 - } - - function Test-RunnerIpMatchesRules { - param( - [string[]]$Rules, - [string]$RunnerPublicIp - ) - - $env:CURRENT_RULES = ($Rules | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ',' - $env:RUNNER_PUBLIC_IP = $RunnerPublicIp - python -c "import ipaddress, os; rules = [rule.strip() for rule in os.environ.get('CURRENT_RULES', '').split(',') if rule.strip()]; ip = ipaddress.ip_address(os.environ['RUNNER_PUBLIC_IP']); matched = any(ip in ipaddress.ip_network(rule, strict=False) if '/' in rule else ip == ipaddress.ip_address(rule) for rule in rules); raise SystemExit(0 if matched else 1)" 2>$null - return $LASTEXITCODE -eq 0 - } - - function Wait-ForCosmosRunnerAccess { - param( - [string]$RunnerPublicIp - ) - - for ($attempt = 1; $attempt -le 20; $attempt++) { - if (Test-CosmosRunnerAccess) { - Write-Host "✓ CosmosDB data-plane access is now available for deployment runner IP $RunnerPublicIp" - return - } - - Write-Host " Waiting for CosmosDB firewall propagation for $RunnerPublicIp ($attempt/20)..." - Start-Sleep -Seconds 30 - } - - throw "CosmosDB firewall rule for $RunnerPublicIp is configured but data-plane access is still not available. Firewall changes can take up to 30 minutes to propagate. Wait and rerun 'azd provision' or 'azd up'." - } - function Test-PythonVirtualEnvironment { python -c "import sys; raise SystemExit(0 if sys.prefix != getattr(sys, 'base_prefix', sys.prefix) else 1)" 2>$null return $LASTEXITCODE -eq 0 @@ -515,47 +341,55 @@ hooks: $rgName = Resolve-ResourceGroupName $cosmosUri = $env:var_cosmosDb_uri $accountName = ([System.Uri]$cosmosUri).Host.Split('.')[0] - $objectId = az ad signed-in-user show --query id -o tsv - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($objectId)) { - throw 'Failed to resolve the signed-in user object ID for Cosmos DB role assignment.' + $identityType = az account show --subscription $subscriptionId --query user.type -o tsv + if ($LASTEXITCODE -ne 0) { + throw 'Failed to resolve the deployment identity type.' } + if ($identityType -eq 'user') { + $objectId = az ad signed-in-user show --query id -o tsv + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($objectId)) { + throw 'Failed to resolve the signed-in user object ID for Cosmos DB role assignment.' + } - $scope = "/subscriptions/$subscriptionId/resourceGroups/$rgName/providers/Microsoft.DocumentDB/databaseAccounts/$accountName" - $roleId = az role definition list --name Contributor --subscription $subscriptionId --query "[0].id" -o tsv - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($roleId)) { - throw 'Failed to resolve the Contributor role definition ID.' - } + $scope = "/subscriptions/$subscriptionId/resourceGroups/$rgName/providers/Microsoft.DocumentDB/databaseAccounts/$accountName" + $roleId = az role definition list --name Contributor --subscription $subscriptionId --query "[0].id" -o tsv + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($roleId)) { + throw 'Failed to resolve the Contributor role definition ID.' + } - $controlPlaneOutput = az role assignment create --assignee-object-id $objectId --assignee-principal-type User --role $roleId --scope $scope 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - if ($controlPlaneOutput -match 'already exists|RoleAssignmentExists|Conflict') { - Write-Host "Control-plane role already exists." - } elseif ($controlPlaneOutput -match 'AADSTS50076|multi-factor authentication|claims challenge') { - Write-Warning "Azure CLI requires multi-factor authentication before it can create the Cosmos DB control-plane role assignment." - Write-Warning "Continuing with Cosmos DB key-based post-deployment configuration." - } else { - throw "Failed to create the Cosmos DB control-plane role assignment.`n$controlPlaneOutput" + $controlPlaneOutput = az role assignment create --assignee-object-id $objectId --assignee-principal-type User --role $roleId --scope $scope 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($controlPlaneOutput -match 'already exists|RoleAssignmentExists|Conflict') { + Write-Host "Control-plane role already exists." + } elseif ($controlPlaneOutput -match 'AADSTS50076|multi-factor authentication|claims challenge') { + Write-Warning "Azure CLI requires multi-factor authentication before it can create the Cosmos DB control-plane role assignment." + throw 'Reauthenticate Azure CLI for the deployment tenant and retry. No key-auth fallback will be attempted.' + } else { + throw "Failed to create the Cosmos DB control-plane role assignment.`n$controlPlaneOutput" + } } - } - $dataPlaneRoleId = az cosmosdb sql role definition list --account-name $accountName --resource-group $rgName --subscription $subscriptionId --query "[?roleName=='Cosmos DB Built-in Data Contributor'].id | [0]" -o tsv - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($dataPlaneRoleId)) { - throw 'Failed to resolve the Cosmos DB built-in data contributor role definition ID.' - } + $dataPlaneRoleId = az cosmosdb sql role definition list --account-name $accountName --resource-group $rgName --subscription $subscriptionId --query "[?roleName=='Cosmos DB Built-in Data Contributor'].id | [0]" -o tsv + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($dataPlaneRoleId)) { + throw 'Failed to resolve the Cosmos DB built-in data contributor role definition ID.' + } - $dataPlaneOutput = az cosmosdb sql role assignment create --account-name $accountName --resource-group $rgName --subscription $subscriptionId --scope / --principal-id $objectId --role-definition-id $dataPlaneRoleId 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - if ($dataPlaneOutput -match 'already exists|RoleAssignmentExists|Conflict') { - Write-Host "Data-plane role already exists." - } elseif ($dataPlaneOutput -match 'AADSTS50076|multi-factor authentication|claims challenge') { - Write-Warning "Azure CLI requires multi-factor authentication before it can create the Cosmos DB data-plane role assignment." - Write-Warning "Continuing with Cosmos DB key-based post-deployment configuration." - } else { - throw "Failed to create the Cosmos DB data-plane role assignment.`n$dataPlaneOutput" + $dataPlaneOutput = az cosmosdb sql role assignment create --account-name $accountName --resource-group $rgName --subscription $subscriptionId --scope / --principal-id $objectId --role-definition-id $dataPlaneRoleId 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($dataPlaneOutput -match 'already exists|RoleAssignmentExists|Conflict') { + Write-Host "Data-plane role already exists." + } elseif ($dataPlaneOutput -match 'AADSTS50076|multi-factor authentication|claims challenge') { + Write-Warning "Azure CLI requires multi-factor authentication before it can create the Cosmos DB data-plane role assignment." + throw 'Reauthenticate Azure CLI for the deployment tenant and retry. No key-auth fallback will be attempted.' + } else { + throw "Failed to create the Cosmos DB data-plane role assignment.`n$dataPlaneOutput" + } } - } - Write-Host "✓ CosmosDB permissions granted successfully" + Write-Host "✓ CosmosDB permissions granted successfully" + } else { + Write-Host 'Non-user runner: using preassigned Cosmos data-plane permissions; postconfig will verify access.' + } Write-Host "" Write-Host "[2/4] Installing Python dependencies..." @@ -564,26 +398,6 @@ hooks: } Write-Host "✓ Dependencies installed successfully" - $cosmosDbKeyOutput = az cosmosdb keys list --name $accountName --resource-group $rgName --subscription $subscriptionId --query primaryMasterKey -o tsv 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - if ($cosmosDbKeyOutput -match 'AADSTS50076|multi-factor authentication|claims challenge') { - throw "Azure CLI requires multi-factor authentication before it can read the Cosmos DB primary key. Run 'az login --scope https://management.azure.com//.default' and rerun 'azd provision' or 'azd up'." - } - - throw "Failed to resolve the Cosmos DB primary key for post-deployment configuration.`n$cosmosDbKeyOutput" - } - - $env:var_cosmosDb_key = $cosmosDbKeyOutput.Trim() - if ([string]::IsNullOrWhiteSpace($env:var_cosmosDb_key)) { - throw 'Failed to resolve the Cosmos DB primary key for post-deployment configuration.' - } - - if (Test-CosmosRunnerAccess) { - Write-Host "✓ Deployment runner already has CosmosDB data-plane access" - } else { - Ensure-CosmosRunnerAccess -AccountName $accountName -ResourceGroupName $rgName -SubscriptionId $subscriptionId - } - Write-Host "" Write-Host "[3/4] Running post-deployment configuration..." python .\bicep\postconfig.py @@ -730,16 +544,24 @@ hooks: [string[]]$Names ) + if ([string]::IsNullOrWhiteSpace($env:AZURE_ENV_NAME)) { + throw 'AZURE_ENV_NAME is required; select the deployment environment explicitly.' + } foreach ($name in $Names) { - $currentValue = [Environment]::GetEnvironmentVariable($name, 'Process') - if (-not [string]::IsNullOrWhiteSpace($currentValue)) { + $lookupName = if ($name -eq 'var_subscriptionId') { 'AZURE_SUBSCRIPTION_ID' } else { $name } + if ($name -in @('SIMPLECHAT_INSTALL_CHROMIUM', 'SIMPLECHAT_INSTALL_FFMPEG') -and + -not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) { continue } - - $resolvedValue = azd env get-value $name 2>$null + $resolvedValue = azd env get-value $lookupName --environment $env:AZURE_ENV_NAME 2>$null if ($LASTEXITCODE -eq 0) { $resolvedText = ($resolvedValue | Out-String).Trim() [Environment]::SetEnvironmentVariable($name, $resolvedText, 'Process') + } else { + [Environment]::SetEnvironmentVariable($name, $null, 'Process') + if ($name -notin @('SIMPLECHAT_INSTALL_CHROMIUM', 'SIMPLECHAT_INSTALL_FFMPEG')) { + throw "Unable to resolve deployment environment value: $name" + } } } } @@ -1068,16 +890,18 @@ hooks: [string[]]$Names ) + if ([string]::IsNullOrWhiteSpace($env:AZURE_ENV_NAME)) { + throw 'AZURE_ENV_NAME is required; select the deployment environment explicitly.' + } foreach ($name in $Names) { - $currentValue = [Environment]::GetEnvironmentVariable($name, 'Process') - if (-not [string]::IsNullOrWhiteSpace($currentValue)) { - continue - } - - $resolvedValue = azd env get-value $name 2>$null + $lookupName = if ($name -eq 'var_subscriptionId') { 'AZURE_SUBSCRIPTION_ID' } else { $name } + $resolvedValue = azd env get-value $lookupName --environment $env:AZURE_ENV_NAME 2>$null if ($LASTEXITCODE -eq 0) { $resolvedText = ($resolvedValue | Out-String).Trim() [Environment]::SetEnvironmentVariable($name, $resolvedText, 'Process') + } else { + [Environment]::SetEnvironmentVariable($name, $null, 'Process') + throw "Unable to resolve deployment environment value: $name" } } } diff --git a/deployers/bicep/cosmosDb-postDeployPerms.sh b/deployers/bicep/cosmosDb-postDeployPerms.sh index eb8174d84..d2dcc031f 100644 --- a/deployers/bicep/cosmosDb-postDeployPerms.sh +++ b/deployers/bicep/cosmosDb-postDeployPerms.sh @@ -2,17 +2,20 @@ #!/usr/bin/env bash set -euo pipefail -RG_NAME="${var_rgName}" - -COSMOS_URI="${var_cosmosDb_uri}" -ACCOUNT_NAME=$(echo "$COSMOS_URI" | sed -E 's#https://([^.]*)\.documents\.azure\.com.*#\1#') +RG_NAME="${var_rgName:?Deployment resource group is required}" +ACCOUNT_NAME="${var_cosmosDb_accountName:?Deployment Cosmos account is required}" +SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:?Deployment subscription is required}" echo "===============================" echo "Cosmos DB Account Name: $ACCOUNT_NAME" -UPN=$(az account show --query user.name -o tsv) +IDENTITY_TYPE=$(az account show --subscription "$SUBSCRIPTION_ID" --query user.type -o tsv) +if [ "$IDENTITY_TYPE" != "user" ]; then + echo "Non-user runner: using preassigned Cosmos data-plane permissions; postconfig will verify access." + exit 0 +fi +UPN=$(az account show --subscription "$SUBSCRIPTION_ID" --query user.name -o tsv) OBJECT_ID=$(az ad signed-in-user show --query id -o tsv) -SUBSCRIPTION_ID=$(az account show --query id -o tsv) is_mfa_error() { printf '%s' "$1" | grep -qiE 'AADSTS50076|multi-factor authentication|claims challenge' @@ -33,9 +36,8 @@ handle_role_assignment_result() { if is_mfa_error "$command_output"; then echo "⚠ Azure CLI requires multi-factor authentication before it can complete $description." >&2 - echo " Continuing with Cosmos DB key-based post-deployment configuration." >&2 - echo " If you want the signed-in user to keep Cosmos DB access, run 'az login --scope https://management.azure.com//.default' and rerun the deployment later." >&2 - return 0 + echo " Reauthenticate Azure CLI for the deployment tenant and retry. No key-auth fallback will be attempted." >&2 + return 1 fi echo "✗ ERROR: Failed to complete $description" >&2 @@ -47,10 +49,11 @@ handle_role_assignment_result() { SCOPE="/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG_NAME/providers/Microsoft.DocumentDB/databaseAccounts/$ACCOUNT_NAME" ROLE_NAME="Contributor" -ROLE_ID=$(az role definition list --name "$ROLE_NAME" --query "[0].id" -o tsv) +ROLE_ID=$(az role definition list --subscription "$SUBSCRIPTION_ID" --name "$ROLE_NAME" --query "[0].id" -o tsv) echo "Assigning role '$ROLE_NAME' to user '$UPN' on scope '$SCOPE'..." CONTROL_PLANE_OUTPUT=$(az role assignment create \ + --subscription "$SUBSCRIPTION_ID" \ --assignee-object-id "$OBJECT_ID" \ --assignee-principal-type "User" \ --role "$ROLE_ID" \ @@ -60,6 +63,7 @@ CONTROL_PLANE_OUTPUT=$(az role assignment create \ # Data-plane assignment DP_ROLE_NAME="Cosmos DB Built-in Data Contributor" DP_ROLE_ID=$(az cosmosdb sql role definition list \ + --subscription "$SUBSCRIPTION_ID" \ --account-name "$ACCOUNT_NAME" \ --resource-group "$RG_NAME" \ --query "[?roleName=='$DP_ROLE_NAME'].id | [0]" -o tsv) @@ -67,6 +71,7 @@ DP_ROLE_ID=$(az cosmosdb sql role definition list \ echo "Assigning data-plane role '$DP_ROLE_NAME' to user '$UPN' on Cosmos DB account '$ACCOUNT_NAME'..." DATA_PLANE_OUTPUT=$(az cosmosdb sql role assignment create \ + --subscription "$SUBSCRIPTION_ID" \ --account-name "$ACCOUNT_NAME" \ --resource-group "$RG_NAME" \ --scope "/" \ diff --git a/deployers/bicep/deployment_configuration.py b/deployers/bicep/deployment_configuration.py new file mode 100644 index 000000000..b7289c56f --- /dev/null +++ b/deployers/bicep/deployment_configuration.py @@ -0,0 +1,207 @@ +# deployment_configuration.py +"""Safe post-provision configuration without importing the Flask application.""" + +import copy +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess + +from azure.core.credentials import AzureKeyCredential +from azure.core.exceptions import ClientAuthenticationError, HttpResponseError, ResourceNotFoundError +from azure.search.documents.indexes import SearchIndexClient +from azure.search.documents.indexes.models import SearchIndex +from dotenv import dotenv_values +from redis import Redis +from redis.exceptions import RedisError + + +APP_DIRECTORY = Path(__file__).resolve().parents[2] / "application" / "single_app" +DEPLOYERS_DIRECTORY = Path(__file__).resolve().parents[1] +REQUIRED_VALUES = ( + "AZURE_ENV_NAME", "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", + "var_authenticationType", "var_cosmosDb_accountName", "var_rgName", + "var_openAIEndpoint", "var_openAIGPTModels", "var_openAIEmbeddingModels", + "var_searchServiceEndpoint", "var_blobStorageEndpoint", + "var_documentIntelligenceServiceEndpoint", +) +REDIS_FIELDS = ( + "enable_redis_cache", "redis_url", "redis_service_type", "redis_port", + "redis_auth_type", "redis_key", +) + + +def load_app_module(name): + spec = importlib.util.spec_from_file_location(name, APP_DIRECTORY / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +settings_store = load_app_module("app_settings_store") +redis_helpers = load_app_module("functions_redis_client") + + +def load_deployment_environment(): + name = os.environ.get("AZURE_ENV_NAME") + if not name: + raise ValueError("AZURE_ENV_NAME is required; select an explicit AZD environment.") + executable = shutil.which("azd") + if not executable: + raise RuntimeError("AZD is required to resolve deployment outputs.") + result = subprocess.run( + [executable, "-C", str(DEPLOYERS_DIRECTORY), "env", "get-values", "--environment", name], + capture_output=True, text=True, check=False, + ) + if result.returncode: + raise RuntimeError("Unable to load the selected AZD environment; no configuration was written.") + values = dict(dotenv_values(stream=io.StringIO(result.stdout), interpolate=False)) + missing = [key for key in REQUIRED_VALUES if not values.get(key)] + if missing: + raise ValueError("Missing required deployment outputs: " + ", ".join(missing)) + if values["AZURE_ENV_NAME"] != name: + raise ValueError("AZD returned a different environment than requested.") + if values["var_authenticationType"] not in ("managed_identity", "key"): + raise ValueError("Unsupported deployment authentication type.") + for key in ("var_openAIGPTModels", "var_openAIEmbeddingModels"): + models = json.loads(values[key]) + if not isinstance(models, list) or not models or any( + not isinstance(model, dict) or not model.get("modelName") for model in models + ): + raise ValueError(f"Invalid model deployment output: {key}") + for key in list(os.environ): + if key.lower().startswith("var_"): + os.environ.pop(key) + os.environ.update({key: value for key, value in values.items() if value is not None}) + os.environ["var_subscriptionId"] = values["AZURE_SUBSCRIPTION_ID"] + return values + + +def redis_client_for_settings(settings, credential): + host = redis_helpers.normalize_redis_host(settings.get("redis_url")) + if not host: + raise ValueError("Enabled Redis requires a host before settings can be published.") + options = { + "host": host, + "port": redis_helpers.resolve_redis_port(settings), + "ssl": True, + "socket_connect_timeout": 10, + "socket_timeout": 10, + } + mode = settings.get("redis_auth_type", "key") + if mode == "managed_identity": + options["credential_provider"] = redis_helpers.RedisManagedIdentityCredentialProvider( + credential=credential, scope=redis_helpers.get_redis_entra_token_scope(settings), + ) + elif mode == "key" and settings.get("redis_key"): + options["password"] = settings["redis_key"] + else: + raise ValueError("Postconfig requires managed_identity or key access to the configured Redis cache.") + return Redis(**options) + + +def configure_redis(item, host, kind, port, authentication_type, keys): + host = (host or "").strip() + if not host: + return + if kind not in ("managed", "classic"): + raise ValueError("Unknown deployed Redis service type.") + if authentication_type not in ("managed_identity", "key"): + raise ValueError("Unknown deployed Redis authentication type.") + if authentication_type == "key" and not keys.get("redis_key"): + raise ValueError("Redis key was not resolved; settings were not saved.") + resolved_port = int(port or (10000 if kind == "managed" else 6380)) + if not 1 <= resolved_port <= 65535: + raise ValueError("Deployed Redis port is out of range.") + item.update({ + "enable_redis_cache": True, + "redis_url": host, + "redis_service_type": "azure_managed_redis" if kind == "managed" else "azure_cache_for_redis", + "redis_port": str(resolved_port), + "redis_auth_type": authentication_type, + "redis_key": keys.get("redis_key", "") if authentication_type == "key" else "", + }) + + +def redis_connection_signature(settings): + mode = str(settings.get("redis_auth_type") or "key").strip().lower() + return ( + bool(settings.get("enable_redis_cache")), + redis_helpers.normalize_redis_host(settings.get("redis_url")), + redis_helpers.resolve_redis_port(settings), + mode, + settings.get("redis_key") if mode != "managed_identity" else None, + ) + + +def persist_settings(container, original, desired, credential): + excluded = settings_store.COSMOS_METADATA_FIELDS | {settings_store.SETTINGS_REVISION_FIELD, "public_workspace_labels"} + updates = { + key: copy.deepcopy(value) + for key, value in desired.items() + if key not in excluded and (key not in original or original[key] != value) + } + before_redis = {key: original.get(key) for key in REDIS_FIELDS} + if original.get("enable_redis_cache") and redis_connection_signature(original) != redis_connection_signature(desired): + raise ValueError("Changing an active Redis configuration requires an administrator-managed cache migration.") + redis_client = None + try: + if desired.get("enable_redis_cache"): + redis_client = redis_client_for_settings(desired, credential) + try: + redis_client.ping() + except (RedisError, ClientAuthenticationError): + raise settings_store.SettingsUnavailableError( + "Deployment runner cannot publish settings to Redis. Check its Redis data access policy " + "and network connectivity; no Cosmos settings were written." + ) from None + store = settings_store.AppSettingsStore( + container, redis_client, redis_required=bool(desired.get("enable_redis_cache")), + ) + + def merge(current): + if {key: current.get(key) for key in REDIS_FIELDS} != before_redis: + raise settings_store.SettingsConflictError("Redis settings changed during deployment; reload and retry.") + current.update(copy.deepcopy(updates)) + current.pop("public_workspace_labels", None) + return current + + stored = store.write(merge, defaults={"id": "app_settings", "partition_key": "app_settings"}) + verified = store.read(use_cosmos=True) + if any(verified.get(key) != value for key, value in updates.items()): + raise settings_store.SettingsConflictError("Settings changed after deployment; verify before retrying.") + return stored + finally: + if redis_client is not None: + redis_client.close() + + +def ensure_search_indexes(client, schemas_directory=None): + schemas_directory = schemas_directory or APP_DIRECTORY / "static" / "json" + created = [] + for kind in ("user", "group", "public"): + schema = json.loads((schemas_directory / f"ai_search-index-{kind}.json").read_text(encoding="utf-8")) + name = schema["name"] + try: + client.get_index(name) + continue + except ResourceNotFoundError: + pass + try: + client.create_index(SearchIndex.deserialize(schema)) + created.append(name) + except HttpResponseError as error: + if error.status_code != 409: + raise + client.get_index(name) + return created + + +def configure_search(settings, credential): + if settings.get("azure_ai_search_authentication_type") == "key": + credential = AzureKeyCredential(settings["azure_ai_search_key"]) + with SearchIndexClient(settings["azure_ai_search_endpoint"], credential) as client: + return ensure_search_indexes(client) \ No newline at end of file diff --git a/deployers/bicep/deployment_cosmos.py b/deployers/bicep/deployment_cosmos.py new file mode 100644 index 000000000..b3567b42f --- /dev/null +++ b/deployers/bicep/deployment_cosmos.py @@ -0,0 +1,85 @@ +# deployment_cosmos.py +"""Deployment-time Cosmos authentication and non-mutating reachability checks.""" + +import json +import os +import shutil +import subprocess + +import azure.cosmos as azure_cosmos +from azure.cosmos.exceptions import CosmosHttpResponseError +from azure.identity import AzureCliCredential +from azure.core.exceptions import ClientAuthenticationError, ServiceRequestError + + +def run_cli(arguments): + executable = shutil.which("az") or shutil.which("az.cmd") + if not executable: + raise RuntimeError("Azure CLI is required for post-provision configuration.") + result = subprocess.run( + [executable, *arguments, "--output", "json"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode: + raise RuntimeError("Azure CLI operation failed. Check deployment identity and subscription access.") + return json.loads(result.stdout) + + +def create_deployment_cosmos_client(environment=None): + environment = os.environ if environment is None else environment + subscription = environment.get("AZURE_SUBSCRIPTION_ID") or environment.get("var_subscriptionId") + group = environment.get("var_rgName") + account_name = environment.get("var_cosmosDb_accountName") + mode = environment.get("var_authenticationType", "").strip().lower() + if not all((subscription, group, account_name)) or mode not in {"managed_identity", "key"}: + raise ValueError("Cosmos subscription, resource group, account and authentication type are required.") + + account = run_cli([ + "cosmosdb", "show", "--subscription", subscription, + "--resource-group", group, "--name", account_name, + ]) + if account.get("publicNetworkAccess") == "Disabled": + print("Cosmos public access is disabled; this runner must reach a private endpoint.") + endpoint = account["documentEndpoint"] + if mode == "managed_identity": + tenant = environment.get("AZURE_TENANT_ID") + credential = AzureCliCredential(**({"tenant_id": tenant} if tenant else {})) + else: + if account.get("disableLocalAuth"): + raise RuntimeError("Cosmos key authentication is disabled. Select managed_identity authentication.") + credential = run_cli([ + "cosmosdb", "keys", "list", "--subscription", subscription, + "--resource-group", group, "--name", account_name, + ])["primaryMasterKey"] + + client = None + try: + client = azure_cosmos.CosmosClient(endpoint, credential=credential, connection_timeout=15, read_timeout=30) + next(client.list_databases(), None) + return client + except CosmosHttpResponseError as error: + if client is not None: + client.close() + message = str(error).lower() + if "firewall" in message or "public internet" in message: + detail = "Cosmos network access is blocked. Use a network-connected runner or an approved firewall rule." + elif error.status_code in (401, 403): + detail = "Cosmos authentication or data-plane RBAC failed. Check the selected credential and Cosmos data role." + else: + detail = "Cosmos data-plane check failed. Check service availability and deployment diagnostics." + raise RuntimeError(detail + " No firewall rules were changed.") from None + except ClientAuthenticationError: + if client is not None: + client.close() + raise RuntimeError("Azure CLI authentication failed. Sign in to the deployment tenant and retry.") from None + except ServiceRequestError: + if client is not None: + client.close() + raise RuntimeError("Cosmos endpoint could not be reached. Check DNS and runner network connectivity.") from None + + +if __name__ == "__main__": + with create_deployment_cosmos_client() as cosmos_client: + print("Cosmos data-plane access verified; firewall settings were not changed.") \ No newline at end of file diff --git a/deployers/bicep/postconfig.py b/deployers/bicep/postconfig.py index 6715c4e26..f4b1bd8c6 100644 --- a/deployers/bicep/postconfig.py +++ b/deployers/bicep/postconfig.py @@ -1,5 +1,5 @@ # postconfig.py -import azure.cosmos as azure_cosmos +import copy from azure.cosmos.exceptions import CosmosResourceNotFoundError from azure.identity import AzureCliCredential import json @@ -8,7 +8,16 @@ import subprocess from urllib.parse import urlparse -credential = AzureCliCredential() +from deployment_configuration import ( + configure_redis, + configure_search, + load_deployment_environment, + persist_settings, +) +from deployment_cosmos import create_deployment_cosmos_client + +load_deployment_environment() +credential = AzureCliCredential(tenant_id=os.environ["AZURE_TENANT_ID"]) STANDARD_AZURE_OPENAI_HOST_SUFFIXES = ( ".openai.azure.com", @@ -348,14 +357,7 @@ def get_core_service_keys( return keys -cosmosEndpoint = os.getenv("var_cosmosDb_uri") -cosmosKey = os.getenv("var_cosmosDb_key") - -if cosmosKey: - client = azure_cosmos.CosmosClient(cosmosEndpoint, cosmosKey) -else: - credential.get_token("https://cosmos.azure.com/.default") - client = azure_cosmos.CosmosClient(cosmosEndpoint, credential=credential) +client = create_deployment_cosmos_client() database_name = "SimpleChat" container_name = "settings" @@ -376,6 +378,8 @@ def get_core_service_keys( "partition_key": partition_key } +original_item = copy.deepcopy(item) + # Get values from environment variables var_authenticationType = os.getenv("var_authenticationType") var_redisAuthenticationType = os.getenv("var_redisAuthenticationType") or var_authenticationType @@ -491,20 +495,8 @@ def get_core_service_keys( item["enable_appinsights_global_logging"] = True # Scale > Redis Cache -# Only written when this deployment provisioned a cache, so an operator-configured -# external Redis is not overwritten when deployRedisCache is false. -redis_cache_host_name = (var_redisCacheHostName or "").strip() -if redis_cache_host_name: - item["enable_redis_cache"] = True - item["redis_url"] = redis_cache_host_name - item["redis_auth_type"] = var_redisAuthenticationType - # The application uses different service-type identifiers than the Bicep redisCacheKind parameter. - item["redis_service_type"] = ( - "azure_managed_redis" if var_redisCacheKind == "managed" else "azure_cache_for_redis" - ) - item["redis_port"] = var_redisCachePort - # Empty under managed identity, which also clears a stale key from an earlier key-auth deployment. - item["redis_key"] = core_service_keys.get("redis_key", "") +configure_redis(item, var_redisCacheHostName, var_redisCacheKind, + var_redisCachePort, var_redisAuthenticationType, core_service_keys) # Workspaces > Metadata Extraction item["enable_extract_meta_data"] = True @@ -536,20 +528,6 @@ def get_core_service_keys( if var_authenticationType == "key" and "content_safety_key" in core_service_keys: item["content_safety_key"] = core_service_keys["content_safety_key"] -# Redis Cache Configuration -if var_redisCacheHostName and var_redisCacheHostName.strip(): - item["enable_redis_cache"] = True - # Only assert a service when a cache was actually deployed. Writing a definitive value - # here otherwise would override host name detection for an existing cache. - item["redis_service_type"] = ( - "azure_cache_for_redis" if var_redisCacheKind == "classic" else "azure_managed_redis" - ) - item["redis_port"] = var_redisCachePort -item["redis_url"] = var_redisCacheHostName -item["redis_auth_type"] = var_redisAuthenticationType -if var_redisAuthenticationType == "key" and "redis_key" in core_service_keys: - item["redis_key"] = core_service_keys["redis_key"] - # Safety > Conversation Archiving item["enable_conversation_archiving"] = True @@ -585,7 +563,8 @@ def get_core_service_keys( if var_authenticationType == "key" and "speech_service_key" in core_service_keys: item["speech_service_key"] = core_service_keys["speech_service_key"] -# 5. Upsert the updated items back into Cosmos DB -response = container.upsert_item(item) -print( - f"Updated item: {response['id']} with enable_external_healthcheck = {response['enable_external_healthcheck']}") +created_indexes = configure_search(item, credential) +response = persist_settings(container, original_item, item, credential) +print(f"Settings saved and verified. Search indexes created: {', '.join(created_indexes) or 'none (already exist)'}.") +print("Restart the web service to activate any changed Redis session configuration.") +client.close() diff --git a/deployers/bicep/requirements.txt b/deployers/bicep/requirements.txt index aafac2676..00b641f7f 100644 --- a/deployers/bicep/requirements.txt +++ b/deployers/bicep/requirements.txt @@ -1,4 +1,7 @@ # requirements.txt azure-identity==1.23.0 azure-cosmos==4.9.0 -azure-keyvault-secrets==4.10.0 \ No newline at end of file +azure-keyvault-secrets==4.10.0 +azure-search-documents==11.5.3 +python-dotenv==1.2.2 +redis==5.3.1 \ No newline at end of file diff --git a/deployers/version.txt b/deployers/version.txt index 475bda9cf..94956151c 100644 --- a/deployers/version.txt +++ b/deployers/version.txt @@ -1 +1 @@ -1.0.30 +1.0.31 diff --git a/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md b/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md index 12958f246..bf29b1c8e 100644 --- a/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md +++ b/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md @@ -34,4 +34,37 @@ Code changes summary: Functional coverage is provided by `functional_tests/test_azd_managed_identity_preflight.py`. -Before the fix, managed identity permission gaps could surface late or be hidden behind postprovision warnings. After the fix, managed identity deployments stop during preprovision with a clear explanation and remediation path. \ No newline at end of file +Before the fix, managed identity permission gaps could surface late or be hidden behind postprovision warnings. After the fix, managed identity deployments stop during preprovision with a clear explanation and remediation path. + +## Post-Provision Reliability (0.261.028) + +Fixed/Implemented in version: **0.261.028**, tracked in +`application/single_app/config.py`; deployer version **1.0.31** in `deployers/version.txt`. + +The former postprovision hook always retrieved Cosmos keys, even for managed-identity +deployments. A disabled-local-auth account therefore failed its probe, which was +misreported as a firewall propagation delay. Its CLI firewall lookup also used the +ARM response path `properties.ipRules` against the CLI's flattened response, risking +replacement of existing rules with a runner-only rule. + +Both platform hooks now use the credential-aware `deployment_cosmos.py` path through +`postconfig.py`. It never changes network rules, never falls back from Entra to keys, +and distinguishes network errors from authentication or RBAC failures. Windows fallback +lookups are explicitly bound to `AZURE_ENV_NAME`, including the subscription alias. +The POSIX role script also uses the deployment subscription rather than the CLI default. + +`deployment_configuration.py` removes the duplicate Redis-writing paths and routes +settings writes through the app's `AppSettingsStore`, preserving concurrent unrelated +settings and publishing the shared cache with the existing fenced-write protocol. +Publication failures remain failures, even if the Cosmos write has already completed. +The runner must have Redis data access when the app uses Redis; automatic migration +between active cache configurations is intentionally refused. + +The same configuration step now creates only missing Search indexes using the app's +JSON definitions. Existing indexes are never updated or deleted by the deployer. + +Offline regression coverage lives in `test_deployment_cosmos_access.py` and +`test_deployment_configuration.py`, including Windows and POSIX hook execution, +explicit environment selection, key-disabled accounts, concurrency, cache publication +failure, first-run settings creation, preserved external caches and index-creation races. +No tenant exemptions or production resource changes are required to run these tests. \ No newline at end of file diff --git a/docs/reference/deploy/azd-cli_deploy.md b/docs/reference/deploy/azd-cli_deploy.md index 28a488da7..d805edb20 100644 --- a/docs/reference/deploy/azd-cli_deploy.md +++ b/docs/reference/deploy/azd-cli_deploy.md @@ -84,6 +84,52 @@ This is the primary recommended deployment path for the repo. - **Resource quota** for required services in target region - **Permissions** to create service principals (if not using existing) +### Post-Provision Access + +Implemented in version: **0.261.028**, deployer **1.0.31**. + +The deployment runner and the App Service managed identity are different identities. +For `managed_identity` deployments, post-configuration uses the runner's Azure CLI +Entra credential in `AZURE_TENANT_ID`, not the App Service identity and not Cosmos keys. +The runner needs Cosmos DB data-plane read/write access and Search Service Contributor +access to create indexes. An interactive CLI user receives the existing Cosmos role +setup; non-user runners must have their Cosmos data role assigned beforehand. + +When Redis is enabled, the runner also needs network access and Redis data-plane +permission to read and publish the shared settings record. For Azure Managed Redis, +assign the runner a suitable database access policy such as Data Owner. Azure RBAC +Owner on the resource alone does not grant Redis data access. If publication fails, +the deployment fails rather than reporting a successful save with stale cached settings. + +Post-configuration no longer changes Cosmos firewall rules. A runner using a public +endpoint must already be permitted by its firewall; an account with public access +disabled requires a runner with private endpoint connectivity. Authentication/RBAC, +network, and service failures have separate diagnostics. The hook does not enable key +authentication or create policy exemptions. Key-mode deployments require local auth +to be enabled; keys are not used as a fallback for failed Entra access. + +Select the environment explicitly before invoking hooks. Missing required outputs +stop configuration instead of falling back to another environment. This includes +`AZURE_TENANT_ID`; set it on older environments that do not already contain it. + +```powershell +azd env set AZURE_TENANT_ID -e +azd hooks run postprovision -e +``` + +Settings updates use Cosmos ETags and the same shared Redis publication protocol as +the app. With Redis disabled there is no shared-cache dependency. Enabling a newly +deployed cache publishes the settings before restarting the app; changing an already +active cache endpoint or authentication configuration requires a planned cache migration +and is not performed automatically. When no Redis host is output by deployment, +existing external Redis settings are preserved. + +The hook creates missing personal, group, and public Search indexes from the app's +vendored JSON schemas. Existing indexes, including customized schemas and documents, +are left unchanged. Authorization and service errors stop creation; only a not-found +response triggers creation. A concurrent creation is accepted only after the index +can be read. Schema upgrades for existing indexes remain an admin-managed operation. + ### Supported Environments - ✅ **Azure Commercial** - ✅ **Azure Government** (with environment configuration) diff --git a/functional_tests/test_azd_windows_hook_environment.py b/functional_tests/test_azd_windows_hook_environment.py index 9cf359f69..537438195 100644 --- a/functional_tests/test_azd_windows_hook_environment.py +++ b/functional_tests/test_azd_windows_hook_environment.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for azd Windows hook environment hydration. -Version: 0.241.101 +Version: 0.261.028 Implemented in: 0.241.101 This test ensures Windows azd hooks import missing var_* values from the @@ -85,7 +85,7 @@ def assert_windows_hook_imports_environment(hook_name: str, section: str) -> Non assert section.count("function Import-AzdHookEnvironment") == 1, ( f"Expected {hook_name} to define the azd environment import helper once." ) - assert "azd env get-value $name 2>$null" in section, ( + assert "azd env get-value $lookupName --environment $env:AZURE_ENV_NAME 2>$null" in section, ( f"Expected {hook_name} to read missing values from azd env get-value." ) assert "[Environment]::SetEnvironmentVariable($name, $resolvedText, 'Process')" in section, ( diff --git a/functional_tests/test_azd_windows_hooks.py b/functional_tests/test_azd_windows_hooks.py index b85bca4c3..8fb76973e 100644 --- a/functional_tests/test_azd_windows_hooks.py +++ b/functional_tests/test_azd_windows_hooks.py @@ -2,7 +2,7 @@ # test_azd_windows_hooks.py """ Functional test for AZD Windows hook coverage. -Version: 0.237.060 +Version: 0.261.028 Implemented in: 0.237.060 This test ensures that azure.yaml defines Windows run hooks for the AZD lifecycle @@ -39,19 +39,9 @@ def test_azd_windows_hooks() -> bool: require_contains(content, "function Get-TargetSubscriptionId", "helper function for subscription targeting") require_contains(content, "az group exists --name", "resource group validation") require_contains(content, "az cosmosdb list", "Cosmos DB RG discovery fallback") - require_contains(content, "az cosmosdb keys list", "Cosmos DB key retrieval for postconfig") - require_contains(content, "Ensure-CosmosRunnerAccess", "Cosmos firewall helper") - require_contains(content, "Test-CosmosRunnerAccess", "Cosmos access probe helper") - require_contains(content, "Test-RunnerIpMatchesRules", "Cosmos firewall CIDR coverage helper") - require_contains(content, "Wait-ForCosmosRunnerAccess", "Cosmos firewall propagation wait helper") - require_contains(content, "api.ipify.org", "deployment runner public IP lookup") - require_contains(content, "az cosmosdb update", "Cosmos firewall update command") - require_contains(content, "Manually add IP $runnerPublicIp", "manual Cosmos firewall guidance") - require_contains(content, "Azure CLI requires multi-factor authentication", "explicit MFA guidance") - require_contains(content, "Checking whether CosmosDB access is already available without an Azure CLI firewall update", "Cosmos MFA fallback access check") - require_contains(content, "Deployment runner already has CosmosDB data-plane access", "Cosmos access short-circuit") - require_contains(content, "Waiting for CosmosDB firewall propagation", "Cosmos propagation wait messaging") - require_contains(content, "$env:var_cosmosDb_key", "Cosmos DB key propagation") + if "--ip-range-filter" in content or "az cosmosdb keys list" in content: + raise AssertionError("Hooks must not mutate Cosmos firewall rules or force key authentication") + require_contains(content, "No key-auth fallback will be attempted", "explicit authentication failure") require_contains(content, "--subscription $subscriptionId", "subscription-pinned Azure CLI commands") require_contains(content, "$env:var_rgName = $resolvedResourceGroup", "resolved RG propagation") @@ -59,11 +49,8 @@ def test_azd_windows_hooks() -> bool: print("✅ Windows predeploy hook is present") print("✅ Windows postup hook is present") print("✅ Windows RG fallback logic is validated") - print("✅ Windows postconfig Cosmos key fallback is validated") - print("✅ Windows Cosmos firewall runner access handling is validated") - print("✅ Windows Cosmos access short-circuit is validated") - print("✅ Windows Cosmos firewall propagation handling is validated") - print("✅ Windows Cosmos MFA fallback access handling is validated") + print("✅ Windows hooks do not force Cosmos key authentication") + print("✅ Windows hooks do not mutate Cosmos firewall IP rules") print("✅ Windows MFA recovery guidance is validated") print("✅ Windows subscription targeting is validated") print("✅ Environment variable propagation is covered") diff --git a/functional_tests/test_deployment_configuration.py b/functional_tests/test_deployment_configuration.py new file mode 100644 index 000000000..639a86e50 --- /dev/null +++ b/functional_tests/test_deployment_configuration.py @@ -0,0 +1,408 @@ +# test_deployment_configuration.py +"""Postconfig environment, cache publication and create-only Search regressions. + +Version: 0.261.028 +Implemented in: 0.261.028 +Uses the real settings store with fake services; never contacts Azure. +""" + +import copy +import importlib.util +import json +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock +import runpy +import shutil +import subprocess +import sys + +import pytest +import yaml +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError + +from test_app_settings_store_consistency import FakeCosmos, FakeRedis + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "deployment_configuration", ROOT / "deployers" / "bicep" / "deployment_configuration.py", +) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +@pytest.fixture +def world(monkeypatch): + cosmos, redis = FakeCosmos(), FakeRedis() + redis.ping = Mock(return_value=True) + redis.close = Mock() + monkeypatch.setattr(MODULE, "redis_client_for_settings", lambda *_: redis) + return SimpleNamespace(cosmos=cosmos, redis=redis) + + +def desired_redis(document, kind="managed", auth="managed_identity"): + desired = copy.deepcopy(document) + MODULE.configure_redis(desired, "cache.redis.azure.net", kind, "", auth, {"redis_key": "test-key"}) + return desired + + +def test_save_publishes_shared_cache_and_preserves_concurrent_edit(world): + original = copy.deepcopy(world.cosmos.document) + desired = desired_redis(original) + + def concurrent_write(): + world.cosmos.document["operator_setting"] = "keep" + world.cosmos.etag += 1 + world.cosmos.document["_etag"] = str(world.cosmos.etag) + + world.cosmos.before_replace = concurrent_write + stored = MODULE.persist_settings(world.cosmos, original, desired, Mock()) + published = json.loads(world.redis.raw)["document"] + assert stored["operator_setting"] == "keep" + assert stored["enable_redis_cache"] is True + assert stored["redis_port"] == "10000" + assert published == stored + assert stored["_settings_revision"] == 1 + world.redis.close.assert_called_once() + + +def test_existing_disabled_external_settings_are_not_erased(world): + world.cosmos.document.update(redis_url="external.example", enable_redis_cache=False, redis_key="keep") + original = copy.deepcopy(world.cosmos.document) + desired = copy.deepcopy(original) + MODULE.configure_redis(desired, "", "", "", "managed_identity", {}) + desired["deployment_setting"] = True + stored = MODULE.persist_settings(world.cosmos, original, desired, Mock()) + assert stored["redis_url"] == "external.example" + assert stored["redis_key"] == "keep" + assert stored["enable_redis_cache"] is False + + +@pytest.mark.parametrize("kind,auth,port,key", [ + ("managed", "managed_identity", "10000", ""), + ("classic", "key", "6380", "test-key"), +]) +def test_redis_mapping_and_authentication(kind, auth, port, key): + result = desired_redis({"redis_key": "stale"}, kind, auth) + assert result["redis_port"] == port + assert result["redis_key"] == key + assert result["redis_service_type"] == ( + "azure_managed_redis" if kind == "managed" else "azure_cache_for_redis" + ) + + +def test_first_run_creates_document(world): + world.cosmos.document = None + initial = {"id": "app_settings", "partition_key": "app_settings"} + stored = MODULE.persist_settings(world.cosmos, initial, {**initial, "deployed": True}, Mock()) + assert stored["deployed"] + assert world.cosmos.writes == 1 + + +def test_redis_failure_prevents_database_write(world): + world.redis.failed = True + original = copy.deepcopy(world.cosmos.document) + with pytest.raises(MODULE.settings_store.SettingsUnavailableError): + MODULE.persist_settings(world.cosmos, original, desired_redis(original), Mock()) + assert world.cosmos.writes == 0 + + +def test_publication_failure_is_not_reported_as_success(world): + world.redis.fail_publication = True + original = copy.deepcopy(world.cosmos.document) + with pytest.raises(MODULE.settings_store.SettingsUnavailableError): + MODULE.persist_settings(world.cosmos, original, desired_redis(original), Mock()) + assert world.cosmos.writes == 1 + assert json.loads(world.redis.raw)["state"] == "pending" + + +def test_active_cache_migration_fails_before_writes(world): + world.cosmos.document = desired_redis(world.cosmos.document) + original = copy.deepcopy(world.cosmos.document) + desired = {**original, "redis_url": "different.redis.azure.net"} + with pytest.raises(ValueError, match="cache migration"): + MODULE.persist_settings(world.cosmos, original, desired, Mock()) + assert world.cosmos.writes == 0 + + +def test_concurrent_redis_change_is_not_overwritten(world): + original = copy.deepcopy(world.cosmos.document) + world.cosmos.document["redis_url"] = "configured-by-admin" + with pytest.raises(MODULE.settings_store.SettingsConflictError): + MODULE.persist_settings(world.cosmos, original, desired_redis(original), Mock()) + assert world.cosmos.document["redis_url"] == "configured-by-admin" + assert world.cosmos.writes == 0 + + +def test_repeated_redis_configuration_preserves_values(world): + original = copy.deepcopy(world.cosmos.document) + first = MODULE.persist_settings(world.cosmos, original, desired_redis(original), Mock()) + repeated = MODULE.persist_settings(world.cosmos, first, desired_redis(first), Mock()) + assert {key: repeated.get(key) for key in MODULE.REDIS_FIELDS} == { + key: first.get(key) for key in MODULE.REDIS_FIELDS + } + assert json.loads(world.redis.raw)["document"] == repeated + + +def test_enabled_external_cache_is_preserved_and_published(world): + world.cosmos.document = desired_redis(world.cosmos.document) + original = copy.deepcopy(world.cosmos.document) + desired = copy.deepcopy(original) + MODULE.configure_redis(desired, None, "", "", "managed_identity", {}) + desired["deployment_setting"] = "new" + stored = MODULE.persist_settings(world.cosmos, original, desired, Mock()) + assert stored["redis_url"] == original["redis_url"] + assert json.loads(world.redis.raw)["document"]["deployment_setting"] == "new" + + +def test_equivalent_active_redis_settings_can_be_normalized(world): + world.cosmos.document = desired_redis(world.cosmos.document) + world.cosmos.document.update(redis_service_type="auto", redis_port=10000, redis_key="unused-old-key") + original = copy.deepcopy(world.cosmos.document) + stored = MODULE.persist_settings(world.cosmos, original, desired_redis(original), Mock()) + assert stored["redis_port"] == "10000" + assert stored["redis_key"] == "" + assert stored["redis_service_type"] == "azure_managed_redis" + + +class FakeSearch: + def __init__(self): + self.indexes = {"simplechat-user-index": {"name": "simplechat-user-index", "custom": True}} + self.created = [] + self.fail_status = None + self.race = False + + def get_index(self, name): + if self.fail_status: + error = HttpResponseError(message="lookup failure") + error.status_code = self.fail_status + raise error + if name not in self.indexes: + raise ResourceNotFoundError(status_code=404) + return self.indexes[name] + + def create_index(self, index): + self.indexes[index.name] = index + if self.race: + error = HttpResponseError(message="concurrent creation") + error.status_code = 409 + raise error + self.created.append(index) + return index + + +def test_search_uses_app_schemas_and_leaves_existing_indexes_untouched(): + client = FakeSearch() + existing = client.indexes["simplechat-user-index"] + created = MODULE.ensure_search_indexes(client) + repeated = MODULE.ensure_search_indexes(client) + assert created == ["simplechat-group-index", "simplechat-public-index"] + assert repeated == [] + assert client.indexes["simplechat-user-index"] is existing + for index in client.created: + kind = index.name.removeprefix("simplechat-").removesuffix("-index") + schema = json.loads((MODULE.APP_DIRECTORY / "static" / "json" / f"ai_search-index-{kind}.json").read_text()) + assert [field.name for field in index.fields] == [field["name"] for field in schema["fields"]] + + +def test_search_create_race_is_idempotent(): + client = FakeSearch() + client.race = True + MODULE.ensure_search_indexes(client) + assert set(client.indexes) == {"simplechat-user-index", "simplechat-group-index", "simplechat-public-index"} + + +@pytest.mark.parametrize("status", [401, 403, 429, 500]) +def test_search_permission_and_service_errors_do_not_trigger_creation(status): + client = FakeSearch() + client.fail_status = status + with pytest.raises(HttpResponseError): + MODULE.ensure_search_indexes(client) + assert not client.created + + +def test_environment_selection_overrides_stale_outputs(monkeypatch): + monkeypatch.setenv("AZURE_ENV_NAME", "chosen") + monkeypatch.setenv("var_redisCacheHostName", "wrong-cache") + monkeypatch.setenv("var_cosmosDb_key", "stale-key") + values = {key: "value" for key in MODULE.REQUIRED_VALUES} + values.update(AZURE_ENV_NAME="chosen", var_authenticationType="managed_identity") + values["var_openAIGPTModels"] = json.dumps([{"modelName": "model"}]) + values["var_openAIEmbeddingModels"] = json.dumps([{"modelName": "embedding"}]) + output = "\n".join(f"{key}={json.dumps(value)}" for key, value in values.items()) + command = Mock(return_value=SimpleNamespace(returncode=0, stdout=output)) + monkeypatch.setattr(MODULE.shutil, "which", lambda _: "azd") + monkeypatch.setattr(MODULE.subprocess, "run", command) + previous = dict(os.environ) + try: + resolved = MODULE.load_deployment_environment() + assert resolved["AZURE_ENV_NAME"] == "chosen" + assert "var_redisCacheHostName" not in os.environ + assert "var_cosmosDb_key" not in os.environ + assert json.loads(os.environ["var_openAIGPTModels"])[0]["modelName"] == "model" + assert command.call_args.args[0][-2:] == ["--environment", "chosen"] + finally: + os.environ.clear() + os.environ.update(previous) + + +def test_missing_environment_output_fails_before_mutation(monkeypatch): + monkeypatch.setenv("AZURE_ENV_NAME", "chosen") + monkeypatch.setenv("var_rgName", "untouched") + monkeypatch.setattr(MODULE.shutil, "which", lambda _: "azd") + monkeypatch.setattr(MODULE.subprocess, "run", lambda *_, **__: SimpleNamespace(returncode=0, stdout='AZURE_ENV_NAME="chosen"')) + with pytest.raises(ValueError, match="Missing required"): + MODULE.load_deployment_environment() + assert os.environ["var_rgName"] == "untouched" + + +@pytest.mark.parametrize("platform", ["windows", "posix"]) +def test_postprovision_has_no_network_mutation_or_key_probe(platform): + hook = yaml.safe_load((ROOT / "deployers" / "azure.yaml").read_text(encoding="utf-8"))["hooks"]["postprovision"][platform]["run"] + assert "--ip-range-filter" not in hook + assert "cosmosdb keys list" not in hook + assert "api.ipify.org" not in hook + assert "postconfig.py" in hook + + +@pytest.mark.parametrize("hook_name", ["postprovision", "predeploy", "postup"]) +def test_windows_hydration_executes_with_explicit_environment(hook_name): + pwsh = shutil.which("pwsh") + if not pwsh: + pytest.skip("PowerShell is unavailable") + source = yaml.safe_load((ROOT / "deployers" / "azure.yaml").read_text(encoding="utf-8"))["hooks"][hook_name]["windows"]["run"] + helper = source.split("Import-AzdHookEnvironment -Names @(", 1)[0] + probe = helper + r''' +function azd { + if ($args[0] -ne 'env' -or $args[1] -ne 'get-value' -or + $args[3] -ne '--environment' -or $args[4] -ne 'selected') { throw 'Unscoped lookup' } + if ($args[2] -eq 'missing') { $global:LASTEXITCODE = 1; return } + $global:LASTEXITCODE = 0 + if ($args[2] -eq 'AZURE_SUBSCRIPTION_ID') { return 'selected-subscription' } + return 'selected-host' +} +$env:AZURE_ENV_NAME = 'selected' +$env:var_redisCacheHostName = 'wrong-environment-host' +Import-AzdHookEnvironment -Names @('var_redisCacheHostName', 'var_subscriptionId') +if ($env:var_redisCacheHostName -ne 'selected-host') { throw 'Stale host was retained' } +if ($env:var_subscriptionId -ne 'selected-subscription') { throw 'Alias was not resolved' } +$failed = $false +try { Import-AzdHookEnvironment -Names @('missing') } catch { $failed = $true } +if (-not $failed) { throw 'Missing required value was ignored' } +''' + result = subprocess.run([pwsh, "-NoProfile", "-NonInteractive", "-Command", probe], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_real_postconfig_entrypoint_writes_once_and_initializes_search(monkeypatch, world): + environment = { + "AZURE_TENANT_ID": "tenant", "var_authenticationType": "managed_identity", + "var_openAIEndpoint": "https://example.openai.azure.com/", + "var_subscriptionId": "subscription", "var_rgName": "group", + "var_openAIGPTModels": '[{"modelName":"gpt"}]', + "var_openAIEmbeddingModels": '[{"modelName":"embedding"}]', + "var_redisCacheHostName": "cache.redis.azure.net", "var_redisCacheKind": "managed", + "var_redisCachePort": "10000", "var_redisAuthenticationType": "managed_identity", + "var_blobStorageEndpoint": "https://example.blob.core.windows.net", + "var_searchServiceEndpoint": "https://example.search.windows.net", + } + for name, value in environment.items(): + monkeypatch.setenv(name, value) + monkeypatch.setattr(MODULE, "load_deployment_environment", lambda: environment) + search = FakeSearch() + monkeypatch.setattr(MODULE, "configure_search", lambda *_: MODULE.ensure_search_indexes(search)) + cosmos = Mock() + cosmos.get_database_client.return_value.get_container_client.return_value = world.cosmos + access_module = SimpleNamespace(create_deployment_cosmos_client=lambda: cosmos) + monkeypatch.setitem(sys.modules, "deployment_cosmos", access_module) + monkeypatch.setitem(sys.modules, "deployment_configuration", MODULE) + runpy.run_path(str(ROOT / "deployers" / "bicep" / "postconfig.py"), run_name="__main__") + assert world.cosmos.writes == 1 + assert world.cosmos.document["redis_service_type"] == "azure_managed_redis" + assert world.cosmos.document["redis_url"] == "cache.redis.azure.net" + assert json.loads(world.redis.raw)["document"] == world.cosmos.document + assert set(search.indexes) == {"simplechat-user-index", "simplechat-group-index", "simplechat-public-index"} + cosmos.close.assert_called_once() + + +@pytest.mark.parametrize("failure", [False, True]) +def test_windows_hook_stops_before_restart_on_postconfig_failure(failure): + pwsh = shutil.which("pwsh") + if not pwsh: + pytest.skip("PowerShell is unavailable") + hook = yaml.safe_load((ROOT / "deployers" / "azure.yaml").read_text(encoding="utf-8"))["hooks"]["postprovision"]["windows"]["run"] + mocks = r''' +$env:AZURE_ENV_NAME = 'chosen' +$global:Calls = @() +function azd { + if ($args[3] -ne '--environment' -or $args[4] -ne 'chosen') { throw 'Wrong environment' } + $global:LASTEXITCODE = 0 + switch ($args[2]) { + 'AZURE_SUBSCRIPTION_ID' { return 'chosen-sub' } + 'var_configureApplication' { return 'true' } + 'var_cosmosDb_uri' { return 'https://account.documents.azure.com/' } + 'var_rgName' { return 'chosen-group' } + 'var_webService' { return 'chosen-web' } + default { return 'value' } + } +} +function az { + $global:Calls += ($args -join ' ') + $global:LASTEXITCODE = 0 + if ($args[0] -eq 'group') { return 'true' } + if ($args[0] -eq 'account') { return 'servicePrincipal' } + if ($args[0] -eq 'webapp' -and $args[1] -eq 'restart') { return } + throw 'Unexpected Azure command' +} +function python { + $global:LASTEXITCODE = 0 + if (($args -join ' ') -match 'postconfig.py' -and $env:TEST_POSTCONFIG_FAILURE -eq 'true') { + $global:LASTEXITCODE = 1 + } +} +''' + script = mocks + "\ntry {\n" + hook + "\n} catch { Write-Output 'HOOK_FAILED' }\n$global:Calls | ForEach-Object { Write-Output ('AZCALL=' + $_) }" + result = subprocess.run( + [pwsh, "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, + env={**os.environ, "TEST_POSTCONFIG_FAILURE": str(failure).lower()}, + ) + assert result.returncode == 0, result.stderr + assert ("HOOK_FAILED" in result.stdout) is failure + assert ("AZCALL=webapp restart" in result.stdout) is not failure + assert "--ip-range-filter" not in result.stdout + if not failure: + assert "--subscription chosen-sub" in result.stdout + + +@pytest.mark.parametrize("failure", [False, True]) +def test_posix_hook_stops_before_restart_on_postconfig_failure(failure): + git_bash = Path("C:/Program Files/Git/bin/bash.exe") + shell = str(git_bash) if git_bash.exists() else shutil.which("sh") + if not shell: + pytest.skip("POSIX shell is unavailable") + hook = yaml.safe_load((ROOT / "deployers" / "azure.yaml").read_text(encoding="utf-8"))["hooks"]["postprovision"]["posix"]["run"] + mocks = ''' +az() { printf 'AZCALL=%s\\n' "$*"; return 0; } +bash() { return 0; } +python3() { + case "$*" in + *postconfig.py*) return "$TEST_POSTCONFIG_FAILURE" ;; + *) return 0 ;; + esac +} +''' + result = subprocess.run( + [shell], input=(mocks + hook).encode("utf-8"), capture_output=True, + env={**os.environ, "var_configureApplication": "true", "AZURE_SUBSCRIPTION_ID": "chosen-sub", + "var_webService": "chosen-web", "var_rgName": "chosen-group", + "TEST_POSTCONFIG_FAILURE": "1" if failure else "0"}, + ) + assert (result.returncode != 0) is failure, result.stderr + output = result.stdout.decode("utf-8") + assert ("AZCALL=webapp restart" in output) is not failure + assert "--ip-range-filter" not in output + if not failure: + assert "--subscription chosen-sub" in output \ No newline at end of file diff --git a/functional_tests/test_deployment_cosmos_access.py b/functional_tests/test_deployment_cosmos_access.py new file mode 100644 index 000000000..a64af9e34 --- /dev/null +++ b/functional_tests/test_deployment_cosmos_access.py @@ -0,0 +1,115 @@ +# test_deployment_cosmos_access.py +"""Cosmos deployment authentication regression tests. + +Version: 0.261.028 +Implemented in: 0.261.028 +Validate credential choice and that failed probes never mutate firewalls. +""" + +import importlib.util +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from azure.cosmos.exceptions import CosmosHttpResponseError + + +SOURCE = Path(__file__).resolve().parents[1] / "deployers" / "bicep" / "deployment_cosmos.py" +SPEC = importlib.util.spec_from_file_location("deployment_cosmos", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +@pytest.fixture +def inputs(): + return { + "AZURE_SUBSCRIPTION_ID": "subscription", + "AZURE_TENANT_ID": "tenant", + "var_rgName": "group", + "var_cosmosDb_accountName": "account", + "var_authenticationType": "managed_identity", + "var_cosmosDb_key": "stale-key-must-not-win", + } + + +def test_entra_works_with_local_auth_disabled(inputs): + account = {"documentEndpoint": "https://account.documents.azure.com", "disableLocalAuth": True} + client = Mock() + client.list_databases.return_value = iter([]) + with patch.object(MODULE, "run_cli", return_value=account) as cli, patch.object( + MODULE, "AzureCliCredential" + ) as credential, patch.object(MODULE.azure_cosmos, "CosmosClient", return_value=client) as factory: + result = MODULE.create_deployment_cosmos_client(inputs) + assert result is client + credential.assert_called_once_with(tenant_id="tenant") + assert factory.call_args.kwargs["credential"] is credential.return_value + assert cli.call_count == 1 + + +def test_key_mode_checks_local_auth_before_list_keys(inputs): + inputs["var_authenticationType"] = "key" + account = {"documentEndpoint": "https://account.documents.azure.com", "disableLocalAuth": True} + with patch.object(MODULE, "run_cli", return_value=account) as cli: + with pytest.raises(RuntimeError, match="key authentication is disabled"): + MODULE.create_deployment_cosmos_client(inputs) + assert cli.call_count == 1 + + +@pytest.mark.parametrize("message,expected", [ + ("Forbidden by firewall", "network access is blocked"), + ("Insufficient data-plane permissions", "data-plane RBAC failed"), +]) +def test_failed_probe_does_not_change_network(inputs, message, expected): + account = {"documentEndpoint": "https://account.documents.azure.com", "ipRules": []} + failure = CosmosHttpResponseError(status_code=403, message=message) + with patch.object(MODULE, "run_cli", return_value=account) as cli, patch.object( + MODULE, "AzureCliCredential" + ), patch.object(MODULE.azure_cosmos, "CosmosClient", side_effect=failure): + with pytest.raises(RuntimeError, match=expected): + MODULE.create_deployment_cosmos_client(inputs) + assert cli.call_count == 1 + assert cli.call_args.args[0][:2] == ["cosmosdb", "show"] + assert account["ipRules"] == [] + + +def test_private_endpoint_runner_can_succeed(inputs): + account = {"documentEndpoint": "https://account.documents.azure.com", "publicNetworkAccess": "Disabled"} + client = Mock() + client.list_databases.return_value = iter([]) + with patch.object(MODULE, "run_cli", return_value=account), patch.object( + MODULE, "AzureCliCredential" + ), patch.object(MODULE.azure_cosmos, "CosmosClient", return_value=client): + result = MODULE.create_deployment_cosmos_client(inputs) + assert result is client + + +def test_key_mode_retrieves_key_only_for_target_account(inputs): + inputs["var_authenticationType"] = "key" + account = {"documentEndpoint": "https://account.documents.azure.com", "disableLocalAuth": False} + client = Mock() + client.list_databases.return_value = iter([]) + with patch.object(MODULE, "run_cli", side_effect=[account, {"primaryMasterKey": "test-key"}]) as cli, patch.object( + MODULE.azure_cosmos, "CosmosClient", return_value=client + ) as factory, patch.object(MODULE, "AzureCliCredential") as entra: + result = MODULE.create_deployment_cosmos_client(inputs) + assert result is client + assert cli.call_count == 2 + assert cli.call_args.args[0] == [ + "cosmosdb", "keys", "list", "--subscription", "subscription", + "--resource-group", "group", "--name", "account", + ] + assert factory.call_args.kwargs["credential"] == "test-key" + entra.assert_not_called() + + +def test_failed_read_closes_client_and_redacts_error(inputs): + account = {"documentEndpoint": "https://account.documents.azure.com"} + client = Mock() + client.list_databases.side_effect = CosmosHttpResponseError(status_code=401, message="sensitive-provider-response") + with patch.object(MODULE, "run_cli", return_value=account), patch.object( + MODULE, "AzureCliCredential" + ), patch.object(MODULE.azure_cosmos, "CosmosClient", return_value=client): + with pytest.raises(RuntimeError) as error: + MODULE.create_deployment_cosmos_client(inputs) + assert "sensitive-provider-response" not in str(error.value) + client.close.assert_called_once() \ No newline at end of file diff --git a/functional_tests/test_log_credential_key_redaction.py b/functional_tests/test_log_credential_key_redaction.py index 6c46e183f..4ea1c49f1 100644 --- a/functional_tests/test_log_credential_key_redaction.py +++ b/functional_tests/test_log_credential_key_redaction.py @@ -2,7 +2,7 @@ # test_log_credential_key_redaction.py """ Functional test for credential key redaction in application logging. -Version: 0.250.218 +Version: 0.261.028 Implemented in: 0.250.218 This test ensures that credential-bearing property names reach the logging sinks @@ -283,7 +283,7 @@ def test_cosmos_client_imports_are_module_qualified(): root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) checked_files = ( os.path.join(root_dir, "scripts", "resolve_multiendpoint_gpt.py"), - os.path.join(root_dir, "deployers", "bicep", "postconfig.py"), + os.path.join(root_dir, "deployers", "bicep", "deployment_cosmos.py"), ) for file_path in checked_files: diff --git a/functional_tests/test_postconfig_azurecli_credential.py b/functional_tests/test_postconfig_azurecli_credential.py index 919b7a145..f4652bb23 100644 --- a/functional_tests/test_postconfig_azurecli_credential.py +++ b/functional_tests/test_postconfig_azurecli_credential.py @@ -2,12 +2,11 @@ # test_postconfig_azurecli_credential.py """ Functional test for postconfig deployment credential usage. -Version: 0.237.053 +Version: 0.261.028 Implemented in: 0.237.053 -This test ensures the AZD post-deployment configuration script supports a -deployment-time Cosmos DB key fallback while still using Azure CLI credentials -for other Azure resource access such as Key Vault. +This test ensures postconfig uses tenant-scoped Azure CLI credentials and the +shared Cosmos authentication helper rather than inheriting a stale Cosmos key. """ from pathlib import Path @@ -35,16 +34,14 @@ def test_postconfig_uses_repeatable_deployment_credentials() -> bool: content = POSTCONFIG.read_text(encoding="utf-8") require_contains(content, "from azure.identity import AzureCliCredential", "Azure CLI credential import") - require_contains(content, "credential = AzureCliCredential()", "Azure CLI credential initialization") - require_contains(content, "cosmosKey = os.getenv(\"var_cosmosDb_key\")", "deployment Cosmos key input") - require_contains(content, "if cosmosKey:", "Cosmos key fallback branch") - require_contains(content, "client = CosmosClient(cosmosEndpoint, cosmosKey)", "Cosmos key client initialization") - require_contains(content, "credential.get_token(\"https://cosmos.azure.com/.default\")", "Azure CLI token fallback") + require_contains(content, 'credential = AzureCliCredential(tenant_id=os.environ["AZURE_TENANT_ID"])', "tenant-scoped CLI identity") + require_contains(content, "client = create_deployment_cosmos_client()", "shared credential-aware Cosmos check") + require_not_contains(content, 'os.getenv("var_cosmosDb_key")', "stale key precedence") require_not_contains(content, "DefaultAzureCredential()", "DefaultAzureCredential initialization") print("✅ postconfig imports AzureCliCredential") - print("✅ postconfig accepts a deployment Cosmos DB key fallback") - print("✅ postconfig retains Azure CLI credential fallback for Cosmos and Key Vault") + print("✅ postconfig uses explicit deployment authentication selection") + print("✅ postconfig scopes the Azure CLI credential to the deployment tenant") print("✅ postconfig no longer initializes DefaultAzureCredential") return True diff --git a/functional_tests/test_postconfig_redis_cache_configuration.py b/functional_tests/test_postconfig_redis_cache_configuration.py index a1d21351c..1b6fb6fdf 100644 --- a/functional_tests/test_postconfig_redis_cache_configuration.py +++ b/functional_tests/test_postconfig_redis_cache_configuration.py @@ -2,7 +2,7 @@ # test_postconfig_redis_cache_configuration.py """ Functional test for post-deployment Redis cache configuration. -Version: 0.261.023 +Version: 0.261.028 Implemented in: 0.261.023 This test ensures the AZD post-deployment configuration script writes the Redis @@ -19,6 +19,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] POSTCONFIG = REPO_ROOT / "deployers" / "bicep" / "postconfig.py" REDIS_CLIENT = REPO_ROOT / "application" / "single_app" / "functions_redis_client.py" +CONFIGURATION = REPO_ROOT / "deployers" / "bicep" / "deployment_configuration.py" def require_contains(content: str, expected: str, description: str) -> None: @@ -35,7 +36,11 @@ def test_postconfig_writes_redis_cache_settings() -> bool: print("🧪 Testing postconfig Redis cache configuration") print("=" * 70) - content = POSTCONFIG.read_text(encoding="utf-8") + content = CONFIGURATION.read_text(encoding="utf-8") + entrypoint = POSTCONFIG.read_text(encoding="utf-8") + require_contains(entrypoint, "configure_redis(item,", "shared Redis configuration") + if entrypoint.count("configure_redis(item,") != 1: + raise AssertionError("Redis must be configured exactly once") require_not_contains(content, "todo support redis cache configuration", "unimplemented Redis configuration") @@ -47,9 +52,9 @@ def test_postconfig_writes_redis_cache_settings() -> bool: "redis_port", "redis_key", ): - require_contains(content, f'item["{key}"]', f"Redis setting assignment for {key}") + require_contains(content, f'"{key}":', f"Redis setting assignment for {key}") - require_contains(content, 'if redis_cache_host_name:', "guard so an operator-configured cache is preserved") + require_contains(content, 'if not host:\n return', "guard so an operator-configured cache is preserved") print("✅ postconfig writes every Redis setting the application reads") print("✅ postconfig only overwrites Redis settings when it provisioned a cache") @@ -66,7 +71,7 @@ def test_service_type_identifiers_match_application() -> bool: print("\n🧪 Testing Redis service type identifiers match the application") print("=" * 70) - postconfig = POSTCONFIG.read_text(encoding="utf-8") + postconfig = CONFIGURATION.read_text(encoding="utf-8") client = REDIS_CLIENT.read_text(encoding="utf-8") supported = dict( @@ -88,15 +93,7 @@ def test_service_type_identifiers_match_application() -> bool: require_contains(postconfig, f'"{classic}"', "classic Redis service type identifier") # The Bicep vocabulary must be translated rather than written through unchanged. - assigned = re.search( - r'item\["redis_service_type"\]\s*=\s*\((.*?)\)', - postconfig, - flags=re.DOTALL, - ) - if not assigned: - raise AssertionError("Could not locate the redis_service_type assignment") - if '"managed"' not in assigned.group(1): - raise AssertionError("redis_service_type assignment does not branch on the Bicep redisCacheKind value") + require_contains(postconfig, 'if kind == "managed" else', "Bicep kind translation") print(f"✅ deployer emits '{managed}' and '{classic}'") print("✅ deployer translates the Bicep redisCacheKind vocabulary") From 04507205dfba135e3bdea07f6d44ee12de2aef3c Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 17 Sep 2026 14:33:20 -0400 Subject: [PATCH 2/2] Document deployment reliability release and tracking issue Add v0.261.028 release notes and link the deployment fix documentation to issue #1489 and PR #1488. Refs #1489. --- .../AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md | 7 ++++- docs/explanation/release_notes.md | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md b/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md index bf29b1c8e..fa8c42ab7 100644 --- a/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md +++ b/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md @@ -67,4 +67,9 @@ Offline regression coverage lives in `test_deployment_cosmos_access.py` and `test_deployment_configuration.py`, including Windows and POSIX hook execution, explicit environment selection, key-disabled accounts, concurrency, cache publication failure, first-run settings creation, preserved external caches and index-creation races. -No tenant exemptions or production resource changes are required to run these tests. \ No newline at end of file +No tenant exemptions or production resource changes are required to run these tests. + +Tracked in [#1489](https://github.com/microsoft/simplechat/issues/1489), implemented by +[PR #1488](https://github.com/microsoft/simplechat/pull/1488). The new hook implementation +has offline regression coverage; Azure validation with an appropriately authorized +deployment runner remains a follow-up before calling the cloud workflow verified. \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 7444ffb52..5516c8d98 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,32 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.028)** + +Tracking: [#1489](https://github.com/microsoft/simplechat/issues/1489); implementation: [PR #1488](https://github.com/microsoft/simplechat/pull/1488). + +#### Bug Fixes + +* **Credential-Aware Cosmos Deployment Checks** + * Managed-identity deployments now use the deployment runner's tenant-scoped Entra credential instead of requiring Cosmos keys. Key-mode deployments report when local authentication is disabled. + * Authentication and network failures no longer trigger automatic firewall changes or misleading propagation waits. Windows environment lookups and POSIX role operations target the selected deployment explicitly. + * **Deployment requirement:** The runner must already have the required data-plane permissions and network access. Post-configuration does not open firewalls, enable key authentication, or create policy exemptions. + * (Ref: [PR #1488](https://github.com/microsoft/simplechat/pull/1488), `deployers/azure.yaml`, `deployment_cosmos.py`, `cosmosDb-postDeployPerms.sh`) + +* **Safe Post-Deployment Settings Publication** + * Post-configuration uses conflict-checked Cosmos writes and the application's shared Redis publication protocol, preserving concurrent unrelated settings changes and reporting publication failures instead of claiming success. + * Consolidated duplicate Redis configuration blocks and preserved external Redis settings when no cache is provisioned. Equivalent connection settings can be normalized; changing an active cache endpoint or authentication mode requires a planned migration. + * **Deployment requirement:** When Redis is enabled, the runner needs Redis data-plane access as well as network connectivity. The service restarts only after post-configuration succeeds. + * (Ref: [PR #1488](https://github.com/microsoft/simplechat/pull/1488), `postconfig.py`, `deployment_configuration.py`, `app_settings_store.py`) + +#### New Features + +* **Automatic Missing Search Index Creation** + * Post-configuration creates missing personal, group, and public Search indexes from the same JSON schemas used by the admin setup controls. + * Existing indexes and documents are left unchanged. Authorization and service failures stop initialization rather than being treated as missing indexes; concurrent creation is verified before continuing. + * Included in deployer version **1.0.31**. Existing-index schema upgrades remain administrator-managed operations. + * (Ref: [PR #1488](https://github.com/microsoft/simplechat/pull/1488), `deployment_configuration.py`, `application/single_app/static/json/ai_search-index-*.json`, [deployment prerequisites](../reference/deploy/azd-cli_deploy.md#post-provision-access)) + ### **(v0.261.027)** #### Bug Fixes