Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,10 @@ flowchart TB
%% LLM usage
PROC -->|LLM call| AOAI

%% Image pulls
ACR -->|Pull image| FE
ACR -->|Pull image| API
ACR -->|Pull image| PROC
%% Image pulls (identity-based, AcrPull via managed identity - no anonymous pull)
ACR -->|Pull image · AcrPull| FE
ACR -->|Pull image · AcrPull| API
ACR -->|Pull image · AcrPull| PROC

%% Identity usage
ID -.-> FE
Expand Down
76 changes: 70 additions & 6 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ var solutionLocation = empty(location) ? resourceGroup().location : location
azd: {
type: 'location'
usageName: [
'OpenAI.GlobalStandard.gpt-5.1, 500'
'OpenAI.GlobalStandard.gpt-5.1, 100'
]
}
})
Expand All @@ -41,12 +41,17 @@ param azureAiServiceLocation string



@description('Optional. The endpoint (excluding https://) of an existing container registry. This is the `loginServer` when using Azure Container Registry.')
param containerRegistryEndpoint string = 'containermigrationacr.azurecr.io'
@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.')
#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. 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'
Comment on lines +51 to +53

@minLength(1)
@allowed(['Standard', 'GlobalStandard'])
@description('Optional. Model deployment type. Defaults to GlobalStandard.')
Expand Down Expand Up @@ -220,6 +225,35 @@ module appIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.
}
}

// ========== Dedicated Azure Container Registry ========== //
// Each deployment provisions its own ACR instead of relying on a shared/public
// registry with anonymous pull. Images are pulled using identity-based
// authentication (AcrPull role granted to the application managed identity).
var containerRegistryName = take('cr${solutionSuffix}', 50)
module containerRegistry './modules/containerRegistry.bicep' = {
name: take('module.container-registry.${solutionSuffix}', 64)
params: {
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.
sku: enablePrivateNetworking ? 'Premium' : 'Standard'
publicNetworkAccess: 'Enabled'
networkRuleBypassOptions: 'AzureServices'
// Application managed identity gets AcrPull for identity-based image pulls.
acrPullPrincipalIds: [
appIdentity.outputs.principalId
]
// Deployer gets a registry-scoped Contributor role so it can run remote
// builds (az acr build) and push images from the post-deployment script.
buildPrincipalId: deployingUserPrincipalId
buildPrincipalType: deployingUserType
}
}

