diff --git a/application/single_app/config.py b/application/single_app/config.py index a032926d5..bb746dac7 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.263" +VERSION = "0.261.264" 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 5c6512b30..e1f47aedd 100644 --- a/deployers/azure.yaml +++ b/deployers/azure.yaml @@ -99,6 +99,7 @@ hooks: export var_redisCachePort=${var_redisCachePort} export var_videoIndexerArmApiVersion=${var_videoIndexerArmApiVersion} export var_videoIndexerEndpoint=${var_videoIndexerEndpoint} + export var_videoIndexerLocation=${var_videoIndexerLocation} export var_videoIndexerName=${var_videoIndexerName} export var_deploymentLocation=${var_deploymentLocation} export var_videoIndexerAccountId=${var_videoIndexerAccountId} @@ -232,6 +233,7 @@ hooks: 'var_videoIndexerAccountId', 'var_videoIndexerArmApiVersion', 'var_videoIndexerEndpoint', + 'var_videoIndexerLocation', 'var_videoIndexerName', 'var_webService' ) diff --git a/deployers/azurecli/deploy-simplechat.ps1 b/deployers/azurecli/deploy-simplechat.ps1 index 94cd81786..d3d439a89 100644 --- a/deployers/azurecli/deploy-simplechat.ps1 +++ b/deployers/azurecli/deploy-simplechat.ps1 @@ -203,6 +203,7 @@ $param_AzureOpenAiEmbeddingModelName = "" $param_AzureOpenAiEmbeddingModelVersion = "" # Leave blank to use the default for the selected cloud and region. $param_AzureOpenAiEmbeddingDeploymentCapacity = 80 $param_DeployVideoIndexerService = $false +$param_VideoIndexerLocation = "" # Optional Video Indexer region. Leave blank to use $paramLocation. Set it when $paramLocation does not offer Video Indexer (for example, northcentralus); a different region gets a dedicated Video Indexer storage account there. $param_CustomGraphUrl = "" $param_CustomIdentityUrl = "" $param_CustomResourceManagerUrl = "" @@ -1388,6 +1389,15 @@ $videoIndexerName = Get-ResourceName -ResourceTypeSuffix $paramVideoIndexerSuffi $storageAccountName = Get-GloballyUniqueResourceName -ResourceTypeSuffix $paramStorageAccountSuffix # Storage names are strict (lowercase, no hyphens, 3-24 chars) if ($storageAccountName.Length -gt 24) { $storageAccountName = $storageAccountName.Substring(0, 24) } if ($storageAccountName.Length -lt 3) { Write-Error "Generated storage account name '$storageAccountName' is too short. Adjust base name or suffix." ; exit 1 } +$videoIndexerLocation = if ([string]::IsNullOrWhiteSpace($param_VideoIndexerLocation)) { $paramLocation } else { $param_VideoIndexerLocation } +$videoIndexerLocation = ($videoIndexerLocation -replace '\s', '').ToLower() +# Video Indexer keeps its media in a storage account in its own region. +$videoIndexerUsesDedicatedStorage = $videoIndexerLocation -ne (($paramLocation -replace '\s', '').ToLower()) +$videoIndexerStorageAccountName = Get-GloballyUniqueResourceName -ResourceTypeSuffix "vi" +if ($videoIndexerStorageAccountName.Length -gt 24) { $videoIndexerStorageAccountName = $videoIndexerStorageAccountName.Substring(0, 24) } +# The app identity needs generateAccessToken. Video Indexer Account Contributor is the least-privilege +# built-in role; Contributor is used where that newer role has not been confirmed. +$videoIndexerAppRoleName = if ($globalWhichAzurePlatform -eq "AzureCloud") { "Video Indexer Account Contributor" } else { "Contributor" } #$containerRegistryName = Get-GloballyUniqueResourceName -ResourceTypeSuffix $paramContainerRegistrySuffix # ACR names are strict $entraGroupName_Admins = "$($paramBaseName)-$($paramEnvironment)-$($paramEntraGroupNameSuffix)-Admins" $entraGroupName_Users = "$($paramBaseName)-$($paramEnvironment)-$($paramEntraGroupNameSuffix)-Users" @@ -2006,16 +2016,37 @@ $searchServiceUrl = $param_AiSearch_Url -f $searchServiceName # --- Create Azure Video Indexer Service --- if ($param_DeployVideoIndexerService) { - Write-Host "`n=====> Creating Azure Video Indexer Service: $($videoIndexerName)..." + Write-Host "`n=====> Creating Azure Video Indexer Service: $($videoIndexerName) in $($videoIndexerLocation)..." $videoIndexerSubscriptionId = az account show --query id --output tsv $videoIndexerArmBaseUrl = $paramArmUrl.TrimEnd('/') $videoIndexerUri = "$videoIndexerArmBaseUrl/subscriptions/$videoIndexerSubscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.VideoIndexer/accounts/$videoIndexerName?api-version=$param_VideoIndexerArmApiVersion" $existingVideoIndexer = az rest --method get --uri $videoIndexerUri 2>$null | ConvertFrom-Json if (-not $existingVideoIndexer) { + if ($videoIndexerUsesDedicatedStorage) { + Write-Host "Video Indexer region '$videoIndexerLocation' differs from '$paramLocation'. Using storage account '$videoIndexerStorageAccountName' in the Video Indexer region." + $videoIndexerStorageResourceId = az storage account show --name $videoIndexerStorageAccountName --resource-group $resourceGroupName --query id --output tsv 2>$null + if (-not $videoIndexerStorageResourceId) { + # Only Video Indexer uses this account, and it connects as a trusted Azure service. A disabled + # public endpoint would also block that path, so private networking denies all other traffic instead. + $videoIndexerStorageDefaultAction = if ($param_EnablePrivateNetworking) { 'Deny' } else { 'Allow' } + $videoIndexerStorageTags = @($tags.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) + $videoIndexerStorageResourceId = az storage account create --name $videoIndexerStorageAccountName --resource-group $resourceGroupName ` + --location $videoIndexerLocation --sku $paramStorageSku --kind StorageV2 --access-tier Hot ` + --allow-blob-public-access false --min-tls-version TLS1_2 --public-network-access Enabled ` + --default-action $videoIndexerStorageDefaultAction --bypass AzureServices ` + --tags $videoIndexerStorageTags --query id --output tsv + if ($LASTEXITCODE -ne 0 -or -not $videoIndexerStorageResourceId) { + Write-Warning "Failed to create Video Indexer storage account '$($videoIndexerStorageAccountName)'." + } + } + } else { + $videoIndexerStorageResourceId = az storage account show --name $storageAccountName --resource-group $resourceGroupName --query id --output tsv + } + $videoIndexerProperties = @{ storageServices = @{ - resourceId = $(az storage account show --name $storageAccountName --resource-group $resourceGroupName --query id --output tsv) + resourceId = $videoIndexerStorageResourceId } } @@ -2035,7 +2066,7 @@ if ($param_DeployVideoIndexerService) { } $videoIndexerBody = @{ - location = $paramLocation + location = $videoIndexerLocation identity = @{ type = 'SystemAssigned' } @@ -2053,6 +2084,10 @@ if ($param_DeployVideoIndexerService) { } else { Write-Host "Azure Video Indexer Service '$videoIndexerName' already exists." } + + if ($existingVideoIndexer) { + Write-Host "Configure Admin Settings > AI Video Intelligence with Resource Group '$resourceGroupName', Account Name '$videoIndexerName', Account ID '$($existingVideoIndexer.properties.accountId)', and Location '$($existingVideoIndexer.location)'." + } } # --- Create App Service Settings --- @@ -2311,7 +2346,8 @@ if ($param_DeployVideoIndexerService) { $videoIndexerPrincipalId = az resource show --ids $videoIndexerResourceId --api-version $param_VideoIndexerArmApiVersion --query "identity.principalId" --output tsv 2>$null if (-not [string]::IsNullOrWhiteSpace($videoIndexerPrincipalId)) { - $storageResourceId = az storage account show --name $storageAccountName --resource-group $resourceGroupName --query "id" --output tsv + # Use the storage account the Video Indexer account is actually connected to. + $storageResourceId = az resource show --ids $videoIndexerResourceId --api-version $param_VideoIndexerArmApiVersion --query "properties.storageServices.resourceId" --output tsv $assignment = az role assignment list --assignee $videoIndexerPrincipalId --scope "$storageResourceId" --query "[?roleDefinitionName=='Storage Blob Data Contributor']" --output json | ConvertFrom-Json if (-not $assignment) { az role assignment create --assignee $videoIndexerPrincipalId --role "Storage Blob Data Contributor" --scope "$storageResourceId" | Out-Null @@ -2331,6 +2367,20 @@ if ($param_DeployVideoIndexerService) { } else { Write-Warning "Azure Video Indexer principal ID could not be resolved. Skipping Video Indexer RBAC assignments." } + + # SimpleChat calls Video Indexer generateAccessToken with the App Service system-assigned identity. + if (-not [string]::IsNullOrWhiteSpace($videoIndexerResourceId) -and -not [string]::IsNullOrWhiteSpace($appService_SystemManagedIdentity_ObjectId)) { + $assignment = az role assignment list --assignee $appService_SystemManagedIdentity_ObjectId --scope "$videoIndexerResourceId" --query "[?roleDefinitionName=='$videoIndexerAppRoleName']" --output json | ConvertFrom-Json + if (-not $assignment) { + az role assignment create --assignee-object-id $appService_SystemManagedIdentity_ObjectId --assignee-principal-type ServicePrincipal ` + --role "$videoIndexerAppRoleName" --scope "$videoIndexerResourceId" | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to grant '$videoIndexerAppRoleName' on Video Indexer to the App Service managed identity." + } + } + } else { + Write-Warning "Azure Video Indexer or App Service identity could not be resolved. Grant the App Service managed identity '$videoIndexerAppRoleName' on the Video Indexer account manually." + } } diff --git a/deployers/bicep/README.md b/deployers/bicep/README.md index 5da1bef6d..808143c36 100644 --- a/deployers/bicep/README.md +++ b/deployers/bicep/README.md @@ -13,6 +13,7 @@ - [Deployment Prompts](#Deployment-Prompts) - [Post Deployment Tasks](#Post-Deployment-Tasks) - [Key Vault secret permissions](#key-vault-secret-permissions) +- [Video Indexer region and permissions](#video-indexer-region-and-permissions) - [Cleanup / Deprovision](#Cleanup-/-Deprovisioning) - [Helpful Info](#Helpful-Info) - [Private Networking](#Private-Networking) @@ -69,6 +70,7 @@ Ensure the following resource providers are registered in your subscription: - `Microsoft.ContainerRegistry` - `Microsoft.Insights` - `Microsoft.OperationalInsights` +- `Microsoft.VideoIndexer` (only when `deployVideoIndexerService` is `true`) ## Deployment Process @@ -364,6 +366,46 @@ If your platform team owns IAM instead, `configureApplicationPermissions=false` Offline contract coverage is in [test_deployer_key_vault_secret_permissions.py](../../functional_tests/test_deployer_key_vault_secret_permissions.py); it checks both Bicep modules, generated ARM, permission-disable conditions, and runtime principals without deploying resources. +## Video Indexer region and permissions + +Implemented in application version **0.261.264** (`application/single_app/config.py`) and deployer version **1.0.33** (`deployers/version.txt`). + +### Choosing a Video Indexer region + +Azure Video Indexer is not offered in every region. North Central US, for example, has no Video Indexer, so `deployVideoIndexerService=true` in that region used to fail Azure Resource Manager validation before anything was deployed. Set `VIDEO_INDEXER_LOCATION` to a region that offers Video Indexer and keep the rest of SimpleChat in `AZURE_LOCATION`: + +```powershell +azd env set DEPLOY_VIDEO_INDEXER_SERVICE true +azd env set VIDEO_INDEXER_LOCATION centralus +azd provision --preview +``` + +- Leave `VIDEO_INDEXER_LOCATION` unset to deploy Video Indexer in `AZURE_LOCATION`, as earlier versions did. +- When the two regions differ, the deployment also creates `vi`, a Standard general-purpose v2 storage account in the Video Indexer region, because Video Indexer keeps its media in a storage account in its own region. Only Video Indexer uses this account; SimpleChat uploads video to the Video Indexer API and never reads it. With `enablePrivateNetworking=true`, its firewall denies public traffic and admits trusted Azure services, which is how Video Indexer reaches firewalled storage. It needs no private endpoint. +- Choose the region before the first deployment that enables Video Indexer. An existing account cannot move regions; Azure rejects a later deployment that asks for the same account name in another region. +- The preprovision hook compares the selected region with the regions the `Microsoft.VideoIndexer` provider reports. If Video Indexer is unavailable there, the hook lists the supported regions and stops before provisioning. If the list cannot be read, it warns and continues, because Azure Resource Manager still validates the region. + +### Application access + +SimpleChat calls the Video Indexer `generateAccessToken` Azure Resource Manager API with the App Service **system-assigned** managed identity, whatever `authenticationType` is. Whenever `configureApplicationPermissions=true`, the container app and the optional native Python app each receive a role on the Video Indexer account: + +| Cloud | Role | Role definition ID | +|---|---|---| +| Azure Commercial | Video Indexer Account Contributor | `3f99eaab-6f59-4877-adf5-1cacd22e20b0` | +| Azure Government and custom clouds | Contributor | `b24988ac-6180-42a0-ab88-20f7382dd24c` | + +Video Indexer Account Contributor is limited to Video Indexer accounts and their access tokens. Contributor is used where that newer built-in role has not been confirmed. The assignment name is `guid(videoIndexerService.id, webApp.id, 'video-indexer-app-access', )`, and these grants live in `modules/setVideoIndexerPermissions.bicep`. + +Earlier deployer versions created the account without this grant, so video processing failed authorization until an administrator added **Contributor** by hand. In Azure Commercial, that manual grant does not conflict with the new assignment; it can stay, or you can remove it afterward to keep only the narrower role. In Azure Government and custom clouds, an identical manual Contributor assignment on the account makes the next deployment return `RoleAssignmentExists`. Keep the manual grant and adopt its GUID in an environment-specific copy of `modules/setVideoIndexerPermissions.bicep`, as described for Key Vault in [Upgrading and adopting existing ARM assignments](#upgrading-and-adopting-existing-arm-assignments). + +Video Indexer's own identity keeps **Storage Blob Data Contributor** on the storage account that holds its media. That assignment moved into the same module with an unchanged name, so upgrades do not recreate it. + +### Application settings + +Post-provision configuration writes the Video Indexer settings only when the deployment created the account, and records the account's own region rather than the deployment region. A provisioning run with `deployVideoIndexerService=false` leaves an existing Video Indexer configuration in **Admin Settings > AI Video Intelligence** untouched; earlier versions blanked the account name and ID and reset the location on every such run. + +Offline coverage is in [test_video_indexer_deployment_region_permissions.py](../../functional_tests/test_video_indexer_deployment_region_permissions.py) and [test_deployment_configuration.py](../../functional_tests/test_deployment_configuration.py). + --- ### Post Deployment Tasks: @@ -616,6 +658,10 @@ If you do not want this deployment to create new Azure OpenAI model deployments, - **Cause:** Video Indexer API capabilities differ by cloud and region - **Solution:** Use the cloud-appropriate API profile. Azure Government deployments use the `2024-01-01` profile; commercial deployments use `2025-04-01`; custom clouds can override the API version and endpoint. +**Error: "The provided location '\' is not available for resource type 'Microsoft.VideoIndexer/accounts'"** +- **Cause:** Azure Video Indexer is not offered in the region Video Indexer is being deployed to. +- **Solution:** Run `azd env set VIDEO_INDEXER_LOCATION ` and rerun `azd up`. The rest of SimpleChat stays in `AZURE_LOCATION`. See [Video Indexer region and permissions](#video-indexer-region-and-permissions). + ### Post-Deployment Issues **Issue: Cannot access the web application** diff --git a/deployers/bicep/main.bicep b/deployers/bicep/main.bicep index 806bf05ad..f9e551767 100644 --- a/deployers/bicep/main.bicep +++ b/deployers/bicep/main.bicep @@ -329,6 +329,14 @@ param deploySpeechService bool - Default is false''') param deployVideoIndexerService bool +@minLength(1) +@description('''Optional Azure region override for Azure Video Indexer. +- Defaults to the deployment location. +- Use when the primary app region does not offer Video Indexer, for example northcentralus. +- A region that differs from the deployment location gets a dedicated Video Indexer storage account in that region. +- Set this before the first deployment that enables Video Indexer; an existing account cannot move regions.''') +param videoIndexerLocation string = location + //========================================================= // variable declarations for the main deployment //========================================================= @@ -378,6 +386,14 @@ var resolvedVideoIndexerEndpoint = scCloudEnvironment == 'usgovernment' : (scCloudEnvironment == 'custom' && !empty(customVideoIndexerEndpoint) ? customVideoIndexerEndpoint : 'https://api.videoindexer.ai') var videoIndexerSupportsOpenAiIntegration = resolvedVideoIndexerArmApiVersion == '2025-04-01' var videoIndexerSupportsPrivateEndpoints = resolvedVideoIndexerArmApiVersion == '2025-04-01' +var normalizedVideoIndexerLocation = toLower(replace(videoIndexerLocation, ' ', '')) +// Video Indexer keeps its media in a storage account in its own region. +var videoIndexerUsesDedicatedStorage = normalizedVideoIndexerLocation != normalizedLocation +// The app identity needs generateAccessToken. Video Indexer Account Contributor is the least-privilege +// built-in role; Contributor is used where that newer role has not been confirmed. +var videoIndexerAppRoleDefinitionId = scCloudEnvironment == 'public' + ? '3f99eaab-6f59-4877-adf5-1cacd22e20b0' + : 'b24988ac-6180-42a0-ab88-20f7382dd24c' var hasExistingAppServiceSubnetId = !empty(existingAppServiceSubnetId) var hasExistingPrivateEndpointSubnetId = !empty(existingPrivateEndpointSubnetId) var inferredVirtualNetworkId = hasExistingAppServiceSubnetId ? split(existingAppServiceSubnetId, '/subnets/')[0] : (hasExistingPrivateEndpointSubnetId ? split(existingPrivateEndpointSubnetId, '/subnets/')[0] : '') @@ -819,7 +835,7 @@ module videoIndexerService 'modules/videoIndexer.bicep' = if (deployVideoIndexer name: 'videoIndexerService' scope: rg params: { - location: location + location: videoIndexerLocation appName: appName environment: environment tags: tags @@ -827,6 +843,7 @@ module videoIndexerService 'modules/videoIndexer.bicep' = if (deployVideoIndexer logAnalyticsId: logAnalytics.outputs.logAnalyticsId storageAccount: storageAccount.outputs.name + useDedicatedStorageAccount: videoIndexerUsesDedicatedStorage openAiServiceName: openAI.outputs.openAIName videoIndexerArmApiVersion: resolvedVideoIndexerArmApiVersion @@ -865,6 +882,9 @@ module setPermissions 'modules/setPermissions.bicep' = if (configureApplicationP contentSafetyName: deployContentSafety ? contentSafety.outputs.contentSafetyName : '' #disable-next-line BCP318 // expect one value to be null videoIndexerName: deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerServiceName : '' + #disable-next-line BCP318 // expect one value to be null + videoIndexerStorageAccountName: deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerStorageAccountName : '' + videoIndexerAppRoleDefinitionId: videoIndexerAppRoleDefinitionId videoIndexerSupportsOpenAiIntegration: videoIndexerSupportsOpenAiIntegration } } @@ -892,6 +912,9 @@ module setNativeWebAppPermissions 'modules/setNativeWebAppPermissions.bicep' = i redisCacheKind: redisCacheKind #disable-next-line BCP318 // expect one value to be null contentSafetyName: deployContentSafety ? contentSafety.outputs.contentSafetyName : '' + #disable-next-line BCP318 // expect one value to be null + videoIndexerName: deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerServiceName : '' + videoIndexerAppRoleDefinitionId: videoIndexerAppRoleDefinitionId } dependsOn: [ setPermissions @@ -976,6 +999,8 @@ output var_videoIndexerArmApiVersion string = resolvedVideoIndexerArmApiVersion output var_videoIndexerAccountId string = deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerAccountId : '' output var_videoIndexerEndpoint string = resolvedVideoIndexerEndpoint #disable-next-line BCP318 // expect one value to be null +output var_videoIndexerLocation string = deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerLocation : '' +#disable-next-line BCP318 // expect one value to be null output var_videoIndexerName string = deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerServiceName : '' // output values required for predeploy script in azure.yaml diff --git a/deployers/bicep/main.json b/deployers/bicep/main.json index 72923be7f..a825cda9d 100644 --- a/deployers/bicep/main.json +++ b/deployers/bicep/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "6424966352943684170" + "templateHash": "3025497095812405668" } }, "parameters": { @@ -448,6 +448,14 @@ "metadata": { "description": "Enable deployment of Azure Video Indexer service and related resources.\n- Default is false" } + }, + "videoIndexerLocation": { + "type": "string", + "defaultValue": "[parameters('location')]", + "minLength": 1, + "metadata": { + "description": "Optional Azure region override for Azure Video Indexer.\n- Defaults to the deployment location.\n- Use when the primary app region does not offer Video Indexer, for example northcentralus.\n- A region that differs from the deployment location gets a dedicated Video Indexer storage account in that region.\n- Set this before the first deployment that enables Video Indexer; an existing account cannot move regions." + } } }, "variables": { @@ -505,6 +513,9 @@ "resolvedVideoIndexerEndpoint": "[if(equals(variables('scCloudEnvironment'), 'usgovernment'), 'https://api.videoindexer.ai.azure.us', if(and(equals(variables('scCloudEnvironment'), 'custom'), not(empty(parameters('customVideoIndexerEndpoint')))), parameters('customVideoIndexerEndpoint'), 'https://api.videoindexer.ai'))]", "videoIndexerSupportsOpenAiIntegration": "[equals(variables('resolvedVideoIndexerArmApiVersion'), '2025-04-01')]", "videoIndexerSupportsPrivateEndpoints": "[equals(variables('resolvedVideoIndexerArmApiVersion'), '2025-04-01')]", + "normalizedVideoIndexerLocation": "[toLower(replace(parameters('videoIndexerLocation'), ' ', ''))]", + "videoIndexerUsesDedicatedStorage": "[not(equals(variables('normalizedVideoIndexerLocation'), variables('normalizedLocation')))]", + "videoIndexerAppRoleDefinitionId": "[if(equals(variables('scCloudEnvironment'), 'public'), '3f99eaab-6f59-4877-adf5-1cacd22e20b0', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", "hasExistingAppServiceSubnetId": "[not(empty(parameters('existingAppServiceSubnetId')))]", "hasExistingPrivateEndpointSubnetId": "[not(empty(parameters('existingPrivateEndpointSubnetId')))]", "inferredVirtualNetworkId": "[if(variables('hasExistingAppServiceSubnetId'), split(parameters('existingAppServiceSubnetId'), '/subnets/')[0], if(variables('hasExistingPrivateEndpointSubnetId'), split(parameters('existingPrivateEndpointSubnetId'), '/subnets/')[0], ''))]", @@ -5909,7 +5920,7 @@ "mode": "Incremental", "parameters": { "location": { - "value": "[parameters('location')]" + "value": "[parameters('videoIndexerLocation')]" }, "appName": { "value": "[parameters('appName')]" @@ -5929,6 +5940,9 @@ "storageAccount": { "value": "[reference('storageAccount').outputs.name.value]" }, + "useDedicatedStorageAccount": { + "value": "[variables('videoIndexerUsesDedicatedStorage')]" + }, "openAiServiceName": { "value": "[reference('openAI').outputs.openAIName.value]" }, @@ -5946,7 +5960,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "16333309402800302725" + "templateHash": "6695969075326641477" } }, "parameters": { @@ -5977,14 +5991,86 @@ "videoIndexerArmApiVersion": { "type": "string" }, + "useDedicatedStorageAccount": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Create a storage account in the Video Indexer region instead of using the application storage account.\n- Set when Video Indexer is deployed to a different region than the application." + } + }, "enablePrivateNetworking": { "type": "bool" } }, "variables": { + "dedicatedStorageAccountName": "[toLower(format('{0}{1}vi', parameters('appName'), parameters('environment')))]", + "videoIndexerStorageAccountId": "[if(parameters('useDedicatedStorageAccount'), resourceId('Microsoft.Storage/storageAccounts', variables('dedicatedStorageAccountName')), resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccount')))]", "useLegacyVideoIndexerApi": "[equals(parameters('videoIndexerArmApiVersion'), '2024-01-01')]" }, "resources": [ + { + "condition": "[parameters('useDedicatedStorageAccount')]", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2022-09-01", + "name": "[variables('dedicatedStorageAccountName')]", + "location": "[parameters('location')]", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "accessTier": "Hot", + "allowBlobPublicAccess": false, + "minimumTlsVersion": "TLS1_2", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "bypass": "AzureServices", + "defaultAction": "[if(parameters('enablePrivateNetworking'), 'Deny', 'Allow')]" + } + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('useDedicatedStorageAccount')]", + "type": "Microsoft.Storage/storageAccounts/blobServices", + "apiVersion": "2023-01-01", + "name": "[format('{0}/{1}', variables('dedicatedStorageAccountName'), 'default')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', variables('dedicatedStorageAccountName'))]" + ] + }, + { + "condition": "[and(parameters('enableDiagLogging'), parameters('useDedicatedStorageAccount'))]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', variables('dedicatedStorageAccountName'))]", + "name": "[format('{0}-diagnostics', variables('dedicatedStorageAccountName'))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": [], + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.transactionMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.Storage/storageAccounts', variables('dedicatedStorageAccountName'))]" + ] + }, + { + "condition": "[and(parameters('enableDiagLogging'), parameters('useDedicatedStorageAccount'))]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[resourceId('Microsoft.Storage/storageAccounts/blobServices', variables('dedicatedStorageAccountName'), 'default')]", + "name": "[format('{0}-blob-diagnostics', variables('dedicatedStorageAccountName'))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.transactionMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.Storage/storageAccounts/blobServices', variables('dedicatedStorageAccountName'), 'default')]" + ] + }, { "condition": "[not(variables('useLegacyVideoIndexerApi'))]", "type": "Microsoft.VideoIndexer/accounts", @@ -5997,13 +6083,16 @@ "properties": { "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", "storageServices": { - "resourceId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccount'))]" + "resourceId": "[variables('videoIndexerStorageAccountId')]" }, "openAiServices": { "resourceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAiServiceName'))]" } }, - "tags": "[parameters('tags')]" + "tags": "[parameters('tags')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', variables('dedicatedStorageAccountName'))]" + ] }, { "condition": "[variables('useLegacyVideoIndexerApi')]", @@ -6016,10 +6105,13 @@ }, "properties": { "storageServices": { - "resourceId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccount'))]" + "resourceId": "[variables('videoIndexerStorageAccountId')]" } }, - "tags": "[parameters('tags')]" + "tags": "[parameters('tags')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', variables('dedicatedStorageAccountName'))]" + ] }, { "condition": "[and(parameters('enableDiagLogging'), not(variables('useLegacyVideoIndexerApi')))]", @@ -6186,6 +6278,14 @@ "videoIndexerAccountId": { "type": "string", "value": "[if(variables('useLegacyVideoIndexerApi'), reference(resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))), '2024-01-01').accountId, reference(resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))), '2025-04-01').accountId)]" + }, + "videoIndexerLocation": { + "type": "string", + "value": "[toLower(replace(parameters('location'), ' ', ''))]" + }, + "videoIndexerStorageAccountName": { + "type": "string", + "value": "[if(parameters('useDedicatedStorageAccount'), variables('dedicatedStorageAccountName'), parameters('storageAccount'))]" } } } @@ -6255,6 +6355,10 @@ }, "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference('contentSafety').outputs.contentSafetyName.value), createObject('value', ''))]", "videoIndexerName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference('videoIndexerService').outputs.videoIndexerServiceName.value), createObject('value', ''))]", + "videoIndexerStorageAccountName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference('videoIndexerService').outputs.videoIndexerStorageAccountName.value), createObject('value', ''))]", + "videoIndexerAppRoleDefinitionId": { + "value": "[variables('videoIndexerAppRoleDefinitionId')]" + }, "videoIndexerSupportsOpenAiIntegration": { "value": "[variables('videoIndexerSupportsOpenAiIntegration')]" } @@ -6266,7 +6370,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "7465815465274830093" + "templateHash": "12563751307025747079" } }, "parameters": { @@ -6337,6 +6441,20 @@ "videoIndexerName": { "type": "string" }, + "videoIndexerStorageAccountName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Storage account that holds the Video Indexer media.\n- Defaults to the application storage account." + } + }, + "videoIndexerAppRoleDefinitionId": { + "type": "string", + "defaultValue": "b24988ac-6180-42a0-ab88-20f7382dd24c", + "metadata": { + "description": "Role definition ID granted to the web app identity on the Video Indexer account." + } + }, "videoIndexerSupportsOpenAiIntegration": { "type": "bool", "defaultValue": true @@ -6551,18 +6669,6 @@ "principalType": "ServicePrincipal" } }, - { - "condition": "[not(equals(parameters('videoIndexerName'), ''))]", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", - "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-storage-blob-data-contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", - "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2024-01-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - } - }, { "condition": "[and(and(not(equals(parameters('videoIndexerName'), '')), not(variables('useExternalOpenAIResource'))), parameters('videoIndexerSupportsOpenAiIntegration'))]", "type": "Microsoft.Authorization/roleAssignments", @@ -6755,6 +6861,91 @@ ] } } + }, + { + "condition": "[not(equals(parameters('videoIndexerName'), ''))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "setVideoIndexerPermissions", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "videoIndexerName": { + "value": "[parameters('videoIndexerName')]" + }, + "videoIndexerStorageAccountName": "[if(empty(parameters('videoIndexerStorageAccountName')), createObject('value', parameters('storageAccountName')), createObject('value', parameters('videoIndexerStorageAccountName')))]", + "webAppName": { + "value": "[parameters('webAppName')]" + }, + "webAppRoleDefinitionId": { + "value": "[parameters('videoIndexerAppRoleDefinitionId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "17195091449059813838" + } + }, + "parameters": { + "videoIndexerName": { + "type": "string", + "metadata": { + "description": "Name of the Video Indexer account." + } + }, + "videoIndexerStorageAccountName": { + "type": "string", + "metadata": { + "description": "Name of the storage account that holds the Video Indexer media." + } + }, + "webAppName": { + "type": "string", + "metadata": { + "description": "Name of the web app whose system-assigned identity calls the Video Indexer generateAccessToken API." + } + }, + "webAppRoleDefinitionId": { + "type": "string", + "metadata": { + "description": "Role definition ID granted to the web app identity on the Video Indexer account.\n- Video Indexer Account Contributor (3f99eaab-6f59-4877-adf5-1cacd22e20b0) in Azure Commercial\n- Contributor (b24988ac-6180-42a0-ab88-20f7382dd24c) where that role has not been confirmed" + } + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName'))]", + "name": "[guid(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'video-indexer-app-access', parameters('webAppRoleDefinitionId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', parameters('webAppRoleDefinitionId'))]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('videoIndexerStorageAccountName'))]", + "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('videoIndexerStorageAccountName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-storage-blob-data-contributor')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", + "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2024-01-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + } + ] + } + } } ] } @@ -6825,7 +7016,11 @@ "redisCacheKind": { "value": "[parameters('redisCacheKind')]" }, - "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference('contentSafety').outputs.contentSafetyName.value), createObject('value', ''))]" + "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference('contentSafety').outputs.contentSafetyName.value), createObject('value', ''))]", + "videoIndexerName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference('videoIndexerService').outputs.videoIndexerServiceName.value), createObject('value', ''))]", + "videoIndexerAppRoleDefinitionId": { + "value": "[variables('videoIndexerAppRoleDefinitionId')]" + } }, "template": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", @@ -6834,7 +7029,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "6086980005977117487" + "templateHash": "13981557879151195852" } }, "parameters": { @@ -6895,6 +7090,17 @@ }, "contentSafetyName": { "type": "string" + }, + "videoIndexerName": { + "type": "string", + "defaultValue": "" + }, + "videoIndexerAppRoleDefinitionId": { + "type": "string", + "defaultValue": "b24988ac-6180-42a0-ab88-20f7382dd24c", + "metadata": { + "description": "Role definition ID granted to the native web app identity on the Video Indexer account." + } } }, "variables": { @@ -7032,6 +7238,18 @@ "principalType": "ServicePrincipal" } }, + { + "condition": "[not(equals(parameters('videoIndexerName'), ''))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName'))]", + "name": "[guid(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), resourceId('Microsoft.Web/sites', parameters('nativeWebAppName')), 'video-indexer-app-access', parameters('videoIndexerAppRoleDefinitionId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', parameters('videoIndexerAppRoleDefinitionId'))]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('nativeWebAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, { "condition": "[variables('deployClassicRedisAccess')]", "type": "Microsoft.Authorization/roleAssignments", @@ -7218,7 +7436,8 @@ "searchService", "setPermissions", "speechService", - "storageAccount" + "storageAccount", + "videoIndexerService" ] }, "privateNetworking": { @@ -10469,6 +10688,10 @@ "type": "string", "value": "[variables('resolvedVideoIndexerEndpoint')]" }, + "var_videoIndexerLocation": { + "type": "string", + "value": "[if(parameters('deployVideoIndexerService'), reference('videoIndexerService').outputs.videoIndexerLocation.value, '')]" + }, "var_videoIndexerName": { "type": "string", "value": "[if(parameters('deployVideoIndexerService'), reference('videoIndexerService').outputs.videoIndexerServiceName.value, '')]" diff --git a/deployers/bicep/main.parameters.json b/deployers/bicep/main.parameters.json index a9e3cf837..e43157627 100644 --- a/deployers/bicep/main.parameters.json +++ b/deployers/bicep/main.parameters.json @@ -130,6 +130,9 @@ }, "deployVideoIndexerService": { "value": "${DEPLOY_VIDEO_INDEXER_SERVICE}" + }, + "videoIndexerLocation": { + "value": "${VIDEO_INDEXER_LOCATION}" } } } \ No newline at end of file diff --git a/deployers/bicep/modules/setNativeWebAppPermissions.bicep b/deployers/bicep/modules/setNativeWebAppPermissions.bicep index 4e75482e7..d22a109de 100644 --- a/deployers/bicep/modules/setNativeWebAppPermissions.bicep +++ b/deployers/bicep/modules/setNativeWebAppPermissions.bicep @@ -27,6 +27,9 @@ managed grants data access through an Azure Managed Redis access policy assignme ]) param redisCacheKind string = 'managed' param contentSafetyName string +param videoIndexerName string = '' +@description('Role definition ID granted to the native web app identity on the Video Indexer account.') +param videoIndexerAppRoleDefinitionId string = 'b24988ac-6180-42a0-ab88-20f7382dd24c' var useExternalOpenAIResource = openAIName != '' && !empty(openAIResourceGroupName) && !empty(openAISubscriptionId) @@ -77,6 +80,10 @@ resource contentSafety 'Microsoft.CognitiveServices/accounts@2025-06-01' existin name: contentSafetyName } +resource videoIndexerService 'Microsoft.VideoIndexer/accounts@2024-01-01' existing = if (videoIndexerName != '') { + name: videoIndexerName +} + // Use a new assignment name rather than changing the immutable Secrets User assignment. resource kvSecretsOfficerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(kv.id, webApp.id, 'kv-secrets-officer') @@ -233,6 +240,20 @@ resource contentSafetyUserRole 'Microsoft.Authorization/roleAssignments@2022-04- } } +// The native app calls Video Indexer generateAccessToken with its own managed identity, whatever authenticationType is. +resource videoIndexerAppAccessRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (videoIndexerName != '') { + name: guid(videoIndexerService.id, webApp.id, 'video-indexer-app-access', videoIndexerAppRoleDefinitionId) + scope: videoIndexerService + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + videoIndexerAppRoleDefinitionId + ) + principalId: webApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + // Azure Managed Redis does not need this control-plane role: data access comes solely from // the access policy assignment below. resource redisCacheContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployClassicRedisAccess) { diff --git a/deployers/bicep/modules/setPermissions.bicep b/deployers/bicep/modules/setPermissions.bicep index 7ec59e2f4..95268bd41 100644 --- a/deployers/bicep/modules/setPermissions.bicep +++ b/deployers/bicep/modules/setPermissions.bicep @@ -30,6 +30,11 @@ managed grants data access through an Azure Managed Redis access policy assignme param redisCacheKind string = 'managed' param contentSafetyName string param videoIndexerName string +@description('''Storage account that holds the Video Indexer media. +- Defaults to the application storage account.''') +param videoIndexerStorageAccountName string = '' +@description('Role definition ID granted to the web app identity on the Video Indexer account.') +param videoIndexerAppRoleDefinitionId string = 'b24988ac-6180-42a0-ab88-20f7382dd24c' param videoIndexerSupportsOpenAiIntegration bool = true var useExternalOpenAIResource = openAIName != '' && !empty(openAIResourceGroupName) && !empty(openAISubscriptionId) @@ -330,18 +335,14 @@ resource contentSafetyUserRole 'Microsoft.Authorization/roleAssignments@2022-04- } } -// grant the video indexer service access to storage account as a Storage Blob Data Contributor -resource videoIndexerStorageBlobDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (videoIndexerName != '') { - name: guid(storageAccount.id, videoIndexerService.id, 'video-indexer-storage-blob-data-contributor') - scope: storageAccount - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' - ) - #disable-next-line BCP318 // may be null if video indexer not deployed - principalId: videoIndexerService.identity.principalId - principalType: 'ServicePrincipal' +// grant the web app access to Video Indexer and the Video Indexer service access to its storage account +module videoIndexerPermissions 'setVideoIndexerPermissions.bicep' = if (videoIndexerName != '') { + name: 'setVideoIndexerPermissions' + params: { + videoIndexerName: videoIndexerName + videoIndexerStorageAccountName: empty(videoIndexerStorageAccountName) ? storageAccountName : videoIndexerStorageAccountName + webAppName: webAppName + webAppRoleDefinitionId: videoIndexerAppRoleDefinitionId } } diff --git a/deployers/bicep/modules/setVideoIndexerPermissions.bicep b/deployers/bicep/modules/setVideoIndexerPermissions.bicep new file mode 100644 index 000000000..8edeb5b13 --- /dev/null +++ b/deployers/bicep/modules/setVideoIndexerPermissions.bicep @@ -0,0 +1,60 @@ +targetScope = 'resourceGroup' + +// Grants that let SimpleChat use a Video Indexer account and let that account read its media storage. +// Kept separate so an existing environment can add Video Indexer without redeploying every application grant. + +@description('Name of the Video Indexer account.') +param videoIndexerName string + +@description('Name of the storage account that holds the Video Indexer media.') +param videoIndexerStorageAccountName string + +@description('Name of the web app whose system-assigned identity calls the Video Indexer generateAccessToken API.') +param webAppName string + +@description('''Role definition ID granted to the web app identity on the Video Indexer account. +- Video Indexer Account Contributor (3f99eaab-6f59-4877-adf5-1cacd22e20b0) in Azure Commercial +- Contributor (b24988ac-6180-42a0-ab88-20f7382dd24c) where that role has not been confirmed''') +param webAppRoleDefinitionId string + +resource webApp 'Microsoft.Web/sites@2022-03-01' existing = { + name: webAppName +} + +resource videoIndexerService 'Microsoft.VideoIndexer/accounts@2024-01-01' existing = { + name: videoIndexerName +} + +resource videoIndexerStorageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { + name: videoIndexerStorageAccountName +} + +// SimpleChat always calls generateAccessToken with its managed identity, whatever authenticationType is. +// The role ID is part of the name so a later role change creates a new assignment instead of failing. +resource webAppVideoIndexerAccessRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(videoIndexerService.id, webApp.id, 'video-indexer-app-access', webAppRoleDefinitionId) + scope: videoIndexerService + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + webAppRoleDefinitionId + ) + principalId: webApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// grant the video indexer service access to its storage account as a Storage Blob Data Contributor. +// The name inputs match the assignment earlier deployer versions created on the application storage account. +resource videoIndexerStorageBlobDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(videoIndexerStorageAccount.id, videoIndexerService.id, 'video-indexer-storage-blob-data-contributor') + scope: videoIndexerStorageAccount + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + ) + principalId: videoIndexerService.identity.principalId + principalType: 'ServicePrincipal' + } +} diff --git a/deployers/bicep/modules/videoIndexer.bicep b/deployers/bicep/modules/videoIndexer.bicep index d91572e4c..5167453ba 100644 --- a/deployers/bicep/modules/videoIndexer.bicep +++ b/deployers/bicep/modules/videoIndexer.bicep @@ -12,6 +12,10 @@ param storageAccount string param openAiServiceName string param videoIndexerArmApiVersion string +@description('''Create a storage account in the Video Indexer region instead of using the application storage account. +- Set when Video Indexer is deployed to a different region than the application.''') +param useDedicatedStorageAccount bool = false + param enablePrivateNetworking bool // Import diagnostic settings configurations @@ -27,6 +31,64 @@ resource openAiService 'Microsoft.CognitiveServices/accounts@2024-10-01' existin name: openAiServiceName } +var dedicatedStorageAccountName = toLower('${appName}${environment}vi') + +// Video Indexer keeps its media in a storage account in its own region, so a Video Indexer +// region that differs from the application region gets its own Standard GPv2 account. +resource videoIndexerStorage 'Microsoft.Storage/storageAccounts@2022-09-01' = if (useDedicatedStorageAccount) { + #disable-next-line BCP334 //Name length managed by Bicep parameters. + name: dedicatedStorageAccountName + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + // Only Video Indexer uses this account, and it connects as a trusted Azure service. A disabled + // public endpoint would also block that path, so private networking denies all other traffic instead. + publicNetworkAccess: 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' + } + } + tags: tags +} + +resource videoIndexerStorageBlobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = if (useDedicatedStorageAccount) { + name: 'default' + parent: videoIndexerStorage +} + +resource videoIndexerStorageDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging && useDedicatedStorageAccount) { + name: '${dedicatedStorageAccountName}-diagnostics' + scope: videoIndexerStorage + properties: { + workspaceId: logAnalyticsId + logs: [] // Storage account main resource doesn't have logs + #disable-next-line BCP318 // expect one value to be null + metrics: diagnosticConfigs.outputs.transactionMetricsCategories + } +} + +resource videoIndexerStorageBlobDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging && useDedicatedStorageAccount) { + name: '${dedicatedStorageAccountName}-blob-diagnostics' + scope: videoIndexerStorageBlobService + properties: { + workspaceId: logAnalyticsId + #disable-next-line BCP318 // expect one value to be null + logs: diagnosticConfigs.outputs.standardLogCategories + #disable-next-line BCP318 // expect one value to be null + metrics: diagnosticConfigs.outputs.transactionMetricsCategories + } +} + +#disable-next-line BCP318 // the dedicated account exists whenever it is selected +var videoIndexerStorageAccountId = useDedicatedStorageAccount ? videoIndexerStorage.id : storage.id + var useLegacyVideoIndexerApi = videoIndexerArmApiVersion == '2024-01-01' resource videoIndexerServiceCurrent 'Microsoft.VideoIndexer/accounts@2025-04-01' = if (!useLegacyVideoIndexerApi) { @@ -39,7 +101,7 @@ resource videoIndexerServiceCurrent 'Microsoft.VideoIndexer/accounts@2025-04-01' properties: { publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' storageServices: { - resourceId: storage.id + resourceId: videoIndexerStorageAccountId } openAiServices: { resourceId: openAiService.id @@ -61,7 +123,7 @@ resource videoIndexerServiceLegacy 'Microsoft.VideoIndexer/accounts@2024-01-01' } properties: { storageServices: { - resourceId: storage.id + resourceId: videoIndexerStorageAccountId } } tags: tags @@ -95,3 +157,6 @@ resource videoIndexerServiceLegacyDiagnostics 'Microsoft.Insights/diagnosticSett output videoIndexerServiceName string = useLegacyVideoIndexerApi ? videoIndexerServiceLegacy.name : videoIndexerServiceCurrent.name #disable-next-line BCP318 // exactly one conditional resource exists for the selected API version output videoIndexerAccountId string = useLegacyVideoIndexerApi ? videoIndexerServiceLegacy.properties.accountId : videoIndexerServiceCurrent.properties.accountId +// SimpleChat builds Video Indexer API URLs from this region name, so return it in ARM's normalized form. +output videoIndexerLocation string = toLower(replace(location, ' ', '')) +output videoIndexerStorageAccountName string = useDedicatedStorageAccount ? dedicatedStorageAccountName : storage.name diff --git a/deployers/bicep/postconfig.py b/deployers/bicep/postconfig.py index f4b1bd8c6..433834ae9 100644 --- a/deployers/bicep/postconfig.py +++ b/deployers/bicep/postconfig.py @@ -412,7 +412,8 @@ def get_core_service_keys( else "classic" ) var_videoIndexerName = os.getenv("var_videoIndexerName") -var_videoIndexerLocation = os.getenv("var_deploymentLocation") +# Older deployments did not report a separate Video Indexer region; it matched the deployment region. +var_videoIndexerLocation = os.getenv("var_videoIndexerLocation") or os.getenv("var_deploymentLocation") var_videoIndexerAccountId = os.getenv("var_videoIndexerAccountId") var_speechServiceEndpoint = os.getenv("var_speechServiceEndpoint") var_speechServiceLocation = os.getenv("var_deploymentLocation") @@ -544,16 +545,17 @@ def get_core_service_keys( item["azure_document_intelligence_key"] = core_service_keys["azure_document_intelligence_key"] # Search and Extract > Multimedia Support -# Video Indexer Configuration +# Video Indexer Configuration. Write these only when this deployment created the account, so a +# manually configured Video Indexer account survives later provisioning runs. if var_videoIndexerName and var_videoIndexerName.strip(): item["enable_video_file_support"] = True -item["video_indexer_resource_group"] = var_rgName -item["video_indexer_subscription_id"] = var_subscriptionId -item["video_indexer_account_name"] = var_videoIndexerName -item["video_indexer_endpoint"] = os.getenv("var_videoIndexerEndpoint", item.get("video_indexer_endpoint", "")) -item["video_indexer_location"] = var_videoIndexerLocation -item["video_indexer_account_id"] = var_videoIndexerAccountId -item["video_indexer_arm_api_version"] = os.getenv("var_videoIndexerArmApiVersion", item.get("video_indexer_arm_api_version", "2024-01-01")) + item["video_indexer_resource_group"] = var_rgName + item["video_indexer_subscription_id"] = var_subscriptionId + item["video_indexer_account_name"] = var_videoIndexerName + item["video_indexer_endpoint"] = os.getenv("var_videoIndexerEndpoint", item.get("video_indexer_endpoint", "")) + item["video_indexer_location"] = var_videoIndexerLocation + item["video_indexer_account_id"] = var_videoIndexerAccountId + item["video_indexer_arm_api_version"] = os.getenv("var_videoIndexerArmApiVersion", item.get("video_indexer_arm_api_version", "2024-01-01")) # Speech Service Configuration if var_speechServiceEndpoint and var_speechServiceEndpoint.strip(): diff --git a/deployers/bicep/validate_azd_prerequisites.py b/deployers/bicep/validate_azd_prerequisites.py index df4fffe22..29a1a3640 100644 --- a/deployers/bicep/validate_azd_prerequisites.py +++ b/deployers/bicep/validate_azd_prerequisites.py @@ -3,14 +3,15 @@ """ Validate and explain deployment prerequisites before `azd provision` or `azd up` continues. -Version: 0.242.057 +Version: 0.261.264 Implemented in: 0.237.018 -Enhanced in: 0.242.057 +Enhanced in: 0.242.057, 0.261.264 This script ensures users understand the prerequisites for reusing an existing VNet and for configuring private DNS zones when private networking is enabled. It also fails fast when managed identity authentication is selected but the deployment -identity cannot create the required RBAC assignments. +identity cannot create the required RBAC assignments, and when Azure Video Indexer +is requested in a region that does not offer it. """ from __future__ import annotations @@ -29,6 +30,8 @@ 'Microsoft.Authorization/roleAssignments/write', 'Microsoft.Authorization/roleDefinitions/write', ) +VIDEO_INDEXER_PROVIDER_NAMESPACE = 'Microsoft.VideoIndexer' +VIDEO_INDEXER_ACCOUNT_RESOURCE_TYPE = 'accounts' def _to_bool(value: str | None) -> bool: @@ -309,6 +312,73 @@ def _validate_managed_identity_preflight() -> bool: return True +def _normalize_location(value: str | None) -> str: + return ''.join(str(value or '').split()).lower() + + +def _get_video_indexer_locations() -> tuple[list[str] | None, str | None]: + exit_code, stdout, stderr = _run_command([ + 'az', + 'provider', + 'show', + '--namespace', + VIDEO_INDEXER_PROVIDER_NAMESPACE, + '--query', + f"resourceTypes[?resourceType=='{VIDEO_INDEXER_ACCOUNT_RESOURCE_TYPE}'].locations | [0]", + '-o', + 'json', + ]) + if exit_code != 0 or not stdout: + return None, stderr or stdout or 'Unable to list Azure Video Indexer regions with Azure CLI.' + + try: + locations = json.loads(stdout) + except json.JSONDecodeError as exc: + return None, f'Azure CLI returned unreadable Azure Video Indexer regions: {exc}' + + if not isinstance(locations, list) or not locations: + return None, 'Azure CLI did not return any Azure Video Indexer regions.' + + return [_normalize_location(location) for location in locations if location], None + + +def _validate_video_indexer_region() -> bool: + if not _to_bool(_get_env('AZURE_ENV_DEPLOY_VIDEO_INDEXER_SERVICE', 'DEPLOY_VIDEO_INDEXER_SERVICE')): + return True + + override_location = _get_env('AZURE_ENV_VIDEO_INDEXER_LOCATION', 'VIDEO_INDEXER_LOCATION').strip() + target_location = _normalize_location(override_location or _get_env('AZURE_ENV_AZURE_LOCATION', 'AZURE_LOCATION')) + if not target_location: + return True + + _print_header('VIDEO INDEXER REGION PREFLIGHT') + supported_locations, lookup_error = _get_video_indexer_locations() + if lookup_error or supported_locations is None: + # Azure Resource Manager still rejects an unsupported region, so a failed lookup must not block deployment. + print('WARNING: Could not confirm that Azure Video Indexer is available in the selected region.') + print(f' Details: {lookup_error}') + print(' Azure Resource Manager still validates the region during provisioning.') + return True + + if target_location in supported_locations: + print(f'Azure Video Indexer is available in {target_location}.') + return True + + setting_name = 'VIDEO_INDEXER_LOCATION' if override_location else 'AZURE_LOCATION' + print('', file=sys.stderr) + print(f'ERROR: Azure Video Indexer is not available in {target_location} ({setting_name}).', file=sys.stderr) + print('', file=sys.stderr) + print('Deploy Video Indexer to a supported region while the rest of SimpleChat stays where it is:', file=sys.stderr) + print(' azd env set VIDEO_INDEXER_LOCATION ', file=sys.stderr) + print('Then rerun azd provision or azd up.', file=sys.stderr) + print('', file=sys.stderr) + print(f'Supported regions: {", ".join(sorted(supported_locations))}', file=sys.stderr) + print('', file=sys.stderr) + print('To deploy without Video Indexer instead, run:', file=sys.stderr) + print(' azd env set DEPLOY_VIDEO_INDEXER_SERVICE false', file=sys.stderr) + return False + + def _parse_private_dns(raw_value: str | None) -> tuple[dict, str | None]: if not raw_value or not raw_value.strip(): return {}, None @@ -406,6 +476,9 @@ def main() -> int: if not _validate_managed_identity_preflight(): return 1 + if not _validate_video_indexer_region(): + return 1 + if not enable_private_networking: return 0 diff --git a/deployers/terraform/ReadMe.md b/deployers/terraform/ReadMe.md index ff304fc68..33cc2fb3c 100644 --- a/deployers/terraform/ReadMe.md +++ b/deployers/terraform/ReadMe.md @@ -121,6 +121,25 @@ After a reviewed deployment and RBAC propagation, test the chosen identity under Offline coverage in [test_deployer_key_vault_secret_permissions.py](../../functional_tests/test_deployer_key_vault_secret_permissions.py) checks both runtime assignments, vault scopes, and the non-destructive legacy state migration. +## Video Indexer region and permissions + +Implemented in application version **0.261.264** (`application/single_app/config.py`) and deployer version **1.0.33** (`deployers/version.txt`). + +Azure Video Indexer is not offered in every region. When `param_location` does not offer it, set `param_video_indexer_location` to a region that does; leave it blank to keep Video Indexer in `param_location`: + +```hcl +param_deploy_video_indexer_service = true +param_video_indexer_location = "centralus" +``` + +A Video Indexer region that differs from `param_location` also creates `azurerm_storage_account.video_indexer_sa`, a Standard general-purpose v2 account named `vi` in that region, because Video Indexer keeps its media in a storage account in its own region. Only Video Indexer uses it. With `param_enable_private_networking = true`, its firewall denies public traffic and admits trusted Azure services, which is how Video Indexer reaches firewalled storage. Choose the region before Video Indexer is first deployed; an existing account cannot move regions. + +SimpleChat calls the Video Indexer `generateAccessToken` API with the App Service **system-assigned** identity. `azurerm_role_assignment.app_service_smi_video_indexer_access` grants that identity **Video Indexer Account Contributor** for `AzureCloud` and **Contributor** for `AzureUSGovernment`, where the newer role has not been confirmed. If you already added the same role by hand at the Video Indexer account scope, import that assignment into this address before applying, as described for Key Vault above, rather than deleting it. + +The Video Indexer identity is read from `azapi_resource.video_indexer[0].output` directly. azapi 2.x, which `main.tf` requires, returns `output` as an object rather than a JSON string, so it is no longer passed through `jsondecode()`. + +Terraform does not write application settings. After `terraform apply`, enter the `video_indexer_account_name`, `video_indexer_account_id`, and `video_indexer_location` outputs, with the resource group and subscription, under **Admin Settings > AI Video Intelligence**. + ## Deploy initial container Terraform does not build the container image itself. It expects `image_name` to point to an image tag that already exists in your Azure Container Registry. diff --git a/deployers/terraform/main.tf b/deployers/terraform/main.tf index 9228ec6c5..7b7ea94b3 100644 --- a/deployers/terraform/main.tf +++ b/deployers/terraform/main.tf @@ -114,6 +114,12 @@ variable "param_deploy_video_indexer_service" { default = false } +variable "param_video_indexer_location" { + description = "Optional Azure region for Azure Video Indexer. Leave blank to use param_location. Set it when param_location does not offer Video Indexer; a different region gets a dedicated Video Indexer storage account there. Set it before Video Indexer is first deployed because an existing account cannot move regions." + type = string + default = "" +} + variable "param_cosmos_capacity_mode" { description = "Cosmos DB capacity mode. Defaults to provisioned throughput; use serverless only for short-lived MVP/evaluation environments." type = string @@ -313,6 +319,8 @@ locals { storage_account_base = "${var.param_base_name}${var.param_environment}sa" storage_account_name = substr(replace(local.storage_account_base, "/[^a-z0-9]/", ""), 0, 24) + video_indexer_storage_account_name = substr(replace(lower("${var.param_base_name}${var.param_environment}vi"), "/[^a-z0-9]/", ""), 0, 24) + acr_base_url = local.is_usgovernment_cloud ? "${var.acr_name}.azurecr.us" : "${var.acr_name}.azurecr.io" param_registry_server = local.is_usgovernment_cloud ? "https://${var.acr_name}.azurecr.us" : "https://${var.acr_name}.azurecr.io" @@ -325,13 +333,20 @@ locals { video_indexer_endpoint = local.is_usgovernment_cloud ? "https://api.videoindexer.ai.azure.us" : (local.is_custom_cloud && var.param_custom_video_indexer_endpoint != "" ? var.param_custom_video_indexer_endpoint : "https://api.videoindexer.ai") video_indexer_supports_openai = local.video_indexer_arm_api_version == "2025-04-01" video_indexer_supports_private_endpoints = local.video_indexer_arm_api_version == "2025-04-01" + video_indexer_location = lower(replace(var.param_video_indexer_location != "" ? var.param_video_indexer_location : var.param_location, " ", "")) + # Video Indexer keeps its media in a storage account in its own region. + video_indexer_uses_dedicated_storage = var.param_deploy_video_indexer_service && local.video_indexer_location != lower(replace(var.param_location, " ", "")) + video_indexer_storage_account_id = local.video_indexer_uses_dedicated_storage ? azurerm_storage_account.video_indexer_sa[0].id : azurerm_storage_account.sa.id + # The app identity needs generateAccessToken. Video Indexer Account Contributor is the least-privilege + # built-in role; Contributor is used where that newer role has not been confirmed. + video_indexer_app_role_name = local.is_usgovernment_cloud || local.is_custom_cloud ? "Contributor" : "Video Indexer Account Contributor" video_indexer_body_json = local.video_indexer_supports_openai ? jsonencode({ identity = { type = "SystemAssigned" } properties = { storageServices = { - resourceId = azurerm_storage_account.sa.id + resourceId = local.video_indexer_storage_account_id } openAiServices = { resourceId = local.openai_resource_id @@ -345,7 +360,7 @@ locals { } properties = { storageServices = { - resourceId = azurerm_storage_account.sa.id + resourceId = local.video_indexer_storage_account_id } } tags = local.common_tags @@ -562,6 +577,30 @@ resource "azurerm_storage_account" "sa" { tags = local.common_tags } +# Video Indexer keeps its media in a storage account in its own region, so a Video Indexer +# region that differs from param_location gets its own Standard GPv2 account. +resource "azurerm_storage_account" "video_indexer_sa" { + count = local.video_indexer_uses_dedicated_storage ? 1 : 0 + name = local.video_indexer_storage_account_name + resource_group_name = azurerm_resource_group.rg.name + location = local.video_indexer_location + account_tier = "Standard" + account_replication_type = "LRS" + account_kind = "StorageV2" + access_tier = "Hot" + allow_nested_items_to_be_public = false + min_tls_version = "TLS1_2" + shared_access_key_enabled = false + # Only Video Indexer uses this account, and it connects as a trusted Azure service. A disabled + # public endpoint would also block that path, so private networking denies all other traffic instead. + public_network_access_enabled = true + network_rules { + default_action = var.param_enable_private_networking ? "Deny" : "Allow" + bypass = ["AzureServices"] + } + tags = local.common_tags +} + # --- User-Assigned Managed Identity --- resource "azurerm_user_assigned_identity" "id" { name = local.managed_identity_name @@ -1030,7 +1069,7 @@ resource "azapi_resource" "video_indexer" { type = "Microsoft.VideoIndexer/accounts@${local.video_indexer_arm_api_version}" name = local.video_indexer_name parent_id = azurerm_resource_group.rg.id - location = azurerm_resource_group.rg.location + location = local.video_indexer_location schema_validation_enabled = false response_export_values = ["identity.principalId", "properties.accountId"] @@ -1158,25 +1197,34 @@ resource "azurerm_role_assignment" "app_service_smi_storage_contributor" { principal_id = azurerm_linux_web_app.app.identity[0].principal_id } +# azapi 2.x exposes output as an object, so read the exported identity directly. resource "azurerm_role_assignment" "video_indexer_storage_contributor" { count = var.param_deploy_video_indexer_service ? 1 : 0 - scope = azurerm_storage_account.sa.id + scope = local.video_indexer_storage_account_id role_definition_name = "Storage Blob Data Contributor" - principal_id = jsondecode(azapi_resource.video_indexer[0].output).identity.principalId + principal_id = azapi_resource.video_indexer[0].output.identity.principalId } resource "azurerm_role_assignment" "video_indexer_openai_contributor" { count = var.param_deploy_video_indexer_service && local.video_indexer_supports_openai && local.openai_resource_id != "" ? 1 : 0 scope = local.openai_resource_id role_definition_name = "Cognitive Services Contributor" - principal_id = jsondecode(azapi_resource.video_indexer[0].output).identity.principalId + principal_id = azapi_resource.video_indexer[0].output.identity.principalId } resource "azurerm_role_assignment" "video_indexer_openai_user" { count = var.param_deploy_video_indexer_service && local.video_indexer_supports_openai && local.openai_resource_id != "" ? 1 : 0 scope = local.openai_resource_id role_definition_name = "Cognitive Services User" - principal_id = jsondecode(azapi_resource.video_indexer[0].output).identity.principalId + principal_id = azapi_resource.video_indexer[0].output.identity.principalId +} + +# SimpleChat calls Video Indexer generateAccessToken with the App Service system-assigned identity. +resource "azurerm_role_assignment" "app_service_smi_video_indexer_access" { + count = var.param_deploy_video_indexer_service ? 1 : 0 + scope = azapi_resource.video_indexer[0].id + role_definition_name = local.video_indexer_app_role_name + principal_id = azurerm_linux_web_app.app.identity[0].principal_id } # Storage Blob Data Contributor on Storage Account @@ -1211,4 +1259,20 @@ output "web_app_url" { output "resource_group_name" { description = "Name of the created Resource Group." value = azurerm_resource_group.rg.name +} + +# Terraform does not write application settings; enter these in Admin Settings > AI Video Intelligence. +output "video_indexer_account_name" { + description = "Video Indexer account name for the application settings." + value = var.param_deploy_video_indexer_service ? azapi_resource.video_indexer[0].name : null +} + +output "video_indexer_account_id" { + description = "Video Indexer account ID for the application settings." + value = var.param_deploy_video_indexer_service ? azapi_resource.video_indexer[0].output.properties.accountId : null +} + +output "video_indexer_location" { + description = "Video Indexer region for the application settings." + value = var.param_deploy_video_indexer_service ? local.video_indexer_location : null } \ No newline at end of file diff --git a/deployers/version.txt b/deployers/version.txt index 15245f3a2..e8eb7f11f 100644 --- a/deployers/version.txt +++ b/deployers/version.txt @@ -1 +1 @@ -1.0.32 +1.0.33 diff --git a/docs/admin/knowledge.md b/docs/admin/knowledge.md index 134ab9632..c8f66a6de 100644 --- a/docs/admin/knowledge.md +++ b/docs/admin/knowledge.md @@ -321,7 +321,10 @@ The endpoint comes first because the account details are read against it: use `https://api.videoindexer.ai` for Azure Public and `https://api.videoindexer.ai.azure.us` for Azure Government, and another value only for a non-standard deployment. The account id, name, location, resource group and subscription follow, and the indexing timeout bounds how -long one file may take. +long one file may take. All five account fields are required: the name, resource group and +subscription identify the account when SimpleChat requests an access token, and the location +and account id address its API. Video Indexer is not offered in every region, so the account +can sit in a different region from SimpleChat; enter the account's own region as the location. ### AI Voice Conversations {#ai-voice-chat-section} diff --git a/docs/deploy/manual/provision-azure-resources.md b/docs/deploy/manual/provision-azure-resources.md index 762571cb8..480825fa2 100644 --- a/docs/deploy/manual/provision-azure-resources.md +++ b/docs/deploy/manual/provision-azure-resources.md @@ -180,11 +180,13 @@ Deploy the necessary Azure services. For a quick estimate of monthly costs based * If using the Video Extraction feature, create an **Azure Video Indexer** resource in the Azure Portal. + * Video Indexer is not offered in every region. Create it in a region that offers it, which can differ from your App Service region, and connect it to a Standard general-purpose v2 storage account in that same region. + * You'll need to associate it with an Azure Media Services account (can be created during VI setup) and a Storage Account (used for temporary processing, can be new or existing). * **Enable System-assigned Managed Identity** on your App Service if not already enabled (Identity > System assigned > Status: On). - * **Grant the App Service's Managed Identity the `Contributor` role** on the Video Indexer resource: + * **Grant the App Service's Managed Identity the `Contributor` role** on the Video Indexer resource. In Azure Commercial, the narrower `Video Indexer Account Contributor` role is also sufficient: - Navigate to your Video Indexer resource > Access control (IAM) @@ -194,7 +196,7 @@ Deploy the necessary Azure services. For a quick estimate of monthly costs based - Select your App Service's managed identity - * Note the **Account ID**, **Account Name**, **Resource Group**, **Subscription ID**, and **Location** (e.g., eastus). These will be configured in Admin Settings. + * Note the **Account ID**, **Account Name**, **Resource Group**, **Subscription ID**, and **Location** (the Video Indexer account's own region, e.g., centralus). These will be configured in Admin Settings. * See [Azure Video Indexer documentation](https://learn.microsoft.com/azure/azure-video-indexer/connect-to-azure) for detailed setup instructions. diff --git a/docs/explanation/fixes/VIDEO_INDEXER_DEPLOYMENT_REGION_AND_PERMISSIONS_FIX.md b/docs/explanation/fixes/VIDEO_INDEXER_DEPLOYMENT_REGION_AND_PERMISSIONS_FIX.md new file mode 100644 index 000000000..3c3d39d0f --- /dev/null +++ b/docs/explanation/fixes/VIDEO_INDEXER_DEPLOYMENT_REGION_AND_PERMISSIONS_FIX.md @@ -0,0 +1,79 @@ +# Video Indexer Deployment Region and Permissions Fix (v0.261.264) + +## Overview + +The deployers can now place Azure Video Indexer in a supported region other than the application region, and they grant the App Service identity the role SimpleChat needs to request Video Indexer access tokens. The azd preprovision hook now stops before provisioning when Video Indexer is unavailable in the chosen region. Post-provision configuration no longer erases a manually configured Video Indexer account. + +Fixed in version: **0.261.264** (deployer version **1.0.33**) + +Related issue: [#1666](https://github.com/microsoft/simplechat/issues/1666) + +Dependencies: Azure Developer CLI hooks, Bicep, Terraform with azapi 2.x, the Azure CLI deployer, and the Azure Video Indexer ARM API (`2025-04-01` in Azure Commercial, `2024-01-01` in Azure Government). + +The application version was updated in `application/single_app/config.py` from `0.261.263` to `0.261.264`, and the deployer version in `deployers/version.txt` from `1.0.32` to `1.0.33`. + +## Issue Description + +In an environment deployed to North Central US, **Admin Settings > AI Video Intelligence** showed *Needs configuration*. The subscription, resource group, and location were filled in, but the account name and account ID were blank, and no Video Indexer account existed. The first deployment ran with `deployVideoIndexerService=false`. A later attempt with it set to `true` failed Azure Resource Manager validation: Video Indexer is not offered in North Central US, and the deployer had no way to place it elsewhere. + +## Root Cause + +Four gaps combined: + +1. Video Indexer always used the deployment `location`. There was no override and no preflight check, so an unsupported region failed the entire deployment. +2. `postconfig.py` gated only `enable_video_file_support` on a deployed account. It always wrote the resource group, subscription, and deployment location. It also wrote the account name and ID from deployment outputs, which are empty when Video Indexer is not deployed. Every provision without Video Indexer therefore blanked a manual configuration and reset its location, leaving the half-filled state described above. +3. No deployer granted the App Service identity a role on the Video Indexer account. SimpleChat calls the ARM `generateAccessToken` API with that identity in every authentication mode, so a deployer-created account rejected video processing until an administrator added Contributor by hand. +4. Terraform passed `azapi_resource.video_indexer[0].output` through `jsondecode()`. azapi 2.x, which `main.tf` requires, returns `output` as an object rather than a JSON string. + +## Technical Details + +### Files Modified + +- `deployers/bicep/main.bicep`, `deployers/bicep/main.json` (regenerated), and `deployers/bicep/main.parameters.json` +- `deployers/bicep/modules/videoIndexer.bicep` +- `deployers/bicep/modules/setVideoIndexerPermissions.bicep` (new) +- `deployers/bicep/modules/setPermissions.bicep` and `deployers/bicep/modules/setNativeWebAppPermissions.bicep` +- `deployers/bicep/postconfig.py` and `deployers/bicep/validate_azd_prerequisites.py` +- `deployers/azure.yaml` +- `deployers/terraform/main.tf` +- `deployers/azurecli/deploy-simplechat.ps1` +- `deployers/version.txt` and `application/single_app/config.py` +- `deployers/bicep/README.md`, `deployers/terraform/ReadMe.md`, `docs/deploy/manual/provision-azure-resources.md`, and `docs/admin/knowledge.md` +- `docs/explanation/release_notes.md` +- `functional_tests/test_video_indexer_deployment_region_permissions.py` (new) and `functional_tests/test_deployment_configuration.py` + +### Code Changes + +- **Region selection.** Each deployer accepts a Video Indexer region that defaults to the deployment region: `videoIndexerLocation` in Bicep (azd `VIDEO_INDEXER_LOCATION`), `param_video_indexer_location` in Terraform, and `$param_VideoIndexerLocation` in the Azure CLI deployer. When that region differs from the deployment region, the deployer also creates `vi`, a Standard general-purpose v2 storage account in the Video Indexer region. Under private networking, its firewall admits trusted Azure services and denies other traffic. Bicep reports the account's normalized region as `var_videoIndexerLocation`. +- **Preflight.** `validate_azd_prerequisites.py` compares the target region with the regions returned by `az provider show --namespace Microsoft.VideoIndexer`. If Video Indexer is unavailable there, it prints the supported regions and the `azd env set VIDEO_INDEXER_LOCATION` command, then fails. If the region list cannot be read, it only warns, because Azure Resource Manager still validates the region. +- **Application access.** The App Service system-assigned identity receives **Video Indexer Account Contributor** in Azure Commercial and **Contributor** in Azure Government and custom clouds. The grant does not depend on `authenticationType`. In Bicep, `setVideoIndexerPermissions.bicep` holds this grant and the Video Indexer storage grant, which keeps its earlier assignment name. The optional native Python app receives the same role. Terraform and the Azure CLI deployer add the same grant; the Azure CLI deployer checks for an existing assignment first. +- **Settings.** `postconfig.py` writes every Video Indexer setting only when the deployment created the account, and records the account's own region. +- **Terraform.** The Video Indexer identity is read from `azapi_resource.video_indexer[0].output` directly. New outputs report the account name, account ID, and region for Admin Settings. + +## Testing And Validation + +- `python functional_tests/test_video_indexer_deployment_region_permissions.py` passed 10 of 10 tests. They cover the Bicep source and generated ARM, Terraform, the Azure CLI deployer, preflight behavior for unsupported, overridden, disabled, and failed-lookup cases, and the version. +- `python -m pytest functional_tests/test_video_indexer_deployment_region_permissions.py functional_tests/test_deployment_configuration.py` passed 43 tests. Against the previous `postconfig.py`, the two new postconfig cases fail: a manual resource group is overwritten, and the deployment region is written instead of the account region. +- Twelve related deployer and Video Indexer test files report the same 11 failures before and after this change. All of them are pre-existing exact-version checks or checks unrelated to Video Indexer deployment. +- `az bicep build` with Bicep 0.44.1, the version that generated `main.json`, reported no warnings. `terraform fmt -check` passed, and `terraform validate` passed with Terraform 1.12.2 and the required providers. The Azure CLI deployer parses without errors. +- The fixed modules were applied to the affected North Central US environment. `what-if` showed only new resources. The deployment created a Video Indexer account in Central US with its own storage account, plus the application, storage, and Azure OpenAI role assignments. ARM `generateAccessToken` and a Video Indexer API call that used the new account ID and `centralus` both succeeded. + +## Impact Analysis + +### Before + +- Enabling Video Indexer in a region without it failed the whole deployment, and administrators had no way to place Video Indexer elsewhere. +- A deployer-created Video Indexer account could not process video until an administrator granted Contributor manually. +- Every provision without Video Indexer erased the Video Indexer account name and ID and reset the location. + +### After + +- Video Indexer can run in any region that offers it while the rest of SimpleChat stays in its region, and azd explains unsupported regions before provisioning. +- Deployer-created accounts work without manual role assignments, using the least-privilege role in Azure Commercial. +- Manually configured Video Indexer settings survive later provisioning runs. + +## Known Limitations + +- An existing Video Indexer account cannot move regions. Set the region before the first deployment that enables Video Indexer. +- In Azure Government and custom clouds, a manual Contributor assignment at the account scope duplicates the new assignment and returns `RoleAssignmentExists`. Adopt that assignment as the deployer READMEs describe. +- Speech settings in `postconfig.py` are still written on every run, even when Speech is not deployed. This change does not alter that behavior. diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md index 4d021f0ba..a6078d612 100644 --- a/docs/explanation/fixes/index.md +++ b/docs/explanation/fixes/index.md @@ -6,6 +6,7 @@ order: 120 category: Version History --- +- [Video Indexer Deployment Region and Permissions Fix](VIDEO_INDEXER_DEPLOYMENT_REGION_AND_PERMISSIONS_FIX.md) - [Search Result Cache Admin Setting Fix](SEARCH_RESULT_CACHE_ADMIN_SETTING_FIX.md) - [Deleted Messages Shown as Masked Fix](SOFT_DELETED_MESSAGES_SHOWN_AS_MASKED_FIX.md) - [Shared Conversation Diagram Editing Fix](SHARED_CONVERSATION_DIAGRAM_EDITING_FIX.md) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 0bb0dde14..97353d4be 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,17 @@ 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.264)** + +#### Bug Fixes + +* **Video Indexer Deploys Outside the App Region and Works Without Manual Role Grants** + * Azure Video Indexer is not offered in every region. The azd, Terraform, and Azure CLI deployers now accept a separate Video Indexer region (`VIDEO_INDEXER_LOCATION`, `param_video_indexer_location`, `$param_VideoIndexerLocation`) and pair it with a storage account in that region. Previously, enabling Video Indexer in a region such as North Central US failed the whole deployment. The azd preprovision hook now stops before provisioning and lists the supported regions. + * Deployer-created Video Indexer accounts now grant the App Service identity **Video Indexer Account Contributor** (**Contributor** in Azure Government and custom clouds), so video processing works without adding a role by hand. + * Post-provision configuration writes the Video Indexer settings only when the deployment created the account, and records the account's own region. Previously, any provision without Video Indexer blanked the account name and ID and reset the location, which left **AI Video Intelligence** showing *Needs configuration*. + * Terraform now reads the Video Indexer identity from the azapi 2.x `output` object directly instead of passing it through `jsondecode()`. + * (Ref: #1666, `videoIndexer.bicep`, `setVideoIndexerPermissions.bicep`, `postconfig.py`, `validate_azd_prerequisites.py`, `main.tf`, `deploy-simplechat.ps1`, deployer version 1.0.33, [Video Indexer Deployment Region and Permissions Fix](fixes/VIDEO_INDEXER_DEPLOYMENT_REGION_AND_PERMISSIONS_FIX.md)) + ### **(v0.261.263)** #### User Interface Enhancements diff --git a/functional_tests/test_deployment_configuration.py b/functional_tests/test_deployment_configuration.py index 639a86e50..bd8c4bd68 100644 --- a/functional_tests/test_deployment_configuration.py +++ b/functional_tests/test_deployment_configuration.py @@ -1,8 +1,9 @@ # test_deployment_configuration.py """Postconfig environment, cache publication and create-only Search regressions. -Version: 0.261.028 +Version: 0.261.264 Implemented in: 0.261.028 +Video Indexer settings preservation and region coverage added in: 0.261.264 Uses the real settings store with fake services; never contacts Azure. """ @@ -327,6 +328,80 @@ def test_real_postconfig_entrypoint_writes_once_and_initializes_search(monkeypat cosmos.close.assert_called_once() +POSTCONFIG_VIDEO_BASE_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_blobStorageEndpoint": "https://example.blob.core.windows.net", + "var_searchServiceEndpoint": "https://example.search.windows.net", + "var_deploymentLocation": "northcentralus", + "var_videoIndexerEndpoint": "https://api.videoindexer.ai", + "var_videoIndexerArmApiVersion": "2025-04-01", +} +MANUAL_VIDEO_INDEXER_SETTINGS = { + "enable_video_file_support": True, + "video_indexer_resource_group": "manual-group", + "video_indexer_subscription_id": "manual-subscription", + "video_indexer_account_name": "manual-video", + "video_indexer_account_id": "manual-account-id", + "video_indexer_location": "centralus", + "video_indexer_endpoint": "https://custom.videoindexer.example", + "video_indexer_arm_api_version": "2024-01-01", +} + + +def run_postconfig_entrypoint(monkeypatch, world, environment): + 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 + monkeypatch.setitem(sys.modules, "deployment_cosmos", SimpleNamespace(create_deployment_cosmos_client=lambda: cosmos)) + monkeypatch.setitem(sys.modules, "deployment_configuration", MODULE) + runpy.run_path(str(ROOT / "deployers" / "bicep" / "postconfig.py"), run_name="__main__") + + +def test_postconfig_preserves_manual_video_indexer_settings(monkeypatch, world): + """A provision without Video Indexer must not blank an administrator-configured account (0.261.264).""" + world.cosmos.document.update(MANUAL_VIDEO_INDEXER_SETTINGS) + environment = { + **POSTCONFIG_VIDEO_BASE_ENVIRONMENT, + "var_videoIndexerName": "", "var_videoIndexerAccountId": "", "var_videoIndexerLocation": "", + } + run_postconfig_entrypoint(monkeypatch, world, environment) + assert world.cosmos.writes == 1 + for key, value in MANUAL_VIDEO_INDEXER_SETTINGS.items(): + assert world.cosmos.document[key] == value, key + + +@pytest.mark.parametrize("reported_location,expected_location", [ + ("centralus", "centralus"), + ("", "northcentralus"), +]) +def test_postconfig_writes_deployed_video_indexer_region(monkeypatch, world, reported_location, expected_location): + """A deployed account is written with its own region; older outputs fall back to the deployment region.""" + environment = { + **POSTCONFIG_VIDEO_BASE_ENVIRONMENT, + "var_videoIndexerName": "app-env-video", + "var_videoIndexerAccountId": "deployed-account-id", + "var_videoIndexerLocation": reported_location, + } + run_postconfig_entrypoint(monkeypatch, world, environment) + document = world.cosmos.document + assert document["enable_video_file_support"] is True + assert document["video_indexer_account_name"] == "app-env-video" + assert document["video_indexer_account_id"] == "deployed-account-id" + assert document["video_indexer_location"] == expected_location + assert document["video_indexer_resource_group"] == "group" + assert document["video_indexer_subscription_id"] == "subscription" + assert document["video_indexer_endpoint"] == "https://api.videoindexer.ai" + assert document["video_indexer_arm_api_version"] == "2025-04-01" + + @pytest.mark.parametrize("failure", [False, True]) def test_windows_hook_stops_before_restart_on_postconfig_failure(failure): pwsh = shutil.which("pwsh") diff --git a/functional_tests/test_video_indexer_deployment_region_permissions.py b/functional_tests/test_video_indexer_deployment_region_permissions.py new file mode 100644 index 000000000..e9d942d50 --- /dev/null +++ b/functional_tests/test_video_indexer_deployment_region_permissions.py @@ -0,0 +1,300 @@ +# test_video_indexer_deployment_region_permissions.py +#!/usr/bin/env python3 +""" +Functional test for the Video Indexer deployment region and permission fix. +Version: 0.261.264 +Implemented in: 0.261.264 + +This test ensures the deployers can place Azure Video Indexer in a supported region +other than the application region, with a storage account in that region; grant the +App Service identity the role SimpleChat needs to call generateAccessToken; and stop +azd before provisioning, with guidance, when Video Indexer is unavailable in the +chosen region. Postconfig behavior is covered in test_deployment_configuration.py. +""" + +import contextlib +import importlib.util +import json +import os +from pathlib import Path +import re +import sys + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +BICEP_DIR = REPO_ROOT / "deployers" / "bicep" +TERRAFORM_MAIN = REPO_ROOT / "deployers" / "terraform" / "main.tf" +AZURECLI_SCRIPT = REPO_ROOT / "deployers" / "azurecli" / "deploy-simplechat.ps1" +VIDEO_INDEXER_ACCOUNT_CONTRIBUTOR_ROLE_ID = "3f99eaab-6f59-4877-adf5-1cacd22e20b0" +CONTRIBUTOR_ROLE_ID = "b24988ac-6180-42a0-ab88-20f7382dd24c" +PREFLIGHT_ENVIRONMENT_NAMES = ( + "AZURE_ENV_DEPLOY_VIDEO_INDEXER_SERVICE", + "DEPLOY_VIDEO_INDEXER_SERVICE", + "AZURE_ENV_VIDEO_INDEXER_LOCATION", + "VIDEO_INDEXER_LOCATION", + "AZURE_ENV_AZURE_LOCATION", + "AZURE_LOCATION", + "AZURE_ENV_AUTHENTICATION_TYPE", + "AUTHENTICATION_TYPE", + "var_authenticationType", + "AZURE_ENV_ENABLE_PRIVATE_NETWORKING", + "ENABLE_PRIVATE_NETWORKING", +) +SUPPORTED_REGIONS_JSON = json.dumps(["Central US", "East US 2", "West US 2"]) + + +def read_text(path): + return path.read_text(encoding="utf-8") + + +def terraform_block(content, header): + """Return one top-level Terraform block, which ends at the first unindented closing brace.""" + start = content.index(header) + return content[start:content.index("\n}", start)] + + +def load_preflight_module(): + spec = importlib.util.spec_from_file_location( + "validate_azd_prerequisites_under_test", BICEP_DIR / "validate_azd_prerequisites.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@contextlib.contextmanager +def patched_environment(values): + """Run with only the given preflight variables set, then restore the caller's environment.""" + names = set(PREFLIGHT_ENVIRONMENT_NAMES) | set(values) + saved = {name: os.environ.get(name) for name in names} + try: + for name in names: + os.environ.pop(name, None) + os.environ.update(values) + yield + finally: + for name, value in saved.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + +@contextlib.contextmanager +def fake_azure_cli(module, exit_code=0, stdout=SUPPORTED_REGIONS_JSON, stderr=""): + """Replace the preflight Azure CLI runner and record each command it receives.""" + calls = [] + original = module._run_command + + def run(command): + calls.append(command) + return exit_code, stdout, stderr + + module._run_command = run + try: + yield calls + finally: + module._run_command = original + + +def test_bicep_places_video_indexer_in_its_own_region(): + """The azd template accepts a Video Indexer region and pairs it with same-region storage.""" + main_bicep = read_text(BICEP_DIR / "main.bicep") + video_indexer_module = read_text(BICEP_DIR / "modules" / "videoIndexer.bicep") + parameters = json.loads(read_text(BICEP_DIR / "main.parameters.json"))["parameters"] + template = json.loads(read_text(BICEP_DIR / "main.json")) + + assert "param videoIndexerLocation string = location" in main_bicep + assert parameters["videoIndexerLocation"]["value"] == "${VIDEO_INDEXER_LOCATION}" + assert "var videoIndexerUsesDedicatedStorage = normalizedVideoIndexerLocation != normalizedLocation" in main_bicep + + assert ( + "resource videoIndexerStorage 'Microsoft.Storage/storageAccounts@2022-09-01' = if (useDedicatedStorageAccount)" + in video_indexer_module + ) + assert "kind: 'StorageV2'" in video_indexer_module + assert "bypass: 'AzureServices'" in video_indexer_module + assert "publicNetworkAccess: 'Enabled'" in video_indexer_module + assert video_indexer_module.count("resourceId: videoIndexerStorageAccountId") == 2, ( + "Both Video Indexer API profiles must use the selected storage account." + ) + assert "output videoIndexerLocation string = toLower(replace(location, ' ', ''))" in video_indexer_module + + module_parameters = template["resources"]["videoIndexerService"]["properties"]["parameters"] + assert template["parameters"]["videoIndexerLocation"]["defaultValue"] == "[parameters('location')]" + assert module_parameters["location"]["value"] == "[parameters('videoIndexerLocation')]" + assert module_parameters["useDedicatedStorageAccount"]["value"] == "[variables('videoIndexerUsesDedicatedStorage')]" + assert "var_videoIndexerLocation" in template["outputs"], "Generated main.json is stale; rebuild it from main.bicep." + + +def test_bicep_grants_app_identity_video_indexer_access(): + """The app identity can call generateAccessToken without colliding with existing grants.""" + main_bicep = read_text(BICEP_DIR / "main.bicep") + permissions = read_text(BICEP_DIR / "modules" / "setVideoIndexerPermissions.bicep") + set_permissions = read_text(BICEP_DIR / "modules" / "setPermissions.bicep") + native_permissions = read_text(BICEP_DIR / "modules" / "setNativeWebAppPermissions.bicep") + template = json.loads(read_text(BICEP_DIR / "main.json")) + + assert "var videoIndexerAppRoleDefinitionId = scCloudEnvironment == 'public'" in main_bicep + assert f"? '{VIDEO_INDEXER_ACCOUNT_CONTRIBUTOR_ROLE_ID}'" in main_bicep + assert f": '{CONTRIBUTOR_ROLE_ID}'" in main_bicep + role_variable = template["variables"]["videoIndexerAppRoleDefinitionId"] + assert VIDEO_INDEXER_ACCOUNT_CONTRIBUTOR_ROLE_ID in role_variable and CONTRIBUTOR_ROLE_ID in role_variable + + assert "scope: videoIndexerService" in permissions + assert "principalId: webApp.identity.principalId" in permissions + # SimpleChat uses managed identity for Video Indexer in every auth mode, so nothing may gate the grant on it. + assert "param authenticationType" not in permissions + assert "authenticationType ==" not in permissions + assert "guid(videoIndexerService.id, webApp.id, 'video-indexer-app-access', webAppRoleDefinitionId)" in permissions + + # Moving the storage grant must keep the assignment name existing deployments already have. + assert ( + "guid(videoIndexerStorageAccount.id, videoIndexerService.id, 'video-indexer-storage-blob-data-contributor')" + in permissions + ) + assert "module videoIndexerPermissions 'setVideoIndexerPermissions.bicep' = if (videoIndexerName != '')" in set_permissions + assert "'video-indexer-storage-blob-data-contributor'" not in set_permissions + + assert ( + "resource videoIndexerAppAccessRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (videoIndexerName != '')" + in native_permissions + ) + assert "video-indexer-app-access" in read_text(BICEP_DIR / "main.json") + + +def test_terraform_places_and_authorizes_video_indexer(): + """Terraform mirrors the region override, dedicated storage, and app identity grant.""" + terraform = read_text(TERRAFORM_MAIN) + + assert 'variable "param_video_indexer_location"' in terraform + video_indexer = terraform_block(terraform, 'resource "azapi_resource" "video_indexer"') + assert re.search(r"^\s*location\s*=\s*local\.video_indexer_location\s*$", video_indexer, re.M) + + storage = terraform_block(terraform, 'resource "azurerm_storage_account" "video_indexer_sa"') + assert re.search(r"^\s*count\s*=\s*local\.video_indexer_uses_dedicated_storage \? 1 : 0\s*$", storage, re.M) + assert re.search(r"^\s*location\s*=\s*local\.video_indexer_location\s*$", storage, re.M) + + app_access = terraform_block(terraform, 'resource "azurerm_role_assignment" "app_service_smi_video_indexer_access"') + assert re.search(r"^\s*scope\s*=\s*azapi_resource\.video_indexer\[0\]\.id\s*$", app_access, re.M) + assert re.search(r"^\s*principal_id\s*=\s*azurerm_linux_web_app\.app\.identity\[0\]\.principal_id\s*$", app_access, re.M) + assert '"Video Indexer Account Contributor"' in terraform + + # azapi 2.x returns output as an object, and jsondecode on an object fails at plan time. + assert "jsondecode(azapi_resource.video_indexer" not in terraform + assert 'output "video_indexer_account_id"' in terraform + + +def test_azurecli_places_and_authorizes_video_indexer(): + """The Azure CLI deployer mirrors the region override and app identity grant.""" + script = read_text(AZURECLI_SCRIPT) + + assert '$param_VideoIndexerLocation = ""' in script + assert "location = $videoIndexerLocation" in script + assert ( + '$videoIndexerAppRoleName = if ($globalWhichAzurePlatform -eq "AzureCloud") ' + '{ "Video Indexer Account Contributor" } else { "Contributor" }' + ) in script + assert "--assignee-object-id $appService_SystemManagedIdentity_ObjectId" in script + assert '--scope "$videoIndexerResourceId"' in script + + +def test_preflight_blocks_unsupported_video_indexer_region(): + """azd stops before provisioning when Video Indexer is unavailable in the app region.""" + module = load_preflight_module() + with patched_environment({"DEPLOY_VIDEO_INDEXER_SERVICE": "true", "AZURE_LOCATION": "northcentralus"}): + with fake_azure_cli(module) as calls: + allowed = module._validate_video_indexer_region() + + assert allowed is False + assert calls and calls[0][:5] == ["az", "provider", "show", "--namespace", "Microsoft.VideoIndexer"] + + +def test_preflight_accepts_supported_override_region(): + """A display-name override such as 'Central US' is normalized before comparison.""" + module = load_preflight_module() + environment = { + "DEPLOY_VIDEO_INDEXER_SERVICE": "true", + "AZURE_LOCATION": "northcentralus", + "VIDEO_INDEXER_LOCATION": "Central US", + } + with patched_environment(environment): + with fake_azure_cli(module): + allowed = module._validate_video_indexer_region() + + assert allowed is True + + +def test_preflight_skips_when_video_indexer_disabled(): + """No Azure CLI lookup runs when Video Indexer is not being deployed.""" + module = load_preflight_module() + with patched_environment({"DEPLOY_VIDEO_INDEXER_SERVICE": "false", "AZURE_LOCATION": "northcentralus"}): + with fake_azure_cli(module) as calls: + allowed = module._validate_video_indexer_region() + + assert allowed is True + assert calls == [] + + +def test_preflight_does_not_block_when_region_lookup_fails(): + """A failed lookup only warns, because Azure Resource Manager still validates the region.""" + module = load_preflight_module() + with patched_environment({"DEPLOY_VIDEO_INDEXER_SERVICE": "true", "AZURE_LOCATION": "northcentralus"}): + with fake_azure_cli(module, exit_code=1, stdout="", stderr="provider lookup failed"): + allowed = module._validate_video_indexer_region() + + assert allowed is True + + +def test_preflight_main_stops_before_provisioning(): + """The preprovision entry point returns a failure code for an unsupported region.""" + module = load_preflight_module() + environment = { + "DEPLOY_VIDEO_INDEXER_SERVICE": "true", + "AZURE_LOCATION": "northcentralus", + "AUTHENTICATION_TYPE": "key", + } + with patched_environment(environment): + with fake_azure_cli(module): + exit_code = module.main() + + assert exit_code == 1 + + +def test_version_includes_fix(): + """The application version includes this deployer fix.""" + assert_app_version_at_least("0.261.264") + + +if __name__ == "__main__": + tests = [ + test_bicep_places_video_indexer_in_its_own_region, + test_bicep_grants_app_identity_video_indexer_access, + test_terraform_places_and_authorizes_video_indexer, + test_azurecli_places_and_authorizes_video_indexer, + test_preflight_blocks_unsupported_video_indexer_region, + test_preflight_accepts_supported_override_region, + test_preflight_skips_when_video_indexer_disabled, + test_preflight_does_not_block_when_region_lookup_fails, + test_preflight_main_stops_before_provisioning, + test_version_includes_fix, + ] + results = [] + + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + except AssertionError as ex: + print(f"FAIL {test.__name__}: {ex}") + results.append(False) + else: + print(f"PASS {test.__name__}") + results.append(True) + + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1)