diff --git a/azure_custom.yaml b/azure_custom.yaml new file mode 100644 index 0000000..8ac9309 --- /dev/null +++ b/azure_custom.yaml @@ -0,0 +1,108 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +# This file contains a developer‑focused Azure Developer CLI configuration. It +# extends the default template by defining three services (backend API, +# processor and frontend) and instructs azd to build container images from +# your local source code. All three services are packaged as container apps +# using the Dockerfiles located in their respective project directories. +# After deployment a post‑deploy hook prints the endpoints of the deployed +# container apps. + +name: container-migration-solution-accelerator +metadata: + template: container-migration-solution-accelerator@1.0 + +requiredVersions: + # Require a recent version of azd that supports the packaging + # functionality used here. Versions less than 1.17.1 had a bug in + # remoteBuild. + azd: ">=1.18.2" + +infra: + parameters: + backendImageName: ${SERVICE_BACKEND_IMAGE_NAME} + processorImageName: ${SERVICE_PROCESSOR_IMAGE_NAME} + frontendImageName: ${SERVICE_FRONTEND_IMAGE_NAME} + +services: + # Backend API service. This is a Python FastAPI application defined in + # src/backend-api. The azd packaging stage builds a Docker image using + # the Dockerfile in that directory. The image name 'backend-api' is + # combined with the automatically created Azure Container Registry login + # server to form the final image reference. + backend: + project: ./src/backend-api + language: py + host: containerapp + docker: + image: backend-api + remoteBuild: true + + # Processor service. This service reads messages from storage queues and + # orchestrates long‑running migrations. It is also packaged as a + # container and deployed to a container app environment. The Dockerfile + # in the src/processor directory defines how the image is built. + processor: + project: ./src/processor + language: py + host: containerapp + docker: + image: processor + remoteBuild: true + + # Frontend service. The frontend consists of a React single‑page + # application and a lightweight Python server that serves the compiled + # assets. azd packages the frontend by building a Docker image using + # the Dockerfile in the src/frontend directory and deploys it to a + # container app. + frontend: + project: ./src/frontend + language: py + host: containerapp + docker: + image: frontend + remoteBuild: true + +hooks: + # After deployment prints the names and endpoints of the deployed + # container apps. This reproduces the behaviour of the default + # configuration so that developers can easily discover their services. + postdeploy: + posix: + shell: sh + run: | + echo "-----" + echo "🧭 Frontend Container App Details:" + echo "✅ Name: $CONTAINER_FRONTEND_APP_NAME" + echo "🌐 Endpoint: https://$CONTAINER_FRONTEND_APP_FQDN" + echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_FRONTEND_APP_NAME" + echo "-----" + echo "🧭 Backend API Container App Details:" + echo "✅ Name: $CONTAINER_API_APP_NAME" + echo "🌐 Endpoint: https://$CONTAINER_API_APP_FQDN" + echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_API_APP_NAME" + echo "-----" + echo "🧭 Processor Container App Details:" + echo "✅ Name: $SERVICE_PROCESSOR_NAME" + echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$SERVICE_PROCESSOR_NAME" + echo "-----" + interactive: true + windows: + shell: pwsh + run: | + Write-Host "-----" + Write-Host "🧭 Frontend Container App Details:" + Write-Host "✅ Name: $env:CONTAINER_FRONTEND_APP_NAME" + Write-Host "🌐 Endpoint: https://$env:CONTAINER_FRONTEND_APP_FQDN" + Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:CONTAINER_FRONTEND_APP_NAME" -ForegroundColor Cyan + Write-Host "-----" + Write-Host "🧭 Backend API Container App Details:" + Write-Host "✅ Name: $env:CONTAINER_API_APP_NAME" + Write-Host "🌐 Endpoint: https://$env:CONTAINER_API_APP_FQDN" + Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:CONTAINER_API_APP_NAME" -ForegroundColor Cyan + Write-Host "-----" + Write-Host "🧭 Processor Container App Details:" + Write-Host "✅ Name: $env:SERVICE_PROCESSOR_NAME" + Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:SERVICE_PROCESSOR_NAME" -ForegroundColor Cyan + Write-Host "-----" + interactive: true diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 32038c0..39d8239 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -240,4 +240,11 @@ If you encounter any issues during the deployment process, please refer [trouble ## Running the application -To help you get started, here's the [Sample Workflow](./SampleWorkflow.md) you can follow to try it out. +To help you get started, here's the [Sample Workflow](../docs/SampleWorkflow.md) you can follow to try it out. + +### Deploy Your local changes +To Deploy your local changes rename the below files. + +Rename `azure.yaml` to `azure_custom2.yaml` and `azure_custom.yaml` to `azure.yaml`. +Go to `infra` directory +Rename `main.bicep` to `main_custom2.bicep` and `main_custom.bicep` to `main.bicep`. Continue with the [deploying steps](#deploying-with-azd). diff --git a/infra/main.bicep b/infra/main.bicep index 067485c..3c34d57 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -53,6 +53,7 @@ param azureAiServiceLocation string 'westus3' ]) @description('Required. Azure region for AI model deployment. Should match azureAiServiceLocation for optimal performance.') +#disable-next-line no-unused-params param aiDeploymentLocation string = azureAiServiceLocation @description('Optional. The host (excluding https://) of an existing container registry. This is the `loginServer` when using Azure Container Registry.') @@ -103,6 +104,11 @@ param createdBy string = contains(deployer(), 'userPrincipalName') ? split(deployer().userPrincipalName, '@')[0] : deployer().objectId +// Get the current deployer's information for local debugging permissions +var deployerInfo = deployer() +var deployingUserPrincipalId = deployerInfo.objectId +var deployingUserType = !empty(deployerInfo.userPrincipalName) ? 'User' : 'ServicePrincipal' + @description('Optional. Resource ID of an existing Foundry project') param existingFoundryProjectResourceId string = '' @@ -469,6 +475,17 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' } + // Add deployer permissions + { + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + principalId: deployingUserPrincipalId + principalType: deployingUserType + } + { + roleDefinitionIdOrName: 'Storage Queue Data Contributor' + principalId: deployingUserPrincipalId + principalType: deployingUserType + } ] // WAF aligned networking networkAcls: { @@ -655,6 +672,12 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'DocumentDB Account Contributor' } + // Add deployer for local debugging + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'DocumentDB Account Contributor' + } ] // Create custom data plane role definition and assignment dataPlaneRoleDefinitions: [ @@ -669,6 +692,8 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { ] assignments: [ { principalId: appIdentity.outputs.principalId } + // ADD THIS for local debugging support: + { principalId: deployingUserPrincipalId } ] } ] @@ -699,7 +724,7 @@ module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.b name: take('module.ai-services-model-deployments.${existingAiFoundryAiServices.name}', 64) scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) params: { - name: existingAiFoundryAiServices.name + name: aiFoundryAiServicesResourceName // Fix: use variable instead of resource reference deployments: [ { name: aiModelDeploymentName @@ -715,6 +740,7 @@ module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.b } ] roleAssignments: [ + // Service Principal permissions { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' @@ -723,12 +749,23 @@ module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.b { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' - roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' } { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' - roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' + } + // Deployer permissions + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'Cognitive Services User' } ] } @@ -746,6 +783,7 @@ module aiFoundry 'br/public:avm/ptn/ai-ml/ai-foundry:0.4.0' = if(!useExistingAiF accountName:aiFoundryAiServicesResourceName allowProjectManagement: true roleAssignments: [ + // Service Principal permissions { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' @@ -761,6 +799,17 @@ module aiFoundry 'br/public:avm/ptn/ai-ml/ai-foundry:0.4.0' = if(!useExistingAiF principalType: 'ServicePrincipal' roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User } + // Deployer permissions for local debugging + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'Cognitive Services User' + } ] // Remove networking configuration to avoid AML workspace creation issues networking: enablePrivateNetworking? { @@ -791,7 +840,7 @@ module aiFoundry 'br/public:avm/ptn/ai-ml/ai-foundry:0.4.0' = if(!useExistingAiF } } -var aiServicesName = useExistingAiFoundryAiProject ? existingAiFoundryAiServices.name : aiFoundry.outputs.aiServicesName +var aiServicesName = useExistingAiFoundryAiProject ? existingAiFoundryAiServices.name : aiFoundryAiServicesResourceName module appConfiguration 'br/public:avm/res/app-configuration/configuration-store:0.9.1' = { name: take('avm.res.app-config.store.${solutionSuffix}', 64) params: { @@ -898,6 +947,8 @@ module appConfiguration 'br/public:avm/res/app-configuration/configuration-store sku: 'Standard' publicNetworkAccess: 'Enabled' } + // Add explicit dependency + dependsOn: useExistingAiFoundryAiProject ? [] : [aiFoundry] } module avmAppConfigUpdated 'br/public:avm/res/app-configuration/configuration-store:0.6.3' = if (enablePrivateNetworking) { @@ -971,7 +1022,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. platformReservedDnsIP: '172.17.17.17' zoneRedundant: (enablePrivateNetworking) ? true : false // Enable zone redundancy if private networking is enabled infrastructureSubnetResourceId: (enablePrivateNetworking) - ? virtualNetwork.outputs.containersSubnetResourceId // Use the container app subnet + ? virtualNetwork!.outputs.containersSubnetResourceId // Use the container app subnet : null // Use the container app subnet } } @@ -1210,3 +1261,7 @@ output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId @description('The Azure resource group name.') output AZURE_RESOURCE_GROUP string = resourceGroup().name + +// Log deployer information for debugging +output deployerObjectId string = deployingUserPrincipalId +output deployerType string = deployingUserType diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep new file mode 100644 index 0000000..7baa89e --- /dev/null +++ b/infra/main_custom.bicep @@ -0,0 +1,1296 @@ +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(16) +@description('Required. A unique application/solution name for all resources in this deployment. This should be 3-16 characters long.') +param solutionName string + +@maxLength(5) +@description('Optional. A unique text/token for the solution. This is used to ensure resource names are unique for global resources. Defaults to a 5-character substring of the unique string generated from the subscription ID, resource group name, and solution name.') +param solutionUniqueText string = substring(uniqueString(subscription().id, resourceGroup().name, solutionName), 0, 5) + +@minLength(3) +@metadata({ azd: { type: 'location' } }) +@description('Required. Azure region for container apps, storage, and other services. Choose a region close to your users.') +param location string +var solutionLocation = empty(location) ? resourceGroup().location : location + +@allowed([ + 'australiaeast' + 'eastus' + 'eastus2' + 'francecentral' + 'japaneast' + 'norwayeast' + 'southindia' + 'swedencentral' + 'uksouth' + 'westus' + 'westus3' +]) +@metadata({ + azd: { + type: 'location' + usageName: [ + 'OpenAI.GlobalStandard.o3, 500' + ] + } +}) +@description('Required. Azure region for AI services (OpenAI/AI Foundry). Must be a region that supports o3 model deployment.') +param azureAiServiceLocation string + + + + + +@secure() +@description('The full image name (including tag) for the backend API container, generated by azd.') +param backendImageName string = '' + +@secure() +@description('The full image name (including tag) for the processor container, generated by azd.') +param processorImageName string = '' + +@secure() +@description('The full image name (including tag) for the frontend container, generated by azd.') +param frontendImageName string = '' + +@minLength(1) +@allowed(['Standard', 'GlobalStandard']) +@description('Optional. Model deployment type. Defaults to GlobalStandard.') +param aiDeploymentType string = 'GlobalStandard' + +@minLength(1) +@description('Optional. Name of the AI model to deploy. Recommend using o3. Defaults to o3.') +param aiModelName string = 'o3' + +@minLength(1) +@description('Optional. Version of AI model. Review available version numbers per model before setting. Defaults to 2025-04-16.') +param aiModelVersion string = '2025-04-16' + +@description('Optional. AI model deployment token capacity. Lower this if initial provisioning fails due to capacity. Defaults to 50K tokens per minute to improve regional success rate.') +param aiModelCapacity int = 1 + +@description('Optional. The tags to apply to all deployed Azure resources.') +param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} + +@description('Optional. Enable redundancy for applicable resources. Defaults to false.') +param enableRedundancy bool = false + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +@description('Optional. Enable private networking for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') +param enablePrivateNetworking bool = false + +@description('Optional. Enable monitoring applicable resources, aligned with the Well Architected Framework recommendations. This setting enables Application Insights and Log Analytics and configures all the resources applicable resources to send logs. Defaults to false.') +param enableMonitoring bool = false + +@description('Optional. Enable scalability for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') +param enableScalability bool = false + +@description('Optional. CosmosDB Location') +param cosmosLocation string = 'eastus2' + +@description('Optional. Existing Log Analytics Workspace Resource ID') +param existingLogAnalyticsWorkspaceId string = '' + +@description('Tag, Created by user name') +param createdBy string = contains(deployer(), 'userPrincipalName') + ? split(deployer().userPrincipalName, '@')[0] + : deployer().objectId + +// Get the current deployer's information +var deployerInfo = deployer() +var deployingUserPrincipalId = deployerInfo.objectId + +@description('Optional. Resource ID of an existing Foundry project') +param existingFoundryProjectResourceId string = '' + +@description('Optional. Admin username for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') +@secure() +//param vmAdminUsername string = take(newGuid(), 20) +param vmAdminUsername string? + +@description('Optional. Admin password for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') +@secure() +//param vmAdminPassword string = newGuid() +param vmAdminPassword string? + +@description('Optional. Size of the Jumpbox Virtual Machine when created. Set to custom value if enablePrivateNetworking is true.') +param vmSize string? + +// Extracts subscription, resource group, and workspace name from the resource ID when using an existing Log Analytics workspace +var useExistingLogAnalytics = !empty(existingLogAnalyticsWorkspaceId) +var existingLawSubscription = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[2] : '' +var existingLawResourceGroup = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[4] : '' +var existingLawName = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[8] : '' + +resource existingLogAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' existing = if (useExistingLogAnalytics) { + name: existingLawName + scope: resourceGroup(existingLawSubscription, existingLawResourceGroup) +} + +var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics + ? existingLogAnalyticsWorkspaceId + : logAnalyticsWorkspace!.outputs.resourceId + +var solutionSuffix = toLower(trim(replace( + replace( + replace(replace(replace(replace('${solutionName}${solutionUniqueText}', '-', ''), '_', ''), '.', ''), '/', ''), + ' ', + '' + ), + '*', + '' +))) + +var allTags = union( + { + 'azd-env-name': solutionName + TemplateName: 'Container Migration' + }, + tags +) + +resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { + name: 'default' + properties: { + tags: { + ...resourceGroup().tags + ...tags + TemplateName: 'Container Migration' + Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' + CreatedBy: createdBy + } + } +} + +// Replica regions list based on article in [Azure regions list](https://learn.microsoft.com/azure/reliability/regions-list) and [Enhance resilience by replicating your Log Analytics workspace across regions](https://learn.microsoft.com/azure/azure-monitor/logs/workspace-replication#supported-regions) for supported regions for Log Analytics Workspace. +var replicaRegionPairs = { + australiaeast: 'australiasoutheast' + centralus: 'westus' + eastasia: 'japaneast' + eastus: 'centralus' + eastus2: 'centralus' + japaneast: 'eastasia' + northeurope: 'westeurope' + southeastasia: 'eastasia' + uksouth: 'westeurope' + westeurope: 'northeurope' +} +var replicaLocation = replicaRegionPairs[resourceGroup().location] + +// ========== User Assigned Identity ========== // +// WAF best practices for identity and access management: https://learn.microsoft.com/en-us/azure/well-architected/security/identity-access +var userAssignedIdentityResourceName = 'id-${solutionSuffix}' +module appIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('avm.res.managed-identity.user-assigned-identity.${userAssignedIdentityResourceName}', 64) + params: { + name: userAssignedIdentityResourceName + location: solutionLocation + tags: allTags + enableTelemetry: enableTelemetry + } +} + +// ========== 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 +var logAnalyticsWorkspaceResourceName = 'log-${solutionSuffix}' +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.12.0' = if ((enableMonitoring || enablePrivateNetworking) && !useExistingLogAnalytics) { + name: take('avm.res.operational-insights.workspace.${logAnalyticsWorkspaceResourceName}', 64) + params: { + name: logAnalyticsWorkspaceResourceName + location: solutionLocation + skuName: 'PerGB2018' + dataRetention: 30 + diagnosticSettings: [{ useThisWorkspace: true }] + tags: allTags + enableTelemetry: enableTelemetry + features: { enableLogAccessUsingOnlyResourcePermissions: true } + // WAF aligned configuration for Redundancy + dailyQuotaGb: enableRedundancy ? 10 : null //WAF recommendation: 10 GB per day is a good starting point for most workloads + replication: enableRedundancy + ? { + enabled: true + location: replicaLocation + } + : null + // WAF aligned configuration for Private Networking + publicNetworkAccessForIngestion: enablePrivateNetworking ? 'Disabled' : 'Enabled' + publicNetworkAccessForQuery: enablePrivateNetworking ? 'Disabled' : 'Enabled' + dataSources: enablePrivateNetworking + ? [ + { + tags: allTags + eventLogName: 'Application' + eventTypes: [ + { + eventType: 'Error' + } + { + eventType: 'Warning' + } + { + eventType: 'Information' + } + ] + kind: 'WindowsEvent' + name: 'applicationEvent' + } + { + counterName: '% Processor Time' + instanceName: '*' + intervalSeconds: 60 + kind: 'WindowsPerformanceCounter' + name: 'windowsPerfCounter1' + objectName: 'Processor' + } + { + kind: 'IISLogs' + name: 'sampleIISLog1' + state: 'OnPremiseEnabled' + } + ] + : null + } +} + +// ========== Application Insights ========== // +// WAF best practices for Application Insights: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/application-insights +// WAF PSRules for Application Insights: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#application-insights +var applicationInsightsResourceName = 'appi-${solutionSuffix}' +module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (enableMonitoring) { + name: take('avm.res.insights.component.${applicationInsightsResourceName}', 64) + #disable-next-line no-unnecessary-dependson + //dependsOn: [logAnalyticsWorkspace] + params: { + name: applicationInsightsResourceName + location: solutionLocation + tags: allTags + enableTelemetry: enableTelemetry + retentionInDays: 365 + kind: 'web' + disableIpMasking: false + flowType: 'Bluefield' + // WAF aligned configuration for Monitoring + workspaceResourceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + } +} + +// ========== Virtual Network ========== // +module virtualNetwork './modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { + name: take('module.virtual-network.${solutionSuffix}', 64) + params: { + name: 'vnet-${solutionSuffix}' + addressPrefixes: ['10.0.0.0/20'] + location: location + tags: allTags + logAnalyticsWorkspaceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' + resourceSuffix: solutionSuffix + enableTelemetry: enableTelemetry + } +} + +// Azure Bastion Host +var bastionHostName = 'bas-${solutionSuffix}' // Bastion host name must be between 3 and 15 characters in length and use numbers and lower-case letters only. +module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (enablePrivateNetworking) { + name: take('avm.res.network.bastion-host.${bastionHostName}', 64) + params: { + name: bastionHostName + skuName: 'Standard' + location: location + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + diagnosticSettings: enableMonitoring + ? [ + { + name: 'bastionDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceResourceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + } + ] + : null + tags: allTags + enableTelemetry: enableTelemetry + publicIPAddressObject: { + name: 'pip-${bastionHostName}' + zones: [] + } + } +} +// Jumpbox Virtual Machine +var jumpboxVmName = take('vm-jumpbox-${solutionSuffix}', 15) +module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (enablePrivateNetworking) { + name: take('avm.res.compute.virtual-machine.${jumpboxVmName}', 64) + params: { + name: take(jumpboxVmName, 15) // Shorten VM name to 15 characters to avoid Azure limits + vmSize: vmSize ?? 'Standard_DS2_v2' + location: location + adminUsername: vmAdminUsername ?? 'JumpboxAdminUser' + adminPassword: vmAdminPassword ?? 'JumpboxAdminP@ssw0rd1234!' + tags: allTags + zone: 0 + imageReference: { + offer: 'WindowsServer' + publisher: 'MicrosoftWindowsServer' + sku: '2019-datacenter' + version: 'latest' + } + osType: 'Windows' + osDisk: { + name: 'osdisk-${jumpboxVmName}' + managedDisk: { + storageAccountType: 'Standard_LRS' + } + } + encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + nicConfigurations: [ + { + name: 'nic-${jumpboxVmName}' + ipConfigurations: [ + { + name: 'ipconfig1' + subnetResourceId: virtualNetwork!.outputs.jumpboxSubnetResourceId + } + ] + diagnosticSettings: enableMonitoring + ? [ + { + name: 'jumpboxDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceResourceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + : null + } + ] + enableTelemetry: enableTelemetry + } +} + +var processBlobContainerName = 'processes' +var processQueueName = 'processes-queue' + +// ========== Private DNS Zones ========== // +var privateDnsZones = [ + 'privatelink.cognitiveservices.azure.com' + 'privatelink.openai.azure.com' + 'privatelink.services.ai.azure.com' + 'privatelink.documents.azure.com' + 'privatelink.blob.${environment().suffixes.storage}' + 'privatelink.queue.${environment().suffixes.storage}' + 'privatelink.azconfig.io' +] + +// DNS Zone Index Constants +var dnsZoneIndex = { + cognitiveServices: 0 + openAI: 1 + aiServices: 2 + cosmosDB: 3 + storageBlob: 4 + storageQueue: 5 + appConfig: 6 +} + +// List of DNS zone indices that correspond to AI-related services. +var aiRelatedDnsZoneIndices = [ + dnsZoneIndex.cognitiveServices + dnsZoneIndex.openAI + dnsZoneIndex.aiServices +] + +// =================================================== +// DEPLOY PRIVATE DNS ZONES +// - Deploys all zones if no existing Foundry project is used +// - Excludes AI-related zones when using with an existing Foundry project +// =================================================== +@batchSize(5) +module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ + for (zone, i) in privateDnsZones: if (enablePrivateNetworking && (empty(existingFoundryProjectResourceId) || !contains( + aiRelatedDnsZoneIndices, + i + ))) { + name: 'dns-zone-${i}' + params: { + name: zone + tags: allTags + enableTelemetry: enableTelemetry + virtualNetworkLinks: [ + { + name: take('vnetlink-${virtualNetwork!.outputs.name}-${split(zone, '.')[1]}', 80) + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + } + ] + } + } +] + +// ========== AVM WAF ========== // +// ========== Storage account module ========== // +var storageAccountName = 'st${solutionSuffix}' // Storage account name must be between 3 and 24 characters in length and use numbers and lower-case letters only. +module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { + name: take('avm.res.storage.storage-account.${storageAccountName}', 64) + params: { + name: storageAccountName + location: solutionLocation + managedIdentities: { systemAssigned: true } + minimumTlsVersion: 'TLS1_2' + enableTelemetry: enableTelemetry + tags: allTags + accessTier: 'Hot' + supportsHttpsTrafficOnly: true + roleAssignments: [ + { + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: 'Storage Queue Data Contributor' + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + ] + // WAF aligned networking + networkAcls: { + bypass: 'AzureServices' + defaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' + } + allowBlobPublicAccess: enablePrivateNetworking ? true : false + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + // Private endpoints for blob and queue + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-storage-${storageAccountName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-blob' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageBlob]!.outputs.resourceId + } + ] + } + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + service: 'blob' + } + { + name: 'pep-queue-${solutionSuffix}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-queue' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageQueue]!.outputs.resourceId + } + ] + } + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + service: 'queue' + } + ] + : [] + blobServices: { + corsRules: [] + deleteRetentionPolicyEnabled: false + containers: [ + { + name: 'data' + publicAccess: 'None' + denyEncryptionScopeOverride: false + defaultEncryptionScope: '$account-encryption-key' + } + ] + } + queueServices: { + deleteRetentionPolicyEnabled: true + deleteRetentionPolicyDays: 7 + queues: [ + for queue in ([processQueueName, '${processQueueName}-dead-letter'] ?? []): { + name: queue + } + ] + } + } +} + +//========== AVM WAF ========== // +//========== Cosmos DB module ========== // +var cosmosDbResourceName = 'cosmos-${solutionSuffix}' +var cosmosDbZoneRedundantHaRegionPairs = { + australiaeast: 'uksouth' //'southeastasia' + centralus: 'eastus2' + eastasia: 'southeastasia' + eastus: 'centralus' + eastus2: 'centralus' + japaneast: 'australiaeast' + northeurope: 'westeurope' + southeastasia: 'eastasia' + uksouth: 'westeurope' + westeurope: 'northeurope' +} +var cosmosDbHaLocation = cosmosDbZoneRedundantHaRegionPairs[resourceGroup().location] + +var cosmosDatabaseName = 'migration_db' +var processCosmosContainerName = 'processes' +var agentTelemetryCosmosContainerName = 'agent_telemetry' +module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { + name: take('avm.res.document-db.database-account.${cosmosDbResourceName}', 64) + params: { + name: cosmosDbResourceName + location: cosmosLocation + tags: allTags + enableTelemetry: enableTelemetry + sqlDatabases: [ + { + name: cosmosDatabaseName + containers: [ + { + name: processCosmosContainerName + paths: [ + '/_partitionKey' + ] + } + { + name: agentTelemetryCosmosContainerName + paths: [ + '/_partitionKey' + ] + } + { + name: 'files' + paths: [ + '/_partitionKey' + ] + } + { + name: 'process_statuses' + paths: [ + '/_partitionKey' + ] + } + ] + } + ] + + diagnosticSettings: enableMonitoring + ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] + : null + + networkRestrictions: { + networkAclBypass: 'None' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + } + + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-${cosmosDbResourceName}' + customNetworkInterfaceName: 'nic-${cosmosDbResourceName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cosmosDB]!.outputs.resourceId } + ] + } + service: 'Sql' + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + } + ] + : [] + + zoneRedundant: enableRedundancy ? true : false + capabilitiesToAdd: enableRedundancy + ? null + : [ + 'EnableServerless' + ] + automaticFailover: enableRedundancy ? true : false + failoverLocations: enableRedundancy + ? [ + { + failoverPriority: 0 + isZoneRedundant: true + locationName: solutionLocation + } + { + failoverPriority: 1 + isZoneRedundant: true + locationName: cosmosDbHaLocation + } + ] + : [ + { + locationName: solutionLocation + failoverPriority: 0 + isZoneRedundant: enableRedundancy + } + ] + // Use built-in Cosmos DB roles for RBAC access + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'DocumentDB Account Contributor' + } + ] + // Create custom data plane role definition and assignment + dataPlaneRoleDefinitions: [ + { + roleName: 'CosmosDB Data Contributor Custom' + dataActions: [ + 'Microsoft.DocumentDB/databaseAccounts/readMetadata' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' + ] + assignments: [ + { principalId: appIdentity.outputs.principalId } + // ADD THIS for local debugging support: + { principalId: deployingUserPrincipalId } + ] + } + ] + } + dependsOn: [storageAccount] +} + + +// ========== Container Registry for developer builds ========== // +var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') +module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.1' = { + name: 'registryDeployment' + params: { + name: 'cr${solutionSuffix}' + acrAdminUserEnabled: false + acrSku: 'Basic' + azureADAuthenticationAsArmPolicyStatus: 'enabled' + exportPolicyStatus: 'enabled' + location: solutionLocation + softDeletePolicyDays: 7 + softDeletePolicyStatus: 'disabled' + tags: allTags + networkRuleBypassOptions: 'AzureServices' + roleAssignments: [ + { + roleDefinitionIdOrName: acrPullRole + principalType: 'ServicePrincipal' + principalId: appIdentity.outputs.principalId + } + ] + } +} + +var aiModelDeploymentName = aiModelName + +var useExistingAiFoundryAiProject = !empty(existingFoundryProjectResourceId) +var aiFoundryAiServicesResourceGroupName = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[4] + : 'rg-${solutionSuffix}' +var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[2] + : subscription().id +var aiFoundryAiServicesResourceName = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[8] + : 'aif-${solutionSuffix}' + + +resource existingAiFoundryAiServices 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (useExistingAiFoundryAiProject) { + name: aiFoundryAiServicesResourceName + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) +} + +module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.bicep' = if (useExistingAiFoundryAiProject) { + name: take('module.ai-services-model-deployments.${existingAiFoundryAiServices.name}', 64) + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) + params: { + name: existingAiFoundryAiServices.name + deployments: [ + { + name: aiModelDeploymentName + model: { + format: 'OpenAI' + name: aiModelName + version: aiModelVersion + } + sku: { + name: aiDeploymentType + capacity: aiModelCapacity + } + } + ] + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + } + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + } + ] + } +} + +// Temporarily disabled AI Foundry due to AML workspace creation issues +module aiFoundry 'br/public:avm/ptn/ai-ml/ai-foundry:0.4.0' = if(!useExistingAiFoundryAiProject) { + name: take('avm.ptn.ai-ml.ai-foundry.${solutionSuffix}', 64) + params: { + #disable-next-line BCP334 + baseName: take(aiFoundryAiServicesResourceName, 12) + baseUniqueName: null + location: empty(azureAiServiceLocation) ? location : azureAiServiceLocation + aiFoundryConfiguration: { + accountName:aiFoundryAiServicesResourceName + allowProjectManagement: true + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + } + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + } + ] + // Remove networking configuration to avoid AML workspace creation issues + networking: enablePrivateNetworking? { + aiServicesPrivateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.aiServices]!.outputs.resourceId + openAiPrivateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.openAI]!.outputs.resourceId + cognitiveServicesPrivateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cognitiveServices]!.outputs.resourceId + } : null + } + // Disable private endpoints temporarily to fix AML workspace issue + privateEndpointSubnetResourceId: enablePrivateNetworking ? virtualNetwork!.outputs.backendSubnetResourceId : null + // Only attempt model deployment when explicitly enabled to avoid AccountIsNotSucceeded failures due to quota or model availability. + aiModelDeployments: [ + { + name: aiModelDeploymentName + model: { + format: 'OpenAI' + name: aiModelName + version: aiModelVersion + } + sku: { + name: aiDeploymentType + capacity: aiModelCapacity + } + } + ] + tags: allTags + enableTelemetry: enableTelemetry + } +} + +// User Role Assignment for Azure OpenAI - New Resources +module userOpenAiRoleAssignment './modules/role.bicep' = if (!useExistingAiFoundryAiProject) { + name: take('user-openai-${uniqueString(deployingUserPrincipalId, aiFoundryAiServicesResourceName)}', 64) + params: { + name: 'user-openai-${uniqueString(deployingUserPrincipalId, aiFoundryAiServicesResourceName)}' + principalId: deployingUserPrincipalId + aiServiceName: aiFoundryAiServicesResourceName + principalType: 'User' + } +} + +// User Role Assignment for Azure OpenAI - Existing Resources +module userOpenAiRoleAssignmentExisting './modules/role.bicep' = if (useExistingAiFoundryAiProject) { + name: take('user-openai-existing-${uniqueString(deployingUserPrincipalId, existingAiFoundryAiServices.name)}', 64) + params: { + name: 'user-openai-existing-${uniqueString(deployingUserPrincipalId, existingAiFoundryAiServices.name)}' + principalId: deployingUserPrincipalId + aiServiceName: existingAiFoundryAiServices.name + principalType: 'User' + } + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) +} + +var aiServicesName = useExistingAiFoundryAiProject ? existingAiFoundryAiServices.name : aiFoundryAiServicesResourceName +module appConfiguration 'br/public:avm/res/app-configuration/configuration-store:0.9.1' = { + name: take('avm.res.app-config.store.${solutionSuffix}', 64) + params: { + location: solutionLocation + name: 'appcs-${solutionSuffix}' + disableLocalAuth: false // needed to allow setting app config key values from this module + tags: allTags + // Always set key values during deployment since Container Apps will be in private network + keyValues: [ + { + name: 'APP_LOGGING_ENABLE' + value: 'true' + } + { + name: 'APP_LOGGING_LEVEL' + value: 'INFO' + } + { + name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' + value: '' + } + { + name: 'AZURE_AI_AGENT_PROJECT_CONNECTION_STRING' + value: '' + } + { + name: 'AZURE_OPENAI_API_VERSION' + value: '2025-01-01-preview' + } + { + name: 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' + value: aiModelDeploymentName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: 'https://${aiServicesName}.cognitiveservices.azure.com/' + } + { + name: 'AZURE_OPENAI_ENDPOINT_BASE' + value: 'https://${aiServicesName}.cognitiveservices.azure.com/' + } + { + name: 'AZURE_TRACING_ENABLED' + value: 'True' + } + { + name: 'STORAGE_ACCOUNT_BLOB_URL' + value: 'https://${storageAccountName}.blob.${environment().suffixes.storage}' + } + { + name: 'STORAGE_ACCOUNT_NAME' + value: storageAccount.outputs.name + } + { + name: 'STORAGE_ACCOUNT_PROCESS_CONTAINER' + value: processBlobContainerName + } + { + name: 'STORAGE_ACCOUNT_PROCESS_QUEUE' + value: processQueueName + } + { + name: 'STORAGE_ACCOUNT_QUEUE_URL' + value: 'https://${storageAccountName}.queue.${environment().suffixes.storage}' + } + { + name: 'COSMOS_DB_CONTAINER_NAME' + value: agentTelemetryCosmosContainerName + } + { + name: 'COSMOS_DB_DATABASE_NAME' + value: cosmosDatabaseName + } + { + name: 'COSMOS_DB_ACCOUNT_URL' + value: cosmosDb.outputs.endpoint + } + { + name: 'COSMOS_DB_PROCESS_CONTAINER' + value: processCosmosContainerName + } + { + name: 'COSMOS_DB_PROCESS_LOG_CONTAINER' + value: agentTelemetryCosmosContainerName + } + { + name: 'GLOBAL_LLM_SERVICE' + value: 'AzureOpenAI' + } + { + name: 'STORAGE_QUEUE_ACCOUNT' + value: storageAccount.outputs.name + } + ] + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'App Configuration Data Reader' + } + ] + enableTelemetry: enableTelemetry + managedIdentities: { systemAssigned: true } + sku: 'Standard' + publicNetworkAccess: 'Enabled' + } +} + +module avmAppConfigUpdated 'br/public:avm/res/app-configuration/configuration-store:0.6.3' = if (enablePrivateNetworking) { + name: take('avm.res.app-configuration.configuration-store-update.${solutionSuffix}', 64) + params: { + name: 'appcs-${solutionSuffix}' + location: solutionLocation + managedIdentities: { systemAssigned: true } + sku: 'Standard' + enableTelemetry: enableTelemetry + tags: allTags + disableLocalAuth: true + // Keep public access enabled for Container Apps access (Container Apps not in private network due to capacity constraints) + publicNetworkAccess: 'Enabled' + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-appconfig-${solutionSuffix}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'appconfig-dns-zone-group' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.appConfig]!.outputs.resourceId + } + ] + } + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + } + ] + : [] + } + dependsOn: [ + appConfiguration + ] +} + +var logAnalyticsPrimarySharedKey = useExistingLogAnalytics + ? existingLogAnalyticsWorkspace!.listKeys().primarySharedKey + : logAnalyticsWorkspace!.outputs!.primarySharedKey +var logAnalyticsWorkspaceId = useExistingLogAnalytics + ? existingLogAnalyticsWorkspace!.properties.customerId + : logAnalyticsWorkspace!.outputs.logAnalyticsWorkspaceId +// ========== Container App Environment ========== // +module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.2' = { + name: take('avm.res.app.managed-environment.${solutionSuffix}', 64) + params: { + name: 'cae-${solutionSuffix}' + location: location + managedIdentities: { systemAssigned: true } + appLogsConfiguration: enableMonitoring + ? { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspaceId + sharedKey: logAnalyticsPrimarySharedKey + } + } + : null + workloadProfiles: [ + { + name: 'Consumption' + workloadProfileType: 'Consumption' + } + ] + enableTelemetry: enableTelemetry + publicNetworkAccess: 'Enabled' // Always enabled for Container Apps Environment + + // <========== WAF related parameters + + platformReservedCidr: '172.17.17.0/24' + platformReservedDnsIP: '172.17.17.17' + zoneRedundant: (enablePrivateNetworking) ? true : false // Enable zone redundancy if private networking is enabled + infrastructureSubnetResourceId: (enablePrivateNetworking) + ? virtualNetwork!.outputs.containersSubnetResourceId // Use the container app subnet + : null // Use the container app subnet + } +} + +var backendContainerPort = 80 +var backendContainerAppName = take('ca-backend-api-${solutionSuffix}', 32) +module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = { + name: take('avm.res.app.container-app.${backendContainerAppName}', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [applicationInsights] + params: { + name: backendContainerAppName + location: solutionLocation + environmentResourceId: containerAppsEnvironment.outputs.resourceId + tags: union(allTags, { 'azd-service-name': 'backend' }) + managedIdentities: { + userAssignedResourceIds: [ + appIdentity.outputs.resourceId + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: appIdentity.outputs.resourceId + } + ] + containers: [ + { + name: 'backend-api' + image: !empty(backendImageName) ? backendImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + env: concat( + [ + { + name: 'APP_CONFIGURATION_URL' + value: appConfiguration.outputs.endpoint + } + { + name: 'AZURE_CLIENT_ID' + value: appIdentity.outputs.clientId + } + ], + enableMonitoring + ? [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights!.outputs.connectionString + } + ] + : [] + ) + resources: { + cpu: 1 + memory: '2.0Gi' + } + } + ] + ingressTargetPort: backendContainerPort + ingressExternal: true + scaleSettings: { + maxReplicas: enableScalability ? 3 : 1 + minReplicas: 1 + rules: enableScalability + ? [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: 100 + } + } + } + ] + : [] + } + corsPolicy: { + allowedOrigins: [ + '*' + ] + allowedMethods: [ + 'GET' + 'POST' + 'PUT' + 'DELETE' + 'OPTIONS' + ] + allowedHeaders: [ + 'Authorization' + 'Content-Type' + '*' + ] + } + enableTelemetry: enableTelemetry + } +} + +var frontEndContainerAppName = take('ca-frontend-${solutionSuffix}', 32) +module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = { + name: take('avm.res.app.container-app.${frontEndContainerAppName}', 64) + params: { + name: frontEndContainerAppName + location: solutionLocation + environmentResourceId: containerAppsEnvironment.outputs.resourceId + tags: union(allTags, { 'azd-service-name': 'frontend' }) + managedIdentities: { + userAssignedResourceIds: [ + appIdentity.outputs.resourceId + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: appIdentity.outputs.resourceId + } + ] + containers: [ + { + name: 'frontend' + image: !empty(frontendImageName) ? frontendImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + env: [ + { + name: 'API_URL' + value: 'https://${containerAppBackend.outputs.fqdn}' + } + { + name: 'APP_ENV' + value: 'prod' + } + ] + resources: { + cpu: '1' + memory: '2.0Gi' + } + } + ] + ingressTargetPort: 3000 + ingressExternal: true + scaleSettings: { + maxReplicas: enableScalability ? 3 : 1 + minReplicas: 1 + rules: enableScalability + ? [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: 100 + } + } + } + ] + : [] + } + enableTelemetry: enableTelemetry + } +} + +var processorContainerAppName = take('ca-processor-${solutionSuffix}', 32) +module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = { + name: take('avm.res.app.container-app.${processorContainerAppName}', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [applicationInsights] + params: { + name: processorContainerAppName + location: solutionLocation + environmentResourceId: containerAppsEnvironment.outputs.resourceId + tags: union(allTags, { 'azd-service-name': 'processor' }) + managedIdentities: { + userAssignedResourceIds: [ + appIdentity.outputs.resourceId + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: appIdentity.outputs.resourceId + } + ] + containers: [ + { + name: 'processor' + image: !empty(processorImageName) ? processorImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + env: concat( + [ + { + name: 'APP_CONFIGURATION_URL' + value: appConfiguration.outputs.endpoint + } + { + name: 'AZURE_CLIENT_ID' + value: appIdentity.outputs.clientId + } + { + name: 'AZURE_STORAGE_ACCOUNT_NAME' // TODO - verify name and if needed or if pulled from app config service + value: storageAccount.outputs.name + } + { + name: 'STORAGE_ACCOUNT_NAME' // TODO - verify name and if needed + value: storageAccount.outputs.name + } + ], + enableMonitoring + ? [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights!.outputs.connectionString + } + ] + : [] + ) + resources: { + // TODO - assess increasing resource limits + cpu: 2 + memory: '4.0Gi' + } + } + ] + ingressTransport: null + disableIngress: true + ingressExternal: false + scaleSettings: { + maxReplicas: enableScalability ? 3 : 1 + minReplicas: 1 + //rules: [] - TODO - what scaling rules to use here? + } + enableTelemetry: enableTelemetry + } +} + +@description('The name of the resource group.') +output resourceGroupName string = resourceGroup().name + +@description('The name of the frontend container app.') +output CONTAINER_FRONTEND_APP_NAME string = containerAppFrontend.outputs.name + +@description('The FQDN of the frontend container app.') +output CONTAINER_FRONTEND_APP_FQDN string = containerAppFrontend.outputs.fqdn + +// Keep these for backward compatibility if needed +@description('The name of the web app container app.') +output CONTAINER_WEB_APP_NAME string = containerAppFrontend.outputs.name + +@description('The FQDN of the web app container app.') +output CONTAINER_WEB_APP_FQDN string = containerAppFrontend.outputs.fqdn + +@description('The name of the API container app.') +output CONTAINER_API_APP_NAME string = containerAppBackend.outputs.name + +@description('The FQDN of the API container app.') +output CONTAINER_API_APP_FQDN string = containerAppBackend.outputs.fqdn + +@description('The Azure subscription ID.') +output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId + +@description('The Azure resource group name.') +output AZURE_RESOURCE_GROUP string = resourceGroup().name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer + +@description('Backend service container app name') +output SERVICE_BACKEND_NAME string = containerAppBackend.outputs.name + +@description('Backend service container app URI') +output SERVICE_BACKEND_URI string = 'https://${containerAppBackend.outputs.fqdn}' + +@description('Processor service container app name') +output SERVICE_PROCESSOR_NAME string = containerAppProcessor.outputs.name + +@description('Frontend service container app name') +output SERVICE_FRONTEND_NAME string = containerAppFrontend.outputs.name + +@description('Frontend service container app URI') +output SERVICE_FRONTEND_URI string = 'https://${containerAppFrontend.outputs.fqdn}' diff --git a/infra/modules/role.bicep b/infra/modules/role.bicep new file mode 100644 index 0000000..79ff04e --- /dev/null +++ b/infra/modules/role.bicep @@ -0,0 +1,60 @@ +@description('The name of the role assignment.') +param name string + +@description('The principal ID to grant access to.') +param principalId string + +@description('The name of the existing Azure Cognitive Services account.') +param aiServiceName string + +@allowed(['Device', 'ForeignGroup', 'Group', 'ServicePrincipal', 'User']) +param principalType string = 'ServicePrincipal' + +resource cognitiveServiceExisting 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: aiServiceName +} + +// Cognitive Services OpenAI User +resource cognitiveServiceOpenAIUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' +} + +// Azure AI Developer +resource aiDeveloper 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '64702f94-c441-49e6-a78b-ef80e0188fee' +} + +// Azure AI Inference Deployment Operator +resource aiUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '53ca6127-db72-4b80-b1b0-d745d6d5456d' +} + +resource aiUserAccessFoundry 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid('${name}-aiuser-${principalId}') + scope: cognitiveServiceExisting + properties: { + roleDefinitionId: aiUser.id + principalId: principalId + principalType: principalType + } +} + +resource aiDeveloperAccessFoundry 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid('${name}-aidev-${principalId}') + scope: cognitiveServiceExisting + properties: { + roleDefinitionId: aiDeveloper.id + principalId: principalId + principalType: principalType + } +} + +resource cognitiveServiceOpenAIUserAccessFoundry 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid('${name}-openai-${principalId}') + scope: cognitiveServiceExisting + properties: { + roleDefinitionId: cognitiveServiceOpenAIUser.id + principalId: principalId + principalType: principalType + } +}