diff --git a/azure_custom.yaml b/azure_custom.yaml new file mode 100644 index 000000000..4a5b3fabc --- /dev/null +++ b/azure_custom.yaml @@ -0,0 +1,38 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: build-your-own-copilot-solution-accelerator + +requiredVersions: + azd: ">= 1.18.0" + +metadata: + template: build-your-own-copilot-solution-accelerator@1.0 + name: build-your-own-copilot-solution-accelerator@1.0 + +# Infrastructure configuration +infra: + path: ./infra + module: main + parameters: + solutionName: bs-azdtest + cosmosLocation: eastus2 + baseUrl: 'https://github.com/microsoft/Build-your-own-copilot-Solution-Accelerator' + +services: + webapp: + project: ./src/App + language: py + host: appservice + dist: ./dist + hooks: + prepackage: + windows: + shell: pwsh + run: ../../infra/scripts/package_webapp.ps1 + interactive: true + continueOnError: false + posix: + shell: sh + run: bash ../../infra/scripts/package_webapp.sh + interactive: true + continueOnError: false \ No newline at end of file diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index d3498bfc0..e29ee460e 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -116,7 +116,6 @@ If you're not using one of the above options for opening the project, then you'l - [Docker Desktop](https://www.docker.com/products/docker-desktop/) - [Git](https://git-scm.com/downloads) - [Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/en-us/sql/connect/odbc/download-odbc-driver-for-sql-server?view=sql-server-ver16) - - [sqlcmd(ODBC-Windows)](https://learn.microsoft.com/en-us/sql/tools/sqlcmd/sqlcmd-utility?view=sql-server-ver16&tabs=odbc%2Cwindows%2Cwindows-support&pivots=cs1-bash#download-and-install-sqlcmd) / [sqlcmd(Linux/Mac)](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-setup-tools?view=sql-server-ver16&tabs=redhat-install) 2. Clone the repository or download the project code via command-line: @@ -236,6 +235,13 @@ This will rebuild the source code, package it into a container, and push it to t ### πŸ› οΈ Troubleshooting If you encounter any issues during the deployment process, please refer [troubleshooting](../docs/TroubleShootingSteps.md) document for detailed steps and solutions +## Deploy Your local changes +To deploy your local changes rename the below files. + 1. Rename `azure.yaml` to `azure_custom2.yaml` and `azure_custom.yaml` to `azure.yaml`. + 2. 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). + ## Post Deployment Steps ### 1. Import Sample Data diff --git a/infra/main.bicep b/infra/main.bicep index 9ac857545..eeddf4169 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -33,8 +33,8 @@ param gptModelVersion string = '2024-07-18' @description('Optional. Version of the GPT model to deploy.') param embeddingModelVersion string = '2' -@description('Optional. API version for the Azure OpenAI service.') -param azureOpenaiAPIVersion string = '2025-04-01-preview' +@description('Optional. API version for the Azure AI Services.') +param azureAIServicesAPIVersion string = '2025-04-01-preview' @minValue(10) @description('Optional. Capacity of the GPT deployment:') @@ -494,7 +494,6 @@ module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (enable // ========== Private DNS Zones ========== // var privateDnsZones = [ 'privatelink.cognitiveservices.azure.com' - 'privatelink.openai.azure.com' 'privatelink.services.ai.azure.com' 'privatelink.azurewebsites.net' 'privatelink.blob.${environment().suffixes.storage}' @@ -509,22 +508,20 @@ var privateDnsZones = [ // DNS Zone Index Constants var dnsZoneIndex = { cognitiveServices: 0 - openAI: 1 - aiServices: 2 - appService: 3 - storageBlob: 4 - storageQueue: 5 - storageFile: 6 - cosmosDB: 7 - keyVault: 8 - sqlServer: 9 - searchService: 10 + aiServices: 1 + appService: 2 + storageBlob: 3 + storageQueue: 4 + storageFile: 5 + cosmosDB: 6 + keyVault: 7 + sqlServer: 8 + searchService: 9 } // List of DNS zone indices that correspond to AI-related services. var aiRelatedDnsZoneIndices = [ dnsZoneIndex.cognitiveServices - dnsZoneIndex.openAI dnsZoneIndex.aiServices ] @@ -553,7 +550,7 @@ module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ ] // ==========Key Vault Module ========== // -var keyVaultName = 'KV-${solutionSuffix}' +var keyVaultName = 'kv-${solutionSuffix}' module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { name: take('avm.res.key-vault.vault.${keyVaultName}', 64) params: { @@ -613,11 +610,11 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { } { name: 'AZURE-OPENAI-PREVIEW-API-VERSION' - value: azureOpenaiAPIVersion + value: azureAIServicesAPIVersion } { name: 'AZURE-OPENAI-ENDPOINT' - value: aiFoundryAiServices.outputs.endpoints['OpenAI Language Model Instance API'] + value: aiFoundryAiServices.outputs.endpoint } { name: 'AZURE-OPENAI-EMBEDDING-MODEL' @@ -631,6 +628,10 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { name: 'AZURE-SEARCH-ENDPOINT' value: 'https://${aiSearchName}.search.windows.net' } + { + name: 'AZURE-AI-AGENT-ENDPOINT' + value: aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + } ] enableTelemetry: enableTelemetry } @@ -733,10 +734,6 @@ module aiFoundryAiServices 'modules/ai-services.bicep' = if (aiFoundryAIservices name: 'ai-services-dns-zone-cognitiveservices' privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cognitiveServices]!.outputs.resourceId } - { - name: 'ai-services-dns-zone-openai' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.openAI]!.outputs.resourceId - } { name: 'ai-services-dns-zone-aiservices' privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.aiServices]!.outputs.resourceId @@ -891,7 +888,7 @@ module avmStorageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { bypass: 'AzureServices' defaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' } - allowBlobPublicAccess: enablePrivateNetworking ? true : false + allowBlobPublicAccess: false publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' // Private endpoints for blob and queue privateEndpoints: enablePrivateNetworking @@ -1111,20 +1108,20 @@ module webSite 'modules/web-sites.bicep' = { AZURE_SEARCH_URL_COLUMN: azureSearchUrlColumn AZURE_OPENAI_RESOURCE: aiFoundryAiServices.outputs.name AZURE_OPENAI_MODEL: gptModelName - AZURE_OPENAI_ENDPOINT: aiFoundryAiServices.outputs.endpoints['OpenAI Language Model Instance API'] + AZURE_OPENAI_ENDPOINT: aiFoundryAiServices.outputs.endpoint AZURE_OPENAI_TEMPERATURE: azureOpenAITemperature AZURE_OPENAI_TOP_P: azureOpenAITopP AZURE_OPENAI_MAX_TOKENS: azureOpenAIMaxTokens AZURE_OPENAI_STOP_SEQUENCE: azureOpenAIStopSequence AZURE_OPENAI_SYSTEM_MESSAGE: azureOpenAISystemMessage - AZURE_OPENAI_PREVIEW_API_VERSION: azureOpenaiAPIVersion + AZURE_OPENAI_PREVIEW_API_VERSION: azureAIServicesAPIVersion AZURE_OPENAI_STREAM: azureOpenAIStream AZURE_SEARCH_QUERY_TYPE: azureSearchQueryType AZURE_SEARCH_VECTOR_COLUMNS: azureSearchVectorFields AZURE_SEARCH_PERMITTED_GROUPS_COLUMN: azureSearchPermittedGroupsField AZURE_SEARCH_STRICTNESS: azureSearchStrictness AZURE_OPENAI_EMBEDDING_NAME: embeddingModel - AZURE_OPENAI_EMBEDDING_ENDPOINT : aiFoundryAiServices.outputs.endpoints['OpenAI Language Model Instance API'] + AZURE_OPENAI_EMBEDDING_ENDPOINT : aiFoundryAiServices.outputs.endpoint SQLDB_SERVER: sqlServerFqdn SQLDB_DATABASE: sqlDbName USE_INTERNAL_STREAM: useInternalStream @@ -1140,7 +1137,7 @@ module webSite 'modules/web-sites.bicep' = { USE_AI_PROJECT_CLIENT: useAIProjectClientFlag AZURE_AI_AGENT_ENDPOINT: aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME: gptModelName - AZURE_AI_AGENT_API_VERSION: azureOpenaiAPIVersion + AZURE_AI_AGENT_API_VERSION: azureAIServicesAPIVersion AZURE_SEARCH_CONNECTION_NAME: aiSearchName AZURE_CLIENT_ID: userAssignedIdentity.outputs.clientId } @@ -1345,8 +1342,8 @@ output APPLICATIONINSIGHTS_CONNECTION_STRING string = enableMonitoring ? applicationInsights!.outputs.connectionString : '' -@description('The API version used for the Azure AI Agent service.') -output AZURE_AI_AGENT_API_VERSION string = azureOpenaiAPIVersion + @description('The API version used for the Azure AI Agent service.') +output AZURE_AI_AGENT_API_VERSION string = azureAIServicesAPIVersion @description('The endpoint URL of the Azure AI Agent project.') output AZURE_AI_AGENT_ENDPOINT string = aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint @@ -1373,13 +1370,13 @@ output AZURE_COSMOSDB_DATABASE string = cosmosDbDatabaseName output AZURE_COSMOSDB_ENABLE_FEEDBACK string = azureCosmosDbEnableFeedback @description('The endpoint URL for the Azure OpenAI Embedding model.') -output AZURE_OPENAI_EMBEDDING_ENDPOINT string = aiFoundryAiServices.outputs.endpoints['OpenAI Language Model Instance API'] +output AZURE_OPENAI_EMBEDDING_ENDPOINT string = aiFoundryAiServices.outputs.endpoint @description('The name of the Azure OpenAI Embedding model.') output AZURE_OPENAI_EMBEDDING_NAME string = embeddingModel @description('The endpoint URL for the Azure OpenAI service.') -output AZURE_OPENAI_ENDPOINT string = aiFoundryAiServices.outputs.endpoints['OpenAI Language Model Instance API'] +output AZURE_OPENAI_ENDPOINT string = aiFoundryAiServices.outputs.endpoint @description('The maximum number of tokens for Azure OpenAI responses.') output AZURE_OPENAI_MAX_TOKENS string = azureOpenAIMaxTokens @@ -1388,7 +1385,7 @@ output AZURE_OPENAI_MAX_TOKENS string = azureOpenAIMaxTokens output AZURE_OPENAI_MODEL string = gptModelName @description('The preview API version for Azure OpenAI.') -output AZURE_OPENAI_PREVIEW_API_VERSION string = azureOpenaiAPIVersion +output AZURE_OPENAI_PREVIEW_API_VERSION string = azureAIServicesAPIVersion @description('The Azure OpenAI resource name.') output AZURE_OPENAI_RESOURCE string = aiFoundryAiServices.outputs.name diff --git a/infra/main.json b/infra/main.json index edab9db41..ba13a0b53 100644 --- a/infra/main.json +++ b/infra/main.json @@ -5,8 +5,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "2150473040924531792" + "version": "0.39.26.7824", + "templateHash": "8399698956794037004" } }, "parameters": { @@ -70,11 +70,11 @@ "description": "Optional. Version of the GPT model to deploy." } }, - "azureOpenaiAPIVersion": { + "azureAIServicesAPIVersion": { "type": "string", "defaultValue": "2025-04-01-preview", "metadata": { - "description": "Optional. API version for the Azure OpenAI service." + "description": "Optional. API version for the Azure AI Services." } }, "gptModelCapacity": { @@ -330,7 +330,6 @@ "jumpboxVmName": "[take(format('vm-jumpbox-{0}', variables('solutionSuffix')), 15)]", "privateDnsZones": [ "privatelink.cognitiveservices.azure.com", - "privatelink.openai.azure.com", "privatelink.services.ai.azure.com", "privatelink.azurewebsites.net", "[format('privatelink.blob.{0}', environment().suffixes.storage)]", @@ -343,23 +342,21 @@ ], "dnsZoneIndex": { "cognitiveServices": 0, - "openAI": 1, - "aiServices": 2, - "appService": 3, - "storageBlob": 4, - "storageQueue": 5, - "storageFile": 6, - "cosmosDB": 7, - "keyVault": 8, - "sqlServer": 9, - "searchService": 10 + "aiServices": 1, + "appService": 2, + "storageBlob": 3, + "storageQueue": 4, + "storageFile": 5, + "cosmosDB": 6, + "keyVault": 7, + "sqlServer": 8, + "searchService": 9 }, "aiRelatedDnsZoneIndices": [ "[variables('dnsZoneIndex').cognitiveServices]", - "[variables('dnsZoneIndex').openAI]", "[variables('dnsZoneIndex').aiServices]" ], - "keyVaultName": "[format('KV-{0}', variables('solutionSuffix'))]", + "keyVaultName": "[format('kv-{0}', variables('solutionSuffix'))]", "useExistingAiFoundryAiProject": "[not(empty(parameters('existingFoundryProjectResourceId')))]", "aiFoundryAiServicesSubscriptionId": "[if(variables('useExistingAiFoundryAiProject'), split(parameters('existingFoundryProjectResourceId'), '/')[2], subscription().id)]", "aiFoundryAiServicesResourceGroupName": "[if(variables('useExistingAiFoundryAiProject'), split(parameters('existingFoundryProjectResourceId'), '/')[4], format('rg-{0}', variables('solutionSuffix')))]", @@ -5297,8 +5294,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "1734974014097019118" + "version": "0.39.26.7824", + "templateHash": "14641679443140532549" } }, "definitions": { @@ -20981,11 +20978,11 @@ }, { "name": "AZURE-OPENAI-PREVIEW-API-VERSION", - "value": "[parameters('azureOpenaiAPIVersion')]" + "value": "[parameters('azureAIServicesAPIVersion')]" }, { "name": "AZURE-OPENAI-ENDPOINT", - "value": "[reference('aiFoundryAiServices').outputs.endpoints.value['OpenAI Language Model Instance API']]" + "value": "[reference('aiFoundryAiServices').outputs.endpoint.value]" }, { "name": "AZURE-OPENAI-EMBEDDING-MODEL", @@ -20998,6 +20995,10 @@ { "name": "AZURE-SEARCH-ENDPOINT", "value": "[format('https://{0}.search.windows.net', variables('aiSearchName'))]" + }, + { + "name": "AZURE-AI-AGENT-ENDPOINT", + "value": "[reference('aiFoundryAiServices').outputs.aiProjectInfo.value.apiEndpoint]" } ] }, @@ -24210,7 +24211,7 @@ }, "diagnosticSettings": "[if(parameters('enableMonitoring'), createObject('value', createArray(createObject('workspaceResourceId', if(variables('useExistingLogAnalytics'), parameters('existingLogAnalyticsWorkspaceId'), reference('logAnalyticsWorkspace').outputs.resourceId.value)))), createObject('value', null()))]", "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Disabled'), createObject('value', 'Enabled'))]", - "privateEndpoints": "[if(and(parameters('enablePrivateNetworking'), empty(parameters('existingFoundryProjectResourceId'))), createObject('value', createArray(createObject('name', format('pep-{0}', variables('aiFoundryAiServicesResourceName')), 'customNetworkInterfaceName', format('nic-{0}', variables('aiFoundryAiServicesResourceName')), 'subnetResourceId', reference('virtualNetwork').outputs.pepsSubnetResourceId.value, 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('name', 'ai-services-dns-zone-cognitiveservices', 'privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').cognitiveServices)).outputs.resourceId.value), createObject('name', 'ai-services-dns-zone-openai', 'privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').openAI)).outputs.resourceId.value), createObject('name', 'ai-services-dns-zone-aiservices', 'privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').aiServices)).outputs.resourceId.value)))))), createObject('value', createArray()))]", + "privateEndpoints": "[if(and(parameters('enablePrivateNetworking'), empty(parameters('existingFoundryProjectResourceId'))), createObject('value', createArray(createObject('name', format('pep-{0}', variables('aiFoundryAiServicesResourceName')), 'customNetworkInterfaceName', format('nic-{0}', variables('aiFoundryAiServicesResourceName')), 'subnetResourceId', reference('virtualNetwork').outputs.pepsSubnetResourceId.value, 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('name', 'ai-services-dns-zone-cognitiveservices', 'privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').cognitiveServices)).outputs.resourceId.value), createObject('name', 'ai-services-dns-zone-aiservices', 'privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').aiServices)).outputs.resourceId.value)))))), createObject('value', createArray()))]", "deployments": { "value": [ { @@ -24249,8 +24250,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "12531718623039828267" + "version": "0.39.26.7824", + "templateHash": "1936381873810101836" }, "name": "Cognitive Services", "description": "This module deploys a Cognitive Service." @@ -25482,8 +25483,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "1396927448823284485" + "version": "0.39.26.7824", + "templateHash": "17351518472581919759" } }, "definitions": { @@ -26408,8 +26409,6 @@ "Cognitive Services LUIS Writer": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '6322a993-d5c9-4bed-b113-e49bbea25b27')]", "Cognitive Services Metrics Advisor Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'cb43c632-a144-4ec5-977c-e80c4affc34a')]", "Cognitive Services Metrics Advisor User": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3b20f47b-3825-43cb-8114-4bd2201156a8')]", - "Cognitive Services OpenAI Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a001fd3d-188f-4b5d-821b-7da978bf7442')]", - "Cognitive Services OpenAI User": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", "Cognitive Services QnA Maker Editor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f4cc2bf9-21be-47a1-bdf1-5c5804381025')]", "Cognitive Services QnA Maker Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '466ccd10-b268-4a11-b098-b4849f024126')]", "Cognitive Services Speech Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0e75ca1e-0464-4b4d-8b93-68208a576181')]", @@ -27297,8 +27296,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "7420599935384266971" + "version": "0.39.26.7824", + "templateHash": "4291957610087788581" } }, "definitions": { @@ -27451,8 +27450,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "5676565623284126112" + "version": "0.39.26.7824", + "templateHash": "5108472911734987415" } }, "definitions": { @@ -27688,8 +27687,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "1396927448823284485" + "version": "0.39.26.7824", + "templateHash": "17351518472581919759" } }, "definitions": { @@ -28614,8 +28613,6 @@ "Cognitive Services LUIS Writer": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '6322a993-d5c9-4bed-b113-e49bbea25b27')]", "Cognitive Services Metrics Advisor Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'cb43c632-a144-4ec5-977c-e80c4affc34a')]", "Cognitive Services Metrics Advisor User": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3b20f47b-3825-43cb-8114-4bd2201156a8')]", - "Cognitive Services OpenAI Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a001fd3d-188f-4b5d-821b-7da978bf7442')]", - "Cognitive Services OpenAI User": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", "Cognitive Services QnA Maker Editor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f4cc2bf9-21be-47a1-bdf1-5c5804381025')]", "Cognitive Services QnA Maker Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '466ccd10-b268-4a11-b098-b4849f024126')]", "Cognitive Services Speech Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0e75ca1e-0464-4b4d-8b93-68208a576181')]", @@ -29503,8 +29500,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "7420599935384266971" + "version": "0.39.26.7824", + "templateHash": "4291957610087788581" } }, "definitions": { @@ -29657,8 +29654,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "5676565623284126112" + "version": "0.39.26.7824", + "templateHash": "5108472911734987415" } }, "definitions": { @@ -29915,9 +29912,8 @@ } }, "dependsOn": [ - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').cognitiveServices)]", "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').aiServices)]", - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').openAI)]", + "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').cognitiveServices)]", "logAnalyticsWorkspace", "userAssignedIdentity", "virtualNetwork" @@ -33814,7 +33810,9 @@ "defaultAction": "[if(parameters('enablePrivateNetworking'), 'Deny', 'Allow')]" } }, - "allowBlobPublicAccess": "[if(parameters('enablePrivateNetworking'), createObject('value', true()), createObject('value', false()))]", + "allowBlobPublicAccess": { + "value": false + }, "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), createObject('value', 'Disabled'), createObject('value', 'Enabled'))]", "privateEndpoints": "[if(parameters('enablePrivateNetworking'), createObject('value', createArray(createObject('name', format('pep-blob-{0}', variables('solutionSuffix')), 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('name', 'storage-dns-zone-group-blob', 'privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageBlob)).outputs.resourceId.value))), 'subnetResourceId', reference('virtualNetwork').outputs.pepsSubnetResourceId.value, 'service', 'blob'), createObject('name', format('pep-queue-{0}', variables('solutionSuffix')), 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('name', 'storage-dns-zone-group-queue', 'privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageQueue)).outputs.resourceId.value))), 'subnetResourceId', reference('virtualNetwork').outputs.pepsSubnetResourceId.value, 'service', 'queue'))), createObject('value', createArray()))]", "blobServices": { @@ -49977,20 +49975,20 @@ "AZURE_SEARCH_URL_COLUMN": "[variables('azureSearchUrlColumn')]", "AZURE_OPENAI_RESOURCE": "[reference('aiFoundryAiServices').outputs.name.value]", "AZURE_OPENAI_MODEL": "[parameters('gptModelName')]", - "AZURE_OPENAI_ENDPOINT": "[reference('aiFoundryAiServices').outputs.endpoints.value['OpenAI Language Model Instance API']]", + "AZURE_OPENAI_ENDPOINT": "[reference('aiFoundryAiServices').outputs.endpoint.value]", "AZURE_OPENAI_TEMPERATURE": "[variables('azureOpenAITemperature')]", "AZURE_OPENAI_TOP_P": "[variables('azureOpenAITopP')]", "AZURE_OPENAI_MAX_TOKENS": "[variables('azureOpenAIMaxTokens')]", "AZURE_OPENAI_STOP_SEQUENCE": "[variables('azureOpenAIStopSequence')]", "AZURE_OPENAI_SYSTEM_MESSAGE": "[variables('azureOpenAISystemMessage')]", - "AZURE_OPENAI_PREVIEW_API_VERSION": "[parameters('azureOpenaiAPIVersion')]", + "AZURE_OPENAI_PREVIEW_API_VERSION": "[parameters('azureAIServicesAPIVersion')]", "AZURE_OPENAI_STREAM": "[variables('azureOpenAIStream')]", "AZURE_SEARCH_QUERY_TYPE": "[variables('azureSearchQueryType')]", "AZURE_SEARCH_VECTOR_COLUMNS": "[variables('azureSearchVectorFields')]", "AZURE_SEARCH_PERMITTED_GROUPS_COLUMN": "[variables('azureSearchPermittedGroupsField')]", "AZURE_SEARCH_STRICTNESS": "[variables('azureSearchStrictness')]", "AZURE_OPENAI_EMBEDDING_NAME": "[parameters('embeddingModel')]", - "AZURE_OPENAI_EMBEDDING_ENDPOINT": "[reference('aiFoundryAiServices').outputs.endpoints.value['OpenAI Language Model Instance API']]", + "AZURE_OPENAI_EMBEDDING_ENDPOINT": "[reference('aiFoundryAiServices').outputs.endpoint.value]", "SQLDB_SERVER": "[variables('sqlServerFqdn')]", "SQLDB_DATABASE": "[variables('sqlDbName')]", "USE_INTERNAL_STREAM": "[variables('useInternalStream')]", @@ -50006,7 +50004,7 @@ "USE_AI_PROJECT_CLIENT": "[variables('useAIProjectClientFlag')]", "AZURE_AI_AGENT_ENDPOINT": "[reference('aiFoundryAiServices').outputs.aiProjectInfo.value.apiEndpoint]", "AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME": "[parameters('gptModelName')]", - "AZURE_AI_AGENT_API_VERSION": "[parameters('azureOpenaiAPIVersion')]", + "AZURE_AI_AGENT_API_VERSION": "[parameters('azureAIServicesAPIVersion')]", "AZURE_SEARCH_CONNECTION_NAME": "[variables('aiSearchName')]", "AZURE_CLIENT_ID": "[reference('userAssignedIdentity').outputs.clientId.value]" }, @@ -50029,8 +50027,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "14170137035624875111" + "version": "0.39.26.7824", + "templateHash": "13074777962389399773" } }, "definitions": { @@ -51042,8 +51040,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "16983009113856606195" + "version": "0.39.26.7824", + "templateHash": "11666262061409473778" }, "name": "Site App Settings", "description": "This module deploys a Site App Setting." @@ -54429,8 +54427,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "11311597701635556530" + "version": "0.39.26.7824", + "templateHash": "904007681755275486" } }, "parameters": { @@ -54524,8 +54522,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.38.33.27573", - "templateHash": "9717690292313179013" + "version": "0.39.26.7824", + "templateHash": "10276790018915749779" } }, "parameters": { @@ -54706,7 +54704,7 @@ "metadata": { "description": "The API version used for the Azure AI Agent service." }, - "value": "[parameters('azureOpenaiAPIVersion')]" + "value": "[parameters('azureAIServicesAPIVersion')]" }, "AZURE_AI_AGENT_ENDPOINT": { "type": "string", @@ -54769,7 +54767,7 @@ "metadata": { "description": "The endpoint URL for the Azure OpenAI Embedding model." }, - "value": "[reference('aiFoundryAiServices').outputs.endpoints.value['OpenAI Language Model Instance API']]" + "value": "[reference('aiFoundryAiServices').outputs.endpoint.value]" }, "AZURE_OPENAI_EMBEDDING_NAME": { "type": "string", @@ -54783,7 +54781,7 @@ "metadata": { "description": "The endpoint URL for the Azure OpenAI service." }, - "value": "[reference('aiFoundryAiServices').outputs.endpoints.value['OpenAI Language Model Instance API']]" + "value": "[reference('aiFoundryAiServices').outputs.endpoint.value]" }, "AZURE_OPENAI_MAX_TOKENS": { "type": "string", @@ -54804,7 +54802,7 @@ "metadata": { "description": "The preview API version for Azure OpenAI." }, - "value": "[parameters('azureOpenaiAPIVersion')]" + "value": "[parameters('azureAIServicesAPIVersion')]" }, "AZURE_OPENAI_RESOURCE": { "type": "string", diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 092e5a315..de31ba4b9 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -14,7 +14,7 @@ "gptModelName": { "value": "${AZURE_ENV_MODEL_NAME}" }, - "azureOpenaiAPIVersion": { + "azureAIServicesAPIVersion": { "value": "${AZURE_ENV_MODEL_VERSION}" }, "gptDeploymentCapacity": { diff --git a/infra/main.waf.parameters.json b/infra/main.waf.parameters.json index ec12206bd..236212679 100644 --- a/infra/main.waf.parameters.json +++ b/infra/main.waf.parameters.json @@ -14,7 +14,7 @@ "gptModelName": { "value": "${AZURE_ENV_MODEL_NAME}" }, - "azureOpenaiAPIVersion": { + "azureAIServicesAPIVersion": { "value": "${AZURE_ENV_MODEL_VERSION}" }, "gptDeploymentCapacity": { diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep new file mode 100644 index 000000000..160358b12 --- /dev/null +++ b/infra/main_custom.bicep @@ -0,0 +1,1476 @@ +// ========== main_custom.bicep ========== // +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(20) +@description('Required. A unique prefix for all resources in this deployment. This should be 3-20 characters long:') +param solutionName string = 'clientadvisor' + +@description('Optional. Existing Log Analytics Workspace Resource ID') +param existingLogAnalyticsWorkspaceId string = '' + +@description('Optional. CosmosDB Location') +param cosmosLocation string = 'eastus2' + +@minLength(1) +@description('Optional. GPT model deployment type:') +@allowed([ + 'Standard' + 'GlobalStandard' +]) +param gptModelDeploymentType string = 'GlobalStandard' + +@minLength(1) +@description('Optional. Name of the GPT model to deploy:') +@allowed([ + 'gpt-4o-mini' +]) +param gptModelName string = 'gpt-4o-mini' + +@description('Optional. Version of the GPT model to deploy.') +param gptModelVersion string = '2024-07-18' + +@description('Optional. Version of the GPT model to deploy.') +param embeddingModelVersion string = '2' + +@description('Optional. API version for the Azure AI Services.') +param azureAIServicesAPIVersion string = '2025-04-01-preview' + +@minValue(10) +@description('Optional. Capacity of the GPT deployment:') +// You can increase this, but capacity is limited per model/region, so you will get errors if you go over +// https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits +param gptModelCapacity int = 200 + +@minLength(1) +@description('Optional. Name of the Text Embedding model to deploy:') +@allowed([ + 'text-embedding-ada-002' +]) +param embeddingModel string = 'text-embedding-ada-002' + +@minValue(10) +@description('Optional. Capacity of the Embedding Model deployment') +param embeddingDeploymentCapacity int = 80 + +//restricting to these regions because assistants api for gpt-4o-mini is available only in these regions +@allowed([ + 'australiaeast' + 'eastus' + 'eastus2' + 'francecentral' + 'japaneast' + 'swedencentral' + 'uksouth' + 'westus' + 'westus3' +]) +@metadata({ + azd: { + type: 'location' + usageName: [ + 'OpenAI.GlobalStandard.gpt-4o-mini,200' + 'OpenAI.GlobalStandard.text-embedding-ada-002,80' + ] + } +}) +@description('Required. Location for AI Foundry deployment. This is the location where the AI Foundry resources will be deployed.') +param azureAiServiceLocation string + +@allowed([ + 'australiaeast' + 'centralus' + 'eastasia' + 'eastus2' + 'japaneast' + 'northeurope' + 'southeastasia' + 'uksouth' +]) +@metadata({ azd: { type: 'location' } }) +@description('Required. Azure region for all services. Regions are restricted to guarantee compatibility with paired regions and replica locations for data redundancy and failover scenarios based on articles [Azure regions list](https://learn.microsoft.com/azure/reliability/regions-list) and [Azure Database for MySQL Flexible Server - Azure Regions](https://learn.microsoft.com/azure/mysql/flexible-server/overview#azure-regions).') +param location string +var solutionLocation = empty(location) ? resourceGroup().location : location + +@maxLength(5) +@description('Optional. A unique 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 solutionUniqueToken string = substring(uniqueString(subscription().id, resourceGroup().name, solutionName), 0, 5) + +var solutionSuffix= toLower(trim(replace( + replace( + replace(replace(replace(replace('${solutionName}${solutionUniqueToken}', '-', ''), '_', ''), '.', ''), '/', ''), + ' ', + '' + ), + '*', + '' +))) + +@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. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +@description('Optional. Enable redundancy for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') +param enableRedundancy bool = false + +//The following parameters are commented out because container registry details are handled dynamically +// @description('Optional. The Container Registry hostname where the docker images for the frontend are located.') +// param containerRegistryHostname string = 'bycwacontainerreg.azurecr.io' + +// @description('Optional. The Container Image Name to deploy on the webapp.') +// param containerImageName string = 'byc-wa-app' + +@description('Optional. The Container Image Tag to deploy on the webapp.') +param imageTag string = 'latest_waf_2025-09-18_794' + +@description('Optional. Resource ID of an existing Foundry project') +param existingFoundryProjectResourceId string = '' + +@description('Optional. Enable purge protection for the Key Vault') +param enablePurgeProtection bool = false + +// Load the abbrevations file required to name the azure resources. +//var abbrs = loadJsonContent('./abbreviations.json') + +var appEnvironment = 'Prod' +var azureSearchIndex = 'transcripts_index' +var azureSearchUseSemanticSearch = 'True' +var azureSearchSemanticSearchConfig = 'my-semantic-config' +var azureSearchTopK = '5' +var azureSearchContentColumns = 'content' +var azureSearchFilenameColumn = 'chunk_id' +var azureSearchTitleColumn = 'client_id' +var azureSearchUrlColumn = 'sourceurl' +var azureOpenAITemperature = '0' +var azureOpenAITopP = '1' +var azureOpenAIMaxTokens = '1000' +var azureOpenAIStopSequence = '\n' +var azureOpenAISystemMessage = '''You are a helpful Wealth Advisor assistant''' +var azureOpenAIStream = 'True' +var azureSearchQueryType = 'simple' +var azureSearchVectorFields = 'contentVector' +var azureSearchPermittedGroupsField = '' +var azureSearchStrictness = '3' +var azureSearchEnableInDomain = 'False' // Set to 'True' if you want to enable in-domain search +var azureCosmosDbEnableFeedback = 'True' +var useInternalStream = 'True' +var useAIProjectClientFlag = 'False' +var sqlServerFqdn = 'sql-${solutionSuffix}.database.windows.net' + +@description('Optional. Size of the Jumpbox Virtual Machine when created. Set to custom value if enablePrivateNetworking is true.') +param vmSize string? + +@description('Optional. Admin username for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') +@secure() +param vmAdminUsername string? + +@description('Optional. Admin password for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') +@secure() +param vmAdminPassword string? + +var functionAppSqlPrompt = '''Generate a valid T-SQL query to find {query} for tables and columns provided below: + 1. Table: Clients + Columns: ClientId, Client, Email, Occupation, MaritalStatus, Dependents + 2. Table: InvestmentGoals + Columns: ClientId, InvestmentGoal + 3. Table: Assets + Columns: ClientId, AssetDate, Investment, ROI, Revenue, AssetType + 4. Table: ClientSummaries + Columns: ClientId, ClientSummary + 5. Table: InvestmentGoalsDetails + Columns: ClientId, InvestmentGoal, TargetAmount, Contribution + 6. Table: Retirement + Columns: ClientId, StatusDate, RetirementGoalProgress, EducationGoalProgress + 7. Table: ClientMeetings + Columns: ClientId, ConversationId, Title, StartTime, EndTime, Advisor, ClientEmail + Always use the Investment column from the Assets table as the value. + Assets table has snapshots of values by date. Do not add numbers across different dates for total values. + Do not use client name in filters. + Do not include assets values unless asked for. + ALWAYS use ClientId = {clientid} in the query filter. + ALWAYS select Client Name (Column: Client) in the query. + Query filters are IMPORTANT. Add filters like AssetType, AssetDate, etc. if needed. + When answering scheduling or time-based meeting questions, always use the StartTime column from ClientMeetings table. Use correct logic to return the most recent past meeting (last/previous) or the nearest future meeting (next/upcoming), and ensure only StartTime column is used for meeting timing comparisons. + For asset values: If the question is about "asset value", "total asset value", "portfolio value", or "AUM" β†’ ALWAYS return the SUM of the latest investments (do not return individual rows). If the question is about "current asset value" or "current investment value" β†’ return all latest investments without SUM. + For trend queries: If the question contains "how did change", "over the last", "trend", or "progression" β†’ return time series data for the requested period with SUM for each time period and show chronological progression. + Only return the generated SQL query. Do not return anything else.''' + +var functionAppCallTranscriptSystemPrompt = '''You are an assistant who supports wealth advisors in preparing for client meetings. + You have access to the client’s past meeting call transcripts. + When answering questions, especially summary requests, provide a detailed and structured response that includes key topics, concerns, decisions, and trends. + If no data is available, state 'No relevant data found for previous meetings.''' + +var functionAppStreamTextSystemPrompt = '''The currently selected client's name is '{SelectedClientName}'. Treat any case-insensitive or partial mention as referring to this client. + If the user mentions no name, assume they are asking about '{SelectedClientName}'. + If the user references a name that clearly differs from '{SelectedClientName}' or comparing with other clients, respond only with: 'Please only ask questions about the selected client or select another client.' Otherwise, provide thorough answers for every question using only data from SQL or call transcripts.' + If no data is found, respond with 'No data found for that client.' Remove any client identifiers from the final response. + Always send clientId as '{client_id}'.''' + +// 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] + +@description('Optional. The tags to apply to all deployed Azure resources.') +param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} + +// Region pairs list based on article in [Azure Database for MySQL Flexible Server - Azure Regions](https://learn.microsoft.com/azure/mysql/flexible-server/overview#azure-regions) for supported high availability regions for CosmosDB. +var cosmosDbZoneRedundantHaRegionPairs = { + australiaeast: 'uksouth' //'southeastasia' + centralus: 'eastus2' + eastasia: 'southeastasia' + eastus: 'centralus' + eastus2: 'centralus' + japaneast: 'australiaeast' + northeurope: 'westeurope' + southeastasia: 'eastasia' + uksouth: 'westeurope' + westeurope: 'northeurope' +} + +var allTags = union( + { + 'azd-env-name': solutionName + }, + tags +) + +// Paired location calculated based on 'location' parameter. This location will be used by applicable resources if `enableScalability` is set to `true` +var cosmosDbHaLocation = cosmosDbZoneRedundantHaRegionPairs[resourceGroup().location] + +// Extracts subscription, resource group, and workspace name from the resource ID when using an existing Log Analytics workspace +var useExistingLogAnalytics = !empty(existingLogAnalyticsWorkspaceId) + +var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics ? existingLogAnalyticsWorkspaceId : logAnalyticsWorkspace!.outputs.resourceId + +@description('Tag, Created by user name') +param createdBy string = contains(deployer(), 'userPrincipalName')? split(deployer().userPrincipalName, '@')[0]: deployer().objectId + +// ========== Resource Group Tag ========== // +resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { + name: 'default' + properties: { + tags: { + ...resourceGroup().tags + ...tags + TemplateName: 'Client Advisor- Developer Experience' + Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' + CreatedBy: createdBy + DeploymentName: deployment().name + } + } +} + +// ========== 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 && !useExistingLogAnalytics) { + name: take('avm.res.operational-insights.workspace.${logAnalyticsWorkspaceResourceName}', 64) + params: { + name: logAnalyticsWorkspaceResourceName + tags: tags + location: solutionLocation + enableTelemetry: enableTelemetry + skuName: 'PerGB2018' + dataRetention: 365 + features: { enableLogAccessUsingOnlyResourcePermissions: true } + diagnosticSettings: [{ useThisWorkspace: 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: tags + 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) + params: { + name: applicationInsightsResourceName + tags: tags + location: solutionLocation + enableTelemetry: enableTelemetry + retentionInDays: 365 + kind: 'web' + disableIpMasking: false + flowType: 'Bluefield' + // WAF aligned configuration for Monitoring + workspaceResourceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + } +} + +// ========== 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 userAssignedIdentity '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: tags + enableTelemetry: enableTelemetry + } +} + +// ========== SQL Operations User Assigned Identity ========== // +// Dedicated identity for backend SQL operations with limited permissions (db_datareader, db_datawriter) +var sqlUserAssignedIdentityResourceName = 'id-sql-${solutionSuffix}' +module sqlUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('avm.res.managed-identity.user-assigned-identity.${sqlUserAssignedIdentityResourceName}', 64) + params: { + name: sqlUserAssignedIdentityResourceName + location: solutionLocation + tags: tags + enableTelemetry: enableTelemetry + } +} + +// ========== Virtual Network and Networking Components ========== // + +// Virtual Network with NSGs and Subnets +module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { + name: take('module.virtualNetwork.${solutionSuffix}', 64) + params: { +name: 'vnet-${solutionSuffix}' + addressPrefixes: ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24) + location: solutionLocation + tags: allTags + logAnalyticsWorkspaceId: logAnalyticsWorkspaceResourceId + resourceSuffix: solutionSuffix + enableTelemetry: enableTelemetry + } +} +// Azure Bastion Host +var bastionHostName = 'bas-${solutionSuffix}' +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: solutionLocation + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + diagnosticSettings: [ + { + name: 'bastionDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceResourceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + } + ] + tags: tags + 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: solutionLocation + adminUsername: vmAdminUsername ?? 'JumpboxAdminUser' + adminPassword: vmAdminPassword ?? 'JumpboxAdminP@ssw0rd1234!' + tags: tags + 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: [ + { + name: 'jumpboxDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceResourceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + } + ] + enableTelemetry: enableTelemetry + } +} +// ========== Private DNS Zones ========== // +var privateDnsZones = [ + 'privatelink.cognitiveservices.azure.com' + 'privatelink.services.ai.azure.com' + 'privatelink.azurewebsites.net' + 'privatelink.blob.${environment().suffixes.storage}' + 'privatelink.queue.${environment().suffixes.storage}' + 'privatelink.file.${environment().suffixes.storage}' + 'privatelink.documents.azure.com' + 'privatelink.vaultcore.azure.net' + 'privatelink${environment().suffixes.sqlServerHostname}' + 'privatelink.search.windows.net' +] + +// DNS Zone Index Constants +var dnsZoneIndex = { + cognitiveServices: 0 + aiServices: 1 + appService: 2 + storageBlob: 3 + storageQueue: 4 + storageFile: 5 + cosmosDB: 6 + keyVault: 7 + sqlServer: 8 + searchService: 9 +} + +// List of DNS zone indices that correspond to AI-related services. +var aiRelatedDnsZoneIndices = [ + dnsZoneIndex.cognitiveServices + 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: 'avm.res.network.private-dns-zone.${split(zone, '.')[1]}' + params: { + name: zone + tags: tags + enableTelemetry: enableTelemetry + virtualNetworkLinks: [ + { + name: take('vnetlink-${virtualNetwork!.outputs.name}-${split(zone, '.')[1]}', 80) + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + } + ] + } + } +] + +// ==========Key Vault Module ========== // +var keyVaultName = 'kv-${solutionSuffix}' +module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { + name: take('avm.res.key-vault.vault.${keyVaultName}', 64) + params: { + name: keyVaultName + location: solutionLocation + tags: tags + sku: 'standard' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + networkAcls: { + defaultAction: 'Allow' + } + enableVaultForDeployment: true + enableVaultForDiskEncryption: true + enableVaultForTemplateDeployment: true + enableRbacAuthorization: true + enableSoftDelete: true + enablePurgeProtection: enablePurgeProtection + softDeleteRetentionInDays: 7 + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : [] + // WAF aligned configuration for Private Networking + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-${keyVaultName}' + customNetworkInterfaceName: 'nic-${keyVaultName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.keyVault]!.outputs.resourceId } + ] + } + service: 'vault' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + } + ] + : [] + // WAF aligned configuration for Role-based Access Control + roleAssignments: [ + { + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Key Vault Administrator' + } + { + principalId: sqlUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Key Vault Secrets User' + } + ] + secrets: [ + { + name: 'SQLDB-SERVER' + value: sqlServerFqdn + } + { + name: 'SQLDB-DATABASE' + value: sqlDbName + } + { + name: 'AZURE-OPENAI-PREVIEW-API-VERSION' + value: azureAIServicesAPIVersion + } + { + name: 'AZURE-OPENAI-ENDPOINT' + value: aiFoundryAiServices.outputs.endpoint + } + { + name: 'AZURE-OPENAI-EMBEDDING-MODEL' + value: embeddingModel + } + { + name: 'AZURE-SEARCH-INDEX' + value: azureSearchIndex + } + { + name: 'AZURE-SEARCH-ENDPOINT' + value: 'https://${aiSearchName}.search.windows.net' + } + { + name: 'AZURE-AI-AGENT-ENDPOINT' + value: aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + } + ] + enableTelemetry: enableTelemetry + } +} + +// ========== AI Foundry: AI Services ========== // +// WAF best practices for Open AI: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-openai +var useExistingAiFoundryAiProject = !empty(existingFoundryProjectResourceId) + +var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[2] + : subscription().id +var aiFoundryAiServicesResourceGroupName = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[4] + : 'rg-${solutionSuffix}' +var aiFoundryAiServicesResourceName = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[8] + : 'aif-${solutionSuffix}' +var aiFoundryAiProjectResourceName = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[10] + : 'proj-${solutionSuffix}' +// AI Project resource id: /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/ + +// NOTE: Required version 'Microsoft.CognitiveServices/accounts@2024-04-01-preview' not available in AVM +// var aiFoundryAiServicesResourceName = 'aif-${solutionSuffix}' +var aiFoundryAiServicesAiProjectResourceName = 'proj-${solutionSuffix}' +var aiFoundryAIservicesEnabled = true +var aiFoundryAiServicesModelDeployment = { + format: 'OpenAI' + name: gptModelName + version: gptModelVersion + sku: { + name: gptModelDeploymentType + capacity: gptModelCapacity + } + raiPolicyName: 'Microsoft.Default' +} + +var aiFoundryAiServicesEmbeddingModel = { + name: embeddingModel + version: embeddingModelVersion + sku: { + name: 'GlobalStandard' + capacity: embeddingDeploymentCapacity + } + raiPolicyName: 'Microsoft.Default' +} + +module aiFoundryAiServices 'modules/ai-services.bicep' = if (aiFoundryAIservicesEnabled) { + name: take('avm.res.cognitive-services.account.${aiFoundryAiServicesResourceName}', 64) + params: { + name: aiFoundryAiServicesResourceName + location: azureAiServiceLocation + tags: tags + existingFoundryProjectResourceId: existingFoundryProjectResourceId + projectName: aiFoundryAiServicesAiProjectResourceName + projectDescription: 'AI Foundry Project' + sku: 'S0' + kind: 'AIServices' + disableLocalAuth: true + customSubDomainName: aiFoundryAiServicesResourceName + apiProperties: { + //staticsEnabled: false + } + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + } + managedIdentities: { userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] } //To create accounts or projects, you must enable a managed identity on your resource + roleAssignments: [ + { + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' // Cognitive Services OpenAI User + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + ] + // WAF aligned configuration for Monitoring + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + privateEndpoints: (enablePrivateNetworking && empty(existingFoundryProjectResourceId)) + ? ([ + { + name: 'pep-${aiFoundryAiServicesResourceName}' + customNetworkInterfaceName: 'nic-${aiFoundryAiServicesResourceName}' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'ai-services-dns-zone-cognitiveservices' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cognitiveServices]!.outputs.resourceId + } + { + name: 'ai-services-dns-zone-aiservices' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.aiServices]!.outputs.resourceId + } + ] + } + } + ]) + : [] + deployments: [ + { + name: aiFoundryAiServicesModelDeployment.name + model: { + format: aiFoundryAiServicesModelDeployment.format + name: aiFoundryAiServicesModelDeployment.name + version: aiFoundryAiServicesModelDeployment.version + } + raiPolicyName: aiFoundryAiServicesModelDeployment.raiPolicyName + sku: { + name: aiFoundryAiServicesModelDeployment.sku.name + capacity: aiFoundryAiServicesModelDeployment.sku.capacity + } + } + { + name: aiFoundryAiServicesEmbeddingModel.name + model: { + format: 'OpenAI' + name: aiFoundryAiServicesEmbeddingModel.name + version: aiFoundryAiServicesEmbeddingModel.version + } + raiPolicyName: aiFoundryAiServicesEmbeddingModel.raiPolicyName + sku: { + name: aiFoundryAiServicesEmbeddingModel.sku.name + capacity: aiFoundryAiServicesEmbeddingModel.sku.capacity + } + } + ] + } +} + +//========== AVM WAF ========== // +//========== Cosmos DB module ========== // +var cosmosDbResourceName = 'cosmos-${solutionSuffix}' +var cosmosDbDatabaseName = 'db_conversation_history' +var collectionName = 'conversations' +module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { + name: take('avm.res.document-db.database-account.${cosmosDbResourceName}', 64) + params: { + // Required parameters + name: cosmosDbResourceName + location: cosmosLocation + tags: tags + enableTelemetry: enableTelemetry + sqlDatabases: [ + { + name: cosmosDbDatabaseName + containers: [ + { + name: collectionName + paths: [ + '/userId' + ] + } + ] + } + ] + dataPlaneRoleDefinitions: [ + { + // Cosmos DB Built-in Data Contributor: https://docs.azure.cn/en-us/cosmos-db/nosql/security/reference-data-plane-roles#cosmos-db-built-in-data-contributor + roleName: 'Cosmos DB SQL Data Contributor' + dataActions: [ + 'Microsoft.DocumentDB/databaseAccounts/readMetadata' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' + ] + assignments: [{ principalId: userAssignedIdentity.outputs.principalId }] + } + ] + // WAF aligned configuration for Monitoring + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + // WAF aligned configuration for Private Networking + 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.pepsSubnetResourceId + } + ] + : [] + // WAF aligned configuration for Redundancy + 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 + } + ] + } + dependsOn: [keyvault, avmStorageAccount] +} + +// ========== AVM WAF ========== // +// ========== Storage account module ========== // +var storageAccountName = 'st${solutionSuffix}' +module avmStorageAccount '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: tags + accessTier: 'Hot' + supportsHttpsTrafficOnly: true + roleAssignments: [ + { + principalId: userAssignedIdentity.outputs.principalId + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + principalType: 'ServicePrincipal' + } + ] + // WAF aligned networking + networkAcls: { + bypass: 'AzureServices' + defaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' + } + allowBlobPublicAccess: false + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + // Private endpoints for blob and queue + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-blob-${solutionSuffix}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-blob' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageBlob]!.outputs.resourceId + } + ] + } + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + service: 'blob' + } + { + name: 'pep-queue-${solutionSuffix}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-queue' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageQueue]!.outputs.resourceId + } + ] + } + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + service: 'queue' + } + ] + : [] + blobServices: { + corsRules: [] + deleteRetentionPolicyEnabled: false + containers: [ + { + name: 'data' + publicAccess: 'None' + denyEncryptionScopeOverride: false + defaultEncryptionScope: '$account-encryption-key' + } + ] + } + } + dependsOn: [keyvault] +} + +// working version of saving storage account secrets in key vault using AVM module +module saveStorageAccountSecretsInKeyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { + name: take('saveStorageAccountSecretsInKeyVault.${keyVaultName}', 64) + params: { + name: keyVaultName + enablePurgeProtection: enablePurgeProtection + enableVaultForDeployment: true + enableVaultForDiskEncryption: true + enableVaultForTemplateDeployment: true + enableRbacAuthorization: true + enableSoftDelete: true + softDeleteRetentionInDays: 7 + secrets: [ + { + name: 'ADLS-ACCOUNT-NAME' + value: storageAccountName + } + { + name: 'ADLS-ACCOUNT-CONTAINER' + value: 'data' + } + { + name: 'ADLS-ACCOUNT-KEY' + value: avmStorageAccount.outputs.primaryAccessKey + } + ] + } +} + +// ========== AVM WAF ========== // +// ========== SQL module ========== // +var sqlDbName = 'sqldb-${solutionSuffix}' +module sqlDBModule 'br/public:avm/res/sql/server:0.20.1' = { + name: take('avm.res.sql.server.${sqlDbName}', 64) + params: { + // Required parameters + name: 'sql-${solutionSuffix}' + // Non-required parameters + administrators: { + azureADOnlyAuthentication: true + login: userAssignedIdentity.outputs.name + principalType: 'Application' + sid: userAssignedIdentity.outputs.principalId + tenantId: subscription().tenantId + } + connectionPolicy: 'Redirect' + databases: [ + { + zoneRedundant: enableRedundancy + // When enableRedundancy is true (zoneRedundant=true), set availabilityZone to -1 + // to let Azure automatically manage zone placement across multiple zones. + // When enableRedundancy is false, also use -1 (no specific zone assignment). + availabilityZone: -1 + collation: 'SQL_Latin1_General_CP1_CI_AS' + diagnosticSettings: enableMonitoring + ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] + : null + licenseType: 'LicenseIncluded' + maxSizeBytes: 34359738368 + name: 'sqldb-${solutionSuffix}' + minCapacity: '1' + sku: { + name: 'GP_S_Gen5' + tier: 'GeneralPurpose' + family: 'Gen5' + capacity: 2 + } + } + ] + location: solutionLocation + managedIdentities: { + systemAssigned: true + userAssignedResourceIds: [ + userAssignedIdentity.outputs.resourceId + ] + } + primaryUserAssignedIdentityResourceId: userAssignedIdentity.outputs.resourceId + privateEndpoints: enablePrivateNetworking + ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.sqlServer]!.outputs.resourceId + } + ] + } + service: 'sqlServer' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + tags: tags + } + ] + : [] + firewallRules: (!enablePrivateNetworking) ? [ + { + endIpAddress: '255.255.255.255' + name: 'AllowSpecificRange' + startIpAddress: '0.0.0.0' + } + { + endIpAddress: '0.0.0.0' + name: 'AllowAllWindowsAzureIps' + startIpAddress: '0.0.0.0' + } + ] : [] + tags: tags + } +} + +// ========== Frontend server farm ========== // +// WAF best practices for Web Application Services: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps +// PSRule for Web Server Farm: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#app-service +var webServerFarmResourceName = 'asp-${solutionSuffix}' +module webServerFarm 'br/public:avm/res/web/serverfarm:0.5.0' = { + name: take('avm.res.web.serverfarm.${webServerFarmResourceName}', 64) + params: { + name: webServerFarmResourceName + tags: tags + enableTelemetry: enableTelemetry + location: solutionLocation + reserved: true + kind: 'linux' + // WAF aligned configuration for Monitoring + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + // WAF aligned configuration for Scalability + skuName: enableScalability || enableRedundancy ? 'P1v3' : 'B3' + // skuCapacity: enableScalability ? 3 : 1 + skuCapacity: 1 // skuCapacity set to 1 (not 3) due to multiple agents created per type during WAF deployment + // WAF aligned configuration for Redundancy + zoneRedundant: enableRedundancy ? true : false + } +} + +// ========== Frontend web site ========== // +// WAF best practices for web app service: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps +// PSRule for Web Server Farm: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#app-service + +//NOTE: AVM module adds 1 MB of overhead to the template. Keeping vanilla resource to save template size. +var webSiteResourceName = 'app-${solutionSuffix}' +module webSite 'modules/web-sites.bicep' = { + name: take('module.web-sites.${webSiteResourceName}', 64) + params: { + name: webSiteResourceName + tags: union(tags, { 'azd-service-name': 'webapp' }) + location: solutionLocation + managedIdentities: { userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId, sqlUserAssignedIdentity!.outputs.resourceId] } + kind: 'app,linux' + serverFarmResourceId: webServerFarm.?outputs.resourceId + siteConfig: { + linuxFxVersion: 'PYTHON|3.11' + minTlsVersion: '1.2' + appCommandLine: 'python -m uvicorn app:app --host 0.0.0.0 --port 8000' + } + configs: [ + { + name: 'appsettings' + properties: { + APP_ENV: appEnvironment + APPINSIGHTS_INSTRUMENTATIONKEY: enableMonitoring ? applicationInsights!.outputs.instrumentationKey : '' + APPLICATIONINSIGHTS_CONNECTION_STRING: enableMonitoring ? applicationInsights!.outputs.connectionString : '' + WEBSITES_PORT: '8000' + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + AZURE_SEARCH_SERVICE: aiSearchName + AZURE_SEARCH_INDEX: azureSearchIndex + AZURE_SEARCH_USE_SEMANTIC_SEARCH: azureSearchUseSemanticSearch + AZURE_SEARCH_SEMANTIC_SEARCH_CONFIG: azureSearchSemanticSearchConfig + AZURE_SEARCH_TOP_K: azureSearchTopK + AZURE_SEARCH_ENABLE_IN_DOMAIN: azureSearchEnableInDomain + AZURE_SEARCH_CONTENT_COLUMNS: azureSearchContentColumns + AZURE_SEARCH_FILENAME_COLUMN: azureSearchFilenameColumn + AZURE_SEARCH_TITLE_COLUMN: azureSearchTitleColumn + AZURE_SEARCH_URL_COLUMN: azureSearchUrlColumn + AZURE_OPENAI_RESOURCE: aiFoundryAiServices.outputs.name + AZURE_OPENAI_MODEL: gptModelName + AZURE_OPENAI_ENDPOINT: aiFoundryAiServices.outputs.endpoint + AZURE_OPENAI_TEMPERATURE: azureOpenAITemperature + AZURE_OPENAI_TOP_P: azureOpenAITopP + AZURE_OPENAI_MAX_TOKENS: azureOpenAIMaxTokens + AZURE_OPENAI_STOP_SEQUENCE: azureOpenAIStopSequence + AZURE_OPENAI_SYSTEM_MESSAGE: azureOpenAISystemMessage + AZURE_OPENAI_PREVIEW_API_VERSION: azureAIServicesAPIVersion + AZURE_OPENAI_STREAM: azureOpenAIStream + AZURE_SEARCH_QUERY_TYPE: azureSearchQueryType + AZURE_SEARCH_VECTOR_COLUMNS: azureSearchVectorFields + AZURE_SEARCH_PERMITTED_GROUPS_COLUMN: azureSearchPermittedGroupsField + AZURE_SEARCH_STRICTNESS: azureSearchStrictness + AZURE_OPENAI_EMBEDDING_NAME: embeddingModel + AZURE_OPENAI_EMBEDDING_ENDPOINT : aiFoundryAiServices.outputs.endpoint + SQLDB_SERVER: sqlServerFqdn + SQLDB_DATABASE: sqlDbName + USE_INTERNAL_STREAM: useInternalStream + AZURE_COSMOSDB_ACCOUNT: cosmosDb.outputs.name + AZURE_COSMOSDB_CONVERSATIONS_CONTAINER: collectionName + AZURE_COSMOSDB_DATABASE: cosmosDbDatabaseName + AZURE_COSMOSDB_ENABLE_FEEDBACK: azureCosmosDbEnableFeedback + SQLDB_USER_MID: sqlUserAssignedIdentity.outputs.clientId + AZURE_AI_SEARCH_ENDPOINT: 'https://${aiSearchName}.search.windows.net' + AZURE_SQL_SYSTEM_PROMPT: functionAppSqlPrompt + AZURE_CALL_TRANSCRIPT_SYSTEM_PROMPT: functionAppCallTranscriptSystemPrompt + AZURE_OPENAI_STREAM_TEXT_SYSTEM_PROMPT: functionAppStreamTextSystemPrompt + USE_AI_PROJECT_CLIENT: useAIProjectClientFlag + AZURE_AI_AGENT_ENDPOINT: aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME: gptModelName + AZURE_AI_AGENT_API_VERSION: azureAIServicesAPIVersion + AZURE_SEARCH_CONNECTION_NAME: aiSearchName + AZURE_CLIENT_ID: userAssignedIdentity.outputs.clientId + } + // WAF aligned configuration for Monitoring + applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null + } + ] + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + // WAF aligned configuration for Private Networking + vnetRouteAllEnabled: enablePrivateNetworking ? true : false + vnetImagePullEnabled: enablePrivateNetworking ? true : false + virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webSubnetResourceId : null + publicNetworkAccess: 'Enabled' + } +} + +resource existingAiFoundryAiServices 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (useExistingAiFoundryAiProject) { + name: aiFoundryAiServicesResourceName + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) +} + +resource existingAiFoundryAiServicesProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = if (useExistingAiFoundryAiProject) { + name: aiFoundryAiProjectResourceName + parent: existingAiFoundryAiServices +} + + +var aiSearchName = 'srch-${solutionSuffix}' +module searchService 'br/public:avm/res/search/search-service:0.11.1' = { + name: take('avm.res.search.search-service.${aiSearchName}', 64) + params: { + // Required parameters + name: aiSearchName + // Authentication options + authOptions: { + aadOrApiKey: { + aadAuthFailureMode: 'http401WithBearerChallenge' + } + } + diagnosticSettings: enableMonitoring ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] : null + disableLocalAuth: false + hostingMode: 'default' + managedIdentities: { + systemAssigned: true + } + networkRuleSet: { + bypass: 'AzureServices' + ipRules: [] + } + roleAssignments: [ + { + roleDefinitionIdOrName: '1407120a-92aa-4202-b7e9-c0e197c71c8f' // Search Index Data Reader + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' // Search Service Contributor + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '1407120a-92aa-4202-b7e9-c0e197c71c8f' // Search Index Data Reader + principalId: !useExistingAiFoundryAiProject ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId : existingAiFoundryAiServicesProject!.identity.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' // Search Service Contributor + principalId: !useExistingAiFoundryAiProject ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId : existingAiFoundryAiServicesProject!.identity.principalId + principalType: 'ServicePrincipal' + } + ] + partitionCount: 1 + replicaCount: 1 + sku: 'standard' + semanticSearch: 'free' + // Use the deployment tags provided to the template + tags: tags + publicNetworkAccess: 'Enabled' + privateEndpoints: false + ? [ + { + name: 'pep-${aiSearchName}' + customNetworkInterfaceName: 'nic-${aiSearchName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.searchService]!.outputs.resourceId } + ] + } + service: 'searchService' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + } + ] + : [] + } +} + +resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (!useExistingAiFoundryAiProject) { + name: '${aiFoundryAiServicesResourceName}/${aiFoundryAiServicesAiProjectResourceName}/${aiSearchName}' + properties: { + category: 'CognitiveSearch' + target: 'https://${aiSearchName}.search.windows.net' + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiType: 'Azure' + ResourceId: searchService.outputs.resourceId + location: searchService.outputs.location + } + } +} + +module existing_AIProject_SearchConnectionModule 'modules/deploy_aifp_aisearch_connection.bicep' = if (useExistingAiFoundryAiProject) { + name: 'aiProjectSearchConnectionDeployment' + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) + params: { + existingAIProjectName: aiFoundryAiProjectResourceName + existingAIFoundryName: aiFoundryAiServicesResourceName + aiSearchName: aiSearchName + aiSearchResourceId: searchService.outputs.resourceId + aiSearchLocation: searchService.outputs.location + aiSearchConnectionName: aiSearchName + } +} + +// ========== Search Service to AI Services Role Assignment ========== // +resource searchServiceToAiServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!useExistingAiFoundryAiProject) { + name: guid(aiSearchName, '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd', aiFoundryAiServicesResourceName) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Cognitive Services OpenAI User + principalId: searchService.outputs.systemAssignedMIPrincipalId! + principalType: 'ServicePrincipal' + } +} + +// Role assignment for existing AI Services scenario +module searchServiceToExistingAiServicesRoleAssignment 'modules/role-assignment.bicep' = if (useExistingAiFoundryAiProject) { + name: 'searchToExistingAiServices-roleAssignment' + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) + params: { + principalId: searchService.outputs.systemAssignedMIPrincipalId! + roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' // Cognitive Services OpenAI User + targetResourceName: aiFoundryAiServices.outputs.name + } +} + +// ========== Outputs ========== // +@description('URL of the deployed web application.') +output WEB_APP_URL string = 'https://${webSite.outputs.name}.azurewebsites.net' + +@description('Name of the storage account.') +output STORAGE_ACCOUNT_NAME string = avmStorageAccount.outputs.name + +@description('Name of the storage container.') +output STORAGE_CONTAINER_NAME string = 'data' + +@description('Name of the Key Vault.') +output KEY_VAULT_NAME string = keyvault.outputs.name + +@description('Name of the Cosmos DB account.') +output COSMOSDB_ACCOUNT_NAME string = cosmosDb.outputs.name + +@description('Name of the resource group.') +output RESOURCE_GROUP_NAME string = resourceGroup().name + +@description('The resource ID of the AI Foundry instance.') +output AI_FOUNDRY_RESOURCE_ID string = aiFoundryAiServices.outputs.resourceId + +@description('Name of the SQL Database server.') +output SQLDB_SERVER_NAME string = sqlDBModule.outputs.name + +@description('Name of the SQL Database.') +output SQLDB_DATABASE string = sqlDbName + +@description('Name of the managed identity used by the web app.') +output MANAGEDIDENTITY_WEBAPP_NAME string = userAssignedIdentity.outputs.name + +@description('Client ID of the managed identity used by the web app.') +output MANAGEDIDENTITY_WEBAPP_CLIENTID string = userAssignedIdentity.outputs.clientId + +@description('Name of the managed identity used for SQL database operations.') +output MANAGEDIDENTITY_SQL_NAME string = sqlUserAssignedIdentity.outputs.name + +@description('Client ID of the managed identity used for SQL database operations.') +output MANAGEDIDENTITY_SQL_CLIENTID string = sqlUserAssignedIdentity.outputs.clientId +@description('Name of the AI Search service.') +output AI_SEARCH_SERVICE_NAME string = aiSearchName + +@description('Name of the deployed web application.') +output WEB_APP_NAME string = webSite.outputs.name +@description('Specifies the current application environment.') +output APP_ENV string = appEnvironment + +@description('The Application Insights instrumentation key.') +output APPINSIGHTS_INSTRUMENTATIONKEY string = enableMonitoring ? applicationInsights!.outputs.instrumentationKey : '' + +@description('The Application Insights connection string.') +output APPLICATIONINSIGHTS_CONNECTION_STRING string = enableMonitoring + ? applicationInsights!.outputs.connectionString + : '' + + @description('The API version used for the Azure AI Agent service.') +output AZURE_AI_AGENT_API_VERSION string = azureAIServicesAPIVersion + +@description('The endpoint URL of the Azure AI Agent project.') +output AZURE_AI_AGENT_ENDPOINT string = aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + +@description('The deployment name of the GPT model for the Azure AI Agent.') +output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = gptModelName + +@description('The endpoint URL of the Azure AI Search service.') +output AZURE_AI_SEARCH_ENDPOINT string = 'https://${aiSearchName}.search.windows.net' + +@description('The system prompt used for call transcript processing in Azure Functions.') +output AZURE_CALL_TRANSCRIPT_SYSTEM_PROMPT string = functionAppCallTranscriptSystemPrompt + +@description('The name of the Azure Cosmos DB account.') +output AZURE_COSMOSDB_ACCOUNT string = cosmosDb.outputs.name + +@description('The name of the Azure Cosmos DB container for storing conversations.') +output AZURE_COSMOSDB_CONVERSATIONS_CONTAINER string = collectionName + +@description('The name of the Azure Cosmos DB database.') +output AZURE_COSMOSDB_DATABASE string = cosmosDbDatabaseName + +@description('Indicates whether feedback is enabled in Azure Cosmos DB.') +output AZURE_COSMOSDB_ENABLE_FEEDBACK string = azureCosmosDbEnableFeedback + +@description('The endpoint URL for the Azure OpenAI Embedding model.') +output AZURE_OPENAI_EMBEDDING_ENDPOINT string = aiFoundryAiServices.outputs.endpoint + +@description('The name of the Azure OpenAI Embedding model.') +output AZURE_OPENAI_EMBEDDING_NAME string = embeddingModel + +@description('The endpoint URL for the Azure OpenAI service.') +output AZURE_OPENAI_ENDPOINT string = aiFoundryAiServices.outputs.endpoint + +@description('The maximum number of tokens for Azure OpenAI responses.') +output AZURE_OPENAI_MAX_TOKENS string = azureOpenAIMaxTokens + +@description('The name of the Azure OpenAI GPT model.') +output AZURE_OPENAI_MODEL string = gptModelName + +@description('The preview API version for Azure OpenAI.') +output AZURE_OPENAI_PREVIEW_API_VERSION string = azureAIServicesAPIVersion + +@description('The Azure OpenAI resource name.') +output AZURE_OPENAI_RESOURCE string = aiFoundryAiServices.outputs.name + +@description('The stop sequence(s) for Azure OpenAI responses.') +output AZURE_OPENAI_STOP_SEQUENCE string = azureOpenAIStopSequence + +@description('Indicates whether streaming is enabled for Azure OpenAI responses.') +output AZURE_OPENAI_STREAM string = azureOpenAIStream + +@description('The system prompt for streaming text responses in Azure Functions.') +output AZURE_OPENAI_STREAM_TEXT_SYSTEM_PROMPT string = functionAppStreamTextSystemPrompt + +@description('The system message for Azure OpenAI requests.') +output AZURE_OPENAI_SYSTEM_MESSAGE string = azureOpenAISystemMessage + +@description('The temperature setting for Azure OpenAI responses.') +output AZURE_OPENAI_TEMPERATURE string = azureOpenAITemperature + +@description('The Top-P setting for Azure OpenAI responses.') +output AZURE_OPENAI_TOP_P string = azureOpenAITopP + +@description('The name of the Azure AI Search connection.') +output AZURE_SEARCH_CONNECTION_NAME string = aiSearchName + +@description('The columns in Azure AI Search that contain content.') +output AZURE_SEARCH_CONTENT_COLUMNS string = azureSearchContentColumns + +@description('Indicates whether in-domain filtering is enabled for Azure AI Search.') +output AZURE_SEARCH_ENABLE_IN_DOMAIN string = azureSearchEnableInDomain + +@description('The filename column used in Azure AI Search.') +output AZURE_SEARCH_FILENAME_COLUMN string = azureSearchFilenameColumn + +@description('The name of the Azure AI Search index.') +output AZURE_SEARCH_INDEX string = azureSearchIndex + +@description('The permitted groups field used in Azure AI Search.') +output AZURE_SEARCH_PERMITTED_GROUPS_COLUMN string = azureSearchPermittedGroupsField + +@description('The query type for Azure AI Search.') +output AZURE_SEARCH_QUERY_TYPE string = azureSearchQueryType + +@description('The semantic search configuration name in Azure AI Search.') +output AZURE_SEARCH_SEMANTIC_SEARCH_CONFIG string = azureSearchSemanticSearchConfig + +@description('The name of the Azure AI Search service.') +output AZURE_SEARCH_SERVICE string = aiSearchName + +@description('The strictness setting for Azure AI Search semantic ranking.') +output AZURE_SEARCH_STRICTNESS string = azureSearchStrictness + +@description('The title column used in Azure AI Search.') +output AZURE_SEARCH_TITLE_COLUMN string = azureSearchTitleColumn + +@description('The number of top results (K) to return from Azure AI Search.') +output AZURE_SEARCH_TOP_K string = azureSearchTopK + +@description('The URL column used in Azure AI Search.') +output AZURE_SEARCH_URL_COLUMN string = azureSearchUrlColumn + +@description('Indicates whether semantic search is used in Azure AI Search.') +output AZURE_SEARCH_USE_SEMANTIC_SEARCH string = azureSearchUseSemanticSearch + +@description('The vector fields used in Azure AI Search.') +output AZURE_SEARCH_VECTOR_COLUMNS string = azureSearchVectorFields + +@description('The system prompt for SQL queries in Azure Functions.') +output AZURE_SQL_SYSTEM_PROMPT string = functionAppSqlPrompt + +@description('The fully qualified domain name (FQDN) of the Azure SQL Server.') +output SQLDB_SERVER string = sqlServerFqdn + +@description('The client ID of the managed identity for the web application.') +output SQLDB_USER_MID string = userAssignedIdentity.outputs.clientId + +@description('Indicates whether the AI Project Client should be used.') +output USE_AI_PROJECT_CLIENT string = useAIProjectClientFlag + +@description('Indicates whether the internal stream should be used.') +output USE_INTERNAL_STREAM string = useInternalStream + +@description('The Azure Subscription ID where the resources are deployed.') +output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId diff --git a/infra/modules/dependencies.bicep b/infra/modules/dependencies.bicep index 9c9efb278..587f45126 100644 --- a/infra/modules/dependencies.bicep +++ b/infra/modules/dependencies.bicep @@ -129,14 +129,6 @@ var builtInRoleNames = { 'Microsoft.Authorization/roleDefinitions', '3b20f47b-3825-43cb-8114-4bd2201156a8' ) - 'Cognitive Services OpenAI Contributor': subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'a001fd3d-188f-4b5d-821b-7da978bf7442' - ) - 'Cognitive Services OpenAI User': subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' - ) 'Cognitive Services QnA Maker Editor': subscriptionResourceId( 'Microsoft.Authorization/roleDefinitions', 'f4cc2bf9-21be-47a1-bdf1-5c5804381025' diff --git a/infra/scripts/index_scripts/assign_sql_roles.py b/infra/scripts/index_scripts/assign_sql_roles.py new file mode 100644 index 000000000..493f7a50c --- /dev/null +++ b/infra/scripts/index_scripts/assign_sql_roles.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Assign SQL roles for Azure AD principals (managed identities/service principals) using Azure AD token auth. + +Simplified: requires --server and --database provided explicitly (no Key Vault lookup). +Roles JSON format (single arg): +[ + {"clientId":"", "displayName":"Name", "role":"db_datareader"}, + {"clientId":"", "displayName":"Name", "role":"db_datawriter"} +] + +Uses pyodbc + azure-identity (AzureCliCredential).""" +import argparse +import json +import struct +import sys +from typing import List, Dict + +import pyodbc +from azure.identity import AzureCliCredential + +SQL_COPT_SS_ACCESS_TOKEN = 1256 # msodbcsql.h constant + + +def build_sql(role_items: List[Dict]) -> str: + statements = [] + for idx, item in enumerate(role_items, start=1): + client_id = item["clientId"].strip() + display_name = item["displayName"].replace("'", "''") + role = item["role"].strip() + # Construct dynamic SQL similar to prior bash script + stmt = f""" +DECLARE @username{idx} nvarchar(max) = N'{display_name}'; +DECLARE @clientId{idx} uniqueidentifier = '{client_id}'; +DECLARE @sid{idx} NVARCHAR(max) = CONVERT(VARCHAR(max), CONVERT(VARBINARY(16), @clientId{idx}), 1); +DECLARE @cmd{idx} NVARCHAR(max) = N'CREATE USER [' + @username{idx} + '] WITH SID = ' + @sid{idx} + ', TYPE = E;'; +IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = @username{idx}) +BEGIN + EXEC(@cmd{idx}) +END +EXEC sp_addrolemember '{role}', @username{idx}; +""".strip() + statements.append(stmt) + return "\n".join(statements) + + +def connect_with_token(server: str, database: str, credential: AzureCliCredential): + token_bytes = credential.get_token("https://database.windows.net/.default").token.encode("utf-16-le") + token_struct = struct.pack(f"