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..fa8c42ab7 100644 --- a/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md +++ b/docs/explanation/fixes/AZD_MANAGED_IDENTITY_PREFLIGHT_FIX.md @@ -34,4 +34,42 @@ 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. + +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 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")