// ========== 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
Expand Down Expand Up @@ -1335,10 +1369,16 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = {
appIdentity.outputs.resourceId
]
}
registries: [
{
server: containerRegistry.outputs.loginServer
identity: appIdentity.outputs.resourceId
}
]
containers: [
{
name: 'backend-api'
image: '${containerRegistryEndpoint}/backend-api:${imageTag}'
image: placeholderContainerImage
env: concat(
[
{
Expand Down Expand Up @@ -1422,10 +1462,16 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = {
appIdentity.outputs.resourceId
]
}
registries: [
{
server: containerRegistry.outputs.loginServer
identity: appIdentity.outputs.resourceId
}
]
containers: [
{
name: 'frontend'
image: '${containerRegistryEndpoint}/frontend:${imageTag}'
image: placeholderContainerImage
env: [
{
name: 'API_URL'
Expand Down Expand Up @@ -1490,10 +1536,16 @@ module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = {
appIdentity.outputs.resourceId
]
}
registries: [
{
server: containerRegistry.outputs.loginServer
identity: appIdentity.outputs.resourceId
}
]
containers: [
{
name: 'processor'
image: '${containerRegistryEndpoint}/processor:${imageTag}'
image: placeholderContainerImage
env: concat(
[
{
Expand Down Expand Up @@ -1572,6 +1624,18 @@ output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId
@description('The Azure resource group name.')
output AZURE_RESOURCE_GROUP string = resourceGroup().name

@description('The name of the dedicated Azure Container Registry.')
output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name

@description('The login server (endpoint) of the dedicated Azure Container Registry.')
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer

@description('The name of the processor container app.')
output CONTAINER_PROCESSOR_APP_NAME string = containerAppProcessor.outputs.name

@description('The image tag used for deployment-specific container images.')
output AZURE_ENV_IMAGE_TAG string = imageTag

// Log deployer information for debugging
output deployerObjectId string = deployingUserPrincipalId
output deployerType string = deployingUserType
110 changes: 110 additions & 0 deletions infra/modules/containerRegistry.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
metadata name = 'Dedicated Azure Container Registry'
metadata description = '''Provisions a dedicated Azure Container Registry (ACR) for a single deployment and configures identity-based authentication.
Admin 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 Contributor role so it can run remote builds (`az acr build`) and push images.'''

@description('Required. Name of the Azure Container Registry. Must be globally unique and 5-50 alphanumeric characters.')
@maxLength(50)
param name string

@description('Optional. Azure region for the registry. Defaults to the resource group location.')
param location string = resourceGroup().location

@description('Optional. Tags to apply to the registry.')
param tags object = {}

@description('Optional. SKU for the registry. Premium is required for private networking. Defaults to Standard.')
@allowed([
'Basic'
'Standard'
'Premium'
])
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.')
@allowed([
'Enabled'
'Disabled'
])
param publicNetworkAccess string = 'Enabled'

@description('Optional. Whether to allow trusted Azure services (e.g. ACR Tasks used by `az acr build`) to bypass network rules. Defaults to AzureServices.')
@allowed([
'AzureServices'
'None'
])
param networkRuleBypassOptions string = 'AzureServices'

@description('Optional. Principal IDs (managed identities) to grant the AcrPull role so they can pull images using identity-based authentication.')
param acrPullPrincipalIds array = []

@description('Optional. Principal ID (e.g. the deployer) to grant a registry-scoped Contributor role so it can run remote builds (az acr build) and push images. Leave empty to skip.')
param buildPrincipalId string = ''

@description('Optional. Principal type for the build principal.')
@allowed([
'Device'
'ForeignGroup'
'Group'
'ServicePrincipal'
'User'
])
param buildPrincipalType string = 'User'

// AcrPull role definition ID (allows pulling images).
var acrPullRoleDefinitionId = '7f951dda-4ed3-4680-a7ca-43fe172d538d'
// Contributor role definition ID (allows remote build / scheduleRun + push).
var contributorRoleDefinitionId = 'b24988ac-6180-42a0-ab88-20f7382dd24c'
Comment on lines +55 to +56

resource registry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = {
name: name
location: location
tags: tags
sku: {
name: sku
}
properties: {
// Identity-based authentication only - no admin credentials.
adminUserEnabled: false
// Explicitly disable anonymous pull; access requires an authenticated identity.
anonymousPullEnabled: false
publicNetworkAccess: publicNetworkAccess
networkRuleBypassOptions: networkRuleBypassOptions
}
}

resource acrPullRoleAssignments 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
for principalId in acrPullPrincipalIds: {
name: guid(registry.id, principalId, acrPullRoleDefinitionId)
scope: registry
properties: {
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
acrPullRoleDefinitionId
)
principalId: principalId
principalType: 'ServicePrincipal'
}
}
]

resource acrBuildRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(buildPrincipalId)) {
name: guid(registry.id, buildPrincipalId, contributorRoleDefinitionId)
scope: registry
properties: {
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
contributorRoleDefinitionId
)
principalId: buildPrincipalId
principalType: buildPrincipalType
}
}

@description('The resource ID of the container registry.')
output resourceId string = registry.id

@description('The name of the container registry.')
output name string = registry.name

@description('The login server (endpoint) of the container registry, e.g. myregistry.azurecr.io.')
output loginServer string = registry.properties.loginServer
119 changes: 119 additions & 0 deletions scripts/deploy_container_images.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<#
.SYNOPSIS
Separate post-deployment script (Windows / PowerShell) that runs FIRST,
before any existing post-deployment scripts.

.DESCRIPTION
Builds the deployment-specific container images using Azure Container
Registry *remote* builds (az acr build - no local Docker required) and
pushes them to the dedicated, per-deployment ACR. It then updates the
Container Apps to use the freshly pushed images.

This does NOT depend on a shared/public registry or anonymous pull. Image
pulls at runtime use identity-based authentication (the app's managed
identity has the AcrPull role on the dedicated ACR).
#>

$ErrorActionPreference = 'Stop'

Write-Host "==> [deploy_container_images] Building and pushing images to the dedicated ACR (remote build)"

# ---------------------------------------------------------------------------
# Resolve required values. azd exports deployment outputs as environment
# variables inside hooks; fall back to `azd env get-values` if needed.
# ---------------------------------------------------------------------------
$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' }
$BackendApp = $env:CONTAINER_API_APP_NAME
$FrontendApp = $env:CONTAINER_WEB_APP_NAME
$ProcessorApp = $env:CONTAINER_PROCESSOR_APP_NAME

if ([string]::IsNullOrEmpty($AcrName) -or [string]::IsNullOrEmpty($ResourceGroup)) {
if (Get-Command azd -ErrorAction SilentlyContinue) {
Write-Host "==> Loading missing values from 'azd env get-values'"
foreach ($line in (azd env get-values)) {
if ($line -match '^(?<k>[A-Za-z0-9_]+)="?(?<v>.*?)"?$') {
$k = $Matches['k']; $v = $Matches['v']
switch ($k) {
'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_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 } }
'CONTAINER_PROCESSOR_APP_NAME' { if (-not $ProcessorApp) { $ProcessorApp = $v } }
}
}
}
}
}

# Derive the login server from the registry name if it was not provided.
if ([string]::IsNullOrEmpty($RegistryEndpoint)) {
$RegistryEndpoint = "$AcrName.azurecr.io"
}

$missing = @()
if ([string]::IsNullOrEmpty($AcrName)) { $missing += 'AZURE_CONTAINER_REGISTRY_NAME' }
if ([string]::IsNullOrEmpty($ResourceGroup)) { $missing += 'AZURE_RESOURCE_GROUP' }
if ($missing.Count -gt 0) {
Write-Error "Missing required deployment values: $($missing -join ', '). Ensure infrastructure has been provisioned (azd provision) first."
exit 1
}

# Ensure the Azure CLI has a valid, non-expired login. `az acr build` and
# `az containerapp update` authenticate via the az CLI (separate from azd), so a
# stale/expired token here would otherwise fail part-way through the build.
az account show --output none 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Error "Azure CLI is not authenticated or its token has expired. Run 'az login' (add '--tenant <tenant-id>' if needed) and re-run this script."
exit 1
}

# Resolve the repository root (this script lives in <root>/scripts).
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RootDir = Split-Path -Parent $ScriptDir

Write-Host " Registry : $RegistryEndpoint ($AcrName)"
Write-Host " Resource group : $ResourceGroup"
Write-Host " Image tag : $ImageTag"

function Build-Image {
param([string]$ImageName, [string]$ContextDir)
Write-Host "==> Remote build (az acr build): ${ImageName}:$ImageTag"
az acr build `
--registry $AcrName `
--image "${ImageName}:$ImageTag" `
--file (Join-Path $ContextDir 'Dockerfile') `
$ContextDir
if ($LASTEXITCODE -ne 0) { throw "az acr build failed for $ImageName" }
}

function Update-App {
param([string]$AppName, [string]$ImageName)
if ([string]::IsNullOrEmpty($AppName)) {
Write-Host "WARN: Container app name for '$ImageName' not set; skipping image update."
return
}
Write-Host "==> Updating container app '$AppName' -> $RegistryEndpoint/${ImageName}:$ImageTag"
az containerapp update `
--name $AppName `
--resource-group $ResourceGroup `
--image "$RegistryEndpoint/${ImageName}:$ImageTag" `
--output none
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')

# 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'

Write-Host "==> [deploy_container_images] Completed successfully."
Loading
Loading