From 4af54b25b8bb64fb573669c6eab967eb0d3b9280 Mon Sep 17 00:00:00 2001 From: Mohammad Rafi Date: Mon, 29 Jun 2026 12:43:06 +0530 Subject: [PATCH 01/12] chore: update CODEOWNERS handles Replace @Vinay-Microsoft -> @VinaySh-Microsoft and @Prajwal-Microsoft -> @Prajwal1-Microsoft --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a9b9004e7..3ed13196c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,4 +2,4 @@ # Each line is a file pattern followed by one or more owners. # These owners will be the default owners for everything in the repo. -* @Avijit-Microsoft @Roopan-Microsoft @Prajwal-Microsoft @aniaroramsft @Vinay-Microsoft @malrose07 @toherman-msft @nchandhi +* @Avijit-Microsoft @Roopan-Microsoft @Prajwal1-Microsoft @aniaroramsft @VinaySh-Microsoft @malrose07 @toherman-msft @nchandhi From cca6e396d1413d45265d0b9248cb40514702dfdc Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Wed, 1 Jul 2026 19:24:14 +0530 Subject: [PATCH 02/12] Updated infra to include ACR deployment --- azure.yaml | 98 +- infra/main.bicep | 48 +- infra/main.json | 3655 ++++++++++++++++++++++-- infra/main.parameters.json | 6 - infra/main.waf.parameters.json | 6 - infra/modules/container-instance.bicep | 15 +- infra/modules/container-registry.bicep | 91 + 7 files changed, 3619 insertions(+), 300 deletions(-) create mode 100644 infra/modules/container-registry.bicep diff --git a/azure.yaml b/azure.yaml index 23f3ced27..c677295b5 100644 --- a/azure.yaml +++ b/azure.yaml @@ -55,98 +55,30 @@ hooks: postprovision: windows: run: | - Write-Host "===== Provision Complete =====" -ForegroundColor Green - Write-Host "" - Write-Host "Web App URL: " -NoNewline - Write-Host "$env:WEB_APP_URL" -ForegroundColor Cyan - Write-Host "Storage Account: " -NoNewline - Write-Host "$env:AZURE_BLOB_ACCOUNT_NAME" -ForegroundColor Cyan - Write-Host "AI Search Service: " -NoNewline - Write-Host "$env:AI_SEARCH_SERVICE_NAME" -ForegroundColor Cyan - Write-Host "AI Search Index: " -NoNewline - Write-Host "$env:AZURE_AI_SEARCH_PRODUCTS_INDEX" -ForegroundColor Cyan - Write-Host "AI Service Location: " -NoNewline - Write-Host "$env:AZURE_ENV_AI_SERVICE_LOCATION" -ForegroundColor Cyan - Write-Host "Container Instance: " -NoNewline - Write-Host "$env:CONTAINER_INSTANCE_NAME" -ForegroundColor Cyan - - # Run post-deploy script to upload sample data and create search index - Write-Host "" - # Note: Cosmos DB role is assigned to deployer via Bicep. - Write-Host "===== Running Post-Deploy Script =====" -ForegroundColor Yellow - Write-Host "This will upload sample data and create the search index..." + Write-Host "===== Provisioning Complete =====" -ForegroundColor Green + Write-Host "Run the following two scripts, in order:" -ForegroundColor Yellow + Write-Host " 1. Build and push the application images to ACR, then update the web app and container instance:" -ForegroundColor Yellow + Write-Host " ./infra/scripts/build_and_deploy_images.ps1" -ForegroundColor Cyan + Write-Host " 2. Load the sample data into the application (run only after the previous script run has completed):" -ForegroundColor Yellow + Write-Host " python ./scripts/post_deploy.py --skip-tests" -ForegroundColor Cyan - # Ensure post-deploy Python dependencies are installed - $python = "python" - if (Test-Path "./.venv/Scripts/python.exe") { $python = "./.venv/Scripts/python.exe" } - elseif (Test-Path "../.venv/Scripts/python.exe") { $python = "../.venv/Scripts/python.exe" } - elseif (Test-Path "./.venv/bin/python") { $python = "./.venv/bin/python" } - elseif (Test-Path "../.venv/bin/python") { $python = "../.venv/bin/python" } - & $python -m pip install -r ./scripts/requirements-post-deploy.txt --quiet | Out-Null - - if (Test-Path "./scripts/post_deploy.py") { - & $python ./scripts/post_deploy.py --skip-tests - - if ($LASTEXITCODE -eq 0) { - Write-Host "Post-deploy script completed successfully!" -ForegroundColor Green - } else { - Write-Host "Post-deploy script completed with warnings (some steps may have failed)" -ForegroundColor Yellow - } - } else { - Write-Host "Warning: post_deploy.py not found, skipping sample data upload" -ForegroundColor Yellow - } - Write-Host "" - Write-Host "===== Deployment Complete =====" -ForegroundColor Green - Write-Host "" - Write-Host "Access the web application:" - Write-Host " $env:WEB_APP_URL" -ForegroundColor Cyan + Write-Host "Web app URL: " -NoNewline + Write-Host "$env:WEB_APP_URL" -ForegroundColor Cyan shell: pwsh continueOnError: false interactive: true posix: run: | - echo "===== Provision Complete =====" - echo "" - echo "Web App URL: $WEB_APP_URL" - echo "Storage Account: $AZURE_BLOB_ACCOUNT_NAME" - echo "AI Search Service: $AI_SEARCH_SERVICE_NAME" - echo "AI Search Index: $AZURE_AI_SEARCH_PRODUCTS_INDEX" - echo "AI Service Location: $AZURE_ENV_AI_SERVICE_LOCATION" - echo "Container Instance: $CONTAINER_INSTANCE_NAME" - - echo "" - echo "Container Registry: $AZURE_ENV_CONTAINER_REGISTRY_NAME" - - # Run post-deploy script to upload sample data and create search index - echo "" - # Note: Cosmos DB role is assigned to deployer via Bicep. - echo "===== Running Post-Deploy Script =====" - echo "This will upload sample data and create the search index..." - - if [ -f "./scripts/post_deploy.py" ]; then - # Prefer local venv if present (repo root or content-gen) - if [ -x "./.venv/bin/python" ]; then - PYTHON_BIN="./.venv/bin/python" - elif [ -x "../.venv/bin/python" ]; then - PYTHON_BIN="../.venv/bin/python" - else - PYTHON_BIN="python3" - fi + echo "===== Provisioning Complete =====" + echo "Run the following two scripts, in order:" + echo " 1. Build and push the application images to ACR, then update the web app and container instance:" + echo " bash ./infra/scripts/build_and_deploy_images.sh" + echo " 2. Load the sample data into the application (run only after the previous script run has completed):" + echo " python ./scripts/post_deploy.py --skip-tests" - "$PYTHON_BIN" -m pip install -r ./scripts/requirements-post-deploy.txt --quiet > /dev/null \ - && "$PYTHON_BIN" ./scripts/post_deploy.py --skip-tests \ - && echo "Post-deploy script completed successfully!" \ - || echo "Post-deploy script completed with warnings (some steps may have failed)" - else - echo "Warning: post_deploy.py not found, skipping sample data upload" - fi - - echo "" - echo "===== Deployment Complete =====" echo "" - echo "Access the web application:" - echo " $WEB_APP_URL" + echo "Web app URL: $WEB_APP_URL" shell: sh continueOnError: false interactive: true diff --git a/infra/main.bicep b/infra/main.bicep index 2451f323b..4f621a38f 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -135,12 +135,6 @@ param enableRedundancy bool = false @description('Optional. Enable private networking for applicable resources (WAF-aligned).') param enablePrivateNetworking bool = false -@description('Optional. The existing Container Registry name (without .azurecr.io). Must contain pre-built images: content-gen-app and content-gen-api.') -param acrName string = 'contentgencontainerreg' - -@description('Optional. Image Tag.') -param imageTag string = 'latest' - @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true @@ -153,8 +147,6 @@ param createdBy string = contains(deployer(), 'userPrincipalName')? split(deploy var solutionLocation = empty(location) ? resourceGroup().location : location -// acrName is required - points to existing ACR with pre-built images -var acrResourceName = acrName var solutionSuffix = toLower(trim(replace( replace( replace(replace(replace(replace('${solutionName}${solutionUniqueText}', '-', ''), '_', ''), '.', ''), '/', ''), @@ -372,6 +364,33 @@ module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-id } } +// ========== Azure Container Registry ========== // +// Provisions the ACR and grants AcrPull to the shared managed identity (used by +// both the frontend App Service and the backend Container Instance). Application +// images are built and pushed to this ACR by a post-deployment step. +module containerRegistry 'modules/container-registry.bicep' = { + name: take('module.container-registry.${solutionSuffix}', 64) + params: { + name: 'cr${solutionSuffix}' + location: solutionLocation + tags: tags + enableTelemetry: enableTelemetry + acrSku: 'Standard' + managedIdentities: { + userAssignedResourceIds: [ + userAssignedIdentity.outputs.resourceId + ] + } + pullPrincipalIds: [ + userAssignedIdentity.outputs.principalId + ] + pushPrincipalIds: [ + deployer().objectId + ] + pushPrincipalType: 'User' + } +} + // ========== Virtual Network and Networking Components ========== // var deployAdminAccessResources = enablePrivateNetworking && deployBastionAndJumpbox && !empty(vmAdminPassword) module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { @@ -966,11 +985,13 @@ module webSite 'modules/web-sites.bicep' = { serverFarmResourceId: webServerFarm.outputs.resourceId managedIdentities: { userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] } siteConfig: { - // Frontend container - same for both modes - linuxFxVersion: 'DOCKER|${acrResourceName}.azurecr.io/content-gen-app:${imageTag}' + // Frontend container - hello-world placeholder (updated post-deployment) + linuxFxVersion: 'DOCKER|mcr.microsoft.com/azuredocs/aci-helloworld:latest' minTlsVersion: '1.2' alwaysOn: true ftpsState: 'FtpsOnly' + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: userAssignedIdentity!.outputs.clientId } virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webSubnetResourceId : null configs: concat( @@ -979,7 +1000,7 @@ module webSite 'modules/web-sites.bicep' = { // Frontend container proxies to ACI backend (both modes) name: 'appsettings' properties: { - DOCKER_REGISTRY_SERVER_URL: 'https://${acrResourceName}.azurecr.io' + DOCKER_REGISTRY_SERVER_URL: 'https://${containerRegistry.outputs.loginServer}' BACKEND_URL: aciBackendUrl AZURE_CLIENT_ID: userAssignedIdentity.outputs.clientId } @@ -1013,13 +1034,14 @@ module containerInstance 'modules/container-instance.bicep' = { name: containerInstanceName location: solutionLocation tags: tags - containerImage: '${acrResourceName}.azurecr.io/content-gen-api:${imageTag}' + containerImage: 'mcr.microsoft.com/azuredocs/aci-helloworld:latest' cpu: 2 memoryInGB: 4 port: 8000 // Only pass subnetResourceId when private networking is enabled subnetResourceId: enablePrivateNetworking ? virtualNetwork!.outputs.aciSubnetResourceId : '' userAssignedIdentityResourceId: userAssignedIdentity.outputs.resourceId + acrLoginServer: containerRegistry.outputs.loginServer enableTelemetry: enableTelemetry environmentVariables: [ // Azure OpenAI Settings @@ -1144,7 +1166,7 @@ output CONTAINER_INSTANCE_NAME string = containerInstance.outputs.name output CONTAINER_INSTANCE_FQDN string = enablePrivateNetworking ? '' : containerInstance.outputs.fqdn @description('Contains ACR Name') -output AZURE_ENV_CONTAINER_REGISTRY_NAME string = acrResourceName +output AZURE_ENV_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name @description('Contains flag for Azure AI Foundry usage') output USE_FOUNDRY bool = useFoundryMode ? true : false diff --git a/infra/main.json b/infra/main.json index 3fddbef2f..2e01ea149 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "6689866125639874285" + "templateHash": "18281771994413307619" }, "name": "Intelligent Content Generation Accelerator", "description": "Solution Accelerator for multimodal marketing content generation using Microsoft Agent Framework.\n" @@ -232,20 +232,6 @@ "description": "Optional. Enable private networking for applicable resources (WAF-aligned)." } }, - "acrName": { - "type": "string", - "defaultValue": "contentgencontainerreg", - "metadata": { - "description": "Optional. The existing Container Registry name (without .azurecr.io). Must contain pre-built images: content-gen-app and content-gen-api." - } - }, - "imageTag": { - "type": "string", - "defaultValue": "latest", - "metadata": { - "description": "Optional. Image Tag." - } - }, "enableTelemetry": { "type": "bool", "defaultValue": true, @@ -263,7 +249,6 @@ }, "variables": { "solutionLocation": "[if(empty(parameters('location')), resourceGroup().location, parameters('location'))]", - "acrResourceName": "[parameters('acrName')]", "solutionSuffix": "[toLower(trim(replace(replace(replace(replace(replace(replace(format('{0}{1}', parameters('solutionName'), parameters('solutionUniqueText')), '-', ''), '_', ''), '.', ''), '/', ''), ' ', ''), '*', '')))]", "cosmosDbZoneRedundantHaRegionPairs": { "australiaeast": "uksouth", @@ -4594,218 +4579,3493 @@ "type": "String", "value": "For more information, see https://aka.ms/avm/TelemetryInfo" } - } - } - } - }, - "userAssignedIdentity": { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2024-11-30", - "name": "[parameters('name')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "properties": "[if(not(equals(parameters('isolationScope'), null())), createObject('isolationScope', parameters('isolationScope')), createObject())]" - }, - "userAssignedIdentity_lock": { - "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", - "type": "Microsoft.Authorization/locks", - "apiVersion": "2020-05-01", - "scope": "[format('Microsoft.ManagedIdentity/userAssignedIdentities/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", - "properties": { - "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", - "notes": "[coalesce(tryGet(parameters('lock'), 'notes'), if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.'))]" - }, - "dependsOn": [ - "userAssignedIdentity" - ] - }, - "userAssignedIdentity_roleAssignments": { - "copy": { - "name": "userAssignedIdentity_roleAssignments", - "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.ManagedIdentity/userAssignedIdentities/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", - "properties": { - "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", - "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", - "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", - "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", - "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", - "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", - "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" - }, - "dependsOn": [ - "userAssignedIdentity" - ] - }, - "userAssignedIdentity_federatedIdentityCredentials": { - "copy": { - "name": "userAssignedIdentity_federatedIdentityCredentials", - "count": "[length(coalesce(parameters('federatedIdentityCredentials'), createArray()))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('{0}-UserMSI-FederatedIdentityCred-{1}', uniqueString(subscription().id, resourceGroup().id, parameters('location')), copyIndex())]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "[coalesce(parameters('federatedIdentityCredentials'), createArray())[copyIndex()].name]" - }, - "userAssignedIdentityName": { - "value": "[parameters('name')]" - }, - "audiences": { - "value": "[coalesce(parameters('federatedIdentityCredentials'), createArray())[copyIndex()].audiences]" - }, - "issuer": { - "value": "[coalesce(parameters('federatedIdentityCredentials'), createArray())[copyIndex()].issuer]" - }, - "subject": { - "value": "[coalesce(parameters('federatedIdentityCredentials'), createArray())[copyIndex()].subject]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "1387931959101373036" - }, - "name": "User Assigned Identity Federated Identity Credential", - "description": "This module deploys a User Assigned Identity Federated Identity Credential." + } + } + } + }, + "userAssignedIdentity": { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2024-11-30", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": "[if(not(equals(parameters('isolationScope'), null())), createObject('isolationScope', parameters('isolationScope')), createObject())]" + }, + "userAssignedIdentity_lock": { + "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", + "type": "Microsoft.Authorization/locks", + "apiVersion": "2020-05-01", + "scope": "[format('Microsoft.ManagedIdentity/userAssignedIdentities/{0}', parameters('name'))]", + "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", + "properties": { + "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", + "notes": "[coalesce(tryGet(parameters('lock'), 'notes'), if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.'))]" + }, + "dependsOn": [ + "userAssignedIdentity" + ] + }, + "userAssignedIdentity_roleAssignments": { + "copy": { + "name": "userAssignedIdentity_roleAssignments", + "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" + }, + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.ManagedIdentity/userAssignedIdentities/{0}', parameters('name'))]", + "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", + "properties": { + "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", + "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", + "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", + "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", + "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", + "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", + "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" + }, + "dependsOn": [ + "userAssignedIdentity" + ] + }, + "userAssignedIdentity_federatedIdentityCredentials": { + "copy": { + "name": "userAssignedIdentity_federatedIdentityCredentials", + "count": "[length(coalesce(parameters('federatedIdentityCredentials'), createArray()))]", + "mode": "serial", + "batchSize": 1 + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('{0}-UserMSI-FederatedIdentityCred-{1}', uniqueString(subscription().id, resourceGroup().id, parameters('location')), copyIndex())]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[coalesce(parameters('federatedIdentityCredentials'), createArray())[copyIndex()].name]" + }, + "userAssignedIdentityName": { + "value": "[parameters('name')]" + }, + "audiences": { + "value": "[coalesce(parameters('federatedIdentityCredentials'), createArray())[copyIndex()].audiences]" + }, + "issuer": { + "value": "[coalesce(parameters('federatedIdentityCredentials'), createArray())[copyIndex()].issuer]" + }, + "subject": { + "value": "[coalesce(parameters('federatedIdentityCredentials'), createArray())[copyIndex()].subject]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.39.26.7824", + "templateHash": "1387931959101373036" + }, + "name": "User Assigned Identity Federated Identity Credential", + "description": "This module deploys a User Assigned Identity Federated Identity Credential." + }, + "parameters": { + "userAssignedIdentityName": { + "type": "string", + "metadata": { + "description": "Conditional. The name of the parent user assigned identity. Required if the template is used in a standalone deployment." + } + }, + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the secret." + } + }, + "audiences": { + "type": "array", + "metadata": { + "description": "Required. The list of audiences that can appear in the issued token. Should be set to api://AzureADTokenExchange for Azure AD. It says what Microsoft identity platform should accept in the aud claim in the incoming token. This value represents Azure AD in your external identity provider and has no fixed value across identity providers - you might need to create a new application registration in your IdP to serve as the audience of this token." + } + }, + "issuer": { + "type": "string", + "metadata": { + "description": "Required. The URL of the issuer to be trusted. Must match the issuer claim of the external token being exchanged." + } + }, + "subject": { + "type": "string", + "metadata": { + "description": "Required. The identifier of the external software workload within the external identity provider. Like the audience value, it has no fixed format, as each IdP uses their own - sometimes a GUID, sometimes a colon delimited identifier, sometimes arbitrary strings. The value here must match the sub claim within the token presented to Azure AD." + } + } + }, + "resources": [ + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials", + "apiVersion": "2024-11-30", + "name": "[format('{0}/{1}', parameters('userAssignedIdentityName'), parameters('name'))]", + "properties": { + "audiences": "[parameters('audiences')]", + "issuer": "[parameters('issuer')]", + "subject": "[parameters('subject')]" + } + } + ], + "outputs": { + "name": { + "type": "string", + "metadata": { + "description": "The name of the federated identity credential." + }, + "value": "[parameters('name')]" + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the federated identity credential." + }, + "value": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials', parameters('userAssignedIdentityName'), parameters('name'))]" + }, + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The name of the resource group the federated identity credential was created in." + }, + "value": "[resourceGroup().name]" + } + } + } + }, + "dependsOn": [ + "userAssignedIdentity" + ] + } + }, + "outputs": { + "name": { + "type": "string", + "metadata": { + "description": "The name of the user assigned identity." + }, + "value": "[parameters('name')]" + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the user assigned identity." + }, + "value": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('name'))]" + }, + "principalId": { + "type": "string", + "metadata": { + "description": "The principal ID (object ID) of the user assigned identity." + }, + "value": "[reference('userAssignedIdentity').principalId]" + }, + "clientId": { + "type": "string", + "metadata": { + "description": "The client ID (application ID) of the user assigned identity." + }, + "value": "[reference('userAssignedIdentity').clientId]" + }, + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The resource group the user assigned identity was deployed into." + }, + "value": "[resourceGroup().name]" + }, + "location": { + "type": "string", + "metadata": { + "description": "The location the resource was deployed into." + }, + "value": "[reference('userAssignedIdentity', '2024-11-30', 'full').location]" + } + } + } + } + }, + "containerRegistry": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[take(format('module.container-registry.{0}', variables('solutionSuffix')), 64)]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[format('cr{0}', variables('solutionSuffix'))]" + }, + "location": { + "value": "[variables('solutionLocation')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "enableTelemetry": { + "value": "[parameters('enableTelemetry')]" + }, + "acrSku": { + "value": "Standard" + }, + "managedIdentities": { + "value": { + "userAssignedResourceIds": [ + "[reference('userAssignedIdentity').outputs.resourceId.value]" + ] + } + }, + "pullPrincipalIds": { + "value": [ + "[reference('userAssignedIdentity').outputs.principalId.value]" + ] + }, + "pushPrincipalIds": { + "value": [ + "[deployer().objectId]" + ] + }, + "pushPrincipalType": { + "value": "User" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "8532463912224257721" + } + }, + "definitions": { + "managedIdentityAllType": { + "type": "object", + "properties": { + "systemAssigned": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Enables system assigned managed identity on the resource." + } + }, + "userAssignedResourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "metadata": { + "description": "Optional. The resource ID(s) to assign to the resource. Required if a user assigned identity is used for encryption." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a managed identity configuration. To be used if both a system-assigned & user-assigned identities are supported by the resource provider.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.7.0" + } + } + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Required. Name of the Azure Container Registry." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Optional. Location for the Container Registry." + } + }, + "tags": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Optional. Tags for all resources." + } + }, + "enableTelemetry": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Optional. Enable/Disable usage telemetry for module." + } + }, + "acrSku": { + "type": "string", + "defaultValue": "Standard", + "allowedValues": [ + "Basic", + "Standard", + "Premium" + ], + "metadata": { + "description": "Optional. SKU for the Container Registry." + } + }, + "pullPrincipalIds": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Optional. Principal IDs (frontend/backend managed identities) that require AcrPull access." + } + }, + "pushPrincipalIds": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Optional. Principal IDs (e.g. the deployer) that require AcrPush access to build and push images." + } + }, + "pushPrincipalType": { + "type": "string", + "defaultValue": "User", + "allowedValues": [ + "User", + "Group", + "ServicePrincipal" + ], + "metadata": { + "description": "Optional. Principal type for the AcrPush role assignments." + } + }, + "managedIdentities": { + "$ref": "#/definitions/managedIdentityAllType", + "nullable": true, + "metadata": { + "description": "Optional. The managed identity definition for this resource." + } + } + }, + "variables": { + "copy": [ + { + "name": "pullRoleAssignments", + "count": "[length(parameters('pullPrincipalIds'))]", + "input": { + "principalId": "[parameters('pullPrincipalIds')[copyIndex('pullRoleAssignments')]]", + "roleDefinitionIdOrName": "[variables('acrPullRoleDefinitionId')]", + "principalType": "ServicePrincipal" + } + }, + { + "name": "pushRoleAssignments", + "count": "[length(parameters('pushPrincipalIds'))]", + "input": { + "principalId": "[parameters('pushPrincipalIds')[copyIndex('pushRoleAssignments')]]", + "roleDefinitionIdOrName": "[variables('acrPushRoleDefinitionId')]", + "principalType": "[parameters('pushPrincipalType')]" + } + } + ], + "acrPullRoleDefinitionId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", + "acrPushRoleDefinitionId": "8311e382-0749-4cb8-b61a-304f252e45ec" + }, + "resources": { + "containerRegistry": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[take(format('avm.res.container-registry.registry.{0}', parameters('name')), 64)]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[parameters('name')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "enableTelemetry": { + "value": "[parameters('enableTelemetry')]" + }, + "acrSku": { + "value": "[parameters('acrSku')]" + }, + "acrAdminUserEnabled": { + "value": false + }, + "anonymousPullEnabled": { + "value": false + }, + "publicNetworkAccess": { + "value": "Enabled" + }, + "networkRuleBypassOptions": { + "value": "AzureServices" + }, + "roleAssignments": { + "value": "[concat(variables('pullRoleAssignments'), variables('pushRoleAssignments'))]" + }, + "managedIdentities": { + "value": "[parameters('managedIdentities')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.33.93.31351", + "templateHash": "4815545096063729768" + }, + "name": "Azure Container Registries (ACR)", + "description": "This module deploys an Azure Container Registry (ACR)." + }, + "definitions": { + "privateEndpointOutputType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "The name of the private endpoint." + } + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the private endpoint." + } + }, + "groupId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "The group Id for the private endpoint Group." + } + }, + "customDnsConfigs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fqdn": { + "type": "string", + "nullable": true, + "metadata": { + "description": "FQDN that resolves to private endpoint IP address." + } + }, + "ipAddresses": { + "type": "array", + "items": { + "type": "string" + }, + "metadata": { + "description": "A list of private IP addresses of the private endpoint." + } + } + } + }, + "metadata": { + "description": "The custom DNS configurations of the private endpoint." + } + }, + "networkInterfaceResourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "metadata": { + "description": "The IDs of the network interfaces associated with the private endpoint." + } + } + }, + "metadata": { + "__bicep_export!": true + } + }, + "scopeMapsType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the scope map." + } + }, + "actions": { + "type": "array", + "items": { + "type": "string" + }, + "metadata": { + "description": "Required. The list of scoped permissions for registry artifacts." + } + }, + "description": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The user friendly description of the scope map." + } + } + }, + "metadata": { + "__bicep_export!": true, + "description": "The type for a scope map." + } + }, + "cacheRuleType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the cache rule. Will be derived from the source repository name if not defined." + } + }, + "sourceRepository": { + "type": "string", + "metadata": { + "description": "Required. Source repository pulled from upstream." + } + }, + "targetRepository": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Target repository specified in docker pull command. E.g.: docker pull myregistry.azurecr.io/{targetRepository}:{tag}." + } + }, + "credentialSetResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The resource ID of the credential store which is associated with the cache rule." + } + } + }, + "metadata": { + "__bicep_export!": true, + "description": "The type for a cache rule." + } + }, + "credentialSetType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the credential set." + } + }, + "managedIdentities": { + "$ref": "#/definitions/managedIdentityOnlySysAssignedType", + "nullable": true, + "metadata": { + "description": "Optional. The managed identity definition for this resource." + } + }, + "authCredentials": { + "type": "array", + "items": { + "$ref": "#/definitions/authCredentialsType" + }, + "metadata": { + "description": "Required. List of authentication credentials stored for an upstream. Usually consists of a primary and an optional secondary credential." + } + }, + "loginServer": { + "type": "string", + "metadata": { + "description": "Required. The credentials are stored for this upstream or login server." + } + } + }, + "metadata": { + "__bicep_export!": true, + "description": "The type for a credential set." + } + }, + "replicationType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the replication." + } + }, + "location": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Location for all resources." + } + }, + "tags": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Tags of the resource." + } + }, + "regionEndpointEnabled": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Specifies whether the replication regional endpoint is enabled. Requests will not be routed to a replication whose regional endpoint is disabled, however its data will continue to be synced with other replications." + } + }, + "zoneRedundancy": { + "type": "string", + "allowedValues": [ + "Disabled", + "Enabled" + ], + "nullable": true, + "metadata": { + "description": "Optional. Whether or not zone redundancy is enabled for this container registry." + } + } + }, + "metadata": { + "__bicep_export!": true, + "description": "The type for a replication." + } + }, + "webhookType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "minLength": 5, + "maxLength": 50, + "metadata": { + "description": "Optional. The name of the registry webhook." + } + }, + "serviceUri": { + "type": "string", + "metadata": { + "description": "Required. The service URI for the webhook to post notifications." + } + }, + "status": { + "type": "string", + "allowedValues": [ + "disabled", + "enabled" + ], + "nullable": true, + "metadata": { + "description": "Optional. The status of the webhook at the time the operation was called." + } + }, + "action": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "metadata": { + "description": "Optional. The list of actions that trigger the webhook to post notifications." + } + }, + "location": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Location for all resources." + } + }, + "tags": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Tags of the resource." + } + }, + "customHeaders": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Custom headers that will be added to the webhook notifications." + } + }, + "scope": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The scope of repositories where the event can be triggered. For example, 'foo:*' means events for all tags under repository 'foo'. 'foo:bar' means events for 'foo:bar' only. 'foo' is equivalent to 'foo:latest'. Empty means all events." + } + } + }, + "metadata": { + "__bicep_export!": true, + "description": "The type for a webhook." + } + }, + "_1.privateEndpointCustomDnsConfigType": { + "type": "object", + "properties": { + "fqdn": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. FQDN that resolves to private endpoint IP address." + } + }, + "ipAddresses": { + "type": "array", + "items": { + "type": "string" + }, + "metadata": { + "description": "Required. A list of private IP addresses of the private endpoint." + } + } + }, + "metadata": { + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "_1.privateEndpointIpConfigurationType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the resource that is unique within a resource group." + } + }, + "properties": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "metadata": { + "description": "Required. The ID of a group obtained from the remote resource that this private endpoint should connect to." + } + }, + "memberName": { + "type": "string", + "metadata": { + "description": "Required. The member name of a group obtained from the remote resource that this private endpoint should connect to." + } + }, + "privateIPAddress": { + "type": "string", + "metadata": { + "description": "Required. A private IP address obtained from the private endpoint's subnet." + } + } + }, + "metadata": { + "description": "Required. Properties of private endpoint IP configurations." + } + } + }, + "metadata": { + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "_1.privateEndpointPrivateDnsZoneGroupType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the Private DNS Zone Group." + } + }, + "privateDnsZoneGroupConfigs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the private DNS Zone Group config." + } + }, + "privateDnsZoneResourceId": { + "type": "string", + "metadata": { + "description": "Required. The resource id of the private DNS zone." + } + } + } + }, + "metadata": { + "description": "Required. The private DNS Zone Groups to associate the Private Endpoint. A DNS Zone Group can support up to 5 DNS zones." + } + } + }, + "metadata": { + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "authCredentialsType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the credential." + } + }, + "usernameSecretIdentifier": { + "type": "string", + "metadata": { + "description": "Required. KeyVault Secret URI for accessing the username." + } + }, + "passwordSecretIdentifier": { + "type": "string", + "metadata": { + "description": "Required. KeyVault Secret URI for accessing the password." + } + } + }, + "metadata": { + "description": "The type for auth credentials.", + "__bicep_imported_from!": { + "sourceTemplate": "credential-set/main.bicep" + } + } + }, + "customerManagedKeyWithAutoRotateType": { + "type": "object", + "properties": { + "keyVaultResourceId": { + "type": "string", + "metadata": { + "description": "Required. The resource ID of a key vault to reference a customer managed key for encryption from." + } + }, + "keyName": { + "type": "string", + "metadata": { + "description": "Required. The name of the customer managed key to use for encryption." + } + }, + "keyVersion": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The version of the customer managed key to reference for encryption. If not provided, using version as per 'autoRotationEnabled' setting." + } + }, + "autoRotationEnabled": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Enable or disable auto-rotating to the latest key version. Default is `true`. If set to `false`, the latest key version at the time of the deployment is used." + } + }, + "userAssignedIdentityResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. User assigned identity to use when fetching the customer managed key. Required if no system assigned identity is available for use." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a customer-managed key. To be used if the resource type supports auto-rotation of the customer-managed key.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "diagnosticSettingFullType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the diagnostic setting." + } + }, + "logCategoriesAndGroups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Name of a Diagnostic Log category for a resource type this setting is applied to. Set the specific logs to collect here." + } + }, + "categoryGroup": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Name of a Diagnostic Log category group for a resource type this setting is applied to. Set to `allLogs` to collect all logs." + } + }, + "enabled": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Enable or disable the category explicitly. Default is `true`." + } + } + } + }, + "nullable": true, + "metadata": { + "description": "Optional. The name of logs that will be streamed. \"allLogs\" includes all possible logs for the resource. Set to `[]` to disable log collection." + } + }, + "metricCategories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { + "type": "string", + "metadata": { + "description": "Required. Name of a Diagnostic Metric category for a resource type this setting is applied to. Set to `AllMetrics` to collect all metrics." + } + }, + "enabled": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Enable or disable the category explicitly. Default is `true`." + } + } + } + }, + "nullable": true, + "metadata": { + "description": "Optional. The name of metrics that will be streamed. \"allMetrics\" includes all possible metrics for the resource. Set to `[]` to disable metric collection." + } + }, + "logAnalyticsDestinationType": { + "type": "string", + "allowedValues": [ + "AzureDiagnostics", + "Dedicated" + ], + "nullable": true, + "metadata": { + "description": "Optional. A string indicating whether the export to Log Analytics should use the default destination type, i.e. AzureDiagnostics, or use a destination type." + } + }, + "workspaceResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Resource ID of the diagnostic log analytics workspace. For security reasons, it is recommended to set diagnostic settings to send data to either storage account, log analytics workspace or event hub." + } + }, + "storageAccountResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Resource ID of the diagnostic storage account. For security reasons, it is recommended to set diagnostic settings to send data to either storage account, log analytics workspace or event hub." + } + }, + "eventHubAuthorizationRuleResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Resource ID of the diagnostic event hub authorization rule for the Event Hubs namespace in which the event hub should be created or streamed to." + } + }, + "eventHubName": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Name of the diagnostic event hub within the namespace to which logs are streamed. Without this, an event hub is created for each log category. For security reasons, it is recommended to set diagnostic settings to send data to either storage account, log analytics workspace or event hub." + } + }, + "marketplacePartnerResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The full ARM resource ID of the Marketplace resource to which you would like to send Diagnostic Logs." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a diagnostic setting. To be used if both logs & metrics are supported by the resource provider.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "lockType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Specify the name of lock." + } + }, + "kind": { + "type": "string", + "allowedValues": [ + "CanNotDelete", + "None", + "ReadOnly" + ], + "nullable": true, + "metadata": { + "description": "Optional. Specify the type of lock." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a lock.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "managedIdentityAllType": { + "type": "object", + "properties": { + "systemAssigned": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Enables system assigned managed identity on the resource." + } + }, + "userAssignedResourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "metadata": { + "description": "Optional. The resource ID(s) to assign to the resource. Required if a user assigned identity is used for encryption." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a managed identity configuration. To be used if both a system-assigned & user-assigned identities are supported by the resource provider.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "managedIdentityOnlySysAssignedType": { + "type": "object", + "properties": { + "systemAssigned": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Enables system assigned managed identity on the resource." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a managed identity configuration. To be used if only system-assigned identities are supported by the resource provider.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "privateEndpointSingleServiceType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the Private Endpoint." + } + }, + "location": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The location to deploy the Private Endpoint to." + } + }, + "privateLinkServiceConnectionName": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the private link connection to create." + } + }, + "service": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The subresource to deploy the Private Endpoint for. For example \"vault\" for a Key Vault Private Endpoint." + } + }, + "subnetResourceId": { + "type": "string", + "metadata": { + "description": "Required. Resource ID of the subnet where the endpoint needs to be created." + } + }, + "resourceGroupResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The resource ID of the Resource Group the Private Endpoint will be created in. If not specified, the Resource Group of the provided Virtual Network Subnet is used." + } + }, + "privateDnsZoneGroup": { + "$ref": "#/definitions/_1.privateEndpointPrivateDnsZoneGroupType", + "nullable": true, + "metadata": { + "description": "Optional. The private DNS Zone Group to configure for the Private Endpoint." + } + }, + "isManualConnection": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. If Manual Private Link Connection is required." + } + }, + "manualConnectionRequestMessage": { + "type": "string", + "nullable": true, + "maxLength": 140, + "metadata": { + "description": "Optional. A message passed to the owner of the remote resource with the manual connection request." + } + }, + "customDnsConfigs": { + "type": "array", + "items": { + "$ref": "#/definitions/_1.privateEndpointCustomDnsConfigType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Custom DNS configurations." + } + }, + "ipConfigurations": { + "type": "array", + "items": { + "$ref": "#/definitions/_1.privateEndpointIpConfigurationType" + }, + "nullable": true, + "metadata": { + "description": "Optional. A list of IP configurations of the Private Endpoint. This will be used to map to the first-party Service endpoints." + } + }, + "applicationSecurityGroupResourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "metadata": { + "description": "Optional. Application security groups in which the Private Endpoint IP configuration is included." + } + }, + "customNetworkInterfaceName": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The custom name of the network interface attached to the Private Endpoint." + } + }, + "lock": { + "$ref": "#/definitions/lockType", + "nullable": true, + "metadata": { + "description": "Optional. Specify the type of lock." + } + }, + "roleAssignments": { + "type": "array", + "items": { + "$ref": "#/definitions/roleAssignmentType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Array of role assignments to create." + } + }, + "tags": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Tags to be applied on all resources/Resource Groups in this deployment." + } + }, + "enableTelemetry": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Enable/Disable usage telemetry for module." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a private endpoint. To be used if the private endpoint's default service / groupId can be assumed (i.e., for services that only have one Private Endpoint type like 'vault' for key vault).", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "roleAssignmentType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name (as GUID) of the role assignment. If not provided, a GUID will be generated." + } + }, + "roleDefinitionIdOrName": { + "type": "string", + "metadata": { + "description": "Required. The role to assign. You can provide either the display name of the role definition, the role definition GUID, or its fully qualified ID in the following format: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'." + } + }, + "principalId": { + "type": "string", + "metadata": { + "description": "Required. The principal ID of the principal (user/group/identity) to assign the role to." + } + }, + "principalType": { + "type": "string", + "allowedValues": [ + "Device", + "ForeignGroup", + "Group", + "ServicePrincipal", + "User" + ], + "nullable": true, + "metadata": { + "description": "Optional. The principal type of the assigned principal ID." + } + }, + "description": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The description of the role assignment." + } + }, + "condition": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The conditions on the role assignment. This limits the resources it can be assigned to. e.g.: @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:ContainerName] StringEqualsIgnoreCase \"foo_storage_container\"." + } + }, + "conditionVersion": { + "type": "string", + "allowedValues": [ + "2.0" + ], + "nullable": true, + "metadata": { + "description": "Optional. Version of the condition." + } + }, + "delegatedManagedIdentityResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The Resource Id of the delegated managed identity resource." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a role assignment.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + } + }, + "parameters": { + "name": { + "type": "string", + "minLength": 5, + "maxLength": 50, + "metadata": { + "description": "Required. Name of your Azure Container Registry." + } + }, + "acrAdminUserEnabled": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. Enable admin user that have push / pull permission to the registry." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Optional. Location for all resources." + } + }, + "roleAssignments": { + "type": "array", + "items": { + "$ref": "#/definitions/roleAssignmentType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Array of role assignments to create." + } + }, + "acrSku": { + "type": "string", + "defaultValue": "Premium", + "allowedValues": [ + "Basic", + "Premium", + "Standard" + ], + "metadata": { + "description": "Optional. Tier of your Azure container registry." + } + }, + "exportPolicyStatus": { + "type": "string", + "defaultValue": "disabled", + "allowedValues": [ + "disabled", + "enabled" + ], + "metadata": { + "description": "Optional. The value that indicates whether the export policy is enabled or not." + } + }, + "quarantinePolicyStatus": { + "type": "string", + "defaultValue": "disabled", + "allowedValues": [ + "disabled", + "enabled" + ], + "metadata": { + "description": "Optional. The value that indicates whether the quarantine policy is enabled or not. Note, requires the 'acrSku' to be 'Premium'." + } + }, + "trustPolicyStatus": { + "type": "string", + "defaultValue": "disabled", + "allowedValues": [ + "disabled", + "enabled" + ], + "metadata": { + "description": "Optional. The value that indicates whether the trust policy is enabled or not. Note, requires the 'acrSku' to be 'Premium'." + } + }, + "retentionPolicyStatus": { + "type": "string", + "defaultValue": "enabled", + "allowedValues": [ + "disabled", + "enabled" + ], + "metadata": { + "description": "Optional. The value that indicates whether the retention policy is enabled or not." + } + }, + "retentionPolicyDays": { + "type": "int", + "defaultValue": 15, + "metadata": { + "description": "Optional. The number of days to retain an untagged manifest after which it gets purged." + } + }, + "azureADAuthenticationAsArmPolicyStatus": { + "type": "string", + "defaultValue": "enabled", + "allowedValues": [ + "disabled", + "enabled" + ], + "metadata": { + "description": "Optional. The value that indicates whether the policy for using ARM audience token for a container registry is enabled or not. Default is enabled." + } + }, + "softDeletePolicyStatus": { + "type": "string", + "defaultValue": "disabled", + "allowedValues": [ + "disabled", + "enabled" + ], + "metadata": { + "description": "Optional. Soft Delete policy status. Default is disabled." + } + }, + "softDeletePolicyDays": { + "type": "int", + "defaultValue": 7, + "metadata": { + "description": "Optional. The number of days after which a soft-deleted item is permanently deleted." + } + }, + "dataEndpointEnabled": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. Enable a single data endpoint per region for serving data. Not relevant in case of disabled public access. Note, requires the 'acrSku' to be 'Premium'." + } + }, + "publicNetworkAccess": { + "type": "string", + "nullable": true, + "allowedValues": [ + "Enabled", + "Disabled" + ], + "metadata": { + "description": "Optional. Whether or not public network access is allowed for this resource. For security reasons it should be disabled. If not specified, it will be disabled by default if private endpoints are set and networkRuleSetIpRules are not set. Note, requires the 'acrSku' to be 'Premium'." + } + }, + "networkRuleBypassOptions": { + "type": "string", + "defaultValue": "AzureServices", + "allowedValues": [ + "AzureServices", + "None" + ], + "metadata": { + "description": "Optional. Whether to allow trusted Azure services to access a network restricted registry." + } + }, + "networkRuleSetDefaultAction": { + "type": "string", + "defaultValue": "Deny", + "allowedValues": [ + "Allow", + "Deny" + ], + "metadata": { + "description": "Optional. The default action of allow or deny when no other rules match." + } + }, + "networkRuleSetIpRules": { + "type": "array", + "nullable": true, + "metadata": { + "description": "Optional. The IP ACL rules. Note, requires the 'acrSku' to be 'Premium'." + } + }, + "privateEndpoints": { + "type": "array", + "items": { + "$ref": "#/definitions/privateEndpointSingleServiceType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Configuration details for private endpoints. For security reasons, it is recommended to use private endpoints whenever possible. Note, requires the 'acrSku' to be 'Premium'." + } + }, + "zoneRedundancy": { + "type": "string", + "defaultValue": "Enabled", + "allowedValues": [ + "Disabled", + "Enabled" + ], + "metadata": { + "description": "Optional. Whether or not zone redundancy is enabled for this container registry." + } + }, + "replications": { + "type": "array", + "items": { + "$ref": "#/definitions/replicationType" + }, + "nullable": true, + "metadata": { + "description": "Optional. All replications to create." + } + }, + "webhooks": { + "type": "array", + "items": { + "$ref": "#/definitions/webhookType" + }, + "nullable": true, + "metadata": { + "description": "Optional. All webhooks to create." + } + }, + "lock": { + "$ref": "#/definitions/lockType", + "nullable": true, + "metadata": { + "description": "Optional. The lock settings of the service." + } + }, + "managedIdentities": { + "$ref": "#/definitions/managedIdentityAllType", + "nullable": true, + "metadata": { + "description": "Optional. The managed identity definition for this resource." + } + }, + "tags": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Tags of the resource." + } + }, + "enableTelemetry": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Optional. Enable/Disable usage telemetry for module." + } + }, + "diagnosticSettings": { + "type": "array", + "items": { + "$ref": "#/definitions/diagnosticSettingFullType" + }, + "nullable": true, + "metadata": { + "description": "Optional. The diagnostic settings of the service." + } + }, + "anonymousPullEnabled": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. Enables registry-wide pull from unauthenticated clients. It's in preview and available in the Standard and Premium service tiers." + } + }, + "customerManagedKey": { + "$ref": "#/definitions/customerManagedKeyWithAutoRotateType", + "nullable": true, + "metadata": { + "description": "Optional. The customer managed key definition." + } + }, + "cacheRules": { + "type": "array", + "items": { + "$ref": "#/definitions/cacheRuleType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Array of Cache Rules." + } + }, + "credentialSets": { + "type": "array", + "items": { + "$ref": "#/definitions/credentialSetType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Array of Credential Sets." + } + }, + "scopeMaps": { + "type": "array", + "items": { + "$ref": "#/definitions/scopeMapsType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Scope maps setting." + } + } + }, + "variables": { + "copy": [ + { + "name": "formattedRoleAssignments", + "count": "[length(coalesce(parameters('roleAssignments'), createArray()))]", + "input": "[union(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')], createObject('roleDefinitionId', coalesce(tryGet(variables('builtInRoleNames'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName), if(contains(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, '/providers/Microsoft.Authorization/roleDefinitions/'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName)))))]" + } + ], + "formattedUserAssignedIdentities": "[reduce(map(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createArray()), lambda('id', createObject(format('{0}', lambdaVariables('id')), createObject()))), createObject(), lambda('cur', 'next', union(lambdaVariables('cur'), lambdaVariables('next'))))]", + "identity": "[if(not(empty(parameters('managedIdentities'))), createObject('type', if(coalesce(tryGet(parameters('managedIdentities'), 'systemAssigned'), false()), if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'SystemAssigned, UserAssigned', 'SystemAssigned'), if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'UserAssigned', 'None')), 'userAssignedIdentities', if(not(empty(variables('formattedUserAssignedIdentities'))), variables('formattedUserAssignedIdentities'), null())), null())]", + "builtInRoleNames": { + "AcrDelete": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'c2f4ef07-c644-48eb-af81-4b1b4947fb11')]", + "AcrImageSigner": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '6cef56e8-d556-48e5-a04f-b8e64114680f')]", + "AcrPull": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", + "AcrPush": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8311e382-0749-4cb8-b61a-304f252e45ec')]", + "AcrQuarantineReader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'cdda3590-29a3-44f6-95f2-9f980659eb04')]", + "AcrQuarantineWriter": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'c8d4ff99-41c3-41a8-9f60-21dfdad59608')]", + "Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", + "Owner": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635')]", + "Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", + "Role Based Access Control Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168')]", + "User Access Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9')]" + } + }, + "resources": { + "cMKKeyVault::cMKKey": { + "condition": "[and(not(empty(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId'))), and(not(empty(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId'))), not(empty(tryGet(parameters('customerManagedKey'), 'keyName')))))]", + "existing": true, + "type": "Microsoft.KeyVault/vaults/keys", + "apiVersion": "2023-02-01", + "subscriptionId": "[split(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId'), '/')[2]]", + "resourceGroup": "[split(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId'), '/')[4]]", + "name": "[format('{0}/{1}', last(split(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId'), '/')), tryGet(parameters('customerManagedKey'), 'keyName'))]" + }, + "avmTelemetry": { + "condition": "[parameters('enableTelemetry')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2024-03-01", + "name": "[format('46d3xbcp.res.containerregistry-registry.{0}.{1}', replace('0.9.0', '.', '-'), substring(uniqueString(deployment().name, parameters('location')), 0, 4))]", + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [], + "outputs": { + "telemetry": { + "type": "String", + "value": "For more information, see https://aka.ms/avm/TelemetryInfo" + } + } + } + } + }, + "cMKKeyVault": { + "condition": "[not(empty(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId')))]", + "existing": true, + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-02-01", + "subscriptionId": "[split(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId'), '/')[2]]", + "resourceGroup": "[split(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId'), '/')[4]]", + "name": "[last(split(tryGet(parameters('customerManagedKey'), 'keyVaultResourceId'), '/'))]" + }, + "cMKUserAssignedIdentity": { + "condition": "[not(empty(tryGet(parameters('customerManagedKey'), 'userAssignedIdentityResourceId')))]", + "existing": true, + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2023-01-31", + "subscriptionId": "[split(tryGet(parameters('customerManagedKey'), 'userAssignedIdentityResourceId'), '/')[2]]", + "resourceGroup": "[split(tryGet(parameters('customerManagedKey'), 'userAssignedIdentityResourceId'), '/')[4]]", + "name": "[last(split(tryGet(parameters('customerManagedKey'), 'userAssignedIdentityResourceId'), '/'))]" + }, + "registry": { + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-06-01-preview", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "identity": "[variables('identity')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "[parameters('acrSku')]" + }, + "properties": { + "anonymousPullEnabled": "[parameters('anonymousPullEnabled')]", + "adminUserEnabled": "[parameters('acrAdminUserEnabled')]", + "encryption": "[if(not(empty(parameters('customerManagedKey'))), createObject('status', 'enabled', 'keyVaultProperties', createObject('identity', if(not(empty(coalesce(tryGet(parameters('customerManagedKey'), 'userAssignedIdentityResourceId'), ''))), reference('cMKUserAssignedIdentity').clientId, null()), 'keyIdentifier', if(not(empty(tryGet(parameters('customerManagedKey'), 'keyVersion'))), format('{0}/{1}', reference('cMKKeyVault::cMKKey').keyUri, tryGet(parameters('customerManagedKey'), 'keyVersion')), if(coalesce(tryGet(parameters('customerManagedKey'), 'autoRotationEnabled'), true()), reference('cMKKeyVault::cMKKey').keyUri, reference('cMKKeyVault::cMKKey').keyUriWithVersion)))), null())]", + "policies": { + "azureADAuthenticationAsArmPolicy": { + "status": "[parameters('azureADAuthenticationAsArmPolicyStatus')]" + }, + "exportPolicy": "[if(equals(parameters('acrSku'), 'Premium'), createObject('status', parameters('exportPolicyStatus')), null())]", + "quarantinePolicy": "[if(equals(parameters('acrSku'), 'Premium'), createObject('status', parameters('quarantinePolicyStatus')), null())]", + "trustPolicy": "[if(equals(parameters('acrSku'), 'Premium'), createObject('type', 'Notary', 'status', parameters('trustPolicyStatus')), null())]", + "retentionPolicy": "[if(equals(parameters('acrSku'), 'Premium'), createObject('days', parameters('retentionPolicyDays'), 'status', parameters('retentionPolicyStatus')), null())]", + "softDeletePolicy": { + "retentionDays": "[parameters('softDeletePolicyDays')]", + "status": "[parameters('softDeletePolicyStatus')]" + } + }, + "dataEndpointEnabled": "[parameters('dataEndpointEnabled')]", + "publicNetworkAccess": "[if(not(empty(parameters('publicNetworkAccess'))), parameters('publicNetworkAccess'), if(and(not(empty(parameters('privateEndpoints'))), empty(parameters('networkRuleSetIpRules'))), 'Disabled', null()))]", + "networkRuleBypassOptions": "[parameters('networkRuleBypassOptions')]", + "networkRuleSet": "[if(not(empty(parameters('networkRuleSetIpRules'))), createObject('defaultAction', parameters('networkRuleSetDefaultAction'), 'ipRules', parameters('networkRuleSetIpRules')), null())]", + "zoneRedundancy": "[if(equals(parameters('acrSku'), 'Premium'), parameters('zoneRedundancy'), null())]" + }, + "dependsOn": [ + "cMKKeyVault::cMKKey", + "cMKUserAssignedIdentity" + ] + }, + "registry_lock": { + "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", + "type": "Microsoft.Authorization/locks", + "apiVersion": "2020-05-01", + "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', parameters('name'))]", + "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", + "properties": { + "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", + "notes": "[if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.')]" + }, + "dependsOn": [ + "registry" + ] + }, + "registry_diagnosticSettings": { + "copy": { + "name": "registry_diagnosticSettings", + "count": "[length(coalesce(parameters('diagnosticSettings'), createArray()))]" + }, + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', parameters('name'))]", + "name": "[coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'name'), format('{0}-diagnosticSettings', parameters('name')))]", + "properties": { + "copy": [ + { + "name": "metrics", + "count": "[length(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'metricCategories'), createArray(createObject('category', 'AllMetrics'))))]", + "input": { + "category": "[coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'metricCategories'), createArray(createObject('category', 'AllMetrics')))[copyIndex('metrics')].category]", + "enabled": "[coalesce(tryGet(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'metricCategories'), createArray(createObject('category', 'AllMetrics')))[copyIndex('metrics')], 'enabled'), true())]", + "timeGrain": null + } + }, + { + "name": "logs", + "count": "[length(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logCategoriesAndGroups'), createArray(createObject('categoryGroup', 'allLogs'))))]", + "input": { + "categoryGroup": "[tryGet(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logCategoriesAndGroups'), createArray(createObject('categoryGroup', 'allLogs')))[copyIndex('logs')], 'categoryGroup')]", + "category": "[tryGet(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logCategoriesAndGroups'), createArray(createObject('categoryGroup', 'allLogs')))[copyIndex('logs')], 'category')]", + "enabled": "[coalesce(tryGet(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logCategoriesAndGroups'), createArray(createObject('categoryGroup', 'allLogs')))[copyIndex('logs')], 'enabled'), true())]" + } + } + ], + "storageAccountId": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'storageAccountResourceId')]", + "workspaceId": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'workspaceResourceId')]", + "eventHubAuthorizationRuleId": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'eventHubAuthorizationRuleResourceId')]", + "eventHubName": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'eventHubName')]", + "marketplacePartnerId": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'marketplacePartnerResourceId')]", + "logAnalyticsDestinationType": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logAnalyticsDestinationType')]" + }, + "dependsOn": [ + "registry" + ] + }, + "registry_roleAssignments": { + "copy": { + "name": "registry_roleAssignments", + "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" + }, + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', parameters('name'))]", + "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", + "properties": { + "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", + "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", + "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", + "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", + "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", + "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", + "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" + }, + "dependsOn": [ + "registry" + ] + }, + "registry_scopeMaps": { + "copy": { + "name": "registry_scopeMaps", + "count": "[length(coalesce(parameters('scopeMaps'), createArray()))]" + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('{0}-Registry-Scope-{1}', uniqueString(deployment().name, parameters('location')), copyIndex())]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[tryGet(coalesce(parameters('scopeMaps'), createArray())[copyIndex()], 'name')]" + }, + "actions": { + "value": "[coalesce(parameters('scopeMaps'), createArray())[copyIndex()].actions]" + }, + "description": { + "value": "[tryGet(coalesce(parameters('scopeMaps'), createArray())[copyIndex()], 'description')]" + }, + "registryName": { + "value": "[parameters('name')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.33.93.31351", + "templateHash": "11112300500664950599" + }, + "name": "Container Registries scopeMaps", + "description": "This module deploys an Azure Container Registry (ACR) scopeMap." + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Conditional. The name of the parent registry. Required if the template is used in a standalone deployment." + } + }, + "name": { + "type": "string", + "defaultValue": "[format('{0}-scopemaps', parameters('registryName'))]", + "metadata": { + "description": "Optional. The name of the scope map." + } + }, + "actions": { + "type": "array", + "items": { + "type": "string" + }, + "metadata": { + "description": "Required. The list of scoped permissions for registry artifacts." + } + }, + "description": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The user friendly description of the scope map." + } + } + }, + "resources": { + "registry": { + "existing": true, + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-06-01-preview", + "name": "[parameters('registryName')]" + }, + "scopeMap": { + "type": "Microsoft.ContainerRegistry/registries/scopeMaps", + "apiVersion": "2023-06-01-preview", + "name": "[format('{0}/{1}', parameters('registryName'), parameters('name'))]", + "properties": { + "actions": "[parameters('actions')]", + "description": "[parameters('description')]" + } + } + }, + "outputs": { + "name": { + "type": "string", + "metadata": { + "description": "The name of the scope map." + }, + "value": "[parameters('name')]" + }, + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The name of the resource group the scope map was created in." + }, + "value": "[resourceGroup().name]" + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the scope map." + }, + "value": "[resourceId('Microsoft.ContainerRegistry/registries/scopeMaps', parameters('registryName'), parameters('name'))]" + } + } + } + }, + "dependsOn": [ + "registry" + ] + }, + "registry_replications": { + "copy": { + "name": "registry_replications", + "count": "[length(coalesce(parameters('replications'), createArray()))]" + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('{0}-Registry-Replication-{1}', uniqueString(deployment().name, parameters('location')), copyIndex())]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[coalesce(parameters('replications'), createArray())[copyIndex()].name]" + }, + "registryName": { + "value": "[parameters('name')]" + }, + "location": { + "value": "[coalesce(parameters('replications'), createArray())[copyIndex()].location]" + }, + "regionEndpointEnabled": { + "value": "[tryGet(coalesce(parameters('replications'), createArray())[copyIndex()], 'regionEndpointEnabled')]" + }, + "zoneRedundancy": { + "value": "[tryGet(coalesce(parameters('replications'), createArray())[copyIndex()], 'zoneRedundancy')]" + }, + "tags": { + "value": "[coalesce(tryGet(coalesce(parameters('replications'), createArray())[copyIndex()], 'tags'), parameters('tags'))]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.33.93.31351", + "templateHash": "6036875058945996178" + }, + "name": "Azure Container Registry (ACR) Replications", + "description": "This module deploys an Azure Container Registry (ACR) Replication." + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Conditional. The name of the parent registry. Required if the template is used in a standalone deployment." + } + }, + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the replication." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Optional. Location for all resources." + } + }, + "tags": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Tags of the resource." + } + }, + "regionEndpointEnabled": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Optional. Specifies whether the replication regional endpoint is enabled. Requests will not be routed to a replication whose regional endpoint is disabled, however its data will continue to be synced with other replications." + } + }, + "zoneRedundancy": { + "type": "string", + "defaultValue": "Disabled", + "allowedValues": [ + "Disabled", + "Enabled" + ], + "metadata": { + "description": "Optional. Whether or not zone redundancy is enabled for this container registry." + } + } + }, + "resources": { + "registry": { + "existing": true, + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-06-01-preview", + "name": "[parameters('registryName')]" + }, + "replication": { + "type": "Microsoft.ContainerRegistry/registries/replications", + "apiVersion": "2023-06-01-preview", + "name": "[format('{0}/{1}', parameters('registryName'), parameters('name'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "regionEndpointEnabled": "[parameters('regionEndpointEnabled')]", + "zoneRedundancy": "[parameters('zoneRedundancy')]" + } + } + }, + "outputs": { + "name": { + "type": "string", + "metadata": { + "description": "The name of the replication." + }, + "value": "[parameters('name')]" + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the replication." + }, + "value": "[resourceId('Microsoft.ContainerRegistry/registries/replications', parameters('registryName'), parameters('name'))]" + }, + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The name of the resource group the replication was created in." + }, + "value": "[resourceGroup().name]" + }, + "location": { + "type": "string", + "metadata": { + "description": "The location the resource was deployed into." + }, + "value": "[reference('replication', '2023-06-01-preview', 'full').location]" + } + } + } + }, + "dependsOn": [ + "registry" + ] + }, + "registry_credentialSets": { + "copy": { + "name": "registry_credentialSets", + "count": "[length(coalesce(parameters('credentialSets'), createArray()))]" + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('{0}-Registry-CredentialSet-{1}', uniqueString(deployment().name, parameters('location')), copyIndex())]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[coalesce(parameters('credentialSets'), createArray())[copyIndex()].name]" + }, + "registryName": { + "value": "[parameters('name')]" + }, + "managedIdentities": { + "value": "[coalesce(parameters('credentialSets'), createArray())[copyIndex()].managedIdentities]" + }, + "authCredentials": { + "value": "[coalesce(parameters('credentialSets'), createArray())[copyIndex()].authCredentials]" + }, + "loginServer": { + "value": "[coalesce(parameters('credentialSets'), createArray())[copyIndex()].loginServer]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.33.93.31351", + "templateHash": "15848218260506856293" + }, + "name": "Container Registries Credential Sets", + "description": "This module deploys an ACR Credential Set." + }, + "definitions": { + "authCredentialsType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the credential." + } + }, + "usernameSecretIdentifier": { + "type": "string", + "metadata": { + "description": "Required. KeyVault Secret URI for accessing the username." + } + }, + "passwordSecretIdentifier": { + "type": "string", + "metadata": { + "description": "Required. KeyVault Secret URI for accessing the password." + } + } + }, + "metadata": { + "__bicep_export!": true, + "description": "The type for auth credentials." + } + }, + "managedIdentityOnlySysAssignedType": { + "type": "object", + "properties": { + "systemAssigned": { + "type": "bool", + "nullable": true, + "metadata": { + "description": "Optional. Enables system assigned managed identity on the resource." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a managed identity configuration. To be used if only system-assigned identities are supported by the resource provider.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + } + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Conditional. The name of the parent registry. Required if the template is used in a standalone deployment." + } + }, + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the credential set." + } + }, + "managedIdentities": { + "$ref": "#/definitions/managedIdentityOnlySysAssignedType", + "nullable": true, + "metadata": { + "description": "Optional. The managed identity definition for this resource." + } + }, + "authCredentials": { + "type": "array", + "items": { + "$ref": "#/definitions/authCredentialsType" + }, + "metadata": { + "description": "Required. List of authentication credentials stored for an upstream. Usually consists of a primary and an optional secondary credential." + } + }, + "loginServer": { + "type": "string", + "metadata": { + "description": "Required. The credentials are stored for this upstream or login server." + } + } + }, + "variables": { + "identity": "[if(not(empty(parameters('managedIdentities'))), createObject('type', if(coalesce(tryGet(parameters('managedIdentities'), 'systemAssigned'), false()), 'SystemAssigned', null())), null())]" + }, + "resources": { + "registry": { + "existing": true, + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-06-01-preview", + "name": "[parameters('registryName')]" + }, + "credentialSet": { + "type": "Microsoft.ContainerRegistry/registries/credentialSets", + "apiVersion": "2023-11-01-preview", + "name": "[format('{0}/{1}', parameters('registryName'), parameters('name'))]", + "identity": "[variables('identity')]", + "properties": { + "authCredentials": "[parameters('authCredentials')]", + "loginServer": "[parameters('loginServer')]" + } + } + }, + "outputs": { + "name": { + "type": "string", + "metadata": { + "description": "The Name of the Credential Set." + }, + "value": "[parameters('name')]" + }, + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The name of the Credential Set." + }, + "value": "[resourceGroup().name]" + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the Credential Set." + }, + "value": "[resourceId('Microsoft.ContainerRegistry/registries/credentialSets', parameters('registryName'), parameters('name'))]" + }, + "systemAssignedMIPrincipalId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "The principal ID of the system assigned identity." + }, + "value": "[tryGet(tryGet(reference('credentialSet', '2023-11-01-preview', 'full'), 'identity'), 'principalId')]" + } + } + } + }, + "dependsOn": [ + "registry" + ] + }, + "registry_cacheRules": { + "copy": { + "name": "registry_cacheRules", + "count": "[length(coalesce(parameters('cacheRules'), createArray()))]" + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('{0}-Registry-Cache-{1}', uniqueString(deployment().name, parameters('location')), copyIndex())]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "registryName": { + "value": "[parameters('name')]" + }, + "sourceRepository": { + "value": "[coalesce(parameters('cacheRules'), createArray())[copyIndex()].sourceRepository]" + }, + "name": { + "value": "[tryGet(coalesce(parameters('cacheRules'), createArray())[copyIndex()], 'name')]" + }, + "targetRepository": { + "value": "[coalesce(tryGet(coalesce(parameters('cacheRules'), createArray())[copyIndex()], 'targetRepository'), coalesce(parameters('cacheRules'), createArray())[copyIndex()].sourceRepository)]" + }, + "credentialSetResourceId": { + "value": "[tryGet(coalesce(parameters('cacheRules'), createArray())[copyIndex()], 'credentialSetResourceId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.33.93.31351", + "templateHash": "3783697279882479947" + }, + "name": "Container Registries Cache", + "description": "Cache for Azure Container Registry (Preview) feature allows users to cache container images in a private container registry. Cache for ACR, is a preview feature available in Basic, Standard, and Premium service tiers ([ref](https://learn.microsoft.com/en-us/azure/container-registry/tutorial-registry-cache))." + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Conditional. The name of the parent registry. Required if the template is used in a standalone deployment." + } + }, + "name": { + "type": "string", + "defaultValue": "[replace(replace(replace(parameters('sourceRepository'), '/', '-'), '.', '-'), '*', '')]", + "metadata": { + "description": "Optional. The name of the cache rule. Will be derived from the source repository name if not defined." + } + }, + "sourceRepository": { + "type": "string", + "metadata": { + "description": "Required. Source repository pulled from upstream." + } + }, + "targetRepository": { + "type": "string", + "defaultValue": "[parameters('sourceRepository')]", + "metadata": { + "description": "Optional. Target repository specified in docker pull command. E.g.: docker pull myregistry.azurecr.io/{targetRepository}:{tag}." + } + }, + "credentialSetResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The resource ID of the credential store which is associated with the cache rule." + } + } + }, + "resources": { + "registry": { + "existing": true, + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-06-01-preview", + "name": "[parameters('registryName')]" + }, + "cacheRule": { + "type": "Microsoft.ContainerRegistry/registries/cacheRules", + "apiVersion": "2023-06-01-preview", + "name": "[format('{0}/{1}', parameters('registryName'), parameters('name'))]", + "properties": { + "sourceRepository": "[parameters('sourceRepository')]", + "targetRepository": "[parameters('targetRepository')]", + "credentialSetResourceId": "[parameters('credentialSetResourceId')]" + } + } + }, + "outputs": { + "name": { + "type": "string", + "metadata": { + "description": "The Name of the Cache Rule." + }, + "value": "[parameters('name')]" + }, + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The name of the Cache Rule." + }, + "value": "[resourceGroup().name]" + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the Cache Rule." + }, + "value": "[resourceId('Microsoft.ContainerRegistry/registries/cacheRules', parameters('registryName'), parameters('name'))]" + } + } + } + }, + "dependsOn": [ + "registry", + "registry_credentialSets" + ] + }, + "registry_webhooks": { + "copy": { + "name": "registry_webhooks", + "count": "[length(coalesce(parameters('webhooks'), createArray()))]" + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('{0}-Registry-Webhook-{1}', uniqueString(deployment().name, parameters('location')), copyIndex())]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[coalesce(parameters('webhooks'), createArray())[copyIndex()].name]" + }, + "registryName": { + "value": "[parameters('name')]" + }, + "location": { + "value": "[coalesce(tryGet(coalesce(parameters('webhooks'), createArray())[copyIndex()], 'location'), parameters('location'))]" + }, + "action": { + "value": "[tryGet(coalesce(parameters('webhooks'), createArray())[copyIndex()], 'action')]" + }, + "customHeaders": { + "value": "[tryGet(coalesce(parameters('webhooks'), createArray())[copyIndex()], 'customHeaders')]" + }, + "scope": { + "value": "[tryGet(coalesce(parameters('webhooks'), createArray())[copyIndex()], 'scope')]" + }, + "status": { + "value": "[tryGet(coalesce(parameters('webhooks'), createArray())[copyIndex()], 'status')]" + }, + "serviceUri": { + "value": "[coalesce(parameters('webhooks'), createArray())[copyIndex()].serviceUri]" + }, + "tags": { + "value": "[coalesce(tryGet(coalesce(parameters('webhooks'), createArray())[copyIndex()], 'tags'), parameters('tags'))]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.33.93.31351", + "templateHash": "10084997815751263562" + }, + "name": "Azure Container Registry (ACR) Webhooks", + "description": "This module deploys an Azure Container Registry (ACR) Webhook." + }, + "parameters": { + "registryName": { + "type": "string", + "metadata": { + "description": "Conditional. The name of the parent registry. Required if the template is used in a standalone deployment." + } + }, + "name": { + "type": "string", + "defaultValue": "[format('{0}webhook', parameters('registryName'))]", + "minLength": 5, + "maxLength": 50, + "metadata": { + "description": "Optional. The name of the registry webhook." + } + }, + "serviceUri": { + "type": "string", + "metadata": { + "description": "Required. The service URI for the webhook to post notifications." + } + }, + "status": { + "type": "string", + "defaultValue": "enabled", + "allowedValues": [ + "disabled", + "enabled" + ], + "metadata": { + "description": "Optional. The status of the webhook at the time the operation was called." + } + }, + "action": { + "type": "array", + "items": { + "type": "string" + }, + "defaultValue": [ + "chart_delete", + "chart_push", + "delete", + "push", + "quarantine" + ], + "metadata": { + "description": "Optional. The list of actions that trigger the webhook to post notifications." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Optional. Location for all resources." + } + }, + "tags": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Tags of the resource." + } + }, + "customHeaders": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Custom headers that will be added to the webhook notifications." + } + }, + "scope": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The scope of repositories where the event can be triggered. For example, 'foo:*' means events for all tags under repository 'foo'. 'foo:bar' means events for 'foo:bar' only. 'foo' is equivalent to 'foo:latest'. Empty means all events." + } + } + }, + "resources": { + "registry": { + "existing": true, + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-06-01-preview", + "name": "[parameters('registryName')]" + }, + "webhook": { + "type": "Microsoft.ContainerRegistry/registries/webhooks", + "apiVersion": "2023-06-01-preview", + "name": "[format('{0}/{1}', parameters('registryName'), parameters('name'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "actions": "[parameters('action')]", + "customHeaders": "[parameters('customHeaders')]", + "scope": "[parameters('scope')]", + "serviceUri": "[parameters('serviceUri')]", + "status": "[parameters('status')]" + } + } + }, + "outputs": { + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the webhook." + }, + "value": "[resourceId('Microsoft.ContainerRegistry/registries/webhooks', parameters('registryName'), parameters('name'))]" + }, + "name": { + "type": "string", + "metadata": { + "description": "The name of the webhook." + }, + "value": "[parameters('name')]" + }, + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The name of the Azure container registry." + }, + "value": "[resourceGroup().name]" + }, + "actions": { + "type": "array", + "metadata": { + "description": "The actions of the webhook." + }, + "value": "[reference('webhook').actions]" + }, + "status": { + "type": "string", + "metadata": { + "description": "The status of the webhook." + }, + "value": "[reference('webhook').status]" + }, + "provistioningState": { + "type": "string", + "metadata": { + "description": "The provisioning state of the webhook." + }, + "value": "[reference('webhook').provisioningState]" + }, + "location": { + "type": "string", + "metadata": { + "description": "The location the resource was deployed into." + }, + "value": "[reference('webhook', '2023-06-01-preview', 'full').location]" + } + } + } + }, + "dependsOn": [ + "registry" + ] + }, + "registry_privateEndpoints": { + "copy": { + "name": "registry_privateEndpoints", + "count": "[length(coalesce(parameters('privateEndpoints'), createArray()))]" + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('{0}-registry-PrivateEndpoint-{1}', uniqueString(deployment().name, parameters('location')), copyIndex())]", + "subscriptionId": "[split(coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'resourceGroupResourceId'), resourceGroup().id), '/')[2]]", + "resourceGroup": "[split(coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'resourceGroupResourceId'), resourceGroup().id), '/')[4]]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'name'), format('pep-{0}-{1}-{2}', last(split(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '/')), coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'service'), 'registry'), copyIndex()))]" + }, + "privateLinkServiceConnections": "[if(not(equals(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'isManualConnection'), true())), createObject('value', createArray(createObject('name', coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'privateLinkServiceConnectionName'), format('{0}-{1}-{2}', last(split(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '/')), coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'service'), 'registry'), copyIndex())), 'properties', createObject('privateLinkServiceId', resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), 'groupIds', createArray(coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'service'), 'registry')))))), createObject('value', null()))]", + "manualPrivateLinkServiceConnections": "[if(equals(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'isManualConnection'), true()), createObject('value', createArray(createObject('name', coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'privateLinkServiceConnectionName'), format('{0}-{1}-{2}', last(split(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '/')), coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'service'), 'registry'), copyIndex())), 'properties', createObject('privateLinkServiceId', resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), 'groupIds', createArray(coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'service'), 'registry')), 'requestMessage', coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'manualConnectionRequestMessage'), 'Manual approval required.'))))), createObject('value', null()))]", + "subnetResourceId": { + "value": "[coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].subnetResourceId]" + }, + "enableTelemetry": { + "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'enableTelemetry'), parameters('enableTelemetry'))]" + }, + "location": { + "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'location'), reference(split(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].subnetResourceId, '/subnets/')[0], '2020-06-01', 'Full').location)]" + }, + "lock": { + "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'lock'), parameters('lock'))]" + }, + "privateDnsZoneGroup": { + "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'privateDnsZoneGroup')]" + }, + "roleAssignments": { + "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'roleAssignments')]" + }, + "tags": { + "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'tags'), parameters('tags'))]" + }, + "customDnsConfigs": { + "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'customDnsConfigs')]" + }, + "ipConfigurations": { + "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'ipConfigurations')]" + }, + "applicationSecurityGroupResourceIds": { + "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'applicationSecurityGroupResourceIds')]" + }, + "customNetworkInterfaceName": { + "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'customNetworkInterfaceName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.33.13.18514", + "templateHash": "15954548978129725136" + }, + "name": "Private Endpoints", + "description": "This module deploys a Private Endpoint." + }, + "definitions": { + "privateDnsZoneGroupType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the Private DNS Zone Group." + } + }, + "privateDnsZoneGroupConfigs": { + "type": "array", + "items": { + "$ref": "#/definitions/privateDnsZoneGroupConfigType" + }, + "metadata": { + "description": "Required. The private DNS zone groups to associate the private endpoint. A DNS zone group can support up to 5 DNS zones." + } + } + }, + "metadata": { + "__bicep_export!": true + } + }, + "ipConfigurationType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the resource that is unique within a resource group." + } + }, + "properties": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "metadata": { + "description": "Required. The ID of a group obtained from the remote resource that this private endpoint should connect to. If used with private link service connection, this property must be defined as empty string." + } + }, + "memberName": { + "type": "string", + "metadata": { + "description": "Required. The member name of a group obtained from the remote resource that this private endpoint should connect to. If used with private link service connection, this property must be defined as empty string." + } + }, + "privateIPAddress": { + "type": "string", + "metadata": { + "description": "Required. A private IP address obtained from the private endpoint's subnet." + } + } + }, + "metadata": { + "description": "Required. Properties of private endpoint IP configurations." + } + } + }, + "metadata": { + "__bicep_export!": true + } + }, + "privateLinkServiceConnectionType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "metadata": { + "description": "Required. The name of the private link service connection." + } + }, + "properties": { + "type": "object", + "properties": { + "groupIds": { + "type": "array", + "items": { + "type": "string" + }, + "metadata": { + "description": "Required. The ID of a group obtained from the remote resource that this private endpoint should connect to. If used with private link service connection, this property must be defined as empty string array `[]`." + } + }, + "privateLinkServiceId": { + "type": "string", + "metadata": { + "description": "Required. The resource id of private link service." + } + }, + "requestMessage": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. A message passed to the owner of the remote resource with this connection request. Restricted to 140 chars." + } + } + }, + "metadata": { + "description": "Required. Properties of private link service connection." + } + } + }, + "metadata": { + "__bicep_export!": true + } + }, + "customDnsConfigType": { + "type": "object", + "properties": { + "fqdn": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. FQDN that resolves to private endpoint IP address." + } + }, + "ipAddresses": { + "type": "array", + "items": { + "type": "string" + }, + "metadata": { + "description": "Required. A list of private IP addresses of the private endpoint." + } + } + }, + "metadata": { + "__bicep_export!": true + } + }, + "lockType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. Specify the name of lock." + } + }, + "kind": { + "type": "string", + "allowedValues": [ + "CanNotDelete", + "None", + "ReadOnly" + ], + "nullable": true, + "metadata": { + "description": "Optional. Specify the type of lock." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a lock.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + }, + "privateDnsZoneGroupConfigType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the private DNS zone group config." + } + }, + "privateDnsZoneResourceId": { + "type": "string", + "metadata": { + "description": "Required. The resource id of the private DNS zone." + } + } + }, + "metadata": { + "__bicep_imported_from!": { + "sourceTemplate": "private-dns-zone-group/main.bicep" + } + } + }, + "roleAssignmentType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name (as GUID) of the role assignment. If not provided, a GUID will be generated." + } + }, + "roleDefinitionIdOrName": { + "type": "string", + "metadata": { + "description": "Required. The role to assign. You can provide either the display name of the role definition, the role definition GUID, or its fully qualified ID in the following format: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'." + } + }, + "principalId": { + "type": "string", + "metadata": { + "description": "Required. The principal ID of the principal (user/group/identity) to assign the role to." + } + }, + "principalType": { + "type": "string", + "allowedValues": [ + "Device", + "ForeignGroup", + "Group", + "ServicePrincipal", + "User" + ], + "nullable": true, + "metadata": { + "description": "Optional. The principal type of the assigned principal ID." + } + }, + "description": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The description of the role assignment." + } + }, + "condition": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The conditions on the role assignment. This limits the resources it can be assigned to. e.g.: @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:ContainerName] StringEqualsIgnoreCase \"foo_storage_container\"." + } + }, + "conditionVersion": { + "type": "string", + "allowedValues": [ + "2.0" + ], + "nullable": true, + "metadata": { + "description": "Optional. Version of the condition." + } + }, + "delegatedManagedIdentityResourceId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The Resource Id of the delegated managed identity resource." + } + } + }, + "metadata": { + "description": "An AVM-aligned type for a role assignment.", + "__bicep_imported_from!": { + "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.5.1" + } + } + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Required. Name of the private endpoint resource to create." + } + }, + "subnetResourceId": { + "type": "string", + "metadata": { + "description": "Required. Resource ID of the subnet where the endpoint needs to be created." + } + }, + "applicationSecurityGroupResourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "metadata": { + "description": "Optional. Application security groups in which the private endpoint IP configuration is included." + } + }, + "customNetworkInterfaceName": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The custom name of the network interface attached to the private endpoint." + } + }, + "ipConfigurations": { + "type": "array", + "items": { + "$ref": "#/definitions/ipConfigurationType" + }, + "nullable": true, + "metadata": { + "description": "Optional. A list of IP configurations of the private endpoint. This will be used to map to the First Party Service endpoints." + } + }, + "privateDnsZoneGroup": { + "$ref": "#/definitions/privateDnsZoneGroupType", + "nullable": true, + "metadata": { + "description": "Optional. The private DNS zone group to configure for the private endpoint." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Optional. Location for all Resources." + } + }, + "lock": { + "$ref": "#/definitions/lockType", + "nullable": true, + "metadata": { + "description": "Optional. The lock settings of the service." + } + }, + "roleAssignments": { + "type": "array", + "items": { + "$ref": "#/definitions/roleAssignmentType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Array of role assignments to create." + } + }, + "tags": { + "type": "object", + "nullable": true, + "metadata": { + "description": "Optional. Tags to be applied on all resources/resource groups in this deployment." + } + }, + "customDnsConfigs": { + "type": "array", + "items": { + "$ref": "#/definitions/customDnsConfigType" + }, + "nullable": true, + "metadata": { + "description": "Optional. Custom DNS configurations." + } + }, + "manualPrivateLinkServiceConnections": { + "type": "array", + "items": { + "$ref": "#/definitions/privateLinkServiceConnectionType" + }, + "nullable": true, + "metadata": { + "description": "Conditional. A grouping of information about the connection to the remote resource. Used when the network admin does not have access to approve connections to the remote resource. Required if `privateLinkServiceConnections` is empty." + } + }, + "privateLinkServiceConnections": { + "type": "array", + "items": { + "$ref": "#/definitions/privateLinkServiceConnectionType" + }, + "nullable": true, + "metadata": { + "description": "Conditional. A grouping of information about the connection to the remote resource. Required if `manualPrivateLinkServiceConnections` is empty." + } + }, + "enableTelemetry": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Optional. Enable/Disable usage telemetry for module." + } + } + }, + "variables": { + "copy": [ + { + "name": "formattedRoleAssignments", + "count": "[length(coalesce(parameters('roleAssignments'), createArray()))]", + "input": "[union(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')], createObject('roleDefinitionId', coalesce(tryGet(variables('builtInRoleNames'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName), if(contains(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, '/providers/Microsoft.Authorization/roleDefinitions/'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName)))))]" + } + ], + "builtInRoleNames": { + "Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", + "DNS Resolver Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0f2ebee7-ffd4-4fc0-b3b7-664099fdad5d')]", + "DNS Zone Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'befefa01-2a29-4197-83a8-272ff33ce314')]", + "Domain Services Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'eeaeda52-9324-47f6-8069-5d5bade478b2')]", + "Domain Services Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '361898ef-9ed1-48c2-849c-a832951106bb')]", + "Network Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4d97b98b-1d4f-4787-a291-c67834d212e7')]", + "Owner": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635')]", + "Private DNS Zone Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b12aa53e-6015-4669-85d0-8515ebb3ae7f')]", + "Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", + "Role Based Access Control Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168')]" + } + }, + "resources": { + "avmTelemetry": { + "condition": "[parameters('enableTelemetry')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2024-03-01", + "name": "[format('46d3xbcp.res.network-privateendpoint.{0}.{1}', replace('0.10.1', '.', '-'), substring(uniqueString(deployment().name, parameters('location')), 0, 4))]", + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [], + "outputs": { + "telemetry": { + "type": "String", + "value": "For more information, see https://aka.ms/avm/TelemetryInfo" + } + } + } + } + }, + "privateEndpoint": { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2023-11-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "copy": [ + { + "name": "applicationSecurityGroups", + "count": "[length(coalesce(parameters('applicationSecurityGroupResourceIds'), createArray()))]", + "input": { + "id": "[coalesce(parameters('applicationSecurityGroupResourceIds'), createArray())[copyIndex('applicationSecurityGroups')]]" + } + } + ], + "customDnsConfigs": "[coalesce(parameters('customDnsConfigs'), createArray())]", + "customNetworkInterfaceName": "[coalesce(parameters('customNetworkInterfaceName'), '')]", + "ipConfigurations": "[coalesce(parameters('ipConfigurations'), createArray())]", + "manualPrivateLinkServiceConnections": "[coalesce(parameters('manualPrivateLinkServiceConnections'), createArray())]", + "privateLinkServiceConnections": "[coalesce(parameters('privateLinkServiceConnections'), createArray())]", + "subnet": { + "id": "[parameters('subnetResourceId')]" + } + } + }, + "privateEndpoint_lock": { + "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", + "type": "Microsoft.Authorization/locks", + "apiVersion": "2020-05-01", + "scope": "[format('Microsoft.Network/privateEndpoints/{0}', parameters('name'))]", + "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", + "properties": { + "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", + "notes": "[if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.')]" + }, + "dependsOn": [ + "privateEndpoint" + ] + }, + "privateEndpoint_roleAssignments": { + "copy": { + "name": "privateEndpoint_roleAssignments", + "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" + }, + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Network/privateEndpoints/{0}', parameters('name'))]", + "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.Network/privateEndpoints', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", + "properties": { + "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", + "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", + "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", + "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", + "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", + "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", + "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" + }, + "dependsOn": [ + "privateEndpoint" + ] + }, + "privateEndpoint_privateDnsZoneGroup": { + "condition": "[not(empty(parameters('privateDnsZoneGroup')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('{0}-PrivateEndpoint-PrivateDnsZoneGroup', uniqueString(deployment().name))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[tryGet(parameters('privateDnsZoneGroup'), 'name')]" + }, + "privateEndpointName": { + "value": "[parameters('name')]" + }, + "privateDnsZoneConfigs": { + "value": "[parameters('privateDnsZoneGroup').privateDnsZoneGroupConfigs]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.33.13.18514", + "templateHash": "5440815542537978381" + }, + "name": "Private Endpoint Private DNS Zone Groups", + "description": "This module deploys a Private Endpoint Private DNS Zone Group." + }, + "definitions": { + "privateDnsZoneGroupConfigType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "metadata": { + "description": "Optional. The name of the private DNS zone group config." + } + }, + "privateDnsZoneResourceId": { + "type": "string", + "metadata": { + "description": "Required. The resource id of the private DNS zone." + } + } + }, + "metadata": { + "__bicep_export!": true + } + } + }, + "parameters": { + "privateEndpointName": { + "type": "string", + "metadata": { + "description": "Conditional. The name of the parent private endpoint. Required if the template is used in a standalone deployment." + } + }, + "privateDnsZoneConfigs": { + "type": "array", + "items": { + "$ref": "#/definitions/privateDnsZoneGroupConfigType" + }, + "minLength": 1, + "maxLength": 5, + "metadata": { + "description": "Required. Array of private DNS zone configurations of the private DNS zone group. A DNS zone group can support up to 5 DNS zones." + } + }, + "name": { + "type": "string", + "defaultValue": "default", + "metadata": { + "description": "Optional. The name of the private DNS zone group." + } + } + }, + "variables": { + "copy": [ + { + "name": "privateDnsZoneConfigsVar", + "count": "[length(parameters('privateDnsZoneConfigs'))]", + "input": { + "name": "[coalesce(tryGet(parameters('privateDnsZoneConfigs')[copyIndex('privateDnsZoneConfigsVar')], 'name'), last(split(parameters('privateDnsZoneConfigs')[copyIndex('privateDnsZoneConfigsVar')].privateDnsZoneResourceId, '/')))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneConfigs')[copyIndex('privateDnsZoneConfigsVar')].privateDnsZoneResourceId]" + } + } + } + ] + }, + "resources": { + "privateEndpoint": { + "existing": true, + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2023-11-01", + "name": "[parameters('privateEndpointName')]" + }, + "privateDnsZoneGroup": { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2023-11-01", + "name": "[format('{0}/{1}', parameters('privateEndpointName'), parameters('name'))]", + "properties": { + "privateDnsZoneConfigs": "[variables('privateDnsZoneConfigsVar')]" + } + } + }, + "outputs": { + "name": { + "type": "string", + "metadata": { + "description": "The name of the private endpoint DNS zone group." + }, + "value": "[parameters('name')]" + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the private endpoint DNS zone group." + }, + "value": "[resourceId('Microsoft.Network/privateEndpoints/privateDnsZoneGroups', parameters('privateEndpointName'), parameters('name'))]" + }, + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The resource group the private endpoint DNS zone group was deployed into." + }, + "value": "[resourceGroup().name]" + } + } + } + }, + "dependsOn": [ + "privateEndpoint" + ] + } + }, + "outputs": { + "resourceGroupName": { + "type": "string", + "metadata": { + "description": "The resource group the private endpoint was deployed into." + }, + "value": "[resourceGroup().name]" + }, + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the private endpoint." + }, + "value": "[resourceId('Microsoft.Network/privateEndpoints', parameters('name'))]" + }, + "name": { + "type": "string", + "metadata": { + "description": "The name of the private endpoint." + }, + "value": "[parameters('name')]" + }, + "location": { + "type": "string", + "metadata": { + "description": "The location the resource was deployed into." + }, + "value": "[reference('privateEndpoint', '2023-11-01', 'full').location]" + }, + "customDnsConfigs": { + "type": "array", + "items": { + "$ref": "#/definitions/customDnsConfigType" + }, + "metadata": { + "description": "The custom DNS configurations of the private endpoint." + }, + "value": "[reference('privateEndpoint').customDnsConfigs]" + }, + "networkInterfaceResourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "metadata": { + "description": "The resource IDs of the network interfaces associated with the private endpoint." + }, + "value": "[map(reference('privateEndpoint').networkInterfaces, lambda('nic', lambdaVariables('nic').id))]" + }, + "groupId": { + "type": "string", + "nullable": true, + "metadata": { + "description": "The group Id for the private endpoint Group." + }, + "value": "[coalesce(tryGet(tryGet(tryGet(tryGet(reference('privateEndpoint'), 'manualPrivateLinkServiceConnections'), 0, 'properties'), 'groupIds'), 0), tryGet(tryGet(tryGet(tryGet(reference('privateEndpoint'), 'privateLinkServiceConnections'), 0, 'properties'), 'groupIds'), 0))]" + } + } + } + }, + "dependsOn": [ + "registry", + "registry_replications" + ] + } }, - "parameters": { - "userAssignedIdentityName": { + "outputs": { + "name": { "type": "string", "metadata": { - "description": "Conditional. The name of the parent user assigned identity. Required if the template is used in a standalone deployment." - } + "description": "The Name of the Azure container registry." + }, + "value": "[parameters('name')]" }, - "name": { + "loginServer": { "type": "string", "metadata": { - "description": "Required. The name of the secret." - } + "description": "The reference to the Azure container registry." + }, + "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2019-05-01').loginServer]" }, - "audiences": { - "type": "array", + "resourceGroupName": { + "type": "string", "metadata": { - "description": "Required. The list of audiences that can appear in the issued token. Should be set to api://AzureADTokenExchange for Azure AD. It says what Microsoft identity platform should accept in the aud claim in the incoming token. This value represents Azure AD in your external identity provider and has no fixed value across identity providers - you might need to create a new application registration in your IdP to serve as the audience of this token." - } + "description": "The name of the Azure container registry." + }, + "value": "[resourceGroup().name]" }, - "issuer": { + "resourceId": { "type": "string", "metadata": { - "description": "Required. The URL of the issuer to be trusted. Must match the issuer claim of the external token being exchanged." - } + "description": "The resource ID of the Azure container registry." + }, + "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" }, - "subject": { + "systemAssignedMIPrincipalId": { "type": "string", + "nullable": true, "metadata": { - "description": "Required. The identifier of the external software workload within the external identity provider. Like the audience value, it has no fixed format, as each IdP uses their own - sometimes a GUID, sometimes a colon delimited identifier, sometimes arbitrary strings. The value here must match the sub claim within the token presented to Azure AD." - } - } - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials", - "apiVersion": "2024-11-30", - "name": "[format('{0}/{1}', parameters('userAssignedIdentityName'), parameters('name'))]", - "properties": { - "audiences": "[parameters('audiences')]", - "issuer": "[parameters('issuer')]", - "subject": "[parameters('subject')]" - } - } - ], - "outputs": { - "name": { + "description": "The principal ID of the system assigned identity." + }, + "value": "[tryGet(tryGet(reference('registry', '2023-06-01-preview', 'full'), 'identity'), 'principalId')]" + }, + "location": { "type": "string", "metadata": { - "description": "The name of the federated identity credential." + "description": "The location the resource was deployed into." }, - "value": "[parameters('name')]" + "value": "[reference('registry', '2023-06-01-preview', 'full').location]" }, - "resourceId": { - "type": "string", + "credentialSetsSystemAssignedMIPrincipalIds": { + "type": "array", "metadata": { - "description": "The resource ID of the federated identity credential." + "description": "The Principal IDs of the ACR Credential Sets system-assigned identities." }, - "value": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials', parameters('userAssignedIdentityName'), parameters('name'))]" + "copy": { + "count": "[length(range(0, length(coalesce(parameters('credentialSets'), createArray()))))]", + "input": "[tryGet(tryGet(reference(format('registry_credentialSets[{0}]', range(0, length(coalesce(parameters('credentialSets'), createArray())))[copyIndex()])).outputs, 'systemAssignedMIPrincipalId'), 'value')]" + } }, - "resourceGroupName": { - "type": "string", + "credentialSetsResourceIds": { + "type": "array", "metadata": { - "description": "The name of the resource group the federated identity credential was created in." + "description": "The Resource IDs of the ACR Credential Sets." }, - "value": "[resourceGroup().name]" + "copy": { + "count": "[length(range(0, length(coalesce(parameters('credentialSets'), createArray()))))]", + "input": "[reference(format('registry_credentialSets[{0}]', range(0, length(coalesce(parameters('credentialSets'), createArray())))[copyIndex()])).outputs.resourceId.value]" + } + }, + "privateEndpoints": { + "type": "array", + "items": { + "$ref": "#/definitions/privateEndpointOutputType" + }, + "metadata": { + "description": "The private endpoints of the Azure container registry." + }, + "copy": { + "count": "[length(coalesce(parameters('privateEndpoints'), createArray()))]", + "input": { + "name": "[reference(format('registry_privateEndpoints[{0}]', copyIndex())).outputs.name.value]", + "resourceId": "[reference(format('registry_privateEndpoints[{0}]', copyIndex())).outputs.resourceId.value]", + "groupId": "[tryGet(tryGet(reference(format('registry_privateEndpoints[{0}]', copyIndex())).outputs, 'groupId'), 'value')]", + "customDnsConfigs": "[reference(format('registry_privateEndpoints[{0}]', copyIndex())).outputs.customDnsConfigs.value]", + "networkInterfaceResourceIds": "[reference(format('registry_privateEndpoints[{0}]', copyIndex())).outputs.networkInterfaceResourceIds.value]" + } + } } } } - }, - "dependsOn": [ - "userAssignedIdentity" - ] + } } }, "outputs": { "name": { "type": "string", "metadata": { - "description": "The name of the user assigned identity." + "description": "The name of the Container Registry." }, - "value": "[parameters('name')]" + "value": "[reference('containerRegistry').outputs.name.value]" }, "resourceId": { "type": "string", "metadata": { - "description": "The resource ID of the user assigned identity." - }, - "value": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('name'))]" - }, - "principalId": { - "type": "string", - "metadata": { - "description": "The principal ID (object ID) of the user assigned identity." - }, - "value": "[reference('userAssignedIdentity').principalId]" - }, - "clientId": { - "type": "string", - "metadata": { - "description": "The client ID (application ID) of the user assigned identity." + "description": "The resource ID of the Container Registry." }, - "value": "[reference('userAssignedIdentity').clientId]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The resource group the user assigned identity was deployed into." - }, - "value": "[resourceGroup().name]" + "value": "[reference('containerRegistry').outputs.resourceId.value]" }, - "location": { + "loginServer": { "type": "string", "metadata": { - "description": "The location the resource was deployed into." + "description": "The login server URL of the Container Registry." }, - "value": "[reference('userAssignedIdentity', '2024-11-30', 'full').location]" + "value": "[reference('containerRegistry').outputs.loginServer.value]" } } } - } + }, + "dependsOn": [ + "userAssignedIdentity" + ] }, "virtualNetwork": { "condition": "[parameters('enablePrivateNetworking')]", @@ -43567,15 +46827,17 @@ }, "siteConfig": { "value": { - "linuxFxVersion": "[format('DOCKER|{0}.azurecr.io/content-gen-app:{1}', variables('acrResourceName'), parameters('imageTag'))]", + "linuxFxVersion": "DOCKER|mcr.microsoft.com/azuredocs/aci-helloworld:latest", "minTlsVersion": "1.2", "alwaysOn": true, - "ftpsState": "FtpsOnly" + "ftpsState": "FtpsOnly", + "acrUseManagedIdentityCreds": true, + "acrUserManagedIdentityID": "[reference('userAssignedIdentity').outputs.clientId.value]" } }, "virtualNetworkSubnetId": "[if(parameters('enablePrivateNetworking'), createObject('value', reference('virtualNetwork').outputs.webSubnetResourceId.value), createObject('value', null()))]", "configs": { - "value": "[concat(createArray(createObject('name', 'appsettings', 'properties', createObject('DOCKER_REGISTRY_SERVER_URL', format('https://{0}.azurecr.io', variables('acrResourceName')), 'BACKEND_URL', if(parameters('enablePrivateNetworking'), format('http://{0}:8000', reference('containerInstance').outputs.ipAddress.value), format('http://{0}:8000', reference('containerInstance').outputs.fqdn.value)), 'AZURE_CLIENT_ID', reference('userAssignedIdentity').outputs.clientId.value), 'applicationInsightResourceId', if(parameters('enableMonitoring'), reference('applicationInsights').outputs.resourceId.value, null()))), if(parameters('enableMonitoring'), createArray(createObject('name', 'logs', 'properties', createObject())), createArray()))]" + "value": "[concat(createArray(createObject('name', 'appsettings', 'properties', createObject('DOCKER_REGISTRY_SERVER_URL', format('https://{0}', reference('containerRegistry').outputs.loginServer.value), 'BACKEND_URL', if(parameters('enablePrivateNetworking'), format('http://{0}:8000', reference('containerInstance').outputs.ipAddress.value), format('http://{0}:8000', reference('containerInstance').outputs.fqdn.value)), 'AZURE_CLIENT_ID', reference('userAssignedIdentity').outputs.clientId.value), 'applicationInsightResourceId', if(parameters('enableMonitoring'), reference('applicationInsights').outputs.resourceId.value, null()))), if(parameters('enableMonitoring'), createArray(createObject('name', 'logs', 'properties', createObject())), createArray()))]" }, "enableMonitoring": { "value": "[parameters('enableMonitoring')]" @@ -45529,6 +48791,7 @@ "dependsOn": [ "applicationInsights", "containerInstance", + "containerRegistry", "logAnalyticsWorkspace", "userAssignedIdentity", "virtualNetwork", @@ -45555,7 +48818,7 @@ "value": "[parameters('tags')]" }, "containerImage": { - "value": "[format('{0}.azurecr.io/content-gen-api:{1}', variables('acrResourceName'), parameters('imageTag'))]" + "value": "mcr.microsoft.com/azuredocs/aci-helloworld:latest" }, "cpu": { "value": 2 @@ -45570,6 +48833,9 @@ "userAssignedIdentityResourceId": { "value": "[reference('userAssignedIdentity').outputs.resourceId.value]" }, + "acrLoginServer": { + "value": "[reference('containerRegistry').outputs.loginServer.value]" + }, "enableTelemetry": { "value": "[parameters('enableTelemetry')]" }, @@ -45693,7 +48959,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "3218681246740688998" + "templateHash": "9581263247326965160" } }, "parameters": { @@ -45768,6 +49034,13 @@ "metadata": { "description": "Required. User-assigned managed identity resource ID for ACR pull." } + }, + "acrLoginServer": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional. ACR login server (e.g. myregistry.azurecr.io). When set, the container group pulls the image using the user-assigned managed identity." + } } }, "variables": { @@ -45834,7 +49107,8 @@ } ], "dnsNameLabel": "[if(variables('isPrivateNetworking'), null(), parameters('name'))]" - } + }, + "imageRegistryCredentials": "[if(not(empty(parameters('acrLoginServer'))), createArray(createObject('server', parameters('acrLoginServer'), 'identity', parameters('userAssignedIdentityResourceId'))), null())]" } } ], @@ -45873,6 +49147,7 @@ "dependsOn": [ "aiFoundryAiServicesProject", "applicationInsights", + "containerRegistry", "userAssignedIdentity", "virtualNetwork" ] @@ -46073,7 +49348,7 @@ "metadata": { "description": "Contains ACR Name" }, - "value": "[variables('acrResourceName')]" + "value": "[reference('containerRegistry').outputs.name.value]" }, "USE_FOUNDRY": { "type": "bool", diff --git a/infra/main.parameters.json b/infra/main.parameters.json index b830e8365..a673863b5 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -40,12 +40,6 @@ }, "azureExistingAIProjectResourceId": { "value": "${AZURE_EXISTING_AIPROJECT_RESOURCE_ID}" - }, - "acrName": { - "value": "${AZURE_ENV_CONTAINER_REGISTRY_NAME}" - }, - "imageTag": { - "value": "${AZURE_ENV_IMAGE_TAG=latest}" } } } diff --git a/infra/main.waf.parameters.json b/infra/main.waf.parameters.json index e4ec5e0c5..6e1c85cf6 100644 --- a/infra/main.waf.parameters.json +++ b/infra/main.waf.parameters.json @@ -41,12 +41,6 @@ "azureExistingAIProjectResourceId": { "value": "${AZURE_EXISTING_AIPROJECT_RESOURCE_ID}" }, - "acrName": { - "value": "${AZURE_ENV_CONTAINER_REGISTRY_NAME}" - }, - "imageTag": { - "value": "${AZURE_ENV_IMAGE_TAG=latest}" - }, "enablePrivateNetworking": { "value": true }, diff --git a/infra/modules/container-instance.bicep b/infra/modules/container-instance.bicep index 9a6767a76..68600f2e5 100644 --- a/infra/modules/container-instance.bicep +++ b/infra/modules/container-instance.bicep @@ -34,6 +34,9 @@ param enableTelemetry bool = true @description('Required. User-assigned managed identity resource ID for ACR pull.') param userAssignedIdentityResourceId string +@description('Optional. ACR login server (e.g. myregistry.azurecr.io). When set, the container group pulls the image using the user-assigned managed identity.') +param acrLoginServer string = '' + var isPrivateNetworking = !empty(subnetResourceId) // ============== // @@ -102,8 +105,16 @@ resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2025-09-01' ] dnsNameLabel: isPrivateNetworking ? null : name } - // Removed imageRegistryCredentials - ACR is public with anonymous pull enabled - // If you need managed identity auth, add AcrPull role to the managed identity on the ACR + // Managed-identity based ACR authentication. Configured up-front so that when + // the placeholder image is later replaced with the private ACR image, the + // container group can authenticate to the registry using the user-assigned + // identity (which holds AcrPull). Unused while running a public image. + imageRegistryCredentials: !empty(acrLoginServer) ? [ + { + server: acrLoginServer + identity: userAssignedIdentityResourceId + } + ] : null } } diff --git a/infra/modules/container-registry.bicep b/infra/modules/container-registry.bicep new file mode 100644 index 000000000..de3cee4a9 --- /dev/null +++ b/infra/modules/container-registry.bicep @@ -0,0 +1,91 @@ +// ========== container-registry.bicep ========== // +// Azure Container Registry module. +// Provisions an ACR and grants AcrPull to the frontend/backend identities so the +// App Service and Container Instance can pull application images once they are +// built and pushed by a post-deployment step. + +@description('Required. Name of the Azure Container Registry.') +param name string + +@description('Optional. Location for the Container Registry.') +param location string = resourceGroup().location + +@description('Optional. Tags for all resources.') +param tags object = {} + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +@description('Optional. SKU for the Container Registry.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param acrSku string = 'Standard' + +@description('Optional. Principal IDs (frontend/backend managed identities) that require AcrPull access.') +param pullPrincipalIds array = [] + +@description('Optional. Principal IDs (e.g. the deployer) that require AcrPush access to build and push images.') +param pushPrincipalIds array = [] + +@description('Optional. Principal type for the AcrPush role assignments.') +@allowed([ + 'User' + 'Group' + 'ServicePrincipal' +]) +param pushPrincipalType string = 'User' + +import { managedIdentityAllType } from 'br/public:avm/utl/types/avm-common-types:0.7.0' +@description('Optional. The managed identity definition for this resource.') +param managedIdentities managedIdentityAllType? + +// AcrPull role: allows the App Service / Container Instance identities to pull images. +var acrPullRoleDefinitionId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' +// AcrPush role: allows the deployer to build and push images to the registry. +var acrPushRoleDefinitionId = '8311e382-0749-4cb8-b61a-304f252e45ec' + +var pullRoleAssignments = [ + for principalId in pullPrincipalIds: { + principalId: principalId + roleDefinitionIdOrName: acrPullRoleDefinitionId + principalType: 'ServicePrincipal' + } +] + +var pushRoleAssignments = [ + for principalId in pushPrincipalIds: { + principalId: principalId + roleDefinitionIdOrName: acrPushRoleDefinitionId + principalType: pushPrincipalType + } +] + +// ========== Azure Container Registry ========== // +module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.0' = { + name: take('avm.res.container-registry.registry.${name}', 64) + params: { + name: name + location: location + tags: tags + enableTelemetry: enableTelemetry + acrSku: acrSku + acrAdminUserEnabled: false + anonymousPullEnabled: false + publicNetworkAccess: 'Enabled' + networkRuleBypassOptions: 'AzureServices' + roleAssignments: concat(pullRoleAssignments, pushRoleAssignments) + managedIdentities: managedIdentities + } +} + +@description('The name of the Container Registry.') +output name string = containerRegistry.outputs.name + +@description('The resource ID of the Container Registry.') +output resourceId string = containerRegistry.outputs.resourceId + +@description('The login server URL of the Container Registry.') +output loginServer string = containerRegistry.outputs.loginServer From bc5a90af6487b202190806a78c2816d617a69997 Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Thu, 2 Jul 2026 12:59:43 +0530 Subject: [PATCH 03/12] Implement build and deployment scripts for ACR integration --- azure.yaml | 5 +- infra/scripts/build_and_deploy_images.ps1 | 121 ++++++++++++++++++++++ infra/scripts/build_and_deploy_images.sh | 116 +++++++++++++++++++++ infra/scripts/process_sample_data.ps1 | 40 +++++++ infra/scripts/process_sample_data.sh | 40 +++++++ 5 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 infra/scripts/build_and_deploy_images.ps1 create mode 100644 infra/scripts/build_and_deploy_images.sh create mode 100644 infra/scripts/process_sample_data.ps1 create mode 100644 infra/scripts/process_sample_data.sh diff --git a/azure.yaml b/azure.yaml index c677295b5..832e0fffb 100644 --- a/azure.yaml +++ b/azure.yaml @@ -60,7 +60,7 @@ hooks: Write-Host " 1. Build and push the application images to ACR, then update the web app and container instance:" -ForegroundColor Yellow Write-Host " ./infra/scripts/build_and_deploy_images.ps1" -ForegroundColor Cyan Write-Host " 2. Load the sample data into the application (run only after the previous script run has completed):" -ForegroundColor Yellow - Write-Host " python ./scripts/post_deploy.py --skip-tests" -ForegroundColor Cyan + Write-Host " ./infra/scripts/process_sample_data.ps1" -ForegroundColor Cyan Write-Host "" Write-Host "Web app URL: " -NoNewline @@ -74,8 +74,9 @@ hooks: echo "Run the following two scripts, in order:" echo " 1. Build and push the application images to ACR, then update the web app and container instance:" echo " bash ./infra/scripts/build_and_deploy_images.sh" + echo " " echo " 2. Load the sample data into the application (run only after the previous script run has completed):" - echo " python ./scripts/post_deploy.py --skip-tests" + echo " bash ./infra/scripts/process_sample_data.sh" echo "" echo "Web app URL: $WEB_APP_URL" diff --git a/infra/scripts/build_and_deploy_images.ps1 b/infra/scripts/build_and_deploy_images.ps1 new file mode 100644 index 000000000..1b7dd743b --- /dev/null +++ b/infra/scripts/build_and_deploy_images.ps1 @@ -0,0 +1,121 @@ +<# +.SYNOPSIS + Builds the frontend and backend images remotely in ACR (az acr build - no + local Docker needed), pushes them to the ACR created by `azd up`, updates the + frontend App Service to the new image and restarts it, then recreates the + backend Container Instance on the new image. + +.DESCRIPTION + Run AFTER `azd up`. Configuration is read from the azd environment. +#> + +$ErrorActionPreference = 'Stop' + +function Invoke-Az { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$AzArgs) + & az @AzArgs + if ($LASTEXITCODE -ne 0) { throw "az $($AzArgs -join ' ') failed with exit code $LASTEXITCODE" } +} + +# Load azd environment outputs into the process environment. +if (Get-Command azd -ErrorAction SilentlyContinue) { + foreach ($line in (azd env get-values 2>$null)) { + if ($line -match '^\s*([A-Za-z0-9_]+)="?(.*?)"?\s*$') { + Set-Item -Path "Env:$($Matches[1])" -Value $Matches[2] + } + } +} + +$AcrName = $env:AZURE_ENV_CONTAINER_REGISTRY_NAME +$ResourceGroup = $env:RESOURCE_GROUP_NAME +$AppService = $env:APP_SERVICE_NAME +$ContainerGroup = $env:CONTAINER_INSTANCE_NAME + +$FrontendImage = 'content-gen-app' +$BackendImage = 'content-gen-api' +$Tag = 'latest' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path + +if (-not $AcrName -or -not $ResourceGroup -or -not $AppService -or -not $ContainerGroup) { + throw "Missing required azd environment values (AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME)." +} + +$AcrLoginServer = "$AcrName.azurecr.io" + +# ===== Build & push frontend image ===== +Write-Host "===== Building Frontend Image ($FrontendImage`:$Tag) =====" -ForegroundColor Yellow +Invoke-Az acr build --registry $AcrName --image "$FrontendImage`:$Tag" ` + --file (Join-Path $RepoRoot 'src\App\WebApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\App') + +# ===== Build & push backend image ===== +Write-Host "===== Building Backend Image ($BackendImage`:$Tag) =====" -ForegroundColor Yellow +Invoke-Az acr build --registry $AcrName --image "$BackendImage`:$Tag" ` + --file (Join-Path $RepoRoot 'src\backend\ApiApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\backend') + +# ===== Update frontend App Service image (managed-identity ACR pull) ===== +Write-Host "===== Updating Frontend App Service ($AppService) =====" -ForegroundColor Yellow +$UamiRid = az webapp identity show -g $ResourceGroup -n $AppService --query "userAssignedIdentities | keys(@) | [0]" -o tsv +$ClientId = az identity show --ids $UamiRid --query clientId -o tsv + +Invoke-Az webapp config container set -g $ResourceGroup -n $AppService ` + --container-image-name "$AcrLoginServer/$FrontendImage`:$Tag" ` + --container-registry-url "https://$AcrLoginServer" --output none + --only-show-errors | Out-Null + +$WebappId = az webapp show -g $ResourceGroup -n $AppService --query id -o tsv +# Invoke-Az resource update --ids "$WebappId/config/web" ` +# --set properties.acrUseManagedIdentityCreds=true ` +# --set properties.acrUserManagedIdentityID=$ClientId --output none + +Write-Host "Restarting Frontend App Service..." -ForegroundColor Yellow +Invoke-Az webapp restart -g $ResourceGroup -n $AppService --output none + +# ===== Recreate backend Container Instance with the new image ===== +# ACI cannot update a container group's image in place, and a restart reuses the +# originally deployed (hello-world) image. So we capture the existing container +# group's configuration and recreate it pointing at the new ACR image. +Write-Host "===== Recreating Backend Container Instance ($ContainerGroup) =====" -ForegroundColor Yellow + +$Aci = az container show -g $ResourceGroup -n $ContainerGroup -o json | ConvertFrom-Json +if (-not $Aci) { throw "Could not read existing container group '$ContainerGroup'." } + +$AciCpu = $Aci.containers[0].resources.requests.cpu +$AciMemory = $Aci.containers[0].resources.requests.memoryInGb +$AciPort = $Aci.containers[0].ports[0].port +$AciOsType = $Aci.osType +$AciRestart = $Aci.restartPolicy +$AciDnsLabel = $Aci.ipAddress.dnsNameLabel +$AciSubnetId = if ($Aci.subnetIds) { $Aci.subnetIds[0].id } else { $null } +$AciUami = @($Aci.identity.userAssignedIdentities.PSObject.Properties.Name)[0] +$AciEnvVars = $Aci.containers[0].environmentVariables | ForEach-Object { "$($_.name)=$($_.value)" } + +$CreateArgs = @( + 'container', 'create', + '--resource-group', $ResourceGroup, + '--name', $ContainerGroup, + '--image', "$AcrLoginServer/$BackendImage`:$Tag", + '--cpu', $AciCpu, + '--memory', $AciMemory, + '--ports', $AciPort, + '--os-type', $AciOsType, + '--restart-policy', $AciRestart, + '--assign-identity', $AciUami, + '--acr-identity', $AciUami, + '--registry-login-server', $AcrLoginServer +) +if ($AciSubnetId) { + $CreateArgs += @('--subnet', $AciSubnetId) +} else { + $CreateArgs += @('--ip-address', 'Public', '--dns-name-label', $AciDnsLabel) +} +$CreateArgs += '--environment-variables' +$CreateArgs += $AciEnvVars + +Invoke-Az @CreateArgs --output none + +Write-Host "" +Write-Host "===== Done =====" -ForegroundColor Green +Write-Host "Frontend image: $AcrLoginServer/$FrontendImage`:$Tag" -ForegroundColor Cyan +Write-Host "Backend image: $AcrLoginServer/$BackendImage`:$Tag" -ForegroundColor Cyan diff --git a/infra/scripts/build_and_deploy_images.sh b/infra/scripts/build_and_deploy_images.sh new file mode 100644 index 000000000..55b3416ab --- /dev/null +++ b/infra/scripts/build_and_deploy_images.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -euo pipefail + +# ============================================================================ +# build_and_deploy_images.sh +# ---------------------------------------------------------------------------- +# Builds the frontend and backend images remotely in ACR (az acr build - no +# local Docker needed), pushes them to the ACR created by `azd up`, updates the +# frontend App Service to the new image and restarts it, then recreates the +# backend Container Instance on the new image. +# +# Run AFTER `azd up`. Configuration is read from the azd environment. +# ============================================================================ + +# Load azd environment outputs into the shell. +if command -v azd >/dev/null 2>&1; then + eval "$(azd env get-values 2>/dev/null | sed 's/^/export /')" +fi + +ACR_NAME="${AZURE_ENV_CONTAINER_REGISTRY_NAME:-}" +RESOURCE_GROUP="${RESOURCE_GROUP_NAME:-}" +APP_SERVICE="${APP_SERVICE_NAME:-}" +CONTAINER_GROUP="${CONTAINER_INSTANCE_NAME:-}" + +FRONTEND_IMAGE="content-gen-app" +BACKEND_IMAGE="content-gen-api" +TAG="latest" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ] || [ -z "${APP_SERVICE}" ] || [ -z "${CONTAINER_GROUP}" ]; then + echo "ERROR: Missing required azd environment values (AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME)." >&2 + exit 1 +fi + +ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io" + +# ===== Build & push frontend image ===== +echo "===== Building Frontend Image (${FRONTEND_IMAGE}:${TAG}) =====" +az acr build --registry "${ACR_NAME}" --image "${FRONTEND_IMAGE}:${TAG}" \ + --file "${REPO_ROOT}/src/App/WebApp.Dockerfile" --platform linux "${REPO_ROOT}/src/App" + +# ===== Build & push backend image ===== +echo "===== Building Backend Image (${BACKEND_IMAGE}:${TAG}) =====" +az acr build --registry "${ACR_NAME}" --image "${BACKEND_IMAGE}:${TAG}" \ + --file "${REPO_ROOT}/src/backend/ApiApp.Dockerfile" --platform linux "${REPO_ROOT}/src/backend" + +# ===== Update frontend App Service image (managed-identity ACR pull) ===== +echo "===== Updating Frontend App Service (${APP_SERVICE}) =====" +UAMI_RID="$(az webapp identity show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --query "userAssignedIdentities | keys(@) | [0]" -o tsv)" +CLIENT_ID="$(az identity show --ids "${UAMI_RID}" --query clientId -o tsv)" + +az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --container-image-name "${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" \ + --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none + +WEBAPP_ID="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" +# az resource update --ids "${WEBAPP_ID}/config/web" \ +# --set properties.acrUseManagedIdentityCreds=true \ +# --set properties.acrUserManagedIdentityID="${CLIENT_ID}" -o none + +echo "Restarting Frontend App Service..." +az webapp restart -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" -o none + +# ===== Recreate backend Container Instance with the new image ===== +# ACI cannot update a container group's image in place, and a restart reuses the +# originally deployed (hello-world) image. So we capture the existing container +# group's configuration and recreate it pointing at the new ACR image. +echo "===== Recreating Backend Container Instance (${CONTAINER_GROUP}) =====" + +ACI_CPU="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.cpu" -o tsv)" +ACI_MEMORY="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.memoryInGb" -o tsv)" +ACI_PORT="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].ports[0].port" -o tsv)" +ACI_OS_TYPE="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "osType" -o tsv)" +ACI_RESTART="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "restartPolicy" -o tsv)" +ACI_DNS_LABEL="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.dnsNameLabel" -o tsv)" +ACI_SUBNET_ID="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "subnetIds[0].id" -o tsv 2>/dev/null || true)" +ACI_UAMI="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv)" + +# Capture env vars as NAME=VALUE (handles values containing '=' such as the +# Application Insights connection string, since az create splits on the first '='). +ENV_VARS=() +while IFS= read -r line; do + [ -n "${line}" ] && ENV_VARS+=("${line}") +done < <(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" \ + --query "containers[0].environmentVariables[].join('=', [name, value])" -o tsv) + +CREATE_ARGS=( + container create + --resource-group "${RESOURCE_GROUP}" + --name "${CONTAINER_GROUP}" + --image "${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" + --cpu "${ACI_CPU}" + --memory "${ACI_MEMORY}" + --ports "${ACI_PORT}" + --os-type "${ACI_OS_TYPE}" + --restart-policy "${ACI_RESTART}" + --assign-identity "${ACI_UAMI}" + --acr-identity "${ACI_UAMI}" + --registry-login-server "${ACR_LOGIN_SERVER}" +) +if [ -n "${ACI_SUBNET_ID}" ]; then + CREATE_ARGS+=(--subnet "${ACI_SUBNET_ID}") +else + CREATE_ARGS+=(--ip-address Public --dns-name-label "${ACI_DNS_LABEL}") +fi +CREATE_ARGS+=(--environment-variables "${ENV_VARS[@]}") + +az "${CREATE_ARGS[@]}" -o none + +echo "" +echo "===== Done =====" +echo "Frontend image: ${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" +echo "Backend image: ${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" diff --git a/infra/scripts/process_sample_data.ps1 b/infra/scripts/process_sample_data.ps1 new file mode 100644 index 000000000..eff25f6a6 --- /dev/null +++ b/infra/scripts/process_sample_data.ps1 @@ -0,0 +1,40 @@ +<# +.SYNOPSIS + Installs the post-deploy Python dependency and runs scripts/post_deploy.py to + load the sample data into the deployed application. + +.DESCRIPTION + Run AFTER build_and_deploy_images.ps1 has completed (the application images + must be built, pushed and running first). Configuration is read from the azd + environment. +#> + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path + +# Load azd environment outputs into the process environment. +if (Get-Command azd -ErrorAction SilentlyContinue) { + foreach ($line in (azd env get-values 2>$null)) { + if ($line -match '^\s*([A-Za-z0-9_]+)="?(.*?)"?\s*$') { + Set-Item -Path "Env:$($Matches[1])" -Value $Matches[2] + } + } +} + +# Resolve the Python executable (python on Windows, python3 on some systems). +$python = (Get-Command python -ErrorAction SilentlyContinue) ?? (Get-Command python3 -ErrorAction SilentlyContinue) +if (-not $python) { throw "Python is not installed or not on PATH." } +$python = $python.Source + +Write-Host "===== Installing post-deploy dependencies =====" -ForegroundColor Yellow +& $python -m pip install -r (Join-Path $RepoRoot 'scripts\requirements-post-deploy.txt') --quiet | Out-Null +if ($LASTEXITCODE -ne 0) { throw "pip install failed with exit code $LASTEXITCODE" } + +Write-Host "===== Loading sample data =====" -ForegroundColor Yellow +& $python (Join-Path $RepoRoot 'scripts\post_deploy.py') --skip-tests +if ($LASTEXITCODE -ne 0) { throw "post_deploy.py failed with exit code $LASTEXITCODE" } + +Write-Host "" +Write-Host "===== Done =====" -ForegroundColor Green diff --git a/infra/scripts/process_sample_data.sh b/infra/scripts/process_sample_data.sh new file mode 100644 index 000000000..3fdfb8da6 --- /dev/null +++ b/infra/scripts/process_sample_data.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -euo pipefail + +# ============================================================================ +# process_sample_data.sh +# ---------------------------------------------------------------------------- +# Installs the post-deploy Python dependency and runs scripts/post_deploy.py to +# load the sample data into the deployed application. +# +# Run AFTER build_and_deploy_images.sh has completed (the application images +# must be built, pushed and running first). Configuration is read from the azd +# environment. +# ============================================================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +# Load azd environment outputs into the shell. +if command -v azd >/dev/null 2>&1; then + eval "$(azd env get-values 2>/dev/null | sed 's/^/export /')" +fi + +# Resolve the Python executable (python3 preferred, fall back to python). +if command -v python3 >/dev/null 2>&1; then + PYTHON="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON="python" +else + echo "ERROR: Python is not installed or not on PATH." >&2 + exit 1 +fi + +echo "===== Installing post-deploy dependencies =====" +"${PYTHON}" -m pip install -r "${REPO_ROOT}/scripts/requirements-post-deploy.txt" --quiet + +echo "===== Loading sample data =====" +"${PYTHON}" "${REPO_ROOT}/scripts/post_deploy.py" --skip-tests + +echo "" +echo "===== Done =====" From 9117e8d40f79634a5b0214eb00daf62078e95d0d Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Fri, 3 Jul 2026 10:39:03 +0530 Subject: [PATCH 04/12] updated the infra for ACR waf conditions --- infra/main.bicep | 7 +++ infra/main.json | 75 +++++++++++++++++++++----- infra/modules/container-registry.bicep | 43 +++++++++++++-- 3 files changed, 110 insertions(+), 15 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 4f621a38f..c4acf93f0 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -376,6 +376,10 @@ module containerRegistry 'modules/container-registry.bicep' = { tags: tags enableTelemetry: enableTelemetry acrSku: 'Standard' + enablePrivateNetworking: enablePrivateNetworking + enableScalability: enableScalability + privateEndpointSubnetResourceId: enablePrivateNetworking ? virtualNetwork!.outputs.pepsSubnetResourceId : '' + privateDnsZoneResourceId: enablePrivateNetworking ? avmPrivateDnsZones[dnsZoneIndex.containerRegistry]!.outputs.resourceId : '' managedIdentities: { userAssignedResourceIds: [ userAssignedIdentity.outputs.resourceId @@ -567,11 +571,13 @@ module jumpboxDcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if // - OpenAI (for Azure OpenAI endpoints) // - Blob Storage // - Cosmos DB (Documents) +// - Container Registry (for private image pulls) var privateDnsZones = [ 'privatelink.cognitiveservices.azure.com' 'privatelink.openai.azure.com' 'privatelink.blob.${environment().suffixes.storage}' 'privatelink.documents.azure.com' + 'privatelink.azurecr.io' ] var dnsZoneIndex = { @@ -579,6 +585,7 @@ var dnsZoneIndex = { openAI: 1 storageBlob: 2 cosmosDB: 3 + containerRegistry: 4 } @batchSize(5) diff --git a/infra/main.json b/infra/main.json index 2e01ea149..f5e77fd89 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "18281771994413307619" + "templateHash": "12340397001946101697" }, "name": "Intelligent Content Generation Accelerator", "description": "Solution Accelerator for multimodal marketing content generation using Microsoft Agent Framework.\n" @@ -344,13 +344,15 @@ "privatelink.cognitiveservices.azure.com", "privatelink.openai.azure.com", "[format('privatelink.blob.{0}', environment().suffixes.storage)]", - "privatelink.documents.azure.com" + "privatelink.documents.azure.com", + "privatelink.azurecr.io" ], "dnsZoneIndex": { "cognitiveServices": 0, "openAI": 1, "storageBlob": 2, - "cosmosDB": 3 + "cosmosDB": 3, + "containerRegistry": 4 }, "storageAccountName": "[format('st{0}', variables('solutionSuffix'))]", "productImagesContainer": "product-images", @@ -4817,6 +4819,14 @@ "acrSku": { "value": "Standard" }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + }, + "enableScalability": { + "value": "[parameters('enableScalability')]" + }, + "privateEndpointSubnetResourceId": "[if(parameters('enablePrivateNetworking'), createObject('value', reference('virtualNetwork').outputs.pepsSubnetResourceId.value), createObject('value', ''))]", + "privateDnsZoneResourceId": "[if(parameters('enablePrivateNetworking'), createObject('value', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').containerRegistry)).outputs.resourceId.value), createObject('value', ''))]", "managedIdentities": { "value": { "userAssignedResourceIds": [ @@ -4846,7 +4856,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "8532463912224257721" + "templateHash": "553009202159475069" } }, "definitions": { @@ -4945,6 +4955,34 @@ "description": "Optional. Principal type for the AcrPush role assignments." } }, + "enablePrivateNetworking": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. Enable private networking. Forces the Premium SKU, disables public network access and creates a private endpoint for the registry." + } + }, + "enableScalability": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. Enable scalability. Bumps the registry to the Premium SKU (WAF-aligned) to allow geo-replication and higher throughput." + } + }, + "privateEndpointSubnetResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional. Resource ID of the subnet to host the registry private endpoint. Required when enablePrivateNetworking is true." + } + }, + "privateDnsZoneResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional. Resource ID of the privatelink.azurecr.io private DNS zone. Required when enablePrivateNetworking is true." + } + }, "managedIdentities": { "$ref": "#/definitions/managedIdentityAllType", "nullable": true, @@ -4975,7 +5013,8 @@ } ], "acrPullRoleDefinitionId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", - "acrPushRoleDefinitionId": "8311e382-0749-4cb8-b61a-304f252e45ec" + "acrPushRoleDefinitionId": "8311e382-0749-4cb8-b61a-304f252e45ec", + "effectiveAcrSku": "[if(or(parameters('enablePrivateNetworking'), parameters('enableScalability')), 'Premium', parameters('acrSku'))]" }, "resources": { "containerRegistry": { @@ -5001,7 +5040,7 @@ "value": "[parameters('enableTelemetry')]" }, "acrSku": { - "value": "[parameters('acrSku')]" + "value": "[variables('effectiveAcrSku')]" }, "acrAdminUserEnabled": { "value": false @@ -5009,18 +5048,28 @@ "anonymousPullEnabled": { "value": false }, - "publicNetworkAccess": { - "value": "Enabled" + "azureADAuthenticationAsArmPolicyStatus": { + "value": "enabled" }, - "networkRuleBypassOptions": { - "value": "AzureServices" + "exportPolicyStatus": { + "value": "enabled" }, + "softDeletePolicyStatus": { + "value": "disabled" + }, + "softDeletePolicyDays": { + "value": 7 + }, + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Disabled'), createObject('value', 'Enabled'))]", + "networkRuleBypassOptions": "[if(or(parameters('enablePrivateNetworking'), parameters('enableScalability')), createObject('value', 'AzureServices'), createObject('value', null()))]", + "networkRuleSetDefaultAction": "[if(or(parameters('enablePrivateNetworking'), parameters('enableScalability')), createObject('value', 'Deny'), createObject('value', 'Allow'))]", "roleAssignments": { "value": "[concat(variables('pullRoleAssignments'), variables('pushRoleAssignments'))]" }, "managedIdentities": { "value": "[parameters('managedIdentities')]" - } + }, + "privateEndpoints": "[if(parameters('enablePrivateNetworking'), createObject('value', createArray(createObject('name', format('pep-{0}', parameters('name')), 'customNetworkInterfaceName', format('nic-{0}', parameters('name')), 'service', 'registry', 'subnetResourceId', parameters('privateEndpointSubnetResourceId'), 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('privateDnsZoneResourceId', parameters('privateDnsZoneResourceId'))))))), createObject('value', createArray()))]" }, "template": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", @@ -8064,7 +8113,9 @@ } }, "dependsOn": [ - "userAssignedIdentity" + "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').containerRegistry)]", + "userAssignedIdentity", + "virtualNetwork" ] }, "virtualNetwork": { diff --git a/infra/modules/container-registry.bicep b/infra/modules/container-registry.bicep index de3cee4a9..cdb17744a 100644 --- a/infra/modules/container-registry.bicep +++ b/infra/modules/container-registry.bicep @@ -38,6 +38,18 @@ param pushPrincipalIds array = [] ]) param pushPrincipalType string = 'User' +@description('Optional. Enable private networking. Forces the Premium SKU, disables public network access and creates a private endpoint for the registry.') +param enablePrivateNetworking bool = false + +@description('Optional. Enable scalability. Bumps the registry to the Premium SKU (WAF-aligned) to allow geo-replication and higher throughput.') +param enableScalability bool = false + +@description('Optional. Resource ID of the subnet to host the registry private endpoint. Required when enablePrivateNetworking is true.') +param privateEndpointSubnetResourceId string = '' + +@description('Optional. Resource ID of the privatelink.azurecr.io private DNS zone. Required when enablePrivateNetworking is true.') +param privateDnsZoneResourceId string = '' + import { managedIdentityAllType } from 'br/public:avm/utl/types/avm-common-types:0.7.0' @description('Optional. The managed identity definition for this resource.') param managedIdentities managedIdentityAllType? @@ -47,6 +59,9 @@ var acrPullRoleDefinitionId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' // AcrPush role: allows the deployer to build and push images to the registry. var acrPushRoleDefinitionId = '8311e382-0749-4cb8-b61a-304f252e45ec' +// Premium is required for private endpoints, and recommended (WAF) for scalability. +var effectiveAcrSku = (enablePrivateNetworking || enableScalability) ? 'Premium' : acrSku + var pullRoleAssignments = [ for principalId in pullPrincipalIds: { principalId: principalId @@ -71,13 +86,35 @@ module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.0' = location: location tags: tags enableTelemetry: enableTelemetry - acrSku: acrSku + acrSku: effectiveAcrSku acrAdminUserEnabled: false anonymousPullEnabled: false - publicNetworkAccess: 'Enabled' - networkRuleBypassOptions: 'AzureServices' + azureADAuthenticationAsArmPolicyStatus: 'enabled' + exportPolicyStatus: 'enabled' + softDeletePolicyStatus: 'disabled' + softDeletePolicyDays: 7 + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + // networkRuleBypassOptions and networkRuleSet are Premium-only; suppress them + // unless the registry is Premium (private networking or scalability enabled). + networkRuleBypassOptions: (enablePrivateNetworking || enableScalability) ? 'AzureServices' : null + networkRuleSetDefaultAction: (enablePrivateNetworking || enableScalability) ? 'Deny' : 'Allow' roleAssignments: concat(pullRoleAssignments, pushRoleAssignments) managedIdentities: managedIdentities + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-${name}' + customNetworkInterfaceName: 'nic-${name}' + service: 'registry' + subnetResourceId: privateEndpointSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: privateDnsZoneResourceId } + ] + } + } + ] + : [] } } From 4adb14d77a5ed081743d77d12eda22fcbf123a6f Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Fri, 3 Jul 2026 11:22:14 +0530 Subject: [PATCH 05/12] add missing flag to suppress output in container config command --- infra/scripts/build_and_deploy_images.ps1 | 3 +-- infra/scripts/build_and_deploy_images.sh | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/infra/scripts/build_and_deploy_images.ps1 b/infra/scripts/build_and_deploy_images.ps1 index 1b7dd743b..6144dc126 100644 --- a/infra/scripts/build_and_deploy_images.ps1 +++ b/infra/scripts/build_and_deploy_images.ps1 @@ -61,8 +61,7 @@ $ClientId = az identity show --ids $UamiRid --query clientId -o tsv Invoke-Az webapp config container set -g $ResourceGroup -n $AppService ` --container-image-name "$AcrLoginServer/$FrontendImage`:$Tag" ` - --container-registry-url "https://$AcrLoginServer" --output none - --only-show-errors | Out-Null + --container-registry-url "https://$AcrLoginServer" --output none --only-show-errors $WebappId = az webapp show -g $ResourceGroup -n $AppService --query id -o tsv # Invoke-Az resource update --ids "$WebappId/config/web" ` diff --git a/infra/scripts/build_and_deploy_images.sh b/infra/scripts/build_and_deploy_images.sh index 55b3416ab..b3ce3dced 100644 --- a/infra/scripts/build_and_deploy_images.sh +++ b/infra/scripts/build_and_deploy_images.sh @@ -54,7 +54,7 @@ CLIENT_ID="$(az identity show --ids "${UAMI_RID}" --query clientId -o tsv)" az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ --container-image-name "${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" \ - --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none + --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none --only-show-errors WEBAPP_ID="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" # az resource update --ids "${WEBAPP_ID}/config/web" \ From 2c75e9678bb1cfedc5c5ae1694bce3610ca9692c Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Fri, 3 Jul 2026 11:34:13 +0530 Subject: [PATCH 06/12] add deployerType parameter for ACR AcrPush role assignment --- infra/main.bicep | 10 +++++++++- infra/main.json | 24 ++++++++++++++++++------ infra/modules/container-registry.bicep | 4 ++-- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index c4acf93f0..1ad06aed5 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -104,6 +104,14 @@ param existingLogAnalyticsWorkspaceId string = '' @description('Optional. Resource ID of an existing Foundry project.') param azureExistingAIProjectResourceId string = '' +@description('Optional. Principal type of the deployer, used for the ACR AcrPush role assignment. Set to ServicePrincipal for CI/OIDC deployments; defaults to User for interactive deployments.') +@allowed([ + 'User' + 'Group' + 'ServicePrincipal' +]) +param deployerType string = 'User' + @description('Optional. Deploy Azure Bastion and Jumpbox resources for private network administration.') param deployBastionAndJumpbox bool = false @@ -391,7 +399,7 @@ module containerRegistry 'modules/container-registry.bicep' = { pushPrincipalIds: [ deployer().objectId ] - pushPrincipalType: 'User' + deployerType: deployerType } } diff --git a/infra/main.json b/infra/main.json index f5e77fd89..b6d403dd4 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "12340397001946101697" + "templateHash": "2883025917739612961" }, "name": "Intelligent Content Generation Accelerator", "description": "Solution Accelerator for multimodal marketing content generation using Microsoft Agent Framework.\n" @@ -162,6 +162,18 @@ "description": "Optional. Resource ID of an existing Foundry project." } }, + "deployerType": { + "type": "string", + "defaultValue": "User", + "allowedValues": [ + "User", + "Group", + "ServicePrincipal" + ], + "metadata": { + "description": "Optional. Principal type of the deployer, used for the ACR AcrPush role assignment. Set to ServicePrincipal for CI/OIDC deployments; defaults to User for interactive deployments." + } + }, "deployBastionAndJumpbox": { "type": "bool", "defaultValue": false, @@ -4844,8 +4856,8 @@ "[deployer().objectId]" ] }, - "pushPrincipalType": { - "value": "User" + "deployerType": { + "value": "[parameters('deployerType')]" } }, "template": { @@ -4856,7 +4868,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "553009202159475069" + "templateHash": "6229495116704884486" } }, "definitions": { @@ -4943,7 +4955,7 @@ "description": "Optional. Principal IDs (e.g. the deployer) that require AcrPush access to build and push images." } }, - "pushPrincipalType": { + "deployerType": { "type": "string", "defaultValue": "User", "allowedValues": [ @@ -5008,7 +5020,7 @@ "input": { "principalId": "[parameters('pushPrincipalIds')[copyIndex('pushRoleAssignments')]]", "roleDefinitionIdOrName": "[variables('acrPushRoleDefinitionId')]", - "principalType": "[parameters('pushPrincipalType')]" + "principalType": "[parameters('deployerType')]" } } ], diff --git a/infra/modules/container-registry.bicep b/infra/modules/container-registry.bicep index cdb17744a..c2c34a354 100644 --- a/infra/modules/container-registry.bicep +++ b/infra/modules/container-registry.bicep @@ -36,7 +36,7 @@ param pushPrincipalIds array = [] 'Group' 'ServicePrincipal' ]) -param pushPrincipalType string = 'User' +param deployerType string = 'User' @description('Optional. Enable private networking. Forces the Premium SKU, disables public network access and creates a private endpoint for the registry.') param enablePrivateNetworking bool = false @@ -74,7 +74,7 @@ var pushRoleAssignments = [ for principalId in pushPrincipalIds: { principalId: principalId roleDefinitionIdOrName: acrPushRoleDefinitionId - principalType: pushPrincipalType + principalType: deployerType } ] From 180ce9a1268d3d4ff051b08524b2f00a85d56708 Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Fri, 3 Jul 2026 12:05:35 +0530 Subject: [PATCH 07/12] Updates in script --- infra/main.json | 8 ++++---- infra/modules/container-registry.bicep | 6 ++---- infra/scripts/build_and_deploy_images.ps1 | 6 +++--- infra/scripts/build_and_deploy_images.sh | 16 +++++++++++----- infra/scripts/process_sample_data.sh | 10 ++++++++-- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/infra/main.json b/infra/main.json index b6d403dd4..0897a7c53 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "2883025917739612961" + "templateHash": "13991311501344883078" }, "name": "Intelligent Content Generation Accelerator", "description": "Solution Accelerator for multimodal marketing content generation using Microsoft Agent Framework.\n" @@ -4868,7 +4868,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "6229495116704884486" + "templateHash": "15150176666831120984" } }, "definitions": { @@ -5073,8 +5073,8 @@ "value": 7 }, "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Disabled'), createObject('value', 'Enabled'))]", - "networkRuleBypassOptions": "[if(or(parameters('enablePrivateNetworking'), parameters('enableScalability')), createObject('value', 'AzureServices'), createObject('value', null()))]", - "networkRuleSetDefaultAction": "[if(or(parameters('enablePrivateNetworking'), parameters('enableScalability')), createObject('value', 'Deny'), createObject('value', 'Allow'))]", + "networkRuleBypassOptions": "[if(parameters('enablePrivateNetworking'), createObject('value', 'AzureServices'), createObject('value', null()))]", + "networkRuleSetDefaultAction": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Deny'), createObject('value', 'Allow'))]", "roleAssignments": { "value": "[concat(variables('pullRoleAssignments'), variables('pushRoleAssignments'))]" }, diff --git a/infra/modules/container-registry.bicep b/infra/modules/container-registry.bicep index c2c34a354..241ae0a67 100644 --- a/infra/modules/container-registry.bicep +++ b/infra/modules/container-registry.bicep @@ -94,10 +94,8 @@ module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.0' = softDeletePolicyStatus: 'disabled' softDeletePolicyDays: 7 publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - // networkRuleBypassOptions and networkRuleSet are Premium-only; suppress them - // unless the registry is Premium (private networking or scalability enabled). - networkRuleBypassOptions: (enablePrivateNetworking || enableScalability) ? 'AzureServices' : null - networkRuleSetDefaultAction: (enablePrivateNetworking || enableScalability) ? 'Deny' : 'Allow' + networkRuleBypassOptions: enablePrivateNetworking ? 'AzureServices' : null + networkRuleSetDefaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' roleAssignments: concat(pullRoleAssignments, pushRoleAssignments) managedIdentities: managedIdentities privateEndpoints: enablePrivateNetworking diff --git a/infra/scripts/build_and_deploy_images.ps1 b/infra/scripts/build_and_deploy_images.ps1 index 6144dc126..564f40331 100644 --- a/infra/scripts/build_and_deploy_images.ps1 +++ b/infra/scripts/build_and_deploy_images.ps1 @@ -64,9 +64,9 @@ Invoke-Az webapp config container set -g $ResourceGroup -n $AppService ` --container-registry-url "https://$AcrLoginServer" --output none --only-show-errors $WebappId = az webapp show -g $ResourceGroup -n $AppService --query id -o tsv -# Invoke-Az resource update --ids "$WebappId/config/web" ` -# --set properties.acrUseManagedIdentityCreds=true ` -# --set properties.acrUserManagedIdentityID=$ClientId --output none +Invoke-Az resource update --ids "$WebappId/config/web" ` + --set properties.acrUseManagedIdentityCreds=true ` + --set properties.acrUserManagedIdentityID=$ClientId --output none Write-Host "Restarting Frontend App Service..." -ForegroundColor Yellow Invoke-Az webapp restart -g $ResourceGroup -n $AppService --output none diff --git a/infra/scripts/build_and_deploy_images.sh b/infra/scripts/build_and_deploy_images.sh index b3ce3dced..d0a2c4cf1 100644 --- a/infra/scripts/build_and_deploy_images.sh +++ b/infra/scripts/build_and_deploy_images.sh @@ -12,9 +12,15 @@ set -euo pipefail # Run AFTER `azd up`. Configuration is read from the azd environment. # ============================================================================ -# Load azd environment outputs into the shell. +# Load azd environment outputs into the shell. Parse without `eval` so that any +# command substitution embedded in a value cannot execute in this shell. if command -v azd >/dev/null 2>&1; then - eval "$(azd env get-values 2>/dev/null | sed 's/^/export /')" + while IFS='=' read -r _key _val; do + [ -z "${_key}" ] && continue + _val="${_val%\"}" + _val="${_val#\"}" + export "${_key}=${_val}" + done < <(azd env get-values 2>/dev/null) fi ACR_NAME="${AZURE_ENV_CONTAINER_REGISTRY_NAME:-}" @@ -57,9 +63,9 @@ az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none --only-show-errors WEBAPP_ID="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" -# az resource update --ids "${WEBAPP_ID}/config/web" \ -# --set properties.acrUseManagedIdentityCreds=true \ -# --set properties.acrUserManagedIdentityID="${CLIENT_ID}" -o none +az resource update --ids "${WEBAPP_ID}/config/web" \ + --set properties.acrUseManagedIdentityCreds=true \ + --set properties.acrUserManagedIdentityID="${CLIENT_ID}" -o none echo "Restarting Frontend App Service..." az webapp restart -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" -o none diff --git a/infra/scripts/process_sample_data.sh b/infra/scripts/process_sample_data.sh index 3fdfb8da6..d40ee1b37 100644 --- a/infra/scripts/process_sample_data.sh +++ b/infra/scripts/process_sample_data.sh @@ -15,9 +15,15 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -# Load azd environment outputs into the shell. +# Load azd environment outputs into the shell. Parse without `eval` so that any +# command substitution embedded in a value cannot execute in this shell. if command -v azd >/dev/null 2>&1; then - eval "$(azd env get-values 2>/dev/null | sed 's/^/export /')" + while IFS='=' read -r _key _val; do + [ -z "${_key}" ] && continue + _val="${_val%\"}" + _val="${_val#\"}" + export "${_key}=${_val}" + done < <(azd env get-values 2>/dev/null) fi # Resolve the Python executable (python3 preferred, fall back to python). From 7bbf0cd3f4c40f19bc3efa6bafcbbb481ac93faa Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Fri, 3 Jul 2026 13:20:54 +0530 Subject: [PATCH 08/12] readme file update for post provision script run --- docs/AZD_DEPLOYMENT.md | 83 ++++++++++++++++++++++++++++++++++++------ docs/DEPLOYMENT.md | 2 + 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/docs/AZD_DEPLOYMENT.md b/docs/AZD_DEPLOYMENT.md index f6c49610b..fd9718a0d 100644 --- a/docs/AZD_DEPLOYMENT.md +++ b/docs/AZD_DEPLOYMENT.md @@ -119,14 +119,67 @@ azd config set provision.preflight off azd up ``` -This single command will: -1. **Provision** all Azure resources (AI Services, Cosmos DB, Storage, AI Search, App Service, Container Registry) -2. **Build** the Docker container image and push to ACR -3. **Deploy** the container to Azure Container Instances -4. **Build** the frontend (React/TypeScript) -5. **Deploy** the frontend to App Service -6. **Configure** RBAC and Cosmos DB roles -7. **Upload** sample data and create the search index +This command will **provision** all Azure resources (AI Services, Cosmos DB, Storage, AI Search, App Service, Container Instance, Container Registry) and configure RBAC and Cosmos DB roles. The Container Registry (ACR) is initially deployed with placeholder images; the application images are built and pushed by the follow-up scripts below. + +When provisioning completes, the terminal prints the two follow-up scripts you must run, **in order**, to finish the deployment. + +### 5. Log in to Azure CLI + +These scripts use the Azure CLI, so log in first: + +```bash +az login +``` + +Alternatively, log in using a device code (**recommended when using VS Code Web**): + +```bash +az login --use-device-code +``` + +> **Note:** If you are running this deployment in **GitHub Codespaces**, a **VS Code Dev Container**, or **VS Code Web**, skip this step and continue with [step 7](#7-run-the-script-to-build-and-push-the-application-images). + +### 6. Create and activate a Python virtual environment + +```powershell +# PowerShell (Windows) +python -m venv .venv +.venv\Scripts\Activate.ps1 +``` + +```bash +# Bash (macOS/Linux, or Git Bash on Windows) +python -m venv .venv +source .venv/bin/activate # on Git Bash (Windows): source .venv/Scripts/activate +``` + +### 7. Run the script to build and push the application images + +Build and push the frontend and backend images to ACR, then update the App Service and Container Instance: + +```powershell +# PowerShell +./infra/scripts/build_and_deploy_images.ps1 +``` + +```bash +# Bash +bash ./infra/scripts/build_and_deploy_images.sh +``` + +### 8. Run the script to load the sample data (script 2) + +Run this **only after step 7 has completed** — it loads the sample data into the application: + +```powershell +# PowerShell +./infra/scripts/process_sample_data.ps1 +``` + +```bash +# Bash +bash ./infra/scripts/process_sample_data.sh +``` ## Using Existing Resources @@ -146,15 +199,21 @@ azd env set AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID "/subscriptions/.azurewebsites.net +Web app URL: https://app-.azurewebsites.net ``` +Run those two scripts as described in [steps 5–8](#5-log-in-to-azure-cli) above, then verify the deployment. + ### Verify Deployment 1. Open the Web App URL in your browser diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 62bc1bebb..ac0340622 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -166,6 +166,8 @@ Depending on your subscription quota and capacity, you can adjust quota settings Once you've opened the project in [Codespaces](#github-codespaces), [Dev Containers](#vs-code-dev-containers), or [locally](#local-environment), you can deploy it to Azure by following the steps in the [AZD Deployment Guide](AZD_DEPLOYMENT.md). +> **Important:** `azd up` only **provisions** the Azure resources (the Container Registry is created with placeholder images). Once provisioning completes, you must run the **two follow-up scripts**, in order - first `build_and_deploy_images` (builds and pushes the application images to ACR and updates the App Service and Container Instance), then `process_sample_data` (loads the sample data). See [Post-Provision Scripts (steps 5–8)](AZD_DEPLOYMENT.md#5-log-in-to-azure-cli) for details. + ## Post Deployment Steps 1. **Add App Authentication** From ea2751c07ea134b3bb3fc2e6c80b49cf2cc044f2 Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Fri, 3 Jul 2026 18:04:22 +0530 Subject: [PATCH 09/12] updates scripts for WAF changes --- infra/scripts/build_and_deploy_images.ps1 | 227 +++++++++++++++------- infra/scripts/build_and_deploy_images.sh | 129 ++++++++++-- 2 files changed, 268 insertions(+), 88 deletions(-) diff --git a/infra/scripts/build_and_deploy_images.ps1 b/infra/scripts/build_and_deploy_images.ps1 index 564f40331..70de3a654 100644 --- a/infra/scripts/build_and_deploy_images.ps1 +++ b/infra/scripts/build_and_deploy_images.ps1 @@ -7,6 +7,12 @@ .DESCRIPTION Run AFTER `azd up`. Configuration is read from the azd environment. + + In private/WAF deployments the registry has public network access disabled, so + the remote `az acr build` agent cannot authenticate to push. In that case this + script temporarily enables public access (default action Allow) for the build, + then re-locks the registry afterwards. Image pulls keep working over the private + endpoint (App Service VNet image pull, ACI VNet injection). #> $ErrorActionPreference = 'Stop' @@ -44,75 +50,160 @@ if (-not $AcrName -or -not $ResourceGroup -or -not $AppService -or -not $Contain $AcrLoginServer = "$AcrName.azurecr.io" -# ===== Build & push frontend image ===== -Write-Host "===== Building Frontend Image ($FrontendImage`:$Tag) =====" -ForegroundColor Yellow -Invoke-Az acr build --registry $AcrName --image "$FrontendImage`:$Tag" ` - --file (Join-Path $RepoRoot 'src\App\WebApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\App') - -# ===== Build & push backend image ===== -Write-Host "===== Building Backend Image ($BackendImage`:$Tag) =====" -ForegroundColor Yellow -Invoke-Az acr build --registry $AcrName --image "$BackendImage`:$Tag" ` - --file (Join-Path $RepoRoot 'src\backend\ApiApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\backend') - -# ===== Update frontend App Service image (managed-identity ACR pull) ===== -Write-Host "===== Updating Frontend App Service ($AppService) =====" -ForegroundColor Yellow -$UamiRid = az webapp identity show -g $ResourceGroup -n $AppService --query "userAssignedIdentities | keys(@) | [0]" -o tsv -$ClientId = az identity show --ids $UamiRid --query clientId -o tsv - -Invoke-Az webapp config container set -g $ResourceGroup -n $AppService ` - --container-image-name "$AcrLoginServer/$FrontendImage`:$Tag" ` - --container-registry-url "https://$AcrLoginServer" --output none --only-show-errors - -$WebappId = az webapp show -g $ResourceGroup -n $AppService --query id -o tsv -Invoke-Az resource update --ids "$WebappId/config/web" ` - --set properties.acrUseManagedIdentityCreds=true ` - --set properties.acrUserManagedIdentityID=$ClientId --output none - -Write-Host "Restarting Frontend App Service..." -ForegroundColor Yellow -Invoke-Az webapp restart -g $ResourceGroup -n $AppService --output none - -# ===== Recreate backend Container Instance with the new image ===== -# ACI cannot update a container group's image in place, and a restart reuses the -# originally deployed (hello-world) image. So we capture the existing container -# group's configuration and recreate it pointing at the new ACR image. -Write-Host "===== Recreating Backend Container Instance ($ContainerGroup) =====" -ForegroundColor Yellow - -$Aci = az container show -g $ResourceGroup -n $ContainerGroup -o json | ConvertFrom-Json -if (-not $Aci) { throw "Could not read existing container group '$ContainerGroup'." } - -$AciCpu = $Aci.containers[0].resources.requests.cpu -$AciMemory = $Aci.containers[0].resources.requests.memoryInGb -$AciPort = $Aci.containers[0].ports[0].port -$AciOsType = $Aci.osType -$AciRestart = $Aci.restartPolicy -$AciDnsLabel = $Aci.ipAddress.dnsNameLabel -$AciSubnetId = if ($Aci.subnetIds) { $Aci.subnetIds[0].id } else { $null } -$AciUami = @($Aci.identity.userAssignedIdentities.PSObject.Properties.Name)[0] -$AciEnvVars = $Aci.containers[0].environmentVariables | ForEach-Object { "$($_.name)=$($_.value)" } - -$CreateArgs = @( - 'container', 'create', - '--resource-group', $ResourceGroup, - '--name', $ContainerGroup, - '--image', "$AcrLoginServer/$BackendImage`:$Tag", - '--cpu', $AciCpu, - '--memory', $AciMemory, - '--ports', $AciPort, - '--os-type', $AciOsType, - '--restart-policy', $AciRestart, - '--assign-identity', $AciUami, - '--acr-identity', $AciUami, - '--registry-login-server', $AcrLoginServer -) -if ($AciSubnetId) { - $CreateArgs += @('--subnet', $AciSubnetId) -} else { - $CreateArgs += @('--ip-address', 'Public', '--dns-name-label', $AciDnsLabel) -} -$CreateArgs += '--environment-variables' -$CreateArgs += $AciEnvVars +# In private/WAF mode the registry's public network access is disabled. Temporarily +# open it so the remote build agent can push, and re-lock it in the finally block. +$AcrPublicAccess = Invoke-Az acr show -n $AcrName --query publicNetworkAccess --output tsv +$AcrOpenedForBuild = $false + +try { + if ($AcrPublicAccess -eq 'Disabled') { + Write-Host "===== ACR public access is disabled (private/WAF mode) - temporarily enabling it for the build =====" -ForegroundColor Yellow + Invoke-Az acr update -n $AcrName --public-network-enabled true --default-action Allow --output none --only-show-errors + $AcrOpenedForBuild = $true + Write-Host "Waiting for the network rule change to propagate..." -ForegroundColor Yellow + Start-Sleep -Seconds 45 + } + + # ===== Build & push frontend image ===== + Write-Host "===== Building Frontend Image ($FrontendImage`:$Tag) =====" -ForegroundColor Yellow + Invoke-Az acr build --registry $AcrName --image "$FrontendImage`:$Tag" ` + --file (Join-Path $RepoRoot 'src\App\WebApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\App') + + # ===== Build & push backend image ===== + Write-Host "===== Building Backend Image ($BackendImage`:$Tag) =====" -ForegroundColor Yellow + Invoke-Az acr build --registry $AcrName --image "$BackendImage`:$Tag" ` + --file (Join-Path $RepoRoot 'src\backend\ApiApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\backend') + + # ===== Recreate backend Container Instance with the new image (done FIRST) ===== + # ACI cannot update a container group's image in place, and a restart reuses the + # originally deployed (hello-world) image. So we capture the existing container + # group's configuration and recreate it pointing at the new ACR image. We do this + # before touching the frontend so the new private IP is known up-front and the + # frontend only needs a single restart. + Write-Host "===== Recreating Backend Container Instance ($ContainerGroup) =====" -ForegroundColor Yellow + + $Aci = az container show -g $ResourceGroup -n $ContainerGroup -o json | ConvertFrom-Json + if (-not $Aci) { throw "Could not read existing container group '$ContainerGroup'." } + + $AciCpu = $Aci.containers[0].resources.requests.cpu + $AciMemory = $Aci.containers[0].resources.requests.memoryInGb + $AciPort = $Aci.containers[0].ports[0].port + $AciOsType = $Aci.osType + $AciRestart = $Aci.restartPolicy + $AciDnsLabel = $Aci.ipAddress.dnsNameLabel + $AciSubnetId = if ($Aci.subnetIds) { $Aci.subnetIds[0].id } else { $null } + $AciUami = @($Aci.identity.userAssignedIdentities.PSObject.Properties.Name)[0] + $AciEnvVars = $Aci.containers[0].environmentVariables | ForEach-Object { "$($_.name)=$($_.value)" } + + $CreateArgs = @( + 'container', 'create', + '--resource-group', $ResourceGroup, + '--name', $ContainerGroup, + '--image', "$AcrLoginServer/$BackendImage`:$Tag", + '--cpu', $AciCpu, + '--memory', $AciMemory, + '--ports', $AciPort, + '--os-type', $AciOsType, + '--restart-policy', $AciRestart, + '--assign-identity', $AciUami, + '--acr-identity', $AciUami, + '--registry-login-server', $AcrLoginServer + ) + if ($AciSubnetId) { + $CreateArgs += @('--subnet', $AciSubnetId) + } else { + $CreateArgs += @('--ip-address', 'Public', '--dns-name-label', $AciDnsLabel) + } + $CreateArgs += '--environment-variables' + $CreateArgs += $AciEnvVars + + Invoke-Az @CreateArgs --output none + + # In private/WAF mode the ACI has a private IP and no FQDN. Recreating it above + # assigns a NEW private IP, so the frontend's BACKEND_URL (baked at deploy time) + # is now stale. Read the new IP so we can set BACKEND_URL before the single + # frontend restart below. (Public mode keeps its stable dns-label FQDN.) + $NewBackendUrl = $null + if ($AciSubnetId) { + $NewAci = az container show -g $ResourceGroup -n $ContainerGroup -o json | ConvertFrom-Json + $NewIp = $NewAci.ipAddress.ip + if ($NewIp) { + $NewBackendUrl = "http://${NewIp}:$AciPort" + } else { + Write-Warning "Could not determine the new ACI private IP; frontend BACKEND_URL not updated." + } + } -Invoke-Az @CreateArgs --output none + # ===== Update frontend App Service image (managed-identity ACR pull) ===== + Write-Host "===== Updating Frontend App Service ($AppService) =====" -ForegroundColor Yellow + Invoke-Az webapp config container set -g $ResourceGroup -n $AppService ` + --container-image-name "$AcrLoginServer/$FrontendImage`:$Tag" ` + --container-registry-url "https://$AcrLoginServer" ` + --output none --only-show-errors + + # Point the frontend at the new ACI private IP (private/WAF mode) before restart. + if ($NewBackendUrl) { + Write-Host "===== Updating frontend BACKEND_URL to $NewBackendUrl =====" -ForegroundColor Yellow + Invoke-Az webapp config appsettings set -g $ResourceGroup -n $AppService ` + --settings "BACKEND_URL=$NewBackendUrl" --output none --only-show-errors + } + + # `webapp config container set` performs a partial write of config/web that only + # sets the image + registry auth; it resets the managed-identity credentials bicep + # configured for ACR pull. The ACR admin user is disabled in BOTH modes, so we must + # always re-apply acrUseManagedIdentityCreds / acrUserManagedIdentityID or the next + # restart fails with ACRTokenRetrievalFailure / ImagePullFailure. + Write-Host "===== Restoring managed-identity ACR-pull config on frontend =====" -ForegroundColor Yellow + $Uami = Invoke-Az webapp identity show -g $ResourceGroup -n $AppService --query "userAssignedIdentities | keys(@) | [0]" --output tsv + if (-not $Uami) { throw "Could not resolve user-assigned identity for web app '$AppService'." } + $ClientId = Invoke-Az identity show --ids $Uami --query clientId --output tsv + if (-not $ClientId) { throw "Could not resolve clientId for identity '$Uami'." } + $WebId = Invoke-Az webapp show -g $ResourceGroup -n $AppService --query id --output tsv + if (-not $WebId) { throw "Could not resolve resource id for web app '$AppService'." } + + if ($AcrPublicAccess -eq 'Disabled') { + # Private/WAF: additionally restore the VNet image-pull properties. The + # vnetImagePullEnabled flag is a SITE-level property; the others live on + # config/web. `az resource update` does not persist these reliably, so PATCH + # via the ARM REST API. + $WebCfgBody = (@{ properties = @{ + acrUseManagedIdentityCreds = $true + acrUserManagedIdentityID = $ClientId + vnetRouteAllEnabled = $true + }} | ConvertTo-Json -Compress) + + $Tmp1 = New-TemporaryFile; Set-Content -Path $Tmp1 -Value $WebCfgBody -Encoding utf8 + Invoke-Az rest --method patch ` + --uri "https://management.azure.com$WebId/config/web?api-version=2023-12-01" ` + --headers "Content-Type=application/json" --body "@$Tmp1" --output none --only-show-errors + Remove-Item $Tmp1 -Force + + $SiteBody = '{"properties":{"vnetImagePullEnabled":true}}' + $Tmp2 = New-TemporaryFile; Set-Content -Path $Tmp2 -Value $SiteBody -Encoding utf8 + Invoke-Az rest --method patch ` + --uri "https://management.azure.com$WebId`?api-version=2023-12-01" ` + --headers "Content-Type=application/json" --body "@$Tmp2" --output none --only-show-errors + Remove-Item $Tmp2 -Force + } else { + # Public (non-WAF): identical to the rc-acr-update-cg branch (proven path). + Invoke-Az resource update --ids "$WebId/config/web" ` + --set properties.acrUseManagedIdentityCreds=true ` + --set properties.acrUserManagedIdentityID=$ClientId --output none + } + + # Single frontend restart, now that image, BACKEND_URL and ACR-pull config are set. + Write-Host "Restarting Frontend App Service..." -ForegroundColor Yellow + Invoke-Az webapp restart -g $ResourceGroup -n $AppService --output none +} +finally { + if ($AcrOpenedForBuild) { + Write-Host "===== Re-locking ACR (disabling public network access) =====" -ForegroundColor Yellow + az acr update -n $AcrName --public-network-enabled false --default-action Deny --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to re-disable ACR public network access. Re-lock it manually: az acr update -n $AcrName --public-network-enabled false --default-action Deny" + } + } +} Write-Host "" Write-Host "===== Done =====" -ForegroundColor Green diff --git a/infra/scripts/build_and_deploy_images.sh b/infra/scripts/build_and_deploy_images.sh index d0a2c4cf1..6bb3e9ce4 100644 --- a/infra/scripts/build_and_deploy_images.sh +++ b/infra/scripts/build_and_deploy_images.sh @@ -10,6 +10,12 @@ set -euo pipefail # backend Container Instance on the new image. # # Run AFTER `azd up`. Configuration is read from the azd environment. +# +# In private/WAF deployments the registry has public network access disabled, so +# the remote `az acr build` agent cannot authenticate to push. In that case this +# script temporarily enables public access (default action Allow) for the build, +# then re-locks the registry afterwards (trap on EXIT). Image pulls keep working +# over the private endpoint (App Service VNet image pull, ACI VNet injection). # ============================================================================ # Load azd environment outputs into the shell. Parse without `eval` so that any @@ -42,6 +48,30 @@ fi ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io" +# In private/WAF mode the registry's public network access is disabled, so the +# remote build agent cannot authenticate to push. Temporarily open the registry +# for the build and re-lock it on exit (trap). Image pulls keep working over the +# private endpoint (App Service VNet image pull, ACI VNet injection). +ACR_PUBLIC_ACCESS="$(az acr show -n "${ACR_NAME}" --query publicNetworkAccess -o tsv)" +ACR_OPENED_FOR_BUILD=false + +relock_acr() { + if [ "${ACR_OPENED_FOR_BUILD}" = true ]; then + echo "===== Re-locking ACR (disabling public network access) =====" + az acr update -n "${ACR_NAME}" --public-network-enabled false --default-action Deny -o none --only-show-errors \ + || echo "WARNING: Failed to re-disable ACR public network access. Re-lock it manually: az acr update -n ${ACR_NAME} --public-network-enabled false --default-action Deny" >&2 + fi +} +trap relock_acr EXIT + +if [ "${ACR_PUBLIC_ACCESS}" = "Disabled" ]; then + echo "===== ACR public access is disabled (private/WAF mode) - temporarily enabling it for the build =====" + az acr update -n "${ACR_NAME}" --public-network-enabled true --default-action Allow -o none --only-show-errors + ACR_OPENED_FOR_BUILD=true + echo "Waiting for the network rule change to propagate..." + sleep 45 +fi + # ===== Build & push frontend image ===== echo "===== Building Frontend Image (${FRONTEND_IMAGE}:${TAG}) =====" az acr build --registry "${ACR_NAME}" --image "${FRONTEND_IMAGE}:${TAG}" \ @@ -52,28 +82,12 @@ echo "===== Building Backend Image (${BACKEND_IMAGE}:${TAG}) =====" az acr build --registry "${ACR_NAME}" --image "${BACKEND_IMAGE}:${TAG}" \ --file "${REPO_ROOT}/src/backend/ApiApp.Dockerfile" --platform linux "${REPO_ROOT}/src/backend" -# ===== Update frontend App Service image (managed-identity ACR pull) ===== -echo "===== Updating Frontend App Service (${APP_SERVICE}) =====" -UAMI_RID="$(az webapp identity show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ - --query "userAssignedIdentities | keys(@) | [0]" -o tsv)" -CLIENT_ID="$(az identity show --ids "${UAMI_RID}" --query clientId -o tsv)" - -az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ - --container-image-name "${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" \ - --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none --only-show-errors - -WEBAPP_ID="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" -az resource update --ids "${WEBAPP_ID}/config/web" \ - --set properties.acrUseManagedIdentityCreds=true \ - --set properties.acrUserManagedIdentityID="${CLIENT_ID}" -o none - -echo "Restarting Frontend App Service..." -az webapp restart -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" -o none - -# ===== Recreate backend Container Instance with the new image ===== +# ===== Recreate backend Container Instance with the new image (done FIRST) ===== # ACI cannot update a container group's image in place, and a restart reuses the # originally deployed (hello-world) image. So we capture the existing container -# group's configuration and recreate it pointing at the new ACR image. +# group's configuration and recreate it pointing at the new ACR image. We do this +# before touching the frontend so the new private IP is known up-front and the +# frontend only needs a single restart. echo "===== Recreating Backend Container Instance (${CONTAINER_GROUP}) =====" ACI_CPU="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.cpu" -o tsv)" @@ -116,6 +130,81 @@ CREATE_ARGS+=(--environment-variables "${ENV_VARS[@]}") az "${CREATE_ARGS[@]}" -o none +# In private/WAF mode the ACI has a private IP and no FQDN. Recreating it above +# assigns a NEW private IP, so the frontend's BACKEND_URL (baked at deploy time) +# is now stale. Read the new IP so we can set BACKEND_URL before the single +# frontend restart below. (Public mode keeps its stable dns-label FQDN.) +NEW_BACKEND_URL="" +if [ -n "${ACI_SUBNET_ID}" ]; then + NEW_IP="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.ip" -o tsv)" + if [ -n "${NEW_IP}" ]; then + NEW_BACKEND_URL="http://${NEW_IP}:${ACI_PORT}" + else + echo "WARNING: Could not determine the new ACI private IP; frontend BACKEND_URL not updated." >&2 + fi +fi + +# ===== Update frontend App Service image (managed-identity ACR pull) ===== +echo "===== Updating Frontend App Service (${APP_SERVICE}) =====" +UAMI_RID="$(az webapp identity show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --query "userAssignedIdentities | keys(@) | [0]" -o tsv)" +if [ -z "${UAMI_RID}" ]; then + echo "ERROR: Could not resolve user-assigned identity for web app '${APP_SERVICE}'." >&2 + exit 1 +fi +CLIENT_ID="$(az identity show --ids "${UAMI_RID}" --query clientId -o tsv)" +if [ -z "${CLIENT_ID}" ]; then + echo "ERROR: Could not resolve clientId for identity '${UAMI_RID}'." >&2 + exit 1 +fi + +az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --container-image-name "${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" \ + --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none --only-show-errors + +WEBAPP_ID="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" +if [ -z "${WEBAPP_ID}" ]; then + echo "ERROR: Could not resolve resource id for web app '${APP_SERVICE}'." >&2 + exit 1 +fi + +# Point the frontend at the new ACI private IP (private/WAF mode) before restart. +if [ -n "${NEW_BACKEND_URL}" ]; then + echo "===== Updating frontend BACKEND_URL to ${NEW_BACKEND_URL} =====" + az webapp config appsettings set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --settings "BACKEND_URL=${NEW_BACKEND_URL}" -o none --only-show-errors +fi + +# `webapp config container set` performs a partial write of config/web that only +# sets the image + registry auth; it resets the managed-identity credentials bicep +# configured for ACR pull. The ACR admin user is disabled in BOTH modes, so we must +# always re-apply acrUseManagedIdentityCreds / acrUserManagedIdentityID or the next +# restart fails with ACRTokenRetrievalFailure / ImagePullFailure. In private/WAF mode +# we additionally restore the VNet image-pull properties (vnetImagePullEnabled is a +# SITE-level property; the others live on config/web) via the ARM REST API. +echo "===== Restoring managed-identity ACR-pull config on frontend =====" +if [ "${ACR_PUBLIC_ACCESS}" = "Disabled" ]; then + az rest --method patch \ + --uri "https://management.azure.com${WEBAPP_ID}/config/web?api-version=2023-12-01" \ + --headers "Content-Type=application/json" \ + --body "{\"properties\":{\"acrUseManagedIdentityCreds\":true,\"acrUserManagedIdentityID\":\"${CLIENT_ID}\",\"vnetRouteAllEnabled\":true}}" \ + -o none --only-show-errors + az rest --method patch \ + --uri "https://management.azure.com${WEBAPP_ID}?api-version=2023-12-01" \ + --headers "Content-Type=application/json" \ + --body '{"properties":{"vnetImagePullEnabled":true}}' \ + -o none --only-show-errors +else + # Public (non-WAF): identical to the rc-acr-update-cg branch (proven path). + az resource update --ids "${WEBAPP_ID}/config/web" \ + --set properties.acrUseManagedIdentityCreds=true \ + --set properties.acrUserManagedIdentityID="${CLIENT_ID}" -o none +fi + +# Single frontend restart, now that image, BACKEND_URL and ACR-pull config are set. +echo "Restarting Frontend App Service..." +az webapp restart -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" -o none + echo "" echo "===== Done =====" echo "Frontend image: ${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" From 563c96ca61d21e933e78c949f30ff4301b19f3bf Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Tue, 7 Jul 2026 10:15:26 +0530 Subject: [PATCH 10/12] refactor: remove deployerType and related parameters from ACR role assignments --- infra/main.bicep | 36 ++++++----- infra/main.json | 89 +++++++++----------------- infra/modules/container-registry.bicep | 23 +------ 3 files changed, 49 insertions(+), 99 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 1ad06aed5..cc41df532 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -104,14 +104,6 @@ param existingLogAnalyticsWorkspaceId string = '' @description('Optional. Resource ID of an existing Foundry project.') param azureExistingAIProjectResourceId string = '' -@description('Optional. Principal type of the deployer, used for the ACR AcrPush role assignment. Set to ServicePrincipal for CI/OIDC deployments; defaults to User for interactive deployments.') -@allowed([ - 'User' - 'Group' - 'ServicePrincipal' -]) -param deployerType string = 'User' - @description('Optional. Deploy Azure Bastion and Jumpbox resources for private network administration.') param deployBastionAndJumpbox bool = false @@ -396,15 +388,11 @@ module containerRegistry 'modules/container-registry.bicep' = { pullPrincipalIds: [ userAssignedIdentity.outputs.principalId ] - pushPrincipalIds: [ - deployer().objectId - ] - deployerType: deployerType } } // ========== Virtual Network and Networking Components ========== // -var deployAdminAccessResources = enablePrivateNetworking && deployBastionAndJumpbox && !empty(vmAdminPassword) +var deployAdminAccessResources = enablePrivateNetworking && deployBastionAndJumpbox module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { name: take('module.virtualNetwork.${solutionSuffix}', 64) params: { @@ -474,8 +462,9 @@ module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.21.0' = if (deploy osType: 'Windows' vmSize: empty(vmSize) ? 'Standard_D2s_v5' : vmSize adminUsername: empty(vmAdminUsername) ? 'JumpboxAdminUser' : vmAdminUsername - adminPassword: vmAdminPassword + adminPassword: empty(vmAdminPassword) ? 'Vm!${uniqueString(subscription().subscriptionId, solutionName)}${guid(subscription().subscriptionId, solutionName, 'vm-admin-password')}' : vmAdminPassword managedIdentities: { + systemAssigned: true // Required by the AADLoginForWindows extension for Entra ID auth userAssignedResourceIds: [ userAssignedIdentity.outputs.resourceId ] @@ -519,6 +508,17 @@ module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.21.0' = if (deploy } location: solutionLocation tags: tags + roleAssignments: [ + { + roleDefinitionIdOrName: '1c0163c0-47e6-4577-8991-ea5c82e286e4' // Virtual Machine Administrator Login + principalId: deployer().objectId + } + ] + extensionAadJoinConfig: { + enabled: true // AAD-joins the VM (AADLoginForWindows) so Entra ID login works + typeHandlerVersion: '2.0' + settings: { mdmId: '' } + } } dependsOn: (enableMonitoring && !useExistingLogAnalytics) ? [logAnalyticsWorkspace, jumpboxDcr] : (enableMonitoring ? [jumpboxDcr] : []) } @@ -539,9 +539,9 @@ module jumpboxDcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if dataSources: { windowsEventLogs: [ { - name: 'securityEventLogsDataSource' + name: 'SecurityAuditEvents' streams: [ - 'Microsoft-SecurityEvent' + 'Microsoft-Event' ] xPathQueries: [ 'Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]' @@ -560,11 +560,13 @@ module jumpboxDcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if dataFlows: [ { streams: [ - 'Microsoft-SecurityEvent' + 'Microsoft-Event' ] destinations: [ dcrLogAnalyticsDestinationName ] + transformKql: 'source' + outputStream: 'Microsoft-Event' } ] } diff --git a/infra/main.json b/infra/main.json index 0897a7c53..1f1d09923 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "13991311501344883078" + "templateHash": "16705306315679210410" }, "name": "Intelligent Content Generation Accelerator", "description": "Solution Accelerator for multimodal marketing content generation using Microsoft Agent Framework.\n" @@ -162,18 +162,6 @@ "description": "Optional. Resource ID of an existing Foundry project." } }, - "deployerType": { - "type": "string", - "defaultValue": "User", - "allowedValues": [ - "User", - "Group", - "ServicePrincipal" - ], - "metadata": { - "description": "Optional. Principal type of the deployer, used for the ACR AcrPush role assignment. Set to ServicePrincipal for CI/OIDC deployments; defaults to User for interactive deployments." - } - }, "deployBastionAndJumpbox": { "type": "bool", "defaultValue": false, @@ -334,7 +322,7 @@ "logAnalyticsWorkspaceResourceName": "[format('log-{0}', variables('solutionSuffix'))]", "applicationInsightsResourceName": "[format('appi-{0}', variables('solutionSuffix'))]", "userAssignedIdentityResourceName": "[format('id-{0}', variables('solutionSuffix'))]", - "deployAdminAccessResources": "[and(and(parameters('enablePrivateNetworking'), parameters('deployBastionAndJumpbox')), not(empty(parameters('vmAdminPassword'))))]", + "deployAdminAccessResources": "[and(parameters('enablePrivateNetworking'), parameters('deployBastionAndJumpbox'))]", "bastionHostName": "[format('bas-{0}', variables('solutionSuffix'))]", "zoneSupportedJumpboxLocations": [ "australiaeast", @@ -4850,14 +4838,6 @@ "value": [ "[reference('userAssignedIdentity').outputs.principalId.value]" ] - }, - "pushPrincipalIds": { - "value": [ - "[deployer().objectId]" - ] - }, - "deployerType": { - "value": "[parameters('deployerType')]" } }, "template": { @@ -4868,7 +4848,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "15150176666831120984" + "templateHash": "9041711177904037864" } }, "definitions": { @@ -4948,25 +4928,6 @@ "description": "Optional. Principal IDs (frontend/backend managed identities) that require AcrPull access." } }, - "pushPrincipalIds": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Principal IDs (e.g. the deployer) that require AcrPush access to build and push images." - } - }, - "deployerType": { - "type": "string", - "defaultValue": "User", - "allowedValues": [ - "User", - "Group", - "ServicePrincipal" - ], - "metadata": { - "description": "Optional. Principal type for the AcrPush role assignments." - } - }, "enablePrivateNetworking": { "type": "bool", "defaultValue": false, @@ -5013,19 +4974,9 @@ "roleDefinitionIdOrName": "[variables('acrPullRoleDefinitionId')]", "principalType": "ServicePrincipal" } - }, - { - "name": "pushRoleAssignments", - "count": "[length(parameters('pushPrincipalIds'))]", - "input": { - "principalId": "[parameters('pushPrincipalIds')[copyIndex('pushRoleAssignments')]]", - "roleDefinitionIdOrName": "[variables('acrPushRoleDefinitionId')]", - "principalType": "[parameters('deployerType')]" - } } ], "acrPullRoleDefinitionId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", - "acrPushRoleDefinitionId": "8311e382-0749-4cb8-b61a-304f252e45ec", "effectiveAcrSku": "[if(or(parameters('enablePrivateNetworking'), parameters('enableScalability')), 'Premium', parameters('acrSku'))]" }, "resources": { @@ -5076,7 +5027,7 @@ "networkRuleBypassOptions": "[if(parameters('enablePrivateNetworking'), createObject('value', 'AzureServices'), createObject('value', null()))]", "networkRuleSetDefaultAction": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Deny'), createObject('value', 'Allow'))]", "roleAssignments": { - "value": "[concat(variables('pullRoleAssignments'), variables('pushRoleAssignments'))]" + "value": "[variables('pullRoleAssignments')]" }, "managedIdentities": { "value": "[parameters('managedIdentities')]" @@ -12484,11 +12435,10 @@ }, "vmSize": "[if(empty(parameters('vmSize')), createObject('value', 'Standard_D2s_v5'), createObject('value', parameters('vmSize')))]", "adminUsername": "[if(empty(parameters('vmAdminUsername')), createObject('value', 'JumpboxAdminUser'), createObject('value', parameters('vmAdminUsername')))]", - "adminPassword": { - "value": "[parameters('vmAdminPassword')]" - }, + "adminPassword": "[if(empty(parameters('vmAdminPassword')), createObject('value', format('Vm!{0}{1}', uniqueString(subscription().subscriptionId, parameters('solutionName')), guid(subscription().subscriptionId, parameters('solutionName'), 'vm-admin-password'))), createObject('value', parameters('vmAdminPassword')))]", "managedIdentities": { "value": { + "systemAssigned": true, "userAssignedResourceIds": [ "[reference('userAssignedIdentity').outputs.resourceId.value]" ] @@ -12540,6 +12490,23 @@ }, "tags": { "value": "[parameters('tags')]" + }, + "roleAssignments": { + "value": [ + { + "roleDefinitionIdOrName": "1c0163c0-47e6-4577-8991-ea5c82e286e4", + "principalId": "[deployer().objectId]" + } + ] + }, + "extensionAadJoinConfig": { + "value": { + "enabled": true, + "typeHandlerVersion": "2.0", + "settings": { + "mdmId": "" + } + } } }, "template": { @@ -21621,9 +21588,9 @@ "dataSources": { "windowsEventLogs": [ { - "name": "securityEventLogsDataSource", + "name": "SecurityAuditEvents", "streams": [ - "Microsoft-SecurityEvent" + "Microsoft-Event" ], "xPathQueries": [ "Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]" @@ -21642,11 +21609,13 @@ "dataFlows": [ { "streams": [ - "Microsoft-SecurityEvent" + "Microsoft-Event" ], "destinations": [ "[variables('dcrLogAnalyticsDestinationName')]" - ] + ], + "transformKql": "source", + "outputStream": "Microsoft-Event" } ] } diff --git a/infra/modules/container-registry.bicep b/infra/modules/container-registry.bicep index 241ae0a67..184cb9b19 100644 --- a/infra/modules/container-registry.bicep +++ b/infra/modules/container-registry.bicep @@ -27,17 +27,6 @@ param acrSku string = 'Standard' @description('Optional. Principal IDs (frontend/backend managed identities) that require AcrPull access.') param pullPrincipalIds array = [] -@description('Optional. Principal IDs (e.g. the deployer) that require AcrPush access to build and push images.') -param pushPrincipalIds array = [] - -@description('Optional. Principal type for the AcrPush role assignments.') -@allowed([ - 'User' - 'Group' - 'ServicePrincipal' -]) -param deployerType string = 'User' - @description('Optional. Enable private networking. Forces the Premium SKU, disables public network access and creates a private endpoint for the registry.') param enablePrivateNetworking bool = false @@ -56,8 +45,6 @@ param managedIdentities managedIdentityAllType? // AcrPull role: allows the App Service / Container Instance identities to pull images. var acrPullRoleDefinitionId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' -// AcrPush role: allows the deployer to build and push images to the registry. -var acrPushRoleDefinitionId = '8311e382-0749-4cb8-b61a-304f252e45ec' // Premium is required for private endpoints, and recommended (WAF) for scalability. var effectiveAcrSku = (enablePrivateNetworking || enableScalability) ? 'Premium' : acrSku @@ -70,14 +57,6 @@ var pullRoleAssignments = [ } ] -var pushRoleAssignments = [ - for principalId in pushPrincipalIds: { - principalId: principalId - roleDefinitionIdOrName: acrPushRoleDefinitionId - principalType: deployerType - } -] - // ========== Azure Container Registry ========== // module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.0' = { name: take('avm.res.container-registry.registry.${name}', 64) @@ -96,7 +75,7 @@ module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.0' = publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' networkRuleBypassOptions: enablePrivateNetworking ? 'AzureServices' : null networkRuleSetDefaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' - roleAssignments: concat(pullRoleAssignments, pushRoleAssignments) + roleAssignments: pullRoleAssignments managedIdentities: managedIdentities privateEndpoints: enablePrivateNetworking ? [ From cd981265f9bd063e538b0869f5ac24ca7abf8663 Mon Sep 17 00:00:00 2001 From: Ragini-Microsoft Date: Tue, 7 Jul 2026 10:15:46 +0530 Subject: [PATCH 11/12] Refactor build_and_deploy_images scripts for improved clarity and functionality --- infra/scripts/build_and_deploy_images.ps1 | 368 ++++++++++----- infra/scripts/build_and_deploy_images.sh | 520 ++++++++++++++-------- 2 files changed, 593 insertions(+), 295 deletions(-) diff --git a/infra/scripts/build_and_deploy_images.ps1 b/infra/scripts/build_and_deploy_images.ps1 index 70de3a654..f3183e17d 100644 --- a/infra/scripts/build_and_deploy_images.ps1 +++ b/infra/scripts/build_and_deploy_images.ps1 @@ -1,89 +1,176 @@ <# .SYNOPSIS - Builds the frontend and backend images remotely in ACR (az acr build - no - local Docker needed), pushes them to the ACR created by `azd up`, updates the - frontend App Service to the new image and restarts it, then recreates the - backend Container Instance on the new image. + Post-deployment script. Builds the frontend/backend images remotely in ACR + (az acr build, no local Docker), updates the frontend App Service and restarts + it, then recreates the backend Container Instance (ACI) on the new image. .DESCRIPTION - Run AFTER `azd up`. Configuration is read from the azd environment. + No ResourceGroupName -> config from the azd environment. + ResourceGroupName -> config from that RG's latest successful deployment. - In private/WAF deployments the registry has public network access disabled, so - the remote `az acr build` agent cannot authenticate to push. In that case this - script temporarily enables public access (default action Allow) for the build, - then re-locks the registry afterwards. Image pulls keep working over the private - endpoint (App Service VNet image pull, ACI VNet injection). + In WAF mode ACR public access is disabled, so the build agent can't push. The + script temporarily opens the registry and re-locks it (finally); pulls keep + working over the private endpoint. + +.PARAMETER ResourceGroupName + Optional. Resolve config from this RG's latest deployment outputs instead of azd. #> +param( + [Parameter(Position = 0)] + [string]$ResourceGroupName = '' +) $ErrorActionPreference = 'Stop' +$FrontendImage = 'content-gen-app' +$BackendImage = 'content-gen-api' +$Tag = 'latest' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path +$FrontendContext = Join-Path $RepoRoot 'src\App' +$FrontendDockerfile = Join-Path $RepoRoot 'src\App\WebApp.Dockerfile' +$BackendContext = Join-Path $RepoRoot 'src\backend' +$BackendDockerfile = Join-Path $RepoRoot 'src\backend\ApiApp.Dockerfile' + +$script:ResourceGroup = '' +$script:AcrName = '' +$script:AppService = '' +$script:ContainerGroup = '' +$script:AcrLoginServer = '' + +$script:AcrPublicAccess = '' +$script:AcrOpenedForBuild = $false +$script:NewBackendUrl = $null + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Run az and throw on non-zero exit so failures surface immediately. function Invoke-Az { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$AzArgs) & az @AzArgs if ($LASTEXITCODE -ne 0) { throw "az $($AzArgs -join ' ') failed with exit code $LASTEXITCODE" } } -# Load azd environment outputs into the process environment. -if (Get-Command azd -ErrorAction SilentlyContinue) { +function Confirm-AzureAuth { + Write-Host 'Checking Azure authentication...' + az account show 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host 'Already authenticated with Azure.' + } else { + Write-Host 'Authenticating with Azure CLI...' + az login --use-device-code | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Failed to authenticate with Azure.' } + } +} + +# Config from the azd environment (default path). +function Get-ConfigFromAzdEnv { + Write-Host 'Getting values from azd environment...' + if (-not (Get-Command azd -ErrorAction SilentlyContinue)) { + Write-Warning 'azd is not installed and no resource group argument was provided.' + return $false + } + foreach ($line in (azd env get-values 2>$null)) { if ($line -match '^\s*([A-Za-z0-9_]+)="?(.*?)"?\s*$') { - Set-Item -Path "Env:$($Matches[1])" -Value $Matches[2] + switch ($Matches[1]) { + 'AZURE_ENV_CONTAINER_REGISTRY_NAME' { $script:AcrName = $Matches[2] } + 'RESOURCE_GROUP_NAME' { $script:ResourceGroup = $Matches[2] } + 'APP_SERVICE_NAME' { $script:AppService = $Matches[2] } + 'CONTAINER_INSTANCE_NAME' { $script:ContainerGroup = $Matches[2] } + } } } -} -$AcrName = $env:AZURE_ENV_CONTAINER_REGISTRY_NAME -$ResourceGroup = $env:RESOURCE_GROUP_NAME -$AppService = $env:APP_SERVICE_NAME -$ContainerGroup = $env:CONTAINER_INSTANCE_NAME + if (-not $script:AcrName -or -not $script:ResourceGroup -or -not $script:AppService -or -not $script:ContainerGroup) { + Write-Warning ('One or more required values could not be retrieved from the azd environment ' + + '(AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME).') + return $false + } + return $true +} -$FrontendImage = 'content-gen-app' -$BackendImage = 'content-gen-api' -$Tag = 'latest' +# Config from the latest successful deployment's outputs in the given RG. +# PowerShell property access is case-insensitive, so ARM's lower-cased first token +# (e.g. apP_SERVICE_NAME) still matches. +function Get-ConfigFromDeployment { + param([string]$Rg) + Write-Host 'Getting values from Azure deployment outputs...' + $script:ResourceGroup = $Rg -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path + $deploymentName = az deployment group list -g $Rg ` + --query "sort_by([?properties.provisioningState=='Succeeded'], &properties.timestamp)[-1].name" -o tsv 2>$null + if (-not $deploymentName) { + Write-Warning "Could not find a successful deployment in resource group '$Rg'." + return $false + } + Write-Host "Using deployment outputs from: $deploymentName" -if (-not $AcrName -or -not $ResourceGroup -or -not $AppService -or -not $ContainerGroup) { - throw "Missing required azd environment values (AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME)." -} + $outputsJson = az deployment group show -g $Rg -n $deploymentName --query 'properties.outputs' -o json 2>$null + if (-not $outputsJson) { + Write-Warning 'Could not read deployment outputs.' + return $false + } + $outputs = $outputsJson | ConvertFrom-Json -$AcrLoginServer = "$AcrName.azurecr.io" + $script:AcrName = $outputs.AZURE_ENV_CONTAINER_REGISTRY_NAME.value + $script:AppService = $outputs.APP_SERVICE_NAME.value + $script:ContainerGroup = $outputs.CONTAINER_INSTANCE_NAME.value -# In private/WAF mode the registry's public network access is disabled. Temporarily -# open it so the remote build agent can push, and re-lock it in the finally block. -$AcrPublicAccess = Invoke-Az acr show -n $AcrName --query publicNetworkAccess --output tsv -$AcrOpenedForBuild = $false + if (-not $script:AcrName -or -not $script:AppService -or -not $script:ContainerGroup) { + Write-Warning 'One or more required values could not be retrieved from deployment outputs.' + return $false + } + return $true +} -try { - if ($AcrPublicAccess -eq 'Disabled') { - Write-Host "===== ACR public access is disabled (private/WAF mode) - temporarily enabling it for the build =====" -ForegroundColor Yellow - Invoke-Az acr update -n $AcrName --public-network-enabled true --default-action Allow --output none --only-show-errors - $AcrOpenedForBuild = $true - Write-Host "Waiting for the network rule change to propagate..." -ForegroundColor Yellow +# Open ACR public access for the remote build (WAF mode); flags it for re-lock. +function Enable-AcrPublicAccess { + $script:AcrPublicAccess = Invoke-Az acr show -n $script:AcrName --query publicNetworkAccess --output tsv + if ($script:AcrPublicAccess -eq 'Disabled') { + Write-Host '===== ACR public access is disabled (private/WAF mode) - temporarily enabling it for the build =====' -ForegroundColor Yellow + Invoke-Az acr update -n $script:AcrName --public-network-enabled true --default-action Allow --output none --only-show-errors + $script:AcrOpenedForBuild = $true + Write-Host 'Waiting for the network rule change to propagate...' -ForegroundColor Yellow Start-Sleep -Seconds 45 } +} - # ===== Build & push frontend image ===== - Write-Host "===== Building Frontend Image ($FrontendImage`:$Tag) =====" -ForegroundColor Yellow - Invoke-Az acr build --registry $AcrName --image "$FrontendImage`:$Tag" ` - --file (Join-Path $RepoRoot 'src\App\WebApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\App') +# Re-lock the ACR if this script opened it (called from finally). +function Restore-AcrPublicAccess { + if ($script:AcrOpenedForBuild) { + Write-Host '===== Re-locking ACR (disabling public network access) =====' -ForegroundColor Yellow + az acr update -n $script:AcrName --public-network-enabled false --default-action Deny --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to re-disable ACR public network access. Re-lock it manually: az acr update -n $($script:AcrName) --public-network-enabled false --default-action Deny" + } + } +} - # ===== Build & push backend image ===== - Write-Host "===== Building Backend Image ($BackendImage`:$Tag) =====" -ForegroundColor Yellow - Invoke-Az acr build --registry $AcrName --image "$BackendImage`:$Tag" ` - --file (Join-Path $RepoRoot 'src\backend\ApiApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\backend') +# Build and push a single image remotely in ACR. +function Build-AndPushImage { + param( + [string]$ImageRef, + [string]$Dockerfile, + [string]$Context + ) + Write-Host "===== Building Image ($ImageRef) =====" -ForegroundColor Yellow + Invoke-Az acr build --registry $script:AcrName --image $ImageRef ` + --file $Dockerfile --platform linux $Context +} - # ===== Recreate backend Container Instance with the new image (done FIRST) ===== - # ACI cannot update a container group's image in place, and a restart reuses the - # originally deployed (hello-world) image. So we capture the existing container - # group's configuration and recreate it pointing at the new ACR image. We do this - # before touching the frontend so the new private IP is known up-front and the - # frontend only needs a single restart. - Write-Host "===== Recreating Backend Container Instance ($ContainerGroup) =====" -ForegroundColor Yellow +# Recreate the backend ACI on the new image. ACI can't swap an image in place and a +# restart reuses the original (hello-world) image, so we read the current group's +# config and recreate it. Done before the frontend so its new private IP (WAF mode) +# is known up-front and the frontend only restarts once. +function New-BackendContainerInstance { + Write-Host "===== Recreating Backend Container Instance ($($script:ContainerGroup)) =====" -ForegroundColor Yellow - $Aci = az container show -g $ResourceGroup -n $ContainerGroup -o json | ConvertFrom-Json - if (-not $Aci) { throw "Could not read existing container group '$ContainerGroup'." } + $Aci = az container show -g $script:ResourceGroup -n $script:ContainerGroup -o json | ConvertFrom-Json + if (-not $Aci) { throw "Could not read existing container group '$($script:ContainerGroup)'." } $AciCpu = $Aci.containers[0].resources.requests.cpu $AciMemory = $Aci.containers[0].resources.requests.memoryInGb @@ -97,9 +184,9 @@ try { $CreateArgs = @( 'container', 'create', - '--resource-group', $ResourceGroup, - '--name', $ContainerGroup, - '--image', "$AcrLoginServer/$BackendImage`:$Tag", + '--resource-group', $script:ResourceGroup, + '--name', $script:ContainerGroup, + '--image', "$($script:AcrLoginServer)/$BackendImage`:$Tag", '--cpu', $AciCpu, '--memory', $AciMemory, '--ports', $AciPort, @@ -107,7 +194,7 @@ try { '--restart-policy', $AciRestart, '--assign-identity', $AciUami, '--acr-identity', $AciUami, - '--registry-login-server', $AcrLoginServer + '--registry-login-server', $script:AcrLoginServer ) if ($AciSubnetId) { $CreateArgs += @('--subnet', $AciSubnetId) @@ -119,53 +206,32 @@ try { Invoke-Az @CreateArgs --output none - # In private/WAF mode the ACI has a private IP and no FQDN. Recreating it above - # assigns a NEW private IP, so the frontend's BACKEND_URL (baked at deploy time) - # is now stale. Read the new IP so we can set BACKEND_URL before the single - # frontend restart below. (Public mode keeps its stable dns-label FQDN.) - $NewBackendUrl = $null + # WAF mode: the recreated ACI gets a NEW private IP (no FQDN), so the frontend's + # baked BACKEND_URL is stale. Capture the new IP to update it before the restart. if ($AciSubnetId) { - $NewAci = az container show -g $ResourceGroup -n $ContainerGroup -o json | ConvertFrom-Json + $NewAci = az container show -g $script:ResourceGroup -n $script:ContainerGroup -o json | ConvertFrom-Json $NewIp = $NewAci.ipAddress.ip if ($NewIp) { - $NewBackendUrl = "http://${NewIp}:$AciPort" + $script:NewBackendUrl = "http://${NewIp}:$AciPort" } else { - Write-Warning "Could not determine the new ACI private IP; frontend BACKEND_URL not updated." + Write-Warning 'Could not determine the new ACI private IP; frontend BACKEND_URL not updated.' } } +} - # ===== Update frontend App Service image (managed-identity ACR pull) ===== - Write-Host "===== Updating Frontend App Service ($AppService) =====" -ForegroundColor Yellow - Invoke-Az webapp config container set -g $ResourceGroup -n $AppService ` - --container-image-name "$AcrLoginServer/$FrontendImage`:$Tag" ` - --container-registry-url "https://$AcrLoginServer" ` - --output none --only-show-errors - - # Point the frontend at the new ACI private IP (private/WAF mode) before restart. - if ($NewBackendUrl) { - Write-Host "===== Updating frontend BACKEND_URL to $NewBackendUrl =====" -ForegroundColor Yellow - Invoke-Az webapp config appsettings set -g $ResourceGroup -n $AppService ` - --settings "BACKEND_URL=$NewBackendUrl" --output none --only-show-errors - } - - # `webapp config container set` performs a partial write of config/web that only - # sets the image + registry auth; it resets the managed-identity credentials bicep - # configured for ACR pull. The ACR admin user is disabled in BOTH modes, so we must - # always re-apply acrUseManagedIdentityCreds / acrUserManagedIdentityID or the next - # restart fails with ACRTokenRetrievalFailure / ImagePullFailure. - Write-Host "===== Restoring managed-identity ACR-pull config on frontend =====" -ForegroundColor Yellow - $Uami = Invoke-Az webapp identity show -g $ResourceGroup -n $AppService --query "userAssignedIdentities | keys(@) | [0]" --output tsv - if (-not $Uami) { throw "Could not resolve user-assigned identity for web app '$AppService'." } - $ClientId = Invoke-Az identity show --ids $Uami --query clientId --output tsv - if (-not $ClientId) { throw "Could not resolve clientId for identity '$Uami'." } - $WebId = Invoke-Az webapp show -g $ResourceGroup -n $AppService --query id --output tsv - if (-not $WebId) { throw "Could not resolve resource id for web app '$AppService'." } - - if ($AcrPublicAccess -eq 'Disabled') { - # Private/WAF: additionally restore the VNet image-pull properties. The - # vnetImagePullEnabled flag is a SITE-level property; the others live on - # config/web. `az resource update` does not persist these reliably, so PATCH - # via the ARM REST API. +# Re-apply the managed-identity ACR-pull config on the frontend. `webapp config +# container set` partially rewrites config/web and drops the MI creds; the ACR admin +# user is disabled in both modes, so without re-applying +# acrUseManagedIdentityCreds/acrUserManagedIdentityID the next restart fails with +# ImagePullFailure. WAF mode also restores the VNet image-pull props via REST +# (vnetImagePullEnabled is site-level; the others live on config/web). +function Restore-FrontendAcrPullConfig { + param( + [string]$ClientId, + [string]$WebId + ) + Write-Host '===== Restoring managed-identity ACR-pull config on frontend =====' -ForegroundColor Yellow + if ($script:AcrPublicAccess -eq 'Disabled') { $WebCfgBody = (@{ properties = @{ acrUseManagedIdentityCreds = $true acrUserManagedIdentityID = $ClientId @@ -185,27 +251,103 @@ try { --headers "Content-Type=application/json" --body "@$Tmp2" --output none --only-show-errors Remove-Item $Tmp2 -Force } else { - # Public (non-WAF): identical to the rc-acr-update-cg branch (proven path). + # Public (non-WAF): proven rc-acr-update-cg path. Invoke-Az resource update --ids "$WebId/config/web" ` --set properties.acrUseManagedIdentityCreds=true ` --set properties.acrUserManagedIdentityID=$ClientId --output none } +} + +# Update the frontend to the new image + ACR-pull config + backend URL, then restart. +function Update-FrontendApp { + Write-Host "===== Updating Frontend App Service ($($script:AppService)) =====" -ForegroundColor Yellow + + $Uami = Invoke-Az webapp identity show -g $script:ResourceGroup -n $script:AppService --query "userAssignedIdentities | keys(@) | [0]" --output tsv + if (-not $Uami) { throw "Could not resolve user-assigned identity for web app '$($script:AppService)'." } + $ClientId = Invoke-Az identity show --ids $Uami --query clientId --output tsv + if (-not $ClientId) { throw "Could not resolve clientId for identity '$Uami'." } + + Invoke-Az webapp config container set -g $script:ResourceGroup -n $script:AppService ` + --container-image-name "$($script:AcrLoginServer)/$FrontendImage`:$Tag" ` + --container-registry-url "https://$($script:AcrLoginServer)" ` + --output none --only-show-errors + + $WebId = Invoke-Az webapp show -g $script:ResourceGroup -n $script:AppService --query id --output tsv + if (-not $WebId) { throw "Could not resolve resource id for web app '$($script:AppService)'." } + + # Point the frontend at the new ACI private IP (WAF mode) before restart. + if ($script:NewBackendUrl) { + Write-Host "===== Updating frontend BACKEND_URL to $($script:NewBackendUrl) =====" -ForegroundColor Yellow + Invoke-Az webapp config appsettings set -g $script:ResourceGroup -n $script:AppService ` + --settings "BACKEND_URL=$($script:NewBackendUrl)" --output none --only-show-errors + } - # Single frontend restart, now that image, BACKEND_URL and ACR-pull config are set. - Write-Host "Restarting Frontend App Service..." -ForegroundColor Yellow - Invoke-Az webapp restart -g $ResourceGroup -n $AppService --output none + Restore-FrontendAcrPullConfig -ClientId $ClientId -WebId $WebId + + Write-Host 'Restarting Frontend App Service...' -ForegroundColor Yellow + Invoke-Az webapp restart -g $script:ResourceGroup -n $script:AppService --output none } -finally { - if ($AcrOpenedForBuild) { - Write-Host "===== Re-locking ACR (disabling public network access) =====" -ForegroundColor Yellow - az acr update -n $AcrName --public-network-enabled false --default-action Deny --output none --only-show-errors - if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to re-disable ACR public network access. Re-lock it manually: az acr update -n $AcrName --public-network-enabled false --default-action Deny" - } + +function Write-Config { + Write-Host '' + Write-Host '===============================================' + Write-Host 'Values to be used:' + Write-Host '===============================================' + Write-Host "Resource Group: $($script:ResourceGroup)" + Write-Host "ACR Name: $($script:AcrName)" + Write-Host "ACR Login Server: $($script:AcrLoginServer)" + Write-Host "App Service: $($script:AppService)" + Write-Host "Container Group: $($script:ContainerGroup)" + Write-Host "Frontend Image: $FrontendImage`:$Tag" + Write-Host "Backend Image: $BackendImage`:$Tag" + Write-Host '===============================================' + Write-Host '' +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +Write-Host '===============================================' +Write-Host 'Build & Deploy Container Images' +Write-Host '===============================================' + +Confirm-AzureAuth + +# Config: azd environment by default, deployment outputs when a RG is given. +if (-not $ResourceGroupName) { + if (-not (Get-ConfigFromAzdEnv)) { + Write-Host '' + Write-Host 'Failed to get values from the azd environment.' + Write-Host 'Provide a resource group name to resolve values from deployment outputs instead:' + Write-Host " Usage: $($MyInvocation.MyCommand.Name) [ResourceGroupName]" + exit 1 } +} else { + if (-not (Get-ConfigFromDeployment -Rg $ResourceGroupName)) { + Write-Host 'Failed to get values from deployment outputs.' + exit 1 + } +} + +$script:AcrLoginServer = "$($script:AcrName).azurecr.io" + +Write-Config + +try { + Enable-AcrPublicAccess + + Build-AndPushImage -ImageRef "$FrontendImage`:$Tag" -Dockerfile $FrontendDockerfile -Context $FrontendContext + Build-AndPushImage -ImageRef "$BackendImage`:$Tag" -Dockerfile $BackendDockerfile -Context $BackendContext + + # Recreate the backend ACI first (new IP up-front), then update/restart frontend once. + New-BackendContainerInstance + Update-FrontendApp +} +finally { + Restore-AcrPublicAccess } -Write-Host "" -Write-Host "===== Done =====" -ForegroundColor Green -Write-Host "Frontend image: $AcrLoginServer/$FrontendImage`:$Tag" -ForegroundColor Cyan -Write-Host "Backend image: $AcrLoginServer/$BackendImage`:$Tag" -ForegroundColor Cyan +Write-Host '' +Write-Host '===== Done =====' -ForegroundColor Green +Write-Host "Frontend image: $($script:AcrLoginServer)/$FrontendImage`:$Tag" -ForegroundColor Cyan +Write-Host "Backend image: $($script:AcrLoginServer)/$BackendImage`:$Tag" -ForegroundColor Cyan diff --git a/infra/scripts/build_and_deploy_images.sh b/infra/scripts/build_and_deploy_images.sh index 6bb3e9ce4..fc42c12d6 100644 --- a/infra/scripts/build_and_deploy_images.sh +++ b/infra/scripts/build_and_deploy_images.sh @@ -3,58 +3,134 @@ set -euo pipefail # ============================================================================ # build_and_deploy_images.sh -# ---------------------------------------------------------------------------- -# Builds the frontend and backend images remotely in ACR (az acr build - no -# local Docker needed), pushes them to the ACR created by `azd up`, updates the -# frontend App Service to the new image and restarts it, then recreates the -# backend Container Instance on the new image. # -# Run AFTER `azd up`. Configuration is read from the azd environment. +# Post-deployment script. Builds the frontend/backend images remotely in ACR +# (az acr build, no local Docker), updates the frontend App Service and restarts +# it, then recreates the backend Container Instance (ACI) on the new image. # -# In private/WAF deployments the registry has public network access disabled, so -# the remote `az acr build` agent cannot authenticate to push. In that case this -# script temporarily enables public access (default action Allow) for the build, -# then re-locks the registry afterwards (trap on EXIT). Image pulls keep working -# over the private endpoint (App Service VNet image pull, ACI VNet injection). +# Usage: bash ./infra/scripts/build_and_deploy_images.sh [ResourceGroupName] +# No argument -> config from the azd environment. +# ResourceGroupName -> config from that RG's latest successful deployment. +# +# In WAF mode ACR public access is disabled, so the build agent can't push. The +# script temporarily opens the registry and re-locks it on exit (trap); pulls +# keep working over the private endpoint. # ============================================================================ -# Load azd environment outputs into the shell. Parse without `eval` so that any -# command substitution embedded in a value cannot execute in this shell. -if command -v azd >/dev/null 2>&1; then +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +FRONTEND_IMAGE="content-gen-app" +BACKEND_IMAGE="content-gen-api" +TAG="latest" +FRONTEND_CONTEXT="${REPO_ROOT}/src/App" +FRONTEND_DOCKERFILE="${REPO_ROOT}/src/App/WebApp.Dockerfile" +BACKEND_CONTEXT="${REPO_ROOT}/src/backend" +BACKEND_DOCKERFILE="${REPO_ROOT}/src/backend/ApiApp.Dockerfile" + +RESOURCE_GROUP="" +ACR_NAME="" +APP_SERVICE="" +CONTAINER_GROUP="" +ACR_LOGIN_SERVER="" + +ACR_PUBLIC_ACCESS="" +ACR_OPENED_FOR_BUILD=false +NEW_BACKEND_URL="" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +check_azure_auth() { + echo "Checking Azure authentication..." + if az account show >/dev/null 2>&1; then + echo "Already authenticated with Azure." + else + echo "Authenticating with Azure CLI..." + if ! az login --use-device-code; then + echo "ERROR: Failed to authenticate with Azure." >&2 + exit 1 + fi + fi +} + +# Config from the azd environment. Parses `azd env get-values` without `eval` so +# any command substitution embedded in a value cannot execute here. +get_values_from_azd_env() { + echo "Getting values from azd environment..." + if ! command -v azd >/dev/null 2>&1; then + echo "ERROR: azd is not installed and no resource group argument was provided." >&2 + return 1 + fi + while IFS='=' read -r _key _val; do [ -z "${_key}" ] && continue _val="${_val%\"}" _val="${_val#\"}" - export "${_key}=${_val}" + case "${_key}" in + AZURE_ENV_CONTAINER_REGISTRY_NAME) ACR_NAME="${_val}" ;; + RESOURCE_GROUP_NAME) RESOURCE_GROUP="${_val}" ;; + APP_SERVICE_NAME) APP_SERVICE="${_val}" ;; + CONTAINER_INSTANCE_NAME) CONTAINER_GROUP="${_val}" ;; + esac done < <(azd env get-values 2>/dev/null) -fi -ACR_NAME="${AZURE_ENV_CONTAINER_REGISTRY_NAME:-}" -RESOURCE_GROUP="${RESOURCE_GROUP_NAME:-}" -APP_SERVICE="${APP_SERVICE_NAME:-}" -CONTAINER_GROUP="${CONTAINER_INSTANCE_NAME:-}" + if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ] || [ -z "${APP_SERVICE}" ] || [ -z "${CONTAINER_GROUP}" ]; then + echo "ERROR: One or more required values could not be retrieved from the azd environment" \ + "(AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME)." >&2 + return 1 + fi + return 0 +} + +# Config from the latest successful deployment's outputs in the given RG. ARM +# lower-cases the first token of each output name (APP_SERVICE_NAME -> +# apP_SERVICE_NAME), so extraction is case-insensitive. +get_values_from_az_deployment() { + echo "Getting values from Azure deployment outputs..." + RESOURCE_GROUP="$1" -FRONTEND_IMAGE="content-gen-app" -BACKEND_IMAGE="content-gen-api" -TAG="latest" + local deploymentName + deploymentName="$(az deployment group list -g "${RESOURCE_GROUP}" \ + --query "sort_by([?properties.provisioningState=='Succeeded'], &properties.timestamp)[-1].name" -o tsv 2>/dev/null || true)" + if [ -z "${deploymentName}" ]; then + echo "ERROR: Could not find a successful deployment in resource group '${RESOURCE_GROUP}'." >&2 + return 1 + fi + echo "Using deployment outputs from: ${deploymentName}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + local outputs + outputs="$(az deployment group show -g "${RESOURCE_GROUP}" -n "${deploymentName}" --query "properties.outputs" -o json 2>/dev/null || true)" -if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ] || [ -z "${APP_SERVICE}" ] || [ -z "${CONTAINER_GROUP}" ]; then - echo "ERROR: Missing required azd environment values (AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME)." >&2 - exit 1 -fi + extract_output() { + echo "${outputs}" | grep -i -A 3 "\"$1\"" | grep '"value"' | head -n1 | sed 's/.*"value": *"\([^"]*\)".*/\1/' || true + } -ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io" + ACR_NAME="$(extract_output AZURE_ENV_CONTAINER_REGISTRY_NAME)" + APP_SERVICE="$(extract_output APP_SERVICE_NAME)" + CONTAINER_GROUP="$(extract_output CONTAINER_INSTANCE_NAME)" -# In private/WAF mode the registry's public network access is disabled, so the -# remote build agent cannot authenticate to push. Temporarily open the registry -# for the build and re-lock it on exit (trap). Image pulls keep working over the -# private endpoint (App Service VNet image pull, ACI VNet injection). -ACR_PUBLIC_ACCESS="$(az acr show -n "${ACR_NAME}" --query publicNetworkAccess -o tsv)" -ACR_OPENED_FOR_BUILD=false + if [ -z "${ACR_NAME}" ] || [ -z "${APP_SERVICE}" ] || [ -z "${CONTAINER_GROUP}" ]; then + echo "ERROR: One or more required values could not be retrieved from deployment outputs." >&2 + return 1 + fi + return 0 +} +# Open ACR public access for the remote build (WAF mode); flags it for re-lock. +enable_acr_public_access() { + ACR_PUBLIC_ACCESS="$(az acr show -n "${ACR_NAME}" --query publicNetworkAccess -o tsv)" + if [ "${ACR_PUBLIC_ACCESS}" = "Disabled" ]; then + echo "===== ACR public access is disabled (private/WAF mode) - temporarily enabling it for the build =====" + az acr update -n "${ACR_NAME}" --public-network-enabled true --default-action Allow -o none --only-show-errors + ACR_OPENED_FOR_BUILD=true + echo "Waiting for the network rule change to propagate..." + sleep 45 + fi +} + +# Re-lock the ACR if this script opened it (runs from the EXIT trap). relock_acr() { if [ "${ACR_OPENED_FOR_BUILD}" = true ]; then echo "===== Re-locking ACR (disabling public network access) =====" @@ -62,150 +138,230 @@ relock_acr() { || echo "WARNING: Failed to re-disable ACR public network access. Re-lock it manually: az acr update -n ${ACR_NAME} --public-network-enabled false --default-action Deny" >&2 fi } -trap relock_acr EXIT - -if [ "${ACR_PUBLIC_ACCESS}" = "Disabled" ]; then - echo "===== ACR public access is disabled (private/WAF mode) - temporarily enabling it for the build =====" - az acr update -n "${ACR_NAME}" --public-network-enabled true --default-action Allow -o none --only-show-errors - ACR_OPENED_FOR_BUILD=true - echo "Waiting for the network rule change to propagate..." - sleep 45 -fi - -# ===== Build & push frontend image ===== -echo "===== Building Frontend Image (${FRONTEND_IMAGE}:${TAG}) =====" -az acr build --registry "${ACR_NAME}" --image "${FRONTEND_IMAGE}:${TAG}" \ - --file "${REPO_ROOT}/src/App/WebApp.Dockerfile" --platform linux "${REPO_ROOT}/src/App" - -# ===== Build & push backend image ===== -echo "===== Building Backend Image (${BACKEND_IMAGE}:${TAG}) =====" -az acr build --registry "${ACR_NAME}" --image "${BACKEND_IMAGE}:${TAG}" \ - --file "${REPO_ROOT}/src/backend/ApiApp.Dockerfile" --platform linux "${REPO_ROOT}/src/backend" - -# ===== Recreate backend Container Instance with the new image (done FIRST) ===== -# ACI cannot update a container group's image in place, and a restart reuses the -# originally deployed (hello-world) image. So we capture the existing container -# group's configuration and recreate it pointing at the new ACR image. We do this -# before touching the frontend so the new private IP is known up-front and the -# frontend only needs a single restart. -echo "===== Recreating Backend Container Instance (${CONTAINER_GROUP}) =====" - -ACI_CPU="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.cpu" -o tsv)" -ACI_MEMORY="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.memoryInGb" -o tsv)" -ACI_PORT="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].ports[0].port" -o tsv)" -ACI_OS_TYPE="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "osType" -o tsv)" -ACI_RESTART="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "restartPolicy" -o tsv)" -ACI_DNS_LABEL="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.dnsNameLabel" -o tsv)" -ACI_SUBNET_ID="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "subnetIds[0].id" -o tsv 2>/dev/null || true)" -ACI_UAMI="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv)" - -# Capture env vars as NAME=VALUE (handles values containing '=' such as the -# Application Insights connection string, since az create splits on the first '='). -ENV_VARS=() -while IFS= read -r line; do - [ -n "${line}" ] && ENV_VARS+=("${line}") -done < <(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" \ - --query "containers[0].environmentVariables[].join('=', [name, value])" -o tsv) - -CREATE_ARGS=( - container create - --resource-group "${RESOURCE_GROUP}" - --name "${CONTAINER_GROUP}" - --image "${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" - --cpu "${ACI_CPU}" - --memory "${ACI_MEMORY}" - --ports "${ACI_PORT}" - --os-type "${ACI_OS_TYPE}" - --restart-policy "${ACI_RESTART}" - --assign-identity "${ACI_UAMI}" - --acr-identity "${ACI_UAMI}" - --registry-login-server "${ACR_LOGIN_SERVER}" -) -if [ -n "${ACI_SUBNET_ID}" ]; then - CREATE_ARGS+=(--subnet "${ACI_SUBNET_ID}") -else - CREATE_ARGS+=(--ip-address Public --dns-name-label "${ACI_DNS_LABEL}") -fi -CREATE_ARGS+=(--environment-variables "${ENV_VARS[@]}") - -az "${CREATE_ARGS[@]}" -o none - -# In private/WAF mode the ACI has a private IP and no FQDN. Recreating it above -# assigns a NEW private IP, so the frontend's BACKEND_URL (baked at deploy time) -# is now stale. Read the new IP so we can set BACKEND_URL before the single -# frontend restart below. (Public mode keeps its stable dns-label FQDN.) -NEW_BACKEND_URL="" -if [ -n "${ACI_SUBNET_ID}" ]; then - NEW_IP="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.ip" -o tsv)" - if [ -n "${NEW_IP}" ]; then - NEW_BACKEND_URL="http://${NEW_IP}:${ACI_PORT}" + +# Build and push a single image remotely in ACR. Args: +build_and_push_image() { + local imageRef="$1" + local dockerfile="$2" + local context="$3" + echo "===== Building Image (${imageRef}) =====" + az acr build --registry "${ACR_NAME}" --image "${imageRef}" \ + --file "${dockerfile}" --platform linux "${context}" +} + +# Recreate the backend ACI on the new image. ACI can't swap an image in place and +# a restart reuses the original (hello-world) image, so we read the current group's +# config and recreate it. Done before the frontend so its new private IP (WAF mode) +# is known up-front and the frontend only restarts once. +recreate_backend_aci() { + echo "===== Recreating Backend Container Instance (${CONTAINER_GROUP}) =====" + + local aci_cpu aci_memory aci_port aci_os_type aci_restart aci_dns_label aci_subnet_id aci_uami + aci_cpu="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.cpu" -o tsv)" + aci_memory="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.memoryInGb" -o tsv)" + aci_port="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].ports[0].port" -o tsv)" + aci_os_type="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "osType" -o tsv)" + aci_restart="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "restartPolicy" -o tsv)" + aci_dns_label="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.dnsNameLabel" -o tsv)" + aci_subnet_id="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "subnetIds[0].id" -o tsv 2>/dev/null || true)" + aci_uami="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv)" + + # Capture env vars as NAME=VALUE (values may contain '=', e.g. the App Insights + # connection string; az create splits on the first '='). + local env_vars=() + while IFS= read -r line; do + [ -n "${line}" ] && env_vars+=("${line}") + done < <(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" \ + --query "containers[0].environmentVariables[].join('=', [name, value])" -o tsv) + + local create_args=( + container create + --resource-group "${RESOURCE_GROUP}" + --name "${CONTAINER_GROUP}" + --image "${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" + --cpu "${aci_cpu}" + --memory "${aci_memory}" + --ports "${aci_port}" + --os-type "${aci_os_type}" + --restart-policy "${aci_restart}" + --assign-identity "${aci_uami}" + --acr-identity "${aci_uami}" + --registry-login-server "${ACR_LOGIN_SERVER}" + ) + if [ -n "${aci_subnet_id}" ]; then + create_args+=(--subnet "${aci_subnet_id}") + else + create_args+=(--ip-address Public --dns-name-label "${aci_dns_label}") + fi + create_args+=(--environment-variables "${env_vars[@]}") + + az "${create_args[@]}" -o none + + # WAF mode: the recreated ACI gets a NEW private IP (no FQDN), so the frontend's + # baked BACKEND_URL is stale. Capture the new IP to update it before the restart. + if [ -n "${aci_subnet_id}" ]; then + local new_ip + new_ip="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.ip" -o tsv)" + if [ -n "${new_ip}" ]; then + NEW_BACKEND_URL="http://${new_ip}:${aci_port}" + else + echo "WARNING: Could not determine the new ACI private IP; frontend BACKEND_URL not updated." >&2 + fi + fi +} + +# Re-apply the managed-identity ACR-pull config on the frontend. `webapp config +# container set` partially rewrites config/web and drops the MI creds; the ACR +# admin user is disabled in both modes, so without re-applying +# acrUseManagedIdentityCreds/acrUserManagedIdentityID the next restart fails with +# ImagePullFailure. WAF mode also restores the VNet image-pull props via REST +# (vnetImagePullEnabled is site-level; the others live on config/web). +# Args: +restore_frontend_acr_pull_config() { + local clientId="$1" + local webAppId="$2" + echo "===== Restoring managed-identity ACR-pull config on frontend =====" + if [ "${ACR_PUBLIC_ACCESS}" = "Disabled" ]; then + az rest --method patch \ + --uri "https://management.azure.com${webAppId}/config/web?api-version=2023-12-01" \ + --headers "Content-Type=application/json" \ + --body "{\"properties\":{\"acrUseManagedIdentityCreds\":true,\"acrUserManagedIdentityID\":\"${clientId}\",\"vnetRouteAllEnabled\":true}}" \ + -o none --only-show-errors + az rest --method patch \ + --uri "https://management.azure.com${webAppId}?api-version=2023-12-01" \ + --headers "Content-Type=application/json" \ + --body '{"properties":{"vnetImagePullEnabled":true}}' \ + -o none --only-show-errors + else + # Public (non-WAF): proven rc-acr-update-cg path. + az resource update --ids "${webAppId}/config/web" \ + --set properties.acrUseManagedIdentityCreds=true \ + --set properties.acrUserManagedIdentityID="${clientId}" -o none + fi +} + +# Update the frontend to the new image + ACR-pull config + backend URL, then restart. +update_frontend_app() { + echo "===== Updating Frontend App Service (${APP_SERVICE}) =====" + local uami_rid client_id webapp_id + uami_rid="$(az webapp identity show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --query "userAssignedIdentities | keys(@) | [0]" -o tsv)" + if [ -z "${uami_rid}" ]; then + echo "ERROR: Could not resolve user-assigned identity for web app '${APP_SERVICE}'." >&2 + exit 1 + fi + client_id="$(az identity show --ids "${uami_rid}" --query clientId -o tsv)" + if [ -z "${client_id}" ]; then + echo "ERROR: Could not resolve clientId for identity '${uami_rid}'." >&2 + exit 1 + fi + + az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --container-image-name "${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" \ + --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none --only-show-errors + + webapp_id="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" + if [ -z "${webapp_id}" ]; then + echo "ERROR: Could not resolve resource id for web app '${APP_SERVICE}'." >&2 + exit 1 + fi + + # Point the frontend at the new ACI private IP (WAF mode) before restart. + if [ -n "${NEW_BACKEND_URL}" ]; then + echo "===== Updating frontend BACKEND_URL to ${NEW_BACKEND_URL} =====" + az webapp config appsettings set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --settings "BACKEND_URL=${NEW_BACKEND_URL}" -o none --only-show-errors + fi + + restore_frontend_acr_pull_config "${client_id}" "${webapp_id}" + + echo "Restarting Frontend App Service..." + az webapp restart -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" -o none +} + +print_config() { + echo "" + echo "===============================================" + echo "Values to be used:" + echo "===============================================" + echo "Resource Group: ${RESOURCE_GROUP}" + echo "ACR Name: ${ACR_NAME}" + echo "ACR Login Server: ${ACR_LOGIN_SERVER}" + echo "App Service: ${APP_SERVICE}" + echo "Container Group: ${CONTAINER_GROUP}" + echo "Frontend Image: ${FRONTEND_IMAGE}:${TAG}" + echo "Backend Image: ${BACKEND_IMAGE}:${TAG}" + echo "===============================================" + echo "" +} + +# Always re-lock the ACR (if opened) on exit and preserve the exit code. +cleanup_on_exit() { + local exit_code=$? + relock_acr + echo "" + if [ ${exit_code} -ne 0 ]; then + echo "Script failed (exit code ${exit_code})." + fi + exit ${exit_code} +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + local resourceGroupArg="" + while [[ $# -gt 0 ]]; do + case "$1" in + *) + if [ -z "${resourceGroupArg}" ]; then + resourceGroupArg="$1" + fi + shift + ;; + esac + done + + echo "===============================================" + echo "Build & Deploy Container Images" + echo "===============================================" + + check_azure_auth + + # Config: azd environment by default, deployment outputs when a RG is given. + if [ -z "${resourceGroupArg}" ]; then + if ! get_values_from_azd_env; then + echo "" >&2 + echo "Failed to get values from the azd environment." >&2 + echo "Provide a resource group name to resolve values from deployment outputs instead:" >&2 + echo " Usage: $0 [ResourceGroupName]" >&2 + exit 1 + fi else - echo "WARNING: Could not determine the new ACI private IP; frontend BACKEND_URL not updated." >&2 - fi -fi - -# ===== Update frontend App Service image (managed-identity ACR pull) ===== -echo "===== Updating Frontend App Service (${APP_SERVICE}) =====" -UAMI_RID="$(az webapp identity show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ - --query "userAssignedIdentities | keys(@) | [0]" -o tsv)" -if [ -z "${UAMI_RID}" ]; then - echo "ERROR: Could not resolve user-assigned identity for web app '${APP_SERVICE}'." >&2 - exit 1 -fi -CLIENT_ID="$(az identity show --ids "${UAMI_RID}" --query clientId -o tsv)" -if [ -z "${CLIENT_ID}" ]; then - echo "ERROR: Could not resolve clientId for identity '${UAMI_RID}'." >&2 - exit 1 -fi - -az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ - --container-image-name "${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" \ - --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none --only-show-errors - -WEBAPP_ID="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" -if [ -z "${WEBAPP_ID}" ]; then - echo "ERROR: Could not resolve resource id for web app '${APP_SERVICE}'." >&2 - exit 1 -fi - -# Point the frontend at the new ACI private IP (private/WAF mode) before restart. -if [ -n "${NEW_BACKEND_URL}" ]; then - echo "===== Updating frontend BACKEND_URL to ${NEW_BACKEND_URL} =====" - az webapp config appsettings set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ - --settings "BACKEND_URL=${NEW_BACKEND_URL}" -o none --only-show-errors -fi - -# `webapp config container set` performs a partial write of config/web that only -# sets the image + registry auth; it resets the managed-identity credentials bicep -# configured for ACR pull. The ACR admin user is disabled in BOTH modes, so we must -# always re-apply acrUseManagedIdentityCreds / acrUserManagedIdentityID or the next -# restart fails with ACRTokenRetrievalFailure / ImagePullFailure. In private/WAF mode -# we additionally restore the VNet image-pull properties (vnetImagePullEnabled is a -# SITE-level property; the others live on config/web) via the ARM REST API. -echo "===== Restoring managed-identity ACR-pull config on frontend =====" -if [ "${ACR_PUBLIC_ACCESS}" = "Disabled" ]; then - az rest --method patch \ - --uri "https://management.azure.com${WEBAPP_ID}/config/web?api-version=2023-12-01" \ - --headers "Content-Type=application/json" \ - --body "{\"properties\":{\"acrUseManagedIdentityCreds\":true,\"acrUserManagedIdentityID\":\"${CLIENT_ID}\",\"vnetRouteAllEnabled\":true}}" \ - -o none --only-show-errors - az rest --method patch \ - --uri "https://management.azure.com${WEBAPP_ID}?api-version=2023-12-01" \ - --headers "Content-Type=application/json" \ - --body '{"properties":{"vnetImagePullEnabled":true}}' \ - -o none --only-show-errors -else - # Public (non-WAF): identical to the rc-acr-update-cg branch (proven path). - az resource update --ids "${WEBAPP_ID}/config/web" \ - --set properties.acrUseManagedIdentityCreds=true \ - --set properties.acrUserManagedIdentityID="${CLIENT_ID}" -o none -fi - -# Single frontend restart, now that image, BACKEND_URL and ACR-pull config are set. -echo "Restarting Frontend App Service..." -az webapp restart -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" -o none - -echo "" -echo "===== Done =====" -echo "Frontend image: ${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" -echo "Backend image: ${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" + if ! get_values_from_az_deployment "${resourceGroupArg}"; then + echo "Failed to get values from deployment outputs." >&2 + exit 1 + fi + fi + + ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io" + + print_config + + trap cleanup_on_exit EXIT + enable_acr_public_access + + build_and_push_image "${FRONTEND_IMAGE}:${TAG}" "${FRONTEND_DOCKERFILE}" "${FRONTEND_CONTEXT}" + build_and_push_image "${BACKEND_IMAGE}:${TAG}" "${BACKEND_DOCKERFILE}" "${BACKEND_CONTEXT}" + + # Recreate the backend ACI first (new IP up-front), then update/restart frontend once. + recreate_backend_aci + update_frontend_app + + echo "" + echo "===== Done =====" + echo "Frontend image: ${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" + echo "Backend image: ${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" +} + +main "$@" From 9881ca5fc7e0319581603540a1ab397ad116f7e8 Mon Sep 17 00:00:00 2001 From: Harsh-Microsoft Date: Tue, 14 Jul 2026 18:02:09 +0530 Subject: [PATCH 12/12] fix: add support for existing Log Analytics workspace in Bicep templates --- infra/main.bicep | 12 +++++++++- infra/main.json | 53 +++++++++++++++++++++++++---------------- infra/main_custom.bicep | 11 ++++++++- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index cc41df532..f7e7498d5 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -335,6 +335,16 @@ var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics ? existingLogAnalyticsWorkspaceId : (enableMonitoring ? logAnalyticsWorkspace!.outputs.resourceId : '') + +var existingLogAnalyticsSubscriptionId = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[2] : subscription().subscriptionId +var existingLogAnalyticsResourceGroupName = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[4] : resourceGroup().name +var existingLogAnalyticsWorkspaceName = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[8] : '' +resource existingLogAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = if (useExistingLogAnalytics) { + name: existingLogAnalyticsWorkspaceName + scope: resourceGroup(existingLogAnalyticsSubscriptionId, existingLogAnalyticsResourceGroupName) +} +var logAnalyticsWorkspaceLocation = useExistingLogAnalytics ? existingLogAnalyticsWorkspace!.location : solutionLocation + // ========== Application Insights ========== // var applicationInsightsResourceName = 'appi-${solutionSuffix}' module applicationInsights 'br/public:avm/res/insights/component:0.7.1' = if (enableMonitoring) { @@ -530,7 +540,7 @@ module jumpboxDcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if name: take('avm.res.insights.data-collection-rule.${jumpboxDcrName}', 64) params: { name: jumpboxDcrName - location: solutionLocation + location: logAnalyticsWorkspaceLocation // Must be same as Log Analytics workspace for DCR tags: tags enableTelemetry: enableTelemetry dataCollectionRuleProperties: { diff --git a/infra/main.json b/infra/main.json index 1f1d09923..09ad91910 100644 --- a/infra/main.json +++ b/infra/main.json @@ -5,8 +5,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "16705306315679210410" + "version": "0.45.6.27763", + "templateHash": "7829787750927986140" }, "name": "Intelligent Content Generation Accelerator", "description": "Solution Accelerator for multimodal marketing content generation using Microsoft Agent Framework.\n" @@ -320,6 +320,9 @@ "aiFoundryAiServicesModelDeployment": "[concat(variables('baseModelDeployments'), variables('imageModelDeployment'))]", "aiFoundryAiProjectDescription": "Content Generation AI Foundry Project", "logAnalyticsWorkspaceResourceName": "[format('log-{0}', variables('solutionSuffix'))]", + "existingLogAnalyticsSubscriptionId": "[if(variables('useExistingLogAnalytics'), split(parameters('existingLogAnalyticsWorkspaceId'), '/')[2], subscription().subscriptionId)]", + "existingLogAnalyticsResourceGroupName": "[if(variables('useExistingLogAnalytics'), split(parameters('existingLogAnalyticsWorkspaceId'), '/')[4], resourceGroup().name)]", + "existingLogAnalyticsWorkspaceName": "[if(variables('useExistingLogAnalytics'), split(parameters('existingLogAnalyticsWorkspaceId'), '/')[8], '')]", "applicationInsightsResourceName": "[format('appi-{0}', variables('solutionSuffix'))]", "userAssignedIdentityResourceName": "[format('id-{0}', variables('solutionSuffix'))]", "deployAdminAccessResources": "[and(parameters('enablePrivateNetworking'), parameters('deployBastionAndJumpbox'))]", @@ -404,6 +407,15 @@ "existingResourceGroup" ] }, + "existingLogAnalyticsWorkspace": { + "condition": "[variables('useExistingLogAnalytics')]", + "existing": true, + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2022-10-01", + "subscriptionId": "[variables('existingLogAnalyticsSubscriptionId')]", + "resourceGroup": "[variables('existingLogAnalyticsResourceGroupName')]", + "name": "[variables('existingLogAnalyticsWorkspaceName')]" + }, "logAnalyticsWorkspace": { "condition": "[and(parameters('enableMonitoring'), not(variables('useExistingLogAnalytics')))]", "type": "Microsoft.Resources/deployments", @@ -4847,8 +4859,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "9041711177904037864" + "version": "0.45.6.27763", + "templateHash": "12763336354304899421" } }, "definitions": { @@ -8123,8 +8135,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "6557649947950257832" + "version": "0.45.6.27763", + "templateHash": "12147106769856360363" } }, "parameters": { @@ -21572,9 +21584,7 @@ "name": { "value": "[variables('jumpboxDcrName')]" }, - "location": { - "value": "[variables('solutionLocation')]" - }, + "location": "[if(variables('useExistingLogAnalytics'), createObject('value', reference('existingLogAnalyticsWorkspace', '2022-10-01', 'full').location), createObject('value', variables('solutionLocation')))]", "tags": { "value": "[parameters('tags')]" }, @@ -22823,6 +22833,7 @@ } }, "dependsOn": [ + "existingLogAnalyticsWorkspace", "logAnalyticsWorkspace" ] }, @@ -29490,8 +29501,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "10687287011543111358" + "version": "0.45.6.27763", + "templateHash": "13545973557632951810" } }, "parameters": { @@ -29628,8 +29639,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "11714054478009335704" + "version": "0.45.6.27763", + "templateHash": "16945699569768051557" } }, "parameters": { @@ -29752,8 +29763,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "15628220945119743042" + "version": "0.45.6.27763", + "templateHash": "6358798222636429727" } }, "parameters": { @@ -46898,8 +46909,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "5713718252926392689" + "version": "0.45.6.27763", + "templateHash": "12017817135849598403" } }, "definitions": { @@ -47937,8 +47948,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "2616621918371988230" + "version": "0.45.6.27763", + "templateHash": "11895150199726048164" }, "name": "Site App Settings", "description": "This module deploys a Site App Setting." @@ -48990,8 +49001,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.44.1.10279", - "templateHash": "9581263247326965160" + "version": "0.45.6.27763", + "templateHash": "6984233280324010497" } }, "parameters": { diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index b472494d0..228476957 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -352,6 +352,15 @@ var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics ? existingLogAnalyticsWorkspaceId : (enableMonitoring ? logAnalyticsWorkspace!.outputs.resourceId : '') +var existingLogAnalyticsSubscriptionId = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[2] : subscription().subscriptionId +var existingLogAnalyticsResourceGroupName = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[4] : resourceGroup().name +var existingLogAnalyticsWorkspaceName = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[8] : '' +resource existingLogAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = if (useExistingLogAnalytics) { + name: existingLogAnalyticsWorkspaceName + scope: resourceGroup(existingLogAnalyticsSubscriptionId, existingLogAnalyticsResourceGroupName) +} +var logAnalyticsWorkspaceLocation = useExistingLogAnalytics ? existingLogAnalyticsWorkspace!.location : solutionLocation + // ========== Application Insights ========== // var applicationInsightsResourceName = 'appi-${solutionSuffix}' module applicationInsights 'br/public:avm/res/insights/component:0.7.1' = if (enableMonitoring) { @@ -525,7 +534,7 @@ module jumpboxDcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if name: take('avm.res.insights.data-collection-rule.${jumpboxDcrName}', 64) params: { name: jumpboxDcrName - location: solutionLocation + location: logAnalyticsWorkspaceLocation tags: tags enableTelemetry: enableTelemetry dataCollectionRuleProperties: {