diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index 915411a8a..381e615bf 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -1,8 +1,5 @@ name: Azure Template Validation on: - push: - branches: - - main workflow_dispatch: permissions: @@ -26,6 +23,7 @@ jobs: AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AZURE_DEV_COLLECT_TELEMETRY: ${{ vars.AZURE_DEV_COLLECT_TELEMETRY }} - name: print result run: cat ${{ steps.validation.outputs.resultFile }} diff --git a/.github/workflows/deploy-waf.yml b/.github/workflows/deploy-waf.yml index e2786216e..2b85b5c56 100644 --- a/.github/workflows/deploy-waf.yml +++ b/.github/workflows/deploy-waf.yml @@ -105,6 +105,9 @@ jobs: id: deploy run: | set -e + # Generate current timestamp in desired format: YYYY-MM-DDTHH:MM:SS.SSSSSSSZ + current_date=$(date -u +"%Y-%m-%dT%H:%M:%S.%7NZ") + az deployment group create \ --resource-group ${{ env.RESOURCE_GROUP_NAME }} \ --template-file infra/main.bicep \ @@ -118,6 +121,7 @@ jobs: enablePrivateNetworking=true \ enableScalability=true \ createdBy="Pipeline" \ + tags="{'SecurityControl':'Ignore','Purpose':'Deploying and Cleaning Up Resources for Validation','CreatedDate':'$current_date'}" - name: Send Notification on Failure diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 11f4dd947..3afff0d53 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -125,6 +125,9 @@ jobs: IMAGE_TAG="latest" fi + # Generate current timestamp in desired format: YYYY-MM-DDTHH:MM:SS.SSSSSSSZ + current_date=$(date -u +"%Y-%m-%dT%H:%M:%S.%7NZ") + az deployment group create \ --resource-group ${{ env.RESOURCE_GROUP_NAME }} \ --template-file infra/main.bicep \ @@ -138,6 +141,7 @@ jobs: azureAiServiceLocation='${{ env.AZURE_LOCATION }}' \ gptModelCapacity=150 \ createdBy="Pipeline" \ + tags="{'SecurityControl':'Ignore','Purpose':'Deploying and Cleaning Up Resources for Validation','CreatedDate':'$current_date'}" \ --output json - name: Extract Web App and API App URLs diff --git a/README.md b/README.md index 84a58ad48..391a806ca 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ Quick deploy ### How to install or deploy Follow the quick deploy steps on the deployment guide to deploy this solution to your own Azure subscription. +> **Note:** This solution accelerator requires **Azure Developer CLI (azd) version 1.18.0 or higher**. Please ensure you have the latest version installed before proceeding with deployment. [Download azd here](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd). + [Click here to launch the deployment guide](./docs/DeploymentGuide.md)

