Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions infra/core/ai/ai-project.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ output aiServicesAccountName string = aiAccount.name
output aiServicesProjectName string = aiAccount::project.name
output aiServicesPrincipalId string = aiAccount.identity.principalId
output projectName string = aiAccount::project.name
output projectPrincipalId string = aiAccount::project.identity.principalId
output APPLICATIONINSIGHTS_CONNECTION_STRING string = applicationInsights.outputs.connectionString

// Grouped dependent resources outputs
Expand Down
114 changes: 114 additions & 0 deletions infra/core/ai/cobo-agent.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
param location string = resourceGroup().location
param tags object = {}

param containerRegistryName string
param serviceName string = 'CalculatorAgentLG'
param openaiEndpoint string
param openaiApiVersion string

@description('AI Foundry Account resource name for OpenAI access')
param aiServicesAccountName string

@description('AI Foundry Project name within the account')
param aiProjectName string

@description('Principal ID for authentication')
param authAppId string

var resourceToken = uniqueString(subscription().id, resourceGroup().id, location)
var prefix = 'ca-${aiProjectName}-${resourceToken}'
var containerAppsEnvironmentName = '${prefix}-env'
var containerAppName = replace(take(prefix, 32), '--', '-')
var userAssignedIdentityName = '${prefix}-id'

// Container Apps Environment for COBO agent
module containerAppsEnvironment '../host/container-apps-environment.bicep' = {
scope: resourceGroup()
name: 'container-apps-environment'
params: {
name: containerAppsEnvironmentName
location: location
tags: tags
}
}

// Get reference to the existing AI project to access its identity
resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = {
name: aiServicesAccountName

resource project 'projects' existing = {
name: aiProjectName
}
}

// Using user-assigned managed identity instead of system-assigned to avoid
// the 60+ second delay required for ACR role assignment propagation.
// With user-assigned identity, we can create the identity and grant ACR access
// before creating the Container App, eliminating the delay during deployment.
resource apiIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: userAssignedIdentityName
location: location
}

module app '../host/container-app.bicep' = {
name: '${serviceName}-container-app-module'
dependsOn: [containerAppsEnvironment]
params: {
name: containerAppName
location: location
tags: union(tags, { 'azd-service-name': serviceName })
identityName: apiIdentity.name
containerAppsEnvironmentName: containerAppsEnvironmentName
containerRegistryName: containerRegistryName
targetPort: 8088
imageName: '' // Empty during provision, azd deploy will update with actual image
authEnabled: true
authAppId: authAppId
authIssuerUrl: ''
authAllowedAudiences: []
authRequireClientApp: false
secrets: []
env: [
{
name: 'AZURE_OPENAI_ENDPOINT'
value: openaiEndpoint
}
{
name: 'OPENAI_API_VERSION'
value: openaiApiVersion
}
{
name: 'AZURE_CLIENT_ID'
value: apiIdentity.properties.clientId
}
]
}
}

// Grant Container Apps Contributor role to AI Foundry Project's system-assigned identity on the Container App
// Role definition ID for "Container Apps Contributor" is: 358470bc-b998-42bd-ab17-a7e34c199c0f
module roleAssignment '../security/container-app-role.bicep' = {
name: '${serviceName}-role-assignment'
params: {
containerAppName: app.outputs.name
principalId: aiAccount::project.identity.principalId
roleDefinitionId: '358470bc-b998-42bd-ab17-a7e34c199c0f'
principalType: 'ServicePrincipal'
}
}

// Grant Azure AI User role to Container App's user-assigned managed identity on AI Foundry Account
// Role ID: 53ca6127-db72-4b80-b1b0-d745d6d5456d (Azure AI User)
resource aiUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(aiAccount.id, apiIdentity.id, '53ca6127-db72-4b80-b1b0-d745d6d5456d')
scope: aiAccount
properties: {
principalId: apiIdentity.properties.principalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d')
}
}


output COBO_ACA_IDENTITY_PRINCIPAL_ID string = apiIdentity.properties.principalId
output SERVICE_API_RESOURCE_ID string = app.outputs.resourceId
173 changes: 173 additions & 0 deletions infra/core/host/container-app.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
param name string
param location string = resourceGroup().location
param tags object = {}

param containerAppsEnvironmentName string
param containerName string = 'main'
param containerRegistryName string

@description('Minimum number of replicas to run')
@minValue(1)
param containerMinReplicas int = 1
@description('Maximum number of replicas to run')
@minValue(1)
param containerMaxReplicas int = 10

param secrets array = []
param env array = []
param external bool = true
param imageName string
param targetPort int = 80

@description('User assigned identity name')
param identityName string

@description('Enabled Ingress for container app')
param ingressEnabled bool = true

