diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 94a0c2eb..78c821dd 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 @Prajwal1-Microsoft @VinaySh-Microsoft @aniaroramsft @Dongbumlee @sethsteenken @toherman-msft @nchandhi @dgp10801 +* @Avijit-Microsoft @Roopan-Microsoft @Prajwal-Microsoft @Vinay-Microsoft @aniaroramsft @Dongbumlee @sethsteenken @toherman-msft @nchandhi @dgp10801 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd2422df..3081a9f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,6 @@ on: paths: - 'infra/**' - 'azure.yaml' - - 'azure_custom.yaml' - 'scripts/**' - '.github/workflows/ci.yml' pull_request: @@ -23,7 +22,6 @@ on: paths: - 'infra/**' - 'azure.yaml' - - 'azure_custom.yaml' - 'scripts/**' - '.github/workflows/ci.yml' schedule: diff --git a/azure.yaml b/azure.yaml index 27ec7d5d..7ad979f6 100644 --- a/azure.yaml +++ b/azure.yaml @@ -12,8 +12,8 @@ hooks: posix: shell: sh run: | - echo "==> Building and pushing container images to the dedicated ACR (remote build)" - bash ./scripts/deploy_container_images.sh + echo "ℹ️ Container images are NOT built automatically. Run the build manually when ready:" + echo " bash ./scripts/acr_build_push.sh" echo "-----" echo "🧭 Web App Details:" echo "✅ Name: $CONTAINER_WEB_APP_NAME" @@ -29,8 +29,8 @@ hooks: windows: shell: pwsh run: | - Write-Host "==> Building and pushing container images to the dedicated ACR (remote build)" - ./scripts/deploy_container_images.ps1 + Write-Host "ℹ️ Container images are NOT built automatically. Run the build manually when ready:" + Write-Host " ./scripts/acr_build_push.ps1" Write-Host "-----" Write-Host "🧭 Web App Details:" Write-Host "✅ Name: $env:CONTAINER_WEB_APP_NAME" diff --git a/azure_custom.yaml b/azure_custom.yaml deleted file mode 100644 index 3dc0a43b..00000000 --- a/azure_custom.yaml +++ /dev/null @@ -1,108 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json - -# This file contains a developer‑focused Azure Developer CLI configuration. It -# extends the default template by defining three services (backend API, -# processor and frontend) and instructs azd to build container images from -# your local source code. All three services are packaged as container apps -# using the Dockerfiles located in their respective project directories. -# After deployment a post‑deploy hook prints the endpoints of the deployed -# container apps. - -name: container-migration-solution-accelerator -metadata: - template: container-migration-solution-accelerator@1.0 - -requiredVersions: - # Require a recent version of azd that supports the packaging - # functionality used here. Versions less than 1.17.1 had a bug in - # remoteBuild. - azd: '>=1.18.2 != 1.23.9' - -infra: - parameters: - backendImageName: ${SERVICE_BACKEND_IMAGE_NAME} - processorImageName: ${SERVICE_PROCESSOR_IMAGE_NAME} - frontendImageName: ${SERVICE_FRONTEND_IMAGE_NAME} - -services: - # Backend API service. This is a Python FastAPI application defined in - # src/backend-api. The azd packaging stage builds a Docker image using - # the Dockerfile in that directory. The image name 'backend-api' is - # combined with the automatically created Azure Container Registry login - # server to form the final image reference. - backend: - project: ./src/backend-api - language: py - host: containerapp - docker: - image: backend-api - remoteBuild: true - - # Processor service. This service reads messages from storage queues and - # orchestrates long‑running migrations. It is also packaged as a - # container and deployed to a container app environment. The Dockerfile - # in the src/processor directory defines how the image is built. - processor: - project: ./src/processor - language: py - host: containerapp - docker: - image: processor - remoteBuild: true - - # Frontend service. The frontend consists of a React single‑page - # application and a lightweight Python server that serves the compiled - # assets. azd packages the frontend by building a Docker image using - # the Dockerfile in the src/frontend directory and deploys it to a - # container app. - frontend: - project: ./src/frontend - language: py - host: containerapp - docker: - image: frontend - remoteBuild: true - -hooks: - # After deployment prints the names and endpoints of the deployed - # container apps. This reproduces the behaviour of the default - # configuration so that developers can easily discover their services. - postdeploy: - posix: - shell: sh - run: | - echo "-----" - echo "🧭 Frontend Container App Details:" - echo "✅ Name: $CONTAINER_FRONTEND_APP_NAME" - echo "🌐 Endpoint: https://$CONTAINER_FRONTEND_APP_FQDN" - echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_FRONTEND_APP_NAME" - echo "-----" - echo "🧭 Backend API Container App Details:" - echo "✅ Name: $CONTAINER_API_APP_NAME" - echo "🌐 Endpoint: https://$CONTAINER_API_APP_FQDN" - echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_API_APP_NAME" - echo "-----" - echo "🧭 Processor Container App Details:" - echo "✅ Name: $SERVICE_PROCESSOR_NAME" - echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$SERVICE_PROCESSOR_NAME" - echo "-----" - interactive: true - windows: - shell: pwsh - run: | - Write-Host "-----" - Write-Host "🧭 Frontend Container App Details:" - Write-Host "✅ Name: $env:CONTAINER_FRONTEND_APP_NAME" - Write-Host "🌐 Endpoint: https://$env:CONTAINER_FRONTEND_APP_FQDN" - Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:CONTAINER_FRONTEND_APP_NAME" -ForegroundColor Cyan - Write-Host "-----" - Write-Host "🧭 Backend API Container App Details:" - Write-Host "✅ Name: $env:CONTAINER_API_APP_NAME" - Write-Host "🌐 Endpoint: https://$env:CONTAINER_API_APP_FQDN" - Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:CONTAINER_API_APP_NAME" -ForegroundColor Cyan - Write-Host "-----" - Write-Host "🧭 Processor Container App Details:" - Write-Host "✅ Name: $env:SERVICE_PROCESSOR_NAME" - Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:SERVICE_PROCESSOR_NAME" -ForegroundColor Cyan - Write-Host "-----" - interactive: true diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 3e3b4537..60ac6ed5 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -294,7 +294,21 @@ azd up **⚠️ Deployment Issues:** If you encounter errors or timeouts, try a different region as there may be capacity constraints. For detailed error solutions, see our [Troubleshooting Guide](./TroubleShootingSteps.md). -### 4.3 Get Application URL +### 4.3 Run the script to build and push the application images + +Build and push the frontend, backend, and processor images to the dedicated ACR, then update the Container Apps to use them. This step is **not run automatically** by `azd up` — run it from the repository root after deployment: + +```powershell +# PowerShell +./scripts/acr_build_push.ps1 +``` + +```bash +# Bash +bash ./scripts/acr_build_push.sh +``` + +### 4.4 Get Application URL After successful deployment: 1. Open [Azure Portal](https://portal.azure.com/) @@ -315,7 +329,7 @@ After successful deployment: ### 5.2 Verify Deployment -1. Access your application using the URL from Step 4.3 +1. Access your application using the URL from Step 4.4 2. Confirm the application loads successfully 3. Verify you can sign in with your authenticated account @@ -460,64 +474,4 @@ Now that your deployment is complete and tested, explore these resources to enha - 🐛 **Issues:** Check [Troubleshooting Guide](./TroubleShootingSteps.md) - 💬 **Support:** Review [Support Guidelines](../SUPPORT.md) -- 🔧 **Development:** See [Contributing Guide](../CONTRIBUTING.md) - ---- - -## Advanced: Deploy Local Changes - -If you've made local modifications to the code and want to deploy them to Azure, follow these steps to swap the configuration files so that `azd up` builds Docker images from your local source code instead of pulling pre-built images from the GitHub repository. - -**How it works:** -- The custom `azure.yaml` defines three services (backend, processor, frontend) with `remoteBuild: true`, which instructs `azd` to build Docker images from your local `src/` directories and push them to Azure Container Registry (ACR). -- The custom `main.bicep` accepts image name parameters (`backendImageName`, `processorImageName`, `frontendImageName`) that `azd` passes automatically after building the images. - -> **Note:** To set up and run the application locally for development, see the [Local Development Setup Guide](./LocalDevelopmentSetup.md). - -### Step 1: Rename Azure Configuration Files - -**In the root directory:** -1. Rename `azure.yaml` to `azure_custom2.yaml` -2. Rename `azure_custom.yaml` to `azure.yaml` - -### Step 2: Rename Infrastructure Files - -**In the `infra` directory:** -1. Rename `main.bicep` to `main_custom2.bicep` -2. Rename `main_custom.bicep` to `main.bicep` - -### Step 3: Deploy Changes - -> ⚠️ **Critical: Redeployment Warning** -> If you have previously run `azd up` in this folder (i.e., a `.azure` folder exists), you must create a fresh environment before deploying to avoid conflicts and deployment failures. - -**Create a fresh environment:** -```shell -# Create a new named environment (3-16 characters, alphanumeric only) -azd env new -``` - -> **Note:** When prompted "Set new environment as default environment?", select **Y**. This eliminates the need to run `azd env select` separately. - -**Run the deployment:** -```shell -azd up -``` - -> **Note:** During the packaging phase, you may see `"No artifacts were found"` for each service. This is expected — because `remoteBuild: true` is configured, Docker images are built remotely on Azure Container Registry, not on your local machine. Your local code is still being deployed. - -**⚠️ Deployment Issues:** If `azd up` fails on the first attempt (e.g., with a `ResourceNotFound` error), try running `azd up` again. Transient errors can occur due to resource propagation delays, and a retry typically resolves them. For other errors, try a different region or see the [Troubleshooting Guide](./TroubleShootingSteps.md). - -### Step 4: Revert Configuration Files - -After your custom deployment is complete, revert the renames to restore the original configuration: - -**In the root directory:** -1. Rename `azure.yaml` to `azure_custom.yaml` -2. Rename `azure_custom2.yaml` to `azure.yaml` - -**In the `infra` directory:** -1. Rename `main.bicep` to `main_custom.bicep` -2. Rename `main_custom2.bicep` to `main.bicep` - -> **Note:** This restores the original files so that standard deployments and git status remain clean. +- 🔧 **Development:** See [Contributing Guide](../CONTRIBUTING.md) \ No newline at end of file diff --git a/infra/main.bicep b/infra/main.bicep index a5216487..af67aef5 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -45,12 +45,12 @@ param azureAiServiceLocation string #disable-next-line no-unused-params param containerRegistryEndpoint string = '' -@description('Optional. The image tag to use for container images. Defaults to "latest_v2".') -param imageTag string = 'latest_v2' +@description('Optional. The image tag to use for container images. Defaults to "latest".') +param imageTag string = 'latest' @description('''Optional. Placeholder container image used to initially provision the container apps. -The dedicated Azure Container Registry is empty right after infrastructure provisioning, so a public image is used as the default allowed image until the post-deployment script (scripts/deploy_container_images.*) builds and pushes the deployment-specific images and updates the apps. Defaults to the Azure Container Apps quickstart image.''') -param placeholderContainerImage string = 'mcr.microsoft.com/k8se/quickstart:latest' +The dedicated Azure Container Registry is empty right after infrastructure provisioning, so a public image is used as the default allowed image until the post-deployment script (scripts/acr_build_push.*) builds and pushes the deployment-specific images and updates the apps. Defaults to the Azure Container Apps hello-world image.''') +param placeholderContainerImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' @minLength(1) @allowed(['Standard', 'GlobalStandard']) @@ -236,13 +236,19 @@ module containerRegistry './modules/containerRegistry.bicep' = { name: containerRegistryName location: solutionLocation tags: allTags - // Premium SKU in WAF/private-networking mode (supports higher throughput and - // future private endpoints). Public network access is kept Enabled in both - // modes so remote `az acr build` (ACR Tasks) and managed-identity pulls work; - // AzureServices bypass lets trusted ACR Tasks reach the registry. + // Premium SKU in WAF/private-networking mode (required for private endpoints + // and network rule sets). In WAF mode public network access is Disabled at + // rest; runtime pulls flow over a private endpoint and the post-deploy build + // script temporarily re-enables public access for the remote `az acr build`. sku: enablePrivateNetworking ? 'Premium' : 'Standard' - publicNetworkAccess: 'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' networkRuleBypassOptions: 'AzureServices' + // WAF: host the registry private endpoint in the backend subnet and link it + // to the privatelink.azurecr.io DNS zone so image pulls resolve privately. + // Reference the VNet and DNS zone outputs directly to avoid case-sensitivity + // issues with manually constructed resource IDs. + privateEndpointSubnetResourceId: enablePrivateNetworking ? virtualNetwork!.outputs.backendSubnetResourceId : '' + privateDnsZoneResourceId: enablePrivateNetworking ? avmPrivateDnsZones[dnsZoneIndex.containerRegistry]!.outputs.resourceId : '' // Application managed identity gets AcrPull for identity-based image pulls. acrPullPrincipalIds: [ appIdentity.outputs.principalId @@ -605,6 +611,7 @@ var privateDnsZones = [ 'privatelink.blob.${environment().suffixes.storage}' 'privatelink.queue.${environment().suffixes.storage}' 'privatelink.azconfig.io' + 'privatelink.azurecr.io' ] // DNS Zone Index Constants @@ -616,6 +623,7 @@ var dnsZoneIndex = { storageBlob: 4 storageQueue: 5 appConfig: 6 + containerRegistry: 7 } // List of DNS zone indices that correspond to AI-related services. diff --git a/infra/main.json b/infra/main.json index d44fc4a0..a34d8817 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.43.8.12551", - "templateHash": "2561563027340256551" + "templateHash": "14967895930995130893" } }, "parameters": { @@ -63,16 +63,23 @@ }, "containerRegistryEndpoint": { "type": "string", - "defaultValue": "containermigrationacr.azurecr.io", + "defaultValue": "", "metadata": { - "description": "Optional. The endpoint (excluding https://) of an existing container registry. This is the `loginServer` when using Azure Container Registry." + "description": "Optional. [Deprecated] The endpoint (excluding https://) of an existing container registry. Retained only for backward compatibility with existing parameter files/pipelines; each deployment now provisions its own dedicated Azure Container Registry and no longer depends on a shared/public registry." } }, "imageTag": { "type": "string", - "defaultValue": "latest_v2", + "defaultValue": "latest", + "metadata": { + "description": "Optional. The image tag to use for container images. Defaults to \"latest\"." + } + }, + "placeholderContainerImage": { + "type": "string", + "defaultValue": "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest", "metadata": { - "description": "Optional. The image tag to use for container images. Defaults to \"latest_v2\"." + "description": "Optional. Placeholder container image used to initially provision the container apps.\nThe dedicated Azure Container Registry is empty right after infrastructure provisioning, so a public image is used as the default allowed image until the post-deployment script (scripts/acr_build_push.*) builds and pushes the deployment-specific images and updates the apps. Defaults to the Azure Container Apps hello-world image." } }, "deploymentType": { @@ -267,6 +274,7 @@ }, "replicaLocation": "[variables('replicaRegionPairs')[resourceGroup().location]]", "userAssignedIdentityResourceName": "[format('id-{0}', variables('solutionSuffix'))]", + "containerRegistryName": "[take(format('cr{0}', variables('solutionSuffix')), 50)]", "logAnalyticsWorkspaceResourceName": "[format('log-{0}', variables('solutionSuffix'))]", "applicationInsightsResourceName": "[format('appi-{0}', variables('solutionSuffix'))]", "bastionHostName": "[format('bas-{0}', variables('solutionSuffix'))]", @@ -282,7 +290,8 @@ "privatelink.documents.azure.com", "[format('privatelink.blob.{0}', environment().suffixes.storage)]", "[format('privatelink.queue.{0}', environment().suffixes.storage)]", - "privatelink.azconfig.io" + "privatelink.azconfig.io", + "privatelink.azurecr.io" ], "dnsZoneIndex": { "cognitiveServices": 0, @@ -291,7 +300,8 @@ "cosmosDB": 3, "storageBlob": 4, "storageQueue": 5, - "appConfig": 6 + "appConfig": 6, + "containerRegistry": 7 }, "aiRelatedDnsZoneIndices": [ "[variables('dnsZoneIndex').cognitiveServices]", @@ -840,6 +850,287 @@ } } }, + "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": "[variables('containerRegistryName')]" + }, + "location": { + "value": "[variables('solutionLocation')]" + }, + "tags": { + "value": "[variables('allTags')]" + }, + "sku": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Premium'), createObject('value', 'Standard'))]", + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Disabled'), createObject('value', 'Enabled'))]", + "networkRuleBypassOptions": { + "value": "AzureServices" + }, + "privateEndpointSubnetResourceId": "[if(parameters('enablePrivateNetworking'), createObject('value', resourceId(resourceGroup().name, 'Microsoft.Network/virtualNetworks/subnets', format('vnet-{0}', variables('solutionSuffix')), 'backend')), createObject('value', ''))]", + "privateDnsZoneResourceId": "[if(parameters('enablePrivateNetworking'), createObject('value', resourceId(resourceGroup().name, 'Microsoft.Network/privateDnsZones', 'privatelink.azurecr.io')), createObject('value', ''))]", + "acrPullPrincipalIds": { + "value": [ + "[reference('appIdentity').outputs.principalId.value]" + ] + }, + "buildPrincipalId": { + "value": "[variables('deployingUserPrincipalId')]" + }, + "buildPrincipalType": { + "value": "[variables('deployingUserType')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "6541411388428378234" + }, + "name": "Dedicated Azure Container Registry", + "description": "Provisions a dedicated Azure Container Registry (ACR) for a single deployment and configures identity-based authentication.\r\nAdmin user and anonymous pull are disabled. The provided application managed identity principals are granted the AcrPull role, and the deployer is granted a registry-scoped AcrPush role so it can push/pull images (remote builds via `az acr build` additionally rely on the deployer's higher-scope role for scheduleRun/action)." + }, + "parameters": { + "name": { + "type": "string", + "maxLength": 50, + "metadata": { + "description": "Required. Name of the Azure Container Registry. Must be globally unique and 5-50 alphanumeric characters." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Optional. Azure region for the registry. Defaults to the resource group location." + } + }, + "tags": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Optional. Tags to apply to the registry." + } + }, + "sku": { + "type": "string", + "defaultValue": "Standard", + "allowedValues": [ + "Basic", + "Standard", + "Premium" + ], + "metadata": { + "description": "Optional. SKU for the registry. Premium is required for private networking. Defaults to Standard." + } + }, + "publicNetworkAccess": { + "type": "string", + "defaultValue": "Enabled", + "allowedValues": [ + "Enabled", + "Disabled" + ], + "metadata": { + "description": "Optional. Public network access for the registry. Defaults to Enabled for non-WAF deployments. In WAF (private-networking) deployments the caller sets this to Disabled: runtime image pulls flow over a private endpoint, and the post-deployment build script (scripts/acr_build_push.*) temporarily re-enables public access for the remote `az acr build` and then restores it to Disabled." + } + }, + "networkRuleBypassOptions": { + "type": "string", + "defaultValue": "AzureServices", + "allowedValues": [ + "AzureServices", + "None" + ], + "metadata": { + "description": "Optional. Whether to allow trusted Azure services (e.g. ACR Tasks used by `az acr build`) to bypass network rules. Defaults to AzureServices." + } + }, + "acrPullPrincipalIds": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Optional. Principal IDs (managed identities) to grant the AcrPull role so they can pull images using identity-based authentication." + } + }, + "buildPrincipalId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional. Principal ID (e.g. the deployer) to grant a registry-scoped AcrPush role so it can push/pull images. Leave empty to skip." + } + }, + "buildPrincipalType": { + "type": "string", + "defaultValue": "User", + "allowedValues": [ + "Device", + "ForeignGroup", + "Group", + "ServicePrincipal", + "User" + ], + "metadata": { + "description": "Optional. Principal type for the build principal." + } + }, + "privateEndpointSubnetResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional. Resource ID of the subnet to host the registry private endpoint. When set (WAF mode), a private endpoint is created so runtime image pulls flow over the private network." + } + }, + "privateDnsZoneResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional. Resource ID of the privatelink.azurecr.io private DNS zone to link the private endpoint to." + } + } + }, + "variables": { + "acrPullRoleDefinitionId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", + "acrPushRoleDefinitionId": "8311e382-0749-4cb8-b61a-304f252e45ec" + }, + "resources": [ + { + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-07-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "[parameters('sku')]" + }, + "properties": { + "adminUserEnabled": false, + "anonymousPullEnabled": false, + "publicNetworkAccess": "[parameters('publicNetworkAccess')]", + "networkRuleBypassOptions": "[parameters('networkRuleBypassOptions')]", + "networkRuleSet": "[if(equals(parameters('publicNetworkAccess'), 'Disabled'), createObject('defaultAction', 'Deny'), null())]", + "policies": "[if(equals(parameters('publicNetworkAccess'), 'Disabled'), createObject('exportPolicy', createObject('status', 'disabled')), null())]" + } + }, + { + "condition": "[not(empty(parameters('privateEndpointSubnetResourceId')))]", + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2023-11-01", + "name": "[format('pep-{0}', parameters('name'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "subnet": { + "id": "[parameters('privateEndpointSubnetResourceId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('pls-{0}', parameters('name'))]", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", + "groupIds": [ + "registry" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + ] + }, + { + "condition": "[and(not(empty(parameters('privateEndpointSubnetResourceId'))), not(empty(parameters('privateDnsZoneResourceId'))))]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2023-11-01", + "name": "[format('{0}/{1}', format('pep-{0}', parameters('name')), 'default')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "privatelink-azurecr-io", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneResourceId')]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('pep-{0}', parameters('name')))]" + ] + }, + { + "copy": { + "name": "acrPullRoleAssignments", + "count": "[length(parameters('acrPullPrincipalIds'))]" + }, + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('acrPullPrincipalIds')[copyIndex()], variables('acrPullRoleDefinitionId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('acrPullRoleDefinitionId'))]", + "principalId": "[parameters('acrPullPrincipalIds')[copyIndex()]]", + "principalType": "ServicePrincipal" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + ] + }, + { + "condition": "[not(empty(parameters('buildPrincipalId')))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), parameters('buildPrincipalId'), variables('acrPushRoleDefinitionId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('acrPushRoleDefinitionId'))]", + "principalId": "[parameters('buildPrincipalId')]", + "principalType": "[parameters('buildPrincipalType')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + ] + } + ], + "outputs": { + "resourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the container registry." + }, + "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + }, + "name": { + "type": "string", + "metadata": { + "description": "The name of the container registry." + }, + "value": "[parameters('name')]" + }, + "loginServer": { + "type": "string", + "metadata": { + "description": "The login server (endpoint) of the container registry, e.g. myregistry.azurecr.io." + }, + "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-07-01').loginServer]" + } + } + } + }, + "dependsOn": [ + "appIdentity" + ] + }, "logAnalyticsWorkspace": { "condition": "[and(or(parameters('enableMonitoring'), parameters('enablePrivateNetworking')), not(variables('useExistingLogAnalytics')))]", "type": "Microsoft.Resources/deployments", @@ -27476,8 +27767,8 @@ }, "dependsOn": [ "appIdentity", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageBlob)]", "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageQueue)]", + "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageBlob)]", "virtualNetwork" ] }, @@ -35199,13 +35490,7 @@ }, "dependsOn": [ "aiFoundryAiServices", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').aiServices)]", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').cognitiveServices)]", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').openAI)]", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').cosmosDB)]", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageBlob)]", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageQueue)]", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').appConfig)]", + "avmPrivateDnsZones", "virtualNetwork" ] }, @@ -40649,11 +40934,19 @@ ] } }, + "registries": { + "value": [ + { + "server": "[reference('containerRegistry').outputs.loginServer.value]", + "identity": "[reference('appIdentity').outputs.resourceId.value]" + } + ] + }, "containers": { "value": [ { "name": "backend-api", - "image": "[format('{0}/backend-api:{1}', parameters('containerRegistryEndpoint'), parameters('imageTag'))]", + "image": "[parameters('placeholderContainerImage')]", "env": "[concat(createArray(createObject('name', 'APP_CONFIGURATION_URL', 'value', reference('appConfiguration').outputs.endpoint.value), createObject('name', 'AZURE_CLIENT_ID', 'value', reference('appIdentity').outputs.clientId.value), createObject('name', 'PROCESSOR_CONTROL_URL', 'value', format('https://{0}.internal.{1}', variables('processorContainerAppName'), reference('containerAppsEnvironment').outputs.defaultDomain.value))), if(parameters('enableMonitoring'), createArray(createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference('applicationInsights').outputs.connectionString.value)), createArray()))]", "resources": { "cpu": 1, @@ -42267,7 +42560,8 @@ "appConfiguration", "appIdentity", "applicationInsights", - "containerAppsEnvironment" + "containerAppsEnvironment", + "containerRegistry" ] }, "containerAppFrontend": { @@ -42296,11 +42590,19 @@ ] } }, + "registries": { + "value": [ + { + "server": "[reference('containerRegistry').outputs.loginServer.value]", + "identity": "[reference('appIdentity').outputs.resourceId.value]" + } + ] + }, "containers": { "value": [ { "name": "frontend", - "image": "[format('{0}/frontend:{1}', parameters('containerRegistryEndpoint'), parameters('imageTag'))]", + "image": "[parameters('placeholderContainerImage')]", "env": [ { "name": "API_URL", @@ -43915,7 +44217,8 @@ "dependsOn": [ "appIdentity", "containerAppBackend", - "containerAppsEnvironment" + "containerAppsEnvironment", + "containerRegistry" ] }, "containerAppProcessor": { @@ -43944,11 +44247,19 @@ ] } }, + "registries": { + "value": [ + { + "server": "[reference('containerRegistry').outputs.loginServer.value]", + "identity": "[reference('appIdentity').outputs.resourceId.value]" + } + ] + }, "containers": { "value": [ { "name": "processor", - "image": "[format('{0}/processor:{1}', parameters('containerRegistryEndpoint'), parameters('imageTag'))]", + "image": "[parameters('placeholderContainerImage')]", "env": "[concat(createArray(createObject('name', 'APP_CONFIGURATION_URL', 'value', reference('appConfiguration').outputs.endpoint.value), createObject('name', 'AZURE_CLIENT_ID', 'value', reference('appIdentity').outputs.clientId.value), createObject('name', 'AZURE_STORAGE_ACCOUNT_NAME', 'value', reference('storageAccount').outputs.name.value), createObject('name', 'STORAGE_ACCOUNT_NAME', 'value', reference('storageAccount').outputs.name.value), createObject('name', 'CONTROL_API_ENABLED', 'value', '1'), createObject('name', 'CONTROL_API_PORT', 'value', '8080')), if(parameters('enableMonitoring'), createArray(createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference('applicationInsights').outputs.connectionString.value)), createArray()))]", "resources": { "cpu": 2, @@ -45546,6 +45857,7 @@ "appIdentity", "applicationInsights", "containerAppsEnvironment", + "containerRegistry", "storageAccount" ] } @@ -45612,6 +45924,34 @@ }, "value": "[resourceGroup().name]" }, + "AZURE_CONTAINER_REGISTRY_NAME": { + "type": "string", + "metadata": { + "description": "The name of the dedicated Azure Container Registry." + }, + "value": "[reference('containerRegistry').outputs.name.value]" + }, + "AZURE_CONTAINER_REGISTRY_ENDPOINT": { + "type": "string", + "metadata": { + "description": "The login server (endpoint) of the dedicated Azure Container Registry." + }, + "value": "[reference('containerRegistry').outputs.loginServer.value]" + }, + "CONTAINER_PROCESSOR_APP_NAME": { + "type": "string", + "metadata": { + "description": "The name of the processor container app." + }, + "value": "[reference('containerAppProcessor').outputs.name.value]" + }, + "AZURE_ENV_IMAGE_TAG": { + "type": "string", + "metadata": { + "description": "The image tag used for deployment-specific container images." + }, + "value": "[parameters('imageTag')]" + }, "deployerObjectId": { "type": "string", "value": "[variables('deployingUserPrincipalId')]" diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep deleted file mode 100644 index a32408d7..00000000 --- a/infra/main_custom.bicep +++ /dev/null @@ -1,1562 +0,0 @@ -targetScope = 'resourceGroup' - -@minLength(3) -@maxLength(16) -@description('Required. A unique application/solution name for all resources in this deployment. This should be 3-16 characters long.') -param solutionName string - -@maxLength(5) -@description('Optional. A unique text/token for the solution. This is used to ensure resource names are unique for global resources. Defaults to a 5-character substring of the unique string generated from the subscription ID, resource group name, and solution name.') -param solutionUniqueText string = substring(uniqueString(subscription().id, resourceGroup().name, solutionName), 0, 5) - -@minLength(3) -@metadata({ azd: { type: 'location' } }) -@description('Required. Azure region for container apps, storage, and other services. Choose a region close to your users.') -param location string -var solutionLocation = empty(location) ? resourceGroup().location : location - -@allowed([ - 'australiaeast' - 'eastus' - 'eastus2' - 'francecentral' - 'japaneast' - 'norwayeast' - 'southindia' - 'swedencentral' - 'uksouth' - 'westus' - 'westus3' -]) -@metadata({ - azd: { - type: 'location' - usageName: [ - 'OpenAI.GlobalStandard.gpt-5.1, 500' - ] - } -}) -@description('Required. Azure region for AI services (OpenAI/AI Foundry). Must be a region that supports GPT5.1 model deployment.') -param azureAiServiceLocation string - - - - - -@secure() -@description('The full image name (including tag) for the backend API container, generated by azd.') -param backendImageName string = '' - -@secure() -@description('The full image name (including tag) for the processor container, generated by azd.') -param processorImageName string = '' - -@secure() -@description('The full image name (including tag) for the frontend container, generated by azd.') -param frontendImageName string = '' - -@minLength(1) -@allowed(['Standard', 'GlobalStandard']) -@description('Optional. Model deployment type. Defaults to GlobalStandard.') -param deploymentType string = 'GlobalStandard' - -@minLength(1) -@description('Optional. Name of the AI model to deploy. Recommend using gpt-5.1. Defaults to gpt-5.1.') -param gptModelName string = 'gpt-5.1' - -@minLength(1) -@description('Optional. Version of AI model. Review available version numbers per model before setting. Defaults to 2025-11-13.') -param gptModelVersion string = '2025-11-13' - -@description('Optional. GPT model deployment token capacity. Lower this if initial provisioning fails due to capacity. Defaults to 500K tokens per minute to improve regional success rate.') -param gptDeploymentCapacity int = 500 - -@description('Optional. The tags to apply to all deployed Azure resources.') -param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} - -@description('Optional. Enable redundancy for applicable resources. Defaults to false.') -param enableRedundancy bool = false - -@description('Optional. Enable/Disable usage telemetry for module.') -param enableTelemetry bool = true - -@description('Optional. Enable private networking for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') -param enablePrivateNetworking bool = false - -@description('Optional. Enable monitoring applicable resources, aligned with the Well Architected Framework recommendations. This setting enables Application Insights and Log Analytics and configures all the resources applicable resources to send logs. Defaults to false.') -param enableMonitoring bool = false - -@description('Optional. Enable scalability for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') -param enableScalability bool = false - -@description('Optional. CosmosDB Location') -param cosmosLocation string = 'eastus2' - -@description('Optional. Existing Log Analytics Workspace Resource ID') -param existingLogAnalyticsWorkspaceId string = '' - -@description('Tag, Created by user name') -param createdBy string = contains(deployer(), 'userPrincipalName') - ? split(deployer().userPrincipalName, '@')[0] - : deployer().objectId - -// Get the current deployer's information -var deployerInfo = deployer() -var deployingUserPrincipalId = deployerInfo.objectId - -@description('Optional. Resource ID of an existing Foundry project') -param existingFoundryProjectResourceId string = '' - -@description('Optional. Admin username for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') -@secure() -//param vmAdminUsername string = take(newGuid(), 20) -param vmAdminUsername string? - -@description('Optional. Admin password for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') -@secure() -//param vmAdminPassword string = newGuid() -param vmAdminPassword string? - -@description('Optional. Size of the Jumpbox Virtual Machine when created. Set to custom value if enablePrivateNetworking is true.') -param vmSize string? - -// Extracts subscription, resource group, and workspace name from the resource ID when using an existing Log Analytics workspace -var useExistingLogAnalytics = !empty(existingLogAnalyticsWorkspaceId) -var existingLawSubscription = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[2] : '' -var existingLawResourceGroup = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[4] : '' -var existingLawName = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[8] : '' - -resource existingLogAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' existing = if (useExistingLogAnalytics) { - name: existingLawName - scope: resourceGroup(existingLawSubscription, existingLawResourceGroup) -} - -var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics - ? existingLogAnalyticsWorkspaceId - : logAnalyticsWorkspace!.outputs.resourceId - -var solutionSuffix = toLower(trim(replace( - replace( - replace(replace(replace(replace('${solutionName}${solutionUniqueText}', '-', ''), '_', ''), '.', ''), '/', ''), - ' ', - '' - ), - '*', - '' -))) - -var allTags = union( - { - 'azd-env-name': solutionName - TemplateName: 'Container Migration' - }, - tags -) - -var existingTags = resourceGroup().tags ?? {} - -resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { - name: 'default' - properties: { - tags: union( - existingTags, - tags, - { - TemplateName: 'Container Migration' - Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' - CreatedBy: createdBy - } - ) - } -} - -// Replica regions list based on article in [Azure regions list](https://learn.microsoft.com/azure/reliability/regions-list) and [Enhance resilience by replicating your Log Analytics workspace across regions](https://learn.microsoft.com/azure/azure-monitor/logs/workspace-replication#supported-regions) for supported regions for Log Analytics Workspace. -var replicaRegionPairs = { - australiaeast: 'australiasoutheast' - centralus: 'westus' - eastasia: 'japaneast' - eastus: 'centralus' - eastus2: 'centralus' - japaneast: 'eastasia' - northeurope: 'westeurope' - southeastasia: 'eastasia' - uksouth: 'westeurope' - westeurope: 'northeurope' -} -var replicaLocation = replicaRegionPairs[resourceGroup().location] - -// ========== User Assigned Identity ========== // -// WAF best practices for identity and access management: https://learn.microsoft.com/en-us/azure/well-architected/security/identity-access -var userAssignedIdentityResourceName = 'id-${solutionSuffix}' -module appIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { - name: take('avm.res.managed-identity.user-assigned-identity.${userAssignedIdentityResourceName}', 64) - params: { - name: userAssignedIdentityResourceName - location: solutionLocation - tags: allTags - enableTelemetry: enableTelemetry - } -} - -// ========== Log Analytics Workspace ========== // -// WAF best practices for Log Analytics: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-log-analytics -// WAF PSRules for Log Analytics: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#azure-monitor-logs -var logAnalyticsWorkspaceResourceName = 'log-${solutionSuffix}' -module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.12.0' = if ((enableMonitoring || enablePrivateNetworking) && !useExistingLogAnalytics) { - name: take('avm.res.operational-insights.workspace.${logAnalyticsWorkspaceResourceName}', 64) - params: { - name: logAnalyticsWorkspaceResourceName - location: solutionLocation - skuName: 'PerGB2018' - dataRetention: 30 - diagnosticSettings: [{ useThisWorkspace: true }] - tags: allTags - enableTelemetry: enableTelemetry - features: { enableLogAccessUsingOnlyResourcePermissions: true } - // WAF aligned configuration for Redundancy - dailyQuotaGb: enableRedundancy ? 10 : null //WAF recommendation: 10 GB per day is a good starting point for most workloads - replication: enableRedundancy - ? { - enabled: true - location: replicaLocation - } - : null - // WAF aligned configuration for Private Networking - publicNetworkAccessForIngestion: enablePrivateNetworking ? 'Disabled' : 'Enabled' - publicNetworkAccessForQuery: enablePrivateNetworking ? 'Disabled' : 'Enabled' - dataSources: enablePrivateNetworking - ? [ - { - tags: allTags - eventLogName: 'Application' - eventTypes: [ - { - eventType: 'Error' - } - { - eventType: 'Warning' - } - { - eventType: 'Information' - } - ] - kind: 'WindowsEvent' - name: 'applicationEvent' - } - { - counterName: '% Processor Time' - instanceName: '*' - intervalSeconds: 60 - kind: 'WindowsPerformanceCounter' - name: 'windowsPerfCounter1' - objectName: 'Processor' - } - { - kind: 'IISLogs' - name: 'sampleIISLog1' - state: 'OnPremiseEnabled' - } - ] - : null - } -} - -// ========== Application Insights ========== // -// WAF best practices for Application Insights: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/application-insights -// WAF PSRules for Application Insights: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#application-insights -var applicationInsightsResourceName = 'appi-${solutionSuffix}' -module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (enableMonitoring) { - name: take('avm.res.insights.component.${applicationInsightsResourceName}', 64) - #disable-next-line no-unnecessary-dependson - //dependsOn: [logAnalyticsWorkspace] - params: { - name: applicationInsightsResourceName - location: solutionLocation - tags: allTags - enableTelemetry: enableTelemetry - retentionInDays: 365 - kind: 'web' - disableIpMasking: false - flowType: 'Bluefield' - // WAF aligned configuration for Monitoring - workspaceResourceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - } -} - -// ========== Virtual Network ========== // -module virtualNetwork './modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { - name: take('module.virtual-network.${solutionSuffix}', 64) - params: { - name: 'vnet-${solutionSuffix}' - addressPrefixes: ['10.0.0.0/20'] - location: location - tags: allTags - logAnalyticsWorkspaceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' - resourceSuffix: solutionSuffix - enableTelemetry: enableTelemetry - } -} - -// Azure Bastion Host -var bastionHostName = 'bas-${solutionSuffix}' // Bastion host name must be between 3 and 15 characters in length and use numbers and lower-case letters only. -module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.bastion-host.${bastionHostName}', 64) - params: { - name: bastionHostName - skuName: 'Standard' - location: location - virtualNetworkResourceId: virtualNetwork!.outputs.resourceId - diagnosticSettings: enableMonitoring - ? [ - { - name: 'bastionDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceResourceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - } - ] - : null - tags: allTags - enableTelemetry: enableTelemetry - publicIPAddressObject: { - name: 'pip-${bastionHostName}' - zones: [] - } - } -} -// Jumpbox Virtual Machine -var jumpboxVmName = take('vm-jumpbox-${solutionSuffix}', 15) -module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (enablePrivateNetworking) { - name: take('avm.res.compute.virtual-machine.${jumpboxVmName}', 64) - params: { - name: take(jumpboxVmName, 15) // Shorten VM name to 15 characters to avoid Azure limits - vmSize: vmSize ?? 'Standard_D2s_v5' - location: location - adminUsername: vmAdminUsername ?? 'JumpboxAdminUser' - adminPassword: vmAdminPassword ?? 'JumpboxAdminP@ssw0rd1234!' - tags: allTags - zone: 0 - // SFI: enable system-assigned managed identity on the jumpbox VM. Required so - // the Azure Monitor Agent can authenticate to the Log Analytics workspace and - // honor the SecurityAuditEvents data collection rule association. - managedIdentities: { systemAssigned: true } - imageReference: { - offer: 'WindowsServer' - publisher: 'MicrosoftWindowsServer' - sku: '2019-datacenter' - version: 'latest' - } - osType: 'Windows' - osDisk: { - name: 'osdisk-${jumpboxVmName}' - managedDisk: { - storageAccountType: 'Standard_LRS' - } - } - encryptionAtHost: false // Some Azure subscriptions do not support encryption at host - nicConfigurations: [ - { - name: 'nic-${jumpboxVmName}' - ipConfigurations: [ - { - name: 'ipconfig1' - subnetResourceId: virtualNetwork!.outputs.jumpboxSubnetResourceId - } - ] - diagnosticSettings: enableMonitoring - ? [ - { - name: 'jumpboxDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceResourceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - metricCategories: [ - { - category: 'AllMetrics' - enabled: true - } - ] - } - ] - : null - } - ] - enableTelemetry: enableTelemetry - // SFI: associate the SecurityAuditEvents data collection rule with the - // jumpbox VM via the Azure Monitor Agent extension. Routes Windows audit - // success / audit failure events to Log Analytics. Gated on the same - // (enablePrivateNetworking && enableMonitoring) expression as the DCR - // module so the dereference of windowsVmDataCollectionRules!.outputs - // stays safe even if the outer jumpbox VM gate ever changes. - extensionMonitoringAgentConfig: (enablePrivateNetworking && enableMonitoring) - ? { - enabled: true - tags: allTags - dataCollectionRuleAssociations: [ - { - name: 'send-${logAnalyticsWorkspaceResourceName}' - dataCollectionRuleResourceId: windowsVmDataCollectionRules!.outputs.resourceId - } - ] - } - : null - } -} - -// SFI: data collection rule that captures Windows Security audit success and -// audit failure events from the jumpbox VM and routes them to Log Analytics -// via the Microsoft-Event stream. The xPath filter uses the Windows -// audit Keywords bitmask (0x30000000000000 = AuditSuccess|AuditFailure) and -// excludes EventID 4624 (successful logon) because it is extremely -// high-volume. Also collects a small set of Windows performance counters via -// Microsoft-Perf for the jumpbox so the same DCR provides basic VM health -// signal. The SecurityEvent / Perf tables are auto-provisioned by Azure -// Monitor on first ingestion via the DCR; no legacy OMSGallery/Security -// solution is needed. -var dataCollectionRulesResourceName = 'dcr-${solutionSuffix}' -var dataCollectionRulesLocation = useExistingLogAnalytics - ? existingLogAnalyticsWorkspace!.location - : logAnalyticsWorkspace!.outputs.location -var dcrLogAnalyticsDestinationName = 'la-${logAnalyticsWorkspaceResourceName}-destination' -module windowsVmDataCollectionRules 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if (enablePrivateNetworking && enableMonitoring) { - name: take('avm.res.insights.data-collection-rule.${dataCollectionRulesResourceName}', 64) - params: { - name: dataCollectionRulesResourceName - tags: allTags - enableTelemetry: enableTelemetry - location: dataCollectionRulesLocation - dataCollectionRuleProperties: { - kind: 'Windows' - dataSources: { - windowsEventLogs: [ - { - name: 'SecurityAuditEvents' - streams: [ - 'Microsoft-Event' - ] - xPathQueries: [ - 'Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]' - ] - } - ] - performanceCounters: [ - { - name: 'perfCounterDataSource60' - streams: [ - 'Microsoft-Perf' - ] - samplingFrequencyInSeconds: 60 - counterSpecifiers: [ - '\\Processor Information(_Total)\\% Processor Time' - '\\Processor Information(_Total)\\% Privileged Time' - '\\Processor Information(_Total)\\% User Time' - '\\Processor Information(_Total)\\Processor Frequency' - '\\System\\Processes' - '\\Process(_Total)\\Thread Count' - '\\Process(_Total)\\Handle Count' - '\\System\\System Up Time' - '\\System\\Context Switches/sec' - '\\System\\Processor Queue Length' - '\\Memory\\% Committed Bytes In Use' - '\\Memory\\Available Bytes' - '\\Memory\\Committed Bytes' - '\\Memory\\Cache Bytes' - '\\Memory\\Pool Paged Bytes' - '\\Memory\\Pool Nonpaged Bytes' - '\\Memory\\Pages/sec' - '\\Memory\\Page Faults/sec' - '\\Process(_Total)\\Working Set' - '\\Process(_Total)\\Working Set - Private' - '\\LogicalDisk(_Total)\\% Disk Time' - '\\LogicalDisk(_Total)\\% Disk Read Time' - '\\LogicalDisk(_Total)\\% Disk Write Time' - '\\LogicalDisk(_Total)\\% Idle Time' - '\\LogicalDisk(_Total)\\Disk Bytes/sec' - '\\LogicalDisk(_Total)\\Disk Read Bytes/sec' - '\\LogicalDisk(_Total)\\Disk Write Bytes/sec' - '\\LogicalDisk(_Total)\\Disk Transfers/sec' - '\\LogicalDisk(_Total)\\Disk Reads/sec' - '\\LogicalDisk(_Total)\\Disk Writes/sec' - '\\LogicalDisk(_Total)\\Avg. Disk sec/Transfer' - '\\LogicalDisk(_Total)\\Avg. Disk sec/Read' - '\\LogicalDisk(_Total)\\Avg. Disk sec/Write' - '\\LogicalDisk(_Total)\\Avg. Disk Queue Length' - '\\LogicalDisk(_Total)\\Avg. Disk Read Queue Length' - '\\LogicalDisk(_Total)\\Avg. Disk Write Queue Length' - '\\LogicalDisk(_Total)\\% Free Space' - '\\LogicalDisk(_Total)\\Free Megabytes' - '\\Network Interface(*)\\Bytes Total/sec' - '\\Network Interface(*)\\Bytes Sent/sec' - '\\Network Interface(*)\\Bytes Received/sec' - '\\Network Interface(*)\\Packets/sec' - '\\Network Interface(*)\\Packets Sent/sec' - '\\Network Interface(*)\\Packets Received/sec' - '\\Network Interface(*)\\Packets Outbound Errors' - '\\Network Interface(*)\\Packets Received Errors' - ] - } - ] - } - destinations: { - logAnalytics: [ - { - workspaceResourceId: logAnalyticsWorkspaceResourceId - name: dcrLogAnalyticsDestinationName - } - ] - } - dataFlows: [ - { - streams: [ - 'Microsoft-Event' - ] - destinations: [ - dcrLogAnalyticsDestinationName - ] - } - { - streams: [ - 'Microsoft-Perf' - ] - destinations: [ - dcrLogAnalyticsDestinationName - ] - } - ] - } - } -} - -var processBlobContainerName = 'processes' -var processQueueName = 'processes-queue' - -// ========== Private DNS Zones ========== // -var privateDnsZones = [ - 'privatelink.cognitiveservices.azure.com' - 'privatelink.openai.azure.com' - 'privatelink.services.ai.azure.com' - 'privatelink.documents.azure.com' - 'privatelink.blob.${environment().suffixes.storage}' - 'privatelink.queue.${environment().suffixes.storage}' - 'privatelink.azconfig.io' -] - -// DNS Zone Index Constants -var dnsZoneIndex = { - cognitiveServices: 0 - openAI: 1 - aiServices: 2 - cosmosDB: 3 - storageBlob: 4 - storageQueue: 5 - appConfig: 6 -} - -// List of DNS zone indices that correspond to AI-related services. -var aiRelatedDnsZoneIndices = [ - dnsZoneIndex.cognitiveServices - dnsZoneIndex.openAI - dnsZoneIndex.aiServices -] - -// =================================================== -// DEPLOY PRIVATE DNS ZONES -// - Deploys all zones if no existing Foundry project is used -// - Excludes AI-related zones when using with an existing Foundry project -// =================================================== -@batchSize(5) -module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ - for (zone, i) in privateDnsZones: if (enablePrivateNetworking && (empty(existingFoundryProjectResourceId) || !contains( - aiRelatedDnsZoneIndices, - i - ))) { - name: 'dns-zone-${i}' - params: { - name: zone - tags: allTags - enableTelemetry: enableTelemetry - virtualNetworkLinks: [ - { - name: take('vnetlink-${virtualNetwork!.outputs.name}-${split(zone, '.')[1]}', 80) - virtualNetworkResourceId: virtualNetwork!.outputs.resourceId - } - ] - } - } -] - -// ========== AVM WAF ========== // -// ========== Storage account module ========== // -var storageAccountName = 'st${solutionSuffix}' // Storage account name must be between 3 and 24 characters in length and use numbers and lower-case letters only. -module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { - name: take('avm.res.storage.storage-account.${storageAccountName}', 64) - params: { - name: storageAccountName - location: solutionLocation - managedIdentities: { systemAssigned: true } - minimumTlsVersion: 'TLS1_2' - // SFI: enable infrastructure (double) encryption at rest - requireInfrastructureEncryption: true - enableTelemetry: enableTelemetry - tags: allTags - accessTier: 'Hot' - supportsHttpsTrafficOnly: true - roleAssignments: [ - { - roleDefinitionIdOrName: 'Storage Blob Data Contributor' - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - } - { - roleDefinitionIdOrName: 'Storage Queue Data Contributor' - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - } - ] - // WAF aligned networking - networkAcls: { - bypass: 'AzureServices' - defaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' - } - allowBlobPublicAccess: enablePrivateNetworking ? true : false - publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - // Private endpoints for blob and queue - privateEndpoints: enablePrivateNetworking - ? [ - { - name: 'pep-storage-${storageAccountName}' - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - name: 'storage-dns-zone-group-blob' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageBlob]!.outputs.resourceId - } - ] - } - subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId - service: 'blob' - } - { - name: 'pep-queue-${solutionSuffix}' - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - name: 'storage-dns-zone-group-queue' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageQueue]!.outputs.resourceId - } - ] - } - subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId - service: 'queue' - } - ] - : [] - blobServices: { - corsRules: [] - deleteRetentionPolicyEnabled: false - containers: [ - { - name: 'data' - publicAccess: 'None' - denyEncryptionScopeOverride: false - defaultEncryptionScope: '$account-encryption-key' - } - ] - } - queueServices: { - deleteRetentionPolicyEnabled: true - deleteRetentionPolicyDays: 7 - queues: [ - for queue in ([processQueueName, '${processQueueName}-dead-letter'] ?? []): { - name: queue - } - ] - } - } -} - -//========== AVM WAF ========== // -//========== Cosmos DB module ========== // -var cosmosDbResourceName = 'cosmos-${solutionSuffix}' -var cosmosDbZoneRedundantHaRegionPairs = { - australiaeast: 'uksouth' //'southeastasia' - centralus: 'eastus2' - eastasia: 'southeastasia' - eastus: 'centralus' - eastus2: 'centralus' - japaneast: 'australiaeast' - northeurope: 'westeurope' - southeastasia: 'eastasia' - uksouth: 'westeurope' - westeurope: 'northeurope' -} -var cosmosDbHaLocation = cosmosDbZoneRedundantHaRegionPairs[resourceGroup().location] - -var cosmosDatabaseName = 'migration_db' -var processCosmosContainerName = 'processes' -var agentTelemetryCosmosContainerName = 'agent_telemetry' -module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { - name: take('avm.res.document-db.database-account.${cosmosDbResourceName}', 64) - params: { - name: cosmosDbResourceName - location: cosmosLocation - tags: allTags - enableTelemetry: enableTelemetry - // SFI: enable system-assigned managed identity for Cosmos DB account - managedIdentities: { systemAssigned: true } - sqlDatabases: [ - { - name: cosmosDatabaseName - containers: [ - { - name: processCosmosContainerName - paths: [ - '/_partitionKey' - ] - } - { - name: agentTelemetryCosmosContainerName - paths: [ - '/_partitionKey' - ] - } - { - name: 'files' - paths: [ - '/_partitionKey' - ] - } - { - name: 'process_statuses' - paths: [ - '/_partitionKey' - ] - } - ] - } - ] - - diagnosticSettings: enableMonitoring - ? [ - { - workspaceResourceId: logAnalyticsWorkspaceResourceId - } - ] - : null - - networkRestrictions: { - networkAclBypass: 'None' - publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - } - - privateEndpoints: enablePrivateNetworking - ? [ - { - name: 'pep-${cosmosDbResourceName}' - customNetworkInterfaceName: 'nic-${cosmosDbResourceName}' - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cosmosDB]!.outputs.resourceId } - ] - } - service: 'Sql' - subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId - } - ] - : [] - - zoneRedundant: enableRedundancy ? true : false - capabilitiesToAdd: enableRedundancy - ? null - : [ - 'EnableServerless' - ] - automaticFailover: enableRedundancy ? true : false - failoverLocations: enableRedundancy - ? [ - { - failoverPriority: 0 - isZoneRedundant: true - locationName: solutionLocation - } - { - failoverPriority: 1 - isZoneRedundant: true - locationName: cosmosDbHaLocation - } - ] - : [ - { - locationName: solutionLocation - failoverPriority: 0 - isZoneRedundant: enableRedundancy - } - ] - // Use built-in Cosmos DB roles for RBAC access - roleAssignments: [ - { - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'DocumentDB Account Contributor' - } - ] - // Create custom data plane role definition and assignment - dataPlaneRoleDefinitions: [ - { - roleName: 'CosmosDB Data Contributor Custom' - dataActions: [ - 'Microsoft.DocumentDB/databaseAccounts/readMetadata' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' - ] - assignments: [ - { principalId: appIdentity.outputs.principalId } - // ADD THIS for local debugging support: - { principalId: deployingUserPrincipalId } - ] - } - ] - } - dependsOn: [storageAccount] -} - - -// ========== Container Registry for developer builds ========== // -var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') -module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.1' = { - name: 'registryDeployment' - params: { - name: 'cr${solutionSuffix}' - acrAdminUserEnabled: false - acrSku: 'Basic' - azureADAuthenticationAsArmPolicyStatus: 'enabled' - exportPolicyStatus: 'enabled' - location: solutionLocation - softDeletePolicyDays: 7 - softDeletePolicyStatus: 'disabled' - tags: allTags - networkRuleBypassOptions: 'AzureServices' - // SFI: enable system-assigned managed identity for the container registry - managedIdentities: { systemAssigned: true } - roleAssignments: [ - { - roleDefinitionIdOrName: acrPullRole - principalType: 'ServicePrincipal' - principalId: appIdentity.outputs.principalId - } - ] - } -} - -var aiModelDeploymentName = gptModelName - -var useExistingAiFoundryAiProject = !empty(existingFoundryProjectResourceId) -var aiFoundryAiServicesResourceGroupName = useExistingAiFoundryAiProject - ? split(existingFoundryProjectResourceId, '/')[4] - : 'rg-${solutionSuffix}' -var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject - ? split(existingFoundryProjectResourceId, '/')[2] - : subscription().id -var aiFoundryAiServicesResourceName = useExistingAiFoundryAiProject - ? split(existingFoundryProjectResourceId, '/')[8] - : 'aif-${solutionSuffix}' -var aiFoundryAiProjectResourceName = 'aifp-${solutionSuffix}' -var aiFoundryAiProjectDescription = 'AI Foundry project for ${solutionName}' - -resource existingAiFoundryAiServices 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (useExistingAiFoundryAiProject) { - name: aiFoundryAiServicesResourceName - scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) -} - -module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.bicep' = if (useExistingAiFoundryAiProject) { - name: take('module.ai-services-model-deployments.${existingAiFoundryAiServices.name}', 64) - scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) - params: { - name: existingAiFoundryAiServices.name - deployments: [ - { - name: aiModelDeploymentName - model: { - format: 'OpenAI' - name: gptModelName - version: gptModelVersion - } - sku: { - name: deploymentType - capacity: gptDeploymentCapacity - } - } - ] - roleAssignments: [ - { - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' - } - { - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer - } - { - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Foundry User - } - ] - } -} - -// ========== AI Foundry AI Services ========== // -module aiFoundryAiServices 'br/public:avm/res/cognitive-services/account:0.13.2' = if (!useExistingAiFoundryAiProject) { - name: take('avm.res.cognitive-services.account.${aiFoundryAiServicesResourceName}', 64) - params: { - name: aiFoundryAiServicesResourceName - location: empty(azureAiServiceLocation) ? location : azureAiServiceLocation - tags: allTags - sku: 'S0' - kind: 'AIServices' - disableLocalAuth: true - allowProjectManagement: true - customSubDomainName: aiFoundryAiServicesResourceName - deployments: [ - { - name: aiModelDeploymentName - model: { - format: 'OpenAI' - name: gptModelName - version: gptModelVersion - } - sku: { - name: deploymentType - capacity: gptDeploymentCapacity - } - } - ] - networkAcls: { - defaultAction: 'Allow' - virtualNetworkRules: [] - ipRules: [] - } - managedIdentities: { - systemAssigned: true - userAssignedResourceIds: [appIdentity.outputs.resourceId] - } - roleAssignments: [ - // Service Principal permissions - { - roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - } - { - roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - } - { - roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Foundry User - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - } - // Deployer permissions for local debugging - { - roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' - principalId: deployingUserPrincipalId - principalType: 'User' - } - { - roleDefinitionIdOrName: 'Cognitive Services User' - principalId: deployingUserPrincipalId - principalType: 'User' - } - ] - // WAF aligned configuration for Monitoring - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - // Private endpoints are deployed separately via the aiFoundryPrivateEndpoint module below - privateEndpoints: [] - enableTelemetry: enableTelemetry - } -} - -// ========== AI Foundry Private Endpoint ========== // -module aiFoundryPrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.8.1' = if (enablePrivateNetworking && !useExistingAiFoundryAiProject) { - name: take('pep-${aiFoundryAiServicesResourceName}-deployment', 64) - dependsOn: [ - aiFoundryAiServices - virtualNetwork - avmPrivateDnsZones - ] - params: { - name: 'pep-${aiFoundryAiServicesResourceName}' - customNetworkInterfaceName: 'nic-${aiFoundryAiServicesResourceName}' - location: solutionLocation - tags: allTags - enableTelemetry: enableTelemetry - privateLinkServiceConnections: [ - { - name: 'pep-${aiFoundryAiServicesResourceName}-connection' - properties: { - privateLinkServiceId: aiFoundryAiServices!.outputs.resourceId - groupIds: ['account'] - } - } - ] - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - name: 'ai-services-dns-zone-cognitiveservices' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cognitiveServices]!.outputs.resourceId - } - { - name: 'ai-services-dns-zone-openai' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.openAI]!.outputs.resourceId - } - { - name: 'ai-services-dns-zone-aiservices' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.aiServices]!.outputs.resourceId - } - ] - } - subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId - } -} - -module aiFoundryProject 'modules/ai-project.bicep' = if (!useExistingAiFoundryAiProject) { - name: take('module.ai-project.${aiFoundryAiProjectResourceName}', 64) - dependsOn: enablePrivateNetworking ? [aiFoundryPrivateEndpoint] : [] - params: { - name: aiFoundryAiProjectResourceName - location: azureAiServiceLocation - tags: tags - desc: aiFoundryAiProjectDescription - //Implicit dependencies below - aiServicesName: aiFoundryAiServices!.outputs.name - } -} - -// User Role Assignment for Azure OpenAI - New Resources -module userOpenAiRoleAssignment './modules/role.bicep' = if (!useExistingAiFoundryAiProject) { - name: take('user-openai-${uniqueString(deployingUserPrincipalId, aiFoundryAiServicesResourceName)}', 64) - params: { - name: 'user-openai-${uniqueString(deployingUserPrincipalId, aiFoundryAiServicesResourceName)}' - principalId: deployingUserPrincipalId - aiServiceName: aiFoundryAiServicesResourceName - principalType: 'User' - } -} - -// User Role Assignment for Azure OpenAI - Existing Resources -module userOpenAiRoleAssignmentExisting './modules/role.bicep' = if (useExistingAiFoundryAiProject) { - name: take('user-openai-existing-${uniqueString(deployingUserPrincipalId, existingAiFoundryAiServices.name)}', 64) - params: { - name: 'user-openai-existing-${uniqueString(deployingUserPrincipalId, existingAiFoundryAiServices.name)}' - principalId: deployingUserPrincipalId - aiServiceName: existingAiFoundryAiServices.name - principalType: 'User' - } - scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) -} - -var aiServicesName = useExistingAiFoundryAiProject ? existingAiFoundryAiServices.name : aiFoundryAiServicesResourceName -module appConfiguration 'br/public:avm/res/app-configuration/configuration-store:0.9.1' = { - name: take('avm.res.app-config.store.${solutionSuffix}', 64) - params: { - location: solutionLocation - name: 'appcs-${solutionSuffix}' - disableLocalAuth: false // needed to allow setting app config key values from this module - tags: allTags - // Always set key values during deployment since Container Apps will be in private network - keyValues: [ - { - name: 'APP_LOGGING_ENABLE' - value: 'true' - } - { - name: 'APP_LOGGING_LEVEL' - value: 'INFO' - } - { - name: 'AZURE_PACKAGE_LOGGING_LEVEL' - value: 'INFO' - } - { - name: 'AZURE_LOGGING_PACKAGES' - value: '' - } - { - name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' - value: '' - } - { - name: 'AZURE_AI_AGENT_PROJECT_CONNECTION_STRING' - value: '' - } - { - name: 'AZURE_OPENAI_API_VERSION' - value: '2025-03-01-preview' - } - { - name: 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' - value: aiModelDeploymentName - } - { - name: 'AZURE_OPENAI_ENDPOINT' - value: 'https://${aiServicesName}.cognitiveservices.azure.com/' - } - { - name: 'AZURE_OPENAI_ENDPOINT_BASE' - value: 'https://${aiServicesName}.cognitiveservices.azure.com/' - } - { - name: 'AZURE_TRACING_ENABLED' - value: 'True' - } - { - name: 'STORAGE_ACCOUNT_BLOB_URL' - value: 'https://${storageAccountName}.blob.${environment().suffixes.storage}' - } - { - name: 'STORAGE_ACCOUNT_NAME' - value: storageAccount.outputs.name - } - { - name: 'STORAGE_ACCOUNT_PROCESS_CONTAINER' - value: processBlobContainerName - } - { - name: 'STORAGE_ACCOUNT_PROCESS_QUEUE' - value: processQueueName - } - { - name: 'STORAGE_ACCOUNT_QUEUE_URL' - value: 'https://${storageAccountName}.queue.${environment().suffixes.storage}' - } - { - name: 'COSMOS_DB_CONTAINER_NAME' - value: agentTelemetryCosmosContainerName - } - { - name: 'COSMOS_DB_DATABASE_NAME' - value: cosmosDatabaseName - } - { - name: 'COSMOS_DB_ACCOUNT_URL' - value: cosmosDb.outputs.endpoint - } - { - name: 'COSMOS_DB_PROCESS_CONTAINER' - value: processCosmosContainerName - } - { - name: 'COSMOS_DB_PROCESS_LOG_CONTAINER' - value: agentTelemetryCosmosContainerName - } - { - name: 'GLOBAL_LLM_SERVICE' - value: 'AzureOpenAI' - } - { - name: 'STORAGE_QUEUE_ACCOUNT' - value: storageAccount.outputs.name - } - ] - roleAssignments: [ - { - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'App Configuration Data Reader' - } - ] - enableTelemetry: enableTelemetry - managedIdentities: { systemAssigned: true } - sku: 'Standard' - publicNetworkAccess: 'Enabled' - } -} - -module avmAppConfigUpdated 'br/public:avm/res/app-configuration/configuration-store:0.6.3' = if (enablePrivateNetworking) { - name: take('avm.res.app-configuration.configuration-store-update.${solutionSuffix}', 64) - params: { - name: 'appcs-${solutionSuffix}' - location: solutionLocation - managedIdentities: { systemAssigned: true } - sku: 'Standard' - enableTelemetry: enableTelemetry - tags: allTags - disableLocalAuth: true - // Keep public access enabled for Container Apps access (Container Apps not in private network due to capacity constraints) - publicNetworkAccess: 'Enabled' - privateEndpoints: enablePrivateNetworking - ? [ - { - name: 'pep-appconfig-${solutionSuffix}' - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - name: 'appconfig-dns-zone-group' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.appConfig]!.outputs.resourceId - } - ] - } - subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId - } - ] - : [] - } - dependsOn: [ - appConfiguration - ] -} - -var logAnalyticsPrimarySharedKey = useExistingLogAnalytics - ? existingLogAnalyticsWorkspace!.listKeys().primarySharedKey - : logAnalyticsWorkspace!.outputs!.primarySharedKey -var logAnalyticsWorkspaceId = useExistingLogAnalytics - ? existingLogAnalyticsWorkspace!.properties.customerId - : logAnalyticsWorkspace!.outputs.logAnalyticsWorkspaceId -// ========== Container App Environment ========== // -module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.2' = { - name: take('avm.res.app.managed-environment.${solutionSuffix}', 64) - params: { - name: 'cae-${solutionSuffix}' - location: location - tags: { - ...resourceGroup().tags - ...existingTags - ...allTags - ...tags - } - managedIdentities: { systemAssigned: true } - appLogsConfiguration: enableMonitoring - ? { - destination: 'log-analytics' - logAnalyticsConfiguration: { - customerId: logAnalyticsWorkspaceId - sharedKey: logAnalyticsPrimarySharedKey - } - } - : null - workloadProfiles: [ - { - name: 'Consumption' - workloadProfileType: 'Consumption' - } - ] - enableTelemetry: enableTelemetry - publicNetworkAccess: 'Enabled' // Always enabled for Container Apps Environment - // SFI: enable mTLS / end-to-end encryption between revisions within the - // Container Apps environment (Container Apps equivalent of App Service's - // endToEndEncryptionEnabled). Applies to Microsoft.App/managedEnvironments - // peerTrafficConfiguration.encryption.enabled. - peerTrafficEncryption: true - - // <========== WAF related parameters - - platformReservedCidr: '172.17.17.0/24' - platformReservedDnsIP: '172.17.17.17' - zoneRedundant: (enablePrivateNetworking) ? true : false // Enable zone redundancy if private networking is enabled - infrastructureSubnetResourceId: (enablePrivateNetworking) - ? virtualNetwork!.outputs.containersSubnetResourceId // Use the container app subnet - : null // Use the container app subnet - } -} - -var backendContainerPort = 80 -var backendContainerAppName = take('ca-backend-api-${solutionSuffix}', 32) -var processorContainerAppName = take('ca-processor-${solutionSuffix}', 32) -module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = { - name: take('avm.res.app.container-app.${backendContainerAppName}', 64) - #disable-next-line no-unnecessary-dependson - dependsOn: [applicationInsights] - params: { - name: backendContainerAppName - location: solutionLocation - environmentResourceId: containerAppsEnvironment.outputs.resourceId - tags: union(allTags, { 'azd-service-name': 'backend' }) - managedIdentities: { - userAssignedResourceIds: [ - appIdentity.outputs.resourceId - ] - } - registries: [ - { - server: containerRegistry.outputs.loginServer - identity: appIdentity.outputs.resourceId - } - ] - containers: [ - { - name: 'backend-api' - image: !empty(backendImageName) ? backendImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' - env: concat( - [ - { - name: 'APP_CONFIGURATION_URL' - value: appConfiguration.outputs.endpoint - } - { - name: 'AZURE_CLIENT_ID' - value: appIdentity.outputs.clientId - } - { - name: 'PROCESSOR_CONTROL_URL' - // Internal ingress FQDN format: https://.internal. - value: 'https://${processorContainerAppName}.internal.${containerAppsEnvironment.outputs.defaultDomain}' - } - ], - enableMonitoring - ? [ - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: applicationInsights!.outputs.connectionString - } - ] - : [] - ) - resources: { - cpu: 1 - memory: '2.0Gi' - } - } - ] - ingressTargetPort: backendContainerPort - ingressExternal: true - scaleSettings: { - maxReplicas: enableScalability ? 3 : 1 - minReplicas: 1 - rules: enableScalability - ? [ - { - name: 'http-scaler' - http: { - metadata: { - concurrentRequests: 100 - } - } - } - ] - : [] - } - corsPolicy: { - allowedOrigins: [ - '*' - ] - allowedMethods: [ - 'GET' - 'POST' - 'PUT' - 'DELETE' - 'OPTIONS' - ] - allowedHeaders: [ - 'Authorization' - 'Content-Type' - '*' - ] - } - enableTelemetry: enableTelemetry - } -} - -var frontEndContainerAppName = take('ca-frontend-${solutionSuffix}', 32) -module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = { - name: take('avm.res.app.container-app.${frontEndContainerAppName}', 64) - params: { - name: frontEndContainerAppName - location: solutionLocation - environmentResourceId: containerAppsEnvironment.outputs.resourceId - tags: union(allTags, { 'azd-service-name': 'frontend' }) - managedIdentities: { - userAssignedResourceIds: [ - appIdentity.outputs.resourceId - ] - } - registries: [ - { - server: containerRegistry.outputs.loginServer - identity: appIdentity.outputs.resourceId - } - ] - containers: [ - { - name: 'frontend' - image: !empty(frontendImageName) ? frontendImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' - env: [ - { - name: 'API_URL' - value: 'https://${containerAppBackend.outputs.fqdn}' - } - { - name: 'APP_ENV' - value: 'prod' - } - { - name: 'ALLOWED_ORIGINS' - value: 'https://${frontEndContainerAppName}.${containerAppsEnvironment.outputs.defaultDomain}' - } - ] - resources: { - cpu: '1' - memory: '2.0Gi' - } - } - ] - ingressTargetPort: 3000 - ingressExternal: true - scaleSettings: { - maxReplicas: enableScalability ? 3 : 1 - minReplicas: 1 - rules: enableScalability - ? [ - { - name: 'http-scaler' - http: { - metadata: { - concurrentRequests: 100 - } - } - } - ] - : [] - } - enableTelemetry: enableTelemetry - } -} - -module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = { - name: take('avm.res.app.container-app.${processorContainerAppName}', 64) - #disable-next-line no-unnecessary-dependson - dependsOn: [applicationInsights] - params: { - name: processorContainerAppName - location: solutionLocation - environmentResourceId: containerAppsEnvironment.outputs.resourceId - tags: union(allTags, { 'azd-service-name': 'processor' }) - managedIdentities: { - userAssignedResourceIds: [ - appIdentity.outputs.resourceId - ] - } - registries: [ - { - server: containerRegistry.outputs.loginServer - identity: appIdentity.outputs.resourceId - } - ] - containers: [ - { - name: 'processor' - image: !empty(processorImageName) ? processorImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' - env: concat( - [ - { - name: 'APP_CONFIGURATION_URL' - value: appConfiguration.outputs.endpoint - } - { - name: 'AZURE_CLIENT_ID' - value: appIdentity.outputs.clientId - } - { - name: 'AZURE_STORAGE_ACCOUNT_NAME' // TODO - verify name and if needed or if pulled from app config service - value: storageAccount.outputs.name - } - { - name: 'STORAGE_ACCOUNT_NAME' // TODO - verify name and if needed - value: storageAccount.outputs.name - } - { - name: 'CONTROL_API_ENABLED' - value: '1' - } - { - name: 'CONTROL_API_PORT' - value: '8080' - } - ], - enableMonitoring - ? [ - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: applicationInsights!.outputs.connectionString - } - ] - : [] - ) - resources: { - // TODO - assess increasing resource limits - cpu: 2 - memory: '4.0Gi' - } - } - ] - // Internal ingress required for container-to-container communication - ingressTargetPort: 8080 - ingressExternal: false - ingressAllowInsecure: true // Allow HTTP without SSL redirect for internal calls - scaleSettings: { - maxReplicas: enableScalability ? 3 : 1 - minReplicas: 1 - //rules: [] - TODO - what scaling rules to use here? - } - enableTelemetry: enableTelemetry - } -} - -@description('The name of the resource group.') -output resourceGroupName string = resourceGroup().name - -@description('The name of the frontend container app.') -output CONTAINER_FRONTEND_APP_NAME string = containerAppFrontend.outputs.name - -@description('The FQDN of the frontend container app.') -output CONTAINER_FRONTEND_APP_FQDN string = containerAppFrontend.outputs.fqdn - -// Keep these for backward compatibility if needed -@description('The name of the web app container app.') -output CONTAINER_WEB_APP_NAME string = containerAppFrontend.outputs.name - -@description('The FQDN of the web app container app.') -output CONTAINER_WEB_APP_FQDN string = containerAppFrontend.outputs.fqdn - -@description('The name of the API container app.') -output CONTAINER_API_APP_NAME string = containerAppBackend.outputs.name - -@description('The FQDN of the API container app.') -output CONTAINER_API_APP_FQDN string = containerAppBackend.outputs.fqdn - -@description('The Azure subscription ID.') -output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId - -@description('The Azure resource group name.') -output AZURE_RESOURCE_GROUP string = resourceGroup().name - -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer - -@description('Backend service container app name') -output SERVICE_BACKEND_NAME string = containerAppBackend.outputs.name - -@description('Backend service container app URI') -output SERVICE_BACKEND_URI string = 'https://${containerAppBackend.outputs.fqdn}' - -@description('Processor service container app name') -output SERVICE_PROCESSOR_NAME string = containerAppProcessor.outputs.name - -@description('Frontend service container app name') -output SERVICE_FRONTEND_NAME string = containerAppFrontend.outputs.name - -@description('Frontend service container app URI') -output SERVICE_FRONTEND_URI string = 'https://${containerAppFrontend.outputs.fqdn}' diff --git a/infra/modules/containerRegistry.bicep b/infra/modules/containerRegistry.bicep index 6593f982..7ac84435 100644 --- a/infra/modules/containerRegistry.bicep +++ b/infra/modules/containerRegistry.bicep @@ -20,7 +20,7 @@ param tags object = {} ]) param sku string = 'Standard' -@description('Optional. Public network access for the registry. Defaults to Enabled. Note: `az acr build` (ACR Tasks quick builds) and Container Apps image pulls require reachability; disabling public access requires VNet agent pools and private endpoints, so it is left Enabled by default for both WAF and non-WAF deployments.') +@description('Optional. Public network access for the registry. Defaults to Enabled for non-WAF deployments. In WAF (private-networking) deployments the caller sets this to Disabled: runtime image pulls flow over a private endpoint, and the post-deployment build script (scripts/acr_build_push.*) temporarily re-enables public access for the remote `az acr build` and then restores it to Disabled.') @allowed([ 'Enabled' 'Disabled' @@ -50,6 +50,12 @@ param buildPrincipalId string = '' ]) param buildPrincipalType string = 'User' +@description('Optional. Resource ID of the subnet to host the registry private endpoint. When set (WAF mode), a private endpoint is created so runtime image pulls flow over the private network.') +param privateEndpointSubnetResourceId string = '' + +@description('Optional. Resource ID of the privatelink.azurecr.io private DNS zone to link the private endpoint to.') +param privateDnsZoneResourceId string = '' + // AcrPull role definition ID (allows pulling images). var acrPullRoleDefinitionId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' // AcrPush role definition ID (least-privilege push/pull for the deployer). @@ -72,6 +78,51 @@ resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { anonymousPullEnabled: false publicNetworkAccess: publicNetworkAccess networkRuleBypassOptions: networkRuleBypassOptions + // WAF-aligned networking: when public access is Disabled, default-deny all + // network traffic (runtime pulls flow over the private endpoint) and disable + // image export. The DisableExport_PublicNetworkAccessMustBeDisabled constraint + // requires public access to be Disabled while exports are disabled, so the + // post-deploy build script toggles both together when it opens the registry. + networkRuleSet: publicNetworkAccess == 'Disabled' ? { defaultAction: 'Deny' } : null + policies: publicNetworkAccess == 'Disabled' ? { exportPolicy: { status: 'disabled' } } : null + } +} + +// WAF: private endpoint for runtime image pulls when public access is disabled. +resource registryPrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-11-01' = if (!empty(privateEndpointSubnetResourceId)) { + name: 'pep-${name}' + location: location + tags: tags + properties: { + subnet: { + id: privateEndpointSubnetResourceId + } + privateLinkServiceConnections: [ + { + name: 'pls-${name}' + properties: { + privateLinkServiceId: registry.id + groupIds: [ + 'registry' + ] + } + } + ] + } +} + +resource registryPrivateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2023-11-01' = if (!empty(privateEndpointSubnetResourceId) && !empty(privateDnsZoneResourceId)) { + parent: registryPrivateEndpoint + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink-azurecr-io' + properties: { + privateDnsZoneId: privateDnsZoneResourceId + } + } + ] } } diff --git a/scripts/deploy_container_images.ps1 b/scripts/acr_build_push.ps1 similarity index 54% rename from scripts/deploy_container_images.ps1 rename to scripts/acr_build_push.ps1 index 81db0b82..8bae78ca 100644 --- a/scripts/deploy_container_images.ps1 +++ b/scripts/acr_build_push.ps1 @@ -16,7 +16,7 @@ $ErrorActionPreference = 'Stop' -Write-Host "==> [deploy_container_images] Building and pushing images to the dedicated ACR (remote build)" +Write-Host "==> [acr_build_push] Building and pushing images to the dedicated ACR (remote build)" # --------------------------------------------------------------------------- # Resolve required values. azd exports deployment outputs as environment @@ -25,7 +25,8 @@ Write-Host "==> [deploy_container_images] Building and pushing images to the ded $AcrName = $env:AZURE_CONTAINER_REGISTRY_NAME $RegistryEndpoint = $env:AZURE_CONTAINER_REGISTRY_ENDPOINT $ResourceGroup = $env:AZURE_RESOURCE_GROUP -$ImageTag = if ($env:AZURE_ENV_IMAGE_TAG) { $env:AZURE_ENV_IMAGE_TAG } else { 'latest_v2' } +$SubscriptionId = $env:AZURE_SUBSCRIPTION_ID +$ImageTag = if ($env:AZURE_ENV_IMAGE_TAG) { $env:AZURE_ENV_IMAGE_TAG } else { 'latest' } $BackendApp = $env:CONTAINER_API_APP_NAME $FrontendApp = $env:CONTAINER_WEB_APP_NAME $ProcessorApp = $env:CONTAINER_PROCESSOR_APP_NAME @@ -34,7 +35,7 @@ $ProcessorApp = $env:CONTAINER_PROCESSOR_APP_NAME # covers not just the registry/resource group but also the container app names # and registry endpoint, so a partially-populated environment does not silently # skip image updates and leave apps on the placeholder image. -if ([string]::IsNullOrEmpty($AcrName) -or [string]::IsNullOrEmpty($ResourceGroup) -or [string]::IsNullOrEmpty($RegistryEndpoint) -or [string]::IsNullOrEmpty($BackendApp) -or [string]::IsNullOrEmpty($FrontendApp) -or [string]::IsNullOrEmpty($ProcessorApp)) { +if ([string]::IsNullOrEmpty($AcrName) -or [string]::IsNullOrEmpty($ResourceGroup) -or [string]::IsNullOrEmpty($RegistryEndpoint) -or [string]::IsNullOrEmpty($BackendApp) -or [string]::IsNullOrEmpty($FrontendApp) -or [string]::IsNullOrEmpty($ProcessorApp) -or [string]::IsNullOrEmpty($SubscriptionId)) { if (Get-Command azd -ErrorAction SilentlyContinue) { Write-Host "==> Loading missing values from 'azd env get-values'" foreach ($line in (azd env get-values)) { @@ -44,6 +45,7 @@ if ([string]::IsNullOrEmpty($AcrName) -or [string]::IsNullOrEmpty($ResourceGroup 'AZURE_CONTAINER_REGISTRY_NAME' { if (-not $AcrName) { $AcrName = $v } } 'AZURE_CONTAINER_REGISTRY_ENDPOINT' { if (-not $RegistryEndpoint) { $RegistryEndpoint = $v } } 'AZURE_RESOURCE_GROUP' { if (-not $ResourceGroup) { $ResourceGroup = $v } } + 'AZURE_SUBSCRIPTION_ID' { if (-not $SubscriptionId) { $SubscriptionId = $v } } 'AZURE_ENV_IMAGE_TAG' { if (-not $env:AZURE_ENV_IMAGE_TAG) { $ImageTag = $v } } 'CONTAINER_API_APP_NAME' { if (-not $BackendApp) { $BackendApp = $v } } 'CONTAINER_WEB_APP_NAME' { if (-not $FrontendApp) { $FrontendApp = $v } } @@ -84,6 +86,18 @@ if ($LASTEXITCODE -ne 0) { exit 1 } +# Pin the Azure CLI to the azd environment's subscription. `az acr build` and +# `az containerapp update` use the CLI's active subscription, which may differ +# from the azd environment when multiple subscriptions are available - without +# this the build/update could target the wrong subscription (or fail). +if (-not [string]::IsNullOrEmpty($SubscriptionId)) { + az account set --subscription $SubscriptionId 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to set Azure CLI subscription to '$SubscriptionId'. Verify the subscription ID and that your account has access to it." + exit 1 + } +} + # Resolve the repository root (this script lives in /scripts). $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $RootDir = Split-Path -Parent $ScriptDir @@ -118,14 +132,59 @@ function Update-App { if ($LASTEXITCODE -ne 0) { throw "az containerapp update failed for $AppName" } } -# Build & push all images to the dedicated ACR. -Build-Image -ImageName 'backend-api' -ContextDir (Join-Path $RootDir 'src/backend-api') -Build-Image -ImageName 'processor' -ContextDir (Join-Path $RootDir 'src/processor') -Build-Image -ImageName 'frontend' -ContextDir (Join-Path $RootDir 'src/frontend') +# --------------------------------------------------------------------------- +# WAF (private networking) support. +# +# In WAF mode the registry has public network access DISABLED at rest (with a +# default-deny network rule set and image export disabled); runtime pulls flow +# over a private endpoint. Remote build (az acr build / ACR Tasks) reaches the +# registry over its PUBLIC endpoint, so we temporarily relax those settings for +# the build/push and restore them afterwards - including on failure, via finally +# - so the registry is never left publicly reachable. WAF is detected from the +# resource group's 'Type' tag (set to 'WAF' when private networking is enabled). +# --------------------------------------------------------------------------- +$DeploymentType = az group show --name $ResourceGroup --query 'tags.Type' -o tsv 2>$null + +try { + # In WAF mode, temporarily relax the ACR restrictions so the remote build + # (az acr build / ACR Tasks) can reach the registry over its public endpoint. + # This is done inside the try so the finally block always restores the + # locked-down state - even if one of the relaxation steps partially succeeds + # and a later one fails. + if ($DeploymentType -eq 'WAF') { + Write-Host "==> WAF deployment detected - temporarily relaxing ACR restrictions for the image push" + az acr update --name $AcrName --resource-group $ResourceGroup --allow-exports true --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { throw "Failed to enable ACR exports." } + az acr update --name $AcrName --resource-group $ResourceGroup --public-network-enabled true --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { throw "Failed to enable ACR public network access." } + az acr update --name $AcrName --resource-group $ResourceGroup --default-action Allow --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { throw "Failed to set ACR default action to Allow." } + Write-Host " Waiting ~45s for the network rule change to propagate..." + Start-Sleep -Seconds 45 + } + + # Build & push all images to the dedicated ACR. + Build-Image -ImageName 'backend-api' -ContextDir (Join-Path $RootDir 'src/backend-api') + Build-Image -ImageName 'processor' -ContextDir (Join-Path $RootDir 'src/processor') + Build-Image -ImageName 'frontend' -ContextDir (Join-Path $RootDir 'src/frontend') -# Point the Container Apps at the freshly built images. -Update-App -AppName $BackendApp -ImageName 'backend-api' -Update-App -AppName $ProcessorApp -ImageName 'processor' -Update-App -AppName $FrontendApp -ImageName 'frontend' + # Point the Container Apps at the freshly built images. + Update-App -AppName $BackendApp -ImageName 'backend-api' + Update-App -AppName $ProcessorApp -ImageName 'processor' + Update-App -AppName $FrontendApp -ImageName 'frontend' +} +finally { + if ($DeploymentType -eq 'WAF') { + Write-Host "==> Restoring WAF ACR configuration (default-action Deny, public access disabled, exports off)" + $restoreFailed = $false + az acr update --name $AcrName --resource-group $ResourceGroup --default-action Deny --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { $restoreFailed = $true; Write-Warning "Failed to restore ACR default-action to Deny." } + az acr update --name $AcrName --resource-group $ResourceGroup --public-network-enabled false --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { $restoreFailed = $true; Write-Warning "Failed to disable ACR public network access." } + az acr update --name $AcrName --resource-group $ResourceGroup --allow-exports false --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { $restoreFailed = $true; Write-Warning "Failed to disable ACR exports." } + if ($restoreFailed) { Write-Warning "ACR was not fully restored to its locked-down state; verify manually that public network access is disabled." } + } +} -Write-Host "==> [deploy_container_images] Completed successfully." +Write-Host "==> [acr_build_push] Completed successfully." diff --git a/scripts/deploy_container_images.sh b/scripts/acr_build_push.sh similarity index 61% rename from scripts/deploy_container_images.sh rename to scripts/acr_build_push.sh index 10a1f21b..6872fb87 100644 --- a/scripts/deploy_container_images.sh +++ b/scripts/acr_build_push.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# deploy_container_images.sh +# acr_build_push.sh # # Separate post-deployment script that runs FIRST (before any existing # post-deployment scripts). It builds the deployment-specific container images @@ -14,7 +14,7 @@ # set -euo pipefail -echo "==> [deploy_container_images] Building and pushing images to the dedicated ACR (remote build)" +echo "==> [acr_build_push] Building and pushing images to the dedicated ACR (remote build)" # --------------------------------------------------------------------------- # Resolve required values. azd exports deployment outputs as environment @@ -23,7 +23,8 @@ echo "==> [deploy_container_images] Building and pushing images to the dedicated ACR_NAME="${AZURE_CONTAINER_REGISTRY_NAME:-}" REGISTRY_ENDPOINT="${AZURE_CONTAINER_REGISTRY_ENDPOINT:-}" RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-}" -IMAGE_TAG="${AZURE_ENV_IMAGE_TAG:-latest_v2}" +SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:-}" +IMAGE_TAG="${AZURE_ENV_IMAGE_TAG:-}" BACKEND_APP="${CONTAINER_API_APP_NAME:-}" FRONTEND_APP="${CONTAINER_WEB_APP_NAME:-}" PROCESSOR_APP="${CONTAINER_PROCESSOR_APP_NAME:-}" @@ -32,7 +33,7 @@ PROCESSOR_APP="${CONTAINER_PROCESSOR_APP_NAME:-}" # This covers not just the registry/resource group but also the container app # names and registry endpoint, so a partially-populated environment does not # silently skip image updates and leave apps on the placeholder image. -if [[ -z "$ACR_NAME" || -z "$RESOURCE_GROUP" || -z "$REGISTRY_ENDPOINT" || -z "$BACKEND_APP" || -z "$FRONTEND_APP" || -z "$PROCESSOR_APP" ]]; then +if [[ -z "$ACR_NAME" || -z "$RESOURCE_GROUP" || -z "$REGISTRY_ENDPOINT" || -z "$BACKEND_APP" || -z "$FRONTEND_APP" || -z "$PROCESSOR_APP" || -z "$SUBSCRIPTION_ID" ]]; then if command -v azd >/dev/null 2>&1; then echo "==> Loading missing values from 'azd env get-values'" while IFS='=' read -r key value; do @@ -41,6 +42,7 @@ if [[ -z "$ACR_NAME" || -z "$RESOURCE_GROUP" || -z "$REGISTRY_ENDPOINT" || -z "$ AZURE_CONTAINER_REGISTRY_NAME) ACR_NAME="${ACR_NAME:-$value}" ;; AZURE_CONTAINER_REGISTRY_ENDPOINT) REGISTRY_ENDPOINT="${REGISTRY_ENDPOINT:-$value}" ;; AZURE_RESOURCE_GROUP) RESOURCE_GROUP="${RESOURCE_GROUP:-$value}" ;; + AZURE_SUBSCRIPTION_ID) SUBSCRIPTION_ID="${SUBSCRIPTION_ID:-$value}" ;; AZURE_ENV_IMAGE_TAG) IMAGE_TAG="${IMAGE_TAG:-$value}" ;; CONTAINER_API_APP_NAME) BACKEND_APP="${BACKEND_APP:-$value}" ;; CONTAINER_WEB_APP_NAME) FRONTEND_APP="${FRONTEND_APP:-$value}" ;; @@ -53,6 +55,10 @@ fi # Derive the login server from the registry name if it was not provided. REGISTRY_ENDPOINT="${REGISTRY_ENDPOINT:-${ACR_NAME}.azurecr.io}" +# Apply the default image tag only after the azd fallback, so an explicitly +# configured tag (env var or `azd env get-values`) is honored. +IMAGE_TAG="${IMAGE_TAG:-latest}" + missing=() [[ -z "$ACR_NAME" ]] && missing+=("AZURE_CONTAINER_REGISTRY_NAME") [[ -z "$RESOURCE_GROUP" ]] && missing+=("AZURE_RESOURCE_GROUP") @@ -80,6 +86,18 @@ if ! az account show >/dev/null 2>&1; then exit 1 fi +# Pin the Azure CLI to the azd environment's subscription. `az acr build` and +# `az containerapp update` use the CLI's active subscription, which may differ +# from the azd environment when the user has multiple subscriptions - without +# this the build/update could target the wrong subscription (or fail). +if [[ -n "$SUBSCRIPTION_ID" ]]; then + if ! az account set --subscription "$SUBSCRIPTION_ID" 2>/dev/null; then + echo "ERROR: Failed to set Azure CLI subscription to '$SUBSCRIPTION_ID'." >&2 + echo " Verify the subscription ID and that your account has access to it." >&2 + exit 1 + fi +fi + # Resolve the repository root (this script lives in /scripts). SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -88,6 +106,44 @@ echo " Registry : $REGISTRY_ENDPOINT ($ACR_NAME)" echo " Resource group : $RESOURCE_GROUP" echo " Image tag : $IMAGE_TAG" +# --------------------------------------------------------------------------- +# WAF (private networking) support. +# +# In WAF mode the registry has public network access DISABLED at rest (with a +# default-deny network rule set and image export disabled); runtime pulls flow +# over a private endpoint. Remote build (az acr build / ACR Tasks) reaches the +# registry over its PUBLIC endpoint, so we must temporarily relax those settings +# for the build/push and restore them afterwards - including on failure, via a +# trap - so the registry is never left publicly reachable. +# +# WAF is detected from the resource group's `Type` tag (set to 'WAF' by the +# infrastructure when private networking is enabled). +# --------------------------------------------------------------------------- +DEPLOYMENT_TYPE="$(az group show --name "$RESOURCE_GROUP" --query 'tags.Type' -o tsv 2>/dev/null || true)" + +relock_acr() { + if [[ "$DEPLOYMENT_TYPE" == "WAF" ]]; then + echo "==> Restoring WAF ACR configuration (default-action Deny, public access disabled, exports off)" + az acr update --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --default-action Deny --output none --only-show-errors \ + || echo "WARNING: failed to restore ACR default-action; verify manually." >&2 + az acr update --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --public-network-enabled false --output none --only-show-errors \ + || echo "WARNING: failed to disable ACR public network access; verify manually." >&2 + az acr update --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --allow-exports false --output none --only-show-errors \ + || echo "WARNING: failed to disable ACR exports; verify manually." >&2 + fi +} + +if [[ "$DEPLOYMENT_TYPE" == "WAF" ]]; then + echo "==> WAF deployment detected - temporarily relaxing ACR restrictions for the image push" + # Ensure the locked-down state is restored on any exit (success or failure). + trap relock_acr EXIT + az acr update --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --allow-exports true --output none --only-show-errors + az acr update --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --public-network-enabled true --output none --only-show-errors + az acr update --name "$ACR_NAME" --resource-group "$RESOURCE_GROUP" --default-action Allow --output none --only-show-errors + echo " Waiting ~45s for the network rule change to propagate..." + sleep 45 +fi + # --------------------------------------------------------------------------- # Remote build helper - uses ACR Tasks (az acr build) so no local Docker daemon # is required on the machine running the deployment. @@ -129,4 +185,4 @@ update_app "$BACKEND_APP" "backend-api" update_app "$PROCESSOR_APP" "processor" update_app "$FRONTEND_APP" "frontend" -echo "==> [deploy_container_images] Completed successfully." +echo "==> [acr_build_push] Completed successfully."