Skip to content

Commit f396ee7

Browse files
feat: Implement dedicated Azure Container Registry with identity-based authentication and update deployment scripts
1 parent 94adfec commit f396ee7

7 files changed

Lines changed: 425 additions & 13 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,10 +205,10 @@ flowchart TB
205205
%% LLM usage
206206
PROC -->|LLM call| AOAI
207207
208-
%% Image pulls
209-
ACR -->|Pull image| FE
210-
ACR -->|Pull image| API
211-
ACR -->|Pull image| PROC
208+
%% Image pulls (identity-based, AcrPull via managed identity - no anonymous pull)
209+
ACR -->|Pull image · AcrPull| FE
210+
ACR -->|Pull image · AcrPull| API
211+
ACR -->|Pull image · AcrPull| PROC
212212
213213
%% Identity usage
214214
ID -.-> FE

infra/main.bicep

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ var solutionLocation = empty(location) ? resourceGroup().location : location
3232
azd: {
3333
type: 'location'
3434
usageName: [
35-
'OpenAI.GlobalStandard.gpt-5.1, 500'
35+
'OpenAI.GlobalStandard.gpt-5.1, 100'
3636
]
3737
}
3838
})
@@ -41,12 +41,17 @@ param azureAiServiceLocation string
4141

4242

4343

44-
@description('Optional. The endpoint (excluding https://) of an existing container registry. This is the `loginServer` when using Azure Container Registry.')
45-
param containerRegistryEndpoint string = 'containermigrationacr.azurecr.io'
44+
@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.')
45+
#disable-next-line no-unused-params
46+
param containerRegistryEndpoint string = ''
4647

4748
@description('Optional. The image tag to use for container images. Defaults to "latest_v2".')
4849
param imageTag string = 'latest_v2'
4950

51+
@description('''Optional. Placeholder container image used to initially provision the container apps.
52+
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.''')
53+
param placeholderContainerImage string = 'mcr.microsoft.com/k8se/quickstart:latest'
54+
5055
@minLength(1)
5156
@allowed(['Standard', 'GlobalStandard'])
5257
@description('Optional. Model deployment type. Defaults to GlobalStandard.')
@@ -220,6 +225,35 @@ module appIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.
220225
}
221226
}
222227

