diff --git a/infra/core/ai/ai-project.bicep b/infra/core/ai/ai-project.bicep index d0e8753..8cfd8a1 100644 --- a/infra/core/ai/ai-project.bicep +++ b/infra/core/ai/ai-project.bicep @@ -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 diff --git a/infra/core/ai/cobo-agent.bicep b/infra/core/ai/cobo-agent.bicep new file mode 100644 index 0000000..bac7ad0 --- /dev/null +++ b/infra/core/ai/cobo-agent.bicep @@ -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 diff --git a/infra/core/host/container-app.bicep b/infra/core/host/container-app.bicep new file mode 100644 index 0000000..d0ff69d --- /dev/null +++ b/infra/core/host/container-app.bicep @@ -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 diff --git a/infra/core/host/container-apps-environment.bicep b/infra/core/host/container-apps-environment.bicep new file mode 100644 index 0000000..698543f --- /dev/null +++ b/infra/core/host/container-apps-environment.bicep @@ -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 diff --git a/infra/core/security/container-app-role.bicep b/infra/core/security/container-app-role.bicep new file mode 100644 index 0000000..c73ddd5 --- /dev/null +++ b/infra/core/security/container-app-role.bicep @@ -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) + } +} diff --git a/infra/core/security/registry-access.bicep b/infra/core/security/registry-access.bicep new file mode 100644 index 0000000..e17e404 --- /dev/null +++ b/infra/core/security/registry-access.bicep @@ -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 +} diff --git a/infra/main.bicep b/infra/main.bicep index 3675047..3416819 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -81,6 +81,9 @@ var aiProjectDependentResources = json(aiProjectDependentResourcesJson) @description('Enable hosted agent deployment') param enableHostedAgents bool +@description('Enable container-based agent deployment') +param enableContainerAgents bool + @description('Enable monitoring for the AI project') param enableMonitoring bool = true @@ -103,7 +106,7 @@ resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { // Build dependent resources array conditionally // Check if ACR already exists in the user-provided array to avoid duplicates var hasAcr = contains(map(aiProjectDependentResources, r => r.resource), 'registry') -var dependentResources = (enableHostedAgents) && !hasAcr ? union(aiProjectDependentResources, [ +var dependentResources = (enableHostedAgents || enableContainerAgents) && !hasAcr ? union(aiProjectDependentResources, [ { resource: 'registry' connectionName: 'acr-connection' @@ -129,6 +132,22 @@ module aiProject 'core/ai/ai-project.bicep' = { } } +// Container Agent module +module coboAgent 'core/ai/cobo-agent.bicep' = if (enableContainerAgents) { + scope: rg + name: 'cobo-agent' + params: { + location: location + tags: tags + containerRegistryName: aiProject.outputs.dependentResources.registry.name + openaiEndpoint: aiProject.outputs.aiServicesEndpoint + openaiApiVersion: '2025-03-01-preview' + aiServicesAccountName: aiProject.outputs.aiServicesAccountName + aiProjectName: aiFoundryProjectName + authAppId: aiProject.outputs.projectPrincipalId + } +} + // Resources output AZURE_RESOURCE_GROUP string = resourceGroupName output AZURE_AI_ACCOUNT_ID string = aiProject.outputs.accountId @@ -136,6 +155,7 @@ output AZURE_AI_PROJECT_ID string = aiProject.outputs.projectId output AZURE_AI_FOUNDRY_PROJECT_ID string = aiProject.outputs.projectId output AZURE_AI_ACCOUNT_NAME string = aiProject.outputs.aiServicesAccountName output AZURE_AI_PROJECT_NAME string = aiProject.outputs.projectName +output AZURE_AI_PROJECT_PRINCIPAL_ID string = aiProject.outputs.projectPrincipalId // Endpoints output AZURE_AI_PROJECT_ENDPOINT string = aiProject.outputs.AZURE_AI_PROJECT_ENDPOINT @@ -166,3 +186,7 @@ output AZURE_AI_SEARCH_SERVICE_NAME string = aiProject.outputs.dependentResource output AZURE_STORAGE_CONNECTION_NAME string = aiProject.outputs.dependentResources.storage.connectionName output AZURE_STORAGE_ACCOUNT_NAME string = aiProject.outputs.dependentResources.storage.accountName +// COBO Agent outputs (only available if enableContainerAgents is true, used in post-deployment steps) +output COBO_ACA_IDENTITY_PRINCIPAL_ID string = enableContainerAgents ? coboAgent!.outputs.COBO_ACA_IDENTITY_PRINCIPAL_ID : '' +output SERVICE_API_RESOURCE_ID string = enableContainerAgents ? coboAgent!.outputs.SERVICE_API_RESOURCE_ID : '' + diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 323829e..b2d448a 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -40,6 +40,9 @@ }, "enableHostedAgents": { "value": "${ENABLE_HOSTED_AGENTS=false}" + }, + "enableContainerAgents": { + "value": "${ENABLE_CONTAINER_AGENTS=false}" } } }