// Dapr Options
@description('Enable Dapr')
param daprEnabled bool = false
@description('Dapr app ID')
param daprAppId string = containerName
@allowed([ 'http', 'grpc' ])
@description('Protocol used by Dapr to connect to the app, e.g. http or grpc')
param daprAppProtocol string = 'http'

@description('CPU cores allocated to a single container instance, e.g. 0.5')
param containerCpuCoreCount string = '0.5'

@description('Memory allocated to a single container instance, e.g. 1Gi')
param containerMemory string = '1.0Gi'

@description('Enable authentication')
param authEnabled bool = false

@description('Authentication App ID (client ID)')
param authAppId string = ''

@description('Authentication Issuer URL')
param authIssuerUrl string = ''

@description('Allowed token audiences')
param authAllowedAudiences array = []

@description('Require client application (true) or allow requests only from this application itself (false)')
param authRequireClientApp bool = false

resource userIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = {
name: identityName
}

module containerRegistryAccess '../security/registry-access.bicep' = {
name: '${deployment().name}-registry-access'
params: {
containerRegistryName: containerRegistryName
principalId: userIdentity.properties.principalId
}
}

resource app 'Microsoft.App/containerApps@2024-03-01' = {
name: name
location: location
tags: tags
// It is critical that the identity is granted ACR pull access before the app is created
// otherwise the container app will throw a provision error
// This also forces us to use an user assigned managed identity since there would no way to
// provide the system assigned identity with the ACR pull access before the app is created
dependsOn: [ containerRegistryAccess ]
identity: {
type: 'UserAssigned'
userAssignedIdentities: { '${userIdentity.id}': {} }
}
properties: {
managedEnvironmentId: containerAppsEnvironment.id
configuration: {
activeRevisionsMode: 'multiple'
ingress: ingressEnabled ? {
external: external
targetPort: targetPort
transport: 'auto'
} : null
dapr: daprEnabled ? {
enabled: true
appId: daprAppId
appProtocol: daprAppProtocol
appPort: ingressEnabled ? targetPort : 0
} : { enabled: false }
secrets: secrets
registries: [
{
server: '${containerRegistry.name}.azurecr.io'
identity: userIdentity.id
}
]
}
template: {
containers: [
{
image: !empty(imageName) ? imageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
name: containerName
env: env
resources: {
cpu: json(containerCpuCoreCount)
memory: containerMemory
}
}
]
scale: {
minReplicas: containerMinReplicas
maxReplicas: containerMaxReplicas
}
}
}
}

resource appAuthConfig 'Microsoft.App/containerApps/authConfigs@2024-03-01' = if (authEnabled) {
name: 'current'
parent: app
properties: {
platform: {
enabled: true
}
globalValidation: {
unauthenticatedClientAction: 'Return401'
}
login: {
tokenStore: {
enabled: false
}
allowedExternalRedirectUrls: []
}
identityProviders: {
azureActiveDirectory: {
enabled: true
registration: {
clientId: authAppId
openIdIssuer: authIssuerUrl
}
validation: {
allowedAudiences: authAllowedAudiences
defaultAuthorizationPolicy: {
allowedApplications: authRequireClientApp ? [] : [authAppId]
}
}
}
}
}
}

resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' existing = {
name: containerAppsEnvironmentName
}

// 2022-02-01-preview needed for anonymousPullEnabled
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' existing = {
name: containerRegistryName
}

output defaultDomain string = containerAppsEnvironment.properties.defaultDomain
output imageName string = imageName
output name string = app.name
output uri string = 'https://${app.properties.configuration.ingress.fqdn}'
output resourceId string = app.id
13 changes: 13 additions & 0 deletions infra/core/host/container-apps-environment.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
param name string
param location string = resourceGroup().location
param tags object = {}

resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = {
name: name
location: location
tags: tags
properties: {}
}

output defaultDomain string = containerAppsEnvironment.properties.defaultDomain
output name string = containerAppsEnvironment.name
26 changes: 26 additions & 0 deletions infra/core/security/container-app-role.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
param containerAppName string
param principalId string

@allowed([
'Device'
'ForeignGroup'
'Group'
'ServicePrincipal'
'User'
])
param principalType string = 'ServicePrincipal'
param roleDefinitionId string

resource containerApp 'Microsoft.App/containerApps@2024-03-01' existing = {
name: containerAppName
}

resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: containerApp
name: guid(containerApp.id, principalId, roleDefinitionId)
properties: {
principalId: principalId
principalType: principalType
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId)
}
}
18 changes: 18 additions & 0 deletions infra/core/security/registry-access.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
param containerRegistryName string
param principalId string

var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')

resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: containerRegistry // Use when specifying a scope that is different than the deployment scope
name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole)
properties: {
roleDefinitionId: acrPullRole
principalType: 'ServicePrincipal'
principalId: principalId
}
}

resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' existing = {
name: containerRegistryName
}
Loading