228+
// ========== Dedicated Azure Container Registry ========== //
229+
// Each deployment provisions its own ACR instead of relying on a shared/public
230+
// registry with anonymous pull. Images are pulled using identity-based
231+
// authentication (AcrPull role granted to the application managed identity).
232+
var containerRegistryName = take('cr${solutionSuffix}', 50)
233+
module containerRegistry './modules/containerRegistry.bicep' = {
234+
name: take('module.container-registry.${solutionSuffix}', 64)
235+
params: {
236+
name: containerRegistryName
237+
location: solutionLocation
238+
tags: allTags
239+
// Premium SKU in WAF/private-networking mode (supports higher throughput and
240+
// future private endpoints). Public network access is kept Enabled in both
241+
// modes so remote `az acr build` (ACR Tasks) and managed-identity pulls work;
242+
// AzureServices bypass lets trusted ACR Tasks reach the registry.
243+
sku: enablePrivateNetworking ? 'Premium' : 'Standard'
244+
publicNetworkAccess: 'Enabled'
245+
networkRuleBypassOptions: 'AzureServices'
246+
// Application managed identity gets AcrPull for identity-based image pulls.
247+
acrPullPrincipalIds: [
248+
appIdentity.outputs.principalId
249+
]
250+
// Deployer gets a registry-scoped Contributor role so it can run remote
251+
// builds (az acr build) and push images from the post-deployment script.
252+
buildPrincipalId: deployingUserPrincipalId
253+
buildPrincipalType: deployingUserType
254+
}
255+
}
256+
223257
// ========== Log Analytics Workspace ========== //
224258
// WAF best practices for Log Analytics: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-log-analytics
225259
// WAF PSRules for Log Analytics: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#azure-monitor-logs
@@ -1335,10 +1369,16 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = {
13351369
appIdentity.outputs.resourceId
13361370
]
13371371
}
1372+
registries: [
1373+
{
1374+
server: containerRegistry.outputs.loginServer
1375+
identity: appIdentity.outputs.resourceId
1376+
}
1377+
]
13381378
containers: [
13391379
{
13401380
name: 'backend-api'
1341-
image: '${containerRegistryEndpoint}/backend-api:${imageTag}'
1381+
image: placeholderContainerImage
13421382
env: concat(
13431383
[
13441384
{
@@ -1422,10 +1462,16 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = {
14221462
appIdentity.outputs.resourceId
14231463
]
14241464
}
1465+
registries: [
1466+
{
1467+
server: containerRegistry.outputs.loginServer
1468+
identity: appIdentity.outputs.resourceId
1469+
}
1470+
]
14251471
containers: [
14261472
{
14271473
name: 'frontend'
1428-
image: '${containerRegistryEndpoint}/frontend:${imageTag}'
1474+
image: placeholderContainerImage
14291475
env: [
14301476
{
14311477
name: 'API_URL'
@@ -1490,10 +1536,16 @@ module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = {
14901536
appIdentity.outputs.resourceId
14911537
]
14921538
}
1539+
registries: [
1540+
{
1541+
server: containerRegistry.outputs.loginServer
1542+
identity: appIdentity.outputs.resourceId
1543+
}
1544+
]
14931545
containers: [
14941546
{
14951547
name: 'processor'
1496-
image: '${containerRegistryEndpoint}/processor:${imageTag}'
1548+
image: placeholderContainerImage
14971549
env: concat(
14981550
[
14991551
{
@@ -1572,6 +1624,18 @@ output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId
15721624
@description('The Azure resource group name.')
15731625
output AZURE_RESOURCE_GROUP string = resourceGroup().name
15741626

1627+
@description('The name of the dedicated Azure Container Registry.')
1628+
output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name
1629+
1630+
@description('The login server (endpoint) of the dedicated Azure Container Registry.')
1631+
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer
1632+
1633+
@description('The name of the processor container app.')
1634+
output CONTAINER_PROCESSOR_APP_NAME string = containerAppProcessor.outputs.name
1635+
1636+
@description('The image tag used for deployment-specific container images.')
1637+
output AZURE_ENV_IMAGE_TAG string = imageTag
1638+
15751639
// Log deployer information for debugging
15761640
output deployerObjectId string = deployingUserPrincipalId
15771641
output deployerType string = deployingUserType
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
metadata name = 'Dedicated Azure Container Registry'
2+
metadata description = '''Provisions a dedicated Azure Container Registry (ACR) for a single deployment and configures identity-based authentication.
3+
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.'''
4+
5+
@description('Required. Name of the Azure Container Registry. Must be globally unique and 5-50 alphanumeric characters.')
6+
@maxLength(50)
7+
param name string
8+
9+
@description('Optional. Azure region for the registry. Defaults to the resource group location.')
10+
param location string = resourceGroup().location
11+
12+
@description('Optional. Tags to apply to the registry.')
13+
param tags object = {}
14+
15+
@description('Optional. SKU for the registry. Premium is required for private networking. Defaults to Standard.')
16+
@allowed([
17+
'Basic'
18+
'Standard'
19+
'Premium'
20+
])
21+
param sku string = 'Standard'
22+
23+
@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.')
24+
@allowed([
25+
'Enabled'
26+
'Disabled'
27+
])
28+
param publicNetworkAccess string = 'Enabled'
29+
30+
@description('Optional. Whether to allow trusted Azure services (e.g. ACR Tasks used by `az acr build`) to bypass network rules. Defaults to AzureServices.')
31+
@allowed([
32+
'AzureServices'
33+
'None'
34+
])
35+
param networkRuleBypassOptions string = 'AzureServices'
36+
37+
@description('Optional. Principal IDs (managed identities) to grant the AcrPull role so they can pull images using identity-based authentication.')
38+
param acrPullPrincipalIds array = []
39+
40+
@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.')
41+
param buildPrincipalId string = ''
42+
43+
@description('Optional. Principal type for the build principal.')
44+
@allowed([
45+
'Device'
46+
'ForeignGroup'
47+
'Group'
48+
'ServicePrincipal'
49+
'User'
50+
])
51+
param buildPrincipalType string = 'User'
52+
53+
// AcrPull role definition ID (allows pulling images).
54+
var acrPullRoleDefinitionId = '7f951dda-4ed3-4680-a7ca-43fe172d538d'
55+
// Contributor role definition ID (allows remote build / scheduleRun + push).
56+
var contributorRoleDefinitionId = 'b24988ac-6180-42a0-ab88-20f7382dd24c'
57+
58+
resource registry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = {
59+
name: name
60+
location: location
61+
tags: tags
62+
sku: {
63+
name: sku
64+
}
65+
properties: {
66+
// Identity-based authentication only - no admin credentials.
67+
adminUserEnabled: false
68+
// Explicitly disable anonymous pull; access requires an authenticated identity.
69+
anonymousPullEnabled: false
70+
publicNetworkAccess: publicNetworkAccess
71+
networkRuleBypassOptions: networkRuleBypassOptions
72+
}
73+
}
74+
75+
resource acrPullRoleAssignments 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
76+
for principalId in acrPullPrincipalIds: {
77+
name: guid(registry.id, principalId, acrPullRoleDefinitionId)
78+
scope: registry
79+
properties: {
80+
roleDefinitionId: subscriptionResourceId(
81+
'Microsoft.Authorization/roleDefinitions',
82+
acrPullRoleDefinitionId
83+
)
84+
principalId: principalId
85+
principalType: 'ServicePrincipal'
86+
}
87+
}
88+
]
89+
90+
resource acrBuildRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(buildPrincipalId)) {
91+
name: guid(registry.id, buildPrincipalId, contributorRoleDefinitionId)
92+
scope: registry
93+
properties: {
94+
roleDefinitionId: subscriptionResourceId(
95+
'Microsoft.Authorization/roleDefinitions',
96+
contributorRoleDefinitionId
97+
)
98+
principalId: buildPrincipalId
99+
principalType: buildPrincipalType
100+
}
101+
}
102+
103+
@description('The resource ID of the container registry.')
104+
output resourceId string = registry.id
105+
106+
@description('The name of the container registry.')
107+
output name string = registry.name
108+
109+
@description('The login server (endpoint) of the container registry, e.g. myregistry.azurecr.io.')
110+
output loginServer string = registry.properties.loginServer
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<#
2+
.SYNOPSIS
3+
Separate post-deployment script (Windows / PowerShell) that runs FIRST,
4+
before any existing post-deployment scripts.
5+
6+
.DESCRIPTION
7+
Builds the deployment-specific container images using Azure Container
8+
Registry *remote* builds (az acr build - no local Docker required) and
9+
pushes them to the dedicated, per-deployment ACR. It then updates the
10+
Container Apps to use the freshly pushed images.
11+
12+
This does NOT depend on a shared/public registry or anonymous pull. Image
13+
pulls at runtime use identity-based authentication (the app's managed
14+
identity has the AcrPull role on the dedicated ACR).
15+
#>
16+
17+
$ErrorActionPreference = 'Stop'
18+
19+
Write-Host "==> [deploy_container_images] Building and pushing images to the dedicated ACR (remote build)"
20+
21+
# ---------------------------------------------------------------------------
22+
# Resolve required values. azd exports deployment outputs as environment
23+
# variables inside hooks; fall back to `azd env get-values` if needed.
24+
# ---------------------------------------------------------------------------
25+
$AcrName = $env:AZURE_CONTAINER_REGISTRY_NAME
26+
$RegistryEndpoint = $env:AZURE_CONTAINER_REGISTRY_ENDPOINT
27+
$ResourceGroup = $env:AZURE_RESOURCE_GROUP
28+
$ImageTag = if ($env:AZURE_ENV_IMAGE_TAG) { $env:AZURE_ENV_IMAGE_TAG } else { 'latest_v2' }
29+
$BackendApp = $env:CONTAINER_API_APP_NAME
30+
$FrontendApp = $env:CONTAINER_WEB_APP_NAME
31+
$ProcessorApp = $env:CONTAINER_PROCESSOR_APP_NAME
32+
33+
if ([string]::IsNullOrEmpty($AcrName) -or [string]::IsNullOrEmpty($ResourceGroup)) {
34+
if (Get-Command azd -ErrorAction SilentlyContinue) {
35+
Write-Host "==> Loading missing values from 'azd env get-values'"
36+
foreach ($line in (azd env get-values)) {
37+
if ($line -match '^(?<k>[A-Za-z0-9_]+)="?(?<v>.*?)"?$') {
38+
$k = $Matches['k']; $v = $Matches['v']
39+
switch ($k) {
40+
'AZURE_CONTAINER_REGISTRY_NAME' { if (-not $AcrName) { $AcrName = $v } }
41+
'AZURE_CONTAINER_REGISTRY_ENDPOINT' { if (-not $RegistryEndpoint) { $RegistryEndpoint = $v } }
42+
'AZURE_RESOURCE_GROUP' { if (-not $ResourceGroup) { $ResourceGroup = $v } }
43+
'AZURE_ENV_IMAGE_TAG' { if (-not $env:AZURE_ENV_IMAGE_TAG) { $ImageTag = $v } }
44+
'CONTAINER_API_APP_NAME' { if (-not $BackendApp) { $BackendApp = $v } }
45+
'CONTAINER_WEB_APP_NAME' { if (-not $FrontendApp) { $FrontendApp = $v } }
46+
'CONTAINER_PROCESSOR_APP_NAME' { if (-not $ProcessorApp) { $ProcessorApp = $v } }
47+
}
48+
}
49+
}
50+
}
51+
}
52+
53+
# Derive the login server from the registry name if it was not provided.
54+
if ([string]::IsNullOrEmpty($RegistryEndpoint)) {
55+
$RegistryEndpoint = "$AcrName.azurecr.io"
56+
}
57+
58+
$missing = @()
59+
if ([string]::IsNullOrEmpty($AcrName)) { $missing += 'AZURE_CONTAINER_REGISTRY_NAME' }
60+
if ([string]::IsNullOrEmpty($ResourceGroup)) { $missing += 'AZURE_RESOURCE_GROUP' }
61+
if ($missing.Count -gt 0) {
62+
Write-Error "Missing required deployment values: $($missing -join ', '). Ensure infrastructure has been provisioned (azd provision) first."
63+
exit 1
64+
}
65+
66+
# Ensure the Azure CLI has a valid, non-expired login. `az acr build` and
67+
# `az containerapp update` authenticate via the az CLI (separate from azd), so a
68+
# stale/expired token here would otherwise fail part-way through the build.
69+
az account show --output none 2>$null
70+
if ($LASTEXITCODE -ne 0) {
71+
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."
72+
exit 1
73+
}
74+
75+
# Resolve the repository root (this script lives in <root>/scripts).
76+
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
77+
$RootDir = Split-Path -Parent $ScriptDir
78+
79+
Write-Host " Registry : $RegistryEndpoint ($AcrName)"
80+
Write-Host " Resource group : $ResourceGroup"
81+
Write-Host " Image tag : $ImageTag"
82+
83+
function Build-Image {
84+
param([string]$ImageName, [string]$ContextDir)
85+
Write-Host "==> Remote build (az acr build): ${ImageName}:$ImageTag"
86+
az acr build `
87+
--registry $AcrName `
88+
--image "${ImageName}:$ImageTag" `
89+
--file (Join-Path $ContextDir 'Dockerfile') `
90+
$ContextDir
91+
if ($LASTEXITCODE -ne 0) { throw "az acr build failed for $ImageName" }
92+
}
93+
94+
function Update-App {
95+
param([string]$AppName, [string]$ImageName)
96+
if ([string]::IsNullOrEmpty($AppName)) {
97+
Write-Host "WARN: Container app name for '$ImageName' not set; skipping image update."
98+
return
99+
}
100+
Write-Host "==> Updating container app '$AppName' -> $RegistryEndpoint/${ImageName}:$ImageTag"
101+
az containerapp update `
102+
--name $AppName `
103+
--resource-group $ResourceGroup `
104+
--image "$RegistryEndpoint/${ImageName}:$ImageTag" `
105+
--output none
106+
if ($LASTEXITCODE -ne 0) { throw "az containerapp update failed for $AppName" }
107+
}
108+
109+
# Build & push all images to the dedicated ACR.
110+
Build-Image -ImageName 'backend-api' -ContextDir (Join-Path $RootDir 'src/backend-api')
111+
Build-Image -ImageName 'processor' -ContextDir (Join-Path $RootDir 'src/processor')
112+
Build-Image -ImageName 'frontend' -ContextDir (Join-Path $RootDir 'src/frontend')
113+
114+
# Point the Container Apps at the freshly built images.
115+
Update-App -AppName $BackendApp -ImageName 'backend-api'
116+
Update-App -AppName $ProcessorApp -ImageName 'processor'
117+
Update-App -AppName $FrontendApp -ImageName 'frontend'
118+
119+
Write-Host "==> [deploy_container_images] Completed successfully."

0 commit comments

Comments
 (0)