diff --git a/infra/main.bicep b/infra/main.bicep index 1ad06aed5..cc41df532 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -104,14 +104,6 @@ param existingLogAnalyticsWorkspaceId string = '' @description('Optional. Resource ID of an existing Foundry project.') param azureExistingAIProjectResourceId string = '' -@description('Optional. Principal type of the deployer, used for the ACR AcrPush role assignment. Set to ServicePrincipal for CI/OIDC deployments; defaults to User for interactive deployments.') -@allowed([ - 'User' - 'Group' - 'ServicePrincipal' -]) -param deployerType string = 'User' - @description('Optional. Deploy Azure Bastion and Jumpbox resources for private network administration.') param deployBastionAndJumpbox bool = false @@ -396,15 +388,11 @@ module containerRegistry 'modules/container-registry.bicep' = { pullPrincipalIds: [ userAssignedIdentity.outputs.principalId ] - pushPrincipalIds: [ - deployer().objectId - ] - deployerType: deployerType } } // ========== Virtual Network and Networking Components ========== // -var deployAdminAccessResources = enablePrivateNetworking && deployBastionAndJumpbox && !empty(vmAdminPassword) +var deployAdminAccessResources = enablePrivateNetworking && deployBastionAndJumpbox module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { name: take('module.virtualNetwork.${solutionSuffix}', 64) params: { @@ -474,8 +462,9 @@ module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.21.0' = if (deploy osType: 'Windows' vmSize: empty(vmSize) ? 'Standard_D2s_v5' : vmSize adminUsername: empty(vmAdminUsername) ? 'JumpboxAdminUser' : vmAdminUsername - adminPassword: vmAdminPassword + adminPassword: empty(vmAdminPassword) ? 'Vm!${uniqueString(subscription().subscriptionId, solutionName)}${guid(subscription().subscriptionId, solutionName, 'vm-admin-password')}' : vmAdminPassword managedIdentities: { + systemAssigned: true // Required by the AADLoginForWindows extension for Entra ID auth userAssignedResourceIds: [ userAssignedIdentity.outputs.resourceId ] @@ -519,6 +508,17 @@ module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.21.0' = if (deploy } location: solutionLocation tags: tags + roleAssignments: [ + { + roleDefinitionIdOrName: '1c0163c0-47e6-4577-8991-ea5c82e286e4' // Virtual Machine Administrator Login + principalId: deployer().objectId + } + ] + extensionAadJoinConfig: { + enabled: true // AAD-joins the VM (AADLoginForWindows) so Entra ID login works + typeHandlerVersion: '2.0' + settings: { mdmId: '' } + } } dependsOn: (enableMonitoring && !useExistingLogAnalytics) ? [logAnalyticsWorkspace, jumpboxDcr] : (enableMonitoring ? [jumpboxDcr] : []) } @@ -539,9 +539,9 @@ module jumpboxDcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if dataSources: { windowsEventLogs: [ { - name: 'securityEventLogsDataSource' + name: 'SecurityAuditEvents' streams: [ - 'Microsoft-SecurityEvent' + 'Microsoft-Event' ] xPathQueries: [ 'Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]' @@ -560,11 +560,13 @@ module jumpboxDcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = if dataFlows: [ { streams: [ - 'Microsoft-SecurityEvent' + 'Microsoft-Event' ] destinations: [ dcrLogAnalyticsDestinationName ] + transformKql: 'source' + outputStream: 'Microsoft-Event' } ] } diff --git a/infra/main.json b/infra/main.json index 0897a7c53..1f1d09923 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "13991311501344883078" + "templateHash": "16705306315679210410" }, "name": "Intelligent Content Generation Accelerator", "description": "Solution Accelerator for multimodal marketing content generation using Microsoft Agent Framework.\n" @@ -162,18 +162,6 @@ "description": "Optional. Resource ID of an existing Foundry project." } }, - "deployerType": { - "type": "string", - "defaultValue": "User", - "allowedValues": [ - "User", - "Group", - "ServicePrincipal" - ], - "metadata": { - "description": "Optional. Principal type of the deployer, used for the ACR AcrPush role assignment. Set to ServicePrincipal for CI/OIDC deployments; defaults to User for interactive deployments." - } - }, "deployBastionAndJumpbox": { "type": "bool", "defaultValue": false, @@ -334,7 +322,7 @@ "logAnalyticsWorkspaceResourceName": "[format('log-{0}', variables('solutionSuffix'))]", "applicationInsightsResourceName": "[format('appi-{0}', variables('solutionSuffix'))]", "userAssignedIdentityResourceName": "[format('id-{0}', variables('solutionSuffix'))]", - "deployAdminAccessResources": "[and(and(parameters('enablePrivateNetworking'), parameters('deployBastionAndJumpbox')), not(empty(parameters('vmAdminPassword'))))]", + "deployAdminAccessResources": "[and(parameters('enablePrivateNetworking'), parameters('deployBastionAndJumpbox'))]", "bastionHostName": "[format('bas-{0}', variables('solutionSuffix'))]", "zoneSupportedJumpboxLocations": [ "australiaeast", @@ -4850,14 +4838,6 @@ "value": [ "[reference('userAssignedIdentity').outputs.principalId.value]" ] - }, - "pushPrincipalIds": { - "value": [ - "[deployer().objectId]" - ] - }, - "deployerType": { - "value": "[parameters('deployerType')]" } }, "template": { @@ -4868,7 +4848,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "15150176666831120984" + "templateHash": "9041711177904037864" } }, "definitions": { @@ -4948,25 +4928,6 @@ "description": "Optional. Principal IDs (frontend/backend managed identities) that require AcrPull access." } }, - "pushPrincipalIds": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Principal IDs (e.g. the deployer) that require AcrPush access to build and push images." - } - }, - "deployerType": { - "type": "string", - "defaultValue": "User", - "allowedValues": [ - "User", - "Group", - "ServicePrincipal" - ], - "metadata": { - "description": "Optional. Principal type for the AcrPush role assignments." - } - }, "enablePrivateNetworking": { "type": "bool", "defaultValue": false, @@ -5013,19 +4974,9 @@ "roleDefinitionIdOrName": "[variables('acrPullRoleDefinitionId')]", "principalType": "ServicePrincipal" } - }, - { - "name": "pushRoleAssignments", - "count": "[length(parameters('pushPrincipalIds'))]", - "input": { - "principalId": "[parameters('pushPrincipalIds')[copyIndex('pushRoleAssignments')]]", - "roleDefinitionIdOrName": "[variables('acrPushRoleDefinitionId')]", - "principalType": "[parameters('deployerType')]" - } } ], "acrPullRoleDefinitionId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", - "acrPushRoleDefinitionId": "8311e382-0749-4cb8-b61a-304f252e45ec", "effectiveAcrSku": "[if(or(parameters('enablePrivateNetworking'), parameters('enableScalability')), 'Premium', parameters('acrSku'))]" }, "resources": { @@ -5076,7 +5027,7 @@ "networkRuleBypassOptions": "[if(parameters('enablePrivateNetworking'), createObject('value', 'AzureServices'), createObject('value', null()))]", "networkRuleSetDefaultAction": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Deny'), createObject('value', 'Allow'))]", "roleAssignments": { - "value": "[concat(variables('pullRoleAssignments'), variables('pushRoleAssignments'))]" + "value": "[variables('pullRoleAssignments')]" }, "managedIdentities": { "value": "[parameters('managedIdentities')]" @@ -12484,11 +12435,10 @@ }, "vmSize": "[if(empty(parameters('vmSize')), createObject('value', 'Standard_D2s_v5'), createObject('value', parameters('vmSize')))]", "adminUsername": "[if(empty(parameters('vmAdminUsername')), createObject('value', 'JumpboxAdminUser'), createObject('value', parameters('vmAdminUsername')))]", - "adminPassword": { - "value": "[parameters('vmAdminPassword')]" - }, + "adminPassword": "[if(empty(parameters('vmAdminPassword')), createObject('value', format('Vm!{0}{1}', uniqueString(subscription().subscriptionId, parameters('solutionName')), guid(subscription().subscriptionId, parameters('solutionName'), 'vm-admin-password'))), createObject('value', parameters('vmAdminPassword')))]", "managedIdentities": { "value": { + "systemAssigned": true, "userAssignedResourceIds": [ "[reference('userAssignedIdentity').outputs.resourceId.value]" ] @@ -12540,6 +12490,23 @@ }, "tags": { "value": "[parameters('tags')]" + }, + "roleAssignments": { + "value": [ + { + "roleDefinitionIdOrName": "1c0163c0-47e6-4577-8991-ea5c82e286e4", + "principalId": "[deployer().objectId]" + } + ] + }, + "extensionAadJoinConfig": { + "value": { + "enabled": true, + "typeHandlerVersion": "2.0", + "settings": { + "mdmId": "" + } + } } }, "template": { @@ -21621,9 +21588,9 @@ "dataSources": { "windowsEventLogs": [ { - "name": "securityEventLogsDataSource", + "name": "SecurityAuditEvents", "streams": [ - "Microsoft-SecurityEvent" + "Microsoft-Event" ], "xPathQueries": [ "Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]" @@ -21642,11 +21609,13 @@ "dataFlows": [ { "streams": [ - "Microsoft-SecurityEvent" + "Microsoft-Event" ], "destinations": [ "[variables('dcrLogAnalyticsDestinationName')]" - ] + ], + "transformKql": "source", + "outputStream": "Microsoft-Event" } ] } diff --git a/infra/modules/container-registry.bicep b/infra/modules/container-registry.bicep index 241ae0a67..184cb9b19 100644 --- a/infra/modules/container-registry.bicep +++ b/infra/modules/container-registry.bicep @@ -27,17 +27,6 @@ param acrSku string = 'Standard' @description('Optional. Principal IDs (frontend/backend managed identities) that require AcrPull access.') param pullPrincipalIds array = [] -@description('Optional. Principal IDs (e.g. the deployer) that require AcrPush access to build and push images.') -param pushPrincipalIds array = [] - -@description('Optional. Principal type for the AcrPush role assignments.') -@allowed([ - 'User' - 'Group' - 'ServicePrincipal' -]) -param deployerType string = 'User' - @description('Optional. Enable private networking. Forces the Premium SKU, disables public network access and creates a private endpoint for the registry.') param enablePrivateNetworking bool = false @@ -56,8 +45,6 @@ param managedIdentities managedIdentityAllType? // AcrPull role: allows the App Service / Container Instance identities to pull images. var acrPullRoleDefinitionId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' -// AcrPush role: allows the deployer to build and push images to the registry. -var acrPushRoleDefinitionId = '8311e382-0749-4cb8-b61a-304f252e45ec' // Premium is required for private endpoints, and recommended (WAF) for scalability. var effectiveAcrSku = (enablePrivateNetworking || enableScalability) ? 'Premium' : acrSku @@ -70,14 +57,6 @@ var pullRoleAssignments = [ } ] -var pushRoleAssignments = [ - for principalId in pushPrincipalIds: { - principalId: principalId - roleDefinitionIdOrName: acrPushRoleDefinitionId - principalType: deployerType - } -] - // ========== Azure Container Registry ========== // module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.0' = { name: take('avm.res.container-registry.registry.${name}', 64) @@ -96,7 +75,7 @@ module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.0' = publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' networkRuleBypassOptions: enablePrivateNetworking ? 'AzureServices' : null networkRuleSetDefaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' - roleAssignments: concat(pullRoleAssignments, pushRoleAssignments) + roleAssignments: pullRoleAssignments managedIdentities: managedIdentities privateEndpoints: enablePrivateNetworking ? [ diff --git a/infra/scripts/build_and_deploy_images.ps1 b/infra/scripts/build_and_deploy_images.ps1 index 564f40331..f3183e17d 100644 --- a/infra/scripts/build_and_deploy_images.ps1 +++ b/infra/scripts/build_and_deploy_images.ps1 @@ -1,120 +1,353 @@ <# .SYNOPSIS - Builds the frontend and backend images remotely in ACR (az acr build - no - local Docker needed), pushes them to the ACR created by `azd up`, updates the - frontend App Service to the new image and restarts it, then recreates the - backend Container Instance on the new image. + Post-deployment script. Builds the frontend/backend images remotely in ACR + (az acr build, no local Docker), updates the frontend App Service and restarts + it, then recreates the backend Container Instance (ACI) on the new image. .DESCRIPTION - Run AFTER `azd up`. Configuration is read from the azd environment. + No ResourceGroupName -> config from the azd environment. + ResourceGroupName -> config from that RG's latest successful deployment. + + In WAF mode ACR public access is disabled, so the build agent can't push. The + script temporarily opens the registry and re-locks it (finally); pulls keep + working over the private endpoint. + +.PARAMETER ResourceGroupName + Optional. Resolve config from this RG's latest deployment outputs instead of azd. #> +param( + [Parameter(Position = 0)] + [string]$ResourceGroupName = '' +) $ErrorActionPreference = 'Stop' +$FrontendImage = 'content-gen-app' +$BackendImage = 'content-gen-api' +$Tag = 'latest' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path +$FrontendContext = Join-Path $RepoRoot 'src\App' +$FrontendDockerfile = Join-Path $RepoRoot 'src\App\WebApp.Dockerfile' +$BackendContext = Join-Path $RepoRoot 'src\backend' +$BackendDockerfile = Join-Path $RepoRoot 'src\backend\ApiApp.Dockerfile' + +$script:ResourceGroup = '' +$script:AcrName = '' +$script:AppService = '' +$script:ContainerGroup = '' +$script:AcrLoginServer = '' + +$script:AcrPublicAccess = '' +$script:AcrOpenedForBuild = $false +$script:NewBackendUrl = $null + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Run az and throw on non-zero exit so failures surface immediately. function Invoke-Az { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$AzArgs) & az @AzArgs if ($LASTEXITCODE -ne 0) { throw "az $($AzArgs -join ' ') failed with exit code $LASTEXITCODE" } } -# Load azd environment outputs into the process environment. -if (Get-Command azd -ErrorAction SilentlyContinue) { +function Confirm-AzureAuth { + Write-Host 'Checking Azure authentication...' + az account show 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host 'Already authenticated with Azure.' + } else { + Write-Host 'Authenticating with Azure CLI...' + az login --use-device-code | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Failed to authenticate with Azure.' } + } +} + +# Config from the azd environment (default path). +function Get-ConfigFromAzdEnv { + Write-Host 'Getting values from azd environment...' + if (-not (Get-Command azd -ErrorAction SilentlyContinue)) { + Write-Warning 'azd is not installed and no resource group argument was provided.' + return $false + } + foreach ($line in (azd env get-values 2>$null)) { if ($line -match '^\s*([A-Za-z0-9_]+)="?(.*?)"?\s*$') { - Set-Item -Path "Env:$($Matches[1])" -Value $Matches[2] + switch ($Matches[1]) { + 'AZURE_ENV_CONTAINER_REGISTRY_NAME' { $script:AcrName = $Matches[2] } + 'RESOURCE_GROUP_NAME' { $script:ResourceGroup = $Matches[2] } + 'APP_SERVICE_NAME' { $script:AppService = $Matches[2] } + 'CONTAINER_INSTANCE_NAME' { $script:ContainerGroup = $Matches[2] } + } } } + + if (-not $script:AcrName -or -not $script:ResourceGroup -or -not $script:AppService -or -not $script:ContainerGroup) { + Write-Warning ('One or more required values could not be retrieved from the azd environment ' + + '(AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME).') + return $false + } + return $true } -$AcrName = $env:AZURE_ENV_CONTAINER_REGISTRY_NAME -$ResourceGroup = $env:RESOURCE_GROUP_NAME -$AppService = $env:APP_SERVICE_NAME -$ContainerGroup = $env:CONTAINER_INSTANCE_NAME +# Config from the latest successful deployment's outputs in the given RG. +# PowerShell property access is case-insensitive, so ARM's lower-cased first token +# (e.g. apP_SERVICE_NAME) still matches. +function Get-ConfigFromDeployment { + param([string]$Rg) + Write-Host 'Getting values from Azure deployment outputs...' + $script:ResourceGroup = $Rg -$FrontendImage = 'content-gen-app' -$BackendImage = 'content-gen-api' -$Tag = 'latest' + $deploymentName = az deployment group list -g $Rg ` + --query "sort_by([?properties.provisioningState=='Succeeded'], &properties.timestamp)[-1].name" -o tsv 2>$null + if (-not $deploymentName) { + Write-Warning "Could not find a successful deployment in resource group '$Rg'." + return $false + } + Write-Host "Using deployment outputs from: $deploymentName" -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path + $outputsJson = az deployment group show -g $Rg -n $deploymentName --query 'properties.outputs' -o json 2>$null + if (-not $outputsJson) { + Write-Warning 'Could not read deployment outputs.' + return $false + } + $outputs = $outputsJson | ConvertFrom-Json -if (-not $AcrName -or -not $ResourceGroup -or -not $AppService -or -not $ContainerGroup) { - throw "Missing required azd environment values (AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME)." + $script:AcrName = $outputs.AZURE_ENV_CONTAINER_REGISTRY_NAME.value + $script:AppService = $outputs.APP_SERVICE_NAME.value + $script:ContainerGroup = $outputs.CONTAINER_INSTANCE_NAME.value + + if (-not $script:AcrName -or -not $script:AppService -or -not $script:ContainerGroup) { + Write-Warning 'One or more required values could not be retrieved from deployment outputs.' + return $false + } + return $true } -$AcrLoginServer = "$AcrName.azurecr.io" - -# ===== Build & push frontend image ===== -Write-Host "===== Building Frontend Image ($FrontendImage`:$Tag) =====" -ForegroundColor Yellow -Invoke-Az acr build --registry $AcrName --image "$FrontendImage`:$Tag" ` - --file (Join-Path $RepoRoot 'src\App\WebApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\App') - -# ===== Build & push backend image ===== -Write-Host "===== Building Backend Image ($BackendImage`:$Tag) =====" -ForegroundColor Yellow -Invoke-Az acr build --registry $AcrName --image "$BackendImage`:$Tag" ` - --file (Join-Path $RepoRoot 'src\backend\ApiApp.Dockerfile') --platform linux (Join-Path $RepoRoot 'src\backend') - -# ===== Update frontend App Service image (managed-identity ACR pull) ===== -Write-Host "===== Updating Frontend App Service ($AppService) =====" -ForegroundColor Yellow -$UamiRid = az webapp identity show -g $ResourceGroup -n $AppService --query "userAssignedIdentities | keys(@) | [0]" -o tsv -$ClientId = az identity show --ids $UamiRid --query clientId -o tsv - -Invoke-Az webapp config container set -g $ResourceGroup -n $AppService ` - --container-image-name "$AcrLoginServer/$FrontendImage`:$Tag" ` - --container-registry-url "https://$AcrLoginServer" --output none --only-show-errors - -$WebappId = az webapp show -g $ResourceGroup -n $AppService --query id -o tsv -Invoke-Az resource update --ids "$WebappId/config/web" ` - --set properties.acrUseManagedIdentityCreds=true ` - --set properties.acrUserManagedIdentityID=$ClientId --output none - -Write-Host "Restarting Frontend App Service..." -ForegroundColor Yellow -Invoke-Az webapp restart -g $ResourceGroup -n $AppService --output none - -# ===== Recreate backend Container Instance with the new image ===== -# ACI cannot update a container group's image in place, and a restart reuses the -# originally deployed (hello-world) image. So we capture the existing container -# group's configuration and recreate it pointing at the new ACR image. -Write-Host "===== Recreating Backend Container Instance ($ContainerGroup) =====" -ForegroundColor Yellow - -$Aci = az container show -g $ResourceGroup -n $ContainerGroup -o json | ConvertFrom-Json -if (-not $Aci) { throw "Could not read existing container group '$ContainerGroup'." } - -$AciCpu = $Aci.containers[0].resources.requests.cpu -$AciMemory = $Aci.containers[0].resources.requests.memoryInGb -$AciPort = $Aci.containers[0].ports[0].port -$AciOsType = $Aci.osType -$AciRestart = $Aci.restartPolicy -$AciDnsLabel = $Aci.ipAddress.dnsNameLabel -$AciSubnetId = if ($Aci.subnetIds) { $Aci.subnetIds[0].id } else { $null } -$AciUami = @($Aci.identity.userAssignedIdentities.PSObject.Properties.Name)[0] -$AciEnvVars = $Aci.containers[0].environmentVariables | ForEach-Object { "$($_.name)=$($_.value)" } - -$CreateArgs = @( - 'container', 'create', - '--resource-group', $ResourceGroup, - '--name', $ContainerGroup, - '--image', "$AcrLoginServer/$BackendImage`:$Tag", - '--cpu', $AciCpu, - '--memory', $AciMemory, - '--ports', $AciPort, - '--os-type', $AciOsType, - '--restart-policy', $AciRestart, - '--assign-identity', $AciUami, - '--acr-identity', $AciUami, - '--registry-login-server', $AcrLoginServer -) -if ($AciSubnetId) { - $CreateArgs += @('--subnet', $AciSubnetId) +# Open ACR public access for the remote build (WAF mode); flags it for re-lock. +function Enable-AcrPublicAccess { + $script:AcrPublicAccess = Invoke-Az acr show -n $script:AcrName --query publicNetworkAccess --output tsv + if ($script:AcrPublicAccess -eq 'Disabled') { + Write-Host '===== ACR public access is disabled (private/WAF mode) - temporarily enabling it for the build =====' -ForegroundColor Yellow + Invoke-Az acr update -n $script:AcrName --public-network-enabled true --default-action Allow --output none --only-show-errors + $script:AcrOpenedForBuild = $true + Write-Host 'Waiting for the network rule change to propagate...' -ForegroundColor Yellow + Start-Sleep -Seconds 45 + } +} + +# Re-lock the ACR if this script opened it (called from finally). +function Restore-AcrPublicAccess { + if ($script:AcrOpenedForBuild) { + Write-Host '===== Re-locking ACR (disabling public network access) =====' -ForegroundColor Yellow + az acr update -n $script:AcrName --public-network-enabled false --default-action Deny --output none --only-show-errors + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to re-disable ACR public network access. Re-lock it manually: az acr update -n $($script:AcrName) --public-network-enabled false --default-action Deny" + } + } +} + +# Build and push a single image remotely in ACR. +function Build-AndPushImage { + param( + [string]$ImageRef, + [string]$Dockerfile, + [string]$Context + ) + Write-Host "===== Building Image ($ImageRef) =====" -ForegroundColor Yellow + Invoke-Az acr build --registry $script:AcrName --image $ImageRef ` + --file $Dockerfile --platform linux $Context +} + +# Recreate the backend ACI on the new image. ACI can't swap an image in place and a +# restart reuses the original (hello-world) image, so we read the current group's +# config and recreate it. Done before the frontend so its new private IP (WAF mode) +# is known up-front and the frontend only restarts once. +function New-BackendContainerInstance { + Write-Host "===== Recreating Backend Container Instance ($($script:ContainerGroup)) =====" -ForegroundColor Yellow + + $Aci = az container show -g $script:ResourceGroup -n $script:ContainerGroup -o json | ConvertFrom-Json + if (-not $Aci) { throw "Could not read existing container group '$($script:ContainerGroup)'." } + + $AciCpu = $Aci.containers[0].resources.requests.cpu + $AciMemory = $Aci.containers[0].resources.requests.memoryInGb + $AciPort = $Aci.containers[0].ports[0].port + $AciOsType = $Aci.osType + $AciRestart = $Aci.restartPolicy + $AciDnsLabel = $Aci.ipAddress.dnsNameLabel + $AciSubnetId = if ($Aci.subnetIds) { $Aci.subnetIds[0].id } else { $null } + $AciUami = @($Aci.identity.userAssignedIdentities.PSObject.Properties.Name)[0] + $AciEnvVars = $Aci.containers[0].environmentVariables | ForEach-Object { "$($_.name)=$($_.value)" } + + $CreateArgs = @( + 'container', 'create', + '--resource-group', $script:ResourceGroup, + '--name', $script:ContainerGroup, + '--image', "$($script:AcrLoginServer)/$BackendImage`:$Tag", + '--cpu', $AciCpu, + '--memory', $AciMemory, + '--ports', $AciPort, + '--os-type', $AciOsType, + '--restart-policy', $AciRestart, + '--assign-identity', $AciUami, + '--acr-identity', $AciUami, + '--registry-login-server', $script:AcrLoginServer + ) + if ($AciSubnetId) { + $CreateArgs += @('--subnet', $AciSubnetId) + } else { + $CreateArgs += @('--ip-address', 'Public', '--dns-name-label', $AciDnsLabel) + } + $CreateArgs += '--environment-variables' + $CreateArgs += $AciEnvVars + + Invoke-Az @CreateArgs --output none + + # WAF mode: the recreated ACI gets a NEW private IP (no FQDN), so the frontend's + # baked BACKEND_URL is stale. Capture the new IP to update it before the restart. + if ($AciSubnetId) { + $NewAci = az container show -g $script:ResourceGroup -n $script:ContainerGroup -o json | ConvertFrom-Json + $NewIp = $NewAci.ipAddress.ip + if ($NewIp) { + $script:NewBackendUrl = "http://${NewIp}:$AciPort" + } else { + Write-Warning 'Could not determine the new ACI private IP; frontend BACKEND_URL not updated.' + } + } +} + +# Re-apply the managed-identity ACR-pull config on the frontend. `webapp config +# container set` partially rewrites config/web and drops the MI creds; the ACR admin +# user is disabled in both modes, so without re-applying +# acrUseManagedIdentityCreds/acrUserManagedIdentityID the next restart fails with +# ImagePullFailure. WAF mode also restores the VNet image-pull props via REST +# (vnetImagePullEnabled is site-level; the others live on config/web). +function Restore-FrontendAcrPullConfig { + param( + [string]$ClientId, + [string]$WebId + ) + Write-Host '===== Restoring managed-identity ACR-pull config on frontend =====' -ForegroundColor Yellow + if ($script:AcrPublicAccess -eq 'Disabled') { + $WebCfgBody = (@{ properties = @{ + acrUseManagedIdentityCreds = $true + acrUserManagedIdentityID = $ClientId + vnetRouteAllEnabled = $true + }} | ConvertTo-Json -Compress) + + $Tmp1 = New-TemporaryFile; Set-Content -Path $Tmp1 -Value $WebCfgBody -Encoding utf8 + Invoke-Az rest --method patch ` + --uri "https://management.azure.com$WebId/config/web?api-version=2023-12-01" ` + --headers "Content-Type=application/json" --body "@$Tmp1" --output none --only-show-errors + Remove-Item $Tmp1 -Force + + $SiteBody = '{"properties":{"vnetImagePullEnabled":true}}' + $Tmp2 = New-TemporaryFile; Set-Content -Path $Tmp2 -Value $SiteBody -Encoding utf8 + Invoke-Az rest --method patch ` + --uri "https://management.azure.com$WebId`?api-version=2023-12-01" ` + --headers "Content-Type=application/json" --body "@$Tmp2" --output none --only-show-errors + Remove-Item $Tmp2 -Force + } else { + # Public (non-WAF): proven rc-acr-update-cg path. + Invoke-Az resource update --ids "$WebId/config/web" ` + --set properties.acrUseManagedIdentityCreds=true ` + --set properties.acrUserManagedIdentityID=$ClientId --output none + } +} + +# Update the frontend to the new image + ACR-pull config + backend URL, then restart. +function Update-FrontendApp { + Write-Host "===== Updating Frontend App Service ($($script:AppService)) =====" -ForegroundColor Yellow + + $Uami = Invoke-Az webapp identity show -g $script:ResourceGroup -n $script:AppService --query "userAssignedIdentities | keys(@) | [0]" --output tsv + if (-not $Uami) { throw "Could not resolve user-assigned identity for web app '$($script:AppService)'." } + $ClientId = Invoke-Az identity show --ids $Uami --query clientId --output tsv + if (-not $ClientId) { throw "Could not resolve clientId for identity '$Uami'." } + + Invoke-Az webapp config container set -g $script:ResourceGroup -n $script:AppService ` + --container-image-name "$($script:AcrLoginServer)/$FrontendImage`:$Tag" ` + --container-registry-url "https://$($script:AcrLoginServer)" ` + --output none --only-show-errors + + $WebId = Invoke-Az webapp show -g $script:ResourceGroup -n $script:AppService --query id --output tsv + if (-not $WebId) { throw "Could not resolve resource id for web app '$($script:AppService)'." } + + # Point the frontend at the new ACI private IP (WAF mode) before restart. + if ($script:NewBackendUrl) { + Write-Host "===== Updating frontend BACKEND_URL to $($script:NewBackendUrl) =====" -ForegroundColor Yellow + Invoke-Az webapp config appsettings set -g $script:ResourceGroup -n $script:AppService ` + --settings "BACKEND_URL=$($script:NewBackendUrl)" --output none --only-show-errors + } + + Restore-FrontendAcrPullConfig -ClientId $ClientId -WebId $WebId + + Write-Host 'Restarting Frontend App Service...' -ForegroundColor Yellow + Invoke-Az webapp restart -g $script:ResourceGroup -n $script:AppService --output none +} + +function Write-Config { + Write-Host '' + Write-Host '===============================================' + Write-Host 'Values to be used:' + Write-Host '===============================================' + Write-Host "Resource Group: $($script:ResourceGroup)" + Write-Host "ACR Name: $($script:AcrName)" + Write-Host "ACR Login Server: $($script:AcrLoginServer)" + Write-Host "App Service: $($script:AppService)" + Write-Host "Container Group: $($script:ContainerGroup)" + Write-Host "Frontend Image: $FrontendImage`:$Tag" + Write-Host "Backend Image: $BackendImage`:$Tag" + Write-Host '===============================================' + Write-Host '' +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +Write-Host '===============================================' +Write-Host 'Build & Deploy Container Images' +Write-Host '===============================================' + +Confirm-AzureAuth + +# Config: azd environment by default, deployment outputs when a RG is given. +if (-not $ResourceGroupName) { + if (-not (Get-ConfigFromAzdEnv)) { + Write-Host '' + Write-Host 'Failed to get values from the azd environment.' + Write-Host 'Provide a resource group name to resolve values from deployment outputs instead:' + Write-Host " Usage: $($MyInvocation.MyCommand.Name) [ResourceGroupName]" + exit 1 + } } else { - $CreateArgs += @('--ip-address', 'Public', '--dns-name-label', $AciDnsLabel) + if (-not (Get-ConfigFromDeployment -Rg $ResourceGroupName)) { + Write-Host 'Failed to get values from deployment outputs.' + exit 1 + } } -$CreateArgs += '--environment-variables' -$CreateArgs += $AciEnvVars -Invoke-Az @CreateArgs --output none +$script:AcrLoginServer = "$($script:AcrName).azurecr.io" + +Write-Config + +try { + Enable-AcrPublicAccess + + Build-AndPushImage -ImageRef "$FrontendImage`:$Tag" -Dockerfile $FrontendDockerfile -Context $FrontendContext + Build-AndPushImage -ImageRef "$BackendImage`:$Tag" -Dockerfile $BackendDockerfile -Context $BackendContext + + # Recreate the backend ACI first (new IP up-front), then update/restart frontend once. + New-BackendContainerInstance + Update-FrontendApp +} +finally { + Restore-AcrPublicAccess +} -Write-Host "" -Write-Host "===== Done =====" -ForegroundColor Green -Write-Host "Frontend image: $AcrLoginServer/$FrontendImage`:$Tag" -ForegroundColor Cyan -Write-Host "Backend image: $AcrLoginServer/$BackendImage`:$Tag" -ForegroundColor Cyan +Write-Host '' +Write-Host '===== Done =====' -ForegroundColor Green +Write-Host "Frontend image: $($script:AcrLoginServer)/$FrontendImage`:$Tag" -ForegroundColor Cyan +Write-Host "Backend image: $($script:AcrLoginServer)/$BackendImage`:$Tag" -ForegroundColor Cyan diff --git a/infra/scripts/build_and_deploy_images.sh b/infra/scripts/build_and_deploy_images.sh index d0a2c4cf1..fc42c12d6 100644 --- a/infra/scripts/build_and_deploy_images.sh +++ b/infra/scripts/build_and_deploy_images.sh @@ -3,120 +3,365 @@ set -euo pipefail # ============================================================================ # build_and_deploy_images.sh -# ---------------------------------------------------------------------------- -# Builds the frontend and backend images remotely in ACR (az acr build - no -# local Docker needed), pushes them to the ACR created by `azd up`, updates the -# frontend App Service to the new image and restarts it, then recreates the -# backend Container Instance on the new image. # -# Run AFTER `azd up`. Configuration is read from the azd environment. +# Post-deployment script. Builds the frontend/backend images remotely in ACR +# (az acr build, no local Docker), updates the frontend App Service and restarts +# it, then recreates the backend Container Instance (ACI) on the new image. +# +# Usage: bash ./infra/scripts/build_and_deploy_images.sh [ResourceGroupName] +# No argument -> config from the azd environment. +# ResourceGroupName -> config from that RG's latest successful deployment. +# +# In WAF mode ACR public access is disabled, so the build agent can't push. The +# script temporarily opens the registry and re-locks it on exit (trap); pulls +# keep working over the private endpoint. # ============================================================================ -# Load azd environment outputs into the shell. Parse without `eval` so that any -# command substitution embedded in a value cannot execute in this shell. -if command -v azd >/dev/null 2>&1; then +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +FRONTEND_IMAGE="content-gen-app" +BACKEND_IMAGE="content-gen-api" +TAG="latest" +FRONTEND_CONTEXT="${REPO_ROOT}/src/App" +FRONTEND_DOCKERFILE="${REPO_ROOT}/src/App/WebApp.Dockerfile" +BACKEND_CONTEXT="${REPO_ROOT}/src/backend" +BACKEND_DOCKERFILE="${REPO_ROOT}/src/backend/ApiApp.Dockerfile" + +RESOURCE_GROUP="" +ACR_NAME="" +APP_SERVICE="" +CONTAINER_GROUP="" +ACR_LOGIN_SERVER="" + +ACR_PUBLIC_ACCESS="" +ACR_OPENED_FOR_BUILD=false +NEW_BACKEND_URL="" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +check_azure_auth() { + echo "Checking Azure authentication..." + if az account show >/dev/null 2>&1; then + echo "Already authenticated with Azure." + else + echo "Authenticating with Azure CLI..." + if ! az login --use-device-code; then + echo "ERROR: Failed to authenticate with Azure." >&2 + exit 1 + fi + fi +} + +# Config from the azd environment. Parses `azd env get-values` without `eval` so +# any command substitution embedded in a value cannot execute here. +get_values_from_azd_env() { + echo "Getting values from azd environment..." + if ! command -v azd >/dev/null 2>&1; then + echo "ERROR: azd is not installed and no resource group argument was provided." >&2 + return 1 + fi + while IFS='=' read -r _key _val; do [ -z "${_key}" ] && continue _val="${_val%\"}" _val="${_val#\"}" - export "${_key}=${_val}" + case "${_key}" in + AZURE_ENV_CONTAINER_REGISTRY_NAME) ACR_NAME="${_val}" ;; + RESOURCE_GROUP_NAME) RESOURCE_GROUP="${_val}" ;; + APP_SERVICE_NAME) APP_SERVICE="${_val}" ;; + CONTAINER_INSTANCE_NAME) CONTAINER_GROUP="${_val}" ;; + esac done < <(azd env get-values 2>/dev/null) -fi -ACR_NAME="${AZURE_ENV_CONTAINER_REGISTRY_NAME:-}" -RESOURCE_GROUP="${RESOURCE_GROUP_NAME:-}" -APP_SERVICE="${APP_SERVICE_NAME:-}" -CONTAINER_GROUP="${CONTAINER_INSTANCE_NAME:-}" + if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ] || [ -z "${APP_SERVICE}" ] || [ -z "${CONTAINER_GROUP}" ]; then + echo "ERROR: One or more required values could not be retrieved from the azd environment" \ + "(AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME)." >&2 + return 1 + fi + return 0 +} -FRONTEND_IMAGE="content-gen-app" -BACKEND_IMAGE="content-gen-api" -TAG="latest" +# Config from the latest successful deployment's outputs in the given RG. ARM +# lower-cases the first token of each output name (APP_SERVICE_NAME -> +# apP_SERVICE_NAME), so extraction is case-insensitive. +get_values_from_az_deployment() { + echo "Getting values from Azure deployment outputs..." + RESOURCE_GROUP="$1" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + local deploymentName + deploymentName="$(az deployment group list -g "${RESOURCE_GROUP}" \ + --query "sort_by([?properties.provisioningState=='Succeeded'], &properties.timestamp)[-1].name" -o tsv 2>/dev/null || true)" + if [ -z "${deploymentName}" ]; then + echo "ERROR: Could not find a successful deployment in resource group '${RESOURCE_GROUP}'." >&2 + return 1 + fi + echo "Using deployment outputs from: ${deploymentName}" + + local outputs + outputs="$(az deployment group show -g "${RESOURCE_GROUP}" -n "${deploymentName}" --query "properties.outputs" -o json 2>/dev/null || true)" + + extract_output() { + echo "${outputs}" | grep -i -A 3 "\"$1\"" | grep '"value"' | head -n1 | sed 's/.*"value": *"\([^"]*\)".*/\1/' || true + } + + ACR_NAME="$(extract_output AZURE_ENV_CONTAINER_REGISTRY_NAME)" + APP_SERVICE="$(extract_output APP_SERVICE_NAME)" + CONTAINER_GROUP="$(extract_output CONTAINER_INSTANCE_NAME)" + + if [ -z "${ACR_NAME}" ] || [ -z "${APP_SERVICE}" ] || [ -z "${CONTAINER_GROUP}" ]; then + echo "ERROR: One or more required values could not be retrieved from deployment outputs." >&2 + return 1 + fi + return 0 +} + +# Open ACR public access for the remote build (WAF mode); flags it for re-lock. +enable_acr_public_access() { + ACR_PUBLIC_ACCESS="$(az acr show -n "${ACR_NAME}" --query publicNetworkAccess -o tsv)" + if [ "${ACR_PUBLIC_ACCESS}" = "Disabled" ]; then + echo "===== ACR public access is disabled (private/WAF mode) - temporarily enabling it for the build =====" + az acr update -n "${ACR_NAME}" --public-network-enabled true --default-action Allow -o none --only-show-errors + ACR_OPENED_FOR_BUILD=true + echo "Waiting for the network rule change to propagate..." + sleep 45 + fi +} + +# Re-lock the ACR if this script opened it (runs from the EXIT trap). +relock_acr() { + if [ "${ACR_OPENED_FOR_BUILD}" = true ]; then + echo "===== Re-locking ACR (disabling public network access) =====" + az acr update -n "${ACR_NAME}" --public-network-enabled false --default-action Deny -o none --only-show-errors \ + || echo "WARNING: Failed to re-disable ACR public network access. Re-lock it manually: az acr update -n ${ACR_NAME} --public-network-enabled false --default-action Deny" >&2 + fi +} + +# Build and push a single image remotely in ACR. Args: +build_and_push_image() { + local imageRef="$1" + local dockerfile="$2" + local context="$3" + echo "===== Building Image (${imageRef}) =====" + az acr build --registry "${ACR_NAME}" --image "${imageRef}" \ + --file "${dockerfile}" --platform linux "${context}" +} + +# Recreate the backend ACI on the new image. ACI can't swap an image in place and +# a restart reuses the original (hello-world) image, so we read the current group's +# config and recreate it. Done before the frontend so its new private IP (WAF mode) +# is known up-front and the frontend only restarts once. +recreate_backend_aci() { + echo "===== Recreating Backend Container Instance (${CONTAINER_GROUP}) =====" + + local aci_cpu aci_memory aci_port aci_os_type aci_restart aci_dns_label aci_subnet_id aci_uami + aci_cpu="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.cpu" -o tsv)" + aci_memory="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.memoryInGb" -o tsv)" + aci_port="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].ports[0].port" -o tsv)" + aci_os_type="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "osType" -o tsv)" + aci_restart="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "restartPolicy" -o tsv)" + aci_dns_label="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.dnsNameLabel" -o tsv)" + aci_subnet_id="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "subnetIds[0].id" -o tsv 2>/dev/null || true)" + aci_uami="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv)" + + # Capture env vars as NAME=VALUE (values may contain '=', e.g. the App Insights + # connection string; az create splits on the first '='). + local env_vars=() + while IFS= read -r line; do + [ -n "${line}" ] && env_vars+=("${line}") + done < <(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" \ + --query "containers[0].environmentVariables[].join('=', [name, value])" -o tsv) + + local create_args=( + container create + --resource-group "${RESOURCE_GROUP}" + --name "${CONTAINER_GROUP}" + --image "${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" + --cpu "${aci_cpu}" + --memory "${aci_memory}" + --ports "${aci_port}" + --os-type "${aci_os_type}" + --restart-policy "${aci_restart}" + --assign-identity "${aci_uami}" + --acr-identity "${aci_uami}" + --registry-login-server "${ACR_LOGIN_SERVER}" + ) + if [ -n "${aci_subnet_id}" ]; then + create_args+=(--subnet "${aci_subnet_id}") + else + create_args+=(--ip-address Public --dns-name-label "${aci_dns_label}") + fi + create_args+=(--environment-variables "${env_vars[@]}") + + az "${create_args[@]}" -o none + + # WAF mode: the recreated ACI gets a NEW private IP (no FQDN), so the frontend's + # baked BACKEND_URL is stale. Capture the new IP to update it before the restart. + if [ -n "${aci_subnet_id}" ]; then + local new_ip + new_ip="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.ip" -o tsv)" + if [ -n "${new_ip}" ]; then + NEW_BACKEND_URL="http://${new_ip}:${aci_port}" + else + echo "WARNING: Could not determine the new ACI private IP; frontend BACKEND_URL not updated." >&2 + fi + fi +} + +# Re-apply the managed-identity ACR-pull config on the frontend. `webapp config +# container set` partially rewrites config/web and drops the MI creds; the ACR +# admin user is disabled in both modes, so without re-applying +# acrUseManagedIdentityCreds/acrUserManagedIdentityID the next restart fails with +# ImagePullFailure. WAF mode also restores the VNet image-pull props via REST +# (vnetImagePullEnabled is site-level; the others live on config/web). +# Args: +restore_frontend_acr_pull_config() { + local clientId="$1" + local webAppId="$2" + echo "===== Restoring managed-identity ACR-pull config on frontend =====" + if [ "${ACR_PUBLIC_ACCESS}" = "Disabled" ]; then + az rest --method patch \ + --uri "https://management.azure.com${webAppId}/config/web?api-version=2023-12-01" \ + --headers "Content-Type=application/json" \ + --body "{\"properties\":{\"acrUseManagedIdentityCreds\":true,\"acrUserManagedIdentityID\":\"${clientId}\",\"vnetRouteAllEnabled\":true}}" \ + -o none --only-show-errors + az rest --method patch \ + --uri "https://management.azure.com${webAppId}?api-version=2023-12-01" \ + --headers "Content-Type=application/json" \ + --body '{"properties":{"vnetImagePullEnabled":true}}' \ + -o none --only-show-errors + else + # Public (non-WAF): proven rc-acr-update-cg path. + az resource update --ids "${webAppId}/config/web" \ + --set properties.acrUseManagedIdentityCreds=true \ + --set properties.acrUserManagedIdentityID="${clientId}" -o none + fi +} + +# Update the frontend to the new image + ACR-pull config + backend URL, then restart. +update_frontend_app() { + echo "===== Updating Frontend App Service (${APP_SERVICE}) =====" + local uami_rid client_id webapp_id + uami_rid="$(az webapp identity show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --query "userAssignedIdentities | keys(@) | [0]" -o tsv)" + if [ -z "${uami_rid}" ]; then + echo "ERROR: Could not resolve user-assigned identity for web app '${APP_SERVICE}'." >&2 + exit 1 + fi + client_id="$(az identity show --ids "${uami_rid}" --query clientId -o tsv)" + if [ -z "${client_id}" ]; then + echo "ERROR: Could not resolve clientId for identity '${uami_rid}'." >&2 + exit 1 + fi + + az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --container-image-name "${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" \ + --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none --only-show-errors + + webapp_id="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" + if [ -z "${webapp_id}" ]; then + echo "ERROR: Could not resolve resource id for web app '${APP_SERVICE}'." >&2 + exit 1 + fi + + # Point the frontend at the new ACI private IP (WAF mode) before restart. + if [ -n "${NEW_BACKEND_URL}" ]; then + echo "===== Updating frontend BACKEND_URL to ${NEW_BACKEND_URL} =====" + az webapp config appsettings set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ + --settings "BACKEND_URL=${NEW_BACKEND_URL}" -o none --only-show-errors + fi + + restore_frontend_acr_pull_config "${client_id}" "${webapp_id}" + + echo "Restarting Frontend App Service..." + az webapp restart -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" -o none +} + +print_config() { + echo "" + echo "===============================================" + echo "Values to be used:" + echo "===============================================" + echo "Resource Group: ${RESOURCE_GROUP}" + echo "ACR Name: ${ACR_NAME}" + echo "ACR Login Server: ${ACR_LOGIN_SERVER}" + echo "App Service: ${APP_SERVICE}" + echo "Container Group: ${CONTAINER_GROUP}" + echo "Frontend Image: ${FRONTEND_IMAGE}:${TAG}" + echo "Backend Image: ${BACKEND_IMAGE}:${TAG}" + echo "===============================================" + echo "" +} + +# Always re-lock the ACR (if opened) on exit and preserve the exit code. +cleanup_on_exit() { + local exit_code=$? + relock_acr + echo "" + if [ ${exit_code} -ne 0 ]; then + echo "Script failed (exit code ${exit_code})." + fi + exit ${exit_code} +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + local resourceGroupArg="" + while [[ $# -gt 0 ]]; do + case "$1" in + *) + if [ -z "${resourceGroupArg}" ]; then + resourceGroupArg="$1" + fi + shift + ;; + esac + done + + echo "===============================================" + echo "Build & Deploy Container Images" + echo "===============================================" + + check_azure_auth + + # Config: azd environment by default, deployment outputs when a RG is given. + if [ -z "${resourceGroupArg}" ]; then + if ! get_values_from_azd_env; then + echo "" >&2 + echo "Failed to get values from the azd environment." >&2 + echo "Provide a resource group name to resolve values from deployment outputs instead:" >&2 + echo " Usage: $0 [ResourceGroupName]" >&2 + exit 1 + fi + else + if ! get_values_from_az_deployment "${resourceGroupArg}"; then + echo "Failed to get values from deployment outputs." >&2 + exit 1 + fi + fi + + ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io" + + print_config + + trap cleanup_on_exit EXIT + enable_acr_public_access + + build_and_push_image "${FRONTEND_IMAGE}:${TAG}" "${FRONTEND_DOCKERFILE}" "${FRONTEND_CONTEXT}" + build_and_push_image "${BACKEND_IMAGE}:${TAG}" "${BACKEND_DOCKERFILE}" "${BACKEND_CONTEXT}" + + # Recreate the backend ACI first (new IP up-front), then update/restart frontend once. + recreate_backend_aci + update_frontend_app + + echo "" + echo "===== Done =====" + echo "Frontend image: ${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" + echo "Backend image: ${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" +} -if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ] || [ -z "${APP_SERVICE}" ] || [ -z "${CONTAINER_GROUP}" ]; then - echo "ERROR: Missing required azd environment values (AZURE_ENV_CONTAINER_REGISTRY_NAME, RESOURCE_GROUP_NAME, APP_SERVICE_NAME, CONTAINER_INSTANCE_NAME)." >&2 - exit 1 -fi - -ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io" - -# ===== Build & push frontend image ===== -echo "===== Building Frontend Image (${FRONTEND_IMAGE}:${TAG}) =====" -az acr build --registry "${ACR_NAME}" --image "${FRONTEND_IMAGE}:${TAG}" \ - --file "${REPO_ROOT}/src/App/WebApp.Dockerfile" --platform linux "${REPO_ROOT}/src/App" - -# ===== Build & push backend image ===== -echo "===== Building Backend Image (${BACKEND_IMAGE}:${TAG}) =====" -az acr build --registry "${ACR_NAME}" --image "${BACKEND_IMAGE}:${TAG}" \ - --file "${REPO_ROOT}/src/backend/ApiApp.Dockerfile" --platform linux "${REPO_ROOT}/src/backend" - -# ===== Update frontend App Service image (managed-identity ACR pull) ===== -echo "===== Updating Frontend App Service (${APP_SERVICE}) =====" -UAMI_RID="$(az webapp identity show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ - --query "userAssignedIdentities | keys(@) | [0]" -o tsv)" -CLIENT_ID="$(az identity show --ids "${UAMI_RID}" --query clientId -o tsv)" - -az webapp config container set -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" \ - --container-image-name "${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" \ - --container-registry-url "https://${ACR_LOGIN_SERVER}" -o none --only-show-errors - -WEBAPP_ID="$(az webapp show -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" --query id -o tsv)" -az resource update --ids "${WEBAPP_ID}/config/web" \ - --set properties.acrUseManagedIdentityCreds=true \ - --set properties.acrUserManagedIdentityID="${CLIENT_ID}" -o none - -echo "Restarting Frontend App Service..." -az webapp restart -g "${RESOURCE_GROUP}" -n "${APP_SERVICE}" -o none - -# ===== Recreate backend Container Instance with the new image ===== -# ACI cannot update a container group's image in place, and a restart reuses the -# originally deployed (hello-world) image. So we capture the existing container -# group's configuration and recreate it pointing at the new ACR image. -echo "===== Recreating Backend Container Instance (${CONTAINER_GROUP}) =====" - -ACI_CPU="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.cpu" -o tsv)" -ACI_MEMORY="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].resources.requests.memoryInGb" -o tsv)" -ACI_PORT="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "containers[0].ports[0].port" -o tsv)" -ACI_OS_TYPE="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "osType" -o tsv)" -ACI_RESTART="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "restartPolicy" -o tsv)" -ACI_DNS_LABEL="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "ipAddress.dnsNameLabel" -o tsv)" -ACI_SUBNET_ID="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "subnetIds[0].id" -o tsv 2>/dev/null || true)" -ACI_UAMI="$(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" --query "identity.userAssignedIdentities | keys(@) | [0]" -o tsv)" - -# Capture env vars as NAME=VALUE (handles values containing '=' such as the -# Application Insights connection string, since az create splits on the first '='). -ENV_VARS=() -while IFS= read -r line; do - [ -n "${line}" ] && ENV_VARS+=("${line}") -done < <(az container show -g "${RESOURCE_GROUP}" -n "${CONTAINER_GROUP}" \ - --query "containers[0].environmentVariables[].join('=', [name, value])" -o tsv) - -CREATE_ARGS=( - container create - --resource-group "${RESOURCE_GROUP}" - --name "${CONTAINER_GROUP}" - --image "${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" - --cpu "${ACI_CPU}" - --memory "${ACI_MEMORY}" - --ports "${ACI_PORT}" - --os-type "${ACI_OS_TYPE}" - --restart-policy "${ACI_RESTART}" - --assign-identity "${ACI_UAMI}" - --acr-identity "${ACI_UAMI}" - --registry-login-server "${ACR_LOGIN_SERVER}" -) -if [ -n "${ACI_SUBNET_ID}" ]; then - CREATE_ARGS+=(--subnet "${ACI_SUBNET_ID}") -else - CREATE_ARGS+=(--ip-address Public --dns-name-label "${ACI_DNS_LABEL}") -fi -CREATE_ARGS+=(--environment-variables "${ENV_VARS[@]}") - -az "${CREATE_ARGS[@]}" -o none - -echo "" -echo "===== Done =====" -echo "Frontend image: ${ACR_LOGIN_SERVER}/${FRONTEND_IMAGE}:${TAG}" -echo "Backend image: ${ACR_LOGIN_SERVER}/${BACKEND_IMAGE}:${TAG}" +main "$@"