diff --git a/azure.yaml b/azure.yaml index 26522f5db..ccb0828c7 100644 --- a/azure.yaml +++ b/azure.yaml @@ -3,4 +3,5 @@ name: multi-agent-custom-automation-engine-solution-accelerator metadata: template: multi-agent-custom-automation-engine-solution-accelerator@1.0 requiredVersions: - azd: ">=1.15.0 !=1.17.1" \ No newline at end of file + azd: '>= 1.18.0' + bicep: '>= 0.33.0' \ No newline at end of file diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 9c8de2184..8c5479c71 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -160,7 +160,7 @@ If you're not using one of the above options for opening the project, then you'l 1. Make sure the following tools are installed: - [PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.5) (v7.0+) - available for Windows, macOS, and Linux. - - [Azure Developer CLI (azd)](https://aka.ms/install-azd) (v1.15.0+) - version + - [Azure Developer CLI (azd)](https://aka.ms/install-azd) (v1.18.0+) - version - [Python 3.9+](https://www.python.org/downloads/) - [Docker Desktop](https://www.docker.com/products/docker-desktop/) - [Git](https://git-scm.com/downloads) @@ -249,6 +249,7 @@ Once you've opened the project in [Codespaces](#github-codespaces), [Dev Contain ```shell azd up ``` + > **Note:** This solution accelerator requires **Azure Developer CLI (azd) version 1.18.0 or higher**. Please ensure you have the latest version installed before proceeding with deployment. [Download azd here](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd). 3. Provide an `azd` environment name (e.g., "macaeapp"). 4. Select a subscription from your Azure account and choose a location that has quota for all the resources. @@ -261,6 +262,7 @@ Once you've opened the project in [Codespaces](#github-codespaces), [Dev Contain 6. When Deployment is complete, follow steps in [Set Up Authentication in Azure App Service](../docs/azure_app_service_auth_setup.md) to add app authentication to your web app running on Azure App Service 7. If you are done trying out the application, you can delete the resources by running `azd down`. + > **Note:** If you deployed with `enableRedundancy=true` and Log Analytics workspace replication is enabled, you must first disable replication before running `azd down` else resource group delete will fail. Follow the steps in [Handling Log Analytics Workspace Deletion with Replication Enabled](./LogAnalyticsReplicationDisable.md), wait until replication returns `false`, then run `azd down`. ### πŸ› οΈ Troubleshooting diff --git a/docs/LogAnalyticsReplicationDisable.md b/docs/LogAnalyticsReplicationDisable.md new file mode 100644 index 000000000..f4379a84a --- /dev/null +++ b/docs/LogAnalyticsReplicationDisable.md @@ -0,0 +1,28 @@ +# πŸ›  Handling Log Analytics Workspace Deletion with Replication Enabled + +If redundancy (replication) is enabled for your Log Analytics workspace, you must disable it before deleting the workspace or resource group. Otherwise, deletion will fail. + +## βœ… Steps to Disable Replication Before Deletion +Run the following Azure CLI command. Note: This operation may take about 5 minutes to complete. + +```bash +az resource update --ids "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{logAnalyticsName}" --set properties.replication.enabled=false +``` + +Replace: +- `{subscriptionId}` β†’ Your Azure subscription ID +- `{resourceGroupName}` β†’ The name of your resource group +- `{logAnalyticsName}` β†’ The name of your Log Analytics workspace + +Optional: Verify replication disabled (should output `false`): +```bash +az resource show --ids "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{logAnalyticsName}" --query properties.replication.enabled -o tsv +``` + +## βœ… After Disabling Replication +You can safely delete: +- The Log Analytics workspace (manual) +- The resource group (manual), or +- All provisioned resources via `azd down` + +Return to: [Deployment Guide](./DeploymentGuide.md) diff --git a/docs/TroubleShootingSteps.md b/docs/TroubleShootingSteps.md index 01f39d44e..7a52ca3f5 100644 --- a/docs/TroubleShootingSteps.md +++ b/docs/TroubleShootingSteps.md @@ -55,7 +55,7 @@ Before deploying the resources, you may need to enable the **Bring Your Own Publ ## Option 1 ### Steps -1. Go to [Azure Portal](https:/portal.azure.com/#home). +1. Go to [Azure Portal](https://portal.azure.com/#home). 2. Click on the **"Resource groups"** option available on the Azure portal home page. ![alt text](../docs/images/AzureHomePage.png) diff --git a/infra/main.bicep b/infra/main.bicep index dd7a907aa..bc0b42f54 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -36,7 +36,7 @@ param location string azd: { type: 'location' usageName: [ - 'OpenAI.GlobalStandard.gpt-4o, 150' + 'OpenAI.GlobalStandard.gpt-4o, 80' ] } }) @@ -61,8 +61,9 @@ param azureopenaiVersion string = '2025-01-01-preview' @description('Optional. GPT model deployment type. Defaults to GlobalStandard.') param gptModelDeploymentType string = 'GlobalStandard' -@description('Optional. AI model deployment token capacity. Defaults to 150 for optimal performance.') -param gptModelCapacity int = 150 +@description('Optional. AI model deployment token capacity (thousands of tokens per minute). Reduce if provisioning fails with InsufficientQuota. Total must not exceed your subscription GlobalStandard quota.') +@minValue(1) +param gptModelCapacity int = 80 @description('Optional. The tags to apply to all deployed Azure resources.') param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} @@ -80,11 +81,11 @@ param enableRedundancy bool = false param enablePrivateNetworking bool = false @secure() -@description('Optional. The user name for the administrator account of the virtual machine. Allows to customize credentials if `enablePrivateNetworking` is set to true.') +@description('Optional. The admin username for the jumpbox VM (used when `enablePrivateNetworking` is true). Provide via AZURE_ENV_VM_ADMIN_USERNAME environment variable for predictable access. Defaults to a random value if not set.') param virtualMachineAdminUsername string = take(newGuid(), 20) -@description('Optional. The password for the administrator account of the virtual machine. Allows to customize credentials if `enablePrivateNetworking` is set to true.') @secure() +@description('Optional. The admin password for the jumpbox VM (used when `enablePrivateNetworking` is true). Must meet Azure complexity requirements (12+ chars, uppercase, lowercase, number, special char). Provide via AZURE_ENV_VM_ADMIN_PASSWORD environment variable for predictable access. Defaults to a random value if not set.') param virtualMachineAdminPassword string = newGuid() @description('Optional. The Container Registry hostname where the docker images for the backend are located.') @@ -170,8 +171,9 @@ var allTags = union( }, tags ) -@description('Optional created by user name') -param createdBy string = empty(deployer().userPrincipalName) ? '' : split(deployer().userPrincipalName, '@')[0] +@description('Tag, Created by user name') +param createdBy string = contains(deployer(), 'userPrincipalName')? split(deployer().userPrincipalName, '@')[0]: deployer().objectId + resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { name: 'default' @@ -179,6 +181,7 @@ resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { tags: { ...allTags TemplateName: 'MACAE' + Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' CreatedBy: createdBy } } @@ -326,339 +329,17 @@ module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-id } } -// ========== Network Security Groups ========== // -// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network -// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking -var networkSecurityGroupBackendResourceName = 'nsg-${solutionSuffix}-backend' -module networkSecurityGroupBackend 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.${networkSecurityGroupBackendResourceName}', 64) - params: { - name: networkSecurityGroupBackendResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'deny-hop-outbound' - properties: { - access: 'Deny' - destinationAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - direction: 'Outbound' - priority: 200 - protocol: 'Tcp' - sourceAddressPrefix: 'VirtualNetwork' - sourcePortRange: '*' - } - } - ] - } -} - -var networkSecurityGroupBastionResourceName = 'nsg-${solutionSuffix}-bastion' -module networkSecurityGroupBastion 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.${networkSecurityGroupBastionResourceName}', 64) - params: { - name: networkSecurityGroupBastionResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'AllowHttpsInBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: 'Internet' - destinationPortRange: '443' - destinationAddressPrefix: '*' - access: 'Allow' - priority: 100 - direction: 'Inbound' - } - } - { - name: 'AllowGatewayManagerInBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: 'GatewayManager' - destinationPortRange: '443' - destinationAddressPrefix: '*' - access: 'Allow' - priority: 110 - direction: 'Inbound' - } - } - { - name: 'AllowLoadBalancerInBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: 'AzureLoadBalancer' - destinationPortRange: '443' - destinationAddressPrefix: '*' - access: 'Allow' - priority: 120 - direction: 'Inbound' - } - } - { - name: 'AllowBastionHostCommunicationInBound' - properties: { - protocol: '*' - sourcePortRange: '*' - sourceAddressPrefix: 'VirtualNetwork' - destinationPortRanges: [ - '8080' - '5701' - ] - destinationAddressPrefix: 'VirtualNetwork' - access: 'Allow' - priority: 130 - direction: 'Inbound' - } - } - { - name: 'DenyAllInBound' - properties: { - protocol: '*' - sourcePortRange: '*' - sourceAddressPrefix: '*' - destinationPortRange: '*' - destinationAddressPrefix: '*' - access: 'Deny' - priority: 1000 - direction: 'Inbound' - } - } - { - name: 'AllowSshRdpOutBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - destinationAddressPrefix: 'VirtualNetwork' - access: 'Allow' - priority: 100 - direction: 'Outbound' - } - } - { - name: 'AllowAzureCloudCommunicationOutBound' - properties: { - protocol: 'Tcp' - sourcePortRange: '*' - sourceAddressPrefix: '*' - destinationPortRange: '443' - destinationAddressPrefix: 'AzureCloud' - access: 'Allow' - priority: 110 - direction: 'Outbound' - } - } - { - name: 'AllowBastionHostCommunicationOutBound' - properties: { - protocol: '*' - sourcePortRange: '*' - sourceAddressPrefix: 'VirtualNetwork' - destinationPortRanges: [ - '8080' - '5701' - ] - destinationAddressPrefix: 'VirtualNetwork' - access: 'Allow' - priority: 120 - direction: 'Outbound' - } - } - { - name: 'AllowGetSessionInformationOutBound' - properties: { - protocol: '*' - sourcePortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: 'Internet' - destinationPortRanges: [ - '80' - '443' - ] - access: 'Allow' - priority: 130 - direction: 'Outbound' - } - } - { - name: 'DenyAllOutBound' - properties: { - protocol: '*' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' - access: 'Deny' - priority: 1000 - direction: 'Outbound' - } - } - ] - } -} - -var networkSecurityGroupAdministrationResourceName = 'nsg-${solutionSuffix}-administration' -module networkSecurityGroupAdministration 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.${networkSecurityGroupAdministrationResourceName}', 64) - params: { - name: networkSecurityGroupAdministrationResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'deny-hop-outbound' - properties: { - access: 'Deny' - destinationAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - direction: 'Outbound' - priority: 200 - protocol: 'Tcp' - sourceAddressPrefix: 'VirtualNetwork' - sourcePortRange: '*' - } - } - ] - } -} - -var networkSecurityGroupContainersResourceName = 'nsg-${solutionSuffix}-containers' -module networkSecurityGroupContainers 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.${networkSecurityGroupContainersResourceName}', 64) - params: { - name: networkSecurityGroupContainersResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'deny-hop-outbound' - properties: { - access: 'Deny' - destinationAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - direction: 'Outbound' - priority: 200 - protocol: 'Tcp' - sourceAddressPrefix: 'VirtualNetwork' - sourcePortRange: '*' - } - } - ] - } -} - -var networkSecurityGroupWebsiteResourceName = 'nsg-${solutionSuffix}-website' -module networkSecurityGroupWebsite 'br/public:avm/res/network/network-security-group:0.5.1' = if (enablePrivateNetworking) { - name: take('avm.res.network.network-security-group.${networkSecurityGroupWebsiteResourceName}', 64) - params: { - name: networkSecurityGroupWebsiteResourceName - location: location - tags: tags - enableTelemetry: enableTelemetry - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null - securityRules: [ - { - name: 'deny-hop-outbound' - properties: { - access: 'Deny' - destinationAddressPrefix: '*' - destinationPortRanges: [ - '22' - '3389' - ] - direction: 'Outbound' - priority: 200 - protocol: 'Tcp' - sourceAddressPrefix: 'VirtualNetwork' - sourcePortRange: '*' - } - } - ] - } -} - -// ========== Virtual Network ========== // -// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network -// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking var virtualNetworkResourceName = 'vnet-${solutionSuffix}' -module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = if (enablePrivateNetworking) { - name: take('avm.res.network.virtual-network.${virtualNetworkResourceName}', 64) +module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { + name: take('module.virtualNetwork.${solutionSuffix}', 64) params: { - name: virtualNetworkResourceName - location: location + name: 'vnet-${solutionSuffix}' tags: tags enableTelemetry: enableTelemetry addressPrefixes: ['10.0.0.0/8'] - subnets: [ - { - name: 'backend' - addressPrefix: '10.0.0.0/27' - //defaultOutboundAccess: false TODO: check this configuration for a more restricted outbound access - networkSecurityGroupResourceId: networkSecurityGroupBackend!.outputs.resourceId - } - { - name: 'administration' - addressPrefix: '10.0.0.32/27' - networkSecurityGroupResourceId: networkSecurityGroupAdministration!.outputs.resourceId - //defaultOutboundAccess: false TODO: check this configuration for a more restricted outbound access - //natGatewayResourceId: natGateway.outputs.resourceId - } - { - // For Azure Bastion resources deployed on or after November 2, 2021, the minimum AzureBastionSubnet size is /26 or larger (/25, /24, etc.). - // https://learn.microsoft.com/en-us/azure/bastion/configuration-settings#subnet - name: 'AzureBastionSubnet' //This exact name is required for Azure Bastion - addressPrefix: '10.0.0.64/26' - networkSecurityGroupResourceId: networkSecurityGroupBastion!.outputs.resourceId - } - { - // If you use your own vnw, you need to provide a subnet that is dedicated exclusively to the Container App environment you deploy. This subnet isn't available to other services - // https://learn.microsoft.com/en-us/azure/container-apps/networking?tabs=workload-profiles-env%2Cazure-cli#custom-vnw-configuration - name: 'containers' - addressPrefix: '10.0.2.0/23' //subnet of size /23 is required for container app - delegation: 'Microsoft.App/environments' - networkSecurityGroupResourceId: networkSecurityGroupContainers!.outputs.resourceId - privateEndpointNetworkPolicies: 'Enabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - { - // If you use your own vnw, you need to provide a subnet that is dedicated exclusively to the App Environment you deploy. This subnet isn't available to other services - // https://learn.microsoft.com/en-us/azure/app-service/overview-vnet-integration#subnet-requirements - name: 'webserverfarm' - addressPrefix: '10.0.4.0/27' //When you're creating subnets in Azure portal as part of integrating with the virtual network, a minimum size of /27 is required - delegation: 'Microsoft.Web/serverfarms' - networkSecurityGroupResourceId: networkSecurityGroupWebsite!.outputs.resourceId - privateEndpointNetworkPolicies: 'Enabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - ] + location: location + logAnalyticsWorkspaceId: logAnalyticsWorkspaceResourceId + resourceSuffix: solutionSuffix } } @@ -906,7 +587,7 @@ module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.17.0' = if (e ipConfigurations: [ { name: '${virtualMachineResourceName}-nic01-ipconfig01' - subnetResourceId: virtualNetwork!.outputs.subnetResourceIds[1] + subnetResourceId: virtualNetwork!.outputs.administrationSubnetResourceId diagnosticSettings: enableMonitoring //WAF aligned configuration for Monitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null @@ -1136,7 +817,7 @@ module aiFoundryAiServices 'br:mcr.microsoft.com/bicep/avm/res/cognitive-service { name: 'pep-${aiFoundryAiServicesResourceName}' customNetworkInterfaceName: 'nic-${aiFoundryAiServicesResourceName}' - subnetResourceId: virtualNetwork!.outputs.subnetResourceIds[0] + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId privateDnsZoneGroup: { privateDnsZoneGroupConfigs: [ { @@ -1244,7 +925,7 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { ] } service: 'Sql' - subnetResourceId: virtualNetwork!.outputs.subnetResourceIds[0] + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId } ] : [] @@ -1289,7 +970,7 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2 // WAF aligned configuration for Private Networking publicNetworkAccess: 'Enabled' // Always enabling the publicNetworkAccess for Container App Environment internal: false // Must be false when publicNetworkAccess is'Enabled' - infrastructureSubnetResourceId: enablePrivateNetworking ? virtualNetwork.?outputs.?subnetResourceIds[3] : null + infrastructureSubnetResourceId: enablePrivateNetworking ? virtualNetwork.?outputs.?containerSubnetResourceId : null // WAF aligned configuration for Monitoring appLogsConfiguration: enableMonitoring ? { @@ -1516,7 +1197,7 @@ module webSite 'modules/web-sites.bicep' = { // WAF aligned configuration for Private Networking vnetRouteAllEnabled: enablePrivateNetworking ? true : false vnetImagePullEnabled: enablePrivateNetworking ? true : false - virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.subnetResourceIds[4] : null + virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webserverfarmSubnetResourceId : null publicNetworkAccess: 'Enabled' // Always enabling the public network access for Web App e2eEncryptionEnabled: true } diff --git a/infra/modules/virtualNetwork.bicep b/infra/modules/virtualNetwork.bicep new file mode 100644 index 000000000..b9b5f11b6 --- /dev/null +++ b/infra/modules/virtualNetwork.bicep @@ -0,0 +1,374 @@ +/****************************************************************************************************************************/ +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG +/****************************************************************************************************************************/ +@description('Name of the virtual network.') +param name string + +@description('Azure region to deploy resources.') +param location string = resourceGroup().location + +@description('Required. An Array of 1 or more IP Address Prefixes for the Virtual Network.') +param addressPrefixes array + +@description('An array of subnets to be created within the virtual network. Each subnet can have its own configuration and associated Network Security Group (NSG).') +param subnets subnetType[] = [ + + + { + name:'backend' + addressPrefixes: ['10.0.0.0/27'] + networkSecurityGroup: { + name: 'nsg-backend' + securityRules: [ + { + name: 'deny-hop-outbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + } + ] + } + } + { + name: 'containers' + addressPrefixes: ['10.0.2.0/23'] + delegation: 'Microsoft.App/environments' + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' + networkSecurityGroup: { + name: 'nsg-containers' + securityRules: [ + { + name: 'deny-hop-outbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + } + ] + } + } + { + name: 'webserverfarm' + addressPrefixes: ['10.0.4.0/27'] + delegation: 'Microsoft.Web/serverfarms' + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' + networkSecurityGroup: { + name: 'nsg-webserverfarm' + securityRules: [ + { + name: 'deny-hop-outbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + } + ] + } + } + { + name: 'administration' + addressPrefixes: ['10.0.0.32/27'] + networkSecurityGroup: { + name: 'nsg-administration' + securityRules: [ + { + name: 'deny-hop-outbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + } + ] + } + } + { + name: 'AzureBastionSubnet' // Required name for Azure Bastion + addressPrefixes: ['10.0.0.64/26'] + networkSecurityGroup: { + name: 'nsg-bastion' + securityRules: [ + { + name: 'AllowGatewayManager' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 2702 + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: 'GatewayManager' + destinationAddressPrefix: '*' + } + } + { + name: 'AllowHttpsInBound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 2703 + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: 'Internet' + destinationAddressPrefix: '*' + } + } + { + name: 'AllowSshRdpOutbound' + properties: { + access: 'Allow' + direction: 'Outbound' + priority: 100 + protocol: '*' + sourcePortRange: '*' + destinationPortRanges: ['22', '3389'] + sourceAddressPrefix: '*' + destinationAddressPrefix: 'VirtualNetwork' + } + } + { + name: 'AllowAzureCloudOutbound' + properties: { + access: 'Allow' + direction: 'Outbound' + priority: 110 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'AzureCloud' + } + } + ] + } + } +] + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +@description('Optional. The resource ID of the Log Analytics Workspace to send diagnostic logs to.') +param logAnalyticsWorkspaceId string + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +@description('Required. Suffix for resource naming.') +param resourceSuffix string + +// VM Size Notes: +// 1 B-series VMs (like Standard_B2ms) do not support accelerated networking. +// 2 Pick a VM size that does support accelerated networking (the usual jump-box candidates): +// Standard_DS2_v2 (2 vCPU, 7 GiB RAM, Premium SSD) // The most broadly available (it’s a legacy SKU supported in virtually every region). +// Standard_D2s_v3 (2 vCPU, 8 GiB RAM, Premium SSD) // next most common +// Standard_D2s_v4 (2 vCPU, 8 GiB RAM, Premium SSD) // Newest, so fewer regions availabl + + +// Subnet Classless Inter-Doman Routing (CIDR) Sizing Reference Table (Best Practices) +// | CIDR | # of Addresses | # of /24s | Notes | +// |-----------|---------------|-----------|----------------------------------------| +// | /24 | 256 | 1 | Smallest recommended for Azure subnets | +// | /23 | 512 | 2 | Good for 1-2 workloads per subnet | +// | /22 | 1024 | 4 | Good for 2-4 workloads per subnet | +// | /21 | 2048 | 8 | | +// | /20 | 4096 | 16 | Used for default VNet in this solution | +// | /19 | 8192 | 32 | | +// | /18 | 16384 | 64 | | +// | /17 | 32768 | 128 | | +// | /16 | 65536 | 256 | | +// | /15 | 131072 | 512 | | +// | /14 | 262144 | 1024 | | +// | /13 | 524288 | 2048 | | +// | /12 | 1048576 | 4096 | | +// | /11 | 2097152 | 8192 | | +// | /10 | 4194304 | 16384 | | +// | /9 | 8388608 | 32768 | | +// | /8 | 16777216 | 65536 | | +// +// Best Practice Notes: +// - Use /24 as the minimum subnet size for Azure (smaller subnets are not supported for most services). +// - Plan for future growth: allocate larger address spaces (e.g., /20 or /21 for VNets) to allow for new subnets. +// - Avoid overlapping address spaces with on-premises or other VNets. +// - Use contiguous, non-overlapping ranges for subnets. +// - Document subnet usage and purpose in code comments. +// - For AVM modules, ensure only one delegation per subnet and leave delegations empty if not required. + +// 1. Create NSGs for subnets +// using AVM Network Security Group module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group + +@batchSize(1) +module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [ + for (subnet, i) in subnets: if (!empty(subnet.?networkSecurityGroup)) { + name: take('avm.res.network.network-security-group.${subnet.?networkSecurityGroup.name}.${resourceSuffix}', 64) + params: { + name: '${subnet.?networkSecurityGroup.name}-${resourceSuffix}' + location: location + securityRules: subnet.?networkSecurityGroup.securityRules + tags: tags + enableTelemetry: enableTelemetry + } + } +] + +// 2. Create VNet and subnets, with subnets associated with corresponding NSGs +// using AVM Virtual Network module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network + +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { + name: take('avm.res.network.virtual-network.${name}', 64) + params: { + name: name + location: location + addressPrefixes: addressPrefixes + subnets: [ + for (subnet, i) in subnets: { + name: subnet.name + addressPrefixes: subnet.?addressPrefixes + networkSecurityGroupResourceId: !empty(subnet.?networkSecurityGroup) ? nsgs[i]!.outputs.resourceId : null + privateEndpointNetworkPolicies: subnet.?privateEndpointNetworkPolicies + privateLinkServiceNetworkPolicies: subnet.?privateLinkServiceNetworkPolicies + delegation: subnet.?delegation + } + ] + diagnosticSettings: [ + { + name: 'vnetDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + tags: tags + enableTelemetry: enableTelemetry + } +} + +output name string = virtualNetwork.outputs.name +output resourceId string = virtualNetwork.outputs.resourceId + +// combined output array that holds subnet details along with NSG information +output subnets subnetOutputType[] = [ + for (subnet, i) in subnets: { + name: subnet.name + resourceId: virtualNetwork.outputs.subnetResourceIds[i] + nsgName: !empty(subnet.?networkSecurityGroup) ? subnet.?networkSecurityGroup.name : null + nsgResourceId: !empty(subnet.?networkSecurityGroup) ? nsgs[i]!.outputs.resourceId : null + } +] + +// Dynamic outputs for individual subnets for backward compatibility +output backendSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'backend') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'backend')] : '' +output containerSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'containers') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'containers')] : '' +output administrationSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'administration') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'administration')] : '' +output webserverfarmSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'webserverfarm') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'webserverfarm')] : '' +output bastionSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'AzureBastionSubnet') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'AzureBastionSubnet')] : '' + +@export() +@description('Custom type definition for subnet resource information as output') +type subnetOutputType = { + @description('The name of the subnet.') + name: string + + @description('The resource ID of the subnet.') + resourceId: string + + @description('The name of the associated network security group, if any.') + nsgName: string? + + @description('The resource ID of the associated network security group, if any.') + nsgResourceId: string? +} + +@export() +@description('Custom type definition for subnet configuration') +type subnetType = { + @description('Required. The Name of the subnet resource.') + name: string + + @description('Required. Prefixes for the subnet.') // Required to ensure at least one prefix is provided + addressPrefixes: string[] + + @description('Optional. The delegation to enable on the subnet.') + delegation: string? + + @description('Optional. enable or disable apply network policies on private endpoint in the subnet.') + privateEndpointNetworkPolicies: ('Disabled' | 'Enabled' | 'NetworkSecurityGroupEnabled' | 'RouteTableEnabled')? + + @description('Optional. Enable or disable apply network policies on private link service in the subnet.') + privateLinkServiceNetworkPolicies: ('Disabled' | 'Enabled')? + + @description('Optional. Network Security Group configuration for the subnet.') + networkSecurityGroup: networkSecurityGroupType? + + @description('Optional. The resource ID of the route table to assign to the subnet.') + routeTableResourceId: string? + + @description('Optional. An array of service endpoint policies.') + serviceEndpointPolicies: object[]? + + @description('Optional. The service endpoints to enable on the subnet.') + serviceEndpoints: string[]? + + @description('Optional. Set this property to false to disable default outbound connectivity for all VMs in the subnet. This property can only be set at the time of subnet creation and cannot be updated for an existing subnet.') + defaultOutboundAccess: bool? +} + +@export() +@description('Custom type definition for network security group configuration') +type networkSecurityGroupType = { + @description('Required. The name of the network security group.') + name: string + + @description('Required. The security rules for the network security group.') + securityRules: object[] +}