From 5d21a0ff64e5a8caabfbf59ee2a5f2c41d032c91 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 27 May 2025 15:46:41 -0400 Subject: [PATCH 001/124] Infra - initial AVM update (WIP) --- infra/deploy_ai_foundry.bicep | 311 ++++++++---------- infra/main.bicep | 601 ++++++++++++++++------------------ infra/main.bicepparam | 4 +- 3 files changed, 414 insertions(+), 502 deletions(-) diff --git a/infra/deploy_ai_foundry.bicep b/infra/deploy_ai_foundry.bicep index 11f49e5..df5a5cd 100644 --- a/infra/deploy_ai_foundry.bicep +++ b/infra/deploy_ai_foundry.bicep @@ -1,97 +1,46 @@ -// Creates Azure dependent resources for Azure AI studio -@minLength(3) -@maxLength(15) -@description('Solution Name') -param solutionName string -param solutionLocation string +param location string + +param projectName string +param hubName string + +param storageName string param keyVaultName string param gptModelName string param gptModelVersion string param managedIdentityObjectId string -param aiServicesEndpoint string -param aiServicesKey string -param aiServicesId string -var abbrs = loadJsonContent('./abbreviations.json') -var storageName = '${abbrs.storage.storageAccount}${solutionName}' +param aiServicesName string -var storageSkuName = 'Standard_LRS' -var aiServicesName = '${abbrs.ai.aiServices}${solutionName}' -var workspaceName = '${abbrs.managementGovernance.logAnalyticsWorkspace}${solutionName}' -var keyvaultName = '${abbrs.security.keyVault}${solutionName}' -var location = solutionLocation -var azureAiHubName = '${abbrs.ai.aiHub}${solutionName}' -var aiHubFriendlyName = azureAiHubName var aiHubDescription = 'AI Hub for KM template' -var aiProjectName = '${abbrs.ai.aiHubProject}${solutionName}' -var aiProjectFriendlyName = aiProjectName -var aiSearchName = '${abbrs.ai.aiSearch}${solutionName}' - -resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { +resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = { name: keyVaultName } -resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { - name: workspaceName - location: location - tags: {} - properties: { - retentionInDays: 30 - sku: { - name: 'PerGB2018' - } - } +resource aiServices 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { + name: aiServicesName } - -var storageNameCleaned = replace(replace(replace(replace('${storageName}cast', '-', ''), '_', ''), '.', ''),'/', '') - - - - -resource storage 'Microsoft.Storage/storageAccounts@2022-09-01' = { - name: storageNameCleaned - location: location - sku: { - name: storageSkuName - } - kind: 'StorageV2' - identity: { - type: 'SystemAssigned' - } - properties: { +var aiServicesKey = aiServices.listKeys().key1 +var aiServicesEndpoint = aiServices.properties.endpoint +var storageAccountName = replace(replace(replace(replace('${storageName}cast', '-', ''), '_', ''), '.', ''),'/', '') + +module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { + name: 'foundry-storage-${storageAccountName}-deployment' + params: { + name: storageAccountName + location: location + kind: 'StorageV2' + skuName: 'Standard_LRS' + publicNetworkAccess: 'Enabled' accessTier: 'Hot' allowBlobPublicAccess: false - allowCrossTenantReplication: false allowSharedKeyAccess: false - encryption: { - keySource: 'Microsoft.Storage' - requireInfrastructureEncryption: false - services: { - blob: { - enabled: true - keyType: 'Account' - } - file: { - enabled: true - keyType: 'Account' - } - queue: { - enabled: true - keyType: 'Service' - } - table: { - enabled: true - keyType: 'Service' - } - } - } - isHnsEnabled: false - isNfsV3Enabled: false - keyPolicy: { - keyExpirationPeriodInDays: 7 - } + allowCrossTenantReplication: false + requireInfrastructureEncryption: false + keyType: 'Service' + enableHierarchicalNamespace: false + enableNfsV3: false largeFileSharesState: 'Disabled' minimumTlsVersion: 'TLS1_2' networkAcls: { @@ -99,106 +48,128 @@ resource storage 'Microsoft.Storage/storageAccounts@2022-09-01' = { defaultAction: 'Allow' } supportsHttpsTrafficOnly: true + roleAssignments: [ + { + principalId: managedIdentityObjectId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + } + ] } } -@description('This is the built-in Storage Blob Data Contributor.') -resource blobDataContributor 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { - scope: subscription() - name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' +module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { + name: 'ai-hub-${hubName}-deployment' + params: { + name: hubName + location: location + sku: 'Standard' + kind: 'Hub' + description: aiHubDescription + associatedKeyVaultResourceId: keyVault.id + associatedStorageAccountResourceId: storageAccount.outputs.resourceId + publicNetworkAccess: 'Enabled' + connections: [ + { + name: aiServicesName + value: null + category: 'AIServices' + target: aiServices.properties.endpoint + connectionProperties: { + authType: 'ApiKey' + credentials: { + key: aiServicesKey + } + } + isSharedToAll: true + metadata: { + ApiType: 'Azure' + ResourceId: aiServices.id + } + } + ] + } } -resource storageroleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(resourceGroup().id, managedIdentityObjectId, blobDataContributor.id) - scope: storage - properties: { - principalId: managedIdentityObjectId - roleDefinitionId: blobDataContributor.id - principalType: 'ServicePrincipal' +module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { + name: 'ai-project-${projectName}-deployment' + params: { + name: projectName + kind: 'Project' + sku: 'Standard' + location: location + hubResourceId: hub.outputs.resourceId + publicNetworkAccess: 'Enabled' + hbiWorkspace: false + roleAssignments: [ + { + principalId: managedIdentityObjectId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Azure AI Developer' + } + ] } } -resource aiHub 'Microsoft.MachineLearningServices/workspaces@2023-08-01-preview' = { - name: azureAiHubName - location: location - identity: { - type: 'SystemAssigned' - } - properties: { - // organization - friendlyName: aiHubFriendlyName - description: aiHubDescription +// get reference to the AI Hub project to get access to the discovery URL property (not presently available on AVM) +// adjust this logic if support on the AVM module is added +// resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10-01' existing = { +// name: projectName +// dependsOn: [project] +// } - // dependent resources - keyVault: keyVault.id - storageAccount: storage.id - } - kind: 'hub' +//var aiProjectConnString = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' +var aiProjectConnString = '${location}.api.azureml.ms;${subscription().subscriptionId};${resourceGroup().name};${projectName}' - resource aiServicesConnection 'connections@2024-07-01-preview' = { - name: '${azureAiHubName}-connection-AzureOpenAI' - properties: { - category: 'AIServices' - target: aiServicesEndpoint - authType: 'ApiKey' - isSharedToAll: true - credentials: { - key: aiServicesKey - } - metadata: { - ApiType: 'Azure' - ResourceId: aiServicesId - } - } +resource projectConnStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { + parent: keyVault + name: 'AZURE-AI-PROJECT-CONN-STRING' + properties: { + value: aiProjectConnString } } -resource aiHubProject 'Microsoft.MachineLearningServices/workspaces@2024-01-01-preview' = { - name: aiProjectName - location: location - kind: 'Project' - identity: { - type: 'SystemAssigned' - } +resource openAiKeySecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { + parent: keyVault + name: 'AZURE-OPENAI-KEY' properties: { - friendlyName: aiProjectFriendlyName - hubResourceId: aiHub.id + value: aiServicesKey } } -resource tenantIdEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource cogServicesKeySecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault - name: 'TENANT-ID' + name: 'COG-SERVICES-KEY' properties: { - value: subscription().tenantId + value: aiServicesKey } } -resource azureOpenAIInferenceEndpoint 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource tenantIdSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault - name: 'AZURE-OPENAI-INFERENCE-ENDPOINT' + name: 'TENANT-ID' properties: { - value:'' + value: subscription().tenantId } } -resource azureOpenAIInferenceKey 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource openAiInferenceEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault - name: 'AZURE-OPENAI-INFERENCE-KEY' + name: 'AZURE-OPENAI-INFERENCE-ENDPOINT' properties: { - value:'' + value: '' } } -resource azureOpenAIApiKeyEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource openAiInferenceKeySecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault - name: 'AZURE-OPENAI-KEY' + name: 'AZURE-OPENAI-INFERENCE-KEY' properties: { - value: aiServicesKey //aiServices_m.listKeys().key1 + value: '' } } -resource azureOpenAIDeploymentModel 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource openAiDeploymentModelSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'AZURE-OPEN-AI-DEPLOYMENT-MODEL' properties: { @@ -206,31 +177,23 @@ resource azureOpenAIDeploymentModel 'Microsoft.KeyVault/vaults/secrets@2021-11-0 } } -resource azureOpenAIApiVersionEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource openAiPreviewApiVersionSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'AZURE-OPENAI-PREVIEW-API-VERSION' properties: { - value: gptModelVersion //'2024-02-15-preview' + value: gptModelVersion } } -resource azureOpenAIEndpointEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource openAiEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'AZURE-OPENAI-ENDPOINT' properties: { - value: aiServicesEndpoint//aiServices_m.properties.endpoint - } -} - -resource azureAIProjectConnectionStringEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { - parent: keyVault - name: 'AZURE-AI-PROJECT-CONN-STRING' - properties: { - value: '${split(aiHubProject.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${aiHubProject.name}' + value: aiServicesEndpoint } } -resource azureOpenAICUApiVersionEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource openAiCuVersionSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'AZURE-OPENAI-CU-VERSION' properties: { @@ -238,7 +201,7 @@ resource azureOpenAICUApiVersionEntry 'Microsoft.KeyVault/vaults/secrets@2021-11 } } -resource azureSearchIndexEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource azureSearchIndexSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'AZURE-SEARCH-INDEX' properties: { @@ -246,7 +209,7 @@ resource azureSearchIndexEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-pre } } -resource cogServiceEndpointEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource cogServicesEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'COG-SERVICES-ENDPOINT' properties: { @@ -254,23 +217,15 @@ resource cogServiceEndpointEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-p } } -resource cogServiceKeyEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { - parent: keyVault - name: 'COG-SERVICES-KEY' - properties: { - value: aiServicesKey - } -} - -resource cogServiceNameEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource cogServicesNameSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'COG-SERVICES-NAME' properties: { - value: aiServicesName + value: aiServices.name } } -resource azureSubscriptionIdEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource azureSubscriptionIdSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'AZURE-SUBSCRIPTION-ID' properties: { @@ -278,7 +233,7 @@ resource azureSubscriptionIdEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01- } } -resource resourceGroupNameEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource azureResourceGroupSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'AZURE-RESOURCE-GROUP' properties: { @@ -286,24 +241,18 @@ resource resourceGroupNameEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-pr } } -resource azureLocatioEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { +resource azureLocationSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault name: 'AZURE-LOCATION' properties: { - value: solutionLocation + value: location } } -output keyvaultName string = keyvaultName -output keyvaultId string = keyVault.id - -output aiServicesName string = aiServicesName -output aiSearchName string = aiSearchName -output aiProjectName string = aiHubProject.name - -output storageAccountName string = storageNameCleaned +output projectName string = project.outputs.name +output hubName string = hub.outputs.name -output logAnalyticsId string = logAnalytics.id -output storageAccountId string = storage.id +output storageAccountName string = storageAccount.outputs.name +output storageAccountId string = storageAccount.outputs.resourceId -output projectConnectionString string = '${split(aiHubProject.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${aiHubProject.name}' +output projectConnectionString string = aiProjectConnString diff --git a/infra/main.bicep b/infra/main.bicep index 51977ce..a7f2dab 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1,8 +1,6 @@ @minLength(3) -@description('Prefix for all resources created by this template. This should be 3-20 characters long. If your provide a prefix longer than 20 characters, it will be truncated to 20 characters.') -param Prefix string -var abbrs = loadJsonContent('./abbreviations.json') -var safePrefix = length(Prefix) > 20 ? substring(Prefix, 0, 20) : Prefix +@description('prefix for all resources created by this template. This should be 3-20 characters long. If your provide a prefix longer than 20 characters, it will be truncated to 20 characters.') +param prefix string @allowed([ 'australiaeast' @@ -30,12 +28,15 @@ var safePrefix = length(Prefix) > 20 ? substring(Prefix, 0, 20) : Prefix 'westus3' ]) @description('Location for all Ai services resources. This location can be different from the resource group location.') -param AzureAiServiceLocation string // The location used for all deployed resources. This location must be in the same region as the resource group. +param azureAiServiceLocation string // The location used for all deployed resources. This location must be in the same region as the resource group. + param capacity int = 5 +var abbrs = loadJsonContent('./abbreviations.json') +var safePrefix = length(prefix) > 20 ? substring(prefix, 0, 20) : prefix var uniqueId = toLower(uniqueString(subscription().id, safePrefix, resourceGroup().location)) -var UniquePrefix = 'cm${padLeft(take(uniqueId, 12), 12, '0')}' -var ResourcePrefix = take('cm${safePrefix}${UniquePrefix}', 15) +var uniquePrefix = 'cm${padLeft(take(uniqueId, 12), 12, '0')}' +var resourcePrefix = take('cm${safePrefix}${uniquePrefix}', 15) var imageVersion = 'latest' var location = resourceGroup().location var dblocation = resourceGroup().location @@ -47,138 +48,158 @@ var deploymentType = 'GlobalStandard' var containerName = 'appstorage' var llmModel = 'gpt-4o' var storageSkuName = 'Standard_LRS' -var storageContainerName = replace(replace(replace(replace('${ResourcePrefix}cast', '-', ''), '_', ''), '.', ''),'/', '') +var storageAccountForContainersName = replace(replace(replace(replace('${resourcePrefix}cast', '-', ''), '_', ''), '.', ''),'/', '') var gptModelVersion = '2024-08-06' -var azureAiServicesName = '${abbrs.ai.aiServices}${ResourcePrefix}' +var azureAiServicesName = '${abbrs.ai.aiServices}${resourcePrefix}' +var aiFoundryProjectName = '${abbrs.ai.aiHubProject}${resourcePrefix}' - - -var aiModelDeployments = [ - { - name: llmModel - model: llmModel - version: gptModelVersion - sku: { - name: deploymentType - capacity: capacity - } - raiPolicyName: 'Microsoft.Default' - } -] - -resource azureAiServices 'Microsoft.CognitiveServices/accounts@2024-04-01-preview' = { - name: azureAiServicesName - location: location - sku: { - name: 'S0' - } - kind: 'AIServices' - properties: { +module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { + name: take('cog-${azureAiServicesName}-deployment', 64) + params: { + name: azureAiServicesName + location: location + sku: 'S0' + kind: 'AIServices' customSubDomainName: azureAiServicesName + disableLocalAuth: false + deployments: [ + { + name: llmModel + model: { + name: llmModel + format: 'OpenAI' + version: gptModelVersion + } + sku: { + name: deploymentType + capacity: capacity + } + raiPolicyName: 'Microsoft.Default' + } + ] + roleAssignments: [ + { + principalId: managedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + ] } } -@batchSize(1) -resource azureAiServicesDeployments 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for aiModeldeployment in aiModelDeployments: { - parent: azureAiServices //aiServices_m - name: aiModeldeployment.name - properties: { - model: { - format: 'OpenAI' - name: aiModeldeployment.model - version: aiModeldeployment.version +module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('${abbrs.security.managedIdentity}${resourcePrefix}-identity-deployment', 64) + params: { + name: '${abbrs.security.managedIdentity}${resourcePrefix}' + location: location + tags: { + app: resourcePrefix + location: location } - raiPolicyName: aiModeldeployment.raiPolicyName - } - sku:{ - name: aiModeldeployment.sku.name - capacity: aiModeldeployment.sku.capacity } -}] - +} +// TODO - verifty if Owner is needed +@description('This is the built-in owner role. See https://docs.microsoft.com/azure/role-based-access-control/built-in-roles#owner') +resource ownerRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + scope: resourceGroup() + name: '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' +} -//param storageAccountId string = 'storageAccountId' -module managedIdentityModule 'deploy_managed_identity.bicep' = { - name: 'deploy_managed_identity' - params: { - miName:'${abbrs.security.managedIdentity}${ResourcePrefix}' - solutionName: ResourcePrefix - solutionLocation: location +resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, managedIdentity.name, ownerRoleDefinition.id) + properties: { + principalId: managedIdentity.outputs.principalId + roleDefinitionId: ownerRoleDefinition.id + principalType: 'ServicePrincipal' } - scope: resourceGroup(resourceGroup().name) } - -// ==========Key Vault Module ========== // -module kvault 'deploy_keyvault.bicep' = { - name: 'deploy_keyvault' +module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { + name: take('${abbrs.security.keyVault}${resourcePrefix}-keyvault-deployment', 64) params: { - keyvaultName: '${abbrs.security.keyVault}${ResourcePrefix}' - solutionName: ResourcePrefix - solutionLocation: location - managedIdentityObjectId:managedIdentityModule.outputs.managedIdentityOutput.objectId + name: '${abbrs.security.keyVault}${resourcePrefix}' + location: location + createMode: 'default' + sku: 'standard' + enableVaultForDeployment: true + enableVaultForDiskEncryption: true + enableVaultForTemplateDeployment: true + enableRbacAuthorization: true + publicNetworkAccess: 'Enabled' + softDeleteRetentionInDays: 7 + roleAssignments: [ + { + principalId: managedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Key Vault Administrator' + } + ] } - scope: resourceGroup(resourceGroup().name) } +// TODO - verify if this is needed + +// module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.0' = { +// name: 'log-analytics-deployment' +// params: { +// name: '${abbrs.managementGovernance.logAnalyticsWorkspace}${resourcePrefix}' +// location: location +// skuName: 'PerGB2018' +// dataRetention: 30 +// } +// } -// ==========AI Foundry and related resources ========== // module azureAifoundry 'deploy_ai_foundry.bicep' = { - name: 'deploy_ai_foundry' + name: 'deploy_ai_foundry-${azureAiServicesName}' params: { - solutionName: ResourcePrefix - solutionLocation: AzureAiServiceLocation - keyVaultName: kvault.outputs.keyvaultName + location: azureAiServiceLocation + hubName: '${abbrs.ai.aiHub}${resourcePrefix}' + projectName: aiFoundryProjectName + storageName: '${abbrs.storage.storageAccount}${resourcePrefix}' + keyVaultName: keyvault.outputs.name gptModelName: llmModel gptModelVersion: gptModelVersion - managedIdentityObjectId:managedIdentityModule.outputs.managedIdentityOutput.objectId - aiServicesEndpoint: azureAiServices.properties.endpoint - aiServicesKey: azureAiServices.listKeys().key1 - aiServicesId: azureAiServices.id + managedIdentityObjectId: managedIdentity.outputs.principalId + aiServicesName: azureAiServices.outputs.name } - scope: resourceGroup(resourceGroup().name) } -module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.9.1' = { - name: toLower('${ResourcePrefix}conAppsEnv') +module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.1' = { + name: toLower('${resourcePrefix}conAppsEnv') params: { - logAnalyticsWorkspaceResourceId: azureAifoundry.outputs.logAnalyticsId - name: toLower('${ResourcePrefix}manenv') + name: toLower('${resourcePrefix}manenv') location: location zoneRedundant: false - managedIdentities: managedIdentityModule + managedIdentities: { + userAssignedResourceIds: [ + managedIdentity.outputs.resourceId + ] + } } } -module databaseAccount 'br/public:avm/res/document-db/database-account:0.9.0' = { - name: toLower('${abbrs.databases.cosmosDBDatabase}${ResourcePrefix}databaseAccount') +var cosmosAccountName = toLower('${abbrs.databases.cosmosDBDatabase}${resourcePrefix}databaseAccount') + +module databaseAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { + name: 'cosmosdb-${cosmosAccountName}-deployment' params: { - // Required parameters - name: toLower('${abbrs.databases.cosmosDBDatabase}${ResourcePrefix}databaseAccount') - // Non-required parameters + name: cosmosAccountName enableAnalyticalStorage: true location: dblocation - managedIdentities: { - systemAssigned: true - userAssignedResourceIds: [ - managedIdentityModule.outputs.managedIdentityOutput.resourceId - ] - } + // managedIdentities: { + // userAssignedResourceIds: [ + // managedIdentity.outputs.resourceId + // ] + // } networkRestrictions: { networkAclBypass: 'AzureServices' publicNetworkAccess: 'Enabled' - ipRules: [] // Adding ipRules as an empty array - virtualNetworkRules: [] // Adding virtualNetworkRules as an empty array + ipRules: [] + virtualNetworkRules: [] } + zoneRedundant: false disableKeyBasedMetadataWriteAccess: false - locations: [ - { - failoverPriority: 0 - isZoneRedundant: false - locationName: dblocation - } - ] sqlDatabases: [ { containers: [ @@ -213,27 +234,26 @@ module databaseAccount 'br/public:avm/res/document-db/database-account:0.9.0' = name: cosmosdbDatabase } ] - } - } -module containerAppFrontend 'br/public:avm/res/app/container-app:0.13.0' = { - name: toLower('${abbrs.containers.containerApp}${ResourcePrefix}containerAppFrontend') +module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { + name: toLower('${abbrs.containers.containerApp}${resourcePrefix}containerAppFrontend') params: { + name: toLower('${abbrs.containers.containerApp}${resourcePrefix}Frontend') + location: location + environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { - systemAssigned: true userAssignedResourceIds: [ - managedIdentityModule.outputs.managedIdentityOutput.resourceId + managedIdentity.outputs.resourceId ] } - // Required parameters containers: [ { env: [ { name: 'API_URL' - value: 'https://${containerAppBackend.properties.configuration.ingress.fqdn}' + value: 'https://${containerAppBackend.outputs.fqdn}' } ] image: 'cmsacontainerreg.azurecr.io/cmsafrontend:${imageVersion}' @@ -246,172 +266,148 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.13.0' = { ] ingressTargetPort: 3000 ingressExternal: true - scaleMinReplicas: 1 - scaleMaxReplicas: 1 - environmentResourceId: containerAppsEnvironment.outputs.resourceId - name: toLower('${abbrs.containers.containerApp}${ResourcePrefix}Frontend') - // Non-required parameters - location: location + scaleSettings: { + minReplicas: 1 + maxReplicas: 1 + } } } - -resource containerAppBackend 'Microsoft.App/containerApps@2023-05-01' = { - name: toLower('${abbrs.containers.containerApp}${ResourcePrefix}Backend') - location: location - identity: { - type: 'SystemAssigned' - } - properties: { - managedEnvironmentId: containerAppsEnvironment.outputs.resourceId - configuration: { - ingress: { - external: true - targetPort: 8000 - } +module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { + name: toLower('${abbrs.containers.containerApp}${resourcePrefix}containerAppBackend') + params: { + name: toLower('${abbrs.containers.containerApp}${resourcePrefix}Backend') + location: location + environmentResourceId: containerAppsEnvironment.outputs.resourceId + managedIdentities: { + userAssignedResourceIds: [ + managedIdentity.outputs.resourceId + ] } - template: { - scale: { - minReplicas: 1 - maxReplicas: 1 - } - containers: [ - { - name: 'cmsabackend' - image: 'cmsacontainerreg.azurecr.io/cmsabackend:${imageVersion}' - env: [ - { - name: 'COSMOSDB_ENDPOINT' - value: databaseAccount.outputs.endpoint - } - { - name: 'COSMOSDB_DATABASE' - value: cosmosdbDatabase - } - { - name: 'COSMOSDB_BATCH_CONTAINER' - value: cosmosdbBatchContainer - } - { - name: 'COSMOSDB_FILE_CONTAINER' - value: cosmosdbFileContainer - } - { - name: 'COSMOSDB_LOG_CONTAINER' - value: cosmosdbLogContainer - } - { - name: 'AZURE_BLOB_ACCOUNT_NAME' - value: storageContianerApp.name - } - { - name: 'AZURE_BLOB_CONTAINER_NAME' - value: containerName - } - { - name: 'AZURE_OPENAI_ENDPOINT' - value: 'https://${azureAifoundry.outputs.aiServicesName}.openai.azure.com/' - } - { - name: 'MIGRATOR_AGENT_MODEL_DEPLOY' - value: llmModel - } - { - name: 'PICKER_AGENT_MODEL_DEPLOY' - value: llmModel - } - { - name: 'FIXER_AGENT_MODEL_DEPLOY' - value: llmModel - } - { - name: 'SEMANTIC_VERIFIER_AGENT_MODEL_DEPLOY' - value: llmModel - } - { - name: 'SYNTAX_CHECKER_AGENT_MODEL_DEPLOY' - value: llmModel - } - { - name: 'SELECTION_MODEL_DEPLOY' - value: llmModel - } - { - name: 'TERMINATION_MODEL_DEPLOY' - value: llmModel - } - { - name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' - value: llmModel - } - { - name: 'AZURE_AI_AGENT_PROJECT_NAME' - value: azureAifoundry.outputs.aiProjectName - } - { - name: 'AZURE_AI_AGENT_RESOURCE_GROUP_NAME' - value: resourceGroup().name - } - { - name: 'AZURE_AI_AGENT_SUBSCRIPTION_ID' - value: subscription().subscriptionId - } - { - name: 'AZURE_AI_AGENT_PROJECT_CONNECTION_STRING' - value: azureAifoundry.outputs.projectConnectionString - } - ] - resources: { - cpu: 1 - memory: '2.0Gi' + containers: [ + { + name: 'cmsabackend' + image: 'cmsacontainerreg.azurecr.io/cmsabackend:${imageVersion}' + env: [ + { + name: 'COSMOSDB_ENDPOINT' + value: databaseAccount.outputs.endpoint + } + { + name: 'COSMOSDB_DATABASE' + value: cosmosdbDatabase + } + { + name: 'COSMOSDB_BATCH_CONTAINER' + value: cosmosdbBatchContainer + } + { + name: 'COSMOSDB_FILE_CONTAINER' + value: cosmosdbFileContainer + } + { + name: 'COSMOSDB_LOG_CONTAINER' + value: cosmosdbLogContainer + } + { + name: 'AZURE_BLOB_ACCOUNT_NAME' + value: storageAccountForContainersName } + { + name: 'AZURE_BLOB_CONTAINER_NAME' + value: containerName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: 'https://${azureAiServices.outputs.name}.openai.azure.com/' + } + { + name: 'MIGRATOR_AGENT_MODEL_DEPLOY' + value: llmModel + } + { + name: 'PICKER_AGENT_MODEL_DEPLOY' + value: llmModel + } + { + name: 'FIXER_AGENT_MODEL_DEPLOY' + value: llmModel + } + { + name: 'SEMANTIC_VERIFIER_AGENT_MODEL_DEPLOY' + value: llmModel + } + { + name: 'SYNTAX_CHECKER_AGENT_MODEL_DEPLOY' + value: llmModel + } + { + name: 'SELECTION_MODEL_DEPLOY' + value: llmModel + } + { + name: 'TERMINATION_MODEL_DEPLOY' + value: llmModel + } + { + name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' + value: llmModel + } + { + name: 'AZURE_AI_AGENT_PROJECT_NAME' + value: azureAifoundry.outputs.projectName + } + { + name: 'AZURE_AI_AGENT_RESOURCE_GROUP_NAME' + value: resourceGroup().name + } + { + name: 'AZURE_AI_AGENT_SUBSCRIPTION_ID' + value: subscription().subscriptionId + } + { + name: 'AZURE_AI_AGENT_PROJECT_CONNECTION_STRING' + value: azureAifoundry.outputs.projectConnectionString + } + { + name: 'AZURE_CLIENT_ID' + value: managedIdentity.outputs.clientId // TODO - VERIFY -> NOTE: This is the client ID of the managed identity, not the Entra application, and is needed for the App Service to access the Cosmos DB account. + } + ] + resources: { + cpu: 1 + memory: '2.0Gi' } - ] + } + ] + ingressTargetPort: 8000 + ingressExternal: true + scaleSettings: { + minReplicas: 1 + maxReplicas: 1 } } } -resource storageContianerApp 'Microsoft.Storage/storageAccounts@2022-09-01' = { - name: storageContainerName - location: location - sku: { - name: storageSkuName - } - kind: 'StorageV2' - identity: { - type: 'SystemAssigned' // Enables Managed Identity - } - properties: { + +module storageAccountForContainers 'br/public:avm/res/storage/storage-account:0.17.0' = { + name: 'storage-account-${storageAccountForContainersName}-deployment' + params: { + name: storageAccountForContainersName + location: location + managedIdentities: { + systemAssigned: true + } + kind: 'StorageV2' + skuName: storageSkuName + publicNetworkAccess: 'Enabled' accessTier: 'Hot' allowBlobPublicAccess: false - allowCrossTenantReplication: false allowSharedKeyAccess: false - encryption: { - keySource: 'Microsoft.Storage' - requireInfrastructureEncryption: false - services: { - blob: { - enabled: true - keyType: 'Account' - } - file: { - enabled: true - keyType: 'Account' - } - queue: { - enabled: true - keyType: 'Service' - } - table: { - enabled: true - keyType: 'Service' - } - } - } - isHnsEnabled: false - isNfsV3Enabled: false - keyPolicy: { - keyExpirationPeriodInDays: 7 - } + allowCrossTenantReplication: false + requireInfrastructureEncryption: false + keyType: 'Service' + enableHierarchicalNamespace: false + enableNfsV3: false largeFileSharesState: 'Disabled' minimumTlsVersion: 'TLS1_2' networkAcls: { @@ -419,77 +415,44 @@ resource storageContianerApp 'Microsoft.Storage/storageAccounts@2022-09-01' = { defaultAction: 'Allow' } supportsHttpsTrafficOnly: true - } -} -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(containerAppBackend.id, 'Storage Blob Data Contributor') - scope: storageContianerApp - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor - principalId: containerAppBackend.identity.principalId - } -} -var openAiContributorRoleId = 'a001fd3d-188f-4b5d-821b-7da978bf7442' // Fixed Role ID for OpenAI Contributor - -resource openAiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(containerAppBackend.id, openAiContributorRoleId) - scope: azureAiServices - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', openAiContributorRoleId) // OpenAI Service Contributor - principalId: containerAppBackend.identity.principalId - } -} - -var containerNames = [ - containerName -] - -// Create a blob container resource for each container name. -resource containers 'Microsoft.Storage/storageAccounts/blobServices/containers@2021-08-01' = [for containerName in containerNames: { - name: '${storageContainerName}/default/${containerName}' - properties: { - publicAccess: 'None' - } - dependsOn: [azureAifoundry] -}] - -resource aiHubProject 'Microsoft.MachineLearningServices/workspaces@2024-01-01-preview' existing = { - name: '${abbrs.ai.aiHubProject}${ResourcePrefix}' // aiProjectName must be calculated - available at main start. -} - -resource aiDeveloper 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { - name: '64702f94-c441-49e6-a78b-ef80e0188fee' -} - -resource aiDeveloperAccessProj 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(containerAppBackend.name, aiHubProject.id, aiDeveloper.id) - scope: aiHubProject - properties: { - roleDefinitionId: aiDeveloper.id - principalId: containerAppBackend.identity.principalId + blobServices: { + containers: [ + { + name: containerName + properties: { + publicAccess: 'None' + } + } + ] + } + roleAssignments: [ + { + principalId: managedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + } + ] } } -resource contributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2021-06-15' existing = { - name: '${databaseAccount.name}/00000000-0000-0000-0000-000000000002' +resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { + name: '${cosmosAccountName}/00000000-0000-0000-0000-000000000002' } -var cosmosAssignCli = 'az cosmosdb sql role assignment create --resource-group "${resourceGroup().name}" --account-name "${databaseAccount.outputs.name}" --role-definition-id "${contributorRoleDefinition.id}" --scope "${databaseAccount.outputs.resourceId}" --principal-id "${containerAppBackend.identity.principalId}"' +var script = 'az cosmosdb sql role assignment create --resource-group "${resourceGroup().name}" --account-name "${databaseAccount.outputs.name}" --role-definition-id "${sqlContributorRoleDefinition.id}" --scope "${databaseAccount.outputs.resourceId}" --principal-id "${managedIdentity.outputs.principalId}"' -module deploymentScriptCLI 'br/public:avm/res/resources/deployment-script:0.5.1' = { - name: 'deploymentScriptCLI' +module cosmosRoleAssignmentScript 'br/public:avm/res/resources/deployment-script:0.5.1' = { + name: 'deploymentScriptCLI-${resourcePrefix}' params: { - // Required parameters kind: 'AzureCLI' name: 'rdsmin001' - // Non-required parameters azCliVersion: '2.69.0' location: resourceGroup().location managedIdentities: { userAssignedResourceIds: [ - managedIdentityModule.outputs.managedIdentityId + managedIdentity.outputs.resourceId ] } - scriptContent: cosmosAssignCli + scriptContent: script } } diff --git a/infra/main.bicepparam b/infra/main.bicepparam index a369041..65f20d5 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -1,4 +1,4 @@ using './main.bicep' -param AzureAiServiceLocation = readEnvironmentVariable('AZURE_LOCATION','japaneast') -param Prefix = readEnvironmentVariable('AZURE_ENV_NAME','azdtemp') +param azureAiServiceLocation = readEnvironmentVariable('AZURE_LOCATION','japaneast') +param prefix = readEnvironmentVariable('AZURE_ENV_NAME','azdtemp') From a2bfd27eb92681c0689e7f134f2c702ab5629e4d Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 28 May 2025 14:22:24 -0400 Subject: [PATCH 002/124] AVM - initial bicep module updates to AVM --- infra/deploy_ai_foundry.bicep | 25 +++++++++++++++++-------- infra/main.bicep | 8 +++----- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/infra/deploy_ai_foundry.bicep b/infra/deploy_ai_foundry.bicep index df5a5cd..446928e 100644 --- a/infra/deploy_ai_foundry.bicep +++ b/infra/deploy_ai_foundry.bicep @@ -30,6 +30,9 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { params: { name: storageAccountName location: location + managedIdentities: { + systemAssigned: true + } kind: 'StorageV2' skuName: 'Standard_LRS' publicNetworkAccess: 'Enabled' @@ -69,6 +72,9 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { associatedKeyVaultResourceId: keyVault.id associatedStorageAccountResourceId: storageAccount.outputs.resourceId publicNetworkAccess: 'Enabled' + managedIdentities: { + systemAssigned: true + } connections: [ { name: aiServicesName @@ -100,12 +106,14 @@ module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = location: location hubResourceId: hub.outputs.resourceId publicNetworkAccess: 'Enabled' - hbiWorkspace: false + managedIdentities: { + systemAssigned: true + } roleAssignments: [ { principalId: managedIdentityObjectId principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'Azure AI Developer' + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer } ] } @@ -113,13 +121,14 @@ module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = // get reference to the AI Hub project to get access to the discovery URL property (not presently available on AVM) // adjust this logic if support on the AVM module is added -// resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10-01' existing = { -// name: projectName -// dependsOn: [project] -// } +resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10-01' existing = { + name: projectName + dependsOn: [project] +} -//var aiProjectConnString = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' -var aiProjectConnString = '${location}.api.azureml.ms;${subscription().subscriptionId};${resourceGroup().name};${projectName}' +// TODO - assess if this works +var aiProjectConnString = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' +//var aiProjectConnString = '${location}.api.azureml.ms;${subscription().subscriptionId};${resourceGroup().name};${projectName}' resource projectConnStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault diff --git a/infra/main.bicep b/infra/main.bicep index a7f2dab..7b09949 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -62,6 +62,7 @@ module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { kind: 'AIServices' customSubDomainName: azureAiServicesName disableLocalAuth: false + publicNetworkAccess: 'Enabled' deployments: [ { name: llmModel @@ -171,6 +172,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. name: toLower('${resourcePrefix}manenv') location: location zoneRedundant: false + publicNetworkAccess: 'Enabled' managedIdentities: { userAssignedResourceIds: [ managedIdentity.outputs.resourceId @@ -187,11 +189,6 @@ module databaseAccount 'br/public:avm/res/document-db/database-account:0.15.0' = name: cosmosAccountName enableAnalyticalStorage: true location: dblocation - // managedIdentities: { - // userAssignedResourceIds: [ - // managedIdentity.outputs.resourceId - // ] - // } networkRestrictions: { networkAclBypass: 'AzureServices' publicNetworkAccess: 'Enabled' @@ -378,6 +375,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { cpu: 1 memory: '2.0Gi' } + } ] ingressTargetPort: 8000 From a3adf5dc7269fb41f1f188a872be18ed2ae7c61a Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 28 May 2025 16:43:42 -0400 Subject: [PATCH 003/124] AVM - removed modules, removed Owner assignment, moved sql role assignment to module --- infra/deploy_keyvault.bicep | 65 ----------------------- infra/deploy_managed_identity.bicep | 49 ----------------- infra/main.bicep | 48 ++++------------- infra/modules/fetch-container-image.bicep | 8 --- 4 files changed, 10 insertions(+), 160 deletions(-) delete mode 100644 infra/deploy_keyvault.bicep delete mode 100644 infra/deploy_managed_identity.bicep delete mode 100644 infra/modules/fetch-container-image.bicep diff --git a/infra/deploy_keyvault.bicep b/infra/deploy_keyvault.bicep deleted file mode 100644 index a10a9af..0000000 --- a/infra/deploy_keyvault.bicep +++ /dev/null @@ -1,65 +0,0 @@ -@minLength(3) -@maxLength(15) -@description('Solution Name') -param solutionName string -param solutionLocation string -param managedIdentityObjectId string - -param keyvaultName string - -resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { - name: keyvaultName - location: solutionLocation - properties: { - createMode: 'default' - accessPolicies: [ - { - objectId: managedIdentityObjectId - permissions: { - certificates: [ - 'all' - ] - keys: [ - 'all' - ] - secrets: [ - 'all' - ] - storage: [ - 'all' - ] - } - tenantId: subscription().tenantId - } - ] - enabledForDeployment: true - enabledForDiskEncryption: true - enabledForTemplateDeployment: true - enableRbacAuthorization: true - publicNetworkAccess: 'enabled' - sku: { - family: 'A' - name: 'standard' - } - softDeleteRetentionInDays: 7 - tenantId: subscription().tenantId - } -} - -@description('This is the built-in Key Vault Administrator role.') -resource kvAdminRole 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { - scope: resourceGroup() - name: '00482a5a-887f-4fb3-b363-3b7fe8e74483' -} - -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(resourceGroup().id, managedIdentityObjectId, kvAdminRole.id) - properties: { - principalId: managedIdentityObjectId - roleDefinitionId:kvAdminRole.id - principalType: 'ServicePrincipal' - } -} - -output keyvaultName string = keyvaultName -output keyvaultId string = keyVault.id diff --git a/infra/deploy_managed_identity.bicep b/infra/deploy_managed_identity.bicep deleted file mode 100644 index 6e0b9dc..0000000 --- a/infra/deploy_managed_identity.bicep +++ /dev/null @@ -1,49 +0,0 @@ -// ========== Managed Identity ========== // -targetScope = 'resourceGroup' - -@minLength(3) -@maxLength(15) -@description('Solution Name') -param solutionName string - -@description('Solution Location') -param solutionLocation string - -@description('Name') - -param miName string - - -resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { - name: miName - location: solutionLocation - tags: { - app: solutionName - location: solutionLocation - } -} - -@description('This is the built-in owner role. See https://docs.microsoft.com/azure/role-based-access-control/built-in-roles#owner') -resource ownerRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { - scope: resourceGroup() - name: '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' -} - -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(resourceGroup().id, managedIdentity.id, ownerRoleDefinition.id) - properties: { - principalId: managedIdentity.properties.principalId - roleDefinitionId: ownerRoleDefinition.id - principalType: 'ServicePrincipal' - } -} - -output managedIdentityOutput object = { - id: managedIdentity.id - objectId: managedIdentity.properties.principalId - resourceId: managedIdentity.id - location: managedIdentity.location - name: miName -} - -output managedIdentityId string = managedIdentity.id diff --git a/infra/main.bicep b/infra/main.bicep index 7b09949..4b2afb3 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -100,22 +100,6 @@ module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identit } } -// TODO - verifty if Owner is needed -@description('This is the built-in owner role. See https://docs.microsoft.com/azure/role-based-access-control/built-in-roles#owner') -resource ownerRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { - scope: resourceGroup() - name: '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' -} - -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(resourceGroup().id, managedIdentity.name, ownerRoleDefinition.id) - properties: { - principalId: managedIdentity.outputs.principalId - roleDefinitionId: ownerRoleDefinition.id - principalType: 'ServicePrincipal' - } -} - module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { name: take('${abbrs.security.keyVault}${resourcePrefix}-keyvault-deployment', 64) params: { @@ -183,6 +167,10 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. var cosmosAccountName = toLower('${abbrs.databases.cosmosDBDatabase}${resourcePrefix}databaseAccount') +resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { + name: '${cosmosAccountName}/00000000-0000-0000-0000-000000000002' +} + module databaseAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { name: 'cosmosdb-${cosmosAccountName}-deployment' params: { @@ -231,6 +219,12 @@ module databaseAccount 'br/public:avm/res/document-db/database-account:0.15.0' = name: cosmosdbDatabase } ] + dataPlaneRoleAssignments: [ + { + principalId: managedIdentity.outputs.principalId + roleDefinitionId: sqlContributorRoleDefinition.id + } + ] } } @@ -432,25 +426,3 @@ module storageAccountForContainers 'br/public:avm/res/storage/storage-account:0. ] } } - -resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { - name: '${cosmosAccountName}/00000000-0000-0000-0000-000000000002' -} - -var script = 'az cosmosdb sql role assignment create --resource-group "${resourceGroup().name}" --account-name "${databaseAccount.outputs.name}" --role-definition-id "${sqlContributorRoleDefinition.id}" --scope "${databaseAccount.outputs.resourceId}" --principal-id "${managedIdentity.outputs.principalId}"' - -module cosmosRoleAssignmentScript 'br/public:avm/res/resources/deployment-script:0.5.1' = { - name: 'deploymentScriptCLI-${resourcePrefix}' - params: { - kind: 'AzureCLI' - name: 'rdsmin001' - azCliVersion: '2.69.0' - location: resourceGroup().location - managedIdentities: { - userAssignedResourceIds: [ - managedIdentity.outputs.resourceId - ] - } - scriptContent: script - } -} diff --git a/infra/modules/fetch-container-image.bicep b/infra/modules/fetch-container-image.bicep deleted file mode 100644 index 78d1e7e..0000000 --- a/infra/modules/fetch-container-image.bicep +++ /dev/null @@ -1,8 +0,0 @@ -param exists bool -param name string - -resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) { - name: name -} - -output containers array = exists ? existingApp.properties.template.containers : [] From ada5d54218170d8bac70bdc2289dce3081ca5ded Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 29 May 2025 11:11:56 -0400 Subject: [PATCH 004/124] AVM - naming refactor, naming validation, new params, cosmos module --- infra/abbreviations.json | 6 +- infra/deploy_ai_foundry.bicep | 11 +- infra/main.bicep | 232 +++++++++++++--------------------- infra/main.bicepparam | 5 +- infra/modules/cosmosDb.bicep | 98 ++++++++++++++ 5 files changed, 198 insertions(+), 154 deletions(-) create mode 100644 infra/modules/cosmosDb.bicep diff --git a/infra/abbreviations.json b/infra/abbreviations.json index 93b9565..da3423f 100644 --- a/infra/abbreviations.json +++ b/infra/abbreviations.json @@ -1,7 +1,7 @@ { "ai": { "aiSearch": "srch-", - "aiServices": "aisa-", + "aiServices": "ais-", "aiVideoIndexer": "avi-", "machineLearningWorkspace": "mlw-", "openAIService": "oai-", @@ -18,8 +18,8 @@ "languageService": "lang-", "speechService": "spch-", "translator": "trsl-", - "aiHub": "aih-", - "aiHubProject": "aihp-" + "hub": "hub-", + "project": "proj-" }, "analytics": { "analysisServicesServer": "as", diff --git a/infra/deploy_ai_foundry.bicep b/infra/deploy_ai_foundry.bicep index 446928e..b62eb49 100644 --- a/infra/deploy_ai_foundry.bicep +++ b/infra/deploy_ai_foundry.bicep @@ -23,12 +23,11 @@ resource aiServices 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = var aiServicesKey = aiServices.listKeys().key1 var aiServicesEndpoint = aiServices.properties.endpoint -var storageAccountName = replace(replace(replace(replace('${storageName}cast', '-', ''), '_', ''), '.', ''),'/', '') module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { - name: 'foundry-storage-${storageAccountName}-deployment' + name: take('aifoundry-${storageName}-deployment', 64) params: { - name: storageAccountName + name: storageName location: location managedIdentities: { systemAssigned: true @@ -62,7 +61,7 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { } module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { - name: 'ai-hub-${hubName}-deployment' + name: take('ai-foundry-${hubName}-deployment', 64) params: { name: hubName location: location @@ -98,7 +97,7 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { } module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { - name: 'ai-project-${projectName}-deployment' + name: take('ai-foundry-${projectName}-deployment', 64) params: { name: projectName kind: 'Project' @@ -126,9 +125,7 @@ resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10- dependsOn: [project] } -// TODO - assess if this works var aiProjectConnString = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' -//var aiProjectConnString = '${location}.api.azureml.ms;${subscription().subscriptionId};${resourceGroup().name};${projectName}' resource projectConnStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { parent: keyVault diff --git a/infra/main.bicep b/infra/main.bicep index 4b2afb3..02e1634 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1,6 +1,11 @@ @minLength(3) -@description('prefix for all resources created by this template. This should be 3-20 characters long. If your provide a prefix longer than 20 characters, it will be truncated to 20 characters.') -param prefix string +@maxLength(20) +@description('A unique application/env name for all resources in this deployment. This should be 3-20 characters long') +param environmentName string + +@minLength(3) +@description('Azure region for all services.') +param location string = resourceGroup().location @allowed([ 'australiaeast' @@ -27,57 +32,52 @@ param prefix string 'westus' 'westus3' ]) -@description('Location for all Ai services resources. This location can be different from the resource group location.') -param azureAiServiceLocation string // The location used for all deployed resources. This location must be in the same region as the resource group. +@description('Location for all AI service resources. This location can be different from the resource group location.') +param azureAiServiceLocation string = location +@description('AI model deployment token capacity. Defaults to 5K tokens per minute.') param capacity int = 5 +@description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') +param tags object = {} + var abbrs = loadJsonContent('./abbreviations.json') -var safePrefix = length(prefix) > 20 ? substring(prefix, 0, 20) : prefix -var uniqueId = toLower(uniqueString(subscription().id, safePrefix, resourceGroup().location)) -var uniquePrefix = 'cm${padLeft(take(uniqueId, 12), 12, '0')}' -var resourcePrefix = take('cm${safePrefix}${uniquePrefix}', 15) -var imageVersion = 'latest' -var location = resourceGroup().location -var dblocation = resourceGroup().location -var cosmosdbDatabase = 'cmsadb' -var cosmosdbBatchContainer = 'cmsabatch' -var cosmosdbFileContainer = 'cmsafile' -var cosmosdbLogContainer = 'cmsalog' -var deploymentType = 'GlobalStandard' -var containerName = 'appstorage' -var llmModel = 'gpt-4o' -var storageSkuName = 'Standard_LRS' -var storageAccountForContainersName = replace(replace(replace(replace('${resourcePrefix}cast', '-', ''), '_', ''), '.', ''),'/', '') -var gptModelVersion = '2024-08-06' -var azureAiServicesName = '${abbrs.ai.aiServices}${resourcePrefix}' -var aiFoundryProjectName = '${abbrs.ai.aiHubProject}${resourcePrefix}' + +var resourcesName = trim(replace(replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) +var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) +var uniqueResourcesName = '${resourcesName}${resourcesToken}' + +var defaultTags = { + 'azd-env-name': resourcesName +} + +var allTags = union(defaultTags, tags) + +var modelDeployment = { + name: 'gpt-4o' + model: { + name: 'gpt-4o' + format: 'OpenAI' + version: '2024-08-06' + } + sku: { + name: 'GlobalStandard' + capacity: capacity + } + raiPolicyName: 'Microsoft.Default' +} module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { - name: take('cog-${azureAiServicesName}-deployment', 64) + name: take('aiservices-${resourcesName}-deployment', 64) params: { - name: azureAiServicesName + name: '${abbrs.ai.aiServices}${uniqueResourcesName}' location: location sku: 'S0' kind: 'AIServices' - customSubDomainName: azureAiServicesName + customSubDomainName: '${abbrs.ai.aiServices}${uniqueResourcesName}' disableLocalAuth: false publicNetworkAccess: 'Enabled' - deployments: [ - { - name: llmModel - model: { - name: llmModel - format: 'OpenAI' - version: gptModelVersion - } - sku: { - name: deploymentType - capacity: capacity - } - raiPolicyName: 'Microsoft.Default' - } - ] + deployments: [modelDeployment] roleAssignments: [ { principalId: managedIdentity.outputs.principalId @@ -85,25 +85,24 @@ module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' } ] + tags: allTags } } + module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { - name: take('${abbrs.security.managedIdentity}${resourcePrefix}-identity-deployment', 64) + name: take('identity-${resourcesName}-deployment', 64) params: { - name: '${abbrs.security.managedIdentity}${resourcePrefix}' + name: '${abbrs.security.managedIdentity}${resourcesName}' location: location - tags: { - app: resourcePrefix - location: location - } + tags: allTags } } module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { - name: take('${abbrs.security.keyVault}${resourcePrefix}-keyvault-deployment', 64) + name: take('keyvault-${resourcesName}-deployment', 64) params: { - name: '${abbrs.security.keyVault}${resourcePrefix}' + name: take('${abbrs.security.keyVault}${uniqueResourcesName}', 24) location: location createMode: 'default' sku: 'standard' @@ -120,6 +119,7 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { roleDefinitionIdOrName: 'Key Vault Administrator' } ] + tags: allTags } } @@ -136,24 +136,24 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { // } module azureAifoundry 'deploy_ai_foundry.bicep' = { - name: 'deploy_ai_foundry-${azureAiServicesName}' + name: take('aifoundry-${resourcesName}-deployment', 64) params: { location: azureAiServiceLocation - hubName: '${abbrs.ai.aiHub}${resourcePrefix}' - projectName: aiFoundryProjectName - storageName: '${abbrs.storage.storageAccount}${resourcePrefix}' + hubName: '${abbrs.ai.hub}${resourcesName}' + projectName: '${abbrs.ai.project}${resourcesName}' + storageName: take('${abbrs.storage.storageAccount}ai${uniqueResourcesName}', 24) keyVaultName: keyvault.outputs.name - gptModelName: llmModel - gptModelVersion: gptModelVersion + gptModelName: modelDeployment.model.name + gptModelVersion: modelDeployment.model.version managedIdentityObjectId: managedIdentity.outputs.principalId aiServicesName: azureAiServices.outputs.name } } module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.1' = { - name: toLower('${resourcePrefix}conAppsEnv') + name: take('container-env-${resourcesName}-deployment', 64) params: { - name: toLower('${resourcePrefix}manenv') + name: '${abbrs.containers.containerAppsEnvironment}${resourcesName}' location: location zoneRedundant: false publicNetworkAccess: 'Enabled' @@ -165,73 +165,22 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. } } -var cosmosAccountName = toLower('${abbrs.databases.cosmosDBDatabase}${resourcePrefix}databaseAccount') - -resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { - name: '${cosmosAccountName}/00000000-0000-0000-0000-000000000002' -} - -module databaseAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { - name: 'cosmosdb-${cosmosAccountName}-deployment' +module cosmosDb 'modules/cosmosDb.bicep' = { + name: take('cosmos-${resourcesName}-deployment', 64) params: { - name: cosmosAccountName - enableAnalyticalStorage: true - location: dblocation - networkRestrictions: { - networkAclBypass: 'AzureServices' - publicNetworkAccess: 'Enabled' - ipRules: [] - virtualNetworkRules: [] - } - zoneRedundant: false - disableKeyBasedMetadataWriteAccess: false - sqlDatabases: [ - { - containers: [ - { - indexingPolicy: { - automatic: true - } - name: cosmosdbBatchContainer - paths:[ - '/batch_id' - ] - } - { - indexingPolicy: { - automatic: true - } - name: cosmosdbFileContainer - paths:[ - '/file_id' - ] - } - { - indexingPolicy: { - automatic: true - } - name: cosmosdbLogContainer - paths:[ - '/log_id' - ] - } - ] - name: cosmosdbDatabase - } - ] - dataPlaneRoleAssignments: [ - { - principalId: managedIdentity.outputs.principalId - roleDefinitionId: sqlContributorRoleDefinition.id - } - ] + name: '${abbrs.databases.cosmosDBDatabase}${uniqueResourcesName}' + location: location + managedIdentityPrincipalId: managedIdentity.outputs.principalId + tags: allTags } } +var appStorageContainerName = 'appstorage' + module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { - name: toLower('${abbrs.containers.containerApp}${resourcePrefix}containerAppFrontend') + name: take('container-app-frontend-${resourcesName}-deployment', 64) params: { - name: toLower('${abbrs.containers.containerApp}${resourcePrefix}Frontend') + name: take('${abbrs.containers.containerApp}${uniqueResourcesName}frontend', 32) location: location environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { @@ -247,7 +196,7 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { value: 'https://${containerAppBackend.outputs.fqdn}' } ] - image: 'cmsacontainerreg.azurecr.io/cmsafrontend:${imageVersion}' + image: 'cmsacontainerreg.azurecr.io/cmsafrontend:latest' name: 'cmsafrontend' resources: { cpu: '1' @@ -265,9 +214,9 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { } module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { - name: toLower('${abbrs.containers.containerApp}${resourcePrefix}containerAppBackend') + name: take('container-app-backend-${resourcesName}-deployment', 64) params: { - name: toLower('${abbrs.containers.containerApp}${resourcePrefix}Backend') + name: take('${abbrs.containers.containerApp}${uniqueResourcesName}backend', 32) location: location environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { @@ -278,35 +227,35 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { containers: [ { name: 'cmsabackend' - image: 'cmsacontainerreg.azurecr.io/cmsabackend:${imageVersion}' + image: 'cmsacontainerreg.azurecr.io/cmsabackend:latest' env: [ { name: 'COSMOSDB_ENDPOINT' - value: databaseAccount.outputs.endpoint + value: cosmosDb.outputs.endpoint } { name: 'COSMOSDB_DATABASE' - value: cosmosdbDatabase + value: cosmosDb.outputs.databaseName } { name: 'COSMOSDB_BATCH_CONTAINER' - value: cosmosdbBatchContainer + value: cosmosDb.outputs.containers.batch.name } { name: 'COSMOSDB_FILE_CONTAINER' - value: cosmosdbFileContainer + value: cosmosDb.outputs.containers.file.name } { name: 'COSMOSDB_LOG_CONTAINER' - value: cosmosdbLogContainer + value: cosmosDb.outputs.containers.log.name } { name: 'AZURE_BLOB_ACCOUNT_NAME' - value: storageAccountForContainersName + value: storageAccountForContainers.outputs.name } { name: 'AZURE_BLOB_CONTAINER_NAME' - value: containerName + value: appStorageContainerName } { name: 'AZURE_OPENAI_ENDPOINT' @@ -314,35 +263,35 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { } { name: 'MIGRATOR_AGENT_MODEL_DEPLOY' - value: llmModel + value: modelDeployment.name } { name: 'PICKER_AGENT_MODEL_DEPLOY' - value: llmModel + value: modelDeployment.name } { name: 'FIXER_AGENT_MODEL_DEPLOY' - value: llmModel + value: modelDeployment.name } { name: 'SEMANTIC_VERIFIER_AGENT_MODEL_DEPLOY' - value: llmModel + value: modelDeployment.name } { name: 'SYNTAX_CHECKER_AGENT_MODEL_DEPLOY' - value: llmModel + value: modelDeployment.name } { name: 'SELECTION_MODEL_DEPLOY' - value: llmModel + value: modelDeployment.name } { name: 'TERMINATION_MODEL_DEPLOY' - value: llmModel + value: modelDeployment.name } { name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' - value: llmModel + value: modelDeployment.name } { name: 'AZURE_AI_AGENT_PROJECT_NAME' @@ -369,7 +318,6 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { cpu: 1 memory: '2.0Gi' } - } ] ingressTargetPort: 8000 @@ -382,15 +330,15 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { } module storageAccountForContainers 'br/public:avm/res/storage/storage-account:0.17.0' = { - name: 'storage-account-${storageAccountForContainersName}-deployment' + name: take('storage-apps-${resourcesName}-deployment', 64) params: { - name: storageAccountForContainersName + name: take('${abbrs.storage.storageAccount}app${uniqueResourcesName}', 24) location: location managedIdentities: { systemAssigned: true } kind: 'StorageV2' - skuName: storageSkuName + skuName: 'Standard_LRS' publicNetworkAccess: 'Enabled' accessTier: 'Hot' allowBlobPublicAccess: false @@ -410,7 +358,7 @@ module storageAccountForContainers 'br/public:avm/res/storage/storage-account:0. blobServices: { containers: [ { - name: containerName + name: appStorageContainerName properties: { publicAccess: 'None' } diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 65f20d5..631558f 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -1,4 +1,5 @@ using './main.bicep' -param azureAiServiceLocation = readEnvironmentVariable('AZURE_LOCATION','japaneast') -param prefix = readEnvironmentVariable('AZURE_ENV_NAME','azdtemp') +param location = readEnvironmentVariable('AZURE_LOCATION','japaneast') +param azureAiServiceLocation = location +param environmentName = readEnvironmentVariable('AZURE_ENV_NAME','azdtemp') diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep new file mode 100644 index 0000000..1d55a91 --- /dev/null +++ b/infra/modules/cosmosDb.bicep @@ -0,0 +1,98 @@ +@description('Name of the Cosmos DB Account.') +param name string + +@description('Specifies the location for all the Azure resources.') +param location string + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +@description('Managed Identity princpial to assign data plane roles for the Cosmos DB Account.') +param managedIdentityPrincipalId string + +resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { + name: '${name}/00000000-0000-0000-0000-000000000002' +} + +var databaseName = 'cmsadb' +var batchContainerName = 'cmsabatch' +var fileContainerName = 'cmsafile' +var logContainerName = 'cmsalog' + +module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { + name: take('${name}-account-deployment', 64) + params: { + name: name + enableAnalyticalStorage: true + location: location + networkRestrictions: { + networkAclBypass: 'AzureServices' + publicNetworkAccess: 'Enabled' + ipRules: [] + virtualNetworkRules: [] + } + zoneRedundant: false + disableKeyBasedMetadataWriteAccess: false + sqlDatabases: [ + { + containers: [ + { + indexingPolicy: { + automatic: true + } + name: batchContainerName + paths:[ + '/batch_id' + ] + } + { + indexingPolicy: { + automatic: true + } + name: fileContainerName + paths:[ + '/file_id' + ] + } + { + indexingPolicy: { + automatic: true + } + name: logContainerName + paths:[ + '/log_id' + ] + } + ] + name: databaseName + } + ] + dataPlaneRoleAssignments: [ + { + principalId: managedIdentityPrincipalId + roleDefinitionId: sqlContributorRoleDefinition.id + } + ] + tags: tags + } +} + +output resourceId string = cosmosAccount.outputs.resourceId +output name string = cosmosAccount.outputs.name +output endpoint string = cosmosAccount.outputs.endpoint +output databaseName string = databaseName + +output containers object = { + batch: { + name: batchContainerName + resourceId: '${cosmosAccount.outputs.resourceId}/sqlDatabases/${databaseName}/containers/${batchContainerName}' + } + file: { + name: fileContainerName + resourceId: '${cosmosAccount.outputs.resourceId}/sqlDatabases/${databaseName}/containers/${fileContainerName}' + } + log: { + name: logContainerName + resourceId: '${cosmosAccount.outputs.resourceId}/sqlDatabases/${databaseName}/containers/${logContainerName}' + } +} From 429805dbd94c0f8ad441e47d90cfbf301ca10421 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 29 May 2025 11:13:39 -0400 Subject: [PATCH 005/124] AVM - foundry module move and desc update --- infra/main.bicep | 2 +- infra/{deploy_ai_foundry.bicep => modules/aiFoundry.bicep} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename infra/{deploy_ai_foundry.bicep => modules/aiFoundry.bicep} (99%) diff --git a/infra/main.bicep b/infra/main.bicep index 02e1634..4b0daaa 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -135,7 +135,7 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { // } // } -module azureAifoundry 'deploy_ai_foundry.bicep' = { +module azureAifoundry 'modules/aiFoundry.bicep' = { name: take('aifoundry-${resourcesName}-deployment', 64) params: { location: azureAiServiceLocation diff --git a/infra/deploy_ai_foundry.bicep b/infra/modules/aiFoundry.bicep similarity index 99% rename from infra/deploy_ai_foundry.bicep rename to infra/modules/aiFoundry.bicep index b62eb49..25d496e 100644 --- a/infra/deploy_ai_foundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -11,7 +11,7 @@ param managedIdentityObjectId string param aiServicesName string -var aiHubDescription = 'AI Hub for KM template' +var aiHubDescription = 'AI Hub for Modernize Your Code' resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = { name: keyVaultName From d7dcffe79be7c231215001b35688585ee6c797b8 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 29 May 2025 11:40:15 -0400 Subject: [PATCH 006/124] AVM - removed unnecessary key vault entries, ai foundry refactoring --- infra/main.bicep | 13 ++- infra/modules/aiFoundry.bicep | 170 ++++++---------------------------- 2 files changed, 34 insertions(+), 149 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 4b0daaa..53cd184 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -89,7 +89,6 @@ module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { } } - module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { name: take('identity-${resourcesName}-deployment', 64) params: { @@ -140,13 +139,13 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { params: { location: azureAiServiceLocation hubName: '${abbrs.ai.hub}${resourcesName}' + hubDescription: 'AI Hub for Modernize Your Code' projectName: '${abbrs.ai.project}${resourcesName}' storageName: take('${abbrs.storage.storageAccount}ai${uniqueResourcesName}', 24) - keyVaultName: keyvault.outputs.name - gptModelName: modelDeployment.model.name - gptModelVersion: modelDeployment.model.version - managedIdentityObjectId: managedIdentity.outputs.principalId + keyVaultResourceId: keyvault.outputs.resourceId + managedIdentityPrincpalId: managedIdentity.outputs.principalId aiServicesName: azureAiServices.outputs.name + tags: allTags } } @@ -162,6 +161,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. managedIdentity.outputs.resourceId ] } + tags: allTags } } @@ -210,6 +210,7 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { minReplicas: 1 maxReplicas: 1 } + tags: allTags } } @@ -326,6 +327,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { minReplicas: 1 maxReplicas: 1 } + tags: allTags } } @@ -372,5 +374,6 @@ module storageAccountForContainers 'br/public:avm/res/storage/storage-account:0. roleDefinitionIdOrName: 'Storage Blob Data Contributor' } ] + tags: allTags } } diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index 25d496e..e84b24c 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -1,28 +1,35 @@ +@description('The Azure region where resources will be deployed.') param location string +@description('The name of the AI Foundry Project workspace.') param projectName string -param hubName string -param storageName string -param keyVaultName string -param gptModelName string -param gptModelVersion string -param managedIdentityObjectId string +@description('The name of the AI Foundry Hub workspace.') +param hubName string -param aiServicesName string +@description('The description of the AI Hub workspace.') +param hubDescription string = hubName -var aiHubDescription = 'AI Hub for Modernize Your Code' +@description('The name of the storage account to be created for AI Foundry.') +param storageName string -resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = { - name: keyVaultName -} +@description('The resource ID of the Azure Key Vault to associate with AI Foundry.') +param keyVaultResourceId string + +@description('The Princpal ID of the managed identity to assign access roles.') +param managedIdentityPrincpalId string + +@description('The name of an existing Azure Cognitive Services account.') +param aiServicesName string + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} resource aiServices 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { name: aiServicesName } var aiServicesKey = aiServices.listKeys().key1 -var aiServicesEndpoint = aiServices.properties.endpoint module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { name: take('aifoundry-${storageName}-deployment', 64) @@ -52,11 +59,12 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { supportsHttpsTrafficOnly: true roleAssignments: [ { - principalId: managedIdentityObjectId + principalId: managedIdentityPrincpalId principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'Storage Blob Data Contributor' } ] + tags: tags } } @@ -67,8 +75,8 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { location: location sku: 'Standard' kind: 'Hub' - description: aiHubDescription - associatedKeyVaultResourceId: keyVault.id + description: hubDescription + associatedKeyVaultResourceId: keyVaultResourceId associatedStorageAccountResourceId: storageAccount.outputs.resourceId publicNetworkAccess: 'Enabled' managedIdentities: { @@ -93,6 +101,7 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { } } ] + tags: tags } } @@ -110,11 +119,12 @@ module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = } roleAssignments: [ { - principalId: managedIdentityObjectId + principalId: managedIdentityPrincpalId principalType: 'ServicePrincipal' roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer } ] + tags: tags } } @@ -127,134 +137,6 @@ resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10- var aiProjectConnString = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' -resource projectConnStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-AI-PROJECT-CONN-STRING' - properties: { - value: aiProjectConnString - } -} - -resource openAiKeySecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-OPENAI-KEY' - properties: { - value: aiServicesKey - } -} - -resource cogServicesKeySecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'COG-SERVICES-KEY' - properties: { - value: aiServicesKey - } -} - -resource tenantIdSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'TENANT-ID' - properties: { - value: subscription().tenantId - } -} - -resource openAiInferenceEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-OPENAI-INFERENCE-ENDPOINT' - properties: { - value: '' - } -} - -resource openAiInferenceKeySecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-OPENAI-INFERENCE-KEY' - properties: { - value: '' - } -} - -resource openAiDeploymentModelSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-OPEN-AI-DEPLOYMENT-MODEL' - properties: { - value: gptModelName - } -} - -resource openAiPreviewApiVersionSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-OPENAI-PREVIEW-API-VERSION' - properties: { - value: gptModelVersion - } -} - -resource openAiEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-OPENAI-ENDPOINT' - properties: { - value: aiServicesEndpoint - } -} - -resource openAiCuVersionSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-OPENAI-CU-VERSION' - properties: { - value: '?api-version=2024-12-01-preview' - } -} - -resource azureSearchIndexSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-SEARCH-INDEX' - properties: { - value: 'transcripts_index' - } -} - -resource cogServicesEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'COG-SERVICES-ENDPOINT' - properties: { - value: aiServicesEndpoint - } -} - -resource cogServicesNameSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'COG-SERVICES-NAME' - properties: { - value: aiServices.name - } -} - -resource azureSubscriptionIdSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-SUBSCRIPTION-ID' - properties: { - value: subscription().subscriptionId - } -} - -resource azureResourceGroupSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-RESOURCE-GROUP' - properties: { - value: resourceGroup().name - } -} - -resource azureLocationSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { - parent: keyVault - name: 'AZURE-LOCATION' - properties: { - value: location - } -} - output projectName string = project.outputs.name output hubName string = hub.outputs.name From ba46485cba10135b33e3f881933f64ef3d5eff1c Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 29 May 2025 11:56:23 -0400 Subject: [PATCH 007/124] initial network and nsgs --- infra/Deployment_Plan.md | 135 ++++++++++++++++++++ infra/main_network.bicep | 143 ++++++++++++++++++++++ infra/main_network.bicepparam | 42 +++++++ infra/modules/bastionHost.bicep | 30 +++++ infra/modules/jumpbox.bicep | 39 ++++++ infra/modules/logAnalyticsWorkSpace.bicep | 26 ++++ infra/modules/network.bicep | 46 +++++++ infra/modules/nsg.bicep | 52 ++++++++ infra/modules/privateDnsZone.bicep | 22 ++++ infra/modules/routeTable.bicep | 43 +++++++ 10 files changed, 578 insertions(+) create mode 100644 infra/Deployment_Plan.md create mode 100644 infra/main_network.bicep create mode 100644 infra/main_network.bicepparam create mode 100644 infra/modules/bastionHost.bicep create mode 100644 infra/modules/jumpbox.bicep create mode 100644 infra/modules/logAnalyticsWorkSpace.bicep create mode 100644 infra/modules/network.bicep create mode 100644 infra/modules/nsg.bicep create mode 100644 infra/modules/privateDnsZone.bicep create mode 100644 infra/modules/routeTable.bicep diff --git a/infra/Deployment_Plan.md b/infra/Deployment_Plan.md new file mode 100644 index 0000000..acc56c1 --- /dev/null +++ b/infra/Deployment_Plan.md @@ -0,0 +1,135 @@ +# Deployment Plan + +The deployment code will be in the **infra** folder. The **modules** subfolder contains reusable, parameterized modules. There are two main deployment files: `main.bicep` (for quick, cost-effective solution exploration and demos) and `main.waf.bicep` (for production-grade, WAF-secured deployments). Both leverage the same modules, but with different parameters and security/networking posture. + +- **main.bicep**: Lightweight deployment for rapid prototyping, demos, and decision making. Minimal networking/security for speed and cost. +- **main.waf.bicep**: Production-ready deployment. Creates a Virtual Network with subnets, private endpoints for all solution resources (e.g., Azure AI Foundry, Storage, Cosmos DB, Key Vault), Bastion Host, and all networking/security resources recommended by WAF Security Guidelines. Uses Azure Verified Modules (AVM) where appropriate. See [Microsoft Azure Well-Architected Framework (WAF) Security design principles](https://learn.microsoft.com/en-us/azure/well-architected/security/principles) and [Azure Verified Modules (AVM)](https://azure.github.io/Azure-Verified-Modules/). + +If you are new to AVM BICEP implementation, refer to [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/). + +**All modules are parameterized for maximum reusability and maintainability. Use outputs from modules to wire dependencies (e.g., VNet/subnet IDs, resource IDs).** + +Below is an example deployment design. The Group (module) Name column shows how to group code in BICEP modules. + +**Solution Components** + +| Component Name | Notes | Subnet | +| ----------------------------------------- | ------------------------------------------------------------ | ----------------- | +| Log Analytics Workspace | monitoring **(global, not subnet) | | +| Application Insights | app-insights **(global, not subnet)** | | +| Container Apps Environment | Shared by two apps | app | +| Frontend Container App | | app | +| Backend Container App | | app | +| NSG for App Subnet | nsg | app | +| AI Hub | ai-foundry / **private end point** | ai | +| AI Project | ai-foundry / **private end point** | ai | +| AI Services | ai-foundry / **private end point** | ai | +| NSG for AI Subnet | nsg | ai | +| Managed Identity (RG, etc) | identity | | +| Key Vault | keyvault / **private end point** | data | +| Cosmos DB | cosmos-db/ **private end point** | data | +| Storage Account Front End | storage / **private end point** | data | +| Storage Account Back End | storage / **private end point** | data | +| NSG for Data Subnet | nsg | data | +| Bastion Host | bastion | bastion | +| NSG for Bastion | nsg | bastion | +| JumpBox VM | (your-jumpbox-module) | jumpbox | +| NSG for JumpBox | nsg | jumpbox | +| Route Table | routeTable | (associated subnets) | +| Private Endpoints | privateEndpoint | respective subnet | +| Private DNS Zone | privateDnsZone | vnet | + + + +### **Example Subnet/NSG Table** + +| Subnet | NSG Rules (Inbound) | NSG Rules (Outbound) | +|----------|-----------------------------------------------------|-----------------------------| +| web | 80/443 from internet or allowed IPs | To app, internet | +| app | From web subnet only | To data, PaaS | +| ai | From app subnet only | To data, PaaS | +| data | From app/ai/private endpoints | To PaaS, as needed | +| jumpbox | RDP/SSH from allowed IPs or Bastion | To internet, as needed | +| bastion | Platform-managed (Bastion Host only, no direct NSG) | To VMs for RDP/SSH | + +## **Monitoring & Logging** +- Enable diagnostic logs for all resources, send to Log Analytics Workspace. +- Enable Application Insights for all app components. + +## **DDoS Protection** +- Enable at the VNet level for public-facing workloads. + +## **Route Tables (UDRs)** +- Optional, but recommended for advanced routing (e.g., force tunneling through Azure Firewall if used). + +## Azure Bastion Host + +**Function:** + +- Azure Bastion is a managed PaaS service that provides secure RDP/SSH connectivity to your VMs directly from the Azure Portal, without exposing public IP addresses on those VMs. + +**How to Use:** + +- Deploy Bastion Host in a dedicated subnet (must be named `bastion` or `AzureBastionSubnet`). +- When you need to access a VM (like a JumpBox or any other VM) for admin purposes, use the “Connect” button in the Azure Portal and select “Bastion.” +- Your session runs over SSL in the browser—no public IP, no direct RDP/SSH from the internet. + +**Best Practice:** + +- Use Bastion Host for all admin access to VMs in your VNet. +- Never assign public IPs to your VMs. + +## JumpBox VM + +**Function:** + +- A JumpBox (or Jump Host) is a hardened VM (often Linux or Windows) that acts as a single entry point for admin access to other VMs or resources in your private network. +- It is typically used for scenarios where you need to run custom tools, scripts, or have a persistent admin environment. + +**How to Use:** + +- Deploy the JumpBox VM in its own subnet (e.g., `jumpbox`). +- Access the JumpBox via Bastion Host (never via public IP). +- From the JumpBox, you can SSH/RDP to other VMs, run scripts, or use it as a staging point for troubleshooting. + +**Best Practice:** + +- The JumpBox should have minimal software, be tightly controlled, and monitored. +- Use NSGs to restrict access to/from the JumpBox subnet. +- Use Bastion Host to access the JumpBox, not a public IP. + +## Deployment Considerations + +### Add Networking Components +- **Network Security Group (NSG):** Protects subnets and controls inbound/outbound traffic. One NSG per subnet is recommended. +- **Bastion Host:** Provides secure RDP/SSH access to VMs without exposing public IPs. +- **Route Table:** Custom route tables for advanced routing scenarios (optional, but recommended for segmented networks). +- **Private Endpoints:** For secure, private connectivity to PaaS services (Key Vault, Storage, Cosmos DB, etc.), add private endpoints in the relevant subnets. Use a dedicated PrivateEndpointSubnet for easier management. +- **Private DNS Zone:** Required for name resolution of private endpoints, ensuring resources in your VNet can resolve the private DNS names of Azure PaaS services to their private IP addresses. + +### Security & Best Practices +- Use Managed Identity for all services that support it. +- Store secrets and connection strings in Key Vault; never hardcode credentials. +- Enable diagnostic logging for all resources (send to Log Analytics Workspace). +- Apply NSGs to each subnet with least-privilege rules. +- Use Bastion Host for secure admin access. +- Consider DDoS Protection for the VNet if required. +- Use Private Endpoints for PaaS services to avoid public exposure. +- For all PaaS resources with private endpoints, set public network access to 'Deny'. +- Enable soft delete and purge protection on Key Vault and Storage Accounts. + +--- + +**For more information, see:** +- [Microsoft Azure Well-Architected Framework (WAF) Security design principles](https://learn.microsoft.com/en-us/azure/well-architected/security/principles) +- [Azure Verified Modules (AVM)](https://azure.github.io/Azure-Verified-Modules/) +- [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/) +- [Azure Application Gateway documentation](https://learn.microsoft.com/en-us/azure/application-gateway/overview) +- [Azure Bastion documentation](https://learn.microsoft.com/en-us/azure/bastion/bastion-overview) +- [Azure Private Endpoint documentation](https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview) +- [Azure Firewall documentation](https://learn.microsoft.com/en-us/azure/firewall/overview) +- [Azure DDoS Protection documentation](https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview) +- [Azure Key Vault documentation](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) +- [Azure Cosmos DB documentation](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) +- [Azure Storage documentation](https://learn.microsoft.com/en-us/azure/storage/common/storage-introduction) + diff --git a/infra/main_network.bicep b/infra/main_network.bicep new file mode 100644 index 0000000..e7aaad5 --- /dev/null +++ b/infra/main_network.bicep @@ -0,0 +1,143 @@ +@minLength(6) +@maxLength(25) +@description('Name of the solution. This is used to generate a short unique hash used in all resources.') +param solutionName string = 'Code Modernization' +param solutionType string = 'Solution Accelerator' + +param tags object = { + 'Solution Name': solutionName + 'Solution Type': solutionType +} + +/**************************************************************************/ +// prefix generation +/**************************************************************************/ +var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces +var resourceToken = toLower(uniqueString(subscription().id, cleanSolutionName)) +var resourceTokenTrimmed = length(resourceToken) > 5 ? substring(resourceToken, 0, 5) : resourceToken +var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) + +// Network parameters (these will be set via main_network.bicepparam) +param networkIsolation bool +param vnetName string +param location string = resourceGroup().location +param addressPrefixes array +param dnsServers array +param subnets array +param diagnosticSettings array = [] + +/**************************************************************************/ +// Network Resource Modules +/**************************************************************************/ + +// 1. Deploy the Virtual Network and Subnets +module network 'modules/network.bicep' = if (networkIsolation) { + name: '${prefix}network' + params: { + vnetName: vnetName + location: location + addressPrefixes: addressPrefixes + dnsServers: dnsServers + subnets: subnets + tags: tags + diagnosticSettings: diagnosticSettings + } +} + +// 2. Deploy NSGs for each subnet (example for web, app, ai, data, bastion, jumpbox) +module webNsg 'modules/nsg.bicep' = if (networkIsolation) { + name: '${prefix}webNsg' + params: { + nsgName: 'web-nsg' + location: location + tags: tags + } +} +module appNsg 'modules/nsg.bicep' = if (networkIsolation) { + name: '${prefix}appNsg' + params: { + nsgName: '${prefix}appNsg' + location: location + tags: tags + } +} +module aiNsg 'modules/nsg.bicep' = if (networkIsolation) { + name: '${prefix}aiNsg' + params: { + nsgName: '${prefix}aiNsg' + location: location + tags: tags + } +} +module dataNsg 'modules/nsg.bicep' = if (networkIsolation) { + name: '${prefix}dataNsg' + params: { + nsgName: '${prefix}dataNsg' + location: location + tags: tags + } +} +module bastionNsg 'modules/nsg.bicep' = if (networkIsolation) { + name: '${prefix}bastionNsg' + params: { + nsgName: '${prefix}bastionNsg' + location: location + tags: tags + } +} +module jumpboxNsg 'modules/nsg.bicep' = if (networkIsolation) { + name: '${prefix}jumpboxNsg' + params: { + nsgName: '${prefix}jumpboxNsg' + location: location + tags: tags + } +} + +// 3. Deploy Route Tables (example for web and app subnets) +module webRouteTable 'modules/routeTable.bicep' = if (networkIsolation) { + name: '${prefix}webRouteTable' + params: { + routeTableName: '${prefix}webRouteTable' + location: location + tags: tags + } +} +module appRouteTable 'modules/routeTable.bicep' = { + name: '${prefix}appRouteTable' + params: { + routeTableName: '${prefix}appRouteTable' + location: location + tags: tags + } +} + + + + + +// 4. (Optional) Deploy Bastion Host and JumpBox VM using outputs from network module +// module bastionHost 'modules/bastionHost.bicep' = { +// name: 'bastionHost' +// params: { +// bastionHostName: 'bastion-host' +// location: location +// vnetId: network.outputs.vnetId +// subnetId: network.outputs.subnetIds[4] // index for 'bastion' subnet +// tags: tags +// } +// } +// module jumpbox 'modules/jumpbox.bicep' = { +// name: 'jumpbox' +// params: { +// vmName: 'jumpbox-vm' +// location: location +// subnetId: network.outputs.subnetIds[5] // index for 'jumpbox' subnet +// adminUsername: '' +// adminPasswordOrKey: '' +// tags: tags +// } +// } + +// Note: To associate NSGs and route tables to subnets, update the subnets array in your parameter file to include nsgId and routeTableId referencing the outputs above. + diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam new file mode 100644 index 0000000..728efff --- /dev/null +++ b/infra/main_network.bicepparam @@ -0,0 +1,42 @@ +// Parameters for main_network.bicep +// Use this file to provide default values for your network deployment + +using './main_network.bicep' + +param networkIsolation = true + +param vnetName = 'my-vnet' +param addressPrefixes = [ + '10.0.0.0/20' // 4,096 IP addresses. Other options: (1) /16: 65,536 (2) /24: 256 Addresses +] +param dnsServers = [ + '10.0.1.4' + '10.0.1.5' +] +param subnets = [ + { + name: 'web' + addressPrefix: '10.0.1.0/24' + } + { + name: 'app' + addressPrefix: '10.0.2.0/24' + } + { + name: 'ai' + addressPrefix: '10.0.3.0/24' + } + { + name: 'data' + addressPrefix: '10.0.4.0/24' + } + { + name: 'bastion' + addressPrefix: '10.0.5.0/24' + } + { + name: 'jumpbox' + addressPrefix: '10.0.6.0/24' + } +] + diff --git a/infra/modules/bastionHost.bicep b/infra/modules/bastionHost.bicep new file mode 100644 index 0000000..0a0ff24 --- /dev/null +++ b/infra/modules/bastionHost.bicep @@ -0,0 +1,30 @@ +// Creates an Azure Bastion Host using AVM +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host + +@description('Name of the Bastion Host') +param bastionHostName string + +@description('Azure region for the Bastion Host') +param location string = resourceGroup().location + +@description('Resource ID of the VNet') +param vnetId string + +@description('Resource ID of the Bastion subnet') +param subnetId string + +@description('Optional: Tags for the Bastion Host') +param tags object = {} + +module bastion 'br/public:avm/res/network/bastion-host:0.2.2' = { + name: bastionHostName + params: { + name: bastionHostName + location: location + virtualNetworkResourceId: vnetId + subnetResourceId: subnetId + tags: tags + } +} + +output bastionHostId string = bastion.outputs.resourceId diff --git a/infra/modules/jumpbox.bicep b/infra/modules/jumpbox.bicep new file mode 100644 index 0000000..23e2247 --- /dev/null +++ b/infra/modules/jumpbox.bicep @@ -0,0 +1,39 @@ +// Creates a JumpBox VM using AVM +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/compute/virtual-machine + +@description('Name of the JumpBox VM') +param vmName string + +@description('Azure region for the VM') +param location string = resourceGroup().location + +@description('Resource ID of the subnet for the VM') +param subnetId string + +@description('Admin username') +param adminUsername string + +@description('Admin password or SSH public key') +@secure() +param adminPasswordOrKey string + +@description('VM size (e.g., "Standard_B2ms")') +param vmSize string = 'Standard_B2ms' + +@description('Optional: Tags for the VM') +param tags object = {} + +module jumpbox 'br/public:avm/res/compute/virtual-machine:0.4.2' = { + name: vmName + params: { + name: vmName + location: location + subnetResourceId: subnetId + adminUsername: adminUsername + adminPasswordOrKey: adminPasswordOrKey + vmSize: vmSize + tags: tags + } +} + +output vmId string = jumpbox.outputs.resourceId diff --git a/infra/modules/logAnalyticsWorkSpace.bicep b/infra/modules/logAnalyticsWorkSpace.bicep new file mode 100644 index 0000000..f608738 --- /dev/null +++ b/infra/modules/logAnalyticsWorkSpace.bicep @@ -0,0 +1,26 @@ +// Creates a Log Analytics Workspace using the AVM module + +//https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/operational-insights/workspace + + +@description('Name of the Log Analytics Workspace') +param logAnalyticsWorkSpaceName string + +@description('Azure region for the workspace') +param location string = resourceGroup().location + +@description('Optional: Tags for the workspace') +param tags object = {} + +module workspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = { + name: logAnalyticsWorkSpaceName + params: { + // Required parameters + name: logAnalyticsWorkSpaceName + // Optional parameters + location: location + tags:tags + } +} + +output workspaceId string = workspace.outputs.resourceId diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep new file mode 100644 index 0000000..dbd0ffc --- /dev/null +++ b/infra/modules/network.bicep @@ -0,0 +1,46 @@ +// networking.bicep +// Creates a VNet and subnets for the solution using AVM modules +//https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network + +param vnetName string +param location string + +@description('Optional: Tags for the VNet') +param tags object = {} + +@description('Optional: Address prefixes for the VNet') +param addressPrefixes array + +@description('Optional: DNS servers for the VNet') +param dnsServers array = [] + +// Subnet definitions as an array of objects +@description('Subnets to create in the VNet') +param subnets array + +@description('Optional: Diagnostic settings for the VNet') +param diagnosticSettings array = [] + +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { + name: vnetName + params: { + addressPrefixes: addressPrefixes + name: vnetName + dnsServers: dnsServers + location: location + subnets: [ + for subnet in subnets: { + name: subnet.name + addressPrefix: subnet.addressPrefix + + } + ] + diagnosticSettings: diagnosticSettings + tags: tags + } +} + +output vnetName string = virtualNetwork.outputs.name +output vnetLocation string = virtualNetwork.outputs.location +output vnetId string = virtualNetwork.outputs.resourceId +output subnetIds array = virtualNetwork.outputs.subnetResourceIds diff --git a/infra/modules/nsg.bicep b/infra/modules/nsg.bicep new file mode 100644 index 0000000..9477a31 --- /dev/null +++ b/infra/modules/nsg.bicep @@ -0,0 +1,52 @@ +// Creates a Network Security Group (NSG) using AVM modules +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group + +@description('Name of the Network Security Group') +param nsgName string + +@description('Azure region for the NSG') +param location string = resourceGroup().location + +@description('Optional: Tags for the NSG') +param tags object = {} + +@description('Optional: Security rules for the NSG') +param securityRules array = [ + { + name: 'AllowHttpsInbound' + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + } +] + +module networkSecurityGroup 'br/public:avm/res/network/network-security-group:0.5.1' = { + name: nsgName + params: { + name: nsgName + location: location + securityRules: [ + for rule in securityRules: { + name: rule.name + properties: { + priority: rule.priority + direction: rule.direction + access: rule.access + protocol: rule.protocol + sourcePortRange: rule.sourcePortRange + destinationPortRange: rule.destinationPortRange + sourceAddressPrefix: rule.sourceAddressPrefix + destinationAddressPrefix: rule.destinationAddressPrefix + } + } + ] + tags: tags + } +} + +output nsgName string = networkSecurityGroup.outputs.name diff --git a/infra/modules/privateDnsZone.bicep b/infra/modules/privateDnsZone.bicep new file mode 100644 index 0000000..aefc2e3 --- /dev/null +++ b/infra/modules/privateDnsZone.bicep @@ -0,0 +1,22 @@ +// Creates a Private DNS Zone using AVM +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/private-dns-zone + +@description('Name of the Private DNS Zone (e.g., "privatelink.vaultcore.azure.net")') +param dnsZoneName string + +@description('Azure region for the DNS Zone') +param location string = resourceGroup().location + +@description('Optional: Tags for the DNS Zone') +param tags object = {} + +module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.2.2' = { + name: dnsZoneName + params: { + name: dnsZoneName + location: location + tags: tags + } +} + +output dnsZoneId string = privateDnsZone.outputs.resourceId diff --git a/infra/modules/routeTable.bicep b/infra/modules/routeTable.bicep new file mode 100644 index 0000000..c74101f --- /dev/null +++ b/infra/modules/routeTable.bicep @@ -0,0 +1,43 @@ +// Creates a Route Table using AVM modules +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/route-table + +@description('Name of the Route Table') +param routeTableName string + +@description('Azure region for the Route Table') +param location string = resourceGroup().location + +@description('Optional: Tags for the Route Table') +param tags object = {} + +@description('Optional: Routes for the Route Table') +param routes array = [ + // Example route + // { + // name: 'defaultRoute' + // addressPrefix: '0.0.0.0/0' + // nextHopType: 'Internet' + // } +] + +module routeTable 'br/public:avm/res/network/route-table:0.4.1' = { + name: routeTableName + params: { + name: routeTableName + location: location + routes: [ + for route in routes: { + name: route.name + properties: { + addressPrefix: route.addressPrefix + nextHopType: route.nextHopType + nextHopIpAddress: route.nextHopIpAddress + } + } + ] + tags: tags + } +} + +output routeTableId string = routeTable.outputs.resourceId +output routeTableName string = routeTable.outputs.name From 68d72c65cd447797087335f6145b073502b1fafe Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 29 May 2025 14:43:15 -0400 Subject: [PATCH 008/124] AVM - waf-aligned monitoring --- infra/main.bicep | 109 +++++++++++++++++++++++----------- infra/modules/aiFoundry.bicep | 6 ++ infra/modules/cosmosDb.bicep | 4 ++ 3 files changed, 84 insertions(+), 35 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 53cd184..e9b93ac 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -38,6 +38,9 @@ param azureAiServiceLocation string = location @description('AI model deployment token capacity. Defaults to 5K tokens per minute.') param capacity int = 5 +@description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') +param enableMonitoring bool = false + @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} @@ -50,7 +53,6 @@ var uniqueResourcesName = '${resourcesName}${resourcesToken}' var defaultTags = { 'azd-env-name': resourcesName } - var allTags = union(defaultTags, tags) var modelDeployment = { @@ -67,6 +69,38 @@ var modelDeployment = { raiPolicyName: 'Microsoft.Default' } +module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('identity-${resourcesName}-deployment', 64) + params: { + name: '${abbrs.security.managedIdentity}${resourcesName}' + location: location + tags: allTags + } +} + +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring) { + name: take('log-analytics-${resourcesName}-deployment', 64) + params: { + name: '${abbrs.managementGovernance.logAnalyticsWorkspace}${resourcesName}' + location: location + skuName: 'PerGB2018' + dataRetention: 30 + diagnosticSettings: [{ useThisWorkspace: true }] + tags: allTags + } +} + +module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (enableMonitoring) { + name: take('app-insights-${resourcesName}-deployment', 64) + params: { + name: '${abbrs.managementGovernance.applicationInsights}${resourcesName}' + location: location + workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId }] + tags: allTags + } +} + module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { name: take('aiservices-${resourcesName}-deployment', 64) params: { @@ -78,6 +112,7 @@ module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { disableLocalAuth: false publicNetworkAccess: 'Enabled' deployments: [modelDeployment] + diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] roleAssignments: [ { principalId: managedIdentity.outputs.principalId @@ -89,15 +124,6 @@ module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { } } -module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { - name: take('identity-${resourcesName}-deployment', 64) - params: { - name: '${abbrs.security.managedIdentity}${resourcesName}' - location: location - tags: allTags - } -} - module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { name: take('keyvault-${resourcesName}-deployment', 64) params: { @@ -111,6 +137,7 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { enableRbacAuthorization: true publicNetworkAccess: 'Enabled' softDeleteRetentionInDays: 7 + diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] roleAssignments: [ { principalId: managedIdentity.outputs.principalId @@ -122,18 +149,6 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { } } -// TODO - verify if this is needed - -// module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.0' = { -// name: 'log-analytics-deployment' -// params: { -// name: '${abbrs.managementGovernance.logAnalyticsWorkspace}${resourcePrefix}' -// location: location -// skuName: 'PerGB2018' -// dataRetention: 30 -// } -// } - module azureAifoundry 'modules/aiFoundry.bicep' = { name: take('aifoundry-${resourcesName}-deployment', 64) params: { @@ -144,13 +159,27 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { storageName: take('${abbrs.storage.storageAccount}ai${uniqueResourcesName}', 24) keyVaultResourceId: keyvault.outputs.resourceId managedIdentityPrincpalId: managedIdentity.outputs.principalId + logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' aiServicesName: azureAiServices.outputs.name tags: allTags } } -module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.1' = { +module cosmosDb 'modules/cosmosDb.bicep' = { + name: take('cosmos-${resourcesName}-deployment', 64) + params: { + name: '${abbrs.databases.cosmosDBDatabase}${uniqueResourcesName}' + location: location + managedIdentityPrincipalId: managedIdentity.outputs.principalId + logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' + tags: allTags + } +} + +module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.2' = { name: take('container-env-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [applicationInsights] // required due to optional flags that could change dependency params: { name: '${abbrs.containers.containerAppsEnvironment}${resourcesName}' location: location @@ -161,16 +190,14 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. managedIdentity.outputs.resourceId ] } - tags: allTags - } -} - -module cosmosDb 'modules/cosmosDb.bicep' = { - name: take('cosmos-${resourcesName}-deployment', 64) - params: { - name: '${abbrs.databases.cosmosDBDatabase}${uniqueResourcesName}' - location: location - managedIdentityPrincipalId: managedIdentity.outputs.principalId + appInsightsConnectionString: enableMonitoring ? applicationInsights.outputs.connectionString : null + appLogsConfiguration: enableMonitoring ? { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.outputs.logAnalyticsWorkspaceId + sharedKey: logAnalyticsWorkspace.outputs.primarySharedKey + } + } : {} tags: allTags } } @@ -216,6 +243,8 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { name: take('container-app-backend-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [applicationInsights] // required due to optional flags that could change dependency params: { name: take('${abbrs.containers.containerApp}${uniqueResourcesName}backend', 32) location: location @@ -229,7 +258,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { { name: 'cmsabackend' image: 'cmsacontainerreg.azurecr.io/cmsabackend:latest' - env: [ + env: concat([ { name: 'COSMOSDB_ENDPOINT' value: cosmosDb.outputs.endpoint @@ -314,7 +343,16 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { name: 'AZURE_CLIENT_ID' value: managedIdentity.outputs.clientId // TODO - VERIFY -> NOTE: This is the client ID of the managed identity, not the Entra application, and is needed for the App Service to access the Cosmos DB account. } - ] + ], enableMonitoring ? [ + { + name: 'APPLICATIONINSIGHTS_INSTRUMENTATION_KEY' + value: applicationInsights.outputs.instrumentationKey + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.outputs.connectionString + } + ] : []) resources: { cpu: 1 memory: '2.0Gi' @@ -357,6 +395,7 @@ module storageAccountForContainers 'br/public:avm/res/storage/storage-account:0. defaultAction: 'Allow' } supportsHttpsTrafficOnly: true + diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] blobServices: { containers: [ { diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index e84b24c..502789c 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -19,6 +19,9 @@ param keyVaultResourceId string @description('The Princpal ID of the managed identity to assign access roles.') param managedIdentityPrincpalId string +@description('Optional. The resource ID of an existing Log Analytics workspace to associate with AI Foundry for monitoring.') +param logAnalyticsWorkspaceResourceId string? + @description('The name of an existing Azure Cognitive Services account.') param aiServicesName string @@ -57,6 +60,7 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { defaultAction: 'Allow' } supportsHttpsTrafficOnly: true + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] roleAssignments: [ { principalId: managedIdentityPrincpalId @@ -82,6 +86,7 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { managedIdentities: { systemAssigned: true } + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] connections: [ { name: aiServicesName @@ -117,6 +122,7 @@ module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = managedIdentities: { systemAssigned: true } + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] roleAssignments: [ { principalId: managedIdentityPrincpalId diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep index 1d55a91..c7a75fe 100644 --- a/infra/modules/cosmosDb.bicep +++ b/infra/modules/cosmosDb.bicep @@ -10,6 +10,9 @@ param tags object = {} @description('Managed Identity princpial to assign data plane roles for the Cosmos DB Account.') param managedIdentityPrincipalId string +@description('Optional. The resource ID of an existing Log Analytics workspace to associate with AI Foundry for monitoring.') +param logAnalyticsWorkspaceResourceId string? + resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { name: '${name}/00000000-0000-0000-0000-000000000002' } @@ -33,6 +36,7 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { } zoneRedundant: false disableKeyBasedMetadataWriteAccess: false + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] sqlDatabases: [ { containers: [ From 9bb1c607e2e4ddd9860e1059f2140b8f4fa6ba08 Mon Sep 17 00:00:00 2001 From: Seth Date: Fri, 30 May 2025 09:37:14 -0400 Subject: [PATCH 009/124] WAF - removed duplicate storage accounts. added flags for scaling, redundancy, and failover --- infra/main.bicep | 145 +++++++++++++++++++++------------- infra/modules/aiFoundry.bicep | 47 +---------- infra/modules/cosmosDb.bicep | 15 +++- 3 files changed, 109 insertions(+), 98 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index e9b93ac..42859fd 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -41,6 +41,15 @@ param capacity int = 5 @description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') param enableMonitoring bool = false +@description('Enable scaling for the container apps. Defaults to false.') +param enableScaling bool = false + +@description('Enable redundancy for applicable resources. Defaults to false.') +param enableRedundancy bool = false + +@description('Optional. The secondary location for the Cosmos DB account if redundancy is enabled.') +param secondaryLocation string? + @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} @@ -50,6 +59,8 @@ var resourcesName = trim(replace(replace(replace(replace(replace(environmentName var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) var uniqueResourcesName = '${resourcesName}${resourcesToken}' +var appStorageContainerName = 'appstorage' + var defaultTags = { 'azd-env-name': resourcesName } @@ -101,6 +112,51 @@ module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (en } } +module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { + name: take('storage-account-${resourcesName}-deployment', 64) + params: { + name: take('${abbrs.storage.storageAccount}${uniqueResourcesName}', 24) + location: location + kind: 'StorageV2' + skuName: enableRedundancy ? 'Standard_LRS' : 'Standard_GZRS' + publicNetworkAccess: 'Enabled' + accessTier: 'Hot' + allowBlobPublicAccess: false + allowSharedKeyAccess: false + allowCrossTenantReplication: false + requireInfrastructureEncryption: false + keyType: 'Service' + enableHierarchicalNamespace: false + enableNfsV3: false + largeFileSharesState: 'Disabled' + minimumTlsVersion: 'TLS1_2' + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Allow' + } + supportsHttpsTrafficOnly: true + diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] + blobServices: { + containers: [ + { + name: appStorageContainerName + properties: { + publicAccess: 'None' + } + } + ] + } + roleAssignments: [ + { + principalId: managedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + } + ] + tags: allTags + } +} + module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { name: take('aiservices-${resourcesName}-deployment', 64) params: { @@ -142,7 +198,7 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { { principalId: managedIdentity.outputs.principalId principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'Key Vault Administrator' + roleDefinitionIdOrName: 'Key Vault Secrets User' } ] tags: allTags @@ -156,7 +212,7 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { hubName: '${abbrs.ai.hub}${resourcesName}' hubDescription: 'AI Hub for Modernize Your Code' projectName: '${abbrs.ai.project}${resourcesName}' - storageName: take('${abbrs.storage.storageAccount}ai${uniqueResourcesName}', 24) + storageAccountResourceId: storageAccount.outputs.resourceId keyVaultResourceId: keyvault.outputs.resourceId managedIdentityPrincpalId: managedIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' @@ -172,6 +228,8 @@ module cosmosDb 'modules/cosmosDb.bicep' = { location: location managedIdentityPrincipalId: managedIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' + zoneRedundant: enableRedundancy + failoverLocation: enableRedundancy && !empty(secondaryLocation) ? secondaryLocation : '' tags: allTags } } @@ -183,7 +241,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. params: { name: '${abbrs.containers.containerAppsEnvironment}${resourcesName}' location: location - zoneRedundant: false + zoneRedundant: enableRedundancy publicNetworkAccess: 'Enabled' managedIdentities: { userAssignedResourceIds: [ @@ -202,8 +260,6 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. } } -var appStorageContainerName = 'appstorage' - module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { name: take('container-app-frontend-${resourcesName}-deployment', 64) params: { @@ -234,8 +290,18 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { ingressTargetPort: 3000 ingressExternal: true scaleSettings: { + maxReplicas: enableScaling ? 3 : 1 minReplicas: 1 - maxReplicas: 1 + rules: enableScaling ? [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: 100 + } + } + } + ] : [] } tags: allTags } @@ -281,7 +347,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { } { name: 'AZURE_BLOB_ACCOUNT_NAME' - value: storageAccountForContainers.outputs.name + value: storageAccount.outputs.name } { name: 'AZURE_BLOB_CONTAINER_NAME' @@ -357,62 +423,35 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { cpu: 1 memory: '2.0Gi' } + probes: enableMonitoring ? [ + { + httpGet: { + path: '/health' + port: 8000 + } + initialDelaySeconds: 3 + periodSeconds: 3 + type: 'Liveness' + } + ] : [] } ] ingressTargetPort: 8000 ingressExternal: true scaleSettings: { + maxReplicas: enableScaling ? 3 : 1 minReplicas: 1 - maxReplicas: 1 - } - tags: allTags - } -} - -module storageAccountForContainers 'br/public:avm/res/storage/storage-account:0.17.0' = { - name: take('storage-apps-${resourcesName}-deployment', 64) - params: { - name: take('${abbrs.storage.storageAccount}app${uniqueResourcesName}', 24) - location: location - managedIdentities: { - systemAssigned: true - } - kind: 'StorageV2' - skuName: 'Standard_LRS' - publicNetworkAccess: 'Enabled' - accessTier: 'Hot' - allowBlobPublicAccess: false - allowSharedKeyAccess: false - allowCrossTenantReplication: false - requireInfrastructureEncryption: false - keyType: 'Service' - enableHierarchicalNamespace: false - enableNfsV3: false - largeFileSharesState: 'Disabled' - minimumTlsVersion: 'TLS1_2' - networkAcls: { - bypass: 'AzureServices' - defaultAction: 'Allow' - } - supportsHttpsTrafficOnly: true - diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] - blobServices: { - containers: [ + rules: enableScaling ? [ { - name: appStorageContainerName - properties: { - publicAccess: 'None' + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: 100 + } } } - ] + ] : [] } - roleAssignments: [ - { - principalId: managedIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'Storage Blob Data Contributor' - } - ] tags: allTags } } diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index 502789c..6c86c1d 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -10,8 +10,8 @@ param hubName string @description('The description of the AI Hub workspace.') param hubDescription string = hubName -@description('The name of the storage account to be created for AI Foundry.') -param storageName string +@description('The Resource Id of an existing storage account to attach to AI Foundry.') +param storageAccountResourceId string @description('The resource ID of the Azure Key Vault to associate with AI Foundry.') param keyVaultResourceId string @@ -34,44 +34,6 @@ resource aiServices 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = var aiServicesKey = aiServices.listKeys().key1 -module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { - name: take('aifoundry-${storageName}-deployment', 64) - params: { - name: storageName - location: location - managedIdentities: { - systemAssigned: true - } - kind: 'StorageV2' - skuName: 'Standard_LRS' - publicNetworkAccess: 'Enabled' - accessTier: 'Hot' - allowBlobPublicAccess: false - allowSharedKeyAccess: false - allowCrossTenantReplication: false - requireInfrastructureEncryption: false - keyType: 'Service' - enableHierarchicalNamespace: false - enableNfsV3: false - largeFileSharesState: 'Disabled' - minimumTlsVersion: 'TLS1_2' - networkAcls: { - bypass: 'AzureServices' - defaultAction: 'Allow' - } - supportsHttpsTrafficOnly: true - diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] - roleAssignments: [ - { - principalId: managedIdentityPrincpalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'Storage Blob Data Contributor' - } - ] - tags: tags - } -} - module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { name: take('ai-foundry-${hubName}-deployment', 64) params: { @@ -81,7 +43,7 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { kind: 'Hub' description: hubDescription associatedKeyVaultResourceId: keyVaultResourceId - associatedStorageAccountResourceId: storageAccount.outputs.resourceId + associatedStorageAccountResourceId: storageAccountResourceId publicNetworkAccess: 'Enabled' managedIdentities: { systemAssigned: true @@ -146,7 +108,4 @@ var aiProjectConnString = '${split(projectReference.properties.discoveryUrl, '/' output projectName string = project.outputs.name output hubName string = hub.outputs.name -output storageAccountName string = storageAccount.outputs.name -output storageAccountId string = storageAccount.outputs.resourceId - output projectConnectionString string = aiProjectConnString diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep index c7a75fe..3d975ca 100644 --- a/infra/modules/cosmosDb.bicep +++ b/infra/modules/cosmosDb.bicep @@ -13,6 +13,12 @@ param managedIdentityPrincipalId string @description('Optional. The resource ID of an existing Log Analytics workspace to associate with AI Foundry for monitoring.') param logAnalyticsWorkspaceResourceId string? +@description('Indicates whether the single-region account is zone redundant. This property is ignored for multi-region accounts.') +param zoneRedundant bool + +@description('Optional. The failover location for the Cosmos DB Account.') +param failoverLocation string? + resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { name: '${name}/00000000-0000-0000-0000-000000000002' } @@ -34,7 +40,7 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { ipRules: [] virtualNetworkRules: [] } - zoneRedundant: false + zoneRedundant: zoneRedundant disableKeyBasedMetadataWriteAccess: false diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] sqlDatabases: [ @@ -71,6 +77,13 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { name: databaseName } ] + failoverLocations: !empty(failoverLocation) ? [ + { + failoverPriority: 0 + isZoneRedundant: zoneRedundant + locationName: failoverLocation! + } + ] : [] dataPlaneRoleAssignments: [ { principalId: managedIdentityPrincipalId From a86a7aa32dd921a134db1173b9b75d19e26c1d68 Mon Sep 17 00:00:00 2001 From: Seth Date: Fri, 30 May 2025 10:40:35 -0400 Subject: [PATCH 010/124] WAF - cosmos redundancy and multi location --- infra/main.bicep | 11 ++--------- infra/main.bicepparam | 2 ++ infra/modules/cosmosDb.bicep | 27 ++++++++++++++++++--------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 42859fd..c0ce62c 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -194,13 +194,6 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { publicNetworkAccess: 'Enabled' softDeleteRetentionInDays: 7 diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] - roleAssignments: [ - { - principalId: managedIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'Key Vault Secrets User' - } - ] tags: allTags } } @@ -229,7 +222,7 @@ module cosmosDb 'modules/cosmosDb.bicep' = { managedIdentityPrincipalId: managedIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' zoneRedundant: enableRedundancy - failoverLocation: enableRedundancy && !empty(secondaryLocation) ? secondaryLocation : '' + secondaryLocation: enableRedundancy && !empty(secondaryLocation) ? secondaryLocation : '' tags: allTags } } @@ -241,7 +234,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. params: { name: '${abbrs.containers.containerAppsEnvironment}${resourcesName}' location: location - zoneRedundant: enableRedundancy + zoneRedundant: false // TODO - use enableRedundancy and privatenetworking flag to enable/disable publicNetworkAccess: 'Enabled' managedIdentities: { userAssignedResourceIds: [ diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 631558f..418c9c4 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -3,3 +3,5 @@ using './main.bicep' param location = readEnvironmentVariable('AZURE_LOCATION','japaneast') param azureAiServiceLocation = location param environmentName = readEnvironmentVariable('AZURE_ENV_NAME','azdtemp') + +param secondaryLocation = readEnvironmentVariable('AZURE_SECONDARY_LOCATION', 'swedencentral') diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep index 3d975ca..071add6 100644 --- a/infra/modules/cosmosDb.bicep +++ b/infra/modules/cosmosDb.bicep @@ -16,8 +16,8 @@ param logAnalyticsWorkspaceResourceId string? @description('Indicates whether the single-region account is zone redundant. This property is ignored for multi-region accounts.') param zoneRedundant bool -@description('Optional. The failover location for the Cosmos DB Account.') -param failoverLocation string? +@description('Optional. The secondary location for the Cosmos DB Account for failover and multiple writes.') +param secondaryLocation string? resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { name: '${name}/00000000-0000-0000-0000-000000000002' @@ -41,6 +41,21 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { virtualNetworkRules: [] } zoneRedundant: zoneRedundant + failoverLocations: !empty(secondaryLocation) ? [ + { + failoverPriority: 0 + isZoneRedundant: zoneRedundant + locationName: location + } + { + failoverPriority: 0 + isZoneRedundant: zoneRedundant + locationName: secondaryLocation! + } + ] : [] + enableMultipleWriteLocations: !empty(secondaryLocation) + backupPolicyType: !empty(secondaryLocation) ? 'Periodic' : 'Continuous' + backupStorageRedundancy: zoneRedundant ? 'Zone' : 'Local' disableKeyBasedMetadataWriteAccess: false diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] sqlDatabases: [ @@ -77,13 +92,7 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { name: databaseName } ] - failoverLocations: !empty(failoverLocation) ? [ - { - failoverPriority: 0 - isZoneRedundant: zoneRedundant - locationName: failoverLocation! - } - ] : [] + dataPlaneRoleAssignments: [ { principalId: managedIdentityPrincipalId From a70e8c529f8ef68907e69915ed81ba8fbb9cf670 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 30 May 2025 15:12:19 -0400 Subject: [PATCH 011/124] basic structures set up --- infra/main_network.bicep | 3 --- 1 file changed, 3 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index e7aaad5..e83de72 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -138,6 +138,3 @@ module appRouteTable 'modules/routeTable.bicep' = { // tags: tags // } // } - -// Note: To associate NSGs and route tables to subnets, update the subnets array in your parameter file to include nsgId and routeTableId referencing the outputs above. - From 3678d904544236ee71108db6e4b26cb177d4befe Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 30 May 2025 15:12:48 -0400 Subject: [PATCH 012/124] basic structure --- infra/main_network.bicep | 233 +++++++++++++++++++++++++++++++-------- 1 file changed, 184 insertions(+), 49 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index e83de72..56588f0 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -1,37 +1,132 @@ +targetScope = 'subscription' + @minLength(6) @maxLength(25) @description('Name of the solution. This is used to generate a short unique hash used in all resources.') param solutionName string = 'Code Modernization' + +@description('Type of the solution. This is used for tagging and categorization.') param solutionType string = 'Solution Accelerator' +param resourceGroupName string +param location string + param tags object = { 'Solution Name': solutionName 'Solution Type': solutionType } + +param vnetReuse bool = false // If true, will reuse existing VNet if available +param bastionHostReuse bool = false // If true, will reuse existing Bastion Host if available +param jumpboxReuse bool = false // If true, will reuse existing Jumpbox VM if available + /**************************************************************************/ // prefix generation /**************************************************************************/ var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces -var resourceToken = toLower(uniqueString(subscription().id, cleanSolutionName)) -var resourceTokenTrimmed = length(resourceToken) > 5 ? substring(resourceToken, 0, 5) : resourceToken +var resourceToken = toLower('${substring(cleanSolutionName, 0, 1)}${uniqueString(cleanSolutionName, resourceGroupName, subscription().id)}') +var resourceTokenTrimmed = length(resourceToken) > 9 ? substring(resourceToken, 0, 9) : resourceToken var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) // Network parameters (these will be set via main_network.bicepparam) param networkIsolation bool -param vnetName string -param location string = resourceGroup().location + +param defaultSecurityRules array +param webSecurityRules array +param appSecurityRules array +param aiSecurityRules array +param dataSecurityRules array + +//param vnetName string param addressPrefixes array param dnsServers array param subnets array -param diagnosticSettings array = [] +var vnetName = '${prefix}-vnet' + + +param jumboxAdminUser string +param jumboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden +param privateEndPoint bool = true + + + +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location +} + + +/**************************************************************************/ +// Log Analytics Workspace that will be used across the solution +/**************************************************************************/ +// crate a Log Analytics Workspace using AVM +module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { + name: '${prefix}logAnalyticsWorkspace' + scope: rg + params: { + logAnalyticsWorkSpaceName: '${prefix}law' + location: location + tags: tags + } +} +output logAnalyticsWorkspaceId string = logAnalyticsWorkSpace.outputs.workspaceId + + /**************************************************************************/ -// Network Resource Modules +// Network Structures /**************************************************************************/ +// Diagnostic settings for VNet using Log Analytics Workspace +var diagnosticSettings = [ + { + name: '${prefix}vnetDiagnostics' + workspaceResourceId: logAnalyticsWorkSpace.outputs.workspaceId + logs: [ + // Prioritized: Only most important categories for VNet/network security + { + category: 'NetworkSecurityGroupEvent' + enabled: true + retentionPolicy: { + enabled: false + days: 0 + } + } + { + category: 'NetworkSecurityGroupRuleCounter' + enabled: true + retentionPolicy: { + enabled: false + days: 0 + } + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + retentionPolicy: { + enabled: false // for development, set to fals= + days: 0 + // Replace with the following lines to enable retention policy + // enabled: true + // days: 30 + } + } + ] + } +] + // 1. Deploy the Virtual Network and Subnets -module network 'modules/network.bicep' = if (networkIsolation) { +// Reference an existing Virtual Network if vnetReuse is true +resource existingVnet 'Microsoft.Network/virtualNetworks@2023-09-01' existing = if (networkIsolation && vnetReuse) { + name: vnetName + scope: rg +} + +module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { + scope: rg name: '${prefix}network' params: { vnetName: vnetName @@ -44,59 +139,77 @@ module network 'modules/network.bicep' = if (networkIsolation) { } } +// Use vnetId for dependencies +var vnetId = vnetReuse ? existingVnet.id : network.outputs.vnetId + + // 2. Deploy NSGs for each subnet (example for web, app, ai, data, bastion, jumpbox) -module webNsg 'modules/nsg.bicep' = if (networkIsolation) { - name: '${prefix}webNsg' + +module webNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { + scope:rg + name: '${prefix}WebNsg' params: { - nsgName: 'web-nsg' + nsgName: '${prefix}WebNsg' location: location + securityRules: webSecurityRules tags: tags } } -module appNsg 'modules/nsg.bicep' = if (networkIsolation) { - name: '${prefix}appNsg' +module appNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { + scope:rg + name: '${prefix}AppNsg' params: { - nsgName: '${prefix}appNsg' + nsgName: '${prefix}AppNsg' location: location + securityRules: appSecurityRules tags: tags } } -module aiNsg 'modules/nsg.bicep' = if (networkIsolation) { - name: '${prefix}aiNsg' +module aiNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { + scope:rg + name: '${prefix}AiNsg' params: { - nsgName: '${prefix}aiNsg' + nsgName: '${prefix}AiNsg' location: location + securityRules: aiSecurityRules tags: tags } } -module dataNsg 'modules/nsg.bicep' = if (networkIsolation) { - name: '${prefix}dataNsg' +module dataNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { + scope:rg + name: '${prefix}DataNsg' params: { - nsgName: '${prefix}dataNsg' + nsgName: '${prefix}DataNsg' location: location + securityRules: dataSecurityRules tags: tags } } -module bastionNsg 'modules/nsg.bicep' = if (networkIsolation) { - name: '${prefix}bastionNsg' +module bastionNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { + scope:rg + name: '${prefix}BastionNsg' params: { - nsgName: '${prefix}bastionNsg' + nsgName: '${prefix}BastionNsg' location: location + securityRules: defaultSecurityRules tags: tags } } -module jumpboxNsg 'modules/nsg.bicep' = if (networkIsolation) { - name: '${prefix}jumpboxNsg' +module jumpboxNsg 'modules/nsg.bicep' = if (networkIsolation && !jumpboxReuse) { + scope:rg + name: '${prefix}JumpboxNsg' params: { - nsgName: '${prefix}jumpboxNsg' + nsgName: '${prefix}JumpboxNsg' location: location + securityRules: defaultSecurityRules tags: tags } } // 3. Deploy Route Tables (example for web and app subnets) module webRouteTable 'modules/routeTable.bicep' = if (networkIsolation) { - name: '${prefix}webRouteTable' + scope:rg + name: '${prefix}WebRouteTable' params: { routeTableName: '${prefix}webRouteTable' location: location @@ -104,7 +217,8 @@ module webRouteTable 'modules/routeTable.bicep' = if (networkIsolation) { } } module appRouteTable 'modules/routeTable.bicep' = { - name: '${prefix}appRouteTable' + scope:rg + name: '${prefix}AppRouteTable' params: { routeTableName: '${prefix}appRouteTable' location: location @@ -112,29 +226,50 @@ module appRouteTable 'modules/routeTable.bicep' = { } } +// ********************************************************************************************* +// Bastion Host and JumpBox VM +// This section is optional and can be enabled based on the network isolation requirements. +// ********************************************************************************************* +// 4. (Optional) Deploy Bastion Host and JumpBox VM using outputs from network module +resource existingBastionHost 'Microsoft.Network/bastionHosts@2023-09-01' existing = if (networkIsolation && bastionHostReuse) { + name: '${prefix}bastionHost' + scope: rg +} +module bastionHost 'modules/bastionHost.bicep' = if (networkIsolation && !bastionHostReuse) { + scope: rg + name: '${prefix}BastionHost' + params: { + bastionHostName: '${prefix}bastionHost' + location: location + vnetId: vnetId + tags: tags + } +} +var bastionHostId = bastionHostReuse ? existingBastionHost.id : bastionHost.outputs.bastionHostId -// 4. (Optional) Deploy Bastion Host and JumpBox VM using outputs from network module -// module bastionHost 'modules/bastionHost.bicep' = { -// name: 'bastionHost' -// params: { -// bastionHostName: 'bastion-host' -// location: location -// vnetId: network.outputs.vnetId -// subnetId: network.outputs.subnetIds[4] // index for 'bastion' subnet -// tags: tags -// } -// } -// module jumpbox 'modules/jumpbox.bicep' = { -// name: 'jumpbox' -// params: { -// vmName: 'jumpbox-vm' -// location: location -// subnetId: network.outputs.subnetIds[5] // index for 'jumpbox' subnet -// adminUsername: '' -// adminPasswordOrKey: '' -// tags: tags -// } -// } +// Reference an existing JumpBox VM if jumpboxReuse is true +resource existingJumpbox 'Microsoft.Compute/virtualMachines@2023-09-01' existing = if (networkIsolation && jumpboxReuse) { + name: '${prefix}jumpbox-vm' + scope: rg +} + +module jumpbox 'modules/jumpbox.bicep' = if (networkIsolation && !jumpboxReuse) { + scope: rg + name: '${prefix}jumpbox-vm' + params: { + prefix: prefix + vmName: '${prefix}jumpbox-vm' + location: location + subnetId: network.outputs.subnetIds[5] // index for 'jumpbox' subnet + adminUsername: jumboxAdminUser + adminPasswordOrKey: 'P@ssword123456789$$$' // TODO - take this from Key Vault later on + vmSize: jumboxVmSize + tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkSpace.outputs.workspaceId + } +} + +var jumpboxId = jumpboxReuse ? existingJumpbox.id : jumpbox.outputs.vmId From fac62d8e7497defa5eaa70cd645e4ba3491951d6 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 30 May 2025 15:13:03 -0400 Subject: [PATCH 013/124] added security rules etc --- infra/main_network.bicepparam | 147 ++++++++++++++++++++++++++++- infra/modules/bastionHost.bicep | 9 +- infra/modules/jumpbox.bicep | 64 +++++++++++-- infra/modules/network.bicep | 6 +- infra/modules/nsg.bicep | 14 +-- infra/modules/privateDnsZone.bicep | 4 - 6 files changed, 212 insertions(+), 32 deletions(-) diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index 728efff..0931787 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -3,9 +3,23 @@ using './main_network.bicep' +param resourceGroupName = 'gaiye-avm-08-rg' +param location = 'eastus' + param networkIsolation = true +param privateEndPoint = true + +param jumboxAdminUser = 'JumpboxAdmin' // Admin user for the jumpbox VM +param jumboxVmSize = 'Standard_D2s_v3' // 'Standard_B2s' not good enough for WAF + +param vnetReuse = false // set it to true if you want to reuse an existing VNet already creatd +param bastionHostReuse = false +param jumpboxReuse = false + +//******************************************************************* +// Network Security Groups (NSGs) and their rules +//******************************************************************* -param vnetName = 'my-vnet' param addressPrefixes = [ '10.0.0.0/20' // 4,096 IP addresses. Other options: (1) /16: 65,536 (2) /24: 256 Addresses ] @@ -40,3 +54,134 @@ param subnets = [ } ] +param defaultSecurityRules = [ + { + name: 'DenyAllInbound' + priority: 4096 + direction: 'Inbound' + access: 'Deny' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + } +] + +param webSecurityRules = [ + { + name: 'AllowHttpsInbound' + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + } + // uncomment the following rule to allow HTTP traffic + // { + // name: 'AllowHttpInbound' + // priority: 110 + // direction: 'Inbound' + // access: 'Allow' + // protocol: 'Tcp' + // sourcePortRange: '*' + // destinationPortRange: '80' + // sourceAddressPrefix: '*' + // destinationAddressPrefix: '*' + // } + { + name: 'DenyAllOtherInbound' + priority: 4096 + direction: 'Inbound' + access: 'Deny' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + } +] + +param appSecurityRules = [ + { + name: 'AllowWebToApp' + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: '10.0.1.0/24' // Web subnet + destinationAddressPrefix: '*' + } + { + name: 'DenyAllOtherInbound' + priority: 4096 + direction: 'Inbound' + access: 'Deny' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + } +] + +param aiSecurityRules = [ + { + name: 'AllowAppToAI' + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.1.0/24' // Web subnet + '10.0.2.0/24' // App subnet + ] + destinationAddressPrefix: '*' + } + { + name: 'DenyAllOtherInbound' + priority: 4096 + direction: 'Inbound' + access: 'Deny' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + } +] + +param dataSecurityRules = [ + { + name: 'AllowAppToData' + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.1.0/24' // Web subnet + '10.0.2.0/24' // App subnet + ] + destinationAddressPrefix: '*' + } + { + name: 'DenyAllOtherInbound' + priority: 4096 + direction: 'Inbound' + access: 'Deny' + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + } +] diff --git a/infra/modules/bastionHost.bicep b/infra/modules/bastionHost.bicep index 0a0ff24..69280f3 100644 --- a/infra/modules/bastionHost.bicep +++ b/infra/modules/bastionHost.bicep @@ -10,9 +10,6 @@ param location string = resourceGroup().location @description('Resource ID of the VNet') param vnetId string -@description('Resource ID of the Bastion subnet') -param subnetId string - @description('Optional: Tags for the Bastion Host') param tags object = {} @@ -22,7 +19,11 @@ module bastion 'br/public:avm/res/network/bastion-host:0.2.2' = { name: bastionHostName location: location virtualNetworkResourceId: vnetId - subnetResourceId: subnetId + publicIPAddressObject: { + name: '${bastionHostName}-pip' + skuName: 'Standard' + location: location + } tags: tags } } diff --git a/infra/modules/jumpbox.bicep b/infra/modules/jumpbox.bicep index 23e2247..c0810ba 100644 --- a/infra/modules/jumpbox.bicep +++ b/infra/modules/jumpbox.bicep @@ -1,6 +1,9 @@ // Creates a JumpBox VM using AVM // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/compute/virtual-machine +@description('Prefix for resource names') +param prefix string + @description('Name of the JumpBox VM') param vmName string @@ -18,22 +21,69 @@ param adminUsername string param adminPasswordOrKey string @description('VM size (e.g., "Standard_B2ms")') -param vmSize string = 'Standard_B2ms' +param vmSize string = 'Standard_D2s_v3' @description('Optional: Tags for the VM') param tags object = {} -module jumpbox 'br/public:avm/res/compute/virtual-machine:0.4.2' = { - name: vmName +@description('Log Analytics Workspace Resource ID for diagnostics') +param logAnalyticsWorkspaceId string + +// Diagnostic settings for Log Analytics only +var diagnosticSettings = [ + { + name: 'jumpboxDiagnostics' + metricCategories: [ + { + category: 'AllMetrics' + } + ] + workspaceResourceId: logAnalyticsWorkspaceId + } +] + + +module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.15.0' = { + name: '${prefix}vmJumpBox' params: { + adminUsername: adminUsername + adminPassword: adminPasswordOrKey name: vmName location: location - subnetResourceId: subnetId - adminUsername: adminUsername - adminPasswordOrKey: adminPasswordOrKey vmSize: vmSize + osType: 'Windows' + imageReference: { + offer: 'WindowsServer' + publisher: 'MicrosoftWindowsServer' + sku: '2019-datacenter' + version: 'latest' + } + nicConfigurations: [ + { + name: 'nic-01' + ipConfigurations: [ + { + name: 'ipconfig-01' + subnetResourceId: subnetId + } + ] + diagnosticSettings: diagnosticSettings + } + ] + osDisk: { + name: '${vmName}-osdisk' + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Premium_LRS' + } + diskSizeGB: 128 + deleteOption: 'Delete' + } + encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + zone: 1 tags: tags } } -output vmId string = jumpbox.outputs.resourceId +output vmId string = virtualMachine.outputs.resourceId +output vmName string = virtualMachine.outputs.name diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index dbd0ffc..2905e71 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -8,7 +8,7 @@ param location string @description('Optional: Tags for the VNet') param tags object = {} -@description('Optional: Address prefixes for the VNet') +@description('Address prefixes for the VNet') param addressPrefixes array @description('Optional: DNS servers for the VNet') @@ -21,7 +21,7 @@ param subnets array @description('Optional: Diagnostic settings for the VNet') param diagnosticSettings array = [] -module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { name: vnetName params: { addressPrefixes: addressPrefixes @@ -32,7 +32,7 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { for subnet in subnets: { name: subnet.name addressPrefix: subnet.addressPrefix - + } ] diagnosticSettings: diagnosticSettings diff --git a/infra/modules/nsg.bicep b/infra/modules/nsg.bicep index 9477a31..9a15287 100644 --- a/infra/modules/nsg.bicep +++ b/infra/modules/nsg.bicep @@ -11,19 +11,7 @@ param location string = resourceGroup().location param tags object = {} @description('Optional: Security rules for the NSG') -param securityRules array = [ - { - name: 'AllowHttpsInbound' - priority: 100 - direction: 'Inbound' - access: 'Allow' - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' - } -] +param securityRules array = [] module networkSecurityGroup 'br/public:avm/res/network/network-security-group:0.5.1' = { name: nsgName diff --git a/infra/modules/privateDnsZone.bicep b/infra/modules/privateDnsZone.bicep index aefc2e3..54bbe78 100644 --- a/infra/modules/privateDnsZone.bicep +++ b/infra/modules/privateDnsZone.bicep @@ -4,9 +4,6 @@ @description('Name of the Private DNS Zone (e.g., "privatelink.vaultcore.azure.net")') param dnsZoneName string -@description('Azure region for the DNS Zone') -param location string = resourceGroup().location - @description('Optional: Tags for the DNS Zone') param tags object = {} @@ -14,7 +11,6 @@ module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.2.2' = { name: dnsZoneName params: { name: dnsZoneName - location: location tags: tags } } From f76c031dc2e91088329369513374a50f01f7261a Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 30 May 2025 23:41:39 -0400 Subject: [PATCH 014/124] refactored --- infra/Deployment_Plan.md | 135 -------------- infra/braches.mc | 0 infra/main_network.bicep | 268 ++++++++++++---------------- infra/main_network.bicepparam | 189 +++++++++----------- infra/modules/network - Copy.bicep | 46 +++++ infra/modules/network.bicep | 3 +- infra/modules/nsg.bicep | 7 +- infra/modules/privateEndpoint.bicep | 0 8 files changed, 250 insertions(+), 398 deletions(-) delete mode 100644 infra/Deployment_Plan.md create mode 100644 infra/braches.mc create mode 100644 infra/modules/network - Copy.bicep create mode 100644 infra/modules/privateEndpoint.bicep diff --git a/infra/Deployment_Plan.md b/infra/Deployment_Plan.md deleted file mode 100644 index acc56c1..0000000 --- a/infra/Deployment_Plan.md +++ /dev/null @@ -1,135 +0,0 @@ -# Deployment Plan - -The deployment code will be in the **infra** folder. The **modules** subfolder contains reusable, parameterized modules. There are two main deployment files: `main.bicep` (for quick, cost-effective solution exploration and demos) and `main.waf.bicep` (for production-grade, WAF-secured deployments). Both leverage the same modules, but with different parameters and security/networking posture. - -- **main.bicep**: Lightweight deployment for rapid prototyping, demos, and decision making. Minimal networking/security for speed and cost. -- **main.waf.bicep**: Production-ready deployment. Creates a Virtual Network with subnets, private endpoints for all solution resources (e.g., Azure AI Foundry, Storage, Cosmos DB, Key Vault), Bastion Host, and all networking/security resources recommended by WAF Security Guidelines. Uses Azure Verified Modules (AVM) where appropriate. See [Microsoft Azure Well-Architected Framework (WAF) Security design principles](https://learn.microsoft.com/en-us/azure/well-architected/security/principles) and [Azure Verified Modules (AVM)](https://azure.github.io/Azure-Verified-Modules/). - -If you are new to AVM BICEP implementation, refer to [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/). - -**All modules are parameterized for maximum reusability and maintainability. Use outputs from modules to wire dependencies (e.g., VNet/subnet IDs, resource IDs).** - -Below is an example deployment design. The Group (module) Name column shows how to group code in BICEP modules. - -**Solution Components** - -| Component Name | Notes | Subnet | -| ----------------------------------------- | ------------------------------------------------------------ | ----------------- | -| Log Analytics Workspace | monitoring **(global, not subnet) | | -| Application Insights | app-insights **(global, not subnet)** | | -| Container Apps Environment | Shared by two apps | app | -| Frontend Container App | | app | -| Backend Container App | | app | -| NSG for App Subnet | nsg | app | -| AI Hub | ai-foundry / **private end point** | ai | -| AI Project | ai-foundry / **private end point** | ai | -| AI Services | ai-foundry / **private end point** | ai | -| NSG for AI Subnet | nsg | ai | -| Managed Identity (RG, etc) | identity | | -| Key Vault | keyvault / **private end point** | data | -| Cosmos DB | cosmos-db/ **private end point** | data | -| Storage Account Front End | storage / **private end point** | data | -| Storage Account Back End | storage / **private end point** | data | -| NSG for Data Subnet | nsg | data | -| Bastion Host | bastion | bastion | -| NSG for Bastion | nsg | bastion | -| JumpBox VM | (your-jumpbox-module) | jumpbox | -| NSG for JumpBox | nsg | jumpbox | -| Route Table | routeTable | (associated subnets) | -| Private Endpoints | privateEndpoint | respective subnet | -| Private DNS Zone | privateDnsZone | vnet | - - - -### **Example Subnet/NSG Table** - -| Subnet | NSG Rules (Inbound) | NSG Rules (Outbound) | -|----------|-----------------------------------------------------|-----------------------------| -| web | 80/443 from internet or allowed IPs | To app, internet | -| app | From web subnet only | To data, PaaS | -| ai | From app subnet only | To data, PaaS | -| data | From app/ai/private endpoints | To PaaS, as needed | -| jumpbox | RDP/SSH from allowed IPs or Bastion | To internet, as needed | -| bastion | Platform-managed (Bastion Host only, no direct NSG) | To VMs for RDP/SSH | - -## **Monitoring & Logging** -- Enable diagnostic logs for all resources, send to Log Analytics Workspace. -- Enable Application Insights for all app components. - -## **DDoS Protection** -- Enable at the VNet level for public-facing workloads. - -## **Route Tables (UDRs)** -- Optional, but recommended for advanced routing (e.g., force tunneling through Azure Firewall if used). - -## Azure Bastion Host - -**Function:** - -- Azure Bastion is a managed PaaS service that provides secure RDP/SSH connectivity to your VMs directly from the Azure Portal, without exposing public IP addresses on those VMs. - -**How to Use:** - -- Deploy Bastion Host in a dedicated subnet (must be named `bastion` or `AzureBastionSubnet`). -- When you need to access a VM (like a JumpBox or any other VM) for admin purposes, use the “Connect” button in the Azure Portal and select “Bastion.” -- Your session runs over SSL in the browser—no public IP, no direct RDP/SSH from the internet. - -**Best Practice:** - -- Use Bastion Host for all admin access to VMs in your VNet. -- Never assign public IPs to your VMs. - -## JumpBox VM - -**Function:** - -- A JumpBox (or Jump Host) is a hardened VM (often Linux or Windows) that acts as a single entry point for admin access to other VMs or resources in your private network. -- It is typically used for scenarios where you need to run custom tools, scripts, or have a persistent admin environment. - -**How to Use:** - -- Deploy the JumpBox VM in its own subnet (e.g., `jumpbox`). -- Access the JumpBox via Bastion Host (never via public IP). -- From the JumpBox, you can SSH/RDP to other VMs, run scripts, or use it as a staging point for troubleshooting. - -**Best Practice:** - -- The JumpBox should have minimal software, be tightly controlled, and monitored. -- Use NSGs to restrict access to/from the JumpBox subnet. -- Use Bastion Host to access the JumpBox, not a public IP. - -## Deployment Considerations - -### Add Networking Components -- **Network Security Group (NSG):** Protects subnets and controls inbound/outbound traffic. One NSG per subnet is recommended. -- **Bastion Host:** Provides secure RDP/SSH access to VMs without exposing public IPs. -- **Route Table:** Custom route tables for advanced routing scenarios (optional, but recommended for segmented networks). -- **Private Endpoints:** For secure, private connectivity to PaaS services (Key Vault, Storage, Cosmos DB, etc.), add private endpoints in the relevant subnets. Use a dedicated PrivateEndpointSubnet for easier management. -- **Private DNS Zone:** Required for name resolution of private endpoints, ensuring resources in your VNet can resolve the private DNS names of Azure PaaS services to their private IP addresses. - -### Security & Best Practices -- Use Managed Identity for all services that support it. -- Store secrets and connection strings in Key Vault; never hardcode credentials. -- Enable diagnostic logging for all resources (send to Log Analytics Workspace). -- Apply NSGs to each subnet with least-privilege rules. -- Use Bastion Host for secure admin access. -- Consider DDoS Protection for the VNet if required. -- Use Private Endpoints for PaaS services to avoid public exposure. -- For all PaaS resources with private endpoints, set public network access to 'Deny'. -- Enable soft delete and purge protection on Key Vault and Storage Accounts. - ---- - -**For more information, see:** -- [Microsoft Azure Well-Architected Framework (WAF) Security design principles](https://learn.microsoft.com/en-us/azure/well-architected/security/principles) -- [Azure Verified Modules (AVM)](https://azure.github.io/Azure-Verified-Modules/) -- [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/) -- [Azure Application Gateway documentation](https://learn.microsoft.com/en-us/azure/application-gateway/overview) -- [Azure Bastion documentation](https://learn.microsoft.com/en-us/azure/bastion/bastion-overview) -- [Azure Private Endpoint documentation](https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview) -- [Azure Firewall documentation](https://learn.microsoft.com/en-us/azure/firewall/overview) -- [Azure DDoS Protection documentation](https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview) -- [Azure Key Vault documentation](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) -- [Azure Cosmos DB documentation](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) -- [Azure Storage documentation](https://learn.microsoft.com/en-us/azure/storage/common/storage-introduction) - diff --git a/infra/braches.mc b/infra/braches.mc new file mode 100644 index 0000000..e69de29 diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 56588f0..57511ae 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -1,4 +1,5 @@ -targetScope = 'subscription' +//targetScope = 'subscription' +targetScope = 'resourceGroup' @minLength(6) @maxLength(25) @@ -11,12 +12,13 @@ param solutionType string = 'Solution Accelerator' param resourceGroupName string param location string + param tags object = { 'Solution Name': solutionName 'Solution Type': solutionType } - +param logAnalyticsWorkspaceReuse bool = false // If true, will reuse existing Log Analytics Workspace if available param vnetReuse bool = false // If true, will reuse existing VNet if available param bastionHostReuse bool = false // If true, will reuse existing Bastion Host if available param jumpboxReuse bool = false // If true, will reuse existing Jumpbox VM if available @@ -32,11 +34,12 @@ var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) // Network parameters (these will be set via main_network.bicepparam) param networkIsolation bool -param defaultSecurityRules array param webSecurityRules array param appSecurityRules array param aiSecurityRules array param dataSecurityRules array +param bastionSecurityRules array // Security rules for Bastion Host +param jumpboxSecurityRules array // Security rules for Jumpbox VM //param vnetName string param addressPrefixes array @@ -51,28 +54,24 @@ param privateEndPoint bool = true -resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: resourceGroupName - location: location -} - - /**************************************************************************/ // Log Analytics Workspace that will be used across the solution /**************************************************************************/ // crate a Log Analytics Workspace using AVM -module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { +resource existingLogAnalyticsWorkSpace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = if (logAnalyticsWorkspaceReuse) { + name: '${prefix}logAnalyticsWorkspace' +} + +module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = if (!logAnalyticsWorkspaceReuse) { name: '${prefix}logAnalyticsWorkspace' - scope: rg params: { logAnalyticsWorkSpaceName: '${prefix}law' location: location tags: tags } } -output logAnalyticsWorkspaceId string = logAnalyticsWorkSpace.outputs.workspaceId - +var logAnalyticsWorkspaceId = logAnalyticsWorkspaceReuse ? existingLogAnalyticsWorkSpace.id : logAnalyticsWorkSpace.outputs.workspaceId /**************************************************************************/ // Network Structures @@ -82,7 +81,7 @@ output logAnalyticsWorkspaceId string = logAnalyticsWorkSpace.outputs.workspaceI var diagnosticSettings = [ { name: '${prefix}vnetDiagnostics' - workspaceResourceId: logAnalyticsWorkSpace.outputs.workspaceId + workspaceResourceId: logAnalyticsWorkspaceId logs: [ // Prioritized: Only most important categories for VNet/network security { @@ -107,7 +106,7 @@ var diagnosticSettings = [ category: 'AllMetrics' enabled: true retentionPolicy: { - enabled: false // for development, set to fals= + enabled: false // for development, set to false days: 0 // Replace with the following lines to enable retention policy // enabled: true @@ -118,158 +117,113 @@ var diagnosticSettings = [ } ] -// 1. Deploy the Virtual Network and Subnets -// Reference an existing Virtual Network if vnetReuse is true -resource existingVnet 'Microsoft.Network/virtualNetworks@2023-09-01' existing = if (networkIsolation && vnetReuse) { - name: vnetName - scope: rg -} -module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { - scope: rg - name: '${prefix}network' +// 1. Create NSGs for subnets using the AVM NSG module +module nsgs 'modules/nsg.bicep' = [for (subnet, i) in subnets: if (!empty(subnet.networkSecurityGroup)) { + name: '${prefix}-${subnet.networkSecurityGroup.name}' params: { - vnetName: vnetName + nsgName: '${prefix}-${subnet.networkSecurityGroup.name}' location: location - addressPrefixes: addressPrefixes - dnsServers: dnsServers - subnets: subnets tags: tags - diagnosticSettings: diagnosticSettings + securityRules: subnet.networkSecurityGroup.securityRules } -} - -// Use vnetId for dependencies -var vnetId = vnetReuse ? existingVnet.id : network.outputs.vnetId - +}] -// 2. Deploy NSGs for each subnet (example for web, app, ai, data, bastion, jumpbox) +// 2. Build subnets array with NSG resource IDs for AVM VNet module is now inlined below -module webNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { - scope:rg - name: '${prefix}WebNsg' - params: { - nsgName: '${prefix}WebNsg' - location: location - securityRules: webSecurityRules - tags: tags - } -} -module appNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { - scope:rg - name: '${prefix}AppNsg' - params: { - nsgName: '${prefix}AppNsg' - location: location - securityRules: appSecurityRules - tags: tags - } -} -module aiNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { - scope:rg - name: '${prefix}AiNsg' - params: { - nsgName: '${prefix}AiNsg' - location: location - securityRules: aiSecurityRules - tags: tags - } -} -module dataNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { - scope:rg - name: '${prefix}DataNsg' - params: { - nsgName: '${prefix}DataNsg' - location: location - securityRules: dataSecurityRules - tags: tags - } -} -module bastionNsg 'modules/nsg.bicep' = if (networkIsolation && !vnetReuse) { - scope:rg - name: '${prefix}BastionNsg' - params: { - nsgName: '${prefix}BastionNsg' - location: location - securityRules: defaultSecurityRules - tags: tags - } -} -module jumpboxNsg 'modules/nsg.bicep' = if (networkIsolation && !jumpboxReuse) { - scope:rg - name: '${prefix}JumpboxNsg' - params: { - nsgName: '${prefix}JumpboxNsg' - location: location - securityRules: defaultSecurityRules - tags: tags - } -} - -// 3. Deploy Route Tables (example for web and app subnets) -module webRouteTable 'modules/routeTable.bicep' = if (networkIsolation) { - scope:rg - name: '${prefix}WebRouteTable' - params: { - routeTableName: '${prefix}webRouteTable' - location: location - tags: tags - } -} -module appRouteTable 'modules/routeTable.bicep' = { - scope:rg - name: '${prefix}AppRouteTable' - params: { - routeTableName: '${prefix}appRouteTable' - location: location - tags: tags - } -} - -// ********************************************************************************************* -// Bastion Host and JumpBox VM -// This section is optional and can be enabled based on the network isolation requirements. -// ********************************************************************************************* - -// 4. (Optional) Deploy Bastion Host and JumpBox VM using outputs from network module -resource existingBastionHost 'Microsoft.Network/bastionHosts@2023-09-01' existing = if (networkIsolation && bastionHostReuse) { - name: '${prefix}bastionHost' - scope: rg -} - -module bastionHost 'modules/bastionHost.bicep' = if (networkIsolation && !bastionHostReuse) { - scope: rg - name: '${prefix}BastionHost' - params: { - bastionHostName: '${prefix}bastionHost' - location: location - vnetId: vnetId - tags: tags - } -} - -var bastionHostId = bastionHostReuse ? existingBastionHost.id : bastionHost.outputs.bastionHostId - -// Reference an existing JumpBox VM if jumpboxReuse is true -resource existingJumpbox 'Microsoft.Compute/virtualMachines@2023-09-01' existing = if (networkIsolation && jumpboxReuse) { - name: '${prefix}jumpbox-vm' - scope: rg -} - -module jumpbox 'modules/jumpbox.bicep' = if (networkIsolation && !jumpboxReuse) { - scope: rg - name: '${prefix}jumpbox-vm' +// 3. Pass avmSubnets to the AVM VNet module +module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { + name: '${prefix}-vnet' params: { - prefix: prefix - vmName: '${prefix}jumpbox-vm' + vnetName: vnetName location: location - subnetId: network.outputs.subnetIds[5] // index for 'jumpbox' subnet - adminUsername: jumboxAdminUser - adminPasswordOrKey: 'P@ssword123456789$$$' // TODO - take this from Key Vault later on - vmSize: jumboxVmSize + addressPrefixes: addressPrefixes + dnsServers: dnsServers + subnets: [ + for (subnet, i) in subnets: { + name: subnet.name + addressPrefix: subnet.addressPrefix + networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.nsgResourceId : null + // Add other properties as needed (e.g., routeTableResourceId) + } + ] tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkSpace.outputs.workspaceId + diagnosticSettings: diagnosticSettings } } -var jumpboxId = jumpboxReuse ? existingJumpbox.id : jumpbox.outputs.vmId +// /**************************************************************************/ +// // TODO: Bastion Host +// /**************************************************************************/ +// // Create or reuse Bastion Host +// module bastionHost 'modules/bastionHost.bicep' = if (networkIsolation && !bastionHostReuse) { +// name: '${prefix}-bastionHost' +// params: { +// location: location +// tags: tags +// virtualNetworkId: network.outputs.vnetId +// publicIpAddressName: '${prefix}-bastionIp' +// sku: 'Standard' +// } +// } + +// /**************************************************************************/ +// //TODO: Jumpbox VM +// /**************************************************************************/ +// // Create or reuse Jumpbox VM +// module jumpbox 'modules/jumpbox.bicep' = if (networkIsolation && !jumpboxReuse) { +// name: '${prefix}-jumpbox' +// params: { +// location: location +// tags: tags +// virtualNetworkId: network.outputs.vnetId +// subnetName: '${prefix}-default' +// publicIpAddressName: '${prefix}-jumpboxIp' +// adminUsername: jumboxAdminUser +// vmSize: jumboxVmSize +// osDiskSizeGb: 30 +// imagePublisher: 'Canonical' +// imageOffer: 'UbuntuServer' +// imageSku: '18.04-LTS' +// sshKeyData: '' // Provide your SSH public key here +// } +// } + +// /**************************************************************************/ +// // TODO: AI and Data Services +// /**************************************************************************/ +// // Example: Deploy an AI service (e.g., Azure Cognitive Services) +// module aiService 'modules/aiService.bicep' = if (networkIsolation) { +// name: '${prefix}-aiService' +// params: { +// location: location +// tags: tags +// virtualNetworkId: network.outputs.vnetId +// subnetName: '${prefix}-default' +// // Add other parameters as needed +// } +// } + +// // Example: Deploy a Data service (e.g., Azure SQL Database) +// module dataService 'modules/dataService.bicep' = if (networkIsolation) { +// name: '${prefix}-dataService' +// params: { +// location: location +// tags: tags +// virtualNetworkId: network.outputs.vnetId +// subnetName: '${prefix}-default' +// // Add other parameters as needed +// } +// } + +// /**************************************************************************/ +// // Outputs +// /**************************************************************************/ +// output workspaceId string = logAnalyticsWorkSpace.outputs.workspaceId +// output workspacePrimaryKey string = logAnalyticsWorkSpace.outputs.primaryKey +// output workspaceSecondaryKey string = logAnalyticsWorkSpace.outputs.secondaryKey +// output workspaceName string = logAnalyticsWorkSpace.outputs.workspaceName + +// // Example outputs for AI and Data services +// output aiServiceEndpoint string = aiService.outputs.endpoint +// output dataServiceConnectionString string = dataService.outputs.connectionString diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index 0931787..d1d0574 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -3,7 +3,7 @@ using './main_network.bicep' -param resourceGroupName = 'gaiye-avm-08-rg' +param resourceGroupName = 'gaiye-avm-09-rg' param location = 'eastus' param networkIsolation = true @@ -12,6 +12,7 @@ param privateEndPoint = true param jumboxAdminUser = 'JumpboxAdmin' // Admin user for the jumpbox VM param jumboxVmSize = 'Standard_D2s_v3' // 'Standard_B2s' not good enough for WAF +param logAnalyticsWorkspaceReuse = true param vnetReuse = false // set it to true if you want to reuse an existing VNet already creatd param bastionHostReuse = false param jumpboxReuse = false @@ -27,46 +28,7 @@ param dnsServers = [ '10.0.1.4' '10.0.1.5' ] -param subnets = [ - { - name: 'web' - addressPrefix: '10.0.1.0/24' - } - { - name: 'app' - addressPrefix: '10.0.2.0/24' - } - { - name: 'ai' - addressPrefix: '10.0.3.0/24' - } - { - name: 'data' - addressPrefix: '10.0.4.0/24' - } - { - name: 'bastion' - addressPrefix: '10.0.5.0/24' - } - { - name: 'jumpbox' - addressPrefix: '10.0.6.0/24' - } -] -param defaultSecurityRules = [ - { - name: 'DenyAllInbound' - priority: 4096 - direction: 'Inbound' - access: 'Deny' - protocol: '*' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' - } -] param webSecurityRules = [ { @@ -77,31 +39,8 @@ param webSecurityRules = [ protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '443' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' - } - // uncomment the following rule to allow HTTP traffic - // { - // name: 'AllowHttpInbound' - // priority: 110 - // direction: 'Inbound' - // access: 'Allow' - // protocol: 'Tcp' - // sourcePortRange: '*' - // destinationPortRange: '80' - // sourceAddressPrefix: '*' - // destinationAddressPrefix: '*' - // } - { - name: 'DenyAllOtherInbound' - priority: 4096 - direction: 'Inbound' - access: 'Deny' - protocol: '*' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['0.0.0.0/0'] } ] @@ -111,22 +50,11 @@ param appSecurityRules = [ priority: 100 direction: 'Inbound' access: 'Allow' - protocol: '*' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: '10.0.1.0/24' // Web subnet - destinationAddressPrefix: '*' - } - { - name: 'DenyAllOtherInbound' - priority: 4096 - direction: 'Inbound' - access: 'Deny' - protocol: '*' + protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' + sourceAddressPrefixes: ['10.0.1.0/24'] // Web subnet + destinationAddressPrefixes: ['0.0.0.0/0'] } ] @@ -136,52 +64,109 @@ param aiSecurityRules = [ priority: 100 direction: 'Inbound' access: 'Allow' - protocol: '*' + protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' sourceAddressPrefixes: [ '10.0.1.0/24' // Web subnet '10.0.2.0/24' // App subnet ] - destinationAddressPrefix: '*' - } - { - name: 'DenyAllOtherInbound' - priority: 4096 - direction: 'Inbound' - access: 'Deny' - protocol: '*' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' + destinationAddressPrefixes: ['0.0.0.0/0'] } ] param dataSecurityRules = [ { - name: 'AllowAppToData' + name: 'AllowWebandAppToData' priority: 100 direction: 'Inbound' access: 'Allow' - protocol: '*' + protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' sourceAddressPrefixes: [ '10.0.1.0/24' // Web subnet '10.0.2.0/24' // App subnet ] - destinationAddressPrefix: '*' + destinationAddressPrefixes: ['0.0.0.0/0'] } +] + +param bastionSecurityRules = [ { - name: 'DenyAllOtherInbound' - priority: 4096 + name: 'AllowBastionInbound' + priority: 100 direction: 'Inbound' - access: 'Deny' - protocol: '*' + access: 'Allow' + protocol: 'Tcp' sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' + destinationPortRange: '22' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.5.0/24'] + } +] + +param jumpboxSecurityRules = [ + { + name: 'AllowJumpboxInbound' + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.6.0/24'] + } +] + +param subnets = [ + { + name: 'web' + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + name: 'web-nsg' + securityRules: webSecurityRules + } + } + { + name: 'app' + addressPrefix: '10.0.2.0/24' + networkSecurityGroup: { + name: 'app-nsg' + securityRules: appSecurityRules + } + } + { + name: 'ai' + addressPrefix: '10.0.3.0/24' + networkSecurityGroup: { + name: 'ai-nsg' + securityRules: aiSecurityRules + } + } + { + name: 'data' + addressPrefix: '10.0.4.0/24' + networkSecurityGroup: { + name: 'data-nsg' + securityRules: dataSecurityRules + } + } + { + name: 'bastion' + addressPrefix: '10.0.5.0/24' + networkSecurityGroup: { + name: 'bastion-nsg' + securityRules: bastionSecurityRules + } + } + { + name: 'jumpbox' + addressPrefix: '10.0.6.0/24' + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: jumpboxSecurityRules + } } ] diff --git a/infra/modules/network - Copy.bicep b/infra/modules/network - Copy.bicep new file mode 100644 index 0000000..2905e71 --- /dev/null +++ b/infra/modules/network - Copy.bicep @@ -0,0 +1,46 @@ +// networking.bicep +// Creates a VNet and subnets for the solution using AVM modules +//https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network + +param vnetName string +param location string + +@description('Optional: Tags for the VNet') +param tags object = {} + +@description('Address prefixes for the VNet') +param addressPrefixes array + +@description('Optional: DNS servers for the VNet') +param dnsServers array = [] + +// Subnet definitions as an array of objects +@description('Subnets to create in the VNet') +param subnets array + +@description('Optional: Diagnostic settings for the VNet') +param diagnosticSettings array = [] + +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { + name: vnetName + params: { + addressPrefixes: addressPrefixes + name: vnetName + dnsServers: dnsServers + location: location + subnets: [ + for subnet in subnets: { + name: subnet.name + addressPrefix: subnet.addressPrefix + + } + ] + diagnosticSettings: diagnosticSettings + tags: tags + } +} + +output vnetName string = virtualNetwork.outputs.name +output vnetLocation string = virtualNetwork.outputs.location +output vnetId string = virtualNetwork.outputs.resourceId +output subnetIds array = virtualNetwork.outputs.subnetResourceIds diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 2905e71..eed8b36 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -32,7 +32,8 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { for subnet in subnets: { name: subnet.name addressPrefix: subnet.addressPrefix - + networkSecurityGroupResourceId: subnet.?networkSecurityGroupResourceId + routeTableResourceId: subnet.?routeTableResourceId } ] diagnosticSettings: diagnosticSettings diff --git a/infra/modules/nsg.bicep b/infra/modules/nsg.bicep index 9a15287..a1426b3 100644 --- a/infra/modules/nsg.bicep +++ b/infra/modules/nsg.bicep @@ -25,11 +25,11 @@ module networkSecurityGroup 'br/public:avm/res/network/network-security-group:0. priority: rule.priority direction: rule.direction access: rule.access - protocol: rule.protocol + protocol: rule.protocol ?? '*' sourcePortRange: rule.sourcePortRange destinationPortRange: rule.destinationPortRange - sourceAddressPrefix: rule.sourceAddressPrefix - destinationAddressPrefix: rule.destinationAddressPrefix + sourceAddressPrefixes: rule.sourceAddressPrefixes ?? (rule.sourceAddressPrefix != null ? [rule.sourceAddressPrefix] : null) + destinationAddressPrefixes: rule.destinationAddressPrefixes ?? (rule.destinationAddressPrefix != null ? [rule.destinationAddressPrefix] : null) } } ] @@ -38,3 +38,4 @@ module networkSecurityGroup 'br/public:avm/res/network/network-security-group:0. } output nsgName string = networkSecurityGroup.outputs.name +output nsgResourceId string = networkSecurityGroup.outputs.resourceId diff --git a/infra/modules/privateEndpoint.bicep b/infra/modules/privateEndpoint.bicep new file mode 100644 index 0000000..e69de29 From ec0267956457192b67ced2f91d77099fd38ceedb Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 30 May 2025 23:47:47 -0400 Subject: [PATCH 015/124] deleted extra file --- infra/modules/network - Copy.bicep | 46 ------------------------------ 1 file changed, 46 deletions(-) delete mode 100644 infra/modules/network - Copy.bicep diff --git a/infra/modules/network - Copy.bicep b/infra/modules/network - Copy.bicep deleted file mode 100644 index 2905e71..0000000 --- a/infra/modules/network - Copy.bicep +++ /dev/null @@ -1,46 +0,0 @@ -// networking.bicep -// Creates a VNet and subnets for the solution using AVM modules -//https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network - -param vnetName string -param location string - -@description('Optional: Tags for the VNet') -param tags object = {} - -@description('Address prefixes for the VNet') -param addressPrefixes array - -@description('Optional: DNS servers for the VNet') -param dnsServers array = [] - -// Subnet definitions as an array of objects -@description('Subnets to create in the VNet') -param subnets array - -@description('Optional: Diagnostic settings for the VNet') -param diagnosticSettings array = [] - -module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { - name: vnetName - params: { - addressPrefixes: addressPrefixes - name: vnetName - dnsServers: dnsServers - location: location - subnets: [ - for subnet in subnets: { - name: subnet.name - addressPrefix: subnet.addressPrefix - - } - ] - diagnosticSettings: diagnosticSettings - tags: tags - } -} - -output vnetName string = virtualNetwork.outputs.name -output vnetLocation string = virtualNetwork.outputs.location -output vnetId string = virtualNetwork.outputs.resourceId -output subnetIds array = virtualNetwork.outputs.subnetResourceIds From 926d082bbbca479814fbbba1ff926ddded65fd0f Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Sat, 31 May 2025 00:17:09 -0400 Subject: [PATCH 016/124] updated bastion and jumpbox --- infra/main_network.bicep | 74 +++++++++++-------------------------- infra/modules/network.bicep | 1 + 2 files changed, 23 insertions(+), 52 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 57511ae..bfbe25a 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -129,9 +129,14 @@ module nsgs 'modules/nsg.bicep' = [for (subnet, i) in subnets: if (!empty(subnet } }] -// 2. Build subnets array with NSG resource IDs for AVM VNet module is now inlined below -// 3. Pass avmSubnets to the AVM VNet module +// 2. Create VNet using the AVM VNet module + +resource existingVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = if (vnetReuse) { + name: vnetName +} + + module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { name: '${prefix}-vnet' params: { @@ -151,6 +156,11 @@ module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { diagnosticSettings: diagnosticSettings } } +// need this value for later resorurces +var vnetId = vnetReuse ? existingVnet.id : network.outputs.vnetId +var subnetIds = network.outputs.subnetIds +var subnetNames = network.outputs.subnetNames + // /**************************************************************************/ // // TODO: Bastion Host @@ -159,11 +169,11 @@ module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { // module bastionHost 'modules/bastionHost.bicep' = if (networkIsolation && !bastionHostReuse) { // name: '${prefix}-bastionHost' // params: { +// bastionHostName: '${prefix}-bastionHost' // location: location +// vnetId: vnetId // tags: tags -// virtualNetworkId: network.outputs.vnetId -// publicIpAddressName: '${prefix}-bastionIp' -// sku: 'Standard' + // } // } @@ -171,59 +181,19 @@ module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { // //TODO: Jumpbox VM // /**************************************************************************/ // // Create or reuse Jumpbox VM + + // module jumpbox 'modules/jumpbox.bicep' = if (networkIsolation && !jumpboxReuse) { // name: '${prefix}-jumpbox' // params: { +// prefix:prefix +// vmName:vnetName // location: location // tags: tags -// virtualNetworkId: network.outputs.vnetId -// subnetName: '${prefix}-default' -// publicIpAddressName: '${prefix}-jumpboxIp' +// logAnalyticsWorkspaceId: logAnalyticsWorkspaceId // adminUsername: jumboxAdminUser +// adminPasswordOrKey: 'your-admin-password-or-ssh-key' // Replace with your secure value // vmSize: jumboxVmSize -// osDiskSizeGb: 30 -// imagePublisher: 'Canonical' -// imageOffer: 'UbuntuServer' -// imageSku: '18.04-LTS' -// sshKeyData: '' // Provide your SSH public key here -// } -// } - -// /**************************************************************************/ -// // TODO: AI and Data Services -// /**************************************************************************/ -// // Example: Deploy an AI service (e.g., Azure Cognitive Services) -// module aiService 'modules/aiService.bicep' = if (networkIsolation) { -// name: '${prefix}-aiService' -// params: { -// location: location -// tags: tags -// virtualNetworkId: network.outputs.vnetId -// subnetName: '${prefix}-default' -// // Add other parameters as needed -// } -// } - -// // Example: Deploy a Data service (e.g., Azure SQL Database) -// module dataService 'modules/dataService.bicep' = if (networkIsolation) { -// name: '${prefix}-dataService' -// params: { -// location: location -// tags: tags -// virtualNetworkId: network.outputs.vnetId -// subnetName: '${prefix}-default' -// // Add other parameters as needed +// subnetId: !empty(subnetIds) ? subnetIds[5].id : null // subnets 0 = web, 1-app, 2-ai, 3-data, 4-bastion, 5-jumpbox // } // } - -// /**************************************************************************/ -// // Outputs -// /**************************************************************************/ -// output workspaceId string = logAnalyticsWorkSpace.outputs.workspaceId -// output workspacePrimaryKey string = logAnalyticsWorkSpace.outputs.primaryKey -// output workspaceSecondaryKey string = logAnalyticsWorkSpace.outputs.secondaryKey -// output workspaceName string = logAnalyticsWorkSpace.outputs.workspaceName - -// // Example outputs for AI and Data services -// output aiServiceEndpoint string = aiService.outputs.endpoint -// output dataServiceConnectionString string = dataService.outputs.connectionString diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index eed8b36..7fe3102 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -45,3 +45,4 @@ output vnetName string = virtualNetwork.outputs.name output vnetLocation string = virtualNetwork.outputs.location output vnetId string = virtualNetwork.outputs.resourceId output subnetIds array = virtualNetwork.outputs.subnetResourceIds +output subnetNames array = virtualNetwork.outputs.subnetNames From 0bba1127b50cdf6fd274e28d8f6be0e190f329cf Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 2 Jun 2025 15:30:25 -0400 Subject: [PATCH 017/124] nsgs vnet with subnets created --- infra/Deployment_Plan.md | 165 ++++++++++++++++++++++ infra/main_network.bicep | 137 +++++++++++-------- infra/main_network.bicepparam | 250 ++++++++++++++++++++-------------- 3 files changed, 391 insertions(+), 161 deletions(-) create mode 100644 infra/Deployment_Plan.md diff --git a/infra/Deployment_Plan.md b/infra/Deployment_Plan.md new file mode 100644 index 0000000..60bd5af --- /dev/null +++ b/infra/Deployment_Plan.md @@ -0,0 +1,165 @@ +# Deployment Plan + +The deployment code will be in the **infra** folder. The **modules** subfolder contains reusable, parameterized modules. + +- **main.bicep**: main bicep program that uses parameters defined in +- **main.biceppram** file + +Creates a Virtual Network with subnets, private endpoints for all solution resources (e.g., Azure AI Foundry, Storage, Cosmos DB, Key Vault), Bastion Host, and all networking/security resources recommended by WAF Security Guidelines. Uses Azure Verified Modules (AVM) where appropriate. See [Microsoft Azure Well-Architected Framework (WAF) Security design principles](https://learn.microsoft.com/en-us/azure/well-architected/security/principles) and [Azure Verified Modules (AVM)](https://azure.github.io/Azure-Verified-Modules/). + +If you are new to AVM BICEP implementation, refer to [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/). + + + +Below is an example deployment design. The Group (module) Name column shows how to group code in BICEP modules. + +**Solution Components and placements the Vnet/Subnets** + +| # | Component Name | Notes | Subnet | +| ----------------------------------------- | ------------------------------------------------------------ | ----------------- | ----------------- | +| 1 | Managed Identities | (User/Sys defined) | | +| 2 | Log Analytics Workspace | monitoring **(global, not subnet) | | +| 3 | Application Insights | app-insights **(global, not subnet)** | | +| S | | | | +| 1 | Web App Environment | | web | +| 2 | Frontend Container App | | web | +| 3 | NSG for Web Subnet | nsg | web | +| S | | | | +| 1 | App Environment | | app | +| 2 | Backend Container App | | app | +| 3 | NSG for App Subnet | nsg | app | +| S | | | | +| 1 | AI Hub | ai-foundry / **private end point** | ai | +| 2 | AI Project | ai-foundry / **private end point** | ai | +| 3 | AI Services | ai-foundry / **private end point** | ai | +| 4 | NSG for AI Subnet | nsg | ai | +| S | | | | +| 1 | Key Vault | keyvault / **private end point** | data | +| 2 | Cosmos DB | cosmos-db/ **private end point** | data | +| 3 | Storage Account Front End | storage / **private end point** | data | +| 4 | Storage Account Back End | storage / **private end point** | data | +| 5 | NSG for Data Subnet | nsg | data | +| S | | | | +| 1 | Bastion Host | bastion | bastion | +| 2 | NSG for Bastion | nsg | bastion | +| S | | | | +| 1 | JumpBox VM | (your-jumpbox-module) | jumpbox | +| 2 | NSG for JumpBox | nsg | jumpbox | +| S | | | | +| 1 | Route Table | routeTable | (associated subnets) | +| 2 | Private Endpoints | privateEndpoint | respective subnet | +| 3 | Private DNS Zone | privateDnsZone | vnet | + + + +#### Network Design + +addressPrefixes = [ + + '10.0.0.0/21' // /21: 2048 addresses, good for up to 8-16 subnets. Other options: /23:512, /22:1024, /21:2048, /20:4096, /16: 65,536 (max for a VNet) + +] + +| Subnet | Address Prefix | IP Range | Total IPs | Usable IPs* | +| ------- | -------------- | --------------------- | --------- | ----------- | +| web | 10.0.0.0/24 | 10.0.0.0 – 10.0.0.255 | 256 | 251 | +| app | 10.0.1.0/24 | 10.0.1.0 – 10.0.1.255 | 256 | 251 | +| ai | 10.0.2.0/24 | 10.0.2.0 – 10.0.2.255 | 256 | 251 | +| data | 10.0.3.0/24 | 10.0.3.0 – 10.0.3.255 | 256 | 251 | +| bastion | 10.0.4.0/24 | 10.0.4.0 – 10.0.4.255 | 256 | 251 | +| jumpbox | 10.0.5.0/24 | 10.0.5.0 – 10.0.5.255 | 256 | 251 | + +*Usable IPs = Total IPs minus 5 reserved by Azure per subnet. + +### **Example Subnet/NSG Table** + +| Subnet | NSG Rules (Inbound) | NSG Rules (Outbound) | +|----------|-----------------------------------------------------|-----------------------------| +| web | 80/443 from internet or allowed IPs | To app, internet | +| app | From web subnet only | To data, PaaS | +| ai | From app subnet only | To data, PaaS | +| data | From app/ai/private endpoints | To PaaS, as needed | +| jumpbox | RDP/SSH from allowed IPs or Bastion | To internet, as needed | +| bastion | Platform-managed (Bastion Host only, no direct NSG) | To VMs for RDP/SSH | + +## **Monitoring & Logging** +- Enable diagnostic logs for all resources, send to Log Analytics Workspace. +- Enable Application Insights for all app components. + +## **DDoS Protection** +- Enable at the VNet level for public-facing workloads. + +## **Route Tables (UDRs)** +- Optional, but recommended for advanced routing (e.g., force tunneling through Azure Firewall if used). + +## Azure Bastion Host + +**Function:** + +- Azure Bastion is a managed PaaS service that provides secure RDP/SSH connectivity to your VMs directly from the Azure Portal, without exposing public IP addresses on those VMs. + +**How to Use:** + +- Deploy Bastion Host in a dedicated subnet (must be named `bastion` or `AzureBastionSubnet`). +- When you need to access a VM (like a JumpBox or any other VM) for admin purposes, use the “Connect” button in the Azure Portal and select “Bastion.” +- Your session runs over SSL in the browser—no public IP, no direct RDP/SSH from the internet. + +**Best Practice:** + +- Use Bastion Host for all admin access to VMs in your VNet. +- Never assign public IPs to your VMs. + +## JumpBox VM + +**Function:** + +- A JumpBox (or Jump Host) is a hardened VM (often Linux or Windows) that acts as a single entry point for admin access to other VMs or resources in your private network. +- It is typically used for scenarios where you need to run custom tools, scripts, or have a persistent admin environment. + +**How to Use:** + +- Deploy the JumpBox VM in its own subnet (e.g., `jumpbox`). +- Access the JumpBox via Bastion Host (never via public IP). +- From the JumpBox, you can SSH/RDP to other VMs, run scripts, or use it as a staging point for troubleshooting. + +**Best Practice:** + +- The JumpBox should have minimal software, be tightly controlled, and monitored. +- Use NSGs to restrict access to/from the JumpBox subnet. +- Use Bastion Host to access the JumpBox, not a public IP. + +## Deployment Considerations + +### Add Networking Components +- **Network Security Group (NSG):** Protects subnets and controls inbound/outbound traffic. One NSG per subnet is recommended. +- **Bastion Host:** Provides secure RDP/SSH access to VMs without exposing public IPs. +- **Route Table:** Custom route tables for advanced routing scenarios (optional, but recommended for segmented networks). +- **Private Endpoints:** For secure, private connectivity to PaaS services (Key Vault, Storage, Cosmos DB, etc.), add private endpoints in the relevant subnets. Use a dedicated PrivateEndpointSubnet for easier management. +- **Private DNS Zone:** Required for name resolution of private endpoints, ensuring resources in your VNet can resolve the private DNS names of Azure PaaS services to their private IP addresses. + +### Security & Best Practices +- Use Managed Identity for all services that support it. +- Store secrets and connection strings in Key Vault; never hardcode credentials. +- Enable diagnostic logging for all resources (send to Log Analytics Workspace). +- Apply NSGs to each subnet with least-privilege rules. +- Use Bastion Host for secure admin access. +- Consider DDoS Protection for the VNet if required. +- Use Private Endpoints for PaaS services to avoid public exposure. +- For all PaaS resources with private endpoints, set public network access to 'Deny'. +- Enable soft delete and purge protection on Key Vault and Storage Accounts. + +--- + +**For more information, see:** +- [Microsoft Azure Well-Architected Framework (WAF) Security design principles](https://learn.microsoft.com/en-us/azure/well-architected/security/principles) +- [Azure Verified Modules (AVM)](https://azure.github.io/Azure-Verified-Modules/) +- [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/) +- [Azure Application Gateway documentation](https://learn.microsoft.com/en-us/azure/application-gateway/overview) +- [Azure Bastion documentation](https://learn.microsoft.com/en-us/azure/bastion/bastion-overview) +- [Azure Private Endpoint documentation](https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview) +- [Azure Firewall documentation](https://learn.microsoft.com/en-us/azure/firewall/overview) +- [Azure DDoS Protection documentation](https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview) +- [Azure Key Vault documentation](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) +- [Azure Cosmos DB documentation](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) +- [Azure Storage documentation](https://learn.microsoft.com/en-us/azure/storage/common/storage-introduction) + diff --git a/infra/main_network.bicep b/infra/main_network.bicep index bfbe25a..76a080a 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -34,20 +34,14 @@ var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) // Network parameters (these will be set via main_network.bicepparam) param networkIsolation bool -param webSecurityRules array -param appSecurityRules array -param aiSecurityRules array -param dataSecurityRules array -param bastionSecurityRules array // Security rules for Bastion Host -param jumpboxSecurityRules array // Security rules for Jumpbox VM - //param vnetName string -param addressPrefixes array -param dnsServers array -param subnets array +param vnetAddressPrefixes array +param mySubnets array +param testSubnets array = [] var vnetName = '${prefix}-vnet' + param jumboxAdminUser string param jumboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden param privateEndPoint bool = true @@ -82,84 +76,111 @@ var diagnosticSettings = [ { name: '${prefix}vnetDiagnostics' workspaceResourceId: logAnalyticsWorkspaceId - logs: [ - // Prioritized: Only most important categories for VNet/network security - { - category: 'NetworkSecurityGroupEvent' - enabled: true - retentionPolicy: { - enabled: false - days: 0 - } - } + logCategoriesAndGroups: [ { - category: 'NetworkSecurityGroupRuleCounter' + categoryGroup: 'allLogs' enabled: true - retentionPolicy: { - enabled: false - days: 0 - } } ] - metrics: [ + metricCategories: [ { category: 'AllMetrics' enabled: true - retentionPolicy: { - enabled: false // for development, set to false - days: 0 - // Replace with the following lines to enable retention policy - // enabled: true - // days: 30 - } } ] } ] -// 1. Create NSGs for subnets using the AVM NSG module -module nsgs 'modules/nsg.bicep' = [for (subnet, i) in subnets: if (!empty(subnet.networkSecurityGroup)) { +// module nsg 'br/public:avm/res/network/network-security-group:0.5.1' = { +// name: 'my-nsg-deployment' +// params: { +// name: 'my-nsg' +// location: location +// securityRules: [ +// { +// name: 'AllowHttpsInbound' +// properties: { +// access: 'Allow' +// direction: 'Inbound' +// priority: 100 +// protocol: 'Tcp' +// sourcePortRange: '*' +// destinationPortRange: '443' +// sourceAddressPrefixes: ['0.0.0.0/0'] +// destinationAddressPrefixes: ['10.0.0.0/24'] +// } +// } +// // Add more rules as needed +// ] +// tags: { +// environment: 'dev' +// } +// } +// } + + +// 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true +@batchSize(1) +module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [for (subnet, i) in mySubnets: if (networkIsolation && !empty(subnet.networkSecurityGroup)) { name: '${prefix}-${subnet.networkSecurityGroup.name}' params: { - nsgName: '${prefix}-${subnet.networkSecurityGroup.name}' + name: '${prefix}-${subnet.networkSecurityGroup.name}' location: location - tags: tags securityRules: subnet.networkSecurityGroup.securityRules + tags: tags } }] - -// 2. Create VNet using the AVM VNet module - -resource existingVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = if (vnetReuse) { +// 2. Create VNet and subnets using AVM Virtual Network module +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { name: vnetName -} - - -module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { - name: '${prefix}-vnet' params: { - vnetName: vnetName + name: vnetName location: location - addressPrefixes: addressPrefixes - dnsServers: dnsServers + addressPrefixes: vnetAddressPrefixes subnets: [ - for (subnet, i) in subnets: { + for (subnet, i) in mySubnets: { name: subnet.name - addressPrefix: subnet.addressPrefix - networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.nsgResourceId : null - // Add other properties as needed (e.g., routeTableResourceId) + addressPrefixes: subnet.addressPrefixes + networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null } ] - tags: tags diagnosticSettings: diagnosticSettings + tags: tags } } -// need this value for later resorurces -var vnetId = vnetReuse ? existingVnet.id : network.outputs.vnetId -var subnetIds = network.outputs.subnetIds -var subnetNames = network.outputs.subnetNames + + +// // 2. Create VNet using the AVM VNet module + +// resource existingVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = if (vnetReuse) { +// name: vnetName +// } + +// module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { +// name: '${prefix}-vnet' +// params: { +// vnetName: vnetName +// location: location +// addressPrefixes: addressPrefixes +// dnsServers: dnsServers +// subnets: [ +// for (subnet, i) in subnets: { +// name: subnet.name +// addressPrefix: subnet.addressPrefix +// networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.nsgResourceId : null +// // Add other properties as needed (e.g., routeTableResourceId) +// } +// ] +// tags: tags +// diagnosticSettings: diagnosticSettings +// } +// } +// // need this value for later resorurces +// var vnetId = vnetReuse ? existingVnet.id : network.outputs.vnetId +// var subnetIds = network.outputs.subnetIds +// var subnetNames = network.outputs.subnetNames // /**************************************************************************/ diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index d1d0574..aef07b8 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -3,7 +3,7 @@ using './main_network.bicep' -param resourceGroupName = 'gaiye-avm-09-rg' +param resourceGroupName = 'gaiye-avm-10-rg' param location = 'eastus' param networkIsolation = true @@ -12,7 +12,7 @@ param privateEndPoint = true param jumboxAdminUser = 'JumpboxAdmin' // Admin user for the jumpbox VM param jumboxVmSize = 'Standard_D2s_v3' // 'Standard_B2s' not good enough for WAF -param logAnalyticsWorkspaceReuse = true +param logAnalyticsWorkspaceReuse = false param vnetReuse = false // set it to true if you want to reuse an existing VNet already creatd param bastionHostReuse = false param jumpboxReuse = false @@ -21,152 +21,196 @@ param jumpboxReuse = false // Network Security Groups (NSGs) and their rules //******************************************************************* -param addressPrefixes = [ - '10.0.0.0/20' // 4,096 IP addresses. Other options: (1) /16: 65,536 (2) /24: 256 Addresses -] -param dnsServers = [ - '10.0.1.4' - '10.0.1.5' +param vnetAddressPrefixes = [ + '10.0.0.0/21' // /21: 2048 addresses, good for up to 8-16 subnets. Other options: /23:512, /22:1024, /21:2048, /20:4096, /16: 65,536 (max for a VNet) ] -param webSecurityRules = [ +param testSubnets = [ { - name: 'AllowHttpsInbound' - priority: 100 - direction: 'Inbound' - access: 'Allow' - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['0.0.0.0/0'] - } -] - -param appSecurityRules = [ - { - name: 'AllowWebToApp' - priority: 100 - direction: 'Inbound' - access: 'Allow' - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.1.0/24'] // Web subnet - destinationAddressPrefixes: ['0.0.0.0/0'] + name: 'web' + addressPrefixes: ['10.0.0.0/24'] + networkSecurityGroup: { + name: 'web-nsg' + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/24'] + } + } + ] + } } -] - -param aiSecurityRules = [ { - name: 'AllowAppToAI' - priority: 100 - direction: 'Inbound' - access: 'Allow' - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.1.0/24' // Web subnet - '10.0.2.0/24' // App subnet - ] - destinationAddressPrefixes: ['0.0.0.0/0'] - } -] - -param dataSecurityRules = [ - { - name: 'AllowWebandAppToData' - priority: 100 - direction: 'Inbound' - access: 'Allow' - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.1.0/24' // Web subnet - '10.0.2.0/24' // App subnet - ] - destinationAddressPrefixes: ['0.0.0.0/0'] + name: 'app' + addressPrefixes: ['10.0.1.0/24'] + networkSecurityGroup: { + name: 'app-nsg' + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/24'] + destinationAddressPrefixes: ['10.0.1.0/24'] + } + } + ] + } } ] -param bastionSecurityRules = [ - { - name: 'AllowBastionInbound' - priority: 100 - direction: 'Inbound' - access: 'Allow' - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.5.0/24'] - } -] -param jumpboxSecurityRules = [ - { - name: 'AllowJumpboxInbound' - priority: 100 - direction: 'Inbound' - access: 'Allow' - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.6.0/24'] - } -] -param subnets = [ +param mySubnets = [ { name: 'web' - addressPrefix: '10.0.1.0/24' + addressPrefixes: ['10.0.0.0/24'] networkSecurityGroup: { name: 'web-nsg' - securityRules: webSecurityRules + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/24'] + } + } + ] } } { name: 'app' - addressPrefix: '10.0.2.0/24' + addressPrefixes: ['10.0.1.0/24'] networkSecurityGroup: { name: 'app-nsg' - securityRules: appSecurityRules + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/24'] + destinationAddressPrefixes: ['10.0.1.0/24'] + } + } + ] } } { name: 'ai' - addressPrefix: '10.0.3.0/24' + addressPrefixes: ['10.0.2.0/24'] networkSecurityGroup: { name: 'ai-nsg' - securityRules: aiSecurityRules + securityRules: [ + { + name: 'AllowAppToAI' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.1.0/24'] + destinationAddressPrefixes: ['10.0.2.0/24'] + } + } + ] } } { name: 'data' - addressPrefix: '10.0.4.0/24' + addressPrefixes: ['10.0.3.0/24'] networkSecurityGroup: { name: 'data-nsg' - securityRules: dataSecurityRules + securityRules: [ + { + name: 'AllowWebandAppToData' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' + '10.0.1.0/24' + '10.0.2.0/24' + ] + destinationAddressPrefixes: ['10.0.3.0/24'] + } + } + ] } } { name: 'bastion' - addressPrefix: '10.0.5.0/24' + addressPrefixes: ['10.0.4.0/24'] networkSecurityGroup: { name: 'bastion-nsg' - securityRules: bastionSecurityRules + securityRules: [ + { + name: 'AllowBastionInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.4.0/24'] + } + } + ] } } { name: 'jumpbox' - addressPrefix: '10.0.6.0/24' + addressPrefixes: ['10.0.5.0/24'] networkSecurityGroup: { name: 'jumpbox-nsg' - securityRules: jumpboxSecurityRules + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.5.0/24'] + } + } + ] } } + // Add more subnets here as needed, e.g. for private endpoints, firewall, etc. ] From c18b734a4fc76cf339f650700044e057eda3eb12 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 2 Jun 2025 16:01:54 -0400 Subject: [PATCH 018/124] law and network created and tested --- infra/main_network.bicep | 82 ++++------------------------------- infra/main_network.bicepparam | 54 ----------------------- 2 files changed, 9 insertions(+), 127 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 76a080a..63701e7 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -18,11 +18,6 @@ param tags object = { 'Solution Type': solutionType } -param logAnalyticsWorkspaceReuse bool = false // If true, will reuse existing Log Analytics Workspace if available -param vnetReuse bool = false // If true, will reuse existing VNet if available -param bastionHostReuse bool = false // If true, will reuse existing Bastion Host if available -param jumpboxReuse bool = false // If true, will reuse existing Jumpbox VM if available - /**************************************************************************/ // prefix generation /**************************************************************************/ @@ -37,7 +32,6 @@ param networkIsolation bool //param vnetName string param vnetAddressPrefixes array param mySubnets array -param testSubnets array = [] var vnetName = '${prefix}-vnet' @@ -52,23 +46,21 @@ param privateEndPoint bool = true // Log Analytics Workspace that will be used across the solution /**************************************************************************/ // crate a Log Analytics Workspace using AVM -resource existingLogAnalyticsWorkSpace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = if (logAnalyticsWorkspaceReuse) { - name: '${prefix}logAnalyticsWorkspace' -} -module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = if (!logAnalyticsWorkspaceReuse) { + +module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { name: '${prefix}logAnalyticsWorkspace' params: { - logAnalyticsWorkSpaceName: '${prefix}law' + logAnalyticsWorkSpaceName: '${prefix}-law' location: location tags: tags } } -var logAnalyticsWorkspaceId = logAnalyticsWorkspaceReuse ? existingLogAnalyticsWorkSpace.id : logAnalyticsWorkSpace.outputs.workspaceId +var logAnalyticsWorkspaceId = logAnalyticsWorkSpace.outputs.workspaceId /**************************************************************************/ -// Network Structures +// Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG /**************************************************************************/ // Diagnostic settings for VNet using Log Analytics Workspace @@ -92,34 +84,6 @@ var diagnosticSettings = [ ] -// module nsg 'br/public:avm/res/network/network-security-group:0.5.1' = { -// name: 'my-nsg-deployment' -// params: { -// name: 'my-nsg' -// location: location -// securityRules: [ -// { -// name: 'AllowHttpsInbound' -// properties: { -// access: 'Allow' -// direction: 'Inbound' -// priority: 100 -// protocol: 'Tcp' -// sourcePortRange: '*' -// destinationPortRange: '443' -// sourceAddressPrefixes: ['0.0.0.0/0'] -// destinationAddressPrefixes: ['10.0.0.0/24'] -// } -// } -// // Add more rules as needed -// ] -// tags: { -// environment: 'dev' -// } -// } -// } - - // 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true @batchSize(1) module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [for (subnet, i) in mySubnets: if (networkIsolation && !empty(subnet.networkSecurityGroup)) { @@ -150,38 +114,10 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { tags: tags } } - - -// // 2. Create VNet using the AVM VNet module - -// resource existingVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = if (vnetReuse) { -// name: vnetName -// } - -// module network 'modules/network.bicep' = if (networkIsolation && !vnetReuse) { -// name: '${prefix}-vnet' -// params: { -// vnetName: vnetName -// location: location -// addressPrefixes: addressPrefixes -// dnsServers: dnsServers -// subnets: [ -// for (subnet, i) in subnets: { -// name: subnet.name -// addressPrefix: subnet.addressPrefix -// networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.nsgResourceId : null -// // Add other properties as needed (e.g., routeTableResourceId) -// } -// ] -// tags: tags -// diagnosticSettings: diagnosticSettings -// } -// } -// // need this value for later resorurces -// var vnetId = vnetReuse ? existingVnet.id : network.outputs.vnetId -// var subnetIds = network.outputs.subnetIds -// var subnetNames = network.outputs.subnetNames - +output vnetName string = virtualNetwork.outputs.name +output vnetLocation string = virtualNetwork.outputs.location +output vnetId string = virtualNetwork.outputs.resourceId +output subnetIds array = virtualNetwork.outputs.subnetResourceIds // /**************************************************************************/ // // TODO: Bastion Host diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index aef07b8..2a107f3 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -12,10 +12,6 @@ param privateEndPoint = true param jumboxAdminUser = 'JumpboxAdmin' // Admin user for the jumpbox VM param jumboxVmSize = 'Standard_D2s_v3' // 'Standard_B2s' not good enough for WAF -param logAnalyticsWorkspaceReuse = false -param vnetReuse = false // set it to true if you want to reuse an existing VNet already creatd -param bastionHostReuse = false -param jumpboxReuse = false //******************************************************************* // Network Security Groups (NSGs) and their rules @@ -25,56 +21,6 @@ param vnetAddressPrefixes = [ '10.0.0.0/21' // /21: 2048 addresses, good for up to 8-16 subnets. Other options: /23:512, /22:1024, /21:2048, /20:4096, /16: 65,536 (max for a VNet) ] - -param testSubnets = [ - { - name: 'web' - addressPrefixes: ['10.0.0.0/24'] - networkSecurityGroup: { - name: 'web-nsg' - securityRules: [ - { - name: 'AllowHttpsInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/24'] - } - } - ] - } - } - { - name: 'app' - addressPrefixes: ['10.0.1.0/24'] - networkSecurityGroup: { - name: 'app-nsg' - securityRules: [ - { - name: 'AllowWebToApp' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/24'] - destinationAddressPrefixes: ['10.0.1.0/24'] - } - } - ] - } - } -] - - - param mySubnets = [ { name: 'web' From 05d3196a3e40396a9a5670c516460bad1ab64605 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 2 Jun 2025 16:19:11 -0400 Subject: [PATCH 019/124] deleted network and nsg code in moduoles subfolder --- infra/main_network.bicep | 13 +++++----- infra/modules/network.bicep | 48 ------------------------------------- infra/modules/nsg.bicep | 41 ------------------------------- 3 files changed, 6 insertions(+), 96 deletions(-) delete mode 100644 infra/modules/network.bicep delete mode 100644 infra/modules/nsg.bicep diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 63701e7..fa7a295 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -18,9 +18,9 @@ param tags object = { 'Solution Type': solutionType } -/**************************************************************************/ +/****************************************************************************************************************************/ // prefix generation -/**************************************************************************/ +/****************************************************************************************************************************/ var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces var resourceToken = toLower('${substring(cleanSolutionName, 0, 1)}${uniqueString(cleanSolutionName, resourceGroupName, subscription().id)}') var resourceTokenTrimmed = length(resourceToken) > 9 ? substring(resourceToken, 0, 9) : resourceToken @@ -41,13 +41,12 @@ param jumboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, ca param privateEndPoint bool = true - -/**************************************************************************/ +/****************************************************************************************************************************/ // Log Analytics Workspace that will be used across the solution -/**************************************************************************/ +/****************************************************************************************************************************/ +// prefix generation // crate a Log Analytics Workspace using AVM - module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { name: '${prefix}logAnalyticsWorkspace' params: { @@ -97,7 +96,7 @@ module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [for (sub }] // 2. Create VNet and subnets using AVM Virtual Network module -module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = if (networkIsolation) { name: vnetName params: { name: vnetName diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep deleted file mode 100644 index 7fe3102..0000000 --- a/infra/modules/network.bicep +++ /dev/null @@ -1,48 +0,0 @@ -// networking.bicep -// Creates a VNet and subnets for the solution using AVM modules -//https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network - -param vnetName string -param location string - -@description('Optional: Tags for the VNet') -param tags object = {} - -@description('Address prefixes for the VNet') -param addressPrefixes array - -@description('Optional: DNS servers for the VNet') -param dnsServers array = [] - -// Subnet definitions as an array of objects -@description('Subnets to create in the VNet') -param subnets array - -@description('Optional: Diagnostic settings for the VNet') -param diagnosticSettings array = [] - -module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { - name: vnetName - params: { - addressPrefixes: addressPrefixes - name: vnetName - dnsServers: dnsServers - location: location - subnets: [ - for subnet in subnets: { - name: subnet.name - addressPrefix: subnet.addressPrefix - networkSecurityGroupResourceId: subnet.?networkSecurityGroupResourceId - routeTableResourceId: subnet.?routeTableResourceId - } - ] - diagnosticSettings: diagnosticSettings - tags: tags - } -} - -output vnetName string = virtualNetwork.outputs.name -output vnetLocation string = virtualNetwork.outputs.location -output vnetId string = virtualNetwork.outputs.resourceId -output subnetIds array = virtualNetwork.outputs.subnetResourceIds -output subnetNames array = virtualNetwork.outputs.subnetNames diff --git a/infra/modules/nsg.bicep b/infra/modules/nsg.bicep deleted file mode 100644 index a1426b3..0000000 --- a/infra/modules/nsg.bicep +++ /dev/null @@ -1,41 +0,0 @@ -// Creates a Network Security Group (NSG) using AVM modules -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group - -@description('Name of the Network Security Group') -param nsgName string - -@description('Azure region for the NSG') -param location string = resourceGroup().location - -@description('Optional: Tags for the NSG') -param tags object = {} - -@description('Optional: Security rules for the NSG') -param securityRules array = [] - -module networkSecurityGroup 'br/public:avm/res/network/network-security-group:0.5.1' = { - name: nsgName - params: { - name: nsgName - location: location - securityRules: [ - for rule in securityRules: { - name: rule.name - properties: { - priority: rule.priority - direction: rule.direction - access: rule.access - protocol: rule.protocol ?? '*' - sourcePortRange: rule.sourcePortRange - destinationPortRange: rule.destinationPortRange - sourceAddressPrefixes: rule.sourceAddressPrefixes ?? (rule.sourceAddressPrefix != null ? [rule.sourceAddressPrefix] : null) - destinationAddressPrefixes: rule.destinationAddressPrefixes ?? (rule.destinationAddressPrefix != null ? [rule.destinationAddressPrefix] : null) - } - } - ] - tags: tags - } -} - -output nsgName string = networkSecurityGroup.outputs.name -output nsgResourceId string = networkSecurityGroup.outputs.resourceId From 45a4016ed215969981f929e3daf5ddf0c615d5be Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 2 Jun 2025 16:23:20 -0400 Subject: [PATCH 020/124] Added logical seperations --- infra/main_network.bicep | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index fa7a295..a118547 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -58,9 +58,10 @@ module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { var logAnalyticsWorkspaceId = logAnalyticsWorkSpace.outputs.workspaceId -/**************************************************************************/ +/****************************************************************************************************************************/ // Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG -/**************************************************************************/ +/****************************************************************************************************************************/ + // Diagnostic settings for VNet using Log Analytics Workspace var diagnosticSettings = [ @@ -118,9 +119,9 @@ output vnetLocation string = virtualNetwork.outputs.location output vnetId string = virtualNetwork.outputs.resourceId output subnetIds array = virtualNetwork.outputs.subnetResourceIds -// /**************************************************************************/ +/****************************************************************************************************************************/ // // TODO: Bastion Host -// /**************************************************************************/ +/****************************************************************************************************************************/ // // Create or reuse Bastion Host // module bastionHost 'modules/bastionHost.bicep' = if (networkIsolation && !bastionHostReuse) { // name: '${prefix}-bastionHost' @@ -133,9 +134,9 @@ output subnetIds array = virtualNetwork.outputs.subnetResourceIds // } // } -// /**************************************************************************/ +/****************************************************************************************************************************/ // //TODO: Jumpbox VM -// /**************************************************************************/ +/****************************************************************************************************************************/ // // Create or reuse Jumpbox VM From e0890f8ba5da42b554ca5edb76e3588a1008ebcb Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 2 Jun 2025 19:28:53 -0400 Subject: [PATCH 021/124] Azure Bastion Host tested --- infra/main_network.bicep | 30 ++++++++++++++++++------------ infra/main_network.bicepparam | 7 +++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index a118547..68ac698 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -34,7 +34,7 @@ param vnetAddressPrefixes array param mySubnets array var vnetName = '${prefix}-vnet' - +param azureBastionSubnet object = {} param jumboxAdminUser string param jumboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden @@ -97,6 +97,10 @@ module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [for (sub }] // 2. Create VNet and subnets 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' = if (networkIsolation) { name: vnetName params: { @@ -122,17 +126,19 @@ output subnetIds array = virtualNetwork.outputs.subnetResourceIds /****************************************************************************************************************************/ // // TODO: Bastion Host /****************************************************************************************************************************/ -// // Create or reuse Bastion Host -// module bastionHost 'modules/bastionHost.bicep' = if (networkIsolation && !bastionHostReuse) { -// name: '${prefix}-bastionHost' -// params: { -// bastionHostName: '${prefix}-bastionHost' -// location: location -// vnetId: vnetId -// tags: tags - -// } -// } +// // Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet + +// Create Azure Bastion Subnet if azureBastionSubnet is not empty and networkIsolation is true +module azureBastionSubnetRes 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && !empty(azureBastionSubnet)) { + name: '${prefix}-AzureBastionSubnet' + params: { + virtualNetworkName:virtualNetwork.outputs.name + name: azureBastionSubnet.name + addressPrefixes: azureBastionSubnet.addressPrefixes + } +} + /****************************************************************************************************************************/ // //TODO: Jumpbox VM diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index 2a107f3..b6e0d90 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -160,3 +160,10 @@ param mySubnets = [ } // Add more subnets here as needed, e.g. for private endpoints, firewall, etc. ] + + +param azureBastionSubnet = { + name: 'AzureBastionSubnet' // Required name for Azure Bastion + addressPrefixes: ['10.0.6.0/27'] + networkSecurityGroup: null // Must not have an NSG +} From 0beea810e7d1ce79a760f3a8d1989b54278b571e Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 2 Jun 2025 23:13:59 -0400 Subject: [PATCH 022/124] all resources created - tested --- infra/Deployment_Plan.md | 31 ++++++----- infra/main_network.bicep | 102 +++++++++++++++++++++++++--------- infra/main_network.bicepparam | 28 ++++++---- 3 files changed, 111 insertions(+), 50 deletions(-) diff --git a/infra/Deployment_Plan.md b/infra/Deployment_Plan.md index 60bd5af..5413480 100644 --- a/infra/Deployment_Plan.md +++ b/infra/Deployment_Plan.md @@ -40,11 +40,13 @@ Below is an example deployment design. The Group (module) Name column shows how | 4 | Storage Account Back End | storage / **private end point** | data | | 5 | NSG for Data Subnet | nsg | data | | S | | | | -| 1 | Bastion Host | bastion | bastion | -| 2 | NSG for Bastion | nsg | bastion | +| 1 | Services | For future expansion, any services that AI or App will utilize | services | +| 2 | NSG for Services | nsg | services | | S | | | | -| 1 | JumpBox VM | (your-jumpbox-module) | jumpbox | -| 2 | NSG for JumpBox | nsg | jumpbox | +| 11 | JumpBox VMAzure Bastion Host | (your-jumpbox-module) | jumpbox | +| 22 | NSG for JumpBox | nsg | jumpbox | +| S | | | | +| 1 | Azure Bastion Host | PaaS, no NSG | AzureBastionSubnet | | S | | | | | 1 | Route Table | routeTable | (associated subnets) | | 2 | Private Endpoints | privateEndpoint | respective subnet | @@ -56,18 +58,21 @@ Below is an example deployment design. The Group (module) Name column shows how addressPrefixes = [ - '10.0.0.0/21' // /21: 2048 addresses, good for up to 8-16 subnets. Other options: /23:512, /22:1024, /21:2048, /20:4096, /16: 65,536 (max for a VNet) + '10.0.0.0/21' // /21: **2048 addresses, good for up to 8-16 subnets**. Other options: /23:512, /22:1024, /21:2048, /20:4096, /16: 65,536 (max for a VNet) ] -| Subnet | Address Prefix | IP Range | Total IPs | Usable IPs* | -| ------- | -------------- | --------------------- | --------- | ----------- | -| web | 10.0.0.0/24 | 10.0.0.0 – 10.0.0.255 | 256 | 251 | -| app | 10.0.1.0/24 | 10.0.1.0 – 10.0.1.255 | 256 | 251 | -| ai | 10.0.2.0/24 | 10.0.2.0 – 10.0.2.255 | 256 | 251 | -| data | 10.0.3.0/24 | 10.0.3.0 – 10.0.3.255 | 256 | 251 | -| bastion | 10.0.4.0/24 | 10.0.4.0 – 10.0.4.255 | 256 | 251 | -| jumpbox | 10.0.5.0/24 | 10.0.5.0 – 10.0.5.255 | 256 | 251 | +256 x 7 = 1792 allocated + +| Subnet | Address Prefix | IP Range | Total IPs | Usable IPs* | +| ----------- | -------------- | --------------------- | --------- | ----------- | +| web | 10.0.0.0/24 | 10.0.0.0 – 10.0.0.255 | 256 | 251 | +| app | 10.0.1.0/24 | 10.0.1.0 – 10.0.1.255 | 256 | 251 | +| ai | 10.0.2.0/24 | 10.0.2.0 – 10.0.2.255 | 256 | 251 | +| data | 10.0.3.0/24 | 10.0.3.0 – 10.0.3.255 | 256 | 251 | +| services | 10.0.4.0/24 | 10.0.4.0 – 10.0.4.255 | 256 | 251 | +| jumpbox | 10.0.5.0/24 | 10.0.5.0 – 10.0.5.255 | 256 | 251 | +| bastionHost | 10.0.6.0/27 | 10.0.6.0 – 10.0.6.255 | 256 | 251 | *Usable IPs = Total IPs minus 5 reserved by Azure per subnet. diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 68ac698..bb634cb 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -85,6 +85,9 @@ var diagnosticSettings = [ // 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true +// 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 mySubnets: if (networkIsolation && !empty(subnet.networkSecurityGroup)) { name: '${prefix}-${subnet.networkSecurityGroup.name}' @@ -96,11 +99,10 @@ module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [for (sub } }] -// 2. Create VNet and subnets using AVM Virtual Network module +// 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' = if (networkIsolation) { name: vnetName params: { @@ -123,10 +125,11 @@ output vnetLocation string = virtualNetwork.outputs.location output vnetId string = virtualNetwork.outputs.resourceId output subnetIds array = virtualNetwork.outputs.subnetResourceIds + /****************************************************************************************************************************/ -// // TODO: Bastion Host +// // TODO:Azure Bastion Host /****************************************************************************************************************************/ -// // Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet +// 1. Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet // Create Azure Bastion Subnet if azureBastionSubnet is not empty and networkIsolation is true @@ -139,24 +142,73 @@ module azureBastionSubnetRes 'br/public:avm/res/network/virtual-network/subnet:0 } } +// 2. Create Azure Bastion Host in AzureBastionSubnet using AVM Bastion Host module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host -/****************************************************************************************************************************/ -// //TODO: Jumpbox VM -/****************************************************************************************************************************/ -// // Create or reuse Jumpbox VM - - -// module jumpbox 'modules/jumpbox.bicep' = if (networkIsolation && !jumpboxReuse) { -// name: '${prefix}-jumpbox' -// params: { -// prefix:prefix -// vmName:vnetName -// location: location -// tags: tags -// logAnalyticsWorkspaceId: logAnalyticsWorkspaceId -// adminUsername: jumboxAdminUser -// adminPasswordOrKey: 'your-admin-password-or-ssh-key' // Replace with your secure value -// vmSize: jumboxVmSize -// subnetId: !empty(subnetIds) ? subnetIds[5].id : null // subnets 0 = web, 1-app, 2-ai, 3-data, 4-bastion, 5-jumpbox -// } -// } +module avmBastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (networkIsolation && !empty(azureBastionSubnet)) { + name: '${prefix}-bastionhost' + params: { + name: '${prefix}-bastionhost' + skuName: 'Standard' + location: location + virtualNetworkResourceId: virtualNetwork.outputs.resourceId + diagnosticSettings: [ + { + name: 'bastionDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + } + ] + tags: tags + } +} + +// /****************************************************************************************************************************/ +// // Jumpbox VM +// /****************************************************************************************************************************/ +// // // Create or reuse Jumpbox VM + +// Variables for dynamic Jumpbox subnet reference (must be after subnetIds output) +var subnetNames = [for subnet in mySubnets: subnet.name] +var jumpboxSubnetIndex = indexOf(subnetNames, 'jumpbox') + +module avmJumpbox 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (networkIsolation) { + name: '${prefix}-jumpbox' + params: { + name: take('${prefix}-jumpbox', 15) + vmSize: jumboxVmSize + location: location + adminUsername: jumboxAdminUser + adminPassword:'${prefix}P@ssw0rd!' // This should be replaced with a secure method of handling passwords + tags: tags + zone:2 + imageReference: { + offer: 'WindowsServer' + publisher: 'MicrosoftWindowsServer' + sku: '2019-datacenter' + version: 'latest' + } + osType: 'Windows' + osDisk:{managedDisk: { + storageAccountType: 'Standard_LRS' + }} + encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + nicConfigurations: [ + { + name: 'nicJumpbox' + ipConfigurations: [ + { + name: 'ipconfig1' + subnetResourceId: virtualNetwork.outputs.subnetResourceIds[jumpboxSubnetIndex] + } + ] + networkSecurityGroupResourceId: !empty(mySubnets[jumpboxSubnetIndex].networkSecurityGroup) ? nsgs[jumpboxSubnetIndex].outputs.resourceId : null + } + ] + } +} diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index b6e0d90..9e3d38b 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -3,7 +3,7 @@ using './main_network.bicep' -param resourceGroupName = 'gaiye-avm-10-rg' +param resourceGroupName = 'gaiye-avm-waf-01-rg' // Name of the resource group for the network resources param location = 'eastus' param networkIsolation = true @@ -59,7 +59,7 @@ param mySubnets = [ protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/24'] + sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet destinationAddressPrefixes: ['10.0.1.0/24'] } } @@ -81,7 +81,7 @@ param mySubnets = [ protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.1.0/24'] + sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet destinationAddressPrefixes: ['10.0.2.0/24'] } } @@ -95,7 +95,7 @@ param mySubnets = [ name: 'data-nsg' securityRules: [ { - name: 'AllowWebandAppToData' + name: 'AllowWebAppAiToData' properties: { access: 'Allow' direction: 'Inbound' @@ -104,9 +104,9 @@ param mySubnets = [ sourcePortRange: '*' destinationPortRange: '*' sourceAddressPrefixes: [ - '10.0.0.0/24' - '10.0.1.0/24' - '10.0.2.0/24' + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet ] destinationAddressPrefixes: ['10.0.3.0/24'] } @@ -115,21 +115,25 @@ param mySubnets = [ } } { - name: 'bastion' + name: 'services' addressPrefixes: ['10.0.4.0/24'] networkSecurityGroup: { - name: 'bastion-nsg' + name: 'services-nsg' securityRules: [ { - name: 'AllowBastionInbound' + name: 'AllowWebAppAiToServices' properties: { access: 'Allow' direction: 'Inbound' priority: 100 protocol: 'Tcp' sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: ['0.0.0.0/0'] + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] destinationAddressPrefixes: ['10.0.4.0/24'] } } From cdcbc4ba57306e6f0e6aec4113d242c1e19fb317 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 3 Jun 2025 10:02:02 -0400 Subject: [PATCH 023/124] Keyvault - removed purged protection --- infra/main.bicep | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infra/main.bicep b/infra/main.bicep index c0ce62c..3f42132 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -191,6 +191,7 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { enableVaultForDiskEncryption: true enableVaultForTemplateDeployment: true enableRbacAuthorization: true + enablePurgeProtection: false publicNetworkAccess: 'Enabled' softDeleteRetentionInDays: 7 diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] @@ -278,6 +279,7 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { cpu: '1' memory: '2.0Gi' } + } ] ingressTargetPort: 3000 From 394699f20f1b1cbfcb05aae8d72be7a4323d16d6 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 3 Jun 2025 12:16:27 -0400 Subject: [PATCH 024/124] Infra - removed abbr json --- infra/abbreviations.json | 227 --------------------------------------- infra/main.bicep | 28 +++-- 2 files changed, 13 insertions(+), 242 deletions(-) delete mode 100644 infra/abbreviations.json diff --git a/infra/abbreviations.json b/infra/abbreviations.json deleted file mode 100644 index da3423f..0000000 --- a/infra/abbreviations.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "ai": { - "aiSearch": "srch-", - "aiServices": "ais-", - "aiVideoIndexer": "avi-", - "machineLearningWorkspace": "mlw-", - "openAIService": "oai-", - "botService": "bot-", - "computerVision": "cv-", - "contentModerator": "cm-", - "contentSafety": "cs-", - "customVisionPrediction": "cstv-", - "customVisionTraining": "cstvt-", - "documentIntelligence": "di-", - "faceApi": "face-", - "healthInsights": "hi-", - "immersiveReader": "ir-", - "languageService": "lang-", - "speechService": "spch-", - "translator": "trsl-", - "hub": "hub-", - "project": "proj-" - }, - "analytics": { - "analysisServicesServer": "as", - "databricksWorkspace": "dbw-", - "dataExplorerCluster": "dec", - "dataExplorerClusterDatabase": "dedb", - "dataFactory": "adf-", - "digitalTwin": "dt-", - "streamAnalytics": "asa-", - "synapseAnalyticsPrivateLinkHub": "synplh-", - "synapseAnalyticsSQLDedicatedPool": "syndp", - "synapseAnalyticsSparkPool": "synsp", - "synapseAnalyticsWorkspaces": "synw", - "dataLakeStoreAccount": "dls", - "dataLakeAnalyticsAccount": "dla", - "eventHubsNamespace": "evhns-", - "eventHub": "evh-", - "eventGridDomain": "evgd-", - "eventGridSubscriptions": "evgs-", - "eventGridTopic": "evgt-", - "eventGridSystemTopic": "egst-", - "hdInsightHadoopCluster": "hadoop-", - "hdInsightHBaseCluster": "hbase-", - "hdInsightKafkaCluster": "kafka-", - "hdInsightSparkCluster": "spark-", - "hdInsightStormCluster": "storm-", - "hdInsightMLServicesCluster": "mls-", - "iotHub": "iot-", - "provisioningServices": "provs-", - "provisioningServicesCertificate": "pcert-", - "powerBIEmbedded": "pbi-", - "timeSeriesInsightsEnvironment": "tsi-" - }, - "compute": { - "appServiceEnvironment": "ase-", - "appServicePlan": "asp-", - "loadTesting": "lt-", - "availabilitySet": "avail-", - "arcEnabledServer": "arcs-", - "arcEnabledKubernetesCluster": "arck", - "batchAccounts": "ba-", - "cloudService": "cld-", - "communicationServices": "acs-", - "diskEncryptionSet": "des", - "functionApp": "func-", - "gallery": "gal", - "hostingEnvironment": "host-", - "imageTemplate": "it-", - "managedDiskOS": "osdisk", - "managedDiskData": "disk", - "notificationHubs": "ntf-", - "notificationHubsNamespace": "ntfns-", - "proximityPlacementGroup": "ppg-", - "restorePointCollection": "rpc-", - "snapshot": "snap-", - "staticWebApp": "stapp-", - "virtualMachine": "vm", - "virtualMachineScaleSet": "vmss-", - "virtualMachineMaintenanceConfiguration": "mc-", - "virtualMachineStorageAccount": "stvm", - "webApp": "app-" - }, - "containers": { - "aksCluster": "aks-", - "aksSystemNodePool": "npsystem-", - "aksUserNodePool": "np-", - "containerApp": "ca-", - "containerAppsEnvironment": "cae-", - "containerRegistry": "cr", - "containerInstance": "ci", - "serviceFabricCluster": "sf-", - "serviceFabricManagedCluster": "sfmc-" - }, - "databases": { - "cosmosDBDatabase": "cosmos-", - "cosmosDBApacheCassandra": "coscas-", - "cosmosDBMongoDB": "cosmon-", - "cosmosDBNoSQL": "cosno-", - "cosmosDBTable": "costab-", - "cosmosDBGremlin": "cosgrm-", - "cosmosDBPostgreSQL": "cospos-", - "cacheForRedis": "redis-", - "sqlDatabaseServer": "sql-", - "sqlDatabase": "sqldb-", - "sqlElasticJobAgent": "sqlja-", - "sqlElasticPool": "sqlep-", - "mariaDBServer": "maria-", - "mariaDBDatabase": "mariadb-", - "mySQLDatabase": "mysql-", - "postgreSQLDatabase": "psql-", - "sqlServerStretchDatabase": "sqlstrdb-", - "sqlManagedInstance": "sqlmi-" - }, - "developerTools": { - "appConfigurationStore": "appcs-", - "mapsAccount": "map-", - "signalR": "sigr", - "webPubSub": "wps-" - }, - "devOps": { - "managedGrafana": "amg-" - }, - "integration": { - "apiManagementService": "apim-", - "integrationAccount": "ia-", - "logicApp": "logic-", - "serviceBusNamespace": "sbns-", - "serviceBusQueue": "sbq-", - "serviceBusTopic": "sbt-", - "serviceBusTopicSubscription": "sbts-" - }, - "managementGovernance": { - "automationAccount": "aa-", - "applicationInsights": "appi-", - "monitorActionGroup": "ag-", - "monitorDataCollectionRules": "dcr-", - "monitorAlertProcessingRule": "apr-", - "blueprint": "bp-", - "blueprintAssignment": "bpa-", - "dataCollectionEndpoint": "dce-", - "logAnalyticsWorkspace": "log-", - "logAnalyticsQueryPacks": "pack-", - "managementGroup": "mg-", - "purviewInstance": "pview-", - "resourceGroup": "rg-", - "templateSpecsName": "ts-" - }, - "migration": { - "migrateProject": "migr-", - "databaseMigrationService": "dms-", - "recoveryServicesVault": "rsv-" - }, - "networking": { - "applicationGateway": "agw-", - "applicationSecurityGroup": "asg-", - "cdnProfile": "cdnp-", - "cdnEndpoint": "cdne-", - "connections": "con-", - "dnsForwardingRuleset": "dnsfrs-", - "dnsPrivateResolver": "dnspr-", - "dnsPrivateResolverInboundEndpoint": "in-", - "dnsPrivateResolverOutboundEndpoint": "out-", - "firewall": "afw-", - "firewallPolicy": "afwp-", - "expressRouteCircuit": "erc-", - "expressRouteGateway": "ergw-", - "frontDoorProfile": "afd-", - "frontDoorEndpoint": "fde-", - "frontDoorFirewallPolicy": "fdfp-", - "ipGroups": "ipg-", - "loadBalancerInternal": "lbi-", - "loadBalancerExternal": "lbe-", - "loadBalancerRule": "rule-", - "localNetworkGateway": "lgw-", - "natGateway": "ng-", - "networkInterface": "nic-", - "networkSecurityGroup": "nsg-", - "networkSecurityGroupSecurityRules": "nsgsr-", - "networkWatcher": "nw-", - "privateLink": "pl-", - "privateEndpoint": "pep-", - "publicIPAddress": "pip-", - "publicIPAddressPrefix": "ippre-", - "routeFilter": "rf-", - "routeServer": "rtserv-", - "routeTable": "rt-", - "serviceEndpointPolicy": "se-", - "trafficManagerProfile": "traf-", - "userDefinedRoute": "udr-", - "virtualNetwork": "vnet-", - "virtualNetworkGateway": "vgw-", - "virtualNetworkManager": "vnm-", - "virtualNetworkPeering": "peer-", - "virtualNetworkSubnet": "snet-", - "virtualWAN": "vwan-", - "virtualWANHub": "vhub-" - }, - "security": { - "bastion": "bas-", - "keyVault": "kv-", - "keyVaultManagedHSM": "kvmhsm-", - "managedIdentity": "id-", - "sshKey": "sshkey-", - "vpnGateway": "vpng-", - "vpnConnection": "vcn-", - "vpnSite": "vst-", - "webApplicationFirewallPolicy": "waf", - "webApplicationFirewallPolicyRuleGroup": "wafrg" - }, - "storage": { - "storSimple": "ssimp", - "backupVault": "bvault-", - "backupVaultPolicy": "bkpol-", - "fileShare": "share-", - "storageAccount": "st", - "storageSyncService": "sss-" - }, - "virtualDesktop": { - "labServicesPlan": "lp-", - "virtualDesktopHostPool": "vdpool-", - "virtualDesktopApplicationGroup": "vdag-", - "virtualDesktopWorkspace": "vdws-", - "virtualDesktopScalingPlan": "vdscaling-" - } - } \ No newline at end of file diff --git a/infra/main.bicep b/infra/main.bicep index 3f42132..212d4ba 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -53,8 +53,6 @@ param secondaryLocation string? @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} -var abbrs = loadJsonContent('./abbreviations.json') - var resourcesName = trim(replace(replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) var uniqueResourcesName = '${resourcesName}${resourcesToken}' @@ -83,7 +81,7 @@ var modelDeployment = { module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { name: take('identity-${resourcesName}-deployment', 64) params: { - name: '${abbrs.security.managedIdentity}${resourcesName}' + name: 'id-${resourcesName}' location: location tags: allTags } @@ -92,7 +90,7 @@ module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identit module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring) { name: take('log-analytics-${resourcesName}-deployment', 64) params: { - name: '${abbrs.managementGovernance.logAnalyticsWorkspace}${resourcesName}' + name: 'log-${resourcesName}' location: location skuName: 'PerGB2018' dataRetention: 30 @@ -104,7 +102,7 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (enableMonitoring) { name: take('app-insights-${resourcesName}-deployment', 64) params: { - name: '${abbrs.managementGovernance.applicationInsights}${resourcesName}' + name: 'appi-${resourcesName}' location: location workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId }] @@ -115,7 +113,7 @@ module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (en module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { name: take('storage-account-${resourcesName}-deployment', 64) params: { - name: take('${abbrs.storage.storageAccount}${uniqueResourcesName}', 24) + name: take('st${uniqueResourcesName}', 24) location: location kind: 'StorageV2' skuName: enableRedundancy ? 'Standard_LRS' : 'Standard_GZRS' @@ -160,11 +158,11 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { name: take('aiservices-${resourcesName}-deployment', 64) params: { - name: '${abbrs.ai.aiServices}${uniqueResourcesName}' + name: 'ais-${uniqueResourcesName}' location: location sku: 'S0' kind: 'AIServices' - customSubDomainName: '${abbrs.ai.aiServices}${uniqueResourcesName}' + customSubDomainName: 'ais-${uniqueResourcesName}' disableLocalAuth: false publicNetworkAccess: 'Enabled' deployments: [modelDeployment] @@ -183,7 +181,7 @@ module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { name: take('keyvault-${resourcesName}-deployment', 64) params: { - name: take('${abbrs.security.keyVault}${uniqueResourcesName}', 24) + name: take('kv-${uniqueResourcesName}', 24) location: location createMode: 'default' sku: 'standard' @@ -203,9 +201,9 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { name: take('aifoundry-${resourcesName}-deployment', 64) params: { location: azureAiServiceLocation - hubName: '${abbrs.ai.hub}${resourcesName}' + hubName: 'hub-${resourcesName}' hubDescription: 'AI Hub for Modernize Your Code' - projectName: '${abbrs.ai.project}${resourcesName}' + projectName: 'proj-${resourcesName}' storageAccountResourceId: storageAccount.outputs.resourceId keyVaultResourceId: keyvault.outputs.resourceId managedIdentityPrincpalId: managedIdentity.outputs.principalId @@ -218,7 +216,7 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { module cosmosDb 'modules/cosmosDb.bicep' = { name: take('cosmos-${resourcesName}-deployment', 64) params: { - name: '${abbrs.databases.cosmosDBDatabase}${uniqueResourcesName}' + name: 'cosmos-${uniqueResourcesName}' location: location managedIdentityPrincipalId: managedIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' @@ -233,7 +231,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. #disable-next-line no-unnecessary-dependson dependsOn: [applicationInsights] // required due to optional flags that could change dependency params: { - name: '${abbrs.containers.containerAppsEnvironment}${resourcesName}' + name: 'cae-${resourcesName}' location: location zoneRedundant: false // TODO - use enableRedundancy and privatenetworking flag to enable/disable publicNetworkAccess: 'Enabled' @@ -257,7 +255,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { name: take('container-app-frontend-${resourcesName}-deployment', 64) params: { - name: take('${abbrs.containers.containerApp}${uniqueResourcesName}frontend', 32) + name: take('ca-${uniqueResourcesName}frontend', 32) location: location environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { @@ -307,7 +305,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { #disable-next-line no-unnecessary-dependson dependsOn: [applicationInsights] // required due to optional flags that could change dependency params: { - name: take('${abbrs.containers.containerApp}${uniqueResourcesName}backend', 32) + name: take('ca-${uniqueResourcesName}backend', 32) location: location environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { From 023369fbd10318e1971aa88f799824ec7374f34d Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 3 Jun 2025 13:23:19 -0400 Subject: [PATCH 025/124] WAF - adding private networking options per resource - moved to modules --- infra/main.bicep | 51 +++------ infra/modules/aiServices.bicep | 159 +++++++++++++++++++++++++++++ infra/modules/customTypes.bicep | 12 +++ infra/modules/keyVault.bicep | 93 +++++++++++++++++ infra/modules/storageAccount.bicep | 145 ++++++++++++++++++++++++++ 5 files changed, 421 insertions(+), 39 deletions(-) create mode 100644 infra/modules/aiServices.bicep create mode 100644 infra/modules/customTypes.bicep create mode 100644 infra/modules/keyVault.bicep create mode 100644 infra/modules/storageAccount.bicep diff --git a/infra/main.bicep b/infra/main.bicep index 212d4ba..11ca93b 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -110,32 +110,16 @@ module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (en } } -module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { +module storageAccount 'modules/storageAccount.bicep' = { name: take('storage-account-${resourcesName}-deployment', 64) params: { name: take('st${uniqueResourcesName}', 24) location: location - kind: 'StorageV2' + tags: allTags skuName: enableRedundancy ? 'Standard_LRS' : 'Standard_GZRS' - publicNetworkAccess: 'Enabled' - accessTier: 'Hot' - allowBlobPublicAccess: false - allowSharedKeyAccess: false - allowCrossTenantReplication: false - requireInfrastructureEncryption: false - keyType: 'Service' - enableHierarchicalNamespace: false - enableNfsV3: false - largeFileSharesState: 'Disabled' - minimumTlsVersion: 'TLS1_2' - networkAcls: { - bypass: 'AzureServices' - defaultAction: 'Allow' - } - supportsHttpsTrafficOnly: true - diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] - blobServices: { - containers: [ + logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' + privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access + containers: [ { name: appStorageContainerName properties: { @@ -143,7 +127,6 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { } } ] - } roleAssignments: [ { principalId: managedIdentity.outputs.principalId @@ -151,22 +134,19 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.17.0' = { roleDefinitionIdOrName: 'Storage Blob Data Contributor' } ] - tags: allTags } } -module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { +module azureAiServices 'modules/aiServices.bicep' = { name: take('aiservices-${resourcesName}-deployment', 64) params: { name: 'ais-${uniqueResourcesName}' location: location sku: 'S0' kind: 'AIServices' - customSubDomainName: 'ais-${uniqueResourcesName}' - disableLocalAuth: false - publicNetworkAccess: 'Enabled' deployments: [modelDeployment] - diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] + logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' + privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access roleAssignments: [ { principalId: managedIdentity.outputs.principalId @@ -178,21 +158,14 @@ module azureAiServices 'br/public:avm/res/cognitive-services/account:0.10.2' = { } } -module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { +module keyVault 'modules/keyVault.bicep' = { name: take('keyvault-${resourcesName}-deployment', 64) params: { name: take('kv-${uniqueResourcesName}', 24) location: location - createMode: 'default' sku: 'standard' - enableVaultForDeployment: true - enableVaultForDiskEncryption: true - enableVaultForTemplateDeployment: true - enableRbacAuthorization: true - enablePurgeProtection: false - publicNetworkAccess: 'Enabled' - softDeleteRetentionInDays: 7 - diagnosticSettings: enableMonitoring ? [{workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId}] : [] + logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' + privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access tags: allTags } } @@ -205,7 +178,7 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { hubDescription: 'AI Hub for Modernize Your Code' projectName: 'proj-${resourcesName}' storageAccountResourceId: storageAccount.outputs.resourceId - keyVaultResourceId: keyvault.outputs.resourceId + keyVaultResourceId: keyVault.outputs.resourceId managedIdentityPrincpalId: managedIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' aiServicesName: azureAiServices.outputs.name diff --git a/infra/modules/aiServices.bicep b/infra/modules/aiServices.bicep new file mode 100644 index 0000000..e79c95a --- /dev/null +++ b/infra/modules/aiServices.bicep @@ -0,0 +1,159 @@ +@description('Name of the Cognitive Services resource. Must be unique in the resource group.') +param name string + +@description('The location of the Cognitive Services resource.') +param location string + +@description('Required. Kind of the Cognitive Services account. Use \'Get-AzCognitiveServicesAccountSku\' to determine a valid combinations of \'kind\' and \'SKU\' for your Azure region.') +@allowed([ + 'AIServices' + 'AnomalyDetector' + 'CognitiveServices' + 'ComputerVision' + 'ContentModerator' + 'ContentSafety' + 'ConversationalLanguageUnderstanding' + 'CustomVision.Prediction' + 'CustomVision.Training' + 'Face' + 'FormRecognizer' + 'HealthInsights' + 'ImmersiveReader' + 'Internal.AllInOne' + 'LUIS' + 'LUIS.Authoring' + 'LanguageAuthoring' + 'MetricsAdvisor' + 'OpenAI' + 'Personalizer' + 'QnAMaker.v2' + 'SpeechServices' + 'TextAnalytics' + 'TextTranslation' +]) +param kind string = 'AIServices' + +@description('Required. The SKU of the Cognitive Services account. Use \'Get-AzCognitiveServicesAccountSku\' to determine a valid combinations of \'kind\' and \'SKU\' for your Azure region.') +@allowed([ + 'S' + 'S0' + 'S1' + 'S2' + 'S3' + 'S4' + 'S5' + 'S6' + 'S7' + 'S8' +]) +param sku string = 'S0' + +@description('Optional. The resource ID of the Log Analytics workspace to use for diagnostic settings.') +param logAnalyticsWorkspaceResourceId string? + +@description('Optional. Specifies the OpenAI deployments to create.') +param deployments deploymentType[] = [] + +@description('Optional. Array of role assignments to create.') +param roleAssignments roleAssignmentType[]? + +@description('Optional. Values to establish private networking for the AI Services resource.') +param privateNetworking aiServicesPrivateNetworkingType? + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +module cognitiveServicesPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?cogServicesPrivateDnsZoneResourceId)) { + name: take('${name}-cognitiveservices-pdns-deployment', 64) + params: { + name: 'privatelink.cognitiveservices.${toLower(environment().name) == 'azureusgovernment' ? 'azure.us' : 'azure.com'}' + virtualNetworkLinks: [ + { + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + } + ] + tags: tags + } +} + +module openAiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?openAIPrivateDnsZoneResourceId)) { + name: take('${name}-openai-pdns-deployment', 64) + params: { + name: 'privatelink.openai.${toLower(environment().name) == 'azureusgovernment' ? 'azure.us' : 'azure.com'}' + virtualNetworkLinks: [ + { + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + } + ] + tags: tags + } +} + +var cogServicesPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?cogServicesPrivateDnsZoneResourceId) ? cognitiveServicesPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?cogServicesPrivateDnsZoneResourceId) : '' +var openAIPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?openAIPrivateDnsZoneResourceId) ? openAiPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?openAIPrivateDnsZoneResourceId) : '' + +module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = { + name: take('${name}-aiservices-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [cognitiveServicesPrivateDnsZone, openAiPrivateDnsZone] // required due to optional flags that could change dependency + params: { + name: name + location: location + tags: tags + sku: sku + kind: kind + managedIdentities: { + systemAssigned: true + } + deployments: deployments + customSubDomainName: name + disableLocalAuth: privateNetworking != null + publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] : [] + roleAssignments: roleAssignments + privateEndpoints: privateNetworking != null ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: cogServicesPrivateDnsZoneResourceId + } + { + privateDnsZoneResourceId: openAIPrivateDnsZoneResourceId + } + ] + } + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] : [] + } +} + +import { deploymentType } from 'br/public:avm/res/cognitive-services/account:0.10.2' +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' + +output resourceId string = cognitiveService.outputs.resourceId +output name string = cognitiveService.outputs.name +output systemAssignedMIPrincipalId string? = cognitiveService.outputs.?systemAssignedMIPrincipalId +output endpoint string = cognitiveService.outputs.endpoint + +@export() +@description('Values to establish private networking for resources that support createing private endpoints.') +type aiServicesPrivateNetworkingType = { + @description('Required. The Resource ID of the virtual network.') + virtualNetworkResourceId: string + + @description('Required. The Resource ID of the subnet to establish the Private Endpoint(s).') + subnetResourceId: string + + @description('Optional. The Resource ID of an existing "cognitiveservices" Private DNS Zone Resource to link to the virtual network. If not provided, a new "cognitiveservices" Private DNS Zone(s) will be created.') + cogServicesPrivateDnsZoneResourceId: string? + + @description('Optional. The Resource ID of an existing "openai" Private DNS Zone Resource to link to the virtual network. If not provided, a new "openai" Private DNS Zone(s) will be created.') + openAIPrivateDnsZoneResourceId: string? +} + diff --git a/infra/modules/customTypes.bicep b/infra/modules/customTypes.bicep new file mode 100644 index 0000000..95d15fe --- /dev/null +++ b/infra/modules/customTypes.bicep @@ -0,0 +1,12 @@ +@export() +@description('Values to establish private networking for resources that support createing private endpoints.') +type resourcePrivateNetworkingType = { + @description('Required. The Resource ID of the virtual network.') + virtualNetworkResourceId: string + + @description('Required. The Resource ID of the subnet to establish the Private Endpoint(s).') + subnetResourceId: string + + @description('Optional. The Resource ID of an existing Private DNS Zone Resource to link to the virtual network. If not provided, a new Private DNS Zone(s) will be created.') + privateDnsZoneResourceId: string? +} diff --git a/infra/modules/keyVault.bicep b/infra/modules/keyVault.bicep new file mode 100644 index 0000000..75206e0 --- /dev/null +++ b/infra/modules/keyVault.bicep @@ -0,0 +1,93 @@ +@description('Name of the Key Vault.') +param name string + +@description('Specifies the location for all the Azure resources.') +param location string + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +@description('Optional. Specifies the SKU for the vault.') +@allowed([ + 'premium' + 'standard' +]) +param sku string = 'premium' + +@description('Optional. Resource ID of the Log Analytics workspace to use for diagnostic settings.') +param logAnalyticsWorkspaceResourceId string? + +@description('Optional. Values to establish private networking for the Key Vault resource.') +param privateNetworking resourcePrivateNetworkingType? + +@description('Optional. Array of role assignments to create.') +param roleAssignments roleAssignmentType[]? + +@description('Optional. Array of secrets to create in the Key Vault.') +param secrets secretType[]? + +module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { + name: take('${name}-kv-pdns-deployment', 64) + params: { + name: 'privatelink.${toLower(environment().name) == 'azureusgovernment' ? 'vaultcore.usgovcloudapi.net' : 'vaultcore.azure.net'}' + virtualNetworkLinks: [ + { + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + } + ] + tags: tags + } +} + +var privateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?privateDnsZoneResourceId) ? privateDnsZone.outputs.resourceId ?? '' : privateNetworking.?privateDnsZoneResourceId ?? '') : '' + +module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { + name: take('${name}-kv-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [privateDnsZone] // required due to optional flags that could change dependency + params: { + name: name + location: location + tags: tags + createMode: 'default' + sku: sku + publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' + networkAcls: { + defaultAction: 'Allow' + } + enableVaultForDeployment: true + enableVaultForDiskEncryption: true + enableVaultForTemplateDeployment: true + enablePurgeProtection: false + enableRbacAuthorization: true + enableSoftDelete: true + softDeleteRetentionInDays: 7 + diagnosticSettings: [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] + privateEndpoints: privateNetworking != null ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: privateDnsZoneResourceId + } + ] + } + service: 'vault' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] : [] + roleAssignments: roleAssignments + secrets: secrets + } +} + +import { resourcePrivateNetworkingType } from 'customTypes.bicep' +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' +import { secretType } from 'br/public:avm/res/key-vault/vault:0.12.1' + +output resourceId string = keyvault.outputs.resourceId +output name string = keyvault.outputs.name diff --git a/infra/modules/storageAccount.bicep b/infra/modules/storageAccount.bicep new file mode 100644 index 0000000..dc17aff --- /dev/null +++ b/infra/modules/storageAccount.bicep @@ -0,0 +1,145 @@ +@description('Name of the Storage Account.') +param name string + +@description('Specifies the location for all the Azure resources.') +param location string + +@allowed([ + 'Standard_LRS' + 'Standard_GRS' + 'Standard_RAGRS' + 'Standard_ZRS' + 'Premium_LRS' + 'Premium_ZRS' + 'Standard_GZRS' + 'Standard_RAGZRS' +]) +@description('Storage Account Sku Name. Defaults to Standard_LRS.') +param skuName string = 'Standard_LRS' + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +@description('Optional. Resource ID of the Log Analytics workspace to use for diagnostic settings.') +param logAnalyticsWorkspaceResourceId string? + +@description('Optional. Values to establish private networking for the Storage Account.') +param privateNetworking storageAccountPrivateNetworkingType? + +@description('Optional. Array of role assignments to create.') +param roleAssignments roleAssignmentType[]? + +@description('Optional. List of the blob storage containers to create in the Storage Account.') +param containers array? + +module blobPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?blobPrivateDnsZoneResourceId)) { + name: take('${name}-blob-pdns-deployment', 64) + params: { + name: 'privatelink.blob.${environment().suffixes.storage}' + virtualNetworkLinks: [ + { + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + } + ] + tags: tags + } +} + +module filePrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?filePrivateDnsZoneResourceId)) { + name: take('${name}-file-pdns-deployment', 64) + params: { + name: 'privatelink.file.${environment().suffixes.storage}' + virtualNetworkLinks: [ + { + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + } + ] + tags: tags + } +} + +var blobPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?blobPrivateDnsZoneResourceId) ? blobPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?blobPrivateDnsZoneResourceId) : '' +var filePrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?filePrivateDnsZoneResourceId) ? filePrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?filePrivateDnsZoneResourceId) : '' + +module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { + name: take('${name}-sa-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [filePrivateDnsZone, blobPrivateDnsZone] // required due to optional flags that could change dependency + params: { + name: name + location: location + kind: 'StorageV2' + skuName: skuName + accessTier: 'Hot' + tags: tags + publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' + allowBlobPublicAccess: false + allowSharedKeyAccess: false + allowCrossTenantReplication: false + minimumTlsVersion: 'TLS1_2' + requireInfrastructureEncryption: false + keyType: 'Service' + enableHierarchicalNamespace: false + enableNfsV3: false + largeFileSharesState: 'Disabled' + networkAcls: { + defaultAction: 'Allow' + bypass: 'AzureServices' + } + supportsHttpsTrafficOnly: true + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] : [] + privateEndpoints: privateNetworking != null ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: blobPrivateDnsZoneResourceId + } + ] + } + service: 'blob' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: filePrivateDnsZoneResourceId + } + ] + } + service: 'file' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] : [] + roleAssignments: roleAssignments + blobServices: { + containers: containers ?? [] + } + } +} + +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' + +output name string = storageAccount.outputs.name +output resourceId string = storageAccount.outputs.resourceId + +@export() +@description('Values to establish private networking for resources that support createing private endpoints.') +type storageAccountPrivateNetworkingType = { + @description('Required. The Resource ID of the virtual network.') + virtualNetworkResourceId: string + + @description('Required. The Resource ID of the subnet to establish the Private Endpoint(s).') + subnetResourceId: string + + @description('Optional. The Resource ID of an existing "file" Private DNS Zone Resource to link to the virtual network. If not provided, a new "file" Private DNS Zone(s) will be created.') + filePrivateDnsZoneResourceId: string? + + @description('Optional. The Resource ID of an existing "blob" Private DNS Zone Resource to link to the virtual network. If not provided, a new "blob" Private DNS Zone(s) will be created.') + blobPrivateDnsZoneResourceId: string? +} From 4fa91c05cbe0570ac57226b63fc1d6ad41313997 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 3 Jun 2025 15:01:18 -0400 Subject: [PATCH 026/124] WAF - private networking additions. other cleanup --- infra/main.bicep | 54 +++++++++++++++++++++--- infra/modules/aiFoundry.bicep | 77 ++++++++++++++++++++++++++++++++--- infra/modules/cosmosDb.bicep | 42 ++++++++++++++++++- infra/modules/keyVault.bicep | 4 +- 4 files changed, 163 insertions(+), 14 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 11ca93b..c8dcf62 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -50,6 +50,9 @@ param enableRedundancy bool = false @description('Optional. The secondary location for the Cosmos DB account if redundancy is enabled.') param secondaryLocation string? +@description('Optional. Enable private networking for the resources. Set to true to enable private networking.') +param enablePrivateNetworking bool = false + @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} @@ -112,6 +115,8 @@ module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (en module storageAccount 'modules/storageAccount.bicep' = { name: take('storage-account-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency params: { name: take('st${uniqueResourcesName}', 24) location: location @@ -139,6 +144,8 @@ module storageAccount 'modules/storageAccount.bicep' = { module azureAiServices 'modules/aiServices.bicep' = { name: take('aiservices-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency params: { name: 'ais-${uniqueResourcesName}' location: location @@ -160,6 +167,8 @@ module azureAiServices 'modules/aiServices.bicep' = { module keyVault 'modules/keyVault.bicep' = { name: take('keyvault-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency params: { name: take('kv-${uniqueResourcesName}', 24) location: location @@ -172,6 +181,8 @@ module keyVault 'modules/keyVault.bicep' = { module azureAifoundry 'modules/aiFoundry.bicep' = { name: take('aifoundry-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency params: { location: azureAiServiceLocation hubName: 'hub-${resourcesName}' @@ -182,12 +193,15 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { managedIdentityPrincpalId: managedIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' aiServicesName: azureAiServices.outputs.name + privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access tags: allTags } } module cosmosDb 'modules/cosmosDb.bicep' = { name: take('cosmos-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency params: { name: 'cosmos-${uniqueResourcesName}' location: location @@ -195,6 +209,7 @@ module cosmosDb 'modules/cosmosDb.bicep' = { logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' zoneRedundant: enableRedundancy secondaryLocation: enableRedundancy && !empty(secondaryLocation) ? secondaryLocation : '' + privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access tags: allTags } } @@ -202,12 +217,13 @@ module cosmosDb 'modules/cosmosDb.bicep' = { module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.2' = { name: take('container-env-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [applicationInsights] // required due to optional flags that could change dependency + dependsOn: [applicationInsights, logAnalyticsWorkspace] // required due to optional flags that could change dependency params: { name: 'cae-${resourcesName}' location: location - zoneRedundant: false // TODO - use enableRedundancy and privatenetworking flag to enable/disable + zoneRedundant: enableRedundancy && enablePrivateNetworking publicNetworkAccess: 'Enabled' + infrastructureSubnetResourceId: enablePrivateNetworking ? '' : '' // TODO managedIdentities: { userAssignedResourceIds: [ managedIdentity.outputs.resourceId @@ -250,7 +266,6 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { cpu: '1' memory: '2.0Gi' } - } ] ingressTargetPort: 3000 @@ -273,14 +288,43 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { } } +module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environment:0.11.2' = if (enablePrivateNetworking) { + name: take('container-env-backend-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [applicationInsights, logAnalyticsWorkspace] // required due to optional flags that could change dependency + params: { + name: 'cae-${resourcesName}-backend' + location: location + zoneRedundant: enableRedundancy && enablePrivateNetworking + publicNetworkAccess: 'Disabled' + infrastructureSubnetResourceId: '' // TODO + managedIdentities: { + userAssignedResourceIds: [ + managedIdentity.outputs.resourceId + ] + } + appInsightsConnectionString: enableMonitoring ? applicationInsights.outputs.connectionString : null + appLogsConfiguration: enableMonitoring ? { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.outputs.logAnalyticsWorkspaceId + sharedKey: logAnalyticsWorkspace.outputs.primarySharedKey + } + } : {} + tags: allTags + } +} + +var containerAppsEnvironmentResourceId = enablePrivateNetworking ? containerAppsEnvironmentBackend.outputs.resourceId : containerAppsEnvironment.outputs.resourceId + module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { name: take('container-app-backend-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [applicationInsights] // required due to optional flags that could change dependency + dependsOn: [applicationInsights, logAnalyticsWorkspace, containerAppsEnvironmentBackend] // required due to optional flags that could change dependency params: { name: take('ca-${uniqueResourcesName}backend', 32) location: location - environmentResourceId: containerAppsEnvironment.outputs.resourceId + environmentResourceId: containerAppsEnvironmentResourceId managedIdentities: { userAssignedResourceIds: [ managedIdentity.outputs.resourceId diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index 6c86c1d..aac0666 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -22,12 +22,47 @@ param managedIdentityPrincpalId string @description('Optional. The resource ID of an existing Log Analytics workspace to associate with AI Foundry for monitoring.') param logAnalyticsWorkspaceResourceId string? +@description('Optional. The resource ID of an existing Application Insights resource to associate with AI Foundry for monitoring.') +param applicationInsightsResourceId string? + @description('The name of an existing Azure Cognitive Services account.') param aiServicesName string +@description('Optional. Values to establish private networking for the AI Foundry resources.') +param privateNetworking machineLearningPrivateNetworkingType? + @description('Optional. Tags to be applied to the resources.') param tags object = {} +module mlApiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?apiPrivateDnsZoneResourceId)) { + name: take('${hubName}-mlapi-pdns-deployment', 64) + params: { + name: 'privatelink.api.${toLower(environment().name) == 'azureusgovernment' ? 'ml.azure.us' : 'azureml.ms'}' + virtualNetworkLinks: [ + { + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + } + ] + tags: tags + } +} + +module mlNotebooksPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?notebooksPrivateDnsZoneResourceId)) { + name: take('${hubName}-mlnotebook-pdns-deployment', 64) + params: { + name: 'privatelink.notebooks.${toLower(environment().name) == 'azureusgovernment' ? 'azureml.us' : 'azureml.net'}' + virtualNetworkLinks: [ + { + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + } + ] + tags: tags + } +} + +var apiPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?apiPrivateDnsZoneResourceId) ? mlApiPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?apiPrivateDnsZoneResourceId) : '' +var notebooksPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?notebooksPrivateDnsZoneResourceId) ? mlNotebooksPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?notebooksPrivateDnsZoneResourceId) : '' + resource aiServices 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { name: aiServicesName } @@ -36,6 +71,8 @@ var aiServicesKey = aiServices.listKeys().key1 module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { name: take('ai-foundry-${hubName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [mlApiPrivateDnsZone, mlNotebooksPrivateDnsZone] // required due to optional flags that could change dependency params: { name: hubName location: location @@ -44,11 +81,28 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { description: hubDescription associatedKeyVaultResourceId: keyVaultResourceId associatedStorageAccountResourceId: storageAccountResourceId - publicNetworkAccess: 'Enabled' + associatedApplicationInsightsResourceId: applicationInsightsResourceId + publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' managedIdentities: { systemAssigned: true } diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] + privateEndpoints: privateNetworking != null ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: apiPrivateDnsZoneResourceId + } + { + privateDnsZoneResourceId: notebooksPrivateDnsZoneResourceId + } + ] + } + service: 'amlworkspace' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] : [] connections: [ { name: aiServicesName @@ -80,7 +134,7 @@ module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = sku: 'Standard' location: location hubResourceId: hub.outputs.resourceId - publicNetworkAccess: 'Enabled' + publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' managedIdentities: { systemAssigned: true } @@ -103,9 +157,22 @@ resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10- dependsOn: [project] } -var aiProjectConnString = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' - output projectName string = project.outputs.name output hubName string = hub.outputs.name +output projectConnectionString string = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' + +@export() +@description('Values to establish private networking for resources that support createing private endpoints.') +type machineLearningPrivateNetworkingType = { + @description('Required. The Resource ID of the virtual network.') + virtualNetworkResourceId: string -output projectConnectionString string = aiProjectConnString + @description('Required. The Resource ID of the subnet to establish the Private Endpoint(s).') + subnetResourceId: string + + @description('Optional. The Resource ID of an existing "api" Private DNS Zone Resource to link to the virtual network. If not provided, a new "api" Private DNS Zone(s) will be created.') + apiPrivateDnsZoneResourceId: string? + + @description('Optional. The Resource ID of an existing "notebooks" Private DNS Zone Resource to link to the virtual network. If not provided, a new "notebooks" Private DNS Zone(s) will be created.') + notebooksPrivateDnsZoneResourceId: string? +} diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep index 071add6..fb95a95 100644 --- a/infra/modules/cosmosDb.bicep +++ b/infra/modules/cosmosDb.bicep @@ -19,6 +19,24 @@ param zoneRedundant bool @description('Optional. The secondary location for the Cosmos DB Account for failover and multiple writes.') param secondaryLocation string? +@description('Optional. Values to establish private networking for the Cosmos DB resource.') +param privateNetworking resourcePrivateNetworkingType? + +module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { + name: take('${name}-documents-pdns-deployment', 64) + params: { + name: 'privatelink.documents.azure.com' + virtualNetworkLinks: [ + { + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + } + ] + tags: tags + } +} + +var privateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?privateDnsZoneResourceId) ? privateDnsZone.outputs.resourceId ?? '' : privateNetworking.?privateDnsZoneResourceId ?? '') : '' + resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { name: '${name}/00000000-0000-0000-0000-000000000002' } @@ -30,17 +48,22 @@ var logContainerName = 'cmsalog' module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { name: take('${name}-account-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [privateDnsZone] // required due to optional flags that could change dependency params: { name: name enableAnalyticalStorage: true location: location + minimumTlsVersion: 'Tls12' + defaultConsistencyLevel: 'Session' networkRestrictions: { networkAclBypass: 'AzureServices' - publicNetworkAccess: 'Enabled' + publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' ipRules: [] virtualNetworkRules: [] } zoneRedundant: zoneRedundant + automaticFailover: !empty(secondaryLocation) failoverLocations: !empty(secondaryLocation) ? [ { failoverPriority: 0 @@ -57,7 +80,21 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { backupPolicyType: !empty(secondaryLocation) ? 'Periodic' : 'Continuous' backupStorageRedundancy: zoneRedundant ? 'Zone' : 'Local' disableKeyBasedMetadataWriteAccess: false + disableLocalAuthentication: privateNetworking != null diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] + privateEndpoints: privateNetworking != null ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: privateDnsZoneResourceId + } + ] + } + service: 'Sql' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] : [] sqlDatabases: [ { containers: [ @@ -92,7 +129,6 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { name: databaseName } ] - dataPlaneRoleAssignments: [ { principalId: managedIdentityPrincipalId @@ -103,6 +139,8 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { } } +import { resourcePrivateNetworkingType } from 'customTypes.bicep' + output resourceId string = cosmosAccount.outputs.resourceId output name string = cosmosAccount.outputs.name output endpoint string = cosmosAccount.outputs.endpoint diff --git a/infra/modules/keyVault.bicep b/infra/modules/keyVault.bicep index 75206e0..348aaf2 100644 --- a/infra/modules/keyVault.bicep +++ b/infra/modules/keyVault.bicep @@ -62,11 +62,11 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { enableRbacAuthorization: true enableSoftDelete: true softDeleteRetentionInDays: 7 - diagnosticSettings: [ + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ { workspaceResourceId: logAnalyticsWorkspaceResourceId } - ] + ] : [] privateEndpoints: privateNetworking != null ? [ { privateDnsZoneGroup: { From 230c8b18665a7c061a1e7d4716c5766693913e24 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 3 Jun 2025 15:10:23 -0400 Subject: [PATCH 027/124] seperated jumpbox and azure bastion host - tested --- infra/main_network.bicep | 240 ++++++++++++++++++++++------------ infra/main_network.bicepparam | 28 ++-- 2 files changed, 172 insertions(+), 96 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index bb634cb..0654d60 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -9,9 +9,8 @@ param solutionName string = 'Code Modernization' @description('Type of the solution. This is used for tagging and categorization.') param solutionType string = 'Solution Accelerator' -param resourceGroupName string -param location string - +param resourceGroupName string +param location string param tags object = { 'Solution Name': solutionName @@ -21,25 +20,30 @@ param tags object = { /****************************************************************************************************************************/ // prefix generation /****************************************************************************************************************************/ -var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces +var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces var resourceToken = toLower('${substring(cleanSolutionName, 0, 1)}${uniqueString(cleanSolutionName, resourceGroupName, subscription().id)}') var resourceTokenTrimmed = length(resourceToken) > 9 ? substring(resourceToken, 0, 9) : resourceToken var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) // Network parameters (these will be set via main_network.bicepparam) -param networkIsolation bool +param networkIsolation bool //param vnetName string param vnetAddressPrefixes array -param mySubnets array +param mySubnets array var vnetName = '${prefix}-vnet' +param azureBationHost bool = false // Flag to create Azure Bastion Host param azureBastionSubnet object = {} -param jumboxAdminUser string -param jumboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden -param privateEndPoint bool = true +param jumpboxVM bool = false // Set to 'true' to deploy a jumpbox VM, 'false' to skip it +param jumpboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden +param jumpboxSubnet object = {} +param jumpboxAdminUser string = 'JumpboxAdminUser' // Default admin username for Jumpbox VM +@secure() +param jumpboxAdminPassword string +param privateEndPoint bool = true /****************************************************************************************************************************/ // Log Analytics Workspace that will be used across the solution @@ -62,48 +66,28 @@ var logAnalyticsWorkspaceId = logAnalyticsWorkSpace.outputs.workspaceId // Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG /****************************************************************************************************************************/ - -// Diagnostic settings for VNet using Log Analytics Workspace -var diagnosticSettings = [ - { - name: '${prefix}vnetDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - metricCategories: [ - { - category: 'AllMetrics' - enabled: true - } - ] - } -] - - // 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true // 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 mySubnets: if (networkIsolation && !empty(subnet.networkSecurityGroup)) { - name: '${prefix}-${subnet.networkSecurityGroup.name}' - params: { +module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [ + for (subnet, i) in mySubnets: if (networkIsolation && !empty(subnet.networkSecurityGroup)) { name: '${prefix}-${subnet.networkSecurityGroup.name}' - location: location - securityRules: subnet.networkSecurityGroup.securityRules - tags: tags + params: { + name: '${prefix}-${subnet.networkSecurityGroup.name}' + location: location + securityRules: subnet.networkSecurityGroup.securityRules + tags: tags + } } -}] +] // 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' = if (networkIsolation) { +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = if (networkIsolation) { name: vnetName params: { name: vnetName @@ -116,36 +100,158 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = if (n networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null } ] - diagnosticSettings: diagnosticSettings + diagnosticSettings: [ + { + name: 'vnetDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] tags: tags } } + output vnetName string = virtualNetwork.outputs.name output vnetLocation string = virtualNetwork.outputs.location output vnetId string = virtualNetwork.outputs.resourceId + output subnetIds array = virtualNetwork.outputs.subnetResourceIds +// /****************************************************************************************************************************/ +// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM +// /****************************************************************************************************************************/ +// // // Create or reuse Jumpbox VM +// Craete NSG for Jumpbox subnet if jumpboxSubnet is not empty and networkIsolation is true +module jumpboxNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { + name: '${prefix}-jumpbox-nsg' + params: { + name: '${prefix}-jumpbox-nsg' + location: location + securityRules: jumpboxSubnet.networkSecurityGroup.securityRules + tags: tags + } +} + +// Create jumpbox subnet if jumpboxSubnet is not empty and networkIsolation is true +module avmJumpboxSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { + name: '${prefix}-jumpbox-subnet' + params: { + virtualNetworkName: virtualNetwork.outputs.name + name: jumpboxSubnet.name + addressPrefixes: jumpboxSubnet.addressPrefixes + networkSecurityGroupResourceId: jumpboxNsg.outputs.resourceId + } +} + +output jumpboxSubnetId string = avmJumpboxSubnet.outputs.resourceId +output jumpboxNsgId string = jumpboxNsg.outputs.resourceId + +output jumpboxNsgName string = jumpboxNsg.outputs.name +output jumpboxSubnetName string = avmJumpboxSubnet.outputs.name +output jumpboxSubnetAddressPrefixes array = avmJumpboxSubnet.outputs.addressPrefixes +output jumpboxSubnetNetworkSecurityGroupId string = jumpboxNsg.outputs.resourceId +output jumpboxSubnetNetworkSecurityGroupName string = jumpboxNsg.outputs.name + +// // Variables for dynamic Jumpbox subnet reference (must be after subnetIds output) +// var subnetNames = [for subnet in mySubnets: subnet.name] +// var jumpboxSubnetIndex = indexOf(subnetNames, 'jumpbox') + +module avmJumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (networkIsolation && jumpboxVM) { + name: '${prefix}-jbVM' + params: { + name: take('${prefix}-jbVm', 15) + vmSize: jumpboxVmSize + location: location + adminUsername: jumpboxAdminUser + adminPassword: jumpboxAdminPassword + tags: tags + zone: 2 + imageReference: { + offer: 'WindowsServer' + publisher: 'MicrosoftWindowsServer' + sku: '2019-datacenter' + version: 'latest' + } + osType: 'Windows' + osDisk: { + managedDisk: { + storageAccountType: 'Standard_LRS' + } + } + encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + nicConfigurations: [ + { + name: 'nicJumpbox' + ipConfigurations: [ + { + name: 'ipconfig1' + subnetResourceId: avmJumpboxSubnet.outputs.resourceId + } + ] + networkSecurityGroupResourceId: jumpboxNsg.outputs.resourceId + diagnosticSettings: [ + { + name: 'jumpboxDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + } + ] + } +} + +output jumpboxVMId string = avmJumpboxVM.outputs.resourceId +output jumpboxVMName string = avmJumpboxVM.outputs.name +output jumpboxVMLocation string = avmJumpboxVM.outputs.location + /****************************************************************************************************************************/ -// // TODO:Azure Bastion Host +// // Create Azure Bastion Subnet and Azure Bastion Host /****************************************************************************************************************************/ // 1. Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet // Create Azure Bastion Subnet if azureBastionSubnet is not empty and networkIsolation is true -module azureBastionSubnetRes 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && !empty(azureBastionSubnet)) { +module avmAzureBastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { name: '${prefix}-AzureBastionSubnet' params: { - virtualNetworkName:virtualNetwork.outputs.name + virtualNetworkName: virtualNetwork.outputs.name name: azureBastionSubnet.name addressPrefixes: azureBastionSubnet.addressPrefixes } } +output azureBastionSubnetId string = avmAzureBastionSubnet.outputs.resourceId +output azureBastionSubnetName string = avmAzureBastionSubnet.outputs.name +output azureBastionSubnetAddressPrefixes array = avmAzureBastionSubnet.outputs.addressPrefixes + // 2. Create Azure Bastion Host in AzureBastionSubnet using AVM Bastion Host module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host -module avmBastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (networkIsolation && !empty(azureBastionSubnet)) { +module avmBastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { name: '${prefix}-bastionhost' params: { name: '${prefix}-bastionhost' @@ -168,47 +274,7 @@ module avmBastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (netwo } } -// /****************************************************************************************************************************/ -// // Jumpbox VM -// /****************************************************************************************************************************/ -// // // Create or reuse Jumpbox VM - -// Variables for dynamic Jumpbox subnet reference (must be after subnetIds output) -var subnetNames = [for subnet in mySubnets: subnet.name] -var jumpboxSubnetIndex = indexOf(subnetNames, 'jumpbox') +output bastionHostId string = avmBastionHost.outputs.resourceId +output bastionHostName string = avmBastionHost.outputs.name +output bastionHostLocation string = avmBastionHost.outputs.location -module avmJumpbox 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (networkIsolation) { - name: '${prefix}-jumpbox' - params: { - name: take('${prefix}-jumpbox', 15) - vmSize: jumboxVmSize - location: location - adminUsername: jumboxAdminUser - adminPassword:'${prefix}P@ssw0rd!' // This should be replaced with a secure method of handling passwords - tags: tags - zone:2 - imageReference: { - offer: 'WindowsServer' - publisher: 'MicrosoftWindowsServer' - sku: '2019-datacenter' - version: 'latest' - } - osType: 'Windows' - osDisk:{managedDisk: { - storageAccountType: 'Standard_LRS' - }} - encryptionAtHost: false // Some Azure subscriptions do not support encryption at host - nicConfigurations: [ - { - name: 'nicJumpbox' - ipConfigurations: [ - { - name: 'ipconfig1' - subnetResourceId: virtualNetwork.outputs.subnetResourceIds[jumpboxSubnetIndex] - } - ] - networkSecurityGroupResourceId: !empty(mySubnets[jumpboxSubnetIndex].networkSecurityGroup) ? nsgs[jumpboxSubnetIndex].outputs.resourceId : null - } - ] - } -} diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index 9e3d38b..4f05cde 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -3,15 +3,12 @@ using './main_network.bicep' -param resourceGroupName = 'gaiye-avm-waf-01-rg' // Name of the resource group for the network resources +param resourceGroupName = 'gaiye-avm-waf-02-rg' // Name of the resource group for the network resources param location = 'eastus' param networkIsolation = true param privateEndPoint = true -param jumboxAdminUser = 'JumpboxAdmin' // Admin user for the jumpbox VM -param jumboxVmSize = 'Standard_D2s_v3' // 'Standard_B2s' not good enough for WAF - //******************************************************************* // Network Security Groups (NSGs) and their rules @@ -140,8 +137,19 @@ param mySubnets = [ ] } } - { - name: 'jumpbox' +] + +//*************************************************************************************** +// Jumpbox VM parameters +//*************************************************************************************** +param jumpboxVM = true // Set to 'true' to deploy a jumpbox VM, 'false' to skip it +param jumpboxAdminUser = 'JumpboxAdminUser' // Admin user for the jumpbox VM +@secure() +param jumpboxAdminPassword = 'JumpboxAdminP@ssw0rd1234!' // Password for the jumpbox VM admin user, must meet Azure password complexity requirements +param jumpboxVmSize = 'Standard_D2s_v3' // 'Standard_B2s' not good enough for WAF + +param jumpboxSubnet = { + name: 'jumpbox' addressPrefixes: ['10.0.5.0/24'] networkSecurityGroup: { name: 'jumpbox-nsg' @@ -162,10 +170,12 @@ param mySubnets = [ ] } } - // Add more subnets here as needed, e.g. for private endpoints, firewall, etc. -] - + +//*************************************************************************************** +// Azure Bastion parameters +//*************************************************************************************** +param azureBationHost = true // Set to 'true' to deploy Azure Bastion, 'false' to skip it param azureBastionSubnet = { name: 'AzureBastionSubnet' // Required name for Azure Bastion addressPrefixes: ['10.0.6.0/27'] From 8da5d10ba108ee96b6604ebd1efa7c365ddfc054 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 3 Jun 2025 15:15:43 -0400 Subject: [PATCH 028/124] added comments --- infra/main_network.bicepparam | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index 4f05cde..c540fbd 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -10,9 +10,10 @@ param networkIsolation = true param privateEndPoint = true -//******************************************************************* -// Network Security Groups (NSGs) and their rules -//******************************************************************* +//*************************************************************************************** +// Vnet and Solution Subnets with respective NSGs. i.g. web, app, ai, data, services +// Jumbox and Azure Bastion subnets are defined separately and optional. +//*************************************************************************************** param vnetAddressPrefixes = [ '10.0.0.0/21' // /21: 2048 addresses, good for up to 8-16 subnets. Other options: /23:512, /22:1024, /21:2048, /20:4096, /16: 65,536 (max for a VNet) @@ -141,6 +142,7 @@ param mySubnets = [ //*************************************************************************************** // Jumpbox VM parameters +// jumpboxVM must be set to true to deploy a jumpbox VM. //*************************************************************************************** param jumpboxVM = true // Set to 'true' to deploy a jumpbox VM, 'false' to skip it param jumpboxAdminUser = 'JumpboxAdminUser' // Admin user for the jumpbox VM @@ -174,6 +176,7 @@ param jumpboxSubnet = { //*************************************************************************************** // Azure Bastion parameters +// azureBationHost must be set to true to deploy Azure Bastion. //*************************************************************************************** param azureBationHost = true // Set to 'true' to deploy Azure Bastion, 'false' to skip it param azureBastionSubnet = { From d1803ea6e66dc2826b53a3a5cf49d5b18beaaed4 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 3 Jun 2025 19:21:10 -0400 Subject: [PATCH 029/124] refactored to modules tested --- infra/main_network.bicep | 258 ++++++------------------ infra/main_network.bicepparam | 66 +++--- infra/main_network_keep_for_now.bicep | 280 ++++++++++++++++++++++++++ infra/modules/azureBationHost.bicep | 58 ++++++ infra/modules/jumpboxWithSubnet.bicep | 111 ++++++++++ infra/modules/vnetWithSubnets.bicep | 81 ++++++++ 6 files changed, 621 insertions(+), 233 deletions(-) create mode 100644 infra/main_network_keep_for_now.bicep create mode 100644 infra/modules/azureBationHost.bicep create mode 100644 infra/modules/jumpboxWithSubnet.bicep create mode 100644 infra/modules/vnetWithSubnets.bicep diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 0654d60..013e7ed 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -26,24 +26,27 @@ var resourceTokenTrimmed = length(resourceToken) > 9 ? substring(resourceToken, var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) // Network parameters (these will be set via main_network.bicepparam) -param networkIsolation bool - -//param vnetName string -param vnetAddressPrefixes array -param mySubnets array +param networkIsolation bool = false // set in .bicepparam file +param vnetAddressPrefixes array = [] // set in .bicepparam file +param mySubnets array = [] // set in .bicepparam file var vnetName = '${prefix}-vnet' -param azureBationHost bool = false // Flag to create Azure Bastion Host -param azureBastionSubnet object = {} - -param jumpboxVM bool = false // Set to 'true' to deploy a jumpbox VM, 'false' to skip it -param jumpboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden -param jumpboxSubnet object = {} -param jumpboxAdminUser string = 'JumpboxAdminUser' // Default admin username for Jumpbox VM +// jumpbox parameters +param jumpboxVM bool = false // set in .bicepparam file +param jumpboxSubnet object = {} // set in .bicepparam file +param jumpboxAdminUser string = 'JumpboxAdminUser' // set in .bicepparam file @secure() -param jumpboxAdminPassword string +param jumpboxAdminPassword string // set in .bicepparam file +param jumpboxVmSize string = 'Standard_D2s_v3' +var jumpboxVmName = '${prefix}-jumpboxVM' + +// Azure Bastion Host parameters +param azureBationHost bool = false // set in .bicepparam file +param azureBastionSubnet object = {} // set in .bicepparam file +var azureBastionHostName = '${prefix}-bastionHost' -param privateEndPoint bool = true +// Private Endpoint parameters +param privateEndPoint bool = false // set in .bicepparam file /****************************************************************************************************************************/ // Log Analytics Workspace that will be used across the solution @@ -60,221 +63,74 @@ module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { } } -var logAnalyticsWorkspaceId = logAnalyticsWorkSpace.outputs.workspaceId - /****************************************************************************************************************************/ // Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG /****************************************************************************************************************************/ -// 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true -// 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 mySubnets: if (networkIsolation && !empty(subnet.networkSecurityGroup)) { - name: '${prefix}-${subnet.networkSecurityGroup.name}' - params: { - name: '${prefix}-${subnet.networkSecurityGroup.name}' - location: location - securityRules: subnet.networkSecurityGroup.securityRules - tags: tags - } - } -] - -// 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' = if (networkIsolation) { - name: vnetName +module vnetWithSubnets 'modules/vnetWithSubnets.bicep' = if (networkIsolation) { + name: '${prefix}-vnetWithSubnets' params: { - name: vnetName + vnetName: vnetName + vnetAddressPrefixes: vnetAddressPrefixes + subnetArray: mySubnets location: location - addressPrefixes: vnetAddressPrefixes - subnets: [ - for (subnet, i) in mySubnets: { - name: subnet.name - addressPrefixes: subnet.addressPrefixes - networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null - } - ] - diagnosticSettings: [ - { - name: 'vnetDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - metricCategories: [ - { - category: 'AllMetrics' - enabled: true - } - ] - } - ] tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkSpace.outputs.workspaceId } } -output vnetName string = virtualNetwork.outputs.name -output vnetLocation string = virtualNetwork.outputs.location -output vnetId string = virtualNetwork.outputs.resourceId -output subnetIds array = virtualNetwork.outputs.subnetResourceIds +output vnetName string = vnetWithSubnets.outputs.vnetName +output vnetResourceId string = vnetWithSubnets.outputs.vnetResourceId +output subnetsOutput array = vnetWithSubnets.outputs.outputSubnetsArray // This one holds critical info for subnets, including NSGs -// /****************************************************************************************************************************/ -// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM -// /****************************************************************************************************************************/ -// // // Create or reuse Jumpbox VM -// Craete NSG for Jumpbox subnet if jumpboxSubnet is not empty and networkIsolation is true -module jumpboxNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { - name: '${prefix}-jumpbox-nsg' - params: { - name: '${prefix}-jumpbox-nsg' - location: location - securityRules: jumpboxSubnet.networkSecurityGroup.securityRules - tags: tags - } -} -// Create jumpbox subnet if jumpboxSubnet is not empty and networkIsolation is true -module avmJumpboxSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { - name: '${prefix}-jumpbox-subnet' - params: { - virtualNetworkName: virtualNetwork.outputs.name - name: jumpboxSubnet.name - addressPrefixes: jumpboxSubnet.addressPrefixes - networkSecurityGroupResourceId: jumpboxNsg.outputs.resourceId - } -} - -output jumpboxSubnetId string = avmJumpboxSubnet.outputs.resourceId -output jumpboxNsgId string = jumpboxNsg.outputs.resourceId - -output jumpboxNsgName string = jumpboxNsg.outputs.name -output jumpboxSubnetName string = avmJumpboxSubnet.outputs.name -output jumpboxSubnetAddressPrefixes array = avmJumpboxSubnet.outputs.addressPrefixes -output jumpboxSubnetNetworkSecurityGroupId string = jumpboxNsg.outputs.resourceId -output jumpboxSubnetNetworkSecurityGroupName string = jumpboxNsg.outputs.name - -// // Variables for dynamic Jumpbox subnet reference (must be after subnetIds output) -// var subnetNames = [for subnet in mySubnets: subnet.name] -// var jumpboxSubnetIndex = indexOf(subnetNames, 'jumpbox') +/****************************************************************************************************************************/ +// // Create Azure Bastion Subnet and Azure Bastion Host +/****************************************************************************************************************************/ -module avmJumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (networkIsolation && jumpboxVM) { - name: '${prefix}-jbVM' +module azureBastionHost 'modules/azureBationHost.bicep' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { + name: '${prefix}-azureBastionHost' params: { - name: take('${prefix}-jbVm', 15) - vmSize: jumpboxVmSize + azureBastionSubnet: azureBastionSubnet location: location - adminUsername: jumpboxAdminUser - adminPassword: jumpboxAdminPassword + vnetName: vnetWithSubnets.outputs.vnetName + vnetId: vnetWithSubnets.outputs.vnetResourceId + azureBationHostName: azureBastionHostName + logAnalyticsWorkspaceId: logAnalyticsWorkSpace.outputs.workspaceId tags: tags - zone: 2 - imageReference: { - offer: 'WindowsServer' - publisher: 'MicrosoftWindowsServer' - sku: '2019-datacenter' - version: 'latest' - } - osType: 'Windows' - osDisk: { - managedDisk: { - storageAccountType: 'Standard_LRS' - } - } - encryptionAtHost: false // Some Azure subscriptions do not support encryption at host - nicConfigurations: [ - { - name: 'nicJumpbox' - ipConfigurations: [ - { - name: 'ipconfig1' - subnetResourceId: avmJumpboxSubnet.outputs.resourceId - } - ] - networkSecurityGroupResourceId: jumpboxNsg.outputs.resourceId - diagnosticSettings: [ - { - name: 'jumpboxDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - metricCategories: [ - { - category: 'AllMetrics' - enabled: true - } - ] - } - ] - } - ] } } -output jumpboxVMId string = avmJumpboxVM.outputs.resourceId -output jumpboxVMName string = avmJumpboxVM.outputs.name -output jumpboxVMLocation string = avmJumpboxVM.outputs.location - - -/****************************************************************************************************************************/ -// // Create Azure Bastion Subnet and Azure Bastion Host -/****************************************************************************************************************************/ -// 1. Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet +output azureBastionSubnetId string = azureBastionHost.outputs.bastionSubnetId +output azureBastionSubnetName string = azureBastionHost.outputs.bastionSubnetName +output azureBastionHostId string = azureBastionHost.outputs.bastionHostId +output azureBastionHostName string = azureBastionHost.outputs.bastionHostName -// Create Azure Bastion Subnet if azureBastionSubnet is not empty and networkIsolation is true -module avmAzureBastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { - name: '${prefix}-AzureBastionSubnet' - params: { - virtualNetworkName: virtualNetwork.outputs.name - name: azureBastionSubnet.name - addressPrefixes: azureBastionSubnet.addressPrefixes - } -} -output azureBastionSubnetId string = avmAzureBastionSubnet.outputs.resourceId -output azureBastionSubnetName string = avmAzureBastionSubnet.outputs.name -output azureBastionSubnetAddressPrefixes array = avmAzureBastionSubnet.outputs.addressPrefixes -// 2. Create Azure Bastion Host in AzureBastionSubnet using AVM Bastion Host module -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host +// /****************************************************************************************************************************/ +// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM +// /****************************************************************************************************************************/ -module avmBastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { - name: '${prefix}-bastionhost' +module jumpboxWithSubnet 'modules/jumpboxWithSubnet.bicep' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { + name: '${prefix}-jumpboxWithSubnet' params: { - name: '${prefix}-bastionhost' - skuName: 'Standard' + vmName: jumpboxVmName location: location - virtualNetworkResourceId: virtualNetwork.outputs.resourceId - diagnosticSettings: [ - { - name: 'bastionDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - } - ] + vnetName: vnetWithSubnets.outputs.vnetName + jumpboxVmSize: jumpboxVmSize + jumpboxSubnet: jumpboxSubnet + jumpboxAdminUser: jumpboxAdminUser + jumpboxAdminPassword: jumpboxAdminPassword tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkSpace.outputs.workspaceId } } -output bastionHostId string = avmBastionHost.outputs.resourceId -output bastionHostName string = avmBastionHost.outputs.name -output bastionHostLocation string = avmBastionHost.outputs.location +output jumpboxSubnetName string = jumpboxWithSubnet.outputs.jumpboxSubnetName +output jumpboxSubnetId string = jumpboxWithSubnet.outputs.jumpboxSubnetId +output jumpboxVmName string = jumpboxWithSubnet.outputs.jumpboxVmName +output jumpboxVmId string = jumpboxWithSubnet.outputs.jumpboxVmId + diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam index c540fbd..ab0b1d4 100644 --- a/infra/main_network.bicepparam +++ b/infra/main_network.bicepparam @@ -9,7 +9,6 @@ param location = 'eastus' param networkIsolation = true param privateEndPoint = true - //*************************************************************************************** // Vnet and Solution Subnets with respective NSGs. i.g. web, app, ai, data, services // Jumbox and Azure Bastion subnets are defined separately and optional. @@ -140,47 +139,50 @@ param mySubnets = [ } ] + +//*************************************************************************************** +// Azure Bastion parameters +// azureBationHost must be set to true to deploy Azure Bastion. +//*************************************************************************************** +param azureBationHost = true // Set to 'true' to deploy Azure Bastion, 'false' to skip it +param azureBastionSubnet = { + name: 'AzureBastionSubnet' // Required name for Azure Bastion + addressPrefixes: ['10.0.5.0/27'] + networkSecurityGroup: null // Must not have an NSG +} + //*************************************************************************************** // Jumpbox VM parameters // jumpboxVM must be set to true to deploy a jumpbox VM. //*************************************************************************************** param jumpboxVM = true // Set to 'true' to deploy a jumpbox VM, 'false' to skip it param jumpboxAdminUser = 'JumpboxAdminUser' // Admin user for the jumpbox VM -@secure() param jumpboxAdminPassword = 'JumpboxAdminP@ssw0rd1234!' // Password for the jumpbox VM admin user, must meet Azure password complexity requirements param jumpboxVmSize = 'Standard_D2s_v3' // 'Standard_B2s' not good enough for WAF +// replace the value for sourceAddressPrefixes with the IP addresses or ranges that should have access to the jumpbox VM. param jumpboxSubnet = { - name: 'jumpbox' - addressPrefixes: ['10.0.5.0/24'] - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.5.0/24'] - } + name: 'jumpbox' + addressPrefixes: ['10.0.6.0/24'] + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.5.0/27' // Azure Bastion subnet + ] + destinationAddressPrefixes: ['10.0.6.0/24'] } - ] - } + } + ] } - - -//*************************************************************************************** -// Azure Bastion parameters -// azureBationHost must be set to true to deploy Azure Bastion. -//*************************************************************************************** -param azureBationHost = true // Set to 'true' to deploy Azure Bastion, 'false' to skip it -param azureBastionSubnet = { - name: 'AzureBastionSubnet' // Required name for Azure Bastion - addressPrefixes: ['10.0.6.0/27'] - networkSecurityGroup: null // Must not have an NSG } + diff --git a/infra/main_network_keep_for_now.bicep b/infra/main_network_keep_for_now.bicep new file mode 100644 index 0000000..0654d60 --- /dev/null +++ b/infra/main_network_keep_for_now.bicep @@ -0,0 +1,280 @@ +//targetScope = 'subscription' +targetScope = 'resourceGroup' + +@minLength(6) +@maxLength(25) +@description('Name of the solution. This is used to generate a short unique hash used in all resources.') +param solutionName string = 'Code Modernization' + +@description('Type of the solution. This is used for tagging and categorization.') +param solutionType string = 'Solution Accelerator' + +param resourceGroupName string +param location string + +param tags object = { + 'Solution Name': solutionName + 'Solution Type': solutionType +} + +/****************************************************************************************************************************/ +// prefix generation +/****************************************************************************************************************************/ +var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces +var resourceToken = toLower('${substring(cleanSolutionName, 0, 1)}${uniqueString(cleanSolutionName, resourceGroupName, subscription().id)}') +var resourceTokenTrimmed = length(resourceToken) > 9 ? substring(resourceToken, 0, 9) : resourceToken +var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) + +// Network parameters (these will be set via main_network.bicepparam) +param networkIsolation bool + +//param vnetName string +param vnetAddressPrefixes array +param mySubnets array +var vnetName = '${prefix}-vnet' + +param azureBationHost bool = false // Flag to create Azure Bastion Host +param azureBastionSubnet object = {} + +param jumpboxVM bool = false // Set to 'true' to deploy a jumpbox VM, 'false' to skip it +param jumpboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden +param jumpboxSubnet object = {} +param jumpboxAdminUser string = 'JumpboxAdminUser' // Default admin username for Jumpbox VM +@secure() +param jumpboxAdminPassword string + +param privateEndPoint bool = true + +/****************************************************************************************************************************/ +// Log Analytics Workspace that will be used across the solution +/****************************************************************************************************************************/ +// prefix generation +// crate a Log Analytics Workspace using AVM + +module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { + name: '${prefix}logAnalyticsWorkspace' + params: { + logAnalyticsWorkSpaceName: '${prefix}-law' + location: location + tags: tags + } +} + +var logAnalyticsWorkspaceId = logAnalyticsWorkSpace.outputs.workspaceId + +/****************************************************************************************************************************/ +// Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG +/****************************************************************************************************************************/ + +// 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true +// 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 mySubnets: if (networkIsolation && !empty(subnet.networkSecurityGroup)) { + name: '${prefix}-${subnet.networkSecurityGroup.name}' + params: { + name: '${prefix}-${subnet.networkSecurityGroup.name}' + location: location + securityRules: subnet.networkSecurityGroup.securityRules + tags: tags + } + } +] + +// 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' = if (networkIsolation) { + name: vnetName + params: { + name: vnetName + location: location + addressPrefixes: vnetAddressPrefixes + subnets: [ + for (subnet, i) in mySubnets: { + name: subnet.name + addressPrefixes: subnet.addressPrefixes + networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null + } + ] + diagnosticSettings: [ + { + name: 'vnetDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + tags: tags + } +} + +output vnetName string = virtualNetwork.outputs.name +output vnetLocation string = virtualNetwork.outputs.location +output vnetId string = virtualNetwork.outputs.resourceId + +output subnetIds array = virtualNetwork.outputs.subnetResourceIds + +// /****************************************************************************************************************************/ +// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM +// /****************************************************************************************************************************/ +// // // Create or reuse Jumpbox VM +// Craete NSG for Jumpbox subnet if jumpboxSubnet is not empty and networkIsolation is true +module jumpboxNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { + name: '${prefix}-jumpbox-nsg' + params: { + name: '${prefix}-jumpbox-nsg' + location: location + securityRules: jumpboxSubnet.networkSecurityGroup.securityRules + tags: tags + } +} + +// Create jumpbox subnet if jumpboxSubnet is not empty and networkIsolation is true +module avmJumpboxSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { + name: '${prefix}-jumpbox-subnet' + params: { + virtualNetworkName: virtualNetwork.outputs.name + name: jumpboxSubnet.name + addressPrefixes: jumpboxSubnet.addressPrefixes + networkSecurityGroupResourceId: jumpboxNsg.outputs.resourceId + } +} + +output jumpboxSubnetId string = avmJumpboxSubnet.outputs.resourceId +output jumpboxNsgId string = jumpboxNsg.outputs.resourceId + +output jumpboxNsgName string = jumpboxNsg.outputs.name +output jumpboxSubnetName string = avmJumpboxSubnet.outputs.name +output jumpboxSubnetAddressPrefixes array = avmJumpboxSubnet.outputs.addressPrefixes +output jumpboxSubnetNetworkSecurityGroupId string = jumpboxNsg.outputs.resourceId +output jumpboxSubnetNetworkSecurityGroupName string = jumpboxNsg.outputs.name + +// // Variables for dynamic Jumpbox subnet reference (must be after subnetIds output) +// var subnetNames = [for subnet in mySubnets: subnet.name] +// var jumpboxSubnetIndex = indexOf(subnetNames, 'jumpbox') + +module avmJumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (networkIsolation && jumpboxVM) { + name: '${prefix}-jbVM' + params: { + name: take('${prefix}-jbVm', 15) + vmSize: jumpboxVmSize + location: location + adminUsername: jumpboxAdminUser + adminPassword: jumpboxAdminPassword + tags: tags + zone: 2 + imageReference: { + offer: 'WindowsServer' + publisher: 'MicrosoftWindowsServer' + sku: '2019-datacenter' + version: 'latest' + } + osType: 'Windows' + osDisk: { + managedDisk: { + storageAccountType: 'Standard_LRS' + } + } + encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + nicConfigurations: [ + { + name: 'nicJumpbox' + ipConfigurations: [ + { + name: 'ipconfig1' + subnetResourceId: avmJumpboxSubnet.outputs.resourceId + } + ] + networkSecurityGroupResourceId: jumpboxNsg.outputs.resourceId + diagnosticSettings: [ + { + name: 'jumpboxDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + } + ] + } +} + +output jumpboxVMId string = avmJumpboxVM.outputs.resourceId +output jumpboxVMName string = avmJumpboxVM.outputs.name +output jumpboxVMLocation string = avmJumpboxVM.outputs.location + + +/****************************************************************************************************************************/ +// // Create Azure Bastion Subnet and Azure Bastion Host +/****************************************************************************************************************************/ +// 1. Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet + +// Create Azure Bastion Subnet if azureBastionSubnet is not empty and networkIsolation is true +module avmAzureBastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { + name: '${prefix}-AzureBastionSubnet' + params: { + virtualNetworkName: virtualNetwork.outputs.name + name: azureBastionSubnet.name + addressPrefixes: azureBastionSubnet.addressPrefixes + } +} + +output azureBastionSubnetId string = avmAzureBastionSubnet.outputs.resourceId +output azureBastionSubnetName string = avmAzureBastionSubnet.outputs.name +output azureBastionSubnetAddressPrefixes array = avmAzureBastionSubnet.outputs.addressPrefixes + +// 2. Create Azure Bastion Host in AzureBastionSubnet using AVM Bastion Host module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host + +module avmBastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { + name: '${prefix}-bastionhost' + params: { + name: '${prefix}-bastionhost' + skuName: 'Standard' + location: location + virtualNetworkResourceId: virtualNetwork.outputs.resourceId + diagnosticSettings: [ + { + name: 'bastionDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + } + ] + tags: tags + } +} + +output bastionHostId string = avmBastionHost.outputs.resourceId +output bastionHostName string = avmBastionHost.outputs.name +output bastionHostLocation string = avmBastionHost.outputs.location + diff --git a/infra/modules/azureBationHost.bicep b/infra/modules/azureBationHost.bicep new file mode 100644 index 0000000..278723d --- /dev/null +++ b/infra/modules/azureBationHost.bicep @@ -0,0 +1,58 @@ + + +/****************************************************************************************************************************/ +// // Create Azure Bastion Subnet and Azure Bastion Host +/****************************************************************************************************************************/ +// 1. Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet + +param azureBastionSubnet object = {} +param location string = resourceGroup().location +param vnetName string +param vnetId string // Resource ID of the Virtual Network +param azureBationHostName string = 'AzureBastionHost' // Default name for Azure Bastion Host +param logAnalyticsWorkspaceId string +param tags object = {} + + +// 1. Create Azure Bastion Subnet with defined name: +module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(azureBastionSubnet)) { + name: azureBastionSubnet.name + params: { + virtualNetworkName: vnetName + name: azureBastionSubnet.name + addressPrefixes: azureBastionSubnet.addressPrefixes + } +} + +output bastionSubnetId string = bastionSubnet.outputs.resourceId +output bastionSubnetName string = bastionSubnet.outputs.name + +// 2. Create Azure Bastion Host in AzureBastionSubnet using AVM Bastion Host module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host + +module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (!empty(azureBastionSubnet)) { + name: azureBationHostName + params: { + name: azureBationHostName + skuName: 'Standard' + location: location + virtualNetworkResourceId: vnetId + diagnosticSettings: [ + { + name: 'bastionDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + } + ] + tags: tags + } +} + +output bastionHostId string = bastionHost.outputs.resourceId +output bastionHostName string = bastionHost.outputs.name diff --git a/infra/modules/jumpboxWithSubnet.bicep b/infra/modules/jumpboxWithSubnet.bicep new file mode 100644 index 0000000..2381f7a --- /dev/null +++ b/infra/modules/jumpboxWithSubnet.bicep @@ -0,0 +1,111 @@ + +// /****************************************************************************************************************************/ +// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM +// /****************************************************************************************************************************/ +param vmName string = 'jumpboxVM' // Default name for Jumpbox VM +param location string = resourceGroup().location +param vnetName string +param jumpboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden + +param jumpboxSubnet object = {} // This was defined in the .param file as a complex object +param jumpboxAdminUser string = 'JumpboxAdminUser' // Default admin username for Jumpbox VM +@secure() +param jumpboxAdminPassword string + +param tags object = {} +param logAnalyticsWorkspaceId string + +// 1. Create jumpbox NSG +// using AVM Network Security Group module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group +module jbNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (!empty(jumpboxSubnet)) { + name: jumpboxSubnet.networkSecurityGroup.name + params: { + name: jumpboxSubnet.networkSecurityGroup.name + location: location + securityRules: jumpboxSubnet.networkSecurityGroup.securityRules + tags: tags + } +} + +// 2. Create jumbox subnet as part of the existing VNet +// using AVM Virtual Network Subnet module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet +module jbSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(jumpboxSubnet)) { + name: jumpboxSubnet.name + params: { + virtualNetworkName: vnetName + name: jumpboxSubnet.name + addressPrefixes: jumpboxSubnet.addressPrefixes + networkSecurityGroupResourceId: jbNsg.outputs.resourceId + } +} + +output jumpboxSubnetId string = jbSubnet.outputs.resourceId +output jumpboxSubnetName string = jbSubnet.outputs.name +output jumpboxNsgId string = jbNsg.outputs.resourceId +output jumpboxNsgName string = jbNsg.outputs.name + +// 3. Create Jumpbox VM +// using AVM Virtual Machine module +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/compute/virtual-machine +var shortenedVmName = take(vmName, 15) // Shorten VM name to 15 characters to avoid Azure limits +module jbVm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { + name: vmName + params: { + name: shortenedVmName + vmSize: jumpboxVmSize + location: location + adminUsername: jumpboxAdminUser + adminPassword: jumpboxAdminPassword + tags: tags + zone: 2 + imageReference: { + offer: 'WindowsServer' + publisher: 'MicrosoftWindowsServer' + sku: '2019-datacenter' + version: 'latest' + } + osType: 'Windows' + osDisk: { + managedDisk: { + storageAccountType: 'Standard_LRS' + } + } + encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + nicConfigurations: [ + { + name: 'nicJumpbox' + ipConfigurations: [ + { + name: 'ipconfig1' + subnetResourceId: jbSubnet.outputs.resourceId + } + ] + networkSecurityGroupResourceId: jbNsg.outputs.resourceId + diagnosticSettings: [ + { + name: 'jumpboxDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + } + ] + } +} + +output jumpboxVmId string = jbVm.outputs.resourceId +output jumpboxVmName string = jbVm.outputs.name +output jumpboxVMLocation string = jbVm.outputs.location diff --git a/infra/modules/vnetWithSubnets.bicep b/infra/modules/vnetWithSubnets.bicep new file mode 100644 index 0000000..6f5d8c5 --- /dev/null +++ b/infra/modules/vnetWithSubnets.bicep @@ -0,0 +1,81 @@ +/****************************************************************************************************************************/ +// Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG +/****************************************************************************************************************************/ + +param prefix string = 'myapp' +param location string = resourceGroup().location +param vnetName string +param vnetAddressPrefixes array +param subnetArray array +param tags object = {} +param logAnalyticsWorkspaceId string + + +// 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true +// 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 subnetArray: if (!empty(subnet.networkSecurityGroup)) { + name: '${prefix}-${subnet.networkSecurityGroup.name}' + params: { + name: '${prefix}-${subnet.networkSecurityGroup.name}' + location: location + securityRules: subnet.networkSecurityGroup.securityRules + tags: tags + } + } +] + +// 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: vnetName + params: { + name: vnetName + location: location + addressPrefixes: vnetAddressPrefixes + subnets: [ + for (subnet, i) in subnetArray: { + name: subnet.name + addressPrefixes: subnet.addressPrefixes + networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null + } + ] + diagnosticSettings: [ + { + name: 'vnetDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + tags: tags + } +} + +output vnetName string = virtualNetwork.outputs.name +output vnetResourceId string = virtualNetwork.outputs.resourceId + +//combined output array that holds subnet details along with NSG information +output outputSubnetsArray array = [ + for (subnet, i) in subnetArray: { + subnetName: subnet.name + subnetResourceId: virtualNetwork.outputs.subnetResourceIds[i] + nsgName: !empty(subnet.networkSecurityGroup) ? subnet.networkSecurityGroup.name : null + nsgResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null + } +] From a189952b171012b0f06f304dcf662e817d51a186 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 3 Jun 2025 21:14:06 -0400 Subject: [PATCH 030/124] naming consistency - tested --- infra/modules/jumpboxWithSubnet.bicep | 4 ++-- infra/modules/vnetWithSubnets.bicep | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/infra/modules/jumpboxWithSubnet.bicep b/infra/modules/jumpboxWithSubnet.bicep index 2381f7a..14fddef 100644 --- a/infra/modules/jumpboxWithSubnet.bicep +++ b/infra/modules/jumpboxWithSubnet.bicep @@ -19,9 +19,9 @@ param logAnalyticsWorkspaceId string // using AVM Network Security Group module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group module jbNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (!empty(jumpboxSubnet)) { - name: jumpboxSubnet.networkSecurityGroup.name + name: '${vnetName}-${jumpboxSubnet.networkSecurityGroup.name}' params: { - name: jumpboxSubnet.networkSecurityGroup.name + name: '${vnetName}-${jumpboxSubnet.networkSecurityGroup.name}' location: location securityRules: jumpboxSubnet.networkSecurityGroup.securityRules tags: tags diff --git a/infra/modules/vnetWithSubnets.bicep b/infra/modules/vnetWithSubnets.bicep index 6f5d8c5..d84a49a 100644 --- a/infra/modules/vnetWithSubnets.bicep +++ b/infra/modules/vnetWithSubnets.bicep @@ -2,7 +2,6 @@ // Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG /****************************************************************************************************************************/ -param prefix string = 'myapp' param location string = resourceGroup().location param vnetName string param vnetAddressPrefixes array @@ -18,9 +17,9 @@ param logAnalyticsWorkspaceId string @batchSize(1) module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [ for (subnet, i) in subnetArray: if (!empty(subnet.networkSecurityGroup)) { - name: '${prefix}-${subnet.networkSecurityGroup.name}' + name: '${vnetName}-${subnet.networkSecurityGroup.name}' params: { - name: '${prefix}-${subnet.networkSecurityGroup.name}' + name: '${vnetName}-${subnet.networkSecurityGroup.name}' location: location securityRules: subnet.networkSecurityGroup.securityRules tags: tags From 92b7898a1376aa09623bf602d0d00f2e833d0343 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 3 Jun 2025 21:14:56 -0400 Subject: [PATCH 031/124] deleted jumpbox.bicep that is no longer used --- infra/modules/jumpbox.bicep | 89 ------------------------------------- 1 file changed, 89 deletions(-) delete mode 100644 infra/modules/jumpbox.bicep diff --git a/infra/modules/jumpbox.bicep b/infra/modules/jumpbox.bicep deleted file mode 100644 index c0810ba..0000000 --- a/infra/modules/jumpbox.bicep +++ /dev/null @@ -1,89 +0,0 @@ -// Creates a JumpBox VM using AVM -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/compute/virtual-machine - -@description('Prefix for resource names') -param prefix string - -@description('Name of the JumpBox VM') -param vmName string - -@description('Azure region for the VM') -param location string = resourceGroup().location - -@description('Resource ID of the subnet for the VM') -param subnetId string - -@description('Admin username') -param adminUsername string - -@description('Admin password or SSH public key') -@secure() -param adminPasswordOrKey string - -@description('VM size (e.g., "Standard_B2ms")') -param vmSize string = 'Standard_D2s_v3' - -@description('Optional: Tags for the VM') -param tags object = {} - -@description('Log Analytics Workspace Resource ID for diagnostics') -param logAnalyticsWorkspaceId string - -// Diagnostic settings for Log Analytics only -var diagnosticSettings = [ - { - name: 'jumpboxDiagnostics' - metricCategories: [ - { - category: 'AllMetrics' - } - ] - workspaceResourceId: logAnalyticsWorkspaceId - } -] - - -module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.15.0' = { - name: '${prefix}vmJumpBox' - params: { - adminUsername: adminUsername - adminPassword: adminPasswordOrKey - name: vmName - location: location - vmSize: vmSize - osType: 'Windows' - imageReference: { - offer: 'WindowsServer' - publisher: 'MicrosoftWindowsServer' - sku: '2019-datacenter' - version: 'latest' - } - nicConfigurations: [ - { - name: 'nic-01' - ipConfigurations: [ - { - name: 'ipconfig-01' - subnetResourceId: subnetId - } - ] - diagnosticSettings: diagnosticSettings - } - ] - osDisk: { - name: '${vmName}-osdisk' - createOption: 'FromImage' - managedDisk: { - storageAccountType: 'Premium_LRS' - } - diskSizeGB: 128 - deleteOption: 'Delete' - } - encryptionAtHost: false // Some Azure subscriptions do not support encryption at host - zone: 1 - tags: tags - } -} - -output vmId string = virtualMachine.outputs.resourceId -output vmName string = virtualMachine.outputs.name From bb23ddd897d1cc14d85fba3d783d5bdd7c05778f Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 3 Jun 2025 21:38:40 -0400 Subject: [PATCH 032/124] new name schema for nic of the Jumpbox --- infra/modules/jumpboxWithSubnet.bicep | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infra/modules/jumpboxWithSubnet.bicep b/infra/modules/jumpboxWithSubnet.bicep index 14fddef..1381f9a 100644 --- a/infra/modules/jumpboxWithSubnet.bicep +++ b/infra/modules/jumpboxWithSubnet.bicep @@ -49,11 +49,11 @@ output jumpboxNsgName string = jbNsg.outputs.name // 3. Create Jumpbox VM // using AVM Virtual Machine module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/compute/virtual-machine -var shortenedVmName = take(vmName, 15) // Shorten VM name to 15 characters to avoid Azure limits +var limitedVmName = take(vmName, 15) // Shorten VM name to 15 characters to avoid Azure limits module jbVm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { name: vmName params: { - name: shortenedVmName + name: limitedVmName vmSize: jumpboxVmSize location: location adminUsername: jumpboxAdminUser @@ -75,7 +75,7 @@ module jbVm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { encryptionAtHost: false // Some Azure subscriptions do not support encryption at host nicConfigurations: [ { - name: 'nicJumpbox' + name: '${limitedVmName}-nic' ipConfigurations: [ { name: 'ipconfig1' From f18067e45ca468b4daebcc855f3886aadeadcfa7 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 3 Jun 2025 22:17:17 -0400 Subject: [PATCH 033/124] Comments improved --- infra/main_network.bicep | 35 +++++++++++++++------------ infra/modules/azureBationHost.bicep | 12 ++++----- infra/modules/jumpboxWithSubnet.bicep | 7 +++--- infra/modules/vnetWithSubnets.bicep | 8 +++--- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 013e7ed..1311d31 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -1,4 +1,9 @@ -//targetScope = 'subscription' +// /****************************************************************************************************************************/ +// Main program to test network isoltion, jumpbox and Azure Bastion Host creation +// It used parameters from main_network.bicepparam file +// /****************************************************************************************************************************/ + + targetScope = 'resourceGroup' @minLength(6) @@ -17,22 +22,22 @@ param tags object = { 'Solution Type': solutionType } -/****************************************************************************************************************************/ -// prefix generation -/****************************************************************************************************************************/ +// /****************************************************************************************************************************/ +// Prefix generation +// /****************************************************************************************************************************/ var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces var resourceToken = toLower('${substring(cleanSolutionName, 0, 1)}${uniqueString(cleanSolutionName, resourceGroupName, subscription().id)}') var resourceTokenTrimmed = length(resourceToken) > 9 ? substring(resourceToken, 0, 9) : resourceToken var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) // Network parameters (these will be set via main_network.bicepparam) -param networkIsolation bool = false // set in .bicepparam file +param networkIsolation bool = false // set in .bicepparam file param vnetAddressPrefixes array = [] // set in .bicepparam file param mySubnets array = [] // set in .bicepparam file var vnetName = '${prefix}-vnet' // jumpbox parameters -param jumpboxVM bool = false // set in .bicepparam file +param jumpboxVM bool = false // set in .bicepparam file param jumpboxSubnet object = {} // set in .bicepparam file param jumpboxAdminUser string = 'JumpboxAdminUser' // set in .bicepparam file @secure() @@ -48,14 +53,12 @@ var azureBastionHostName = '${prefix}-bastionHost' // Private Endpoint parameters param privateEndPoint bool = false // set in .bicepparam file -/****************************************************************************************************************************/ +// /****************************************************************************************************************************/ // Log Analytics Workspace that will be used across the solution -/****************************************************************************************************************************/ -// prefix generation -// crate a Log Analytics Workspace using AVM +// /****************************************************************************************************************************/ module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { - name: '${prefix}logAnalyticsWorkspace' + name: '${prefix}-law' params: { logAnalyticsWorkSpaceName: '${prefix}-law' location: location @@ -63,9 +66,9 @@ module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { } } -/****************************************************************************************************************************/ -// Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG -/****************************************************************************************************************************/ +// /****************************************************************************************************************************/ +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG +// /****************************************************************************************************************************/ module vnetWithSubnets 'modules/vnetWithSubnets.bicep' = if (networkIsolation) { name: '${prefix}-vnetWithSubnets' @@ -85,9 +88,9 @@ output vnetResourceId string = vnetWithSubnets.outputs.vnetResourceId output subnetsOutput array = vnetWithSubnets.outputs.outputSubnetsArray // This one holds critical info for subnets, including NSGs -/****************************************************************************************************************************/ +// /****************************************************************************************************************************/ // // Create Azure Bastion Subnet and Azure Bastion Host -/****************************************************************************************************************************/ +// /****************************************************************************************************************************/ module azureBastionHost 'modules/azureBationHost.bicep' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { name: '${prefix}-azureBastionHost' diff --git a/infra/modules/azureBationHost.bicep b/infra/modules/azureBationHost.bicep index 278723d..45ec79b 100644 --- a/infra/modules/azureBationHost.bicep +++ b/infra/modules/azureBationHost.bicep @@ -1,11 +1,8 @@ +// /****************************************************************************************************************************/ +// Create Azure Bastion Subnet and Azure Bastion Host +// /****************************************************************************************************************************/ -/****************************************************************************************************************************/ -// // Create Azure Bastion Subnet and Azure Bastion Host -/****************************************************************************************************************************/ -// 1. Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet - param azureBastionSubnet object = {} param location string = resourceGroup().location param vnetName string @@ -15,7 +12,8 @@ param logAnalyticsWorkspaceId string param tags object = {} -// 1. Create Azure Bastion Subnet with defined name: +// 1. Create Azure Bastion Host using AVM Subnet Module with special config for Azure Bastion Subnet +// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(azureBastionSubnet)) { name: azureBastionSubnet.name params: { diff --git a/infra/modules/jumpboxWithSubnet.bicep b/infra/modules/jumpboxWithSubnet.bicep index 1381f9a..6b414ad 100644 --- a/infra/modules/jumpboxWithSubnet.bicep +++ b/infra/modules/jumpboxWithSubnet.bicep @@ -1,6 +1,5 @@ - // /****************************************************************************************************************************/ -// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM +// Create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM // /****************************************************************************************************************************/ param vmName string = 'jumpboxVM' // Default name for Jumpbox VM param location string = resourceGroup().location @@ -15,7 +14,7 @@ param jumpboxAdminPassword string param tags object = {} param logAnalyticsWorkspaceId string -// 1. Create jumpbox NSG +// 1. Create Jumpbox NSG // using AVM Network Security Group module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group module jbNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (!empty(jumpboxSubnet)) { @@ -28,7 +27,7 @@ module jbNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (!emp } } -// 2. Create jumbox subnet as part of the existing VNet +// 2. Create Jumpbox subnet as part of the existing VNet // using AVM Virtual Network Subnet module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet module jbSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(jumpboxSubnet)) { diff --git a/infra/modules/vnetWithSubnets.bicep b/infra/modules/vnetWithSubnets.bicep index d84a49a..6c53c26 100644 --- a/infra/modules/vnetWithSubnets.bicep +++ b/infra/modules/vnetWithSubnets.bicep @@ -1,5 +1,5 @@ /****************************************************************************************************************************/ -// Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG /****************************************************************************************************************************/ param location string = resourceGroup().location @@ -10,7 +10,7 @@ param tags object = {} param logAnalyticsWorkspaceId string -// 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true +// 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 @@ -27,7 +27,7 @@ module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [ } ] -// 2. Create VNet and subnets with subnets associated with corresponding NSGs +// 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 @@ -69,7 +69,7 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { output vnetName string = virtualNetwork.outputs.name output vnetResourceId string = virtualNetwork.outputs.resourceId -//combined output array that holds subnet details along with NSG information +// combined output array that holds subnet details along with NSG information output outputSubnetsArray array = [ for (subnet, i) in subnetArray: { subnetName: subnet.name From de9ff686cca650f646d89155af333f7fe6456bbf Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 4 Jun 2025 09:48:01 -0400 Subject: [PATCH 034/124] WAF - removed unnecessary flag check --- infra/main.bicep | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index c8dcf62..909a3f0 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -295,7 +295,7 @@ module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environmen params: { name: 'cae-${resourcesName}-backend' location: location - zoneRedundant: enableRedundancy && enablePrivateNetworking + zoneRedundant: enableRedundancy publicNetworkAccess: 'Disabled' infrastructureSubnetResourceId: '' // TODO managedIdentities: { @@ -320,7 +320,7 @@ var containerAppsEnvironmentResourceId = enablePrivateNetworking ? containerApps module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { name: take('container-app-backend-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [applicationInsights, logAnalyticsWorkspace, containerAppsEnvironmentBackend] // required due to optional flags that could change dependency + dependsOn: [applicationInsights, containerAppsEnvironmentBackend] // required due to optional flags that could change dependency params: { name: take('ca-${uniqueResourcesName}backend', 32) location: location From c02e364982a8e908024a669c3b150a69a1e7b848 Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 4 Jun 2025 10:58:45 -0400 Subject: [PATCH 035/124] WAF - network integration into main (WIP) --- infra/main.bicep | 208 ++++++++++++++++-- infra/modules/bastionHost.bicep | 31 --- infra/modules/logAnalyticsWorkSpace.bicep | 26 --- .../{ => network}/azureBationHost.bicep | 0 .../{ => network}/jumpboxWithSubnet.bicep | 0 infra/modules/network/network.bicep | 105 +++++++++ .../{ => network}/vnetWithSubnets.bicep | 4 +- infra/modules/privateDnsZone.bicep | 18 -- infra/modules/privateEndpoint.bicep | 0 infra/modules/routeTable.bicep | 43 ---- 10 files changed, 301 insertions(+), 134 deletions(-) delete mode 100644 infra/modules/bastionHost.bicep delete mode 100644 infra/modules/logAnalyticsWorkSpace.bicep rename infra/modules/{ => network}/azureBationHost.bicep (100%) rename infra/modules/{ => network}/jumpboxWithSubnet.bicep (100%) create mode 100644 infra/modules/network/network.bicep rename infra/modules/{ => network}/vnetWithSubnets.bicep (96%) delete mode 100644 infra/modules/privateDnsZone.bicep delete mode 100644 infra/modules/privateEndpoint.bicep delete mode 100644 infra/modules/routeTable.bicep diff --git a/infra/main.bicep b/infra/main.bicep index 909a3f0..8786a1b 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -90,7 +90,7 @@ module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identit } } -module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring) { +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring || enablePrivateNetworking) { name: take('log-analytics-${resourcesName}-deployment', 64) params: { name: 'log-${resourcesName}' @@ -113,17 +113,185 @@ module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (en } } +module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { + name: take('network-${resourcesName}-deployment', 64) + params: { + resourcesName: resourcesName + logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId + addressPrefixes: ['10.0.0.0/21'] + mySubnets: [ + { + name: 'web' + addressPrefixes: ['10.0.0.0/24'] + networkSecurityGroup: { + name: 'web-nsg' + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/24'] + } + } + ] + } + } + { + name: 'app' + addressPrefixes: ['10.0.1.0/24'] + networkSecurityGroup: { + name: 'app-nsg' + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet + destinationAddressPrefixes: ['10.0.1.0/24'] + } + } + ] + } + } + { + name: 'ai' + addressPrefixes: ['10.0.2.0/24'] + networkSecurityGroup: { + name: 'ai-nsg' + securityRules: [ + { + name: 'AllowAppToAI' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet + destinationAddressPrefixes: ['10.0.2.0/24'] + } + } + ] + } + } + { + name: 'data' + addressPrefixes: ['10.0.3.0/24'] + networkSecurityGroup: { + name: 'data-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToData' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] + destinationAddressPrefixes: ['10.0.3.0/24'] + } + } + ] + } + } + { + name: 'services' + addressPrefixes: ['10.0.4.0/24'] + networkSecurityGroup: { + name: 'services-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToServices' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] + destinationAddressPrefixes: ['10.0.4.0/24'] + } + } + ] + } + } + ] + azureBationHost: true + azureBastionSubnet: { + name: 'AzureBastionSubnet' // Required name for Azure Bastion + addressPrefixes: ['10.0.5.0/27'] + networkSecurityGroup: null // Must not have an NSG + } + jumpboxVM: true + jumpboxVmSize: 'Standard_D2s_v3' + jumpboxAdminUser: 'JumpboxAdminUser' + jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' + jumpboxSubnet: { + name: 'jumpbox' + addressPrefixes: ['10.0.6.0/24'] + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.5.0/27' // Azure Bastion subnet + ] + destinationAddressPrefixes: ['10.0.6.0/24'] + } + } + ] + } + } + location: location + tags: allTags + } +} + module storageAccount 'modules/storageAccount.bicep' = { name: take('storage-account-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency + dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { name: take('st${uniqueResourcesName}', 24) location: location tags: allTags skuName: enableRedundancy ? 'Standard_LRS' : 'Standard_GZRS' logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' - privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access + privateNetworking: enablePrivateNetworking ? { + virtualNetworkResourceId: network.outputs.vnetResourceId + subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'data')).resourceId + } : null containers: [ { name: appStorageContainerName @@ -145,7 +313,7 @@ module storageAccount 'modules/storageAccount.bicep' = { module azureAiServices 'modules/aiServices.bicep' = { name: take('aiservices-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency + dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { name: 'ais-${uniqueResourcesName}' location: location @@ -153,7 +321,10 @@ module azureAiServices 'modules/aiServices.bicep' = { kind: 'AIServices' deployments: [modelDeployment] logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' - privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access + privateNetworking: enablePrivateNetworking ? { + virtualNetworkResourceId: network.outputs.vnetResourceId + subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'ai')).resourceId + } : null roleAssignments: [ { principalId: managedIdentity.outputs.principalId @@ -168,13 +339,16 @@ module azureAiServices 'modules/aiServices.bicep' = { module keyVault 'modules/keyVault.bicep' = { name: take('keyvault-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency + dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { name: take('kv-${uniqueResourcesName}', 24) location: location sku: 'standard' logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' - privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access + privateNetworking: enablePrivateNetworking ? { + virtualNetworkResourceId: network.outputs.vnetResourceId + subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'data')).resourceId + } : null tags: allTags } } @@ -182,7 +356,7 @@ module keyVault 'modules/keyVault.bicep' = { module azureAifoundry 'modules/aiFoundry.bicep' = { name: take('aifoundry-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency + dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { location: azureAiServiceLocation hubName: 'hub-${resourcesName}' @@ -193,7 +367,10 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { managedIdentityPrincpalId: managedIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' aiServicesName: azureAiServices.outputs.name - privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access + privateNetworking: enablePrivateNetworking ? { + virtualNetworkResourceId: network.outputs.vnetResourceId + subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'ai')).resourceId + } : null tags: allTags } } @@ -201,7 +378,7 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { module cosmosDb 'modules/cosmosDb.bicep' = { name: take('cosmos-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [logAnalyticsWorkspace] // required due to optional flags that could change dependency + dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { name: 'cosmos-${uniqueResourcesName}' location: location @@ -209,7 +386,10 @@ module cosmosDb 'modules/cosmosDb.bicep' = { logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' zoneRedundant: enableRedundancy secondaryLocation: enableRedundancy && !empty(secondaryLocation) ? secondaryLocation : '' - privateNetworking: null // Set to null for public access, or provide networking resource IDs for private access + privateNetworking: enablePrivateNetworking ? { + virtualNetworkResourceId: network.outputs.vnetResourceId + subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'data')).resourceId + } : null tags: allTags } } @@ -217,13 +397,13 @@ module cosmosDb 'modules/cosmosDb.bicep' = { module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.2' = { name: take('container-env-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [applicationInsights, logAnalyticsWorkspace] // required due to optional flags that could change dependency + dependsOn: [applicationInsights, logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { name: 'cae-${resourcesName}' location: location zoneRedundant: enableRedundancy && enablePrivateNetworking publicNetworkAccess: 'Enabled' - infrastructureSubnetResourceId: enablePrivateNetworking ? '' : '' // TODO + infrastructureSubnetResourceId: enablePrivateNetworking ? first(filter(network.outputs.subnets, s => s.name == 'web')).resourceId : null managedIdentities: { userAssignedResourceIds: [ managedIdentity.outputs.resourceId @@ -297,7 +477,7 @@ module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environmen location: location zoneRedundant: enableRedundancy publicNetworkAccess: 'Disabled' - infrastructureSubnetResourceId: '' // TODO + infrastructureSubnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'app')).resourceId managedIdentities: { userAssignedResourceIds: [ managedIdentity.outputs.resourceId diff --git a/infra/modules/bastionHost.bicep b/infra/modules/bastionHost.bicep deleted file mode 100644 index 69280f3..0000000 --- a/infra/modules/bastionHost.bicep +++ /dev/null @@ -1,31 +0,0 @@ -// Creates an Azure Bastion Host using AVM -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host - -@description('Name of the Bastion Host') -param bastionHostName string - -@description('Azure region for the Bastion Host') -param location string = resourceGroup().location - -@description('Resource ID of the VNet') -param vnetId string - -@description('Optional: Tags for the Bastion Host') -param tags object = {} - -module bastion 'br/public:avm/res/network/bastion-host:0.2.2' = { - name: bastionHostName - params: { - name: bastionHostName - location: location - virtualNetworkResourceId: vnetId - publicIPAddressObject: { - name: '${bastionHostName}-pip' - skuName: 'Standard' - location: location - } - tags: tags - } -} - -output bastionHostId string = bastion.outputs.resourceId diff --git a/infra/modules/logAnalyticsWorkSpace.bicep b/infra/modules/logAnalyticsWorkSpace.bicep deleted file mode 100644 index f608738..0000000 --- a/infra/modules/logAnalyticsWorkSpace.bicep +++ /dev/null @@ -1,26 +0,0 @@ -// Creates a Log Analytics Workspace using the AVM module - -//https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/operational-insights/workspace - - -@description('Name of the Log Analytics Workspace') -param logAnalyticsWorkSpaceName string - -@description('Azure region for the workspace') -param location string = resourceGroup().location - -@description('Optional: Tags for the workspace') -param tags object = {} - -module workspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = { - name: logAnalyticsWorkSpaceName - params: { - // Required parameters - name: logAnalyticsWorkSpaceName - // Optional parameters - location: location - tags:tags - } -} - -output workspaceId string = workspace.outputs.resourceId diff --git a/infra/modules/azureBationHost.bicep b/infra/modules/network/azureBationHost.bicep similarity index 100% rename from infra/modules/azureBationHost.bicep rename to infra/modules/network/azureBationHost.bicep diff --git a/infra/modules/jumpboxWithSubnet.bicep b/infra/modules/network/jumpboxWithSubnet.bicep similarity index 100% rename from infra/modules/jumpboxWithSubnet.bicep rename to infra/modules/network/jumpboxWithSubnet.bicep diff --git a/infra/modules/network/network.bicep b/infra/modules/network/network.bicep new file mode 100644 index 0000000..3b114b6 --- /dev/null +++ b/infra/modules/network/network.bicep @@ -0,0 +1,105 @@ +@minLength(6) +@maxLength(25) +@description('Default name used for all resources.') +param resourcesName string + +@minLength(3) +@description('Azure region for all services.') +param location string + +@description('Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') +param logAnalyticsWorkSpaceResourceId string + +@description('Networking address prefix for the VNET and subnets.') +param addressPrefixes array + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +param mySubnets array + +var vnetName = 'vnet-${resourcesName}' + +// jumpbox parameters +param jumpboxVM bool = false // set in .bicepparam file +param jumpboxSubnet object = {} // set in .bicepparam file +param jumpboxAdminUser string = 'JumpboxAdminUser' // set in .bicepparam file +@secure() +param jumpboxAdminPassword string // set in .bicepparam file +param jumpboxVmSize string = 'Standard_D2s_v3' +var jumpboxVmName = 'jumpboxVM-${resourcesName}' + +// Azure Bastion Host parameters +param azureBationHost bool = false // set in .bicepparam file +param azureBastionSubnet object = {} // set in .bicepparam file +var azureBastionHostName = 'bastionHost-${resourcesName}' + + +// /****************************************************************************************************************************/ +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG +// /****************************************************************************************************************************/ + +module vnetWithSubnets 'vnetWithSubnets.bicep' = { + name: '${resourcesName}-vnetWithSubnets' + params: { + vnetName: vnetName + vnetAddressPrefixes: addressPrefixes + subnetArray: mySubnets + location: location + tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + } +} + +// /****************************************************************************************************************************/ +// // Create Azure Bastion Subnet and Azure Bastion Host +// /****************************************************************************************************************************/ + +module azureBastionHost 'azureBationHost.bicep' = if (azureBationHost && !empty(azureBastionSubnet)) { + name: '${resourcesName}-azureBastionHost' + params: { + azureBastionSubnet: azureBastionSubnet + location: location + vnetName: vnetWithSubnets.outputs.vnetName + vnetId: vnetWithSubnets.outputs.vnetResourceId + azureBationHostName: azureBastionHostName + logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + tags: tags + } +} + +// /****************************************************************************************************************************/ +// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM +// /****************************************************************************************************************************/ + +module jumpboxWithSubnet 'jumpboxWithSubnet.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { + name: '${resourcesName}-jumpboxWithSubnet' + params: { + vmName: jumpboxVmName + location: location + vnetName: vnetWithSubnets.outputs.vnetName + jumpboxVmSize: jumpboxVmSize + jumpboxSubnet: jumpboxSubnet + jumpboxAdminUser: jumpboxAdminUser + jumpboxAdminPassword: jumpboxAdminPassword + tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + } +} + + +output vnetName string = vnetWithSubnets.outputs.vnetName +output vnetResourceId string = vnetWithSubnets.outputs.vnetResourceId +output subnets array = vnetWithSubnets.outputs.outputSubnetsArray // This one holds critical info for subnets, including NSGs + +output azureBastionSubnetId string = azureBastionHost.outputs.bastionSubnetId +output azureBastionSubnetName string = azureBastionHost.outputs.bastionSubnetName +output azureBastionHostId string = azureBastionHost.outputs.bastionHostId +output azureBastionHostName string = azureBastionHost.outputs.bastionHostName + +output jumpboxSubnetName string = jumpboxWithSubnet.outputs.jumpboxSubnetName +output jumpboxSubnetId string = jumpboxWithSubnet.outputs.jumpboxSubnetId +output jumpboxVmName string = jumpboxWithSubnet.outputs.jumpboxVmName +output jumpboxVmId string = jumpboxWithSubnet.outputs.jumpboxVmId + + diff --git a/infra/modules/vnetWithSubnets.bicep b/infra/modules/network/vnetWithSubnets.bicep similarity index 96% rename from infra/modules/vnetWithSubnets.bicep rename to infra/modules/network/vnetWithSubnets.bicep index 6c53c26..1378073 100644 --- a/infra/modules/vnetWithSubnets.bicep +++ b/infra/modules/network/vnetWithSubnets.bicep @@ -72,8 +72,8 @@ output vnetResourceId string = virtualNetwork.outputs.resourceId // combined output array that holds subnet details along with NSG information output outputSubnetsArray array = [ for (subnet, i) in subnetArray: { - subnetName: subnet.name - subnetResourceId: virtualNetwork.outputs.subnetResourceIds[i] + 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 } diff --git a/infra/modules/privateDnsZone.bicep b/infra/modules/privateDnsZone.bicep deleted file mode 100644 index 54bbe78..0000000 --- a/infra/modules/privateDnsZone.bicep +++ /dev/null @@ -1,18 +0,0 @@ -// Creates a Private DNS Zone using AVM -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/private-dns-zone - -@description('Name of the Private DNS Zone (e.g., "privatelink.vaultcore.azure.net")') -param dnsZoneName string - -@description('Optional: Tags for the DNS Zone') -param tags object = {} - -module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.2.2' = { - name: dnsZoneName - params: { - name: dnsZoneName - tags: tags - } -} - -output dnsZoneId string = privateDnsZone.outputs.resourceId diff --git a/infra/modules/privateEndpoint.bicep b/infra/modules/privateEndpoint.bicep deleted file mode 100644 index e69de29..0000000 diff --git a/infra/modules/routeTable.bicep b/infra/modules/routeTable.bicep deleted file mode 100644 index c74101f..0000000 --- a/infra/modules/routeTable.bicep +++ /dev/null @@ -1,43 +0,0 @@ -// Creates a Route Table using AVM modules -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/route-table - -@description('Name of the Route Table') -param routeTableName string - -@description('Azure region for the Route Table') -param location string = resourceGroup().location - -@description('Optional: Tags for the Route Table') -param tags object = {} - -@description('Optional: Routes for the Route Table') -param routes array = [ - // Example route - // { - // name: 'defaultRoute' - // addressPrefix: '0.0.0.0/0' - // nextHopType: 'Internet' - // } -] - -module routeTable 'br/public:avm/res/network/route-table:0.4.1' = { - name: routeTableName - params: { - name: routeTableName - location: location - routes: [ - for route in routes: { - name: route.name - properties: { - addressPrefix: route.addressPrefix - nextHopType: route.nextHopType - nextHopIpAddress: route.nextHopIpAddress - } - } - ] - tags: tags - } -} - -output routeTableId string = routeTable.outputs.resourceId -output routeTableName string = routeTable.outputs.name From 20dba154bd01c1317a462a5c178e5c1289b56f4f Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Wed, 4 Jun 2025 12:32:18 -0400 Subject: [PATCH 036/124] refactored network code tested --- infra/main.bicep | 2 +- infra/main_network.bicep | 306 ++++++++++++++++---------- infra/main_network_keep_for_now.bicep | 280 ----------------------- infra/modules/network/network.bicep | 4 +- 4 files changed, 189 insertions(+), 403 deletions(-) delete mode 100644 infra/main_network_keep_for_now.bicep diff --git a/infra/main.bicep b/infra/main.bicep index 8786a1b..f2a0b8c 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -119,7 +119,7 @@ module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { resourcesName: resourcesName logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId addressPrefixes: ['10.0.0.0/21'] - mySubnets: [ + solutionSubnets: [ { name: 'web' addressPrefixes: ['10.0.0.0/24'] diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 1311d31..f1eb432 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -1,139 +1,205 @@ -// /****************************************************************************************************************************/ -// Main program to test network isoltion, jumpbox and Azure Bastion Host creation -// It used parameters from main_network.bicepparam file -// /****************************************************************************************************************************/ +@minLength(3) +@maxLength(20) +@description('A unique application/env name for all resources in this deployment. This should be 3-20 characters long') +param environmentName string = 'Code Mod Dev' +@minLength(3) +@description('Azure region for all services.') +param location string = resourceGroup().location -targetScope = 'resourceGroup' -@minLength(6) -@maxLength(25) -@description('Name of the solution. This is used to generate a short unique hash used in all resources.') -param solutionName string = 'Code Modernization' +@description('Optional. Enable private networking for the resources. Set to true to enable private networking.') +param enablePrivateNetworking bool = true -@description('Type of the solution. This is used for tagging and categorization.') -param solutionType string = 'Solution Accelerator' -param resourceGroupName string -param location string +@description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') +param enableMonitoring bool = true -param tags object = { - 'Solution Name': solutionName - 'Solution Type': solutionType -} - -// /****************************************************************************************************************************/ -// Prefix generation -// /****************************************************************************************************************************/ -var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces -var resourceToken = toLower('${substring(cleanSolutionName, 0, 1)}${uniqueString(cleanSolutionName, resourceGroupName, subscription().id)}') -var resourceTokenTrimmed = length(resourceToken) > 9 ? substring(resourceToken, 0, 9) : resourceToken -var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) - -// Network parameters (these will be set via main_network.bicepparam) -param networkIsolation bool = false // set in .bicepparam file -param vnetAddressPrefixes array = [] // set in .bicepparam file -param mySubnets array = [] // set in .bicepparam file -var vnetName = '${prefix}-vnet' - -// jumpbox parameters -param jumpboxVM bool = false // set in .bicepparam file -param jumpboxSubnet object = {} // set in .bicepparam file -param jumpboxAdminUser string = 'JumpboxAdminUser' // set in .bicepparam file -@secure() -param jumpboxAdminPassword string // set in .bicepparam file -param jumpboxVmSize string = 'Standard_D2s_v3' -var jumpboxVmName = '${prefix}-jumpboxVM' +@description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') +param tags object = {} -// Azure Bastion Host parameters -param azureBationHost bool = false // set in .bicepparam file -param azureBastionSubnet object = {} // set in .bicepparam file -var azureBastionHostName = '${prefix}-bastionHost' +var resourcesName = trim(replace(replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) +var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) +var uniqueResourcesName = '${resourcesName}${resourcesToken}' -// Private Endpoint parameters -param privateEndPoint bool = false // set in .bicepparam file - -// /****************************************************************************************************************************/ -// Log Analytics Workspace that will be used across the solution -// /****************************************************************************************************************************/ - -module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { - name: '${prefix}-law' - params: { - logAnalyticsWorkSpaceName: '${prefix}-law' - location: location - tags: tags - } -} - -// /****************************************************************************************************************************/ -// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG -// /****************************************************************************************************************************/ - -module vnetWithSubnets 'modules/vnetWithSubnets.bicep' = if (networkIsolation) { - name: '${prefix}-vnetWithSubnets' - params: { - vnetName: vnetName - vnetAddressPrefixes: vnetAddressPrefixes - subnetArray: mySubnets - location: location - tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkSpace.outputs.workspaceId - } +var defaultTags = { + 'azd-env-name': resourcesName } +var allTags = union(defaultTags, tags) - -output vnetName string = vnetWithSubnets.outputs.vnetName -output vnetResourceId string = vnetWithSubnets.outputs.vnetResourceId -output subnetsOutput array = vnetWithSubnets.outputs.outputSubnetsArray // This one holds critical info for subnets, including NSGs - - -// /****************************************************************************************************************************/ -// // Create Azure Bastion Subnet and Azure Bastion Host -// /****************************************************************************************************************************/ - -module azureBastionHost 'modules/azureBationHost.bicep' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { - name: '${prefix}-azureBastionHost' +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring || enablePrivateNetworking) { + name: take('log-analytics-${resourcesName}-deployment', 64) params: { - azureBastionSubnet: azureBastionSubnet + name: 'log-${resourcesName}' location: location - vnetName: vnetWithSubnets.outputs.vnetName - vnetId: vnetWithSubnets.outputs.vnetResourceId - azureBationHostName: azureBastionHostName - logAnalyticsWorkspaceId: logAnalyticsWorkSpace.outputs.workspaceId - tags: tags + skuName: 'PerGB2018' + dataRetention: 30 + diagnosticSettings: [{ useThisWorkspace: true }] + tags: allTags } } -output azureBastionSubnetId string = azureBastionHost.outputs.bastionSubnetId -output azureBastionSubnetName string = azureBastionHost.outputs.bastionSubnetName -output azureBastionHostId string = azureBastionHost.outputs.bastionHostId -output azureBastionHostName string = azureBastionHost.outputs.bastionHostName - - - -// /****************************************************************************************************************************/ -// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM -// /****************************************************************************************************************************/ - -module jumpboxWithSubnet 'modules/jumpboxWithSubnet.bicep' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { - name: '${prefix}-jumpboxWithSubnet' +module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { + name: take('network-${resourcesName}-deployment', 64) params: { - vmName: jumpboxVmName + resourcesName: take('network-${resourcesName}',10) + logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId + addressPrefixes: ['10.0.0.0/21'] + solutionSubnets: [ + { + name: 'web' + addressPrefixes: ['10.0.0.0/24'] + networkSecurityGroup: { + name: 'web-nsg' + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/24'] + } + } + ] + } + } + { + name: 'app' + addressPrefixes: ['10.0.1.0/24'] + networkSecurityGroup: { + name: 'app-nsg' + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet + destinationAddressPrefixes: ['10.0.1.0/24'] + } + } + ] + } + } + { + name: 'ai' + addressPrefixes: ['10.0.2.0/24'] + networkSecurityGroup: { + name: 'ai-nsg' + securityRules: [ + { + name: 'AllowAppToAI' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet + destinationAddressPrefixes: ['10.0.2.0/24'] + } + } + ] + } + } + { + name: 'data' + addressPrefixes: ['10.0.3.0/24'] + networkSecurityGroup: { + name: 'data-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToData' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] + destinationAddressPrefixes: ['10.0.3.0/24'] + } + } + ] + } + } + { + name: 'services' + addressPrefixes: ['10.0.4.0/24'] + networkSecurityGroup: { + name: 'services-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToServices' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] + destinationAddressPrefixes: ['10.0.4.0/24'] + } + } + ] + } + } + ] + azureBationHost: true + azureBastionSubnet: { + name: 'AzureBastionSubnet' // Required name for Azure Bastion + addressPrefixes: ['10.0.5.0/27'] + networkSecurityGroup: null // Must not have an NSG + } + jumpboxVM: true + jumpboxVmSize: 'Standard_D2s_v3' + jumpboxAdminUser: 'JumpboxAdminUser' + jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' + jumpboxSubnet: { + name: 'jumpbox' + addressPrefixes: ['10.0.6.0/24'] + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.5.0/27' // Azure Bastion subnet + ] + destinationAddressPrefixes: ['10.0.6.0/24'] + } + } + ] + } + } location: location - vnetName: vnetWithSubnets.outputs.vnetName - jumpboxVmSize: jumpboxVmSize - jumpboxSubnet: jumpboxSubnet - jumpboxAdminUser: jumpboxAdminUser - jumpboxAdminPassword: jumpboxAdminPassword - tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkSpace.outputs.workspaceId + tags: allTags } } - -output jumpboxSubnetName string = jumpboxWithSubnet.outputs.jumpboxSubnetName -output jumpboxSubnetId string = jumpboxWithSubnet.outputs.jumpboxSubnetId -output jumpboxVmName string = jumpboxWithSubnet.outputs.jumpboxVmName -output jumpboxVmId string = jumpboxWithSubnet.outputs.jumpboxVmId - - diff --git a/infra/main_network_keep_for_now.bicep b/infra/main_network_keep_for_now.bicep deleted file mode 100644 index 0654d60..0000000 --- a/infra/main_network_keep_for_now.bicep +++ /dev/null @@ -1,280 +0,0 @@ -//targetScope = 'subscription' -targetScope = 'resourceGroup' - -@minLength(6) -@maxLength(25) -@description('Name of the solution. This is used to generate a short unique hash used in all resources.') -param solutionName string = 'Code Modernization' - -@description('Type of the solution. This is used for tagging and categorization.') -param solutionType string = 'Solution Accelerator' - -param resourceGroupName string -param location string - -param tags object = { - 'Solution Name': solutionName - 'Solution Type': solutionType -} - -/****************************************************************************************************************************/ -// prefix generation -/****************************************************************************************************************************/ -var cleanSolutionName = replace(solutionName, ' ', '') // get rid of spaces -var resourceToken = toLower('${substring(cleanSolutionName, 0, 1)}${uniqueString(cleanSolutionName, resourceGroupName, subscription().id)}') -var resourceTokenTrimmed = length(resourceToken) > 9 ? substring(resourceToken, 0, 9) : resourceToken -var prefix = toLower(replace(resourceTokenTrimmed, '_', '')) - -// Network parameters (these will be set via main_network.bicepparam) -param networkIsolation bool - -//param vnetName string -param vnetAddressPrefixes array -param mySubnets array -var vnetName = '${prefix}-vnet' - -param azureBationHost bool = false // Flag to create Azure Bastion Host -param azureBastionSubnet object = {} - -param jumpboxVM bool = false // Set to 'true' to deploy a jumpbox VM, 'false' to skip it -param jumpboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden -param jumpboxSubnet object = {} -param jumpboxAdminUser string = 'JumpboxAdminUser' // Default admin username for Jumpbox VM -@secure() -param jumpboxAdminPassword string - -param privateEndPoint bool = true - -/****************************************************************************************************************************/ -// Log Analytics Workspace that will be used across the solution -/****************************************************************************************************************************/ -// prefix generation -// crate a Log Analytics Workspace using AVM - -module logAnalyticsWorkSpace 'modules/logAnalyticsWorkSpace.bicep' = { - name: '${prefix}logAnalyticsWorkspace' - params: { - logAnalyticsWorkSpaceName: '${prefix}-law' - location: location - tags: tags - } -} - -var logAnalyticsWorkspaceId = logAnalyticsWorkSpace.outputs.workspaceId - -/****************************************************************************************************************************/ -// Netowrking - NSGs, VNET and Subnets. Each subnet has its own NSG -/****************************************************************************************************************************/ - -// 1. Create NSGs for subnets using the AVM NSG module, only if networkIsolation is true -// 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 mySubnets: if (networkIsolation && !empty(subnet.networkSecurityGroup)) { - name: '${prefix}-${subnet.networkSecurityGroup.name}' - params: { - name: '${prefix}-${subnet.networkSecurityGroup.name}' - location: location - securityRules: subnet.networkSecurityGroup.securityRules - tags: tags - } - } -] - -// 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' = if (networkIsolation) { - name: vnetName - params: { - name: vnetName - location: location - addressPrefixes: vnetAddressPrefixes - subnets: [ - for (subnet, i) in mySubnets: { - name: subnet.name - addressPrefixes: subnet.addressPrefixes - networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null - } - ] - diagnosticSettings: [ - { - name: 'vnetDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - metricCategories: [ - { - category: 'AllMetrics' - enabled: true - } - ] - } - ] - tags: tags - } -} - -output vnetName string = virtualNetwork.outputs.name -output vnetLocation string = virtualNetwork.outputs.location -output vnetId string = virtualNetwork.outputs.resourceId - -output subnetIds array = virtualNetwork.outputs.subnetResourceIds - -// /****************************************************************************************************************************/ -// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM -// /****************************************************************************************************************************/ -// // // Create or reuse Jumpbox VM -// Craete NSG for Jumpbox subnet if jumpboxSubnet is not empty and networkIsolation is true -module jumpboxNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { - name: '${prefix}-jumpbox-nsg' - params: { - name: '${prefix}-jumpbox-nsg' - location: location - securityRules: jumpboxSubnet.networkSecurityGroup.securityRules - tags: tags - } -} - -// Create jumpbox subnet if jumpboxSubnet is not empty and networkIsolation is true -module avmJumpboxSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && jumpboxVM && !empty(jumpboxSubnet)) { - name: '${prefix}-jumpbox-subnet' - params: { - virtualNetworkName: virtualNetwork.outputs.name - name: jumpboxSubnet.name - addressPrefixes: jumpboxSubnet.addressPrefixes - networkSecurityGroupResourceId: jumpboxNsg.outputs.resourceId - } -} - -output jumpboxSubnetId string = avmJumpboxSubnet.outputs.resourceId -output jumpboxNsgId string = jumpboxNsg.outputs.resourceId - -output jumpboxNsgName string = jumpboxNsg.outputs.name -output jumpboxSubnetName string = avmJumpboxSubnet.outputs.name -output jumpboxSubnetAddressPrefixes array = avmJumpboxSubnet.outputs.addressPrefixes -output jumpboxSubnetNetworkSecurityGroupId string = jumpboxNsg.outputs.resourceId -output jumpboxSubnetNetworkSecurityGroupName string = jumpboxNsg.outputs.name - -// // Variables for dynamic Jumpbox subnet reference (must be after subnetIds output) -// var subnetNames = [for subnet in mySubnets: subnet.name] -// var jumpboxSubnetIndex = indexOf(subnetNames, 'jumpbox') - -module avmJumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (networkIsolation && jumpboxVM) { - name: '${prefix}-jbVM' - params: { - name: take('${prefix}-jbVm', 15) - vmSize: jumpboxVmSize - location: location - adminUsername: jumpboxAdminUser - adminPassword: jumpboxAdminPassword - tags: tags - zone: 2 - imageReference: { - offer: 'WindowsServer' - publisher: 'MicrosoftWindowsServer' - sku: '2019-datacenter' - version: 'latest' - } - osType: 'Windows' - osDisk: { - managedDisk: { - storageAccountType: 'Standard_LRS' - } - } - encryptionAtHost: false // Some Azure subscriptions do not support encryption at host - nicConfigurations: [ - { - name: 'nicJumpbox' - ipConfigurations: [ - { - name: 'ipconfig1' - subnetResourceId: avmJumpboxSubnet.outputs.resourceId - } - ] - networkSecurityGroupResourceId: jumpboxNsg.outputs.resourceId - diagnosticSettings: [ - { - name: 'jumpboxDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - metricCategories: [ - { - category: 'AllMetrics' - enabled: true - } - ] - } - ] - } - ] - } -} - -output jumpboxVMId string = avmJumpboxVM.outputs.resourceId -output jumpboxVMName string = avmJumpboxVM.outputs.name -output jumpboxVMLocation string = avmJumpboxVM.outputs.location - - -/****************************************************************************************************************************/ -// // Create Azure Bastion Subnet and Azure Bastion Host -/****************************************************************************************************************************/ -// 1. Create or reuse Azure Bastion Host Using AVM Subnet Module With special config for Azure Bastion Subnet -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet - -// Create Azure Bastion Subnet if azureBastionSubnet is not empty and networkIsolation is true -module avmAzureBastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { - name: '${prefix}-AzureBastionSubnet' - params: { - virtualNetworkName: virtualNetwork.outputs.name - name: azureBastionSubnet.name - addressPrefixes: azureBastionSubnet.addressPrefixes - } -} - -output azureBastionSubnetId string = avmAzureBastionSubnet.outputs.resourceId -output azureBastionSubnetName string = avmAzureBastionSubnet.outputs.name -output azureBastionSubnetAddressPrefixes array = avmAzureBastionSubnet.outputs.addressPrefixes - -// 2. Create Azure Bastion Host in AzureBastionSubnet using AVM Bastion Host module -// https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host - -module avmBastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (networkIsolation && azureBationHost && !empty(azureBastionSubnet)) { - name: '${prefix}-bastionhost' - params: { - name: '${prefix}-bastionhost' - skuName: 'Standard' - location: location - virtualNetworkResourceId: virtualNetwork.outputs.resourceId - diagnosticSettings: [ - { - name: 'bastionDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - } - ] - tags: tags - } -} - -output bastionHostId string = avmBastionHost.outputs.resourceId -output bastionHostName string = avmBastionHost.outputs.name -output bastionHostLocation string = avmBastionHost.outputs.location - diff --git a/infra/modules/network/network.bicep b/infra/modules/network/network.bicep index 3b114b6..b9488e0 100644 --- a/infra/modules/network/network.bicep +++ b/infra/modules/network/network.bicep @@ -16,7 +16,7 @@ param addressPrefixes array @description('Optional. Tags to be applied to the resources.') param tags object = {} -param mySubnets array +param solutionSubnets array var vnetName = 'vnet-${resourcesName}' @@ -44,7 +44,7 @@ module vnetWithSubnets 'vnetWithSubnets.bicep' = { params: { vnetName: vnetName vnetAddressPrefixes: addressPrefixes - subnetArray: mySubnets + subnetArray: solutionSubnets location: location tags: tags logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId From 38c58a8e51edd652b597569ba55d1143588f9e25 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Wed, 4 Jun 2025 12:32:55 -0400 Subject: [PATCH 037/124] deleted main_network.bicepparam --- infra/main_network.bicepparam | 188 ---------------------------------- 1 file changed, 188 deletions(-) delete mode 100644 infra/main_network.bicepparam diff --git a/infra/main_network.bicepparam b/infra/main_network.bicepparam deleted file mode 100644 index ab0b1d4..0000000 --- a/infra/main_network.bicepparam +++ /dev/null @@ -1,188 +0,0 @@ -// Parameters for main_network.bicep -// Use this file to provide default values for your network deployment - -using './main_network.bicep' - -param resourceGroupName = 'gaiye-avm-waf-02-rg' // Name of the resource group for the network resources -param location = 'eastus' - -param networkIsolation = true -param privateEndPoint = true - -//*************************************************************************************** -// Vnet and Solution Subnets with respective NSGs. i.g. web, app, ai, data, services -// Jumbox and Azure Bastion subnets are defined separately and optional. -//*************************************************************************************** - -param vnetAddressPrefixes = [ - '10.0.0.0/21' // /21: 2048 addresses, good for up to 8-16 subnets. Other options: /23:512, /22:1024, /21:2048, /20:4096, /16: 65,536 (max for a VNet) -] - -param mySubnets = [ - { - name: 'web' - addressPrefixes: ['10.0.0.0/24'] - networkSecurityGroup: { - name: 'web-nsg' - securityRules: [ - { - name: 'AllowHttpsInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/24'] - } - } - ] - } - } - { - name: 'app' - addressPrefixes: ['10.0.1.0/24'] - networkSecurityGroup: { - name: 'app-nsg' - securityRules: [ - { - name: 'AllowWebToApp' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet - destinationAddressPrefixes: ['10.0.1.0/24'] - } - } - ] - } - } - { - name: 'ai' - addressPrefixes: ['10.0.2.0/24'] - networkSecurityGroup: { - name: 'ai-nsg' - securityRules: [ - { - name: 'AllowAppToAI' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet - destinationAddressPrefixes: ['10.0.2.0/24'] - } - } - ] - } - } - { - name: 'data' - addressPrefixes: ['10.0.3.0/24'] - networkSecurityGroup: { - name: 'data-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToData' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet - ] - destinationAddressPrefixes: ['10.0.3.0/24'] - } - } - ] - } - } - { - name: 'services' - addressPrefixes: ['10.0.4.0/24'] - networkSecurityGroup: { - name: 'services-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToServices' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet - ] - destinationAddressPrefixes: ['10.0.4.0/24'] - } - } - ] - } - } -] - - -//*************************************************************************************** -// Azure Bastion parameters -// azureBationHost must be set to true to deploy Azure Bastion. -//*************************************************************************************** -param azureBationHost = true // Set to 'true' to deploy Azure Bastion, 'false' to skip it -param azureBastionSubnet = { - name: 'AzureBastionSubnet' // Required name for Azure Bastion - addressPrefixes: ['10.0.5.0/27'] - networkSecurityGroup: null // Must not have an NSG -} - -//*************************************************************************************** -// Jumpbox VM parameters -// jumpboxVM must be set to true to deploy a jumpbox VM. -//*************************************************************************************** -param jumpboxVM = true // Set to 'true' to deploy a jumpbox VM, 'false' to skip it -param jumpboxAdminUser = 'JumpboxAdminUser' // Admin user for the jumpbox VM -param jumpboxAdminPassword = 'JumpboxAdminP@ssw0rd1234!' // Password for the jumpbox VM admin user, must meet Azure password complexity requirements -param jumpboxVmSize = 'Standard_D2s_v3' // 'Standard_B2s' not good enough for WAF - -// replace the value for sourceAddressPrefixes with the IP addresses or ranges that should have access to the jumpbox VM. -param jumpboxSubnet = { - name: 'jumpbox' - addressPrefixes: ['10.0.6.0/24'] - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: [ - '10.0.5.0/27' // Azure Bastion subnet - ] - destinationAddressPrefixes: ['10.0.6.0/24'] - } - } - ] - } -} - From ffa2a80ec8a5b22e15826d97bafb8d41873d13ec Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Wed, 4 Jun 2025 16:20:22 -0400 Subject: [PATCH 038/124] added networkConfig and simplified main.bicep --- infra/main.bicep | 178 ++-------------- infra/main_network.bicep | 188 +++-------------- infra/main_network_complex.bicep | 220 ++++++++++++++++++++ infra/modules/network/networkConfig.bicep | 179 ++++++++++++++++ infra/modules/network/vnetWithSubnets.bicep | 1 + 5 files changed, 448 insertions(+), 318 deletions(-) create mode 100644 infra/main_network_complex.bicep create mode 100644 infra/modules/network/networkConfig.bicep diff --git a/infra/main.bicep b/infra/main.bicep index f2a0b8c..13f567f 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -113,171 +113,35 @@ module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (en } } +// Attention: Below two modules are intended to be used together. +// You need to edit and verity modules/network/networkConfig.bicep before using the modules below. +// // Otherwise, you will get the default configuration written in this file. + +module configNetwork 'modules/network/networkConfig.bicep' = if (enablePrivateNetworking) { + name: take('network-${resourcesName}-config', 64) + params: { + } +} module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { - name: take('network-${resourcesName}-deployment', 64) + name: take('network-${resourcesName}-create', 64) params: { - resourcesName: resourcesName + resourcesName: take('network-${resourcesName}', 15) logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId - addressPrefixes: ['10.0.0.0/21'] - solutionSubnets: [ - { - name: 'web' - addressPrefixes: ['10.0.0.0/24'] - networkSecurityGroup: { - name: 'web-nsg' - securityRules: [ - { - name: 'AllowHttpsInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/24'] - } - } - ] - } - } - { - name: 'app' - addressPrefixes: ['10.0.1.0/24'] - networkSecurityGroup: { - name: 'app-nsg' - securityRules: [ - { - name: 'AllowWebToApp' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet - destinationAddressPrefixes: ['10.0.1.0/24'] - } - } - ] - } - } - { - name: 'ai' - addressPrefixes: ['10.0.2.0/24'] - networkSecurityGroup: { - name: 'ai-nsg' - securityRules: [ - { - name: 'AllowAppToAI' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet - destinationAddressPrefixes: ['10.0.2.0/24'] - } - } - ] - } - } - { - name: 'data' - addressPrefixes: ['10.0.3.0/24'] - networkSecurityGroup: { - name: 'data-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToData' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet - ] - destinationAddressPrefixes: ['10.0.3.0/24'] - } - } - ] - } - } - { - name: 'services' - addressPrefixes: ['10.0.4.0/24'] - networkSecurityGroup: { - name: 'services-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToServices' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet - ] - destinationAddressPrefixes: ['10.0.4.0/24'] - } - } - ] - } - } - ] - azureBationHost: true - azureBastionSubnet: { - name: 'AzureBastionSubnet' // Required name for Azure Bastion - addressPrefixes: ['10.0.5.0/27'] - networkSecurityGroup: null // Must not have an NSG - } - jumpboxVM: true - jumpboxVmSize: 'Standard_D2s_v3' - jumpboxAdminUser: 'JumpboxAdminUser' - jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' - jumpboxSubnet: { - name: 'jumpbox' - addressPrefixes: ['10.0.6.0/24'] - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: [ - '10.0.5.0/27' // Azure Bastion subnet - ] - destinationAddressPrefixes: ['10.0.6.0/24'] - } - } - ] - } - } + addressPrefixes: configNetwork.outputs.networkConfig.addressPrefixes + solutionSubnets: configNetwork.outputs.networkConfig.solutionSubnets + azureBationHost: configNetwork.outputs.networkConfig.azureBationHost + azureBastionSubnet: configNetwork.outputs.networkConfig.azureBastionSubnet + jumpboxVM: configNetwork.outputs.networkConfig.jumpboxVM + jumpboxVmSize: configNetwork.outputs.networkConfig.jumpboxVmSize + jumpboxAdminUser: configNetwork.outputs.networkConfig.jumpboxAdminUser + jumpboxAdminPassword: configNetwork.outputs.networkConfig.jumpboxAdminPassword + jumpboxSubnet:configNetwork.outputs.networkConfig.jumpboxSubnet location: location tags: allTags } } + module storageAccount 'modules/storageAccount.bicep' = { name: take('storage-account-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson diff --git a/infra/main_network.bicep b/infra/main_network.bicep index f1eb432..21f6d57 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -7,18 +7,20 @@ param environmentName string = 'Code Mod Dev' @description('Azure region for all services.') param location string = resourceGroup().location - @description('Optional. Enable private networking for the resources. Set to true to enable private networking.') param enablePrivateNetworking bool = true - @description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') param enableMonitoring bool = true @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} -var resourcesName = trim(replace(replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) +var resourcesName = trim(replace( + replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''), '/', ''), + ' ', + '' +)) var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) var uniqueResourcesName = '${resourcesName}${resourcesToken}' @@ -39,166 +41,30 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 } } -module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { - name: take('network-${resourcesName}-deployment', 64) + +// Attention: Below two modules are intended to be used together. +// You need to edit and verity modules/network/networkConfig.bicep before using the modules below. +// // Otherwise, you will get the default configuration written in this file. + +module configNetwork 'modules/network/networkConfig.bicep' = if (enablePrivateNetworking) { + name: take('network-${resourcesName}-config', 64) + params: { + } +} +module createNetwork 'modules/network/network.bicep' = if (enablePrivateNetworking) { + name: take('network-${resourcesName}-create', 64) params: { - resourcesName: take('network-${resourcesName}',10) + resourcesName: take('network-${resourcesName}', 15) logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId - addressPrefixes: ['10.0.0.0/21'] - solutionSubnets: [ - { - name: 'web' - addressPrefixes: ['10.0.0.0/24'] - networkSecurityGroup: { - name: 'web-nsg' - securityRules: [ - { - name: 'AllowHttpsInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/24'] - } - } - ] - } - } - { - name: 'app' - addressPrefixes: ['10.0.1.0/24'] - networkSecurityGroup: { - name: 'app-nsg' - securityRules: [ - { - name: 'AllowWebToApp' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet - destinationAddressPrefixes: ['10.0.1.0/24'] - } - } - ] - } - } - { - name: 'ai' - addressPrefixes: ['10.0.2.0/24'] - networkSecurityGroup: { - name: 'ai-nsg' - securityRules: [ - { - name: 'AllowAppToAI' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet - destinationAddressPrefixes: ['10.0.2.0/24'] - } - } - ] - } - } - { - name: 'data' - addressPrefixes: ['10.0.3.0/24'] - networkSecurityGroup: { - name: 'data-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToData' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet - ] - destinationAddressPrefixes: ['10.0.3.0/24'] - } - } - ] - } - } - { - name: 'services' - addressPrefixes: ['10.0.4.0/24'] - networkSecurityGroup: { - name: 'services-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToServices' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet - ] - destinationAddressPrefixes: ['10.0.4.0/24'] - } - } - ] - } - } - ] - azureBationHost: true - azureBastionSubnet: { - name: 'AzureBastionSubnet' // Required name for Azure Bastion - addressPrefixes: ['10.0.5.0/27'] - networkSecurityGroup: null // Must not have an NSG - } - jumpboxVM: true - jumpboxVmSize: 'Standard_D2s_v3' - jumpboxAdminUser: 'JumpboxAdminUser' - jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' - jumpboxSubnet: { - name: 'jumpbox' - addressPrefixes: ['10.0.6.0/24'] - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: [ - '10.0.5.0/27' // Azure Bastion subnet - ] - destinationAddressPrefixes: ['10.0.6.0/24'] - } - } - ] - } - } + addressPrefixes: configNetwork.outputs.networkConfig.addressPrefixes + solutionSubnets: configNetwork.outputs.networkConfig.solutionSubnets + azureBationHost: configNetwork.outputs.networkConfig.azureBationHost + azureBastionSubnet: configNetwork.outputs.networkConfig.azureBastionSubnet + jumpboxVM: configNetwork.outputs.networkConfig.jumpboxVM + jumpboxVmSize: configNetwork.outputs.networkConfig.jumpboxVmSize + jumpboxAdminUser: configNetwork.outputs.networkConfig.jumpboxAdminUser + jumpboxAdminPassword: configNetwork.outputs.networkConfig.jumpboxAdminPassword + jumpboxSubnet:configNetwork.outputs.networkConfig.jumpboxSubnet location: location tags: allTags } diff --git a/infra/main_network_complex.bicep b/infra/main_network_complex.bicep new file mode 100644 index 0000000..5e59e2f --- /dev/null +++ b/infra/main_network_complex.bicep @@ -0,0 +1,220 @@ +@minLength(3) +@maxLength(20) +@description('A unique application/env name for all resources in this deployment. This should be 3-20 characters long') +param environmentName string = 'Code Mod Dev' + +@minLength(3) +@description('Azure region for all services.') +param location string = resourceGroup().location + + +@description('Optional. Enable private networking for the resources. Set to true to enable private networking.') +param enablePrivateNetworking bool = true + + +@description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') +param enableMonitoring bool = true + +@description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') +param tags object = {} + +var resourcesName = trim(replace(replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) +var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) +var uniqueResourcesName = '${resourcesName}${resourcesToken}' + +var defaultTags = { + 'azd-env-name': resourcesName +} +var allTags = union(defaultTags, tags) + +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring || enablePrivateNetworking) { + name: take('log-analytics-${resourcesName}-deployment', 64) + params: { + name: 'log-${resourcesName}' + location: location + skuName: 'PerGB2018' + dataRetention: 30 + diagnosticSettings: [{ useThisWorkspace: true }] + tags: allTags + } +} + +module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { + name: take('network-${resourcesName}-deployment', 64) + params: { + resourcesName: take('network-${resourcesName}',15) + logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId + addressPrefixes: ['10.0.0.0/21'] + solutionSubnets: [ + { + name: 'web' + addressPrefixes: ['10.0.0.0/24'] + networkSecurityGroup: { + name: 'web-nsg' + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/24'] + } + } + ] + } + delegations: [ // only one delegation per subnet is supported by AVM + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'app' + addressPrefixes: ['10.0.1.0/24'] + networkSecurityGroup: { + name: 'app-nsg' + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet + destinationAddressPrefixes: ['10.0.1.0/24'] + } + } + ] + } + delegations: [ // only one delegation per subnet is supported by AVM + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'ai' + addressPrefixes: ['10.0.2.0/24'] + networkSecurityGroup: { + name: 'ai-nsg' + securityRules: [ + { + name: 'AllowAppToAI' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet + destinationAddressPrefixes: ['10.0.2.0/24'] + } + } + ] + } + delegations: [] // only one delegation per subnet is supported by AVM + } + { + name: 'data' + addressPrefixes: ['10.0.3.0/24'] + networkSecurityGroup: { + name: 'data-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToData' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] + destinationAddressPrefixes: ['10.0.3.0/24'] + } + } + ] + } + delegations: [] // only one delegation per subnet is supported by AVM] + } + { + name: 'services' + addressPrefixes: ['10.0.4.0/24'] + networkSecurityGroup: { + name: 'services-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToServices' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] + destinationAddressPrefixes: ['10.0.4.0/24'] + } + } + ] + } + delegations: [] // only one delegation per subnet is supported by AVM] + } + ] + azureBationHost: true + azureBastionSubnet: { + name: 'AzureBastionSubnet' // Required name for Azure Bastion + addressPrefixes: ['10.0.5.0/27'] + networkSecurityGroup: null // Must not have an NSG + } + jumpboxVM: true + jumpboxVmSize: 'Standard_D2s_v3' + jumpboxAdminUser: 'JumpboxAdminUser' + jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' + jumpboxSubnet: { + name: 'jumpbox' + addressPrefixes: ['10.0.6.0/24'] + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.5.0/27' // Azure Bastion subnet + ] + destinationAddressPrefixes: ['10.0.6.0/24'] + } + } + ] + } + } + location: location + tags: allTags + } +} diff --git a/infra/modules/network/networkConfig.bicep b/infra/modules/network/networkConfig.bicep new file mode 100644 index 0000000..51c1f8a --- /dev/null +++ b/infra/modules/network/networkConfig.bicep @@ -0,0 +1,179 @@ +// This module defines the network configuration only. +// It does not create any resources. +// the output networkConfig object contains the network configuration details. + +var inputNetworkConfig object = { + addressPrefixes: ['10.0.0.0/21'] + solutionSubnets: [ + { + name: 'web' + addressPrefixes: ['10.0.0.0/24'] + networkSecurityGroup: { + name: 'web-nsg' + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/24'] + } + } + ] + } + delegations: [ // only one delegation per subnet is supported by AVM + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'app' + addressPrefixes: ['10.0.1.0/24'] + networkSecurityGroup: { + name: 'app-nsg' + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet + destinationAddressPrefixes: ['10.0.1.0/24'] + } + } + ] + } + delegations: [ // only one delegation per subnet is supported by AVM + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'ai' + addressPrefixes: ['10.0.2.0/24'] + networkSecurityGroup: { + name: 'ai-nsg' + securityRules: [ + { + name: 'AllowAppToAI' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet + destinationAddressPrefixes: ['10.0.2.0/24'] + } + } + ] + } + delegations: [] // only one delegation per subnet is supported by AVM + } + { + name: 'data' + addressPrefixes: ['10.0.3.0/24'] + networkSecurityGroup: { + name: 'data-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToData' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] + destinationAddressPrefixes: ['10.0.3.0/24'] + } + } + ] + } + delegations: [] // only one delegation per subnet is supported by AVM] + } + { + name: 'services' + addressPrefixes: ['10.0.4.0/24'] + networkSecurityGroup: { + name: 'services-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToServices' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/24' // web subnet + '10.0.1.0/24' // app subnet + '10.0.2.0/24' // ai subnet + ] + destinationAddressPrefixes: ['10.0.4.0/24'] + } + } + ] + } + delegations: [] // only one delegation per subnet is supported by AVM] + } + ] + azureBationHost: true + azureBastionSubnet: { + name: 'AzureBastionSubnet' // Required name for Azure Bastion + addressPrefixes: ['10.0.5.0/27'] + networkSecurityGroup: null // Must not have an NSG + } + jumpboxVM: true + jumpboxVmSize: 'Standard_D2s_v3' + jumpboxAdminUser: 'JumpboxAdminUser' + jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' + jumpboxSubnet: { + name: 'jumpbox' + addressPrefixes: ['10.0.6.0/24'] + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.5.0/27' // Azure Bastion subnet + ] + destinationAddressPrefixes: ['10.0.6.0/24'] + } + } + ] + } + } + } + + output networkConfig object = inputNetworkConfig + diff --git a/infra/modules/network/vnetWithSubnets.bicep b/infra/modules/network/vnetWithSubnets.bicep index 1378073..f2b4476 100644 --- a/infra/modules/network/vnetWithSubnets.bicep +++ b/infra/modules/network/vnetWithSubnets.bicep @@ -42,6 +42,7 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { name: subnet.name addressPrefixes: subnet.addressPrefixes networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null + delegation: !empty(subnet.delegations) ? subnet.delegations[0].serviceName : null // AVM module expects a single delegation per subnet } ] diagnosticSettings: [ From a6d937568212ce1266ed956a9dff4d67af9e7399 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Wed, 4 Jun 2025 16:33:05 -0400 Subject: [PATCH 039/124] comments updated only --- infra/modules/network/networkConfig.bicep | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/infra/modules/network/networkConfig.bicep b/infra/modules/network/networkConfig.bicep index 51c1f8a..4ee5ebc 100644 --- a/infra/modules/network/networkConfig.bicep +++ b/infra/modules/network/networkConfig.bicep @@ -1,10 +1,12 @@ -// This module defines the network configuration only. -// It does not create any resources. -// the output networkConfig object contains the network configuration details. +// This module defines the network configuration object only. +// It does NOT create any Azure resources. +// The output 'networkConfig' object is used as input for network deployment modules. var inputNetworkConfig object = { addressPrefixes: ['10.0.0.0/21'] solutionSubnets: [ + // Only one delegation per subnet is supported by the AVM module as of June 2025. + // For subnets that do not require delegation, leave the array empty. { name: 'web' addressPrefixes: ['10.0.0.0/24'] @@ -26,7 +28,7 @@ var inputNetworkConfig object = { } ] } - delegations: [ // only one delegation per subnet is supported by AVM + delegations: [ { name: 'containerapps-delegation' serviceName: 'Microsoft.App/environments' @@ -54,7 +56,7 @@ var inputNetworkConfig object = { } ] } - delegations: [ // only one delegation per subnet is supported by AVM + delegations: [ { name: 'containerapps-delegation' serviceName: 'Microsoft.App/environments' @@ -82,7 +84,7 @@ var inputNetworkConfig object = { } ] } - delegations: [] // only one delegation per subnet is supported by AVM + delegations: [] // No delegation required for this subnet. } { name: 'data' @@ -109,7 +111,7 @@ var inputNetworkConfig object = { } ] } - delegations: [] // only one delegation per subnet is supported by AVM] + delegations: [] // No delegation required for this subnet. } { name: 'services' @@ -136,16 +138,16 @@ var inputNetworkConfig object = { } ] } - delegations: [] // only one delegation per subnet is supported by AVM] + delegations: [] // No delegation required for this subnet. } ] - azureBationHost: true + azureBationHost: true // Set to true to enable Azure Bastion Host creation. azureBastionSubnet: { name: 'AzureBastionSubnet' // Required name for Azure Bastion addressPrefixes: ['10.0.5.0/27'] - networkSecurityGroup: null // Must not have an NSG + networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG } - jumpboxVM: true + jumpboxVM: true // Set to true to enable Jumpbox VM creation. jumpboxVmSize: 'Standard_D2s_v3' jumpboxAdminUser: 'JumpboxAdminUser' jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' From 97be4bfa64dcbcff0e631471d1c309b101c5c08e Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Wed, 4 Jun 2025 16:34:40 -0400 Subject: [PATCH 040/124] addtional comments --- infra/modules/network/networkConfig.bicep | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/modules/network/networkConfig.bicep b/infra/modules/network/networkConfig.bicep index 4ee5ebc..abacbff 100644 --- a/infra/modules/network/networkConfig.bicep +++ b/infra/modules/network/networkConfig.bicep @@ -167,7 +167,7 @@ var inputNetworkConfig object = { sourcePortRange: '*' destinationPortRange: '22' sourceAddressPrefixes: [ - '10.0.5.0/27' // Azure Bastion subnet + '10.0.5.0/27' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more ] destinationAddressPrefixes: ['10.0.6.0/24'] } From de02ef7721834d55e0a2df0723279948a23c6557 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Wed, 4 Jun 2025 16:37:51 -0400 Subject: [PATCH 041/124] make it consistent wit main.bicep --- infra/main_network.bicep | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 21f6d57..4004c2d 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -41,17 +41,19 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 } } - -// Attention: Below two modules are intended to be used together. -// You need to edit and verity modules/network/networkConfig.bicep before using the modules below. -// // Otherwise, you will get the default configuration written in this file. +// Attention: The two modules below are intended to be used together for network deployment. +// IMPORTANT: Edit and verify 'modules/network/networkConfig.bicep' before using these modules. +// If you do not customize 'networkConfig.bicep', the default configuration in that file will be used. +// +// 'configNetwork' outputs the network configuration object, which is then consumed by 'network'. +// This pattern separates configuration definition from resource creation for flexibility and reuse. module configNetwork 'modules/network/networkConfig.bicep' = if (enablePrivateNetworking) { name: take('network-${resourcesName}-config', 64) params: { } } -module createNetwork 'modules/network/network.bicep' = if (enablePrivateNetworking) { +module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { name: take('network-${resourcesName}-create', 64) params: { resourcesName: take('network-${resourcesName}', 15) From acd94a6e8a8eb131a97897988e3ef8061df157d6 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 5 Jun 2025 10:33:37 -0400 Subject: [PATCH 042/124] WAF - network module refactor --- infra/main.bicep | 33 ++++--------------- .../networkConfig.bicep => network.bicep} | 32 ++++++++++++++---- .../network/{network.bicep => main.bicep} | 0 3 files changed, 33 insertions(+), 32 deletions(-) rename infra/modules/{network/networkConfig.bicep => network.bicep} (82%) rename infra/modules/network/{network.bicep => main.bicep} (100%) diff --git a/infra/main.bicep b/infra/main.bicep index 13f567f..6fc6148 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -39,19 +39,19 @@ param azureAiServiceLocation string = location param capacity int = 5 @description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') -param enableMonitoring bool = false +param enableMonitoring bool = true @description('Enable scaling for the container apps. Defaults to false.') -param enableScaling bool = false +param enableScaling bool = true @description('Enable redundancy for applicable resources. Defaults to false.') -param enableRedundancy bool = false +param enableRedundancy bool = true @description('Optional. The secondary location for the Cosmos DB account if redundancy is enabled.') param secondaryLocation string? @description('Optional. Enable private networking for the resources. Set to true to enable private networking.') -param enablePrivateNetworking bool = false +param enablePrivateNetworking bool = true @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} @@ -113,35 +113,16 @@ module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (en } } -// Attention: Below two modules are intended to be used together. -// You need to edit and verity modules/network/networkConfig.bicep before using the modules below. -// // Otherwise, you will get the default configuration written in this file. - -module configNetwork 'modules/network/networkConfig.bicep' = if (enablePrivateNetworking) { - name: take('network-${resourcesName}-config', 64) +module network 'modules/network.bicep' = if (enablePrivateNetworking) { + name: take('network-${resourcesName}-deployment', 64) params: { - } -} -module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { - name: take('network-${resourcesName}-create', 64) - params: { - resourcesName: take('network-${resourcesName}', 15) + resourcesName: resourcesName logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId - addressPrefixes: configNetwork.outputs.networkConfig.addressPrefixes - solutionSubnets: configNetwork.outputs.networkConfig.solutionSubnets - azureBationHost: configNetwork.outputs.networkConfig.azureBationHost - azureBastionSubnet: configNetwork.outputs.networkConfig.azureBastionSubnet - jumpboxVM: configNetwork.outputs.networkConfig.jumpboxVM - jumpboxVmSize: configNetwork.outputs.networkConfig.jumpboxVmSize - jumpboxAdminUser: configNetwork.outputs.networkConfig.jumpboxAdminUser - jumpboxAdminPassword: configNetwork.outputs.networkConfig.jumpboxAdminPassword - jumpboxSubnet:configNetwork.outputs.networkConfig.jumpboxSubnet location: location tags: allTags } } - module storageAccount 'modules/storageAccount.bicep' = { name: take('storage-account-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson diff --git a/infra/modules/network/networkConfig.bicep b/infra/modules/network.bicep similarity index 82% rename from infra/modules/network/networkConfig.bicep rename to infra/modules/network.bicep index abacbff..10aa5a9 100644 --- a/infra/modules/network/networkConfig.bicep +++ b/infra/modules/network.bicep @@ -1,9 +1,16 @@ -// This module defines the network configuration object only. -// It does NOT create any Azure resources. -// The output 'networkConfig' object is used as input for network deployment modules. +param resourcesName string +param logAnalyticsWorkSpaceResourceId string +param location string +param tags object = {} -var inputNetworkConfig object = { - addressPrefixes: ['10.0.0.0/21'] +module network 'network/main.bicep' = { + name: take('network-${resourcesName}-create', 64) + params: { + resourcesName: resourcesName + location: location + logAnalyticsWorkSpaceResourceId: logAnalyticsWorkSpaceResourceId + tags: tags + addressPrefixes: ['10.0.0.0/21'] solutionSubnets: [ // Only one delegation per subnet is supported by the AVM module as of June 2025. // For subnets that do not require delegation, leave the array empty. @@ -176,6 +183,19 @@ var inputNetworkConfig object = { } } } +} - output networkConfig object = inputNetworkConfig +output vnetName string = network.outputs.vnetName +output vnetResourceId string = network.outputs.vnetResourceId +output subnets array = network.outputs.subnets // This one holds critical info for subnets, including NSGs + +output azureBastionSubnetId string = network.outputs.azureBastionSubnetId +output azureBastionSubnetName string = network.outputs.azureBastionSubnetName +output azureBastionHostId string = network.outputs.azureBastionHostId +output azureBastionHostName string = network.outputs.azureBastionHostName + +output jumpboxSubnetName string = network.outputs.jumpboxSubnetName +output jumpboxSubnetId string = network.outputs.jumpboxSubnetId +output jumpboxVmName string = network.outputs.jumpboxVmName +output jumpboxVmId string = network.outputs.jumpboxVmId diff --git a/infra/modules/network/network.bicep b/infra/modules/network/main.bicep similarity index 100% rename from infra/modules/network/network.bicep rename to infra/modules/network/main.bicep From ada77c9d2161a6ea07b2a84568a99d0cb7154b5b Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 5 Jun 2025 10:56:03 -0400 Subject: [PATCH 043/124] WAF - network refactoring. network subnet fixes. api key workaround WIP --- infra/main.bicepparam | 1 - infra/modules/aiServices.bicep | 2 +- infra/modules/network.bicep | 21 ++++---- ...zureBationHost.bicep => bastionHost.bicep} | 20 ++++---- ...{jumpboxWithSubnet.bicep => jumpbox.bicep} | 16 +++--- infra/modules/network/main.bicep | 50 +++++++++---------- ...WithSubnets.bicep => virtualNetwork.bicep} | 2 +- 7 files changed, 55 insertions(+), 57 deletions(-) rename infra/modules/network/{azureBationHost.bicep => bastionHost.bicep} (76%) rename infra/modules/network/{jumpboxWithSubnet.bicep => jumpbox.bicep} (90%) rename infra/modules/network/{vnetWithSubnets.bicep => virtualNetwork.bicep} (98%) diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 418c9c4..975fb96 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -4,4 +4,3 @@ param location = readEnvironmentVariable('AZURE_LOCATION','japaneast') param azureAiServiceLocation = location param environmentName = readEnvironmentVariable('AZURE_ENV_NAME','azdtemp') -param secondaryLocation = readEnvironmentVariable('AZURE_SECONDARY_LOCATION', 'swedencentral') diff --git a/infra/modules/aiServices.bicep b/infra/modules/aiServices.bicep index e79c95a..f070435 100644 --- a/infra/modules/aiServices.bicep +++ b/infra/modules/aiServices.bicep @@ -107,7 +107,7 @@ module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = } deployments: deployments customSubDomainName: name - disableLocalAuth: privateNetworking != null + disableLocalAuth: false // privateNetworking != null publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ { diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 10aa5a9..68d0fb6 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -10,13 +10,13 @@ module network 'network/main.bicep' = { location: location logAnalyticsWorkSpaceResourceId: logAnalyticsWorkSpaceResourceId tags: tags - addressPrefixes: ['10.0.0.0/21'] + addressPrefixes: ['10.0.0.0/23'] solutionSubnets: [ // Only one delegation per subnet is supported by the AVM module as of June 2025. // For subnets that do not require delegation, leave the array empty. { name: 'web' - addressPrefixes: ['10.0.0.0/24'] + addressPrefixes: ['10.0.0.0/23'] networkSecurityGroup: { name: 'web-nsg' securityRules: [ @@ -44,7 +44,7 @@ module network 'network/main.bicep' = { } { name: 'app' - addressPrefixes: ['10.0.1.0/24'] + addressPrefixes: ['10.0.1.0/23'] networkSecurityGroup: { name: 'app-nsg' securityRules: [ @@ -148,10 +148,9 @@ module network 'network/main.bicep' = { delegations: [] // No delegation required for this subnet. } ] - azureBationHost: true // Set to true to enable Azure Bastion Host creation. - azureBastionSubnet: { - name: 'AzureBastionSubnet' // Required name for Azure Bastion - addressPrefixes: ['10.0.5.0/27'] + enableBastionHost: true // Set to true to enable Azure Bastion Host creation. + bastionSubnet: { + addressPrefixes: ['10.0.5.0/23'] networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG } jumpboxVM: true // Set to true to enable Jumpbox VM creation. @@ -189,10 +188,10 @@ output vnetName string = network.outputs.vnetName output vnetResourceId string = network.outputs.vnetResourceId output subnets array = network.outputs.subnets // This one holds critical info for subnets, including NSGs -output azureBastionSubnetId string = network.outputs.azureBastionSubnetId -output azureBastionSubnetName string = network.outputs.azureBastionSubnetName -output azureBastionHostId string = network.outputs.azureBastionHostId -output azureBastionHostName string = network.outputs.azureBastionHostName +output bastionSubnetId string = network.outputs.bastionSubnetId +output bastionSubnetName string = network.outputs.bastionSubnetName +output bastionHostId string = network.outputs.bastionHostId +output bastionHostName string = network.outputs.bastionHostName output jumpboxSubnetName string = network.outputs.jumpboxSubnetName output jumpboxSubnetId string = network.outputs.jumpboxSubnetId diff --git a/infra/modules/network/azureBationHost.bicep b/infra/modules/network/bastionHost.bicep similarity index 76% rename from infra/modules/network/azureBationHost.bicep rename to infra/modules/network/bastionHost.bicep index 45ec79b..5dedd7b 100644 --- a/infra/modules/network/azureBationHost.bicep +++ b/infra/modules/network/bastionHost.bicep @@ -3,36 +3,36 @@ // /****************************************************************************************************************************/ -param azureBastionSubnet object = {} +param subnet object = {} param location string = resourceGroup().location param vnetName string param vnetId string // Resource ID of the Virtual Network -param azureBationHostName string = 'AzureBastionHost' // Default name for Azure Bastion Host +param name string = 'AzureBastionHost' // Default name for Azure Bastion Host param logAnalyticsWorkspaceId string param tags object = {} // 1. Create Azure Bastion Host using AVM Subnet Module with special config for Azure Bastion Subnet // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet -module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(azureBastionSubnet)) { - name: azureBastionSubnet.name +module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(subnet)) { + name: take('bastionSubnet-${vnetName}', 64) params: { virtualNetworkName: vnetName - name: azureBastionSubnet.name - addressPrefixes: azureBastionSubnet.addressPrefixes + name: 'AzureBastionSubnet' + addressPrefixes: subnet.addressPrefixes } } output bastionSubnetId string = bastionSubnet.outputs.resourceId output bastionSubnetName string = bastionSubnet.outputs.name -// 2. Create Azure Bastion Host in AzureBastionSubnet using AVM Bastion Host module +// 2. Create Azure Bastion Host in AzureBastionsubnetSubnet using AVM Bastion Host module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host -module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (!empty(azureBastionSubnet)) { - name: azureBationHostName +module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (!empty(subnet)) { + name: name params: { - name: azureBationHostName + name: name skuName: 'Standard' location: location virtualNetworkResourceId: vnetId diff --git a/infra/modules/network/jumpboxWithSubnet.bicep b/infra/modules/network/jumpbox.bicep similarity index 90% rename from infra/modules/network/jumpboxWithSubnet.bicep rename to infra/modules/network/jumpbox.bicep index 6b414ad..cd3af4b 100644 --- a/infra/modules/network/jumpboxWithSubnet.bicep +++ b/infra/modules/network/jumpbox.bicep @@ -40,11 +40,6 @@ module jbSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (! } } -output jumpboxSubnetId string = jbSubnet.outputs.resourceId -output jumpboxSubnetName string = jbSubnet.outputs.name -output jumpboxNsgId string = jbNsg.outputs.resourceId -output jumpboxNsgName string = jbNsg.outputs.name - // 3. Create Jumpbox VM // using AVM Virtual Machine module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/compute/virtual-machine @@ -105,6 +100,11 @@ module jbVm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { } } -output jumpboxVmId string = jbVm.outputs.resourceId -output jumpboxVmName string = jbVm.outputs.name -output jumpboxVMLocation string = jbVm.outputs.location +output vmId string = jbVm.outputs.resourceId +output vmName string = jbVm.outputs.name +output vMLocation string = jbVm.outputs.location + +output subnetId string = jbSubnet.outputs.resourceId +output subnetName string = jbSubnet.outputs.name +output nsgId string = jbNsg.outputs.resourceId +output nsgName string = jbNsg.outputs.name diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index b9488e0..e44a9d1 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -30,17 +30,17 @@ param jumpboxVmSize string = 'Standard_D2s_v3' var jumpboxVmName = 'jumpboxVM-${resourcesName}' // Azure Bastion Host parameters -param azureBationHost bool = false // set in .bicepparam file -param azureBastionSubnet object = {} // set in .bicepparam file -var azureBastionHostName = 'bastionHost-${resourcesName}' +param enableBastionHost bool = false // set in .bicepparam file +param bastionSubnet object = {} // set in .bicepparam file +var bastionHostName = 'bastionHost-${resourcesName}' // /****************************************************************************************************************************/ // Networking - NSGs, VNET and Subnets. Each subnet has its own NSG // /****************************************************************************************************************************/ -module vnetWithSubnets 'vnetWithSubnets.bicep' = { - name: '${resourcesName}-vnetWithSubnets' +module virtualNetwork 'virtualNetwork.bicep' = { + name: '${resourcesName}-virtualNetwork' params: { vnetName: vnetName vnetAddressPrefixes: addressPrefixes @@ -55,14 +55,14 @@ module vnetWithSubnets 'vnetWithSubnets.bicep' = { // // Create Azure Bastion Subnet and Azure Bastion Host // /****************************************************************************************************************************/ -module azureBastionHost 'azureBationHost.bicep' = if (azureBationHost && !empty(azureBastionSubnet)) { - name: '${resourcesName}-azureBastionHost' +module bastionHost 'bastionHost.bicep' = if (enableBastionHost && !empty(bastionSubnet)) { + name: '${resourcesName}-bastionHost' params: { - azureBastionSubnet: azureBastionSubnet + subnet: bastionSubnet location: location - vnetName: vnetWithSubnets.outputs.vnetName - vnetId: vnetWithSubnets.outputs.vnetResourceId - azureBationHostName: azureBastionHostName + vnetName: virtualNetwork.outputs.vnetName + vnetId: virtualNetwork.outputs.vnetResourceId + name: bastionHostName logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId tags: tags } @@ -72,12 +72,12 @@ module azureBastionHost 'azureBationHost.bicep' = if (azureBationHost && !empty( // // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM // /****************************************************************************************************************************/ -module jumpboxWithSubnet 'jumpboxWithSubnet.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { - name: '${resourcesName}-jumpboxWithSubnet' +module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { + name: '${resourcesName}-jumpbox' params: { vmName: jumpboxVmName location: location - vnetName: vnetWithSubnets.outputs.vnetName + vnetName: virtualNetwork.outputs.vnetName jumpboxVmSize: jumpboxVmSize jumpboxSubnet: jumpboxSubnet jumpboxAdminUser: jumpboxAdminUser @@ -88,18 +88,18 @@ module jumpboxWithSubnet 'jumpboxWithSubnet.bicep' = if (jumpboxVM && !empty(jum } -output vnetName string = vnetWithSubnets.outputs.vnetName -output vnetResourceId string = vnetWithSubnets.outputs.vnetResourceId -output subnets array = vnetWithSubnets.outputs.outputSubnetsArray // This one holds critical info for subnets, including NSGs +output vnetName string = virtualNetwork.outputs.vnetName +output vnetResourceId string = virtualNetwork.outputs.vnetResourceId +output subnets array = virtualNetwork.outputs.subnets // This one holds critical info for subnets, including NSGs -output azureBastionSubnetId string = azureBastionHost.outputs.bastionSubnetId -output azureBastionSubnetName string = azureBastionHost.outputs.bastionSubnetName -output azureBastionHostId string = azureBastionHost.outputs.bastionHostId -output azureBastionHostName string = azureBastionHost.outputs.bastionHostName +output bastionSubnetId string = bastionHost.outputs.bastionSubnetId +output bastionSubnetName string = bastionHost.outputs.bastionSubnetName +output bastionHostId string = bastionHost.outputs.bastionHostId +output bastionHostName string = bastionHost.outputs.bastionHostName -output jumpboxSubnetName string = jumpboxWithSubnet.outputs.jumpboxSubnetName -output jumpboxSubnetId string = jumpboxWithSubnet.outputs.jumpboxSubnetId -output jumpboxVmName string = jumpboxWithSubnet.outputs.jumpboxVmName -output jumpboxVmId string = jumpboxWithSubnet.outputs.jumpboxVmId +output jumpboxSubnetName string = jumpbox.outputs.subnetId +output jumpboxSubnetId string = jumpbox.outputs.subnetId +output jumpboxVmName string = jumpbox.outputs.vmName +output jumpboxVmId string = jumpbox.outputs.vmId diff --git a/infra/modules/network/vnetWithSubnets.bicep b/infra/modules/network/virtualNetwork.bicep similarity index 98% rename from infra/modules/network/vnetWithSubnets.bicep rename to infra/modules/network/virtualNetwork.bicep index f2b4476..2520d84 100644 --- a/infra/modules/network/vnetWithSubnets.bicep +++ b/infra/modules/network/virtualNetwork.bicep @@ -71,7 +71,7 @@ output vnetName string = virtualNetwork.outputs.name output vnetResourceId string = virtualNetwork.outputs.resourceId // combined output array that holds subnet details along with NSG information -output outputSubnetsArray array = [ +output subnets array = [ for (subnet, i) in subnetArray: { name: subnet.name resourceId: virtualNetwork.outputs.subnetResourceIds[i] From 76053b0350e4175e5396b4fa8a0af240ca79eecb Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 5 Jun 2025 11:19:51 -0400 Subject: [PATCH 044/124] WAF - network refactor and address updates --- infra/modules/network.bicep | 12 +++++----- infra/modules/network/bastionHost.bicep | 11 ++++----- infra/modules/network/main.bicep | 27 +++++++++++----------- infra/modules/network/virtualNetwork.bicep | 26 ++++++++++----------- 4 files changed, 36 insertions(+), 40 deletions(-) diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 68d0fb6..10f48a4 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -10,13 +10,13 @@ module network 'network/main.bicep' = { location: location logAnalyticsWorkSpaceResourceId: logAnalyticsWorkSpaceResourceId tags: tags - addressPrefixes: ['10.0.0.0/23'] - solutionSubnets: [ + addressPrefixes: ['10.0.0.0/8'] + subnets: [ // Only one delegation per subnet is supported by the AVM module as of June 2025. // For subnets that do not require delegation, leave the array empty. { name: 'web' - addressPrefixes: ['10.0.0.0/23'] + addressPrefixes: ['10.0.0.0/24'] networkSecurityGroup: { name: 'web-nsg' securityRules: [ @@ -44,7 +44,7 @@ module network 'network/main.bicep' = { } { name: 'app' - addressPrefixes: ['10.0.1.0/23'] + addressPrefixes: ['10.0.1.0/24'] networkSecurityGroup: { name: 'app-nsg' securityRules: [ @@ -150,7 +150,7 @@ module network 'network/main.bicep' = { ] enableBastionHost: true // Set to true to enable Azure Bastion Host creation. bastionSubnet: { - addressPrefixes: ['10.0.5.0/23'] + addressPrefixes: ['10.0.5.0/24'] networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG } jumpboxVM: true // Set to true to enable Jumpbox VM creation. @@ -173,7 +173,7 @@ module network 'network/main.bicep' = { sourcePortRange: '*' destinationPortRange: '22' sourceAddressPrefixes: [ - '10.0.5.0/27' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more + '10.0.5.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more ] destinationAddressPrefixes: ['10.0.6.0/24'] } diff --git a/infra/modules/network/bastionHost.bicep b/infra/modules/network/bastionHost.bicep index 5dedd7b..3e6e2ff 100644 --- a/infra/modules/network/bastionHost.bicep +++ b/infra/modules/network/bastionHost.bicep @@ -2,7 +2,6 @@ // Create Azure Bastion Subnet and Azure Bastion Host // /****************************************************************************************************************************/ - param subnet object = {} param location string = resourceGroup().location param vnetName string @@ -11,7 +10,6 @@ param name string = 'AzureBastionHost' // Default name for Azure Bastion Host param logAnalyticsWorkspaceId string param tags object = {} - // 1. Create Azure Bastion Host using AVM Subnet Module with special config for Azure Bastion Subnet // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(subnet)) { @@ -23,9 +21,6 @@ module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = } } -output bastionSubnetId string = bastionSubnet.outputs.resourceId -output bastionSubnetName string = bastionSubnet.outputs.name - // 2. Create Azure Bastion Host in AzureBastionsubnetSubnet using AVM Bastion Host module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host @@ -52,5 +47,7 @@ module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (!empty(s } } -output bastionHostId string = bastionHost.outputs.resourceId -output bastionHostName string = bastionHost.outputs.name +output resourceId string = bastionHost.outputs.resourceId +output name string = bastionHost.outputs.name +output subnetId string = bastionSubnet.outputs.resourceId +output subnetName string = bastionSubnet.outputs.name diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index e44a9d1..d11bcef 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -16,7 +16,7 @@ param addressPrefixes array @description('Optional. Tags to be applied to the resources.') param tags object = {} -param solutionSubnets array +param subnets array var vnetName = 'vnet-${resourcesName}' @@ -42,9 +42,9 @@ var bastionHostName = 'bastionHost-${resourcesName}' module virtualNetwork 'virtualNetwork.bicep' = { name: '${resourcesName}-virtualNetwork' params: { - vnetName: vnetName - vnetAddressPrefixes: addressPrefixes - subnetArray: solutionSubnets + name: vnetName + addressPrefixes: addressPrefixes + subnets: subnets location: location tags: tags logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId @@ -60,8 +60,8 @@ module bastionHost 'bastionHost.bicep' = if (enableBastionHost && !empty(bastion params: { subnet: bastionSubnet location: location - vnetName: virtualNetwork.outputs.vnetName - vnetId: virtualNetwork.outputs.vnetResourceId + vnetName: virtualNetwork.outputs.name + vnetId: virtualNetwork.outputs.resourceId name: bastionHostName logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId tags: tags @@ -77,7 +77,7 @@ module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { params: { vmName: jumpboxVmName location: location - vnetName: virtualNetwork.outputs.vnetName + vnetName: virtualNetwork.outputs.name jumpboxVmSize: jumpboxVmSize jumpboxSubnet: jumpboxSubnet jumpboxAdminUser: jumpboxAdminUser @@ -87,15 +87,14 @@ module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { } } - -output vnetName string = virtualNetwork.outputs.vnetName -output vnetResourceId string = virtualNetwork.outputs.vnetResourceId +output vnetName string = virtualNetwork.outputs.name +output vnetResourceId string = virtualNetwork.outputs.resourceId output subnets array = virtualNetwork.outputs.subnets // This one holds critical info for subnets, including NSGs -output bastionSubnetId string = bastionHost.outputs.bastionSubnetId -output bastionSubnetName string = bastionHost.outputs.bastionSubnetName -output bastionHostId string = bastionHost.outputs.bastionHostId -output bastionHostName string = bastionHost.outputs.bastionHostName +output bastionSubnetId string = bastionHost.outputs.subnetId +output bastionSubnetName string = bastionHost.outputs.subnetName +output bastionHostId string = bastionHost.outputs.resourceId +output bastionHostName string = bastionHost.outputs.name output jumpboxSubnetName string = jumpbox.outputs.subnetId output jumpboxSubnetId string = jumpbox.outputs.subnetId diff --git a/infra/modules/network/virtualNetwork.bicep b/infra/modules/network/virtualNetwork.bicep index 2520d84..c1c9ff1 100644 --- a/infra/modules/network/virtualNetwork.bicep +++ b/infra/modules/network/virtualNetwork.bicep @@ -3,9 +3,9 @@ /****************************************************************************************************************************/ param location string = resourceGroup().location -param vnetName string -param vnetAddressPrefixes array -param subnetArray array +param name string +param addressPrefixes array +param subnets array param tags object = {} param logAnalyticsWorkspaceId string @@ -16,10 +16,10 @@ param logAnalyticsWorkspaceId string @batchSize(1) module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [ - for (subnet, i) in subnetArray: if (!empty(subnet.networkSecurityGroup)) { - name: '${vnetName}-${subnet.networkSecurityGroup.name}' + for (subnet, i) in subnets: if (!empty(subnet.networkSecurityGroup)) { + name: take('${name}-${subnet.networkSecurityGroup.name}-networksecuritygroup', 64) params: { - name: '${vnetName}-${subnet.networkSecurityGroup.name}' + name: '${name}-${subnet.networkSecurityGroup.name}' location: location securityRules: subnet.networkSecurityGroup.securityRules tags: tags @@ -32,13 +32,13 @@ module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [ // 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: vnetName + name: take('${name}-virtualNetwork', 64) params: { - name: vnetName + name: name location: location - addressPrefixes: vnetAddressPrefixes + addressPrefixes: addressPrefixes subnets: [ - for (subnet, i) in subnetArray: { + for (subnet, i) in subnets: { name: subnet.name addressPrefixes: subnet.addressPrefixes networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null @@ -67,12 +67,12 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { } } -output vnetName string = virtualNetwork.outputs.name -output vnetResourceId string = virtualNetwork.outputs.resourceId +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 array = [ - for (subnet, i) in subnetArray: { + for (subnet, i) in subnets: { name: subnet.name resourceId: virtualNetwork.outputs.subnetResourceIds[i] nsgName: !empty(subnet.networkSecurityGroup) ? subnet.networkSecurityGroup.name : null From 129398e12a5cd8c8e7e85c0ebf1913248a72d678 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 5 Jun 2025 12:55:37 -0400 Subject: [PATCH 045/124] updated network design --- infra/main_network.bicep | 26 +--- infra/main_network_complex.bicep | 220 ------------------------------- infra/modules/network.bicep | 56 ++++---- 3 files changed, 36 insertions(+), 266 deletions(-) delete mode 100644 infra/main_network_complex.bicep diff --git a/infra/main_network.bicep b/infra/main_network.bicep index 4004c2d..f5df883 100644 --- a/infra/main_network.bicep +++ b/infra/main_network.bicep @@ -41,32 +41,12 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 } } -// Attention: The two modules below are intended to be used together for network deployment. -// IMPORTANT: Edit and verify 'modules/network/networkConfig.bicep' before using these modules. -// If you do not customize 'networkConfig.bicep', the default configuration in that file will be used. -// -// 'configNetwork' outputs the network configuration object, which is then consumed by 'network'. -// This pattern separates configuration definition from resource creation for flexibility and reuse. -module configNetwork 'modules/network/networkConfig.bicep' = if (enablePrivateNetworking) { - name: take('network-${resourcesName}-config', 64) +module network 'modules/network.bicep' = if (enablePrivateNetworking) { + name: take('network-${resourcesName}-deployment', 64) params: { - } -} -module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { - name: take('network-${resourcesName}-create', 64) - params: { - resourcesName: take('network-${resourcesName}', 15) + resourcesName: resourcesName logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId - addressPrefixes: configNetwork.outputs.networkConfig.addressPrefixes - solutionSubnets: configNetwork.outputs.networkConfig.solutionSubnets - azureBationHost: configNetwork.outputs.networkConfig.azureBationHost - azureBastionSubnet: configNetwork.outputs.networkConfig.azureBastionSubnet - jumpboxVM: configNetwork.outputs.networkConfig.jumpboxVM - jumpboxVmSize: configNetwork.outputs.networkConfig.jumpboxVmSize - jumpboxAdminUser: configNetwork.outputs.networkConfig.jumpboxAdminUser - jumpboxAdminPassword: configNetwork.outputs.networkConfig.jumpboxAdminPassword - jumpboxSubnet:configNetwork.outputs.networkConfig.jumpboxSubnet location: location tags: allTags } diff --git a/infra/main_network_complex.bicep b/infra/main_network_complex.bicep deleted file mode 100644 index 5e59e2f..0000000 --- a/infra/main_network_complex.bicep +++ /dev/null @@ -1,220 +0,0 @@ -@minLength(3) -@maxLength(20) -@description('A unique application/env name for all resources in this deployment. This should be 3-20 characters long') -param environmentName string = 'Code Mod Dev' - -@minLength(3) -@description('Azure region for all services.') -param location string = resourceGroup().location - - -@description('Optional. Enable private networking for the resources. Set to true to enable private networking.') -param enablePrivateNetworking bool = true - - -@description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') -param enableMonitoring bool = true - -@description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') -param tags object = {} - -var resourcesName = trim(replace(replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) -var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) -var uniqueResourcesName = '${resourcesName}${resourcesToken}' - -var defaultTags = { - 'azd-env-name': resourcesName -} -var allTags = union(defaultTags, tags) - -module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring || enablePrivateNetworking) { - name: take('log-analytics-${resourcesName}-deployment', 64) - params: { - name: 'log-${resourcesName}' - location: location - skuName: 'PerGB2018' - dataRetention: 30 - diagnosticSettings: [{ useThisWorkspace: true }] - tags: allTags - } -} - -module network 'modules/network/network.bicep' = if (enablePrivateNetworking) { - name: take('network-${resourcesName}-deployment', 64) - params: { - resourcesName: take('network-${resourcesName}',15) - logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId - addressPrefixes: ['10.0.0.0/21'] - solutionSubnets: [ - { - name: 'web' - addressPrefixes: ['10.0.0.0/24'] - networkSecurityGroup: { - name: 'web-nsg' - securityRules: [ - { - name: 'AllowHttpsInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/24'] - } - } - ] - } - delegations: [ // only one delegation per subnet is supported by AVM - { - name: 'containerapps-delegation' - serviceName: 'Microsoft.App/environments' - } - ] - } - { - name: 'app' - addressPrefixes: ['10.0.1.0/24'] - networkSecurityGroup: { - name: 'app-nsg' - securityRules: [ - { - name: 'AllowWebToApp' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet - destinationAddressPrefixes: ['10.0.1.0/24'] - } - } - ] - } - delegations: [ // only one delegation per subnet is supported by AVM - { - name: 'containerapps-delegation' - serviceName: 'Microsoft.App/environments' - } - ] - } - { - name: 'ai' - addressPrefixes: ['10.0.2.0/24'] - networkSecurityGroup: { - name: 'ai-nsg' - securityRules: [ - { - name: 'AllowAppToAI' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet - destinationAddressPrefixes: ['10.0.2.0/24'] - } - } - ] - } - delegations: [] // only one delegation per subnet is supported by AVM - } - { - name: 'data' - addressPrefixes: ['10.0.3.0/24'] - networkSecurityGroup: { - name: 'data-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToData' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet - ] - destinationAddressPrefixes: ['10.0.3.0/24'] - } - } - ] - } - delegations: [] // only one delegation per subnet is supported by AVM] - } - { - name: 'services' - addressPrefixes: ['10.0.4.0/24'] - networkSecurityGroup: { - name: 'services-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToServices' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet - ] - destinationAddressPrefixes: ['10.0.4.0/24'] - } - } - ] - } - delegations: [] // only one delegation per subnet is supported by AVM] - } - ] - azureBationHost: true - azureBastionSubnet: { - name: 'AzureBastionSubnet' // Required name for Azure Bastion - addressPrefixes: ['10.0.5.0/27'] - networkSecurityGroup: null // Must not have an NSG - } - jumpboxVM: true - jumpboxVmSize: 'Standard_D2s_v3' - jumpboxAdminUser: 'JumpboxAdminUser' - jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' - jumpboxSubnet: { - name: 'jumpbox' - addressPrefixes: ['10.0.6.0/24'] - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: [ - '10.0.5.0/27' // Azure Bastion subnet - ] - destinationAddressPrefixes: ['10.0.6.0/24'] - } - } - ] - } - } - location: location - tags: allTags - } -} diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 10f48a4..a877327 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -3,6 +3,16 @@ param logAnalyticsWorkSpaceResourceId string param location string param tags object = {} + +// The address prefixes for the subnets - use below CIDR as a reference +// /24 subnet = 256 addresses +// /20 = 4096 addresses (enough for 16 /24 subnets) +// /16 = 65,536 addresses (enough for 256 /24 subnets) +// /14 = 262,144 addresses (enough for 1024 /24 subnets) +// /13 = 524,288 addresses (enough for 2048 /24 subnets) +// /12 = 1,048,576 addresses (enough for 4096 /24 subnets) + + module network 'network/main.bicep' = { name: take('network-${resourcesName}-create', 64) params: { @@ -10,13 +20,13 @@ module network 'network/main.bicep' = { location: location logAnalyticsWorkSpaceResourceId: logAnalyticsWorkSpaceResourceId tags: tags - addressPrefixes: ['10.0.0.0/8'] + addressPrefixes: ['10.0.0.0/20'] // 4096 addresses (enough for 16 /24 subnets) subnets: [ // Only one delegation per subnet is supported by the AVM module as of June 2025. // For subnets that do not require delegation, leave the array empty. { name: 'web' - addressPrefixes: ['10.0.0.0/24'] + addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255) networkSecurityGroup: { name: 'web-nsg' securityRules: [ @@ -30,7 +40,7 @@ module network 'network/main.bicep' = { sourcePortRange: '*' destinationPortRange: '443' sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/24'] + destinationAddressPrefixes: ['10.0.0.0/23'] } } ] @@ -44,7 +54,7 @@ module network 'network/main.bicep' = { } { name: 'app' - addressPrefixes: ['10.0.1.0/24'] + addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255) networkSecurityGroup: { name: 'app-nsg' securityRules: [ @@ -57,8 +67,8 @@ module network 'network/main.bicep' = { protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/24'] // web subnet - destinationAddressPrefixes: ['10.0.1.0/24'] + sourceAddressPrefixes: ['10.0.0.0/23'] // web subnet + destinationAddressPrefixes: ['10.0.2.0/23'] } } ] @@ -72,7 +82,7 @@ module network 'network/main.bicep' = { } { name: 'ai' - addressPrefixes: ['10.0.2.0/24'] + addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255) networkSecurityGroup: { name: 'ai-nsg' securityRules: [ @@ -85,8 +95,8 @@ module network 'network/main.bicep' = { protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.1.0/24'] // app subnet - destinationAddressPrefixes: ['10.0.2.0/24'] + sourceAddressPrefixes: ['10.0.2.0/23'] // app subnet + destinationAddressPrefixes: ['10.0.4.0/23'] } } ] @@ -95,7 +105,7 @@ module network 'network/main.bicep' = { } { name: 'data' - addressPrefixes: ['10.0.3.0/24'] + addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255) networkSecurityGroup: { name: 'data-nsg' securityRules: [ @@ -109,11 +119,11 @@ module network 'network/main.bicep' = { sourcePortRange: '*' destinationPortRange: '*' sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + '10.0.4.0/23' // ai subnet ] - destinationAddressPrefixes: ['10.0.3.0/24'] + destinationAddressPrefixes: ['10.0.6.0/23'] } } ] @@ -122,7 +132,7 @@ module network 'network/main.bicep' = { } { name: 'services' - addressPrefixes: ['10.0.4.0/24'] + addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255) networkSecurityGroup: { name: 'services-nsg' securityRules: [ @@ -136,11 +146,11 @@ module network 'network/main.bicep' = { sourcePortRange: '*' destinationPortRange: '*' sourceAddressPrefixes: [ - '10.0.0.0/24' // web subnet - '10.0.1.0/24' // app subnet - '10.0.2.0/24' // ai subnet + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + '10.0.4.0/23' // ai subnet ] - destinationAddressPrefixes: ['10.0.4.0/24'] + destinationAddressPrefixes: ['10.0.8.0/23'] } } ] @@ -150,7 +160,7 @@ module network 'network/main.bicep' = { ] enableBastionHost: true // Set to true to enable Azure Bastion Host creation. bastionSubnet: { - addressPrefixes: ['10.0.5.0/24'] + addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255) networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG } jumpboxVM: true // Set to true to enable Jumpbox VM creation. @@ -159,7 +169,7 @@ module network 'network/main.bicep' = { jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' jumpboxSubnet: { name: 'jumpbox' - addressPrefixes: ['10.0.6.0/24'] + addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255) networkSecurityGroup: { name: 'jumpbox-nsg' securityRules: [ @@ -173,9 +183,9 @@ module network 'network/main.bicep' = { sourcePortRange: '*' destinationPortRange: '22' sourceAddressPrefixes: [ - '10.0.5.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more + '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more ] - destinationAddressPrefixes: ['10.0.6.0/24'] + destinationAddressPrefixes: ['10.0.12.0/23'] } } ] From da06db3901dcea31d28a17c75c0683e73ca4cd7e Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 5 Jun 2025 18:12:44 -0400 Subject: [PATCH 046/124] WAF - storage sku fix, app env workload profiles --- infra/main.bicep | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 6fc6148..3e43668 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -131,7 +131,7 @@ module storageAccount 'modules/storageAccount.bicep' = { name: take('st${uniqueResourcesName}', 24) location: location tags: allTags - skuName: enableRedundancy ? 'Standard_LRS' : 'Standard_GZRS' + skuName: enableRedundancy ? 'Standard_GZRS' : 'Standard_LRS' logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' privateNetworking: enablePrivateNetworking ? { virtualNetworkResourceId: network.outputs.vnetResourceId @@ -244,7 +244,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. #disable-next-line no-unnecessary-dependson dependsOn: [applicationInsights, logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { - name: 'cae-${resourcesName}' + name: 'cae-${resourcesName}${enablePrivateNetworking ? '-frontend' : ''}' location: location zoneRedundant: enableRedundancy && enablePrivateNetworking publicNetworkAccess: 'Enabled' @@ -262,6 +262,12 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. sharedKey: logAnalyticsWorkspace.outputs.primarySharedKey } } : {} + workloadProfiles: enablePrivateNetworking ? [ // NOTE: workload profiles are required for private networking + { + name: 'Consumption' + workloadProfileType: 'Consumption' + } + ] : [] tags: allTags } } @@ -321,7 +327,7 @@ module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environmen name: 'cae-${resourcesName}-backend' location: location zoneRedundant: enableRedundancy - publicNetworkAccess: 'Disabled' + publicNetworkAccess: 'Enabled' // TODO confirm this infrastructureSubnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'app')).resourceId managedIdentities: { userAssignedResourceIds: [ @@ -336,6 +342,12 @@ module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environmen sharedKey: logAnalyticsWorkspace.outputs.primarySharedKey } } : {} + workloadProfiles: [ // NOTE: workload profiles are required for private networking + { + name: 'Consumption' + workloadProfileType: 'Consumption' + } + ] tags: allTags } } From 73fece65b8dc96e5783964e4114df38417949dea Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 9 Jun 2025 10:02:56 -0400 Subject: [PATCH 047/124] WAF - azure.yaml cleanup, other minor cleanup --- azure.yaml | 20 -------------------- infra/main.bicep | 2 +- infra/modules/network/main.bicep | 4 +++- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/azure.yaml b/azure.yaml index 35deb7e..d0601fe 100644 --- a/azure.yaml +++ b/azure.yaml @@ -1,23 +1,3 @@ -environment: - name: modernize-your-code-solution-accelerator - location: eastus name: modernize-your-code-solution-accelerator metadata: template: modernize-your-code-solution-accelerator@1.0 -parameters: - AiLocation: - type: string - default: japaneast - ResourcePrefix: - type: string - default: bs-azdtest - baseUrl: - type: string - default: 'https://raw.githubusercontent.com/microsoft/Modernize-your-code-solution-accelerator' -deployment: - mode: Incremental - template: ./infra/main.bicep # Path to the main.bicep file inside the 'deployment' folder - prameters: - AiLocation: ${{ parameters.AiLocation }} - ResourcePrefix: ${{ parameters.ResourcePrefix }} - baseUrl: ${{ parameters.baseUrl }} diff --git a/infra/main.bicep b/infra/main.bicep index 3e43668..09ca0be 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -327,7 +327,7 @@ module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environmen name: 'cae-${resourcesName}-backend' location: location zoneRedundant: enableRedundancy - publicNetworkAccess: 'Enabled' // TODO confirm this + publicNetworkAccess: 'Enabled' // This most likely needs to remain public so the container app can be deployed infrastructureSubnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'app')).resourceId managedIdentities: { userAssignedResourceIds: [ diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index d11bcef..73a418d 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -13,10 +13,12 @@ param logAnalyticsWorkSpaceResourceId string @description('Networking address prefix for the VNET and subnets.') param addressPrefixes array +@description('Array of subnets to be created within the VNET.') +param subnets array + @description('Optional. Tags to be applied to the resources.') param tags object = {} -param subnets array var vnetName = 'vnet-${resourcesName}' From d76ce7b01181ddfc088c890f0c29299b5b3a9009 Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 9 Jun 2025 16:08:22 -0400 Subject: [PATCH 048/124] WAF - Foundry project RBAC updates and temp removed AI Services private networking --- infra/main.bicep | 70 +++++++++++++++++++++++++--------- infra/modules/aiFoundry.bicep | 28 ++++++-------- infra/modules/aiServices.bicep | 2 +- 3 files changed, 65 insertions(+), 35 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 09ca0be..c236245 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -81,10 +81,19 @@ var modelDeployment = { raiPolicyName: 'Microsoft.Default' } -module managedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { - name: take('identity-${resourcesName}-deployment', 64) +module appIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('identity-app-${resourcesName}-deployment', 64) params: { - name: 'id-${resourcesName}' + name: 'id-app-${resourcesName}' + location: location + tags: allTags + } +} + +module aiFoundryProjectIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('identity-proj-${resourcesName}-deployment', 64) + params: { + name: 'id-proj-${resourcesName}' location: location tags: allTags } @@ -147,7 +156,7 @@ module storageAccount 'modules/storageAccount.bicep' = { ] roleAssignments: [ { - principalId: managedIdentity.outputs.principalId + principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'Storage Blob Data Contributor' } @@ -166,13 +175,24 @@ module azureAiServices 'modules/aiServices.bicep' = { kind: 'AIServices' deployments: [modelDeployment] logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' - privateNetworking: enablePrivateNetworking ? { - virtualNetworkResourceId: network.outputs.vnetResourceId - subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'ai')).resourceId - } : null + // TODO - add back when container apps can properly access AI Services via Foundry Project over private endpoint + // Issue: When private endpoint is enabled for OpenAI, the container app cannot access the AI Services endpoint through the Foundry project connection string. + // Request: POST /api/start-processing + // Response: ERROR:sql_agents.agents.agent_base:Error creating agent definition: (403) Public access is disabled. Please configure private endpoint. + // --------------------- + // privateNetworking: enablePrivateNetworking ? { + // virtualNetworkResourceId: network.outputs.vnetResourceId + // subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'ai')).resourceId + // } : null + // --------------------- roleAssignments: [ { - principalId: managedIdentity.outputs.principalId + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: aiFoundryProjectIdentity.outputs.principalId principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' } @@ -194,6 +214,13 @@ module keyVault 'modules/keyVault.bicep' = { virtualNetworkResourceId: network.outputs.vnetResourceId subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'data')).resourceId } : null + roleAssignments: [ + { + principalId: aiFoundryProjectIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Key Vault Reader' + } + ] tags: allTags } } @@ -209,13 +236,20 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { projectName: 'proj-${resourcesName}' storageAccountResourceId: storageAccount.outputs.resourceId keyVaultResourceId: keyVault.outputs.resourceId - managedIdentityPrincpalId: managedIdentity.outputs.principalId + userAssignedIdentityResourceId: aiFoundryProjectIdentity.outputs.resourceId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' aiServicesName: azureAiServices.outputs.name privateNetworking: enablePrivateNetworking ? { virtualNetworkResourceId: network.outputs.vnetResourceId subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'ai')).resourceId - } : null + } : null + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + } + ] tags: allTags } } @@ -227,7 +261,7 @@ module cosmosDb 'modules/cosmosDb.bicep' = { params: { name: 'cosmos-${uniqueResourcesName}' location: location - managedIdentityPrincipalId: managedIdentity.outputs.principalId + managedIdentityPrincipalId: appIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' zoneRedundant: enableRedundancy secondaryLocation: enableRedundancy && !empty(secondaryLocation) ? secondaryLocation : '' @@ -251,7 +285,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. infrastructureSubnetResourceId: enablePrivateNetworking ? first(filter(network.outputs.subnets, s => s.name == 'web')).resourceId : null managedIdentities: { userAssignedResourceIds: [ - managedIdentity.outputs.resourceId + appIdentity.outputs.resourceId ] } appInsightsConnectionString: enableMonitoring ? applicationInsights.outputs.connectionString : null @@ -280,7 +314,7 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { userAssignedResourceIds: [ - managedIdentity.outputs.resourceId + appIdentity.outputs.resourceId ] } containers: [ @@ -327,11 +361,11 @@ module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environmen name: 'cae-${resourcesName}-backend' location: location zoneRedundant: enableRedundancy - publicNetworkAccess: 'Enabled' // This most likely needs to remain public so the container app can be deployed + publicNetworkAccess: 'Enabled' // TODO - verify -> This most likely needs to remain public so the container app can be deployed infrastructureSubnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'app')).resourceId managedIdentities: { userAssignedResourceIds: [ - managedIdentity.outputs.resourceId + appIdentity.outputs.resourceId ] } appInsightsConnectionString: enableMonitoring ? applicationInsights.outputs.connectionString : null @@ -364,7 +398,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { environmentResourceId: containerAppsEnvironmentResourceId managedIdentities: { userAssignedResourceIds: [ - managedIdentity.outputs.resourceId + appIdentity.outputs.resourceId ] } containers: [ @@ -454,7 +488,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { } { name: 'AZURE_CLIENT_ID' - value: managedIdentity.outputs.clientId // TODO - VERIFY -> NOTE: This is the client ID of the managed identity, not the Entra application, and is needed for the App Service to access the Cosmos DB account. + value: appIdentity.outputs.clientId // TODO - VERIFY -> NOTE: This is the client ID of the managed identity, not the Entra application, and is needed for the App Service to access the Cosmos DB account. } ], enableMonitoring ? [ { diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index aac0666..987d13d 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -16,8 +16,8 @@ param storageAccountResourceId string @description('The resource ID of the Azure Key Vault to associate with AI Foundry.') param keyVaultResourceId string -@description('The Princpal ID of the managed identity to assign access roles.') -param managedIdentityPrincpalId string +@description('The Resource ID of the managed identity to assign to the AI Foundry Project workspace.') +param userAssignedIdentityResourceId string @description('Optional. The resource ID of an existing Log Analytics workspace to associate with AI Foundry for monitoring.') param logAnalyticsWorkspaceResourceId string? @@ -31,6 +31,9 @@ param aiServicesName string @description('Optional. Values to establish private networking for the AI Foundry resources.') param privateNetworking machineLearningPrivateNetworkingType? +@description('Optional. Array of role assignments to create.') +param roleAssignments roleAssignmentType[]? + @description('Optional. Tags to be applied to the resources.') param tags object = {} @@ -67,8 +70,6 @@ resource aiServices 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = name: aiServicesName } -var aiServicesKey = aiServices.listKeys().key1 - module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { name: take('ai-foundry-${hubName}-deployment', 64) #disable-next-line no-unnecessary-dependson @@ -110,14 +111,12 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { category: 'AIServices' target: aiServices.properties.endpoint connectionProperties: { - authType: 'ApiKey' - credentials: { - key: aiServicesKey - } + authType: 'AAD' } isSharedToAll: true metadata: { ApiType: 'Azure' + Kind: 'AIServices' ResourceId: aiServices.id } } @@ -136,16 +135,11 @@ module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = hubResourceId: hub.outputs.resourceId publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' managedIdentities: { - systemAssigned: true + userAssignedResourceIds: [userAssignedIdentityResourceId] } + primaryUserAssignedIdentity: userAssignedIdentityResourceId diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] - roleAssignments: [ - { - principalId: managedIdentityPrincpalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer - } - ] + roleAssignments: roleAssignments tags: tags } } @@ -157,6 +151,8 @@ resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10- dependsOn: [project] } +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' + output projectName string = project.outputs.name output hubName string = hub.outputs.name output projectConnectionString string = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' diff --git a/infra/modules/aiServices.bicep b/infra/modules/aiServices.bicep index f070435..a9db7bb 100644 --- a/infra/modules/aiServices.bicep +++ b/infra/modules/aiServices.bicep @@ -107,7 +107,7 @@ module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = } deployments: deployments customSubDomainName: name - disableLocalAuth: false // privateNetworking != null + disableLocalAuth: false // TODO - verify if this should remain false or be set dynamically via privateNetworking publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ { From ec75c64d20f82832c3035c0f56a2b4becbe2bc6b Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 9 Jun 2025 21:48:43 -0400 Subject: [PATCH 049/124] updated security rules --- infra/modules/network.bicep | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index a877327..5ee3132 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -95,7 +95,10 @@ module network 'network/main.bicep' = { protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.2.0/23'] // app subnet + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + ] destinationAddressPrefixes: ['10.0.4.0/23'] } } From 695b86976adfb9679fe0b8e7f7376d6d5616b863 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 10 Jun 2025 10:52:12 -0400 Subject: [PATCH 050/124] WAF - minor infra cleanup --- infra/main.bicep | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index c236245..f74564a 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -56,17 +56,16 @@ param enablePrivateNetworking bool = true @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} +var allTags = union({ + 'azd-env-name': environmentName +}, tags) + var resourcesName = trim(replace(replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) var uniqueResourcesName = '${resourcesName}${resourcesToken}' var appStorageContainerName = 'appstorage' -var defaultTags = { - 'azd-env-name': resourcesName -} -var allTags = union(defaultTags, tags) - var modelDeployment = { name: 'gpt-4o' model: { @@ -170,7 +169,7 @@ module azureAiServices 'modules/aiServices.bicep' = { dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { name: 'ais-${uniqueResourcesName}' - location: location + location: azureAiServiceLocation sku: 'S0' kind: 'AIServices' deployments: [modelDeployment] From 021d92fabfc36c96f6b28944aac847274352b7fc Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 10 Jun 2025 11:23:42 -0400 Subject: [PATCH 051/124] Backend App Env and App public access disabled --- infra/main.bicep | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index c236245..95ef82d 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -361,7 +361,7 @@ module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environmen name: 'cae-${resourcesName}-backend' location: location zoneRedundant: enableRedundancy - publicNetworkAccess: 'Enabled' // TODO - verify -> This most likely needs to remain public so the container app can be deployed + publicNetworkAccess: 'Disabled' // 'Enabled' or 'Disabled', both tested. Container deployed in both cases. infrastructureSubnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'app')).resourceId managedIdentities: { userAssignedResourceIds: [ @@ -518,7 +518,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { } ] ingressTargetPort: 8000 - ingressExternal: true + ingressExternal: false // set to false to prevent public access scaleSettings: { maxReplicas: enableScaling ? 3 : 1 minReplicas: 1 From b0122a85012857e018b9619396b22a1070288e5f Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 10 Jun 2025 12:11:13 -0400 Subject: [PATCH 052/124] nsg rule name for ai subnet is updated --- infra/modules/network.bicep | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 5ee3132..656d810 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -87,7 +87,7 @@ module network 'network/main.bicep' = { name: 'ai-nsg' securityRules: [ { - name: 'AllowAppToAI' + name: 'AllowWebAppToAI' properties: { access: 'Allow' direction: 'Inbound' From 888173058c060d1e819db1cb87a96307d14ae360 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 10 Jun 2025 12:17:21 -0400 Subject: [PATCH 053/124] added nire comments --- infra/modules/network.bicep | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 656d810..3ecfaa4 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -6,8 +6,15 @@ param tags object = {} // The address prefixes for the subnets - use below CIDR as a reference // /24 subnet = 256 addresses -// /20 = 4096 addresses (enough for 16 /24 subnets) +// /23 = 512 addresses (enough for 2 /24 subnets) +// /22 = 1024 addresses (enough for 4 /24 subnets) +// /21 = 2048 addresses (enough for 8 /24 subnets) +// /20 = 4096 addresses (enough for 16 /24 subnets) // This was used for the default VNet address prefix +// /19 = 8192 addresses (enough for 32 /24 subnets) +// /18 = 16,384 addresses (enough for 64 /24 subnets) +// /17 = 32,768 addresses (enough for 128 /24 subnets) // /16 = 65,536 addresses (enough for 256 /24 subnets) +// /15 = 131,072 addresses (enough for 512 /24 subnets) // /14 = 262,144 addresses (enough for 1024 /24 subnets) // /13 = 524,288 addresses (enough for 2048 /24 subnets) // /12 = 1,048,576 addresses (enough for 4096 /24 subnets) @@ -20,13 +27,13 @@ module network 'network/main.bicep' = { location: location logAnalyticsWorkSpaceResourceId: logAnalyticsWorkSpaceResourceId tags: tags - addressPrefixes: ['10.0.0.0/20'] // 4096 addresses (enough for 16 /24 subnets) + addressPrefixes: ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24) subnets: [ // Only one delegation per subnet is supported by the AVM module as of June 2025. // For subnets that do not require delegation, leave the array empty. { name: 'web' - addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255) + addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses networkSecurityGroup: { name: 'web-nsg' securityRules: [ @@ -54,7 +61,7 @@ module network 'network/main.bicep' = { } { name: 'app' - addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255) + addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses networkSecurityGroup: { name: 'app-nsg' securityRules: [ @@ -82,7 +89,7 @@ module network 'network/main.bicep' = { } { name: 'ai' - addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255) + addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses networkSecurityGroup: { name: 'ai-nsg' securityRules: [ @@ -135,7 +142,7 @@ module network 'network/main.bicep' = { } { name: 'services' - addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255) + addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses networkSecurityGroup: { name: 'services-nsg' securityRules: [ @@ -163,7 +170,7 @@ module network 'network/main.bicep' = { ] enableBastionHost: true // Set to true to enable Azure Bastion Host creation. bastionSubnet: { - addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255) + addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG } jumpboxVM: true // Set to true to enable Jumpbox VM creation. @@ -172,7 +179,7 @@ module network 'network/main.bicep' = { jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' jumpboxSubnet: { name: 'jumpbox' - addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255) + addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses networkSecurityGroup: { name: 'jumpbox-nsg' securityRules: [ From bb26f00f5e1043859317f6a679dc67d6dc0d6c52 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 10 Jun 2025 12:36:03 -0400 Subject: [PATCH 054/124] updated network design --- infra/Deployment_Plan.md | 53 +++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/infra/Deployment_Plan.md b/infra/Deployment_Plan.md index 5413480..a345e88 100644 --- a/infra/Deployment_Plan.md +++ b/infra/Deployment_Plan.md @@ -1,4 +1,4 @@ -# Deployment Plan +# Deployment Plan The deployment code will be in the **infra** folder. The **modules** subfolder contains reusable, parameterized modules. @@ -9,11 +9,9 @@ Creates a Virtual Network with subnets, private endpoints for all solution resou If you are new to AVM BICEP implementation, refer to [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/). +## Network & Subnets for Components - -Below is an example deployment design. The Group (module) Name column shows how to group code in BICEP modules. - -**Solution Components and placements the Vnet/Subnets** +**Solution Components and placements in the Vnet/Subnets** | # | Component Name | Notes | Subnet | | ----------------------------------------- | ------------------------------------------------------------ | ----------------- | ----------------- | @@ -56,36 +54,32 @@ Below is an example deployment design. The Group (module) Name column shows how #### Network Design -addressPrefixes = [ - - '10.0.0.0/21' // /21: **2048 addresses, good for up to 8-16 subnets**. Other options: /23:512, /22:1024, /21:2048, /20:4096, /16: 65,536 (max for a VNet) +**addressPrefixes** = ['10.0.0.0/20' ] // 4096 addresses (enough for 8 /23 subnets or 16 /24) -] +512 x 7 = 3584 allocated -256 x 7 = 1792 allocated - -| Subnet | Address Prefix | IP Range | Total IPs | Usable IPs* | -| ----------- | -------------- | --------------------- | --------- | ----------- | -| web | 10.0.0.0/24 | 10.0.0.0 – 10.0.0.255 | 256 | 251 | -| app | 10.0.1.0/24 | 10.0.1.0 – 10.0.1.255 | 256 | 251 | -| ai | 10.0.2.0/24 | 10.0.2.0 – 10.0.2.255 | 256 | 251 | -| data | 10.0.3.0/24 | 10.0.3.0 – 10.0.3.255 | 256 | 251 | -| services | 10.0.4.0/24 | 10.0.4.0 – 10.0.4.255 | 256 | 251 | -| jumpbox | 10.0.5.0/24 | 10.0.5.0 – 10.0.5.255 | 256 | 251 | -| bastionHost | 10.0.6.0/27 | 10.0.6.0 – 10.0.6.255 | 256 | 251 | +| Subnet | Address Prefix | IP Range | Total IPs | Usable IPs* | +| ----------- | -------------- | ------------------------- | --------- | ----------- | +| web | 10.0.0.0/23 | (10.0.0.0 - 10.0.1.255) | 512 | 507 | +| app | 10.0.2.0/23 | (10.0.2.0 - 10.0.3.255) | 512 | 507 | +| ai | 10.0.4.0/23 | (10.0.4.0 - 10.0.5.255) | 512 | 507 | +| data | 10.0.6.0/23 | (10.0.6.0 - 10.0.7.255) | 512 | 507 | +| services | 10.0.8.0/23 | (10.0.8.0 - 10.0.9.255) | 512 | 507 | +| bastionHost | 10.0.10.0/23 | (10.0.10.0 - 10.0.11.255) | 512 | 507 | +| jumpbox | 10.0.12.0/23 | (10.0.12.0 - 10.0.13.255) | 512 | 507 | *Usable IPs = Total IPs minus 5 reserved by Azure per subnet. ### **Example Subnet/NSG Table** -| Subnet | NSG Rules (Inbound) | NSG Rules (Outbound) | -|----------|-----------------------------------------------------|-----------------------------| -| web | 80/443 from internet or allowed IPs | To app, internet | -| app | From web subnet only | To data, PaaS | -| ai | From app subnet only | To data, PaaS | -| data | From app/ai/private endpoints | To PaaS, as needed | -| jumpbox | RDP/SSH from allowed IPs or Bastion | To internet, as needed | -| bastion | Platform-managed (Bastion Host only, no direct NSG) | To VMs for RDP/SSH | +| Subnet | NSG Rules (Inbound) | NSG Rules (Outbound) | +| ------- | --------------------------------------------------- | ---------------------- | +| web | 80/443 from internet or allowed IPs | To app, internet | +| app | From web subnet only | To data, PaaS | +| ai | From web and app subnets only | To data, PaaS | +| data | From web/app/ai/private endpoints | To PaaS, as needed | +| bastion | Platform-managed (Bastion Host only, no direct NSG) | To VMs for RDP/SSH | +| jumpbox | RDP/SSH from allowed IPs or Bastion | To internet, as needed | ## **Monitoring & Logging** - Enable diagnostic logs for all resources, send to Log Analytics Workspace. @@ -94,9 +88,6 @@ addressPrefixes = [ ## **DDoS Protection** - Enable at the VNet level for public-facing workloads. -## **Route Tables (UDRs)** -- Optional, but recommended for advanced routing (e.g., force tunneling through Azure Firewall if used). - ## Azure Bastion Host **Function:** From 3fef520c9cd29fa6e295a4b7c1ad5e33ef1602c2 Mon Sep 17 00:00:00 2001 From: Seth Date: Tue, 10 Jun 2025 14:18:48 -0400 Subject: [PATCH 055/124] WAF - name adjustments for managed env resources --- infra/main.bicep | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index de02fa2..87db86a 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -272,15 +272,18 @@ module cosmosDb 'modules/cosmosDb.bicep' = { } } +var containerAppsEnvironmentName = 'cae-${resourcesName}${enablePrivateNetworking ? '-frontend' : ''}' + module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.2' = { name: take('container-env-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson dependsOn: [applicationInsights, logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { - name: 'cae-${resourcesName}${enablePrivateNetworking ? '-frontend' : ''}' + name: containerAppsEnvironmentName + infrastructureResourceGroupName: '${resourceGroup().name}-ME-${containerAppsEnvironmentName}' location: location zoneRedundant: enableRedundancy && enablePrivateNetworking - publicNetworkAccess: 'Enabled' + publicNetworkAccess: 'Enabled' // public access required for frontend (and backend if private networking is not enabled) infrastructureSubnetResourceId: enablePrivateNetworking ? first(filter(network.outputs.subnets, s => s.name == 'web')).resourceId : null managedIdentities: { userAssignedResourceIds: [ @@ -333,7 +336,7 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { } ] ingressTargetPort: 3000 - ingressExternal: true + ingressExternal: true // public access required for frontend scaleSettings: { maxReplicas: enableScaling ? 3 : 1 minReplicas: 1 @@ -352,15 +355,18 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { } } +var containerAppsEnvironmentBackendName = 'cae-${resourcesName}-backend' + module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environment:0.11.2' = if (enablePrivateNetworking) { name: take('container-env-backend-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson dependsOn: [applicationInsights, logAnalyticsWorkspace] // required due to optional flags that could change dependency params: { - name: 'cae-${resourcesName}-backend' + name: containerAppsEnvironmentBackendName + infrastructureResourceGroupName: '${resourceGroup().name}-ME-${containerAppsEnvironmentBackendName}' location: location zoneRedundant: enableRedundancy - publicNetworkAccess: 'Disabled' // 'Enabled' or 'Disabled', both tested. Container deployed in both cases. + publicNetworkAccess: 'Disabled' // public access denied for backend infrastructureSubnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'app')).resourceId managedIdentities: { userAssignedResourceIds: [ From 885201b0ce1b5fa99fe5432c237202e5291e56af Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 10 Jun 2025 17:33:16 -0400 Subject: [PATCH 056/124] updated CIDR comments --- infra/modules/network.bicep | 44 ++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 3ecfaa4..4f53b8c 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -4,21 +4,35 @@ param location string param tags object = {} -// The address prefixes for the subnets - use below CIDR as a reference -// /24 subnet = 256 addresses -// /23 = 512 addresses (enough for 2 /24 subnets) -// /22 = 1024 addresses (enough for 4 /24 subnets) -// /21 = 2048 addresses (enough for 8 /24 subnets) -// /20 = 4096 addresses (enough for 16 /24 subnets) // This was used for the default VNet address prefix -// /19 = 8192 addresses (enough for 32 /24 subnets) -// /18 = 16,384 addresses (enough for 64 /24 subnets) -// /17 = 32,768 addresses (enough for 128 /24 subnets) -// /16 = 65,536 addresses (enough for 256 /24 subnets) -// /15 = 131,072 addresses (enough for 512 /24 subnets) -// /14 = 262,144 addresses (enough for 1024 /24 subnets) -// /13 = 524,288 addresses (enough for 2048 /24 subnets) -// /12 = 1,048,576 addresses (enough for 4096 /24 subnets) - +// 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 | Good for larger scale, future growth | +// | /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. +// module network 'network/main.bicep' = { name: take('network-${resourcesName}-create', 64) From 71f3fe6b9c6c5dde02699389e3b00bb0b19aa4a7 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Tue, 10 Jun 2025 17:35:05 -0400 Subject: [PATCH 057/124] updated CIDR table --- infra/modules/network.bicep | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 4f53b8c..0519b85 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -6,24 +6,24 @@ param tags object = {} // 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 | Good for larger scale, future growth | +// | /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 | | +// | /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). From 19467206efbec7069030c77299cb805f3ec15ecc Mon Sep 17 00:00:00 2001 From: Seth Date: Wed, 11 Jun 2025 13:10:35 -0400 Subject: [PATCH 058/124] WAF - network role update and param cleanup --- infra/main.bicep | 99 +++++++++++++++-------------------- infra/main.bicepparam | 6 +-- infra/modules/aiFoundry.bicep | 17 ++++++ infra/modules/cosmosDb.bicep | 9 +++- 4 files changed, 67 insertions(+), 64 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 87db86a..13ad238 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -39,19 +39,19 @@ param azureAiServiceLocation string = location param capacity int = 5 @description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') -param enableMonitoring bool = true +param enableMonitoring bool = false @description('Enable scaling for the container apps. Defaults to false.') -param enableScaling bool = true +param enableScaling bool = false @description('Enable redundancy for applicable resources. Defaults to false.') -param enableRedundancy bool = true +param enableRedundancy bool = false -@description('Optional. The secondary location for the Cosmos DB account if redundancy is enabled.') +@description('Optional. The secondary location for the Cosmos DB account if redundancy is enabled. Defaults to false.') param secondaryLocation string? -@description('Optional. Enable private networking for the resources. Set to true to enable private networking.') -param enablePrivateNetworking bool = true +@description('Optional. Enable private networking for the resources. Set to true to enable private networking. Defaults to false.') +param enablePrivateNetworking bool = false @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} @@ -89,7 +89,7 @@ module appIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0. } } -module aiFoundryProjectIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { +module aiFoundryIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { name: take('identity-proj-${resourcesName}-deployment', 64) params: { name: 'id-proj-${resourcesName}' @@ -98,12 +98,20 @@ module aiFoundryProjectIdentity 'br/public:avm/res/managed-identity/user-assigne } } +// used for foundry to create and approve managed virtual network and approve private endpoint connections +// Ref: https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-managed-network?tabs=portal#approval-of-private-endpoints +var foundryNetworkConnectionApproverRoleAssignment = { + principalId: aiFoundryIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'b556d68e-0be0-4f35-a333-ad7ee1ce17ea' // Azure AI Enterprise Network Connection Approver +} + module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring || enablePrivateNetworking) { name: take('log-analytics-${resourcesName}-deployment', 64) params: { name: 'log-${resourcesName}' location: location - skuName: 'PerGB2018' + skuName: 'PerGB2018' dataRetention: 30 diagnosticSettings: [{ useThisWorkspace: true }] tags: allTags @@ -159,6 +167,7 @@ module storageAccount 'modules/storageAccount.bicep' = { principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'Storage Blob Data Contributor' } + foundryNetworkConnectionApproverRoleAssignment ] } } @@ -191,10 +200,11 @@ module azureAiServices 'modules/aiServices.bicep' = { roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' } { - principalId: aiFoundryProjectIdentity.outputs.principalId + principalId: aiFoundryIdentity.outputs.principalId principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' } + foundryNetworkConnectionApproverRoleAssignment ] tags: allTags } @@ -215,10 +225,11 @@ module keyVault 'modules/keyVault.bicep' = { } : null roleAssignments: [ { - principalId: aiFoundryProjectIdentity.outputs.principalId + principalId: aiFoundryIdentity.outputs.principalId principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'Key Vault Reader' } + foundryNetworkConnectionApproverRoleAssignment ] tags: allTags } @@ -235,7 +246,7 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { projectName: 'proj-${resourcesName}' storageAccountResourceId: storageAccount.outputs.resourceId keyVaultResourceId: keyVault.outputs.resourceId - userAssignedIdentityResourceId: aiFoundryProjectIdentity.outputs.resourceId + userAssignedIdentityResourceId: aiFoundryIdentity.outputs.resourceId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' aiServicesName: azureAiServices.outputs.name privateNetworking: enablePrivateNetworking ? { @@ -260,7 +271,7 @@ module cosmosDb 'modules/cosmosDb.bicep' = { params: { name: 'cosmos-${uniqueResourcesName}' location: location - managedIdentityPrincipalId: appIdentity.outputs.principalId + dataAccessIdentityPrincipalId: appIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' zoneRedundant: enableRedundancy secondaryLocation: enableRedundancy && !empty(secondaryLocation) ? secondaryLocation : '' @@ -268,11 +279,14 @@ module cosmosDb 'modules/cosmosDb.bicep' = { virtualNetworkResourceId: network.outputs.vnetResourceId subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'data')).resourceId } : null + roleAssignments: [ + foundryNetworkConnectionApproverRoleAssignment + ] tags: allTags } } -var containerAppsEnvironmentName = 'cae-${resourcesName}${enablePrivateNetworking ? '-frontend' : ''}' +var containerAppsEnvironmentName = 'cae-${resourcesName}' module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.2' = { name: take('container-env-${resourcesName}-deployment', 64) @@ -283,7 +297,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. infrastructureResourceGroupName: '${resourceGroup().name}-ME-${containerAppsEnvironmentName}' location: location zoneRedundant: enableRedundancy && enablePrivateNetworking - publicNetworkAccess: 'Enabled' // public access required for frontend (and backend if private networking is not enabled) + publicNetworkAccess: 'Enabled' // public access required for frontend infrastructureSubnetResourceId: enablePrivateNetworking ? first(filter(network.outputs.subnets, s => s.name == 'web')).resourceId : null managedIdentities: { userAssignedResourceIds: [ @@ -308,7 +322,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. } } -module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { +module containerAppFrontend 'br/public:avm/res/app/container-app:0.17.0' = { name: take('container-app-frontend-${resourcesName}-deployment', 64) params: { name: take('ca-${uniqueResourcesName}frontend', 32) @@ -336,7 +350,7 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { } ] ingressTargetPort: 3000 - ingressExternal: true // public access required for frontend + ingressExternal: true scaleSettings: { maxReplicas: enableScaling ? 3 : 1 minReplicas: 1 @@ -355,52 +369,14 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.16.0' = { } } -var containerAppsEnvironmentBackendName = 'cae-${resourcesName}-backend' - -module containerAppsEnvironmentBackend 'br/public:avm/res/app/managed-environment:0.11.2' = if (enablePrivateNetworking) { - name: take('container-env-backend-${resourcesName}-deployment', 64) - #disable-next-line no-unnecessary-dependson - dependsOn: [applicationInsights, logAnalyticsWorkspace] // required due to optional flags that could change dependency - params: { - name: containerAppsEnvironmentBackendName - infrastructureResourceGroupName: '${resourceGroup().name}-ME-${containerAppsEnvironmentBackendName}' - location: location - zoneRedundant: enableRedundancy - publicNetworkAccess: 'Disabled' // public access denied for backend - infrastructureSubnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'app')).resourceId - managedIdentities: { - userAssignedResourceIds: [ - appIdentity.outputs.resourceId - ] - } - appInsightsConnectionString: enableMonitoring ? applicationInsights.outputs.connectionString : null - appLogsConfiguration: enableMonitoring ? { - destination: 'log-analytics' - logAnalyticsConfiguration: { - customerId: logAnalyticsWorkspace.outputs.logAnalyticsWorkspaceId - sharedKey: logAnalyticsWorkspace.outputs.primarySharedKey - } - } : {} - workloadProfiles: [ // NOTE: workload profiles are required for private networking - { - name: 'Consumption' - workloadProfileType: 'Consumption' - } - ] - tags: allTags - } -} - -var containerAppsEnvironmentResourceId = enablePrivateNetworking ? containerAppsEnvironmentBackend.outputs.resourceId : containerAppsEnvironment.outputs.resourceId - -module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { +module containerAppBackend 'br/public:avm/res/app/container-app:0.17.0' = { name: take('container-app-backend-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson - dependsOn: [applicationInsights, containerAppsEnvironmentBackend] // required due to optional flags that could change dependency + dependsOn: [applicationInsights] // required due to optional flags that could change dependency params: { name: take('ca-${uniqueResourcesName}backend', 32) location: location - environmentResourceId: containerAppsEnvironmentResourceId + environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { userAssignedResourceIds: [ appIdentity.outputs.resourceId @@ -523,7 +499,13 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { } ] ingressTargetPort: 8000 - ingressExternal: false // set to false to prevent public access + ingressExternal: true + // TODO - need way to set this CORS policy after frontend container app is deployed (issue is circular dependency since frontend needs backend to be deployed first) + // corsPolicy: { + // allowedOrigins: [ + // 'https://${containerAppFrontend.outputs.fqdn}' + // ] + // } scaleSettings: { maxReplicas: enableScaling ? 3 : 1 minReplicas: 1 @@ -541,3 +523,4 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.16.0' = { tags: allTags } } + diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 975fb96..5ac276b 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -1,6 +1,4 @@ using './main.bicep' -param location = readEnvironmentVariable('AZURE_LOCATION','japaneast') -param azureAiServiceLocation = location -param environmentName = readEnvironmentVariable('AZURE_ENV_NAME','azdtemp') - +param environmentName = readEnvironmentVariable('AZURE_ENV_NAME') +param location = readEnvironmentVariable('AZURE_LOCATION') diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index 987d13d..890fccd 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -84,9 +84,24 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { associatedStorageAccountResourceId: storageAccountResourceId associatedApplicationInsightsResourceId: applicationInsightsResourceId publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' + managedNetworkSettings: { + isolationMode: privateNetworking != null ? 'AllowInternetOutbound' : 'Disabled' + outboundRules: { + cog_services_pep: { + category: 'UserDefined' + destination: { + serviceResourceId: aiServices.id + sparkEnabled: true + subresourceTarget: 'account' + } + type: 'PrivateEndpoint' + } + } + } managedIdentities: { systemAssigned: true } + systemDatastoresAuthMode: 'Identity' diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] privateEndpoints: privateNetworking != null ? [ { @@ -133,6 +148,8 @@ module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = sku: 'Standard' location: location hubResourceId: hub.outputs.resourceId + hbiWorkspace: false + systemDatastoresAuthMode: 'Identity' publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' managedIdentities: { userAssignedResourceIds: [userAssignedIdentityResourceId] diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep index fb95a95..7fa4dad 100644 --- a/infra/modules/cosmosDb.bicep +++ b/infra/modules/cosmosDb.bicep @@ -8,7 +8,7 @@ param location string param tags object = {} @description('Managed Identity princpial to assign data plane roles for the Cosmos DB Account.') -param managedIdentityPrincipalId string +param dataAccessIdentityPrincipalId string @description('Optional. The resource ID of an existing Log Analytics workspace to associate with AI Foundry for monitoring.') param logAnalyticsWorkspaceResourceId string? @@ -22,6 +22,9 @@ param secondaryLocation string? @description('Optional. Values to establish private networking for the Cosmos DB resource.') param privateNetworking resourcePrivateNetworkingType? +@description('Optional. Array of role assignments to create.') +param roleAssignments roleAssignmentType[]? + module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { name: take('${name}-documents-pdns-deployment', 64) params: { @@ -131,15 +134,17 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { ] dataPlaneRoleAssignments: [ { - principalId: managedIdentityPrincipalId + principalId: dataAccessIdentityPrincipalId roleDefinitionId: sqlContributorRoleDefinition.id } ] + roleAssignments: roleAssignments tags: tags } } import { resourcePrivateNetworkingType } from 'customTypes.bicep' +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' output resourceId string = cosmosAccount.outputs.resourceId output name string = cosmosAccount.outputs.name From 602fd5b40d7f6a4f7ce52b30d9a1fce2766e2adb Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 13:55:45 -0400 Subject: [PATCH 059/124] comments updated --- infra/modules/network/main.bicep | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index 73a418d..7dafc98 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -10,7 +10,7 @@ param location string @description('Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') param logAnalyticsWorkSpaceResourceId string -@description('Networking address prefix for the VNET and subnets.') +@description('Networking address prefix for the VNET only.') param addressPrefixes array @description('Array of subnets to be created within the VNET.') @@ -23,17 +23,17 @@ param tags object = {} var vnetName = 'vnet-${resourcesName}' // jumpbox parameters -param jumpboxVM bool = false // set in .bicepparam file -param jumpboxSubnet object = {} // set in .bicepparam file -param jumpboxAdminUser string = 'JumpboxAdminUser' // set in .bicepparam file +param jumpboxVM bool = false +param jumpboxSubnet object = {} +param jumpboxAdminUser string = 'JumpboxAdminUser' @secure() -param jumpboxAdminPassword string // set in .bicepparam file +param jumpboxAdminPassword string param jumpboxVmSize string = 'Standard_D2s_v3' var jumpboxVmName = 'jumpboxVM-${resourcesName}' // Azure Bastion Host parameters -param enableBastionHost bool = false // set in .bicepparam file -param bastionSubnet object = {} // set in .bicepparam file +param enableBastionHost bool = true +param bastionSubnet object = {} var bastionHostName = 'bastionHost-${resourcesName}' From 785bc804d3dadf41318891ff3114667a40fb0f6c Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 13:56:18 -0400 Subject: [PATCH 060/124] added for testing network modules --- .../network/test_network_modules.bicep | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 infra/modules/network/test_network_modules.bicep diff --git a/infra/modules/network/test_network_modules.bicep b/infra/modules/network/test_network_modules.bicep new file mode 100644 index 0000000..6e3d354 --- /dev/null +++ b/infra/modules/network/test_network_modules.bicep @@ -0,0 +1,292 @@ +// /****************************************************************************************************************************/ +// This is an example test program to create private networking resources independently to show the usage of the modules +// with sample inputs. +// +// Next Steps: +// Review infra/main.bicep and infra/modules/network.bicep for intended usage of the modules +// Please infra/modules/network.bicep on how to customize the networking resources for your application. +// +// /****************************************************************************************************************************/ + +@minLength(6) +@maxLength(25) +@description('Default name used for all resources.') +param resourcesName string = 'testNetwork' + +@minLength(3) +@description('Azure region for all services.') +param location string = 'eastus' + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +var vnetName = 'vnet-${resourcesName}' +@description('Networking address prefix for the VNET only') +param addressPrefixes array = ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24 subnets) + +param enableBastionHost bool = true +var bastionHostName = 'bastionHost-${resourcesName}' + +param jumpboxVM bool = true +param jumpboxAdminUser string = 'JumpboxAdminUser' +@secure() +param jumpboxAdminPassword string = 'JumpboxAdminP@ssw0rd1234!' +param jumpboxVmSize string = 'Standard_D2s_v3' +var jumpboxVmName = 'jumpboxVM-${resourcesName}' + +@description('Array of subnets to be created within the VNET.') +param subnets array = [ + // Only one delegation per subnet is supported by the AVM module as of June 2025. + // For subnets that do not require delegation, leave the array empty. + { + name: 'web' + addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses + networkSecurityGroup: { + name: 'web-nsg' + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/23'] + } + } + ] + } + delegations: [ + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'app' + addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses + networkSecurityGroup: { + name: 'app-nsg' + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/23'] // web subnet + destinationAddressPrefixes: ['10.0.2.0/23'] + } + } + ] + } + delegations: [ + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'ai' + addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses + networkSecurityGroup: { + name: 'ai-nsg' + securityRules: [ + { + name: 'AllowWebAppToAI' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + ] + destinationAddressPrefixes: ['10.0.4.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } + { + name: 'data' + addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255) + networkSecurityGroup: { + name: 'data-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToData' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + '10.0.4.0/23' // ai subnet + ] + destinationAddressPrefixes: ['10.0.6.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } + { + name: 'services' + addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses + networkSecurityGroup: { + name: 'services-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToServices' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + '10.0.4.0/23' // ai subnet + ] + destinationAddressPrefixes: ['10.0.8.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } +] + +// jumpbox parameters +param jumpboxSubnet object = { + name: 'jumpbox' + addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more + ] + destinationAddressPrefixes: ['10.0.12.0/23'] + } + } + ] + } +} + +// Azure Bastion Host parameters +param bastionSubnet object = { + addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses + networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG +} + + +// /****************************************************************************************************************************/ +// Create Log Analytics Workspace for monitoring and diagnostics +// /****************************************************************************************************************************/ + +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = { + name: take('log-analytics-${resourcesName}-deployment', 64) + params: { + name: 'log-${resourcesName}' + location: location + skuName: 'PerGB2018' + dataRetention: 30 + diagnosticSettings: [{ useThisWorkspace: true }] + tags: tags + } +} + +// /****************************************************************************************************************************/ +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG +// /****************************************************************************************************************************/ + +module virtualNetwork 'virtualNetwork.bicep' = { + name: '${resourcesName}-virtualNetwork' + params: { + name: vnetName + addressPrefixes: addressPrefixes + subnets: subnets + location: location + tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + } +} + +// /****************************************************************************************************************************/ +// // Create Azure Bastion Subnet and Azure Bastion Host +// /****************************************************************************************************************************/ + +module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionSubnet)) { + name: '${resourcesName}-bastionHost' + params: { + subnet: bastionSubnet + location: location + vnetName: virtualNetwork.outputs.name + vnetId: virtualNetwork.outputs.resourceId + name: bastionHostName + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + tags: tags + } +} + +// /****************************************************************************************************************************/ +// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM +// /****************************************************************************************************************************/ + +module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { + name: '${resourcesName}-jumpbox' + params: { + vmName: jumpboxVmName + location: location + vnetName: virtualNetwork.outputs.name + jumpboxVmSize: jumpboxVmSize + jumpboxSubnet: jumpboxSubnet + jumpboxAdminUser: jumpboxAdminUser + jumpboxAdminPassword: jumpboxAdminPassword + tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + } +} + +output vnetName string = virtualNetwork.outputs.name +output vnetResourceId string = virtualNetwork.outputs.resourceId +output subnets array = virtualNetwork.outputs.subnets // This one holds critical info for subnets, including NSGs + +output bastionSubnetId string = bastionHost.outputs.subnetId +output bastionSubnetName string = bastionHost.outputs.subnetName +output bastionHostId string = bastionHost.outputs.resourceId +output bastionHostName string = bastionHost.outputs.name + +output jumpboxSubnetName string = jumpbox.outputs.subnetId +output jumpboxSubnetId string = jumpbox.outputs.subnetId +output jumpboxVmName string = jumpbox.outputs.vmName +output jumpboxVmId string = jumpbox.outputs.vmId From f57a00a60c608a4fedcf3e3beb114bb44ad1bd64 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 13:57:15 -0400 Subject: [PATCH 061/124] deleted --- infra/main_network.bicep | 53 ---------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 infra/main_network.bicep diff --git a/infra/main_network.bicep b/infra/main_network.bicep deleted file mode 100644 index f5df883..0000000 --- a/infra/main_network.bicep +++ /dev/null @@ -1,53 +0,0 @@ -@minLength(3) -@maxLength(20) -@description('A unique application/env name for all resources in this deployment. This should be 3-20 characters long') -param environmentName string = 'Code Mod Dev' - -@minLength(3) -@description('Azure region for all services.') -param location string = resourceGroup().location - -@description('Optional. Enable private networking for the resources. Set to true to enable private networking.') -param enablePrivateNetworking bool = true - -@description('Enable monitoring for the resources. This will enable Application Insights and Log Analytics. Defaults to false.') -param enableMonitoring bool = true - -@description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') -param tags object = {} - -var resourcesName = trim(replace( - replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''), '/', ''), - ' ', - '' -)) -var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) -var uniqueResourcesName = '${resourcesName}${resourcesToken}' - -var defaultTags = { - 'azd-env-name': resourcesName -} -var allTags = union(defaultTags, tags) - -module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring || enablePrivateNetworking) { - name: take('log-analytics-${resourcesName}-deployment', 64) - params: { - name: 'log-${resourcesName}' - location: location - skuName: 'PerGB2018' - dataRetention: 30 - diagnosticSettings: [{ useThisWorkspace: true }] - tags: allTags - } -} - - -module network 'modules/network.bicep' = if (enablePrivateNetworking) { - name: take('network-${resourcesName}-deployment', 64) - params: { - resourcesName: resourcesName - logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId - location: location - tags: allTags - } -} From 5bd75c4a5ce8e4bb4f7c908836545e8ff2e38c54 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 14:01:52 -0400 Subject: [PATCH 062/124] made it a true testing program --- infra/modules/network/main.bicep | 246 +++++++++++++++++++++++++++---- 1 file changed, 216 insertions(+), 30 deletions(-) diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index 7dafc98..6e3d354 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -1,44 +1,232 @@ +// /****************************************************************************************************************************/ +// This is an example test program to create private networking resources independently to show the usage of the modules +// with sample inputs. +// +// Next Steps: +// Review infra/main.bicep and infra/modules/network.bicep for intended usage of the modules +// Please infra/modules/network.bicep on how to customize the networking resources for your application. +// +// /****************************************************************************************************************************/ + @minLength(6) @maxLength(25) @description('Default name used for all resources.') -param resourcesName string +param resourcesName string = 'testNetwork' @minLength(3) @description('Azure region for all services.') -param location string - -@description('Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') -param logAnalyticsWorkSpaceResourceId string - -@description('Networking address prefix for the VNET only.') -param addressPrefixes array - -@description('Array of subnets to be created within the VNET.') -param subnets array +param location string = 'eastus' @description('Optional. Tags to be applied to the resources.') param tags object = {} - var vnetName = 'vnet-${resourcesName}' +@description('Networking address prefix for the VNET only') +param addressPrefixes array = ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24 subnets) -// jumpbox parameters -param jumpboxVM bool = false -param jumpboxSubnet object = {} -param jumpboxAdminUser string = 'JumpboxAdminUser' +param enableBastionHost bool = true +var bastionHostName = 'bastionHost-${resourcesName}' + +param jumpboxVM bool = true +param jumpboxAdminUser string = 'JumpboxAdminUser' @secure() -param jumpboxAdminPassword string -param jumpboxVmSize string = 'Standard_D2s_v3' -var jumpboxVmName = 'jumpboxVM-${resourcesName}' +param jumpboxAdminPassword string = 'JumpboxAdminP@ssw0rd1234!' +param jumpboxVmSize string = 'Standard_D2s_v3' +var jumpboxVmName = 'jumpboxVM-${resourcesName}' + +@description('Array of subnets to be created within the VNET.') +param subnets array = [ + // Only one delegation per subnet is supported by the AVM module as of June 2025. + // For subnets that do not require delegation, leave the array empty. + { + name: 'web' + addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses + networkSecurityGroup: { + name: 'web-nsg' + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/23'] + } + } + ] + } + delegations: [ + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'app' + addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses + networkSecurityGroup: { + name: 'app-nsg' + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/23'] // web subnet + destinationAddressPrefixes: ['10.0.2.0/23'] + } + } + ] + } + delegations: [ + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'ai' + addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses + networkSecurityGroup: { + name: 'ai-nsg' + securityRules: [ + { + name: 'AllowWebAppToAI' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + ] + destinationAddressPrefixes: ['10.0.4.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } + { + name: 'data' + addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255) + networkSecurityGroup: { + name: 'data-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToData' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + '10.0.4.0/23' // ai subnet + ] + destinationAddressPrefixes: ['10.0.6.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } + { + name: 'services' + addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses + networkSecurityGroup: { + name: 'services-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToServices' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + '10.0.4.0/23' // ai subnet + ] + destinationAddressPrefixes: ['10.0.8.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } +] + +// jumpbox parameters +param jumpboxSubnet object = { + name: 'jumpbox' + addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more + ] + destinationAddressPrefixes: ['10.0.12.0/23'] + } + } + ] + } +} // Azure Bastion Host parameters -param enableBastionHost bool = true -param bastionSubnet object = {} -var bastionHostName = 'bastionHost-${resourcesName}' +param bastionSubnet object = { + addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses + networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG +} + + +// /****************************************************************************************************************************/ +// Create Log Analytics Workspace for monitoring and diagnostics +// /****************************************************************************************************************************/ +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = { + name: take('log-analytics-${resourcesName}-deployment', 64) + params: { + name: 'log-${resourcesName}' + location: location + skuName: 'PerGB2018' + dataRetention: 30 + diagnosticSettings: [{ useThisWorkspace: true }] + tags: tags + } +} // /****************************************************************************************************************************/ -// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG // /****************************************************************************************************************************/ module virtualNetwork 'virtualNetwork.bicep' = { @@ -49,7 +237,7 @@ module virtualNetwork 'virtualNetwork.bicep' = { subnets: subnets location: location tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId } } @@ -57,7 +245,7 @@ module virtualNetwork 'virtualNetwork.bicep' = { // // Create Azure Bastion Subnet and Azure Bastion Host // /****************************************************************************************************************************/ -module bastionHost 'bastionHost.bicep' = if (enableBastionHost && !empty(bastionSubnet)) { +module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionSubnet)) { name: '${resourcesName}-bastionHost' params: { subnet: bastionSubnet @@ -65,7 +253,7 @@ module bastionHost 'bastionHost.bicep' = if (enableBastionHost && !empty(bastion vnetName: virtualNetwork.outputs.name vnetId: virtualNetwork.outputs.resourceId name: bastionHostName - logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId tags: tags } } @@ -74,7 +262,7 @@ module bastionHost 'bastionHost.bicep' = if (enableBastionHost && !empty(bastion // // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM // /****************************************************************************************************************************/ -module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { +module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { name: '${resourcesName}-jumpbox' params: { vmName: jumpboxVmName @@ -85,7 +273,7 @@ module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { jumpboxAdminUser: jumpboxAdminUser jumpboxAdminPassword: jumpboxAdminPassword tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId } } @@ -102,5 +290,3 @@ output jumpboxSubnetName string = jumpbox.outputs.subnetId output jumpboxSubnetId string = jumpbox.outputs.subnetId output jumpboxVmName string = jumpbox.outputs.vmName output jumpboxVmId string = jumpbox.outputs.vmId - - From f126806871d19d0cc1ebf3e62466ba534b5b6884 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 14:02:00 -0400 Subject: [PATCH 063/124] deleted --- .../network/test_network_modules.bicep | 292 ------------------ 1 file changed, 292 deletions(-) delete mode 100644 infra/modules/network/test_network_modules.bicep diff --git a/infra/modules/network/test_network_modules.bicep b/infra/modules/network/test_network_modules.bicep deleted file mode 100644 index 6e3d354..0000000 --- a/infra/modules/network/test_network_modules.bicep +++ /dev/null @@ -1,292 +0,0 @@ -// /****************************************************************************************************************************/ -// This is an example test program to create private networking resources independently to show the usage of the modules -// with sample inputs. -// -// Next Steps: -// Review infra/main.bicep and infra/modules/network.bicep for intended usage of the modules -// Please infra/modules/network.bicep on how to customize the networking resources for your application. -// -// /****************************************************************************************************************************/ - -@minLength(6) -@maxLength(25) -@description('Default name used for all resources.') -param resourcesName string = 'testNetwork' - -@minLength(3) -@description('Azure region for all services.') -param location string = 'eastus' - -@description('Optional. Tags to be applied to the resources.') -param tags object = {} - -var vnetName = 'vnet-${resourcesName}' -@description('Networking address prefix for the VNET only') -param addressPrefixes array = ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24 subnets) - -param enableBastionHost bool = true -var bastionHostName = 'bastionHost-${resourcesName}' - -param jumpboxVM bool = true -param jumpboxAdminUser string = 'JumpboxAdminUser' -@secure() -param jumpboxAdminPassword string = 'JumpboxAdminP@ssw0rd1234!' -param jumpboxVmSize string = 'Standard_D2s_v3' -var jumpboxVmName = 'jumpboxVM-${resourcesName}' - -@description('Array of subnets to be created within the VNET.') -param subnets array = [ - // Only one delegation per subnet is supported by the AVM module as of June 2025. - // For subnets that do not require delegation, leave the array empty. - { - name: 'web' - addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses - networkSecurityGroup: { - name: 'web-nsg' - securityRules: [ - { - name: 'AllowHttpsInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/23'] - } - } - ] - } - delegations: [ - { - name: 'containerapps-delegation' - serviceName: 'Microsoft.App/environments' - } - ] - } - { - name: 'app' - addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses - networkSecurityGroup: { - name: 'app-nsg' - securityRules: [ - { - name: 'AllowWebToApp' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/23'] // web subnet - destinationAddressPrefixes: ['10.0.2.0/23'] - } - } - ] - } - delegations: [ - { - name: 'containerapps-delegation' - serviceName: 'Microsoft.App/environments' - } - ] - } - { - name: 'ai' - addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses - networkSecurityGroup: { - name: 'ai-nsg' - securityRules: [ - { - name: 'AllowWebAppToAI' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - ] - destinationAddressPrefixes: ['10.0.4.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } - { - name: 'data' - addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255) - networkSecurityGroup: { - name: 'data-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToData' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - '10.0.4.0/23' // ai subnet - ] - destinationAddressPrefixes: ['10.0.6.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } - { - name: 'services' - addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses - networkSecurityGroup: { - name: 'services-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToServices' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - '10.0.4.0/23' // ai subnet - ] - destinationAddressPrefixes: ['10.0.8.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } -] - -// jumpbox parameters -param jumpboxSubnet object = { - name: 'jumpbox' - addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: [ - '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more - ] - destinationAddressPrefixes: ['10.0.12.0/23'] - } - } - ] - } -} - -// Azure Bastion Host parameters -param bastionSubnet object = { - addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses - networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG -} - - -// /****************************************************************************************************************************/ -// Create Log Analytics Workspace for monitoring and diagnostics -// /****************************************************************************************************************************/ - -module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = { - name: take('log-analytics-${resourcesName}-deployment', 64) - params: { - name: 'log-${resourcesName}' - location: location - skuName: 'PerGB2018' - dataRetention: 30 - diagnosticSettings: [{ useThisWorkspace: true }] - tags: tags - } -} - -// /****************************************************************************************************************************/ -// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG -// /****************************************************************************************************************************/ - -module virtualNetwork 'virtualNetwork.bicep' = { - name: '${resourcesName}-virtualNetwork' - params: { - name: vnetName - addressPrefixes: addressPrefixes - subnets: subnets - location: location - tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId - } -} - -// /****************************************************************************************************************************/ -// // Create Azure Bastion Subnet and Azure Bastion Host -// /****************************************************************************************************************************/ - -module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionSubnet)) { - name: '${resourcesName}-bastionHost' - params: { - subnet: bastionSubnet - location: location - vnetName: virtualNetwork.outputs.name - vnetId: virtualNetwork.outputs.resourceId - name: bastionHostName - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId - tags: tags - } -} - -// /****************************************************************************************************************************/ -// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM -// /****************************************************************************************************************************/ - -module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { - name: '${resourcesName}-jumpbox' - params: { - vmName: jumpboxVmName - location: location - vnetName: virtualNetwork.outputs.name - jumpboxVmSize: jumpboxVmSize - jumpboxSubnet: jumpboxSubnet - jumpboxAdminUser: jumpboxAdminUser - jumpboxAdminPassword: jumpboxAdminPassword - tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId - } -} - -output vnetName string = virtualNetwork.outputs.name -output vnetResourceId string = virtualNetwork.outputs.resourceId -output subnets array = virtualNetwork.outputs.subnets // This one holds critical info for subnets, including NSGs - -output bastionSubnetId string = bastionHost.outputs.subnetId -output bastionSubnetName string = bastionHost.outputs.subnetName -output bastionHostId string = bastionHost.outputs.resourceId -output bastionHostName string = bastionHost.outputs.name - -output jumpboxSubnetName string = jumpbox.outputs.subnetId -output jumpboxSubnetId string = jumpbox.outputs.subnetId -output jumpboxVmName string = jumpbox.outputs.vmName -output jumpboxVmId string = jumpbox.outputs.vmId From cc27b0693ebddfdc07344b9bf54c78b4ccfa49a6 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 14:05:58 -0400 Subject: [PATCH 064/124] deleted Deployment_Plan.md --- infra/Deployment_Plan.md | 161 --------------------------------------- 1 file changed, 161 deletions(-) delete mode 100644 infra/Deployment_Plan.md diff --git a/infra/Deployment_Plan.md b/infra/Deployment_Plan.md deleted file mode 100644 index a345e88..0000000 --- a/infra/Deployment_Plan.md +++ /dev/null @@ -1,161 +0,0 @@ -# Deployment Plan - -The deployment code will be in the **infra** folder. The **modules** subfolder contains reusable, parameterized modules. - -- **main.bicep**: main bicep program that uses parameters defined in -- **main.biceppram** file - -Creates a Virtual Network with subnets, private endpoints for all solution resources (e.g., Azure AI Foundry, Storage, Cosmos DB, Key Vault), Bastion Host, and all networking/security resources recommended by WAF Security Guidelines. Uses Azure Verified Modules (AVM) where appropriate. See [Microsoft Azure Well-Architected Framework (WAF) Security design principles](https://learn.microsoft.com/en-us/azure/well-architected/security/principles) and [Azure Verified Modules (AVM)](https://azure.github.io/Azure-Verified-Modules/). - -If you are new to AVM BICEP implementation, refer to [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/). - -## Network & Subnets for Components - -**Solution Components and placements in the Vnet/Subnets** - -| # | Component Name | Notes | Subnet | -| ----------------------------------------- | ------------------------------------------------------------ | ----------------- | ----------------- | -| 1 | Managed Identities | (User/Sys defined) | | -| 2 | Log Analytics Workspace | monitoring **(global, not subnet) | | -| 3 | Application Insights | app-insights **(global, not subnet)** | | -| S | | | | -| 1 | Web App Environment | | web | -| 2 | Frontend Container App | | web | -| 3 | NSG for Web Subnet | nsg | web | -| S | | | | -| 1 | App Environment | | app | -| 2 | Backend Container App | | app | -| 3 | NSG for App Subnet | nsg | app | -| S | | | | -| 1 | AI Hub | ai-foundry / **private end point** | ai | -| 2 | AI Project | ai-foundry / **private end point** | ai | -| 3 | AI Services | ai-foundry / **private end point** | ai | -| 4 | NSG for AI Subnet | nsg | ai | -| S | | | | -| 1 | Key Vault | keyvault / **private end point** | data | -| 2 | Cosmos DB | cosmos-db/ **private end point** | data | -| 3 | Storage Account Front End | storage / **private end point** | data | -| 4 | Storage Account Back End | storage / **private end point** | data | -| 5 | NSG for Data Subnet | nsg | data | -| S | | | | -| 1 | Services | For future expansion, any services that AI or App will utilize | services | -| 2 | NSG for Services | nsg | services | -| S | | | | -| 11 | JumpBox VMAzure Bastion Host | (your-jumpbox-module) | jumpbox | -| 22 | NSG for JumpBox | nsg | jumpbox | -| S | | | | -| 1 | Azure Bastion Host | PaaS, no NSG | AzureBastionSubnet | -| S | | | | -| 1 | Route Table | routeTable | (associated subnets) | -| 2 | Private Endpoints | privateEndpoint | respective subnet | -| 3 | Private DNS Zone | privateDnsZone | vnet | - - - -#### Network Design - -**addressPrefixes** = ['10.0.0.0/20' ] // 4096 addresses (enough for 8 /23 subnets or 16 /24) - -512 x 7 = 3584 allocated - -| Subnet | Address Prefix | IP Range | Total IPs | Usable IPs* | -| ----------- | -------------- | ------------------------- | --------- | ----------- | -| web | 10.0.0.0/23 | (10.0.0.0 - 10.0.1.255) | 512 | 507 | -| app | 10.0.2.0/23 | (10.0.2.0 - 10.0.3.255) | 512 | 507 | -| ai | 10.0.4.0/23 | (10.0.4.0 - 10.0.5.255) | 512 | 507 | -| data | 10.0.6.0/23 | (10.0.6.0 - 10.0.7.255) | 512 | 507 | -| services | 10.0.8.0/23 | (10.0.8.0 - 10.0.9.255) | 512 | 507 | -| bastionHost | 10.0.10.0/23 | (10.0.10.0 - 10.0.11.255) | 512 | 507 | -| jumpbox | 10.0.12.0/23 | (10.0.12.0 - 10.0.13.255) | 512 | 507 | - -*Usable IPs = Total IPs minus 5 reserved by Azure per subnet. - -### **Example Subnet/NSG Table** - -| Subnet | NSG Rules (Inbound) | NSG Rules (Outbound) | -| ------- | --------------------------------------------------- | ---------------------- | -| web | 80/443 from internet or allowed IPs | To app, internet | -| app | From web subnet only | To data, PaaS | -| ai | From web and app subnets only | To data, PaaS | -| data | From web/app/ai/private endpoints | To PaaS, as needed | -| bastion | Platform-managed (Bastion Host only, no direct NSG) | To VMs for RDP/SSH | -| jumpbox | RDP/SSH from allowed IPs or Bastion | To internet, as needed | - -## **Monitoring & Logging** -- Enable diagnostic logs for all resources, send to Log Analytics Workspace. -- Enable Application Insights for all app components. - -## **DDoS Protection** -- Enable at the VNet level for public-facing workloads. - -## Azure Bastion Host - -**Function:** - -- Azure Bastion is a managed PaaS service that provides secure RDP/SSH connectivity to your VMs directly from the Azure Portal, without exposing public IP addresses on those VMs. - -**How to Use:** - -- Deploy Bastion Host in a dedicated subnet (must be named `bastion` or `AzureBastionSubnet`). -- When you need to access a VM (like a JumpBox or any other VM) for admin purposes, use the “Connect” button in the Azure Portal and select “Bastion.” -- Your session runs over SSL in the browser—no public IP, no direct RDP/SSH from the internet. - -**Best Practice:** - -- Use Bastion Host for all admin access to VMs in your VNet. -- Never assign public IPs to your VMs. - -## JumpBox VM - -**Function:** - -- A JumpBox (or Jump Host) is a hardened VM (often Linux or Windows) that acts as a single entry point for admin access to other VMs or resources in your private network. -- It is typically used for scenarios where you need to run custom tools, scripts, or have a persistent admin environment. - -**How to Use:** - -- Deploy the JumpBox VM in its own subnet (e.g., `jumpbox`). -- Access the JumpBox via Bastion Host (never via public IP). -- From the JumpBox, you can SSH/RDP to other VMs, run scripts, or use it as a staging point for troubleshooting. - -**Best Practice:** - -- The JumpBox should have minimal software, be tightly controlled, and monitored. -- Use NSGs to restrict access to/from the JumpBox subnet. -- Use Bastion Host to access the JumpBox, not a public IP. - -## Deployment Considerations - -### Add Networking Components -- **Network Security Group (NSG):** Protects subnets and controls inbound/outbound traffic. One NSG per subnet is recommended. -- **Bastion Host:** Provides secure RDP/SSH access to VMs without exposing public IPs. -- **Route Table:** Custom route tables for advanced routing scenarios (optional, but recommended for segmented networks). -- **Private Endpoints:** For secure, private connectivity to PaaS services (Key Vault, Storage, Cosmos DB, etc.), add private endpoints in the relevant subnets. Use a dedicated PrivateEndpointSubnet for easier management. -- **Private DNS Zone:** Required for name resolution of private endpoints, ensuring resources in your VNet can resolve the private DNS names of Azure PaaS services to their private IP addresses. - -### Security & Best Practices -- Use Managed Identity for all services that support it. -- Store secrets and connection strings in Key Vault; never hardcode credentials. -- Enable diagnostic logging for all resources (send to Log Analytics Workspace). -- Apply NSGs to each subnet with least-privilege rules. -- Use Bastion Host for secure admin access. -- Consider DDoS Protection for the VNet if required. -- Use Private Endpoints for PaaS services to avoid public exposure. -- For all PaaS resources with private endpoints, set public network access to 'Deny'. -- Enable soft delete and purge protection on Key Vault and Storage Accounts. - ---- - -**For more information, see:** -- [Microsoft Azure Well-Architected Framework (WAF) Security design principles](https://learn.microsoft.com/en-us/azure/well-architected/security/principles) -- [Azure Verified Modules (AVM)](https://azure.github.io/Azure-Verified-Modules/) -- [AVM Bicep Quickstart Guide](https://azure.github.io/Azure-Verified-Modules/usage/quickstart/bicep/) -- [Azure Application Gateway documentation](https://learn.microsoft.com/en-us/azure/application-gateway/overview) -- [Azure Bastion documentation](https://learn.microsoft.com/en-us/azure/bastion/bastion-overview) -- [Azure Private Endpoint documentation](https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview) -- [Azure Firewall documentation](https://learn.microsoft.com/en-us/azure/firewall/overview) -- [Azure DDoS Protection documentation](https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview) -- [Azure Key Vault documentation](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) -- [Azure Cosmos DB documentation](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) -- [Azure Storage documentation](https://learn.microsoft.com/en-us/azure/storage/common/storage-introduction) - From 904006eb6a29413fcfaef995f5f6e3c54ad2c716 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 16:05:54 -0400 Subject: [PATCH 065/124] reverted back to main.bicep prior --- infra/modules/network/Test_Network.bicep | 292 +++++++++++++++++++++++ infra/modules/network/main.bicep | 243 +++---------------- 2 files changed, 320 insertions(+), 215 deletions(-) create mode 100644 infra/modules/network/Test_Network.bicep diff --git a/infra/modules/network/Test_Network.bicep b/infra/modules/network/Test_Network.bicep new file mode 100644 index 0000000..6e3d354 --- /dev/null +++ b/infra/modules/network/Test_Network.bicep @@ -0,0 +1,292 @@ +// /****************************************************************************************************************************/ +// This is an example test program to create private networking resources independently to show the usage of the modules +// with sample inputs. +// +// Next Steps: +// Review infra/main.bicep and infra/modules/network.bicep for intended usage of the modules +// Please infra/modules/network.bicep on how to customize the networking resources for your application. +// +// /****************************************************************************************************************************/ + +@minLength(6) +@maxLength(25) +@description('Default name used for all resources.') +param resourcesName string = 'testNetwork' + +@minLength(3) +@description('Azure region for all services.') +param location string = 'eastus' + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +var vnetName = 'vnet-${resourcesName}' +@description('Networking address prefix for the VNET only') +param addressPrefixes array = ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24 subnets) + +param enableBastionHost bool = true +var bastionHostName = 'bastionHost-${resourcesName}' + +param jumpboxVM bool = true +param jumpboxAdminUser string = 'JumpboxAdminUser' +@secure() +param jumpboxAdminPassword string = 'JumpboxAdminP@ssw0rd1234!' +param jumpboxVmSize string = 'Standard_D2s_v3' +var jumpboxVmName = 'jumpboxVM-${resourcesName}' + +@description('Array of subnets to be created within the VNET.') +param subnets array = [ + // Only one delegation per subnet is supported by the AVM module as of June 2025. + // For subnets that do not require delegation, leave the array empty. + { + name: 'web' + addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses + networkSecurityGroup: { + name: 'web-nsg' + securityRules: [ + { + name: 'AllowHttpsInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefixes: ['0.0.0.0/0'] + destinationAddressPrefixes: ['10.0.0.0/23'] + } + } + ] + } + delegations: [ + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'app' + addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses + networkSecurityGroup: { + name: 'app-nsg' + securityRules: [ + { + name: 'AllowWebToApp' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/23'] // web subnet + destinationAddressPrefixes: ['10.0.2.0/23'] + } + } + ] + } + delegations: [ + { + name: 'containerapps-delegation' + serviceName: 'Microsoft.App/environments' + } + ] + } + { + name: 'ai' + addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses + networkSecurityGroup: { + name: 'ai-nsg' + securityRules: [ + { + name: 'AllowWebAppToAI' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + ] + destinationAddressPrefixes: ['10.0.4.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } + { + name: 'data' + addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255) + networkSecurityGroup: { + name: 'data-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToData' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + '10.0.4.0/23' // ai subnet + ] + destinationAddressPrefixes: ['10.0.6.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } + { + name: 'services' + addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses + networkSecurityGroup: { + name: 'services-nsg' + securityRules: [ + { + name: 'AllowWebAppAiToServices' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: [ + '10.0.0.0/23' // web subnet + '10.0.2.0/23' // app subnet + '10.0.4.0/23' // ai subnet + ] + destinationAddressPrefixes: ['10.0.8.0/23'] + } + } + ] + } + delegations: [] // No delegation required for this subnet. + } +] + +// jumpbox parameters +param jumpboxSubnet object = { + name: 'jumpbox' + addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more + ] + destinationAddressPrefixes: ['10.0.12.0/23'] + } + } + ] + } +} + +// Azure Bastion Host parameters +param bastionSubnet object = { + addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses + networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG +} + + +// /****************************************************************************************************************************/ +// Create Log Analytics Workspace for monitoring and diagnostics +// /****************************************************************************************************************************/ + +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = { + name: take('log-analytics-${resourcesName}-deployment', 64) + params: { + name: 'log-${resourcesName}' + location: location + skuName: 'PerGB2018' + dataRetention: 30 + diagnosticSettings: [{ useThisWorkspace: true }] + tags: tags + } +} + +// /****************************************************************************************************************************/ +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG +// /****************************************************************************************************************************/ + +module virtualNetwork 'virtualNetwork.bicep' = { + name: '${resourcesName}-virtualNetwork' + params: { + name: vnetName + addressPrefixes: addressPrefixes + subnets: subnets + location: location + tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + } +} + +// /****************************************************************************************************************************/ +// // Create Azure Bastion Subnet and Azure Bastion Host +// /****************************************************************************************************************************/ + +module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionSubnet)) { + name: '${resourcesName}-bastionHost' + params: { + subnet: bastionSubnet + location: location + vnetName: virtualNetwork.outputs.name + vnetId: virtualNetwork.outputs.resourceId + name: bastionHostName + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + tags: tags + } +} + +// /****************************************************************************************************************************/ +// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM +// /****************************************************************************************************************************/ + +module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { + name: '${resourcesName}-jumpbox' + params: { + vmName: jumpboxVmName + location: location + vnetName: virtualNetwork.outputs.name + jumpboxVmSize: jumpboxVmSize + jumpboxSubnet: jumpboxSubnet + jumpboxAdminUser: jumpboxAdminUser + jumpboxAdminPassword: jumpboxAdminPassword + tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + } +} + +output vnetName string = virtualNetwork.outputs.name +output vnetResourceId string = virtualNetwork.outputs.resourceId +output subnets array = virtualNetwork.outputs.subnets // This one holds critical info for subnets, including NSGs + +output bastionSubnetId string = bastionHost.outputs.subnetId +output bastionSubnetName string = bastionHost.outputs.subnetName +output bastionHostId string = bastionHost.outputs.resourceId +output bastionHostName string = bastionHost.outputs.name + +output jumpboxSubnetName string = jumpbox.outputs.subnetId +output jumpboxSubnetId string = jumpbox.outputs.subnetId +output jumpboxVmName string = jumpbox.outputs.vmName +output jumpboxVmId string = jumpbox.outputs.vmId diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index 6e3d354..49af7fa 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -1,232 +1,44 @@ -// /****************************************************************************************************************************/ -// This is an example test program to create private networking resources independently to show the usage of the modules -// with sample inputs. -// -// Next Steps: -// Review infra/main.bicep and infra/modules/network.bicep for intended usage of the modules -// Please infra/modules/network.bicep on how to customize the networking resources for your application. -// -// /****************************************************************************************************************************/ - @minLength(6) @maxLength(25) @description('Default name used for all resources.') -param resourcesName string = 'testNetwork' +param resourcesName string @minLength(3) @description('Azure region for all services.') -param location string = 'eastus' +param location string -@description('Optional. Tags to be applied to the resources.') -param tags object = {} +@description('Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') +param logAnalyticsWorkSpaceResourceId string -var vnetName = 'vnet-${resourcesName}' @description('Networking address prefix for the VNET only') -param addressPrefixes array = ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24 subnets) +param addressPrefixes array -param enableBastionHost bool = true -var bastionHostName = 'bastionHost-${resourcesName}' +@description('Array of subnets to be created within the VNET.') +param subnets array -param jumpboxVM bool = true -param jumpboxAdminUser string = 'JumpboxAdminUser' -@secure() -param jumpboxAdminPassword string = 'JumpboxAdminP@ssw0rd1234!' -param jumpboxVmSize string = 'Standard_D2s_v3' -var jumpboxVmName = 'jumpboxVM-${resourcesName}' +@description('Optional. Tags to be applied to the resources.') +param tags object = {} -@description('Array of subnets to be created within the VNET.') -param subnets array = [ - // Only one delegation per subnet is supported by the AVM module as of June 2025. - // For subnets that do not require delegation, leave the array empty. - { - name: 'web' - addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses - networkSecurityGroup: { - name: 'web-nsg' - securityRules: [ - { - name: 'AllowHttpsInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/23'] - } - } - ] - } - delegations: [ - { - name: 'containerapps-delegation' - serviceName: 'Microsoft.App/environments' - } - ] - } - { - name: 'app' - addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses - networkSecurityGroup: { - name: 'app-nsg' - securityRules: [ - { - name: 'AllowWebToApp' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/23'] // web subnet - destinationAddressPrefixes: ['10.0.2.0/23'] - } - } - ] - } - delegations: [ - { - name: 'containerapps-delegation' - serviceName: 'Microsoft.App/environments' - } - ] - } - { - name: 'ai' - addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses - networkSecurityGroup: { - name: 'ai-nsg' - securityRules: [ - { - name: 'AllowWebAppToAI' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - ] - destinationAddressPrefixes: ['10.0.4.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } - { - name: 'data' - addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255) - networkSecurityGroup: { - name: 'data-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToData' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - '10.0.4.0/23' // ai subnet - ] - destinationAddressPrefixes: ['10.0.6.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } - { - name: 'services' - addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses - networkSecurityGroup: { - name: 'services-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToServices' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - '10.0.4.0/23' // ai subnet - ] - destinationAddressPrefixes: ['10.0.8.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } -] + +var vnetName = 'vnet-${resourcesName}' // jumpbox parameters -param jumpboxSubnet object = { - name: 'jumpbox' - addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: [ - '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more - ] - destinationAddressPrefixes: ['10.0.12.0/23'] - } - } - ] - } -} +param jumpboxVM bool = false // set in .bicepparam file +param jumpboxSubnet object = {} // set in .bicepparam file +param jumpboxAdminUser string = 'JumpboxAdminUser' // set in .bicepparam file +@secure() +param jumpboxAdminPassword string // set in .bicepparam file +param jumpboxVmSize string = 'Standard_D2s_v3' +var jumpboxVmName = 'jumpboxVM-${resourcesName}' // Azure Bastion Host parameters -param bastionSubnet object = { - addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses - networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG -} +param enableBastionHost bool = false // set in .bicepparam file +param bastionSubnet object = {} // set in .bicepparam file +var bastionHostName = 'bastionHost-${resourcesName}' // /****************************************************************************************************************************/ -// Create Log Analytics Workspace for monitoring and diagnostics -// /****************************************************************************************************************************/ - -module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = { - name: take('log-analytics-${resourcesName}-deployment', 64) - params: { - name: 'log-${resourcesName}' - location: location - skuName: 'PerGB2018' - dataRetention: 30 - diagnosticSettings: [{ useThisWorkspace: true }] - tags: tags - } -} - -// /****************************************************************************************************************************/ -// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG // /****************************************************************************************************************************/ module virtualNetwork 'virtualNetwork.bicep' = { @@ -237,7 +49,7 @@ module virtualNetwork 'virtualNetwork.bicep' = { subnets: subnets location: location tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId } } @@ -245,7 +57,7 @@ module virtualNetwork 'virtualNetwork.bicep' = { // // Create Azure Bastion Subnet and Azure Bastion Host // /****************************************************************************************************************************/ -module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionSubnet)) { +module bastionHost 'bastionHost.bicep' = if (enableBastionHost && !empty(bastionSubnet)) { name: '${resourcesName}-bastionHost' params: { subnet: bastionSubnet @@ -253,7 +65,7 @@ module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionS vnetName: virtualNetwork.outputs.name vnetId: virtualNetwork.outputs.resourceId name: bastionHostName - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId tags: tags } } @@ -262,7 +74,7 @@ module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionS // // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM // /****************************************************************************************************************************/ -module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { +module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { name: '${resourcesName}-jumpbox' params: { vmName: jumpboxVmName @@ -273,7 +85,7 @@ module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { jumpboxAdminUser: jumpboxAdminUser jumpboxAdminPassword: jumpboxAdminPassword tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId } } @@ -290,3 +102,4 @@ output jumpboxSubnetName string = jumpbox.outputs.subnetId output jumpboxSubnetId string = jumpbox.outputs.subnetId output jumpboxVmName string = jumpbox.outputs.vmName output jumpboxVmId string = jumpbox.outputs.vmId + From e35542e2b0008f6c5da9994fbdfbe61b849df59f Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 16:08:42 -0400 Subject: [PATCH 066/124] comment change only --- infra/modules/network/main.bicep | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index 49af7fa..02c44b3 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -23,17 +23,17 @@ param tags object = {} var vnetName = 'vnet-${resourcesName}' // jumpbox parameters -param jumpboxVM bool = false // set in .bicepparam file -param jumpboxSubnet object = {} // set in .bicepparam file -param jumpboxAdminUser string = 'JumpboxAdminUser' // set in .bicepparam file +param jumpboxVM bool = false +param jumpboxSubnet object = {} +param jumpboxAdminUser string = 'JumpboxAdminUser' @secure() -param jumpboxAdminPassword string // set in .bicepparam file +param jumpboxAdminPassword string param jumpboxVmSize string = 'Standard_D2s_v3' var jumpboxVmName = 'jumpboxVM-${resourcesName}' // Azure Bastion Host parameters -param enableBastionHost bool = false // set in .bicepparam file -param bastionSubnet object = {} // set in .bicepparam file +param enableBastionHost bool = false +param bastionSubnet object = {} var bastionHostName = 'bastionHost-${resourcesName}' From d90fdeda870fb9d4252fe53dbba773082f8ecd02 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 16:27:03 -0400 Subject: [PATCH 067/124] rename and content update --- ...st_Network.bicep => test_network_modules.bicep} | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) rename infra/modules/network/{Test_Network.bicep => test_network_modules.bicep} (95%) diff --git a/infra/modules/network/Test_Network.bicep b/infra/modules/network/test_network_modules.bicep similarity index 95% rename from infra/modules/network/Test_Network.bicep rename to infra/modules/network/test_network_modules.bicep index 6e3d354..9df273e 100644 --- a/infra/modules/network/Test_Network.bicep +++ b/infra/modules/network/test_network_modules.bicep @@ -1,12 +1,12 @@ -// /****************************************************************************************************************************/ -// This is an example test program to create private networking resources independently to show the usage of the modules -// with sample inputs. +// /******************************************************************************************************************/ +// This is an example test program to create private networking resources independently with sample inputs // -// Next Steps: -// Review infra/main.bicep and infra/modules/network.bicep for intended usage of the modules -// Please infra/modules/network.bicep on how to customize the networking resources for your application. +// Please review below modules to understand the how things are wired together: +// infra/main.bicep +// infra/modules/network.bicep +// infra/moddules/network/main.bicep // -// /****************************************************************************************************************************/ +// /******************************************************************************************************************/ @minLength(6) @maxLength(25) From 21fc125ec6ad64adf75713c335bbcee01d26148a Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 12 Jun 2025 16:37:22 -0400 Subject: [PATCH 068/124] comments update only --- .../network/test_network_modules.bicep | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/infra/modules/network/test_network_modules.bicep b/infra/modules/network/test_network_modules.bicep index 9df273e..7e4445a 100644 --- a/infra/modules/network/test_network_modules.bicep +++ b/infra/modules/network/test_network_modules.bicep @@ -1,7 +1,7 @@ // /******************************************************************************************************************/ // This is an example test program to create private networking resources independently with sample inputs // -// Please review below modules to understand the how things are wired together: +// Please review below modules to understand how things are wired together: // infra/main.bicep // infra/modules/network.bicep // infra/moddules/network/main.bicep @@ -205,14 +205,13 @@ param jumpboxSubnet object = { // Azure Bastion Host parameters param bastionSubnet object = { addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses - networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG + networkSecurityGroup: null // Azure Bastion subnet must NOT have custom NSG as it is managed by Azure } -// /****************************************************************************************************************************/ -// Create Log Analytics Workspace for monitoring and diagnostics -// /****************************************************************************************************************************/ - +// /******************************************************************************************************************/ +// Create Log Analytics Workspace for monitoring and diagnostics +// /******************************************************************************************************************/ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = { name: take('log-analytics-${resourcesName}-deployment', 64) params: { @@ -225,10 +224,9 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 } } -// /****************************************************************************************************************************/ +// /******************************************************************************************************************/ // Networking - NSGs, VNET and Subnets. Each subnet has its own NSG -// /****************************************************************************************************************************/ - +// /******************************************************************************************************************/ module virtualNetwork 'virtualNetwork.bicep' = { name: '${resourcesName}-virtualNetwork' params: { @@ -241,10 +239,9 @@ module virtualNetwork 'virtualNetwork.bicep' = { } } -// /****************************************************************************************************************************/ +// /******************************************************************************************************************/ // // Create Azure Bastion Subnet and Azure Bastion Host -// /****************************************************************************************************************************/ - +// /******************************************************************************************************************/ module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionSubnet)) { name: '${resourcesName}-bastionHost' params: { @@ -258,10 +255,9 @@ module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionS } } -// /****************************************************************************************************************************/ +// /******************************************************************************************************************/ // // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM -// /****************************************************************************************************************************/ - +// /******************************************************************************************************************/ module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { name: '${resourcesName}-jumpbox' params: { From 6d1d94cd7e82208d25a53aab263bb1e771ebeea3 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 12 Jun 2025 17:05:57 -0400 Subject: [PATCH 069/124] WAF - naming and infra cleanup. networking adjustments (WIP) --- infra/main.bicep | 124 +++++++++---------- infra/modules/aiFoundry.bicep | 9 +- infra/modules/aiServices.bicep | 7 +- infra/modules/cosmosDb.bicep | 5 +- infra/modules/keyVault.bicep | 7 +- infra/modules/network.bicep | 132 +++------------------ infra/modules/network/jumpbox.bicep | 22 ++++ infra/modules/network/main.bicep | 16 ++- infra/modules/network/virtualNetwork.bicep | 88 ++++++++++++-- infra/modules/storageAccount.bicep | 3 +- 10 files changed, 193 insertions(+), 220 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 13ad238..75b1f99 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1,7 +1,11 @@ @minLength(3) -@maxLength(20) -@description('A unique application/env name for all resources in this deployment. This should be 3-20 characters long') -param environmentName string +@maxLength(16) +@description('A unique application/solution name for all resources in this deployment. This should be 3-16 characters long.') +param solutionName string + +@maxLength(5) +@description('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) @minLength(3) @description('Azure region for all services.') @@ -57,14 +61,10 @@ param enablePrivateNetworking bool = false param tags object = {} var allTags = union({ - 'azd-env-name': environmentName + 'azd-env-name': solutionName }, tags) -var resourcesName = trim(replace(replace(replace(replace(replace(environmentName, '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) -var resourcesToken = substring(uniqueString(subscription().id, location, resourcesName), 0, 5) -var uniqueResourcesName = '${resourcesName}${resourcesToken}' - -var appStorageContainerName = 'appstorage' +var resourcesName = trim(replace(replace(replace(replace(replace('${solutionName}${solutionUniqueToken}', '-', ''), '_', ''), '.', ''),'/', ''), ' ', '')) var modelDeployment = { name: 'gpt-4o' @@ -98,14 +98,6 @@ module aiFoundryIdentity 'br/public:avm/res/managed-identity/user-assigned-ident } } -// used for foundry to create and approve managed virtual network and approve private endpoint connections -// Ref: https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-managed-network?tabs=portal#approval-of-private-endpoints -var foundryNetworkConnectionApproverRoleAssignment = { - principalId: aiFoundryIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'b556d68e-0be0-4f35-a333-ad7ee1ce17ea' // Azure AI Enterprise Network Connection Approver -} - module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (enableMonitoring || enablePrivateNetworking) { name: take('log-analytics-${resourcesName}-deployment', 64) params: { @@ -139,45 +131,12 @@ module network 'modules/network.bicep' = if (enablePrivateNetworking) { } } -module storageAccount 'modules/storageAccount.bicep' = { - name: take('storage-account-${resourcesName}-deployment', 64) - #disable-next-line no-unnecessary-dependson - dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency - params: { - name: take('st${uniqueResourcesName}', 24) - location: location - tags: allTags - skuName: enableRedundancy ? 'Standard_GZRS' : 'Standard_LRS' - logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' - privateNetworking: enablePrivateNetworking ? { - virtualNetworkResourceId: network.outputs.vnetResourceId - subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'data')).resourceId - } : null - containers: [ - { - name: appStorageContainerName - properties: { - publicAccess: 'None' - } - } - ] - roleAssignments: [ - { - principalId: appIdentity.outputs.principalId - principalType: 'ServicePrincipal' - roleDefinitionIdOrName: 'Storage Blob Data Contributor' - } - foundryNetworkConnectionApproverRoleAssignment - ] - } -} - -module azureAiServices 'modules/aiServices.bicep' = { +module aiServices 'modules/aiServices.bicep' = { name: take('aiservices-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { - name: 'ais-${uniqueResourcesName}' + name: 'ais-${resourcesName}' location: azureAiServiceLocation sku: 'S0' kind: 'AIServices' @@ -190,7 +149,7 @@ module azureAiServices 'modules/aiServices.bicep' = { // --------------------- // privateNetworking: enablePrivateNetworking ? { // virtualNetworkResourceId: network.outputs.vnetResourceId - // subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'ai')).resourceId + // subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'peps')).resourceId // } : null // --------------------- roleAssignments: [ @@ -204,24 +163,57 @@ module azureAiServices 'modules/aiServices.bicep' = { principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' } - foundryNetworkConnectionApproverRoleAssignment ] tags: allTags } } +var appStorageContainerName = 'appstorage' + +module storageAccount 'modules/storageAccount.bicep' = { + name: take('storage-account-${resourcesName}-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency + params: { + name: take('st${resourcesName}', 24) + location: location + tags: allTags + skuName: enableRedundancy ? 'Standard_GZRS' : 'Standard_LRS' + logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' + privateNetworking: enablePrivateNetworking ? { + virtualNetworkResourceId: network.outputs.vnetResourceId + subnetResourceId: network.outputs.subnetPrivateEndpointsResourceId + } : null + containers: [ + { + name: appStorageContainerName + properties: { + publicAccess: 'None' + } + } + ] + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + } + ] + } +} + module keyVault 'modules/keyVault.bicep' = { name: take('keyvault-${resourcesName}-deployment', 64) #disable-next-line no-unnecessary-dependson dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { - name: take('kv-${uniqueResourcesName}', 24) + name: take('kv-${resourcesName}', 24) location: location sku: 'standard' logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' privateNetworking: enablePrivateNetworking ? { virtualNetworkResourceId: network.outputs.vnetResourceId - subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'data')).resourceId + subnetResourceId: network.outputs.subnetPrivateEndpointsResourceId } : null roleAssignments: [ { @@ -229,7 +221,6 @@ module keyVault 'modules/keyVault.bicep' = { principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'Key Vault Reader' } - foundryNetworkConnectionApproverRoleAssignment ] tags: allTags } @@ -248,10 +239,10 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { keyVaultResourceId: keyVault.outputs.resourceId userAssignedIdentityResourceId: aiFoundryIdentity.outputs.resourceId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' - aiServicesName: azureAiServices.outputs.name + aiServicesName: aiServices.outputs.name privateNetworking: enablePrivateNetworking ? { virtualNetworkResourceId: network.outputs.vnetResourceId - subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'ai')).resourceId + subnetResourceId: network.outputs.subnetPrivateEndpointsResourceId } : null roleAssignments: [ { @@ -269,7 +260,7 @@ module cosmosDb 'modules/cosmosDb.bicep' = { #disable-next-line no-unnecessary-dependson dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { - name: 'cosmos-${uniqueResourcesName}' + name: 'cosmos-${resourcesName}' location: location dataAccessIdentityPrincipalId: appIdentity.outputs.principalId logAnalyticsWorkspaceResourceId: enableMonitoring ? logAnalyticsWorkspace.outputs.resourceId : '' @@ -277,11 +268,8 @@ module cosmosDb 'modules/cosmosDb.bicep' = { secondaryLocation: enableRedundancy && !empty(secondaryLocation) ? secondaryLocation : '' privateNetworking: enablePrivateNetworking ? { virtualNetworkResourceId: network.outputs.vnetResourceId - subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'data')).resourceId + subnetResourceId: network.outputs.subnetPrivateEndpointsResourceId } : null - roleAssignments: [ - foundryNetworkConnectionApproverRoleAssignment - ] tags: allTags } } @@ -298,7 +286,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. location: location zoneRedundant: enableRedundancy && enablePrivateNetworking publicNetworkAccess: 'Enabled' // public access required for frontend - infrastructureSubnetResourceId: enablePrivateNetworking ? first(filter(network.outputs.subnets, s => s.name == 'web')).resourceId : null + infrastructureSubnetResourceId: enablePrivateNetworking ? network.outputs.subnetWebResourceId : null managedIdentities: { userAssignedResourceIds: [ appIdentity.outputs.resourceId @@ -325,7 +313,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. module containerAppFrontend 'br/public:avm/res/app/container-app:0.17.0' = { name: take('container-app-frontend-${resourcesName}-deployment', 64) params: { - name: take('ca-${uniqueResourcesName}frontend', 32) + name: take('ca-${resourcesName}frontend', 32) location: location environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { @@ -374,7 +362,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.17.0' = { #disable-next-line no-unnecessary-dependson dependsOn: [applicationInsights] // required due to optional flags that could change dependency params: { - name: take('ca-${uniqueResourcesName}backend', 32) + name: take('ca-${resourcesName}backend', 32) location: location environmentResourceId: containerAppsEnvironment.outputs.resourceId managedIdentities: { @@ -417,7 +405,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.17.0' = { } { name: 'AZURE_OPENAI_ENDPOINT' - value: 'https://${azureAiServices.outputs.name}.openai.azure.com/' + value: 'https://${aiServices.outputs.name}.openai.azure.com/' } { name: 'MIGRATOR_AGENT_MODEL_DEPLOY' diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index 890fccd..cbb21b6 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -31,6 +31,7 @@ param aiServicesName string @description('Optional. Values to establish private networking for the AI Foundry resources.') param privateNetworking machineLearningPrivateNetworkingType? +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' @description('Optional. Array of role assignments to create.') param roleAssignments roleAssignmentType[]? @@ -86,7 +87,7 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' managedNetworkSettings: { isolationMode: privateNetworking != null ? 'AllowInternetOutbound' : 'Disabled' - outboundRules: { + outboundRules: privateNetworking != null ? { cog_services_pep: { category: 'UserDefined' destination: { @@ -96,8 +97,8 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { } type: 'PrivateEndpoint' } - } - } + } : null + } managedIdentities: { systemAssigned: true } @@ -168,8 +169,6 @@ resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10- dependsOn: [project] } -import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' - output projectName string = project.outputs.name output hubName string = hub.outputs.name output projectConnectionString string = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' diff --git a/infra/modules/aiServices.bicep b/infra/modules/aiServices.bicep index a9db7bb..9256a6e 100644 --- a/infra/modules/aiServices.bicep +++ b/infra/modules/aiServices.bicep @@ -51,9 +51,11 @@ param sku string = 'S0' @description('Optional. The resource ID of the Log Analytics workspace to use for diagnostic settings.') param logAnalyticsWorkspaceResourceId string? +import { deploymentType } from 'br/public:avm/res/cognitive-services/account:0.10.2' @description('Optional. Specifies the OpenAI deployments to create.') param deployments deploymentType[] = [] +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' @description('Optional. Array of role assignments to create.') param roleAssignments roleAssignmentType[]? @@ -107,7 +109,7 @@ module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = } deployments: deployments customSubDomainName: name - disableLocalAuth: false // TODO - verify if this should remain false or be set dynamically via privateNetworking + disableLocalAuth: false publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ { @@ -133,9 +135,6 @@ module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = } } -import { deploymentType } from 'br/public:avm/res/cognitive-services/account:0.10.2' -import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' - output resourceId string = cognitiveService.outputs.resourceId output name string = cognitiveService.outputs.name output systemAssignedMIPrincipalId string? = cognitiveService.outputs.?systemAssignedMIPrincipalId diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep index 7fa4dad..19680c9 100644 --- a/infra/modules/cosmosDb.bicep +++ b/infra/modules/cosmosDb.bicep @@ -19,9 +19,11 @@ param zoneRedundant bool @description('Optional. The secondary location for the Cosmos DB Account for failover and multiple writes.') param secondaryLocation string? +import { resourcePrivateNetworkingType } from 'customTypes.bicep' @description('Optional. Values to establish private networking for the Cosmos DB resource.') param privateNetworking resourcePrivateNetworkingType? +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' @description('Optional. Array of role assignments to create.') param roleAssignments roleAssignmentType[]? @@ -143,9 +145,6 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { } } -import { resourcePrivateNetworkingType } from 'customTypes.bicep' -import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' - output resourceId string = cosmosAccount.outputs.resourceId output name string = cosmosAccount.outputs.name output endpoint string = cosmosAccount.outputs.endpoint diff --git a/infra/modules/keyVault.bicep b/infra/modules/keyVault.bicep index 348aaf2..d325bc0 100644 --- a/infra/modules/keyVault.bicep +++ b/infra/modules/keyVault.bicep @@ -17,12 +17,15 @@ param sku string = 'premium' @description('Optional. Resource ID of the Log Analytics workspace to use for diagnostic settings.') param logAnalyticsWorkspaceResourceId string? +import { resourcePrivateNetworkingType } from 'customTypes.bicep' @description('Optional. Values to establish private networking for the Key Vault resource.') param privateNetworking resourcePrivateNetworkingType? +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' @description('Optional. Array of role assignments to create.') param roleAssignments roleAssignmentType[]? +import { secretType } from 'br/public:avm/res/key-vault/vault:0.12.1' @description('Optional. Array of secrets to create in the Key Vault.') param secrets secretType[]? @@ -85,9 +88,5 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { } } -import { resourcePrivateNetworkingType } from 'customTypes.bicep' -import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' -import { secretType } from 'br/public:avm/res/key-vault/vault:0.12.1' - output resourceId string = keyvault.outputs.resourceId output name string = keyvault.outputs.name diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 0519b85..9021155 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -1,8 +1,15 @@ +@description('Named used for all resource naming.') param resourcesName string + +@description('Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') param logAnalyticsWorkSpaceResourceId string + +@minLength(3) +@description('Azure region for all services.') param location string -param tags object = {} +@description('Optional. Tags to be applied to the resources.') +param tags object = {} // Subnet Classless Inter-Doman Routing (CIDR) Sizing Reference Table (Best Practices) // | CIDR | # of Addresses | # of /24s | Notes | @@ -32,7 +39,6 @@ param tags object = {} // - 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. -// module network 'network/main.bicep' = { name: take('network-${resourcesName}-create', 64) @@ -44,7 +50,7 @@ module network 'network/main.bicep' = { addressPrefixes: ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24) subnets: [ // Only one delegation per subnet is supported by the AVM module as of June 2025. - // For subnets that do not require delegation, leave the array empty. + // For subnets that do not require delegation, leave the value empty. { name: 'web' addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses @@ -66,120 +72,13 @@ module network 'network/main.bicep' = { } ] } - delegations: [ - { - name: 'containerapps-delegation' - serviceName: 'Microsoft.App/environments' - } - ] + delegation: 'Microsoft.App/environments' } { - name: 'app' + name: 'peps' addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses - networkSecurityGroup: { - name: 'app-nsg' - securityRules: [ - { - name: 'AllowWebToApp' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/23'] // web subnet - destinationAddressPrefixes: ['10.0.2.0/23'] - } - } - ] - } - delegations: [ - { - name: 'containerapps-delegation' - serviceName: 'Microsoft.App/environments' - } - ] - } - { - name: 'ai' - addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses - networkSecurityGroup: { - name: 'ai-nsg' - securityRules: [ - { - name: 'AllowWebAppToAI' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - ] - destinationAddressPrefixes: ['10.0.4.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } - { - name: 'data' - addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255) - networkSecurityGroup: { - name: 'data-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToData' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - '10.0.4.0/23' // ai subnet - ] - destinationAddressPrefixes: ['10.0.6.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } - { - name: 'services' - addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses - networkSecurityGroup: { - name: 'services-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToServices' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - '10.0.4.0/23' // ai subnet - ] - destinationAddressPrefixes: ['10.0.8.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' } ] enableBastionHost: true // Set to true to enable Azure Bastion Host creation. @@ -220,7 +119,9 @@ module network 'network/main.bicep' = { output vnetName string = network.outputs.vnetName output vnetResourceId string = network.outputs.vnetResourceId -output subnets array = network.outputs.subnets // This one holds critical info for subnets, including NSGs + +output subnetWebResourceId string = first(filter(network.outputs.subnets, s => s.name == 'web')).?resourceId ?? '' +output subnetPrivateEndpointsResourceId string = first(filter(network.outputs.subnets, s => s.name == 'peps')).?resourceId ?? '' output bastionSubnetId string = network.outputs.bastionSubnetId output bastionSubnetName string = network.outputs.bastionSubnetName @@ -231,4 +132,3 @@ output jumpboxSubnetName string = network.outputs.jumpboxSubnetName output jumpboxSubnetId string = network.outputs.jumpboxSubnetId output jumpboxVmName string = network.outputs.jumpboxVmName output jumpboxVmId string = network.outputs.jumpboxVmId - diff --git a/infra/modules/network/jumpbox.bicep b/infra/modules/network/jumpbox.bicep index cd3af4b..49ea081 100644 --- a/infra/modules/network/jumpbox.bicep +++ b/infra/modules/network/jumpbox.bicep @@ -108,3 +108,25 @@ output subnetId string = jbSubnet.outputs.resourceId output subnetName string = jbSubnet.outputs.name output nsgId string = jbNsg.outputs.resourceId output nsgName string = jbNsg.outputs.name + +import { subnetType } from 'virtualNetwork.bicep' + +@export() +@description('Custom type definition for establishing Jumpbox Virtual Machine and its associated resources.') +type jumpBoxConfigurationType = { + @description('The name of the Virtual Machine.') + name: string + + @description('The size of the VM.') + size: string + + @description('Username to access VM.') + username: string + + @secure() + @description('Password to access VM.') + password: string + + @description('Optional. Subnet configuration for the Jumpbox VM.') + subnet: subnetType? +} diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index 73a418d..2b46a6b 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -1,6 +1,6 @@ @minLength(6) @maxLength(25) -@description('Default name used for all resources.') +@description('Name used for naming all network resources.') param resourcesName string @minLength(3) @@ -10,17 +10,17 @@ param location string @description('Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') param logAnalyticsWorkSpaceResourceId string -@description('Networking address prefix for the VNET and subnets.') +@description('Networking address prefix for the VNET.') param addressPrefixes array +import { subnetType } from 'virtualNetwork.bicep' @description('Array of subnets to be created within the VNET.') -param subnets array +param subnets subnetType[] @description('Optional. Tags to be applied to the resources.') param tags object = {} + - -var vnetName = 'vnet-${resourcesName}' // jumpbox parameters param jumpboxVM bool = false // set in .bicepparam file @@ -40,6 +40,7 @@ var bastionHostName = 'bastionHost-${resourcesName}' // /****************************************************************************************************************************/ // Networking - NSGs, VNET and Subnets. Each subnet has its own NSG // /****************************************************************************************************************************/ +var vnetName = 'vnet-${resourcesName}' module virtualNetwork 'virtualNetwork.bicep' = { name: '${resourcesName}-virtualNetwork' @@ -91,7 +92,9 @@ module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { output vnetName string = virtualNetwork.outputs.name output vnetResourceId string = virtualNetwork.outputs.resourceId -output subnets array = virtualNetwork.outputs.subnets // This one holds critical info for subnets, including NSGs + +import { subnetOutputType } from 'virtualNetwork.bicep' +output subnets subnetOutputType[] = virtualNetwork.outputs.subnets // This one holds critical info for subnets, including NSGs output bastionSubnetId string = bastionHost.outputs.subnetId output bastionSubnetName string = bastionHost.outputs.subnetName @@ -104,3 +107,4 @@ output jumpboxVmName string = jumpbox.outputs.vmName output jumpboxVmId string = jumpbox.outputs.vmId + diff --git a/infra/modules/network/virtualNetwork.bicep b/infra/modules/network/virtualNetwork.bicep index c1c9ff1..d9ed9bb 100644 --- a/infra/modules/network/virtualNetwork.bicep +++ b/infra/modules/network/virtualNetwork.bicep @@ -5,23 +5,22 @@ param location string = resourceGroup().location param name string param addressPrefixes array -param subnets array +param subnets subnetType[] = [] param tags object = {} param logAnalyticsWorkspaceId string - // 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('${name}-${subnet.networkSecurityGroup.name}-networksecuritygroup', 64) + for (subnet, i) in subnets: if (!empty(subnet.?networkSecurityGroup)) { + name: take('${name}-${subnet.?networkSecurityGroup.name}-networksecuritygroup', 64) params: { - name: '${name}-${subnet.networkSecurityGroup.name}' + name: '${name}-${subnet.?networkSecurityGroup.name}' location: location - securityRules: subnet.networkSecurityGroup.securityRules + securityRules: subnet.?networkSecurityGroup.securityRules tags: tags } } @@ -40,9 +39,11 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { subnets: [ for (subnet, i) in subnets: { name: subnet.name - addressPrefixes: subnet.addressPrefixes - networkSecurityGroupResourceId: !empty(subnet.networkSecurityGroup) ? nsgs[i].outputs.resourceId : null - delegation: !empty(subnet.delegations) ? subnet.delegations[0].serviceName : null // AVM module expects a single delegation per subnet + addressPrefixes: subnet.?addressPrefixes + networkSecurityGroupResourceId: !empty(subnet.?networkSecurityGroup) ? nsgs[i].outputs.resourceId : null + privateEndpointNetworkPolicies: subnet.?privateEndpointNetworkPolicies + privateLinkServiceNetworkPolicies: subnet.?privateLinkServiceNetworkPolicies + delegation: subnet.?delegation } ] diagnosticSettings: [ @@ -71,11 +72,74 @@ 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 array = [ +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 + nsgName: !empty(subnet.?networkSecurityGroup) ? subnet.?networkSecurityGroup.name : null + nsgResourceId: !empty(subnet.?networkSecurityGroup) ? nsgs[i].outputs.resourceId : null } ] + +@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('Conditional. The address prefix for the subnet. Required if `addressPrefixes` is empty.') + addressPrefix: string? + + @description('Conditional. List of address prefixes for the subnet. Required if `addressPrefix` is empty.') + 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[] +} diff --git a/infra/modules/storageAccount.bicep b/infra/modules/storageAccount.bicep index dc17aff..2d25ae8 100644 --- a/infra/modules/storageAccount.bicep +++ b/infra/modules/storageAccount.bicep @@ -26,6 +26,7 @@ param logAnalyticsWorkspaceResourceId string? @description('Optional. Values to establish private networking for the Storage Account.') param privateNetworking storageAccountPrivateNetworkingType? +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' @description('Optional. Array of role assignments to create.') param roleAssignments roleAssignmentType[]? @@ -123,8 +124,6 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { } } -import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' - output name string = storageAccount.outputs.name output resourceId string = storageAccount.outputs.resourceId From 75ee5ac44a46f2c19db2caebb7d1f3166a6ac351 Mon Sep 17 00:00:00 2001 From: Seth Date: Fri, 13 Jun 2025 09:08:20 -0400 Subject: [PATCH 070/124] WAF - sln name update, networking param types --- infra/main.bicepparam | 2 +- infra/modules/network.bicep | 63 ++++++++++--------- infra/modules/network/bastionHost.bicep | 39 +++++++++--- infra/modules/network/jumpbox.bicep | 84 +++++++++++++++---------- infra/modules/network/main.bicep | 61 ++++++++---------- 5 files changed, 139 insertions(+), 110 deletions(-) diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 5ac276b..24f8799 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -1,4 +1,4 @@ using './main.bicep' -param environmentName = readEnvironmentVariable('AZURE_ENV_NAME') +param solutionName = readEnvironmentVariable('AZURE_ENV_NAME') param location = readEnvironmentVariable('AZURE_LOCATION') diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 9021155..332e577 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -81,37 +81,38 @@ module network 'network/main.bicep' = { privateLinkServiceNetworkPolicies: 'Disabled' } ] - enableBastionHost: true // Set to true to enable Azure Bastion Host creation. - bastionSubnet: { - addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses - networkSecurityGroup: null // Azure Bastion subnet must NOT have an NSG + bastionConfiguration: { + name: 'bastion-${resourcesName}' + subnetAddressPrefixes: ['10.0.10.0/23'] } - jumpboxVM: true // Set to true to enable Jumpbox VM creation. - jumpboxVmSize: 'Standard_D2s_v3' - jumpboxAdminUser: 'JumpboxAdminUser' - jumpboxAdminPassword: 'JumpboxAdminP@ssw0rd1234!' - jumpboxSubnet: { - name: 'jumpbox' - addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: [ - '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more - ] - destinationAddressPrefixes: ['10.0.12.0/23'] + jumpboxConfiguration: { + name: 'vm-jumpbox-${resourcesName}' + size: 'Standard_D2s_v3' + username: 'JumpboxAdminUser' + password: 'JumpboxAdminP@ssw0rd1234!' + subnet: { + name: 'jumpbox' + addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more + ] + destinationAddressPrefixes: ['10.0.12.0/23'] + } } - } - ] + ] + } } } } @@ -130,5 +131,5 @@ output bastionHostName string = network.outputs.bastionHostName output jumpboxSubnetName string = network.outputs.jumpboxSubnetName output jumpboxSubnetId string = network.outputs.jumpboxSubnetId -output jumpboxVmName string = network.outputs.jumpboxVmName -output jumpboxVmId string = network.outputs.jumpboxVmId +output jumpboxName string = network.outputs.jumpboxName +output jumpboxResourceId string = network.outputs.jumpboxResourceId diff --git a/infra/modules/network/bastionHost.bicep b/infra/modules/network/bastionHost.bicep index 3e6e2ff..c9a04a4 100644 --- a/infra/modules/network/bastionHost.bicep +++ b/infra/modules/network/bastionHost.bicep @@ -2,30 +2,43 @@ // Create Azure Bastion Subnet and Azure Bastion Host // /****************************************************************************************************************************/ -param subnet object = {} +@description('Name of the Azure Bastion Host resource.') +param name string + +@description('Azure region to deploy resources.') param location string = resourceGroup().location -param vnetName string -param vnetId string // Resource ID of the Virtual Network -param name string = 'AzureBastionHost' // Default name for Azure Bastion Host + +@description('Conditional. List of address prefixes for the subnet. Leave empty to skip subnet creation.') +param subnetAddressPrefixes string[]? + +@description('Resource ID of the Virtual Network where the Azure Bastion Host will be deployed.') +param vnetId string + +@description('Name of the Virtual Network where the Azure Bastion Host will be deployed.') +param vnetName string + +@description('Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') param logAnalyticsWorkspaceId string + +@description('Optional. Tags to apply to the resources.') param tags object = {} // 1. Create Azure Bastion Host using AVM Subnet Module with special config for Azure Bastion Subnet // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet -module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(subnet)) { +module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(subnetAddressPrefixes)) { name: take('bastionSubnet-${vnetName}', 64) params: { virtualNetworkName: vnetName name: 'AzureBastionSubnet' - addressPrefixes: subnet.addressPrefixes + addressPrefixes: subnetAddressPrefixes } } // 2. Create Azure Bastion Host in AzureBastionsubnetSubnet using AVM Bastion Host module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/bastion-host -module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (!empty(subnet)) { - name: name +module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = { + name: take('bastionHost-${vnetName}-${name}', 64) params: { name: name skuName: 'Standard' @@ -51,3 +64,13 @@ output resourceId string = bastionHost.outputs.resourceId output name string = bastionHost.outputs.name output subnetId string = bastionSubnet.outputs.resourceId output subnetName string = bastionSubnet.outputs.name + +@export() +@description('Custom type definition for establishing Bastion Host for remote connection.') +type bastionHostConfigurationType = { + @description('The name of the Bastion Host resource.') + name: string + + @description('Optional. List of address prefixes for the subnet.') + subnetAddressPrefixes: string[]? +} diff --git a/infra/modules/network/jumpbox.bicep b/infra/modules/network/jumpbox.bicep index 49ea081..163ae43 100644 --- a/infra/modules/network/jumpbox.bicep +++ b/infra/modules/network/jumpbox.bicep @@ -1,28 +1,45 @@ // /****************************************************************************************************************************/ // Create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM // /****************************************************************************************************************************/ -param vmName string = 'jumpboxVM' // Default name for Jumpbox VM + +@description('Name of the Jumpbox Virtual Machine.') +param name string + +@description('Azure region to deploy resources.') param location string = resourceGroup().location + +@description('Name of the Virtual Network where the Jumpbox VM will be deployed.') param vnetName string -param jumpboxVmSize string = 'Standard_D2s_v3' // Default VM size for Jumpbox, can be overridden -param jumpboxSubnet object = {} // This was defined in the .param file as a complex object -param jumpboxAdminUser string = 'JumpboxAdminUser' // Default admin username for Jumpbox VM +@description('Size of the Jumpbox Virtual Machine.') +param size string + +import { subnetType } from 'virtualNetwork.bicep' +@description('Optional. Subnet configuration for the Jumpbox VM.') +param subnet subnetType? + +@description('Username to access the Jumpbox VM.') +param username string + @secure() -param jumpboxAdminPassword string +@description('Password to access the Jumpbox VM.') +param password string +@description('Optional. Tags to apply to the resources.') param tags object = {} + +@description('Log Analytics Workspace Resource ID for VM diagnostics.') param logAnalyticsWorkspaceId string // 1. Create Jumpbox NSG // using AVM Network Security Group module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group -module jbNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (!empty(jumpboxSubnet)) { - name: '${vnetName}-${jumpboxSubnet.networkSecurityGroup.name}' +module nsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (!empty(subnet)) { + name: '${vnetName}-${subnet.?networkSecurityGroup.name}' params: { - name: '${vnetName}-${jumpboxSubnet.networkSecurityGroup.name}' + name: '${vnetName}-${subnet.?networkSecurityGroup.name}' location: location - securityRules: jumpboxSubnet.networkSecurityGroup.securityRules + securityRules: subnet.?networkSecurityGroup.securityRules tags: tags } } @@ -30,28 +47,29 @@ module jbNsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (!emp // 2. Create Jumpbox subnet as part of the existing VNet // using AVM Virtual Network Subnet module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet -module jbSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(jumpboxSubnet)) { - name: jumpboxSubnet.name +module subnetResource 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(subnet)) { + name: subnet.?name ?? '${vnetName}-jumpbox-subnet' params: { virtualNetworkName: vnetName - name: jumpboxSubnet.name - addressPrefixes: jumpboxSubnet.addressPrefixes - networkSecurityGroupResourceId: jbNsg.outputs.resourceId + name: subnet.?name ?? '' + addressPrefixes: subnet.?addressPrefixes + networkSecurityGroupResourceId: nsg.outputs.resourceId } } // 3. Create Jumpbox VM // using AVM Virtual Machine module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/compute/virtual-machine -var limitedVmName = take(vmName, 15) // Shorten VM name to 15 characters to avoid Azure limits -module jbVm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { - name: vmName +var vmName = take(name, 15) // Shorten VM name to 15 characters to avoid Azure limits + +module vm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { + name: take('${vmName}-jumpbox', 64) params: { - name: limitedVmName - vmSize: jumpboxVmSize + name: vmName + vmSize: size location: location - adminUsername: jumpboxAdminUser - adminPassword: jumpboxAdminPassword + adminUsername: username + adminPassword: password tags: tags zone: 2 imageReference: { @@ -69,14 +87,14 @@ module jbVm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { encryptionAtHost: false // Some Azure subscriptions do not support encryption at host nicConfigurations: [ { - name: '${limitedVmName}-nic' + name: '${vmName}-nic' ipConfigurations: [ { name: 'ipconfig1' - subnetResourceId: jbSubnet.outputs.resourceId + subnetResourceId: subnetResource.outputs.resourceId } ] - networkSecurityGroupResourceId: jbNsg.outputs.resourceId + networkSecurityGroupResourceId: nsg.outputs.resourceId diagnosticSettings: [ { name: 'jumpboxDiagnostics' @@ -100,16 +118,14 @@ module jbVm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { } } -output vmId string = jbVm.outputs.resourceId -output vmName string = jbVm.outputs.name -output vMLocation string = jbVm.outputs.location +output resourceId string = vm.outputs.resourceId +output name string = vm.outputs.name +output location string = vm.outputs.location -output subnetId string = jbSubnet.outputs.resourceId -output subnetName string = jbSubnet.outputs.name -output nsgId string = jbNsg.outputs.resourceId -output nsgName string = jbNsg.outputs.name - -import { subnetType } from 'virtualNetwork.bicep' +output subnetId string = subnetResource.outputs.resourceId +output subnetName string = subnetResource.outputs.name +output nsgId string = nsg.outputs.resourceId +output nsgName string = nsg.outputs.name @export() @description('Custom type definition for establishing Jumpbox Virtual Machine and its associated resources.') @@ -118,7 +134,7 @@ type jumpBoxConfigurationType = { name: string @description('The size of the VM.') - size: string + size: string? @description('Username to access VM.') username: string diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index 36842a3..919a8a8 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -17,35 +17,25 @@ import { subnetType } from 'virtualNetwork.bicep' @description('Array of subnets to be created within the VNET.') param subnets subnetType[] -@description('Optional. Tags to be applied to the resources.') -param tags object = {} - - - -// jumpbox parameters -param jumpboxVM bool = false -param jumpboxSubnet object = {} -param jumpboxAdminUser string = 'JumpboxAdminUser' -@secure() -param jumpboxAdminPassword string -param jumpboxVmSize string = 'Standard_D2s_v3' -var jumpboxVmName = 'jumpboxVM-${resourcesName}' - -// Azure Bastion Host parameters -param enableBastionHost bool = false -param bastionSubnet object = {} -var bastionHostName = 'bastionHost-${resourcesName}' +import { jumpBoxConfigurationType } from 'jumpbox.bicep' +@description('Optional. Configuration for the Jumpbox VM. Leave null to omit Jumpbox creation.') +param jumpboxConfiguration jumpBoxConfigurationType? +import { bastionHostConfigurationType } from 'bastionHost.bicep' +@description('Optional. Configuration for the Azure Bastion Host. Leave null to omit Bastion creation.') +param bastionConfiguration bastionHostConfigurationType? +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + // /****************************************************************************************************************************/ // Networking - NSGs, VNET and Subnets. Each subnet has its own NSG // /****************************************************************************************************************************/ -var vnetName = 'vnet-${resourcesName}' module virtualNetwork 'virtualNetwork.bicep' = { name: '${resourcesName}-virtualNetwork' params: { - name: vnetName + name: 'vnet-${resourcesName}' addressPrefixes: addressPrefixes subnets: subnets location: location @@ -58,15 +48,15 @@ module virtualNetwork 'virtualNetwork.bicep' = { // // Create Azure Bastion Subnet and Azure Bastion Host // /****************************************************************************************************************************/ -module bastionHost 'bastionHost.bicep' = if (enableBastionHost && !empty(bastionSubnet)) { +module bastionHost 'bastionHost.bicep' = if (!empty(bastionConfiguration)) { name: '${resourcesName}-bastionHost' params: { - subnet: bastionSubnet - location: location - vnetName: virtualNetwork.outputs.name + name: bastionConfiguration.?name ?? 'bastion-${resourcesName}' vnetId: virtualNetwork.outputs.resourceId - name: bastionHostName + vnetName: virtualNetwork.outputs.name + location: location logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + subnetAddressPrefixes: bastionConfiguration.?subnetAddressPrefixes tags: tags } } @@ -75,18 +65,17 @@ module bastionHost 'bastionHost.bicep' = if (enableBastionHost && !empty(bastion // // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM // /****************************************************************************************************************************/ -module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { +module jumpbox 'jumpbox.bicep' = if (!empty(jumpboxConfiguration)) { name: '${resourcesName}-jumpbox' params: { - vmName: jumpboxVmName - location: location + name: jumpboxConfiguration.?name ?? 'vm-jumpbox-${resourcesName}' vnetName: virtualNetwork.outputs.name - jumpboxVmSize: jumpboxVmSize - jumpboxSubnet: jumpboxSubnet - jumpboxAdminUser: jumpboxAdminUser - jumpboxAdminPassword: jumpboxAdminPassword - tags: tags + size: jumpboxConfiguration.?size ?? 'Standard_D2s_v3' logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + location: location + subnet: jumpboxConfiguration.?subnet + username: jumpboxConfiguration.?username ?? '' // required + password: jumpboxConfiguration.?password ?? '' // required } } @@ -101,8 +90,8 @@ output bastionSubnetName string = bastionHost.outputs.subnetName output bastionHostId string = bastionHost.outputs.resourceId output bastionHostName string = bastionHost.outputs.name -output jumpboxSubnetName string = jumpbox.outputs.subnetId +output jumpboxSubnetName string = jumpbox.outputs.subnetName output jumpboxSubnetId string = jumpbox.outputs.subnetId -output jumpboxVmName string = jumpbox.outputs.vmName -output jumpboxVmId string = jumpbox.outputs.vmId +output jumpboxName string = jumpbox.outputs.name +output jumpboxResourceId string = jumpbox.outputs.resourceId From 005a4239ebc5f212b9349a117930920ce45efd0e Mon Sep 17 00:00:00 2001 From: Seth Date: Fri, 13 Jun 2025 09:52:54 -0400 Subject: [PATCH 071/124] WAF - added telemetry flag. file cleanup --- infra/braches.mc | 0 infra/main.bicep | 17 +- infra/main.json | 7679 -------------------- infra/main.parameters.json | 56 - infra/main.waf-aligned.bicepparam | 10 + infra/modules/aiFoundry.bicep | 7 + infra/modules/aiServices.bicep | 6 + infra/modules/cosmosDb.bicep | 5 + infra/modules/keyVault.bicep | 5 + infra/modules/network.bicep | 14 +- infra/modules/network/bastionHost.bicep | 5 + infra/modules/network/jumpbox.bicep | 6 + infra/modules/network/main.bicep | 7 + infra/modules/network/virtualNetwork.bicep | 19 +- infra/modules/storageAccount.bicep | 6 + 15 files changed, 94 insertions(+), 7748 deletions(-) delete mode 100644 infra/braches.mc delete mode 100644 infra/main.json delete mode 100644 infra/main.parameters.json create mode 100644 infra/main.waf-aligned.bicepparam diff --git a/infra/braches.mc b/infra/braches.mc deleted file mode 100644 index e69de29..0000000 diff --git a/infra/main.bicep b/infra/main.bicep index 75b1f99..fb95327 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -60,6 +60,9 @@ param enablePrivateNetworking bool = false @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + var allTags = union({ 'azd-env-name': solutionName }, tags) @@ -86,6 +89,7 @@ module appIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0. name: 'id-app-${resourcesName}' location: location tags: allTags + enableTelemetry: enableTelemetry } } @@ -95,6 +99,7 @@ module aiFoundryIdentity 'br/public:avm/res/managed-identity/user-assigned-ident name: 'id-proj-${resourcesName}' location: location tags: allTags + enableTelemetry: enableTelemetry } } @@ -107,6 +112,7 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 dataRetention: 30 diagnosticSettings: [{ useThisWorkspace: true }] tags: allTags + enableTelemetry: enableTelemetry } } @@ -118,6 +124,7 @@ module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (en workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId }] tags: allTags + enableTelemetry: enableTelemetry } } @@ -128,6 +135,7 @@ module network 'modules/network.bicep' = if (enablePrivateNetworking) { logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId location: location tags: allTags + enableTelemetry: enableTelemetry } } @@ -165,6 +173,7 @@ module aiServices 'modules/aiServices.bicep' = { } ] tags: allTags + enableTelemetry: enableTelemetry } } @@ -199,6 +208,7 @@ module storageAccount 'modules/storageAccount.bicep' = { roleDefinitionIdOrName: 'Storage Blob Data Contributor' } ] + enableTelemetry: enableTelemetry } } @@ -223,6 +233,7 @@ module keyVault 'modules/keyVault.bicep' = { } ] tags: allTags + enableTelemetry: enableTelemetry } } @@ -252,6 +263,7 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { } ] tags: allTags + enableTelemetry: enableTelemetry } } @@ -271,6 +283,7 @@ module cosmosDb 'modules/cosmosDb.bicep' = { subnetResourceId: network.outputs.subnetPrivateEndpointsResourceId } : null tags: allTags + enableTelemetry: enableTelemetry } } @@ -307,6 +320,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. } ] : [] tags: allTags + enableTelemetry: enableTelemetry } } @@ -354,6 +368,7 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.17.0' = { ] : [] } tags: allTags + enableTelemetry: enableTelemetry } } @@ -509,6 +524,6 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.17.0' = { ] : [] } tags: allTags + enableTelemetry: enableTelemetry } } - diff --git a/infra/main.json b/infra/main.json deleted file mode 100644 index c2344f8..0000000 --- a/infra/main.json +++ /dev/null @@ -1,7679 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.33.93.31351", - "templateHash": "16851365929061577011" - } - }, - "parameters": { - "Prefix": { - "type": "string", - "minLength": 3, - "metadata": { - "description": "Prefix for all resources created by this template. This should be 3-20 characters long. If your provide a prefix longer than 20 characters, it will be truncated to 20 characters." - } - }, - "AzureAiServiceLocation": { - "type": "string", - "allowedValues": [ - "australiaeast", - "brazilsouth", - "canadacentral", - "canadaeast", - "eastus", - "eastus2", - "francecentral", - "germanywestcentral", - "japaneast", - "koreacentral", - "northcentralus", - "norwayeast", - "polandcentral", - "southafricanorth", - "southcentralus", - "southindia", - "swedencentral", - "switzerlandnorth", - "uaenorth", - "uksouth", - "westeurope", - "westus", - "westus3" - ], - "metadata": { - "description": "Location for all Ai services resources. This location can be different from the resource group location." - } - }, - "capacity": { - "type": "int", - "defaultValue": 5 - } - }, - "variables": { - "$fxv#0": { - "ai": { - "aiSearch": "srch-", - "aiServices": "aisa-", - "aiVideoIndexer": "avi-", - "machineLearningWorkspace": "mlw-", - "openAIService": "oai-", - "botService": "bot-", - "computerVision": "cv-", - "contentModerator": "cm-", - "contentSafety": "cs-", - "customVisionPrediction": "cstv-", - "customVisionTraining": "cstvt-", - "documentIntelligence": "di-", - "faceApi": "face-", - "healthInsights": "hi-", - "immersiveReader": "ir-", - "languageService": "lang-", - "speechService": "spch-", - "translator": "trsl-", - "aiHub": "aih-", - "aiHubProject": "aihp-" - }, - "analytics": { - "analysisServicesServer": "as", - "databricksWorkspace": "dbw-", - "dataExplorerCluster": "dec", - "dataExplorerClusterDatabase": "dedb", - "dataFactory": "adf-", - "digitalTwin": "dt-", - "streamAnalytics": "asa-", - "synapseAnalyticsPrivateLinkHub": "synplh-", - "synapseAnalyticsSQLDedicatedPool": "syndp", - "synapseAnalyticsSparkPool": "synsp", - "synapseAnalyticsWorkspaces": "synw", - "dataLakeStoreAccount": "dls", - "dataLakeAnalyticsAccount": "dla", - "eventHubsNamespace": "evhns-", - "eventHub": "evh-", - "eventGridDomain": "evgd-", - "eventGridSubscriptions": "evgs-", - "eventGridTopic": "evgt-", - "eventGridSystemTopic": "egst-", - "hdInsightHadoopCluster": "hadoop-", - "hdInsightHBaseCluster": "hbase-", - "hdInsightKafkaCluster": "kafka-", - "hdInsightSparkCluster": "spark-", - "hdInsightStormCluster": "storm-", - "hdInsightMLServicesCluster": "mls-", - "iotHub": "iot-", - "provisioningServices": "provs-", - "provisioningServicesCertificate": "pcert-", - "powerBIEmbedded": "pbi-", - "timeSeriesInsightsEnvironment": "tsi-" - }, - "compute": { - "appServiceEnvironment": "ase-", - "appServicePlan": "asp-", - "loadTesting": "lt-", - "availabilitySet": "avail-", - "arcEnabledServer": "arcs-", - "arcEnabledKubernetesCluster": "arck", - "batchAccounts": "ba-", - "cloudService": "cld-", - "communicationServices": "acs-", - "diskEncryptionSet": "des", - "functionApp": "func-", - "gallery": "gal", - "hostingEnvironment": "host-", - "imageTemplate": "it-", - "managedDiskOS": "osdisk", - "managedDiskData": "disk", - "notificationHubs": "ntf-", - "notificationHubsNamespace": "ntfns-", - "proximityPlacementGroup": "ppg-", - "restorePointCollection": "rpc-", - "snapshot": "snap-", - "staticWebApp": "stapp-", - "virtualMachine": "vm", - "virtualMachineScaleSet": "vmss-", - "virtualMachineMaintenanceConfiguration": "mc-", - "virtualMachineStorageAccount": "stvm", - "webApp": "app-" - }, - "containers": { - "aksCluster": "aks-", - "aksSystemNodePool": "npsystem-", - "aksUserNodePool": "np-", - "containerApp": "ca-", - "containerAppsEnvironment": "cae-", - "containerRegistry": "cr", - "containerInstance": "ci", - "serviceFabricCluster": "sf-", - "serviceFabricManagedCluster": "sfmc-" - }, - "databases": { - "cosmosDBDatabase": "cosmos-", - "cosmosDBApacheCassandra": "coscas-", - "cosmosDBMongoDB": "cosmon-", - "cosmosDBNoSQL": "cosno-", - "cosmosDBTable": "costab-", - "cosmosDBGremlin": "cosgrm-", - "cosmosDBPostgreSQL": "cospos-", - "cacheForRedis": "redis-", - "sqlDatabaseServer": "sql-", - "sqlDatabase": "sqldb-", - "sqlElasticJobAgent": "sqlja-", - "sqlElasticPool": "sqlep-", - "mariaDBServer": "maria-", - "mariaDBDatabase": "mariadb-", - "mySQLDatabase": "mysql-", - "postgreSQLDatabase": "psql-", - "sqlServerStretchDatabase": "sqlstrdb-", - "sqlManagedInstance": "sqlmi-" - }, - "developerTools": { - "appConfigurationStore": "appcs-", - "mapsAccount": "map-", - "signalR": "sigr", - "webPubSub": "wps-" - }, - "devOps": { - "managedGrafana": "amg-" - }, - "integration": { - "apiManagementService": "apim-", - "integrationAccount": "ia-", - "logicApp": "logic-", - "serviceBusNamespace": "sbns-", - "serviceBusQueue": "sbq-", - "serviceBusTopic": "sbt-", - "serviceBusTopicSubscription": "sbts-" - }, - "managementGovernance": { - "automationAccount": "aa-", - "applicationInsights": "appi-", - "monitorActionGroup": "ag-", - "monitorDataCollectionRules": "dcr-", - "monitorAlertProcessingRule": "apr-", - "blueprint": "bp-", - "blueprintAssignment": "bpa-", - "dataCollectionEndpoint": "dce-", - "logAnalyticsWorkspace": "log-", - "logAnalyticsQueryPacks": "pack-", - "managementGroup": "mg-", - "purviewInstance": "pview-", - "resourceGroup": "rg-", - "templateSpecsName": "ts-" - }, - "migration": { - "migrateProject": "migr-", - "databaseMigrationService": "dms-", - "recoveryServicesVault": "rsv-" - }, - "networking": { - "applicationGateway": "agw-", - "applicationSecurityGroup": "asg-", - "cdnProfile": "cdnp-", - "cdnEndpoint": "cdne-", - "connections": "con-", - "dnsForwardingRuleset": "dnsfrs-", - "dnsPrivateResolver": "dnspr-", - "dnsPrivateResolverInboundEndpoint": "in-", - "dnsPrivateResolverOutboundEndpoint": "out-", - "firewall": "afw-", - "firewallPolicy": "afwp-", - "expressRouteCircuit": "erc-", - "expressRouteGateway": "ergw-", - "frontDoorProfile": "afd-", - "frontDoorEndpoint": "fde-", - "frontDoorFirewallPolicy": "fdfp-", - "ipGroups": "ipg-", - "loadBalancerInternal": "lbi-", - "loadBalancerExternal": "lbe-", - "loadBalancerRule": "rule-", - "localNetworkGateway": "lgw-", - "natGateway": "ng-", - "networkInterface": "nic-", - "networkSecurityGroup": "nsg-", - "networkSecurityGroupSecurityRules": "nsgsr-", - "networkWatcher": "nw-", - "privateLink": "pl-", - "privateEndpoint": "pep-", - "publicIPAddress": "pip-", - "publicIPAddressPrefix": "ippre-", - "routeFilter": "rf-", - "routeServer": "rtserv-", - "routeTable": "rt-", - "serviceEndpointPolicy": "se-", - "trafficManagerProfile": "traf-", - "userDefinedRoute": "udr-", - "virtualNetwork": "vnet-", - "virtualNetworkGateway": "vgw-", - "virtualNetworkManager": "vnm-", - "virtualNetworkPeering": "peer-", - "virtualNetworkSubnet": "snet-", - "virtualWAN": "vwan-", - "virtualWANHub": "vhub-" - }, - "security": { - "bastion": "bas-", - "keyVault": "kv-", - "keyVaultManagedHSM": "kvmhsm-", - "managedIdentity": "id-", - "sshKey": "sshkey-", - "vpnGateway": "vpng-", - "vpnConnection": "vcn-", - "vpnSite": "vst-", - "webApplicationFirewallPolicy": "waf", - "webApplicationFirewallPolicyRuleGroup": "wafrg" - }, - "storage": { - "storSimple": "ssimp", - "backupVault": "bvault-", - "backupVaultPolicy": "bkpol-", - "fileShare": "share-", - "storageAccount": "st", - "storageSyncService": "sss-" - }, - "virtualDesktop": { - "labServicesPlan": "lp-", - "virtualDesktopHostPool": "vdpool-", - "virtualDesktopApplicationGroup": "vdag-", - "virtualDesktopWorkspace": "vdws-", - "virtualDesktopScalingPlan": "vdscaling-" - } - }, - "abbrs": "[variables('$fxv#0')]", - "safePrefix": "[if(greater(length(parameters('Prefix')), 20), substring(parameters('Prefix'), 0, 20), parameters('Prefix'))]", - "uniqueId": "[toLower(uniqueString(subscription().id, variables('safePrefix'), resourceGroup().location))]", - "UniquePrefix": "[format('cm{0}', padLeft(take(variables('uniqueId'), 12), 12, '0'))]", - "ResourcePrefix": "[take(format('cm{0}{1}', variables('safePrefix'), variables('UniquePrefix')), 15)]", - "imageVersion": "latest", - "location": "[resourceGroup().location]", - "dblocation": "[resourceGroup().location]", - "cosmosdbDatabase": "cmsadb", - "cosmosdbBatchContainer": "cmsabatch", - "cosmosdbFileContainer": "cmsafile", - "cosmosdbLogContainer": "cmsalog", - "deploymentType": "GlobalStandard", - "containerName": "appstorage", - "llmModel": "gpt-4o", - "storageSkuName": "Standard_LRS", - "storageContainerName": "[replace(replace(replace(replace(format('{0}cast', variables('ResourcePrefix')), '-', ''), '_', ''), '.', ''), '/', '')]", - "gptModelVersion": "2024-08-06", - "azureAiServicesName": "[format('{0}{1}', variables('abbrs').ai.aiServices, variables('ResourcePrefix'))]", - "aiModelDeployments": [ - { - "name": "[variables('llmModel')]", - "model": "[variables('llmModel')]", - "version": "[variables('gptModelVersion')]", - "sku": { - "name": "[variables('deploymentType')]", - "capacity": "[parameters('capacity')]" - }, - "raiPolicyName": "Microsoft.Default" - } - ], - "openAiContributorRoleId": "a001fd3d-188f-4b5d-821b-7da978bf7442", - "containerNames": [ - "[variables('containerName')]" - ] - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2024-04-01-preview", - "name": "[variables('azureAiServicesName')]", - "location": "[variables('location')]", - "sku": { - "name": "S0" - }, - "kind": "AIServices", - "properties": { - "customSubDomainName": "[variables('azureAiServicesName')]" - } - }, - { - "copy": { - "name": "azureAiServicesDeployments", - "count": "[length(variables('aiModelDeployments'))]", - "mode": "serial", - "batchSize": 1 - }, - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2023-05-01", - "name": "[format('{0}/{1}', variables('azureAiServicesName'), variables('aiModelDeployments')[copyIndex()].name)]", - "properties": { - "model": { - "format": "OpenAI", - "name": "[variables('aiModelDeployments')[copyIndex()].model]", - "version": "[variables('aiModelDeployments')[copyIndex()].version]" - }, - "raiPolicyName": "[variables('aiModelDeployments')[copyIndex()].raiPolicyName]" - }, - "sku": { - "name": "[variables('aiModelDeployments')[copyIndex()].sku.name]", - "capacity": "[variables('aiModelDeployments')[copyIndex()].sku.capacity]" - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', variables('azureAiServicesName'))]" - ] - }, - { - "type": "Microsoft.App/containerApps", - "apiVersion": "2023-05-01", - "name": "[toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))]", - "location": "[variables('location')]", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "managedEnvironmentId": "[reference(resourceId('Microsoft.Resources/deployments', toLower(format('{0}conAppsEnv', variables('ResourcePrefix')))), '2022-09-01').outputs.resourceId.value]", - "configuration": { - "ingress": { - "external": true, - "targetPort": 8000 - } - }, - "template": { - "scale": { - "minReplicas": 1, - "maxReplicas": 1 - }, - "containers": [ - { - "name": "cmsabackend", - "image": "[format('cmsacontainerreg.azurecr.io/cmsabackend:{0}', variables('imageVersion'))]", - "env": [ - { - "name": "COSMOSDB_ENDPOINT", - "value": "[reference(resourceId('Microsoft.Resources/deployments', toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix')))), '2022-09-01').outputs.endpoint.value]" - }, - { - "name": "COSMOSDB_DATABASE", - "value": "[variables('cosmosdbDatabase')]" - }, - { - "name": "COSMOSDB_BATCH_CONTAINER", - "value": "[variables('cosmosdbBatchContainer')]" - }, - { - "name": "COSMOSDB_FILE_CONTAINER", - "value": "[variables('cosmosdbFileContainer')]" - }, - { - "name": "COSMOSDB_LOG_CONTAINER", - "value": "[variables('cosmosdbLogContainer')]" - }, - { - "name": "AZURE_BLOB_ACCOUNT_NAME", - "value": "[variables('storageContainerName')]" - }, - { - "name": "AZURE_BLOB_CONTAINER_NAME", - "value": "[variables('containerName')]" - }, - { - "name": "AZURE_OPENAI_ENDPOINT", - "value": "[format('https://{0}.openai.azure.com/', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.aiServicesName.value)]" - }, - { - "name": "MIGRATOR_AGENT_MODEL_DEPLOY", - "value": "[variables('llmModel')]" - }, - { - "name": "PICKER_AGENT_MODEL_DEPLOY", - "value": "[variables('llmModel')]" - }, - { - "name": "FIXER_AGENT_MODEL_DEPLOY", - "value": "[variables('llmModel')]" - }, - { - "name": "SEMANTIC_VERIFIER_AGENT_MODEL_DEPLOY", - "value": "[variables('llmModel')]" - }, - { - "name": "SYNTAX_CHECKER_AGENT_MODEL_DEPLOY", - "value": "[variables('llmModel')]" - }, - { - "name": "SELECTION_MODEL_DEPLOY", - "value": "[variables('llmModel')]" - }, - { - "name": "TERMINATION_MODEL_DEPLOY", - "value": "[variables('llmModel')]" - }, - { - "name": "AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME", - "value": "[variables('llmModel')]" - }, - { - "name": "AZURE_AI_AGENT_PROJECT_NAME", - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.aiProjectName.value]" - }, - { - "name": "AZURE_AI_AGENT_RESOURCE_GROUP_NAME", - "value": "[resourceGroup().name]" - }, - { - "name": "AZURE_AI_AGENT_SUBSCRIPTION_ID", - "value": "[subscription().subscriptionId]" - }, - { - "name": "AZURE_AI_AGENT_PROJECT_CONNECTION_STRING", - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.projectConnectionString.value]" - } - ], - "resources": { - "cpu": 1, - "memory": "2.0Gi" - } - } - ] - } - }, - "dependsOn": [ - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry')]", - "[resourceId('Microsoft.Resources/deployments', toLower(format('{0}conAppsEnv', variables('ResourcePrefix'))))]", - "[resourceId('Microsoft.Resources/deployments', toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix'))))]", - "[resourceId('Microsoft.Storage/storageAccounts', variables('storageContainerName'))]" - ] - }, - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2022-09-01", - "name": "[variables('storageContainerName')]", - "location": "[variables('location')]", - "sku": { - "name": "[variables('storageSkuName')]" - }, - "kind": "StorageV2", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "accessTier": "Hot", - "allowBlobPublicAccess": false, - "allowCrossTenantReplication": false, - "allowSharedKeyAccess": false, - "encryption": { - "keySource": "Microsoft.Storage", - "requireInfrastructureEncryption": false, - "services": { - "blob": { - "enabled": true, - "keyType": "Account" - }, - "file": { - "enabled": true, - "keyType": "Account" - }, - "queue": { - "enabled": true, - "keyType": "Service" - }, - "table": { - "enabled": true, - "keyType": "Service" - } - } - }, - "isHnsEnabled": false, - "isNfsV3Enabled": false, - "keyPolicy": { - "keyExpirationPeriodInDays": 7 - }, - "largeFileSharesState": "Disabled", - "minimumTlsVersion": "TLS1_2", - "networkAcls": { - "bypass": "AzureServices", - "defaultAction": "Allow" - }, - "supportsHttpsTrafficOnly": true - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Storage/storageAccounts/{0}', variables('storageContainerName'))]", - "name": "[guid(resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))), 'Storage Blob Data Contributor')]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", - "principalId": "[reference(resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))), '2023-05-01', 'full').identity.principalId]" - }, - "dependsOn": [ - "[resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix'))))]", - "[resourceId('Microsoft.Storage/storageAccounts', variables('storageContainerName'))]" - ] - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', variables('azureAiServicesName'))]", - "name": "[guid(resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))), variables('openAiContributorRoleId'))]", - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('openAiContributorRoleId'))]", - "principalId": "[reference(resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))), '2023-05-01', 'full').identity.principalId]" - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', variables('azureAiServicesName'))]", - "[resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix'))))]" - ] - }, - { - "copy": { - "name": "containers", - "count": "[length(variables('containerNames'))]" - }, - "type": "Microsoft.Storage/storageAccounts/blobServices/containers", - "apiVersion": "2021-08-01", - "name": "[format('{0}/default/{1}', variables('storageContainerName'), variables('containerNames')[copyIndex()])]", - "properties": { - "publicAccess": "None" - }, - "dependsOn": [ - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry')]" - ] - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.MachineLearningServices/workspaces/{0}', format('{0}{1}', variables('abbrs').ai.aiHubProject, variables('ResourcePrefix')))]", - "name": "[guid(toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix'))), resourceId('Microsoft.MachineLearningServices/workspaces', format('{0}{1}', variables('abbrs').ai.aiHubProject, variables('ResourcePrefix'))), resourceId('Microsoft.Authorization/roleDefinitions', '64702f94-c441-49e6-a78b-ef80e0188fee'))]", - "properties": { - "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', '64702f94-c441-49e6-a78b-ef80e0188fee')]", - "principalId": "[reference(resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))), '2023-05-01', 'full').identity.principalId]" - }, - "dependsOn": [ - "[resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix'))))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "deploy_managed_identity", - "resourceGroup": "[resourceGroup().name]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "miName": { - "value": "[format('{0}{1}', variables('abbrs').security.managedIdentity, variables('ResourcePrefix'))]" - }, - "solutionName": { - "value": "[variables('ResourcePrefix')]" - }, - "solutionLocation": { - "value": "[variables('location')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.33.93.31351", - "templateHash": "4801202077166882355" - } - }, - "parameters": { - "solutionName": { - "type": "string", - "minLength": 3, - "maxLength": 15, - "metadata": { - "description": "Solution Name" - } - }, - "solutionLocation": { - "type": "string", - "metadata": { - "description": "Solution Location" - } - }, - "miName": { - "type": "string", - "metadata": { - "description": "Name" - } - } - }, - "resources": [ - { - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[parameters('miName')]", - "location": "[parameters('solutionLocation')]", - "tags": { - "app": "[parameters('solutionName')]", - "location": "[parameters('solutionLocation')]" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "name": "[guid(resourceGroup().id, resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('miName')), resourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635'))]", - "properties": { - "principalId": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('miName')), '2023-01-31').principalId]", - "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635')]", - "principalType": "ServicePrincipal" - }, - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('miName'))]" - ] - } - ], - "outputs": { - "managedIdentityOutput": { - "type": "object", - "value": { - "id": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('miName'))]", - "objectId": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('miName')), '2023-01-31').principalId]", - "resourceId": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('miName'))]", - "location": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('miName')), '2023-01-31', 'full').location]", - "name": "[parameters('miName')]" - } - }, - "managedIdentityId": { - "type": "string", - "value": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('miName'))]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "deploy_keyvault", - "resourceGroup": "[resourceGroup().name]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyvaultName": { - "value": "[format('{0}{1}', variables('abbrs').security.keyVault, variables('ResourcePrefix'))]" - }, - "solutionName": { - "value": "[variables('ResourcePrefix')]" - }, - "solutionLocation": { - "value": "[variables('location')]" - }, - "managedIdentityObjectId": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity'), '2022-09-01').outputs.managedIdentityOutput.value.objectId]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.33.93.31351", - "templateHash": "3632224099881800620" - } - }, - "parameters": { - "solutionName": { - "type": "string", - "minLength": 3, - "maxLength": 15, - "metadata": { - "description": "Solution Name" - } - }, - "solutionLocation": { - "type": "string" - }, - "managedIdentityObjectId": { - "type": "string" - }, - "keyvaultName": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2022-07-01", - "name": "[parameters('keyvaultName')]", - "location": "[parameters('solutionLocation')]", - "properties": { - "createMode": "default", - "accessPolicies": [ - { - "objectId": "[parameters('managedIdentityObjectId')]", - "permissions": { - "certificates": [ - "all" - ], - "keys": [ - "all" - ], - "secrets": [ - "all" - ], - "storage": [ - "all" - ] - }, - "tenantId": "[subscription().tenantId]" - } - ], - "enabledForDeployment": true, - "enabledForDiskEncryption": true, - "enabledForTemplateDeployment": true, - "enableRbacAuthorization": true, - "publicNetworkAccess": "enabled", - "sku": { - "family": "A", - "name": "standard" - }, - "softDeleteRetentionInDays": 7, - "tenantId": "[subscription().tenantId]" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "name": "[guid(resourceGroup().id, parameters('managedIdentityObjectId'), resourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483'))]", - "properties": { - "principalId": "[parameters('managedIdentityObjectId')]", - "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483')]", - "principalType": "ServicePrincipal" - } - } - ], - "outputs": { - "keyvaultName": { - "type": "string", - "value": "[parameters('keyvaultName')]" - }, - "keyvaultId": { - "type": "string", - "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyvaultName'))]" - } - } - } - }, - "dependsOn": [ - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "deploy_ai_foundry", - "resourceGroup": "[resourceGroup().name]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "solutionName": { - "value": "[variables('ResourcePrefix')]" - }, - "solutionLocation": { - "value": "[parameters('AzureAiServiceLocation')]" - }, - "keyVaultName": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_keyvault'), '2022-09-01').outputs.keyvaultName.value]" - }, - "gptModelName": { - "value": "[variables('llmModel')]" - }, - "gptModelVersion": { - "value": "[variables('gptModelVersion')]" - }, - "managedIdentityObjectId": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity'), '2022-09-01').outputs.managedIdentityOutput.value.objectId]" - }, - "aiServicesEndpoint": { - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('azureAiServicesName')), '2024-04-01-preview').endpoint]" - }, - "aiServicesKey": { - "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', variables('azureAiServicesName')), '2024-04-01-preview').key1]" - }, - "aiServicesId": { - "value": "[resourceId('Microsoft.CognitiveServices/accounts', variables('azureAiServicesName'))]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.33.93.31351", - "templateHash": "9456018511253136487" - } - }, - "parameters": { - "solutionName": { - "type": "string", - "minLength": 3, - "maxLength": 15, - "metadata": { - "description": "Solution Name" - } - }, - "solutionLocation": { - "type": "string" - }, - "keyVaultName": { - "type": "string" - }, - "gptModelName": { - "type": "string" - }, - "gptModelVersion": { - "type": "string" - }, - "managedIdentityObjectId": { - "type": "string" - }, - "aiServicesEndpoint": { - "type": "string" - }, - "aiServicesKey": { - "type": "string" - }, - "aiServicesId": { - "type": "string" - } - }, - "variables": { - "$fxv#0": { - "ai": { - "aiSearch": "srch-", - "aiServices": "aisa-", - "aiVideoIndexer": "avi-", - "machineLearningWorkspace": "mlw-", - "openAIService": "oai-", - "botService": "bot-", - "computerVision": "cv-", - "contentModerator": "cm-", - "contentSafety": "cs-", - "customVisionPrediction": "cstv-", - "customVisionTraining": "cstvt-", - "documentIntelligence": "di-", - "faceApi": "face-", - "healthInsights": "hi-", - "immersiveReader": "ir-", - "languageService": "lang-", - "speechService": "spch-", - "translator": "trsl-", - "aiHub": "aih-", - "aiHubProject": "aihp-" - }, - "analytics": { - "analysisServicesServer": "as", - "databricksWorkspace": "dbw-", - "dataExplorerCluster": "dec", - "dataExplorerClusterDatabase": "dedb", - "dataFactory": "adf-", - "digitalTwin": "dt-", - "streamAnalytics": "asa-", - "synapseAnalyticsPrivateLinkHub": "synplh-", - "synapseAnalyticsSQLDedicatedPool": "syndp", - "synapseAnalyticsSparkPool": "synsp", - "synapseAnalyticsWorkspaces": "synw", - "dataLakeStoreAccount": "dls", - "dataLakeAnalyticsAccount": "dla", - "eventHubsNamespace": "evhns-", - "eventHub": "evh-", - "eventGridDomain": "evgd-", - "eventGridSubscriptions": "evgs-", - "eventGridTopic": "evgt-", - "eventGridSystemTopic": "egst-", - "hdInsightHadoopCluster": "hadoop-", - "hdInsightHBaseCluster": "hbase-", - "hdInsightKafkaCluster": "kafka-", - "hdInsightSparkCluster": "spark-", - "hdInsightStormCluster": "storm-", - "hdInsightMLServicesCluster": "mls-", - "iotHub": "iot-", - "provisioningServices": "provs-", - "provisioningServicesCertificate": "pcert-", - "powerBIEmbedded": "pbi-", - "timeSeriesInsightsEnvironment": "tsi-" - }, - "compute": { - "appServiceEnvironment": "ase-", - "appServicePlan": "asp-", - "loadTesting": "lt-", - "availabilitySet": "avail-", - "arcEnabledServer": "arcs-", - "arcEnabledKubernetesCluster": "arck", - "batchAccounts": "ba-", - "cloudService": "cld-", - "communicationServices": "acs-", - "diskEncryptionSet": "des", - "functionApp": "func-", - "gallery": "gal", - "hostingEnvironment": "host-", - "imageTemplate": "it-", - "managedDiskOS": "osdisk", - "managedDiskData": "disk", - "notificationHubs": "ntf-", - "notificationHubsNamespace": "ntfns-", - "proximityPlacementGroup": "ppg-", - "restorePointCollection": "rpc-", - "snapshot": "snap-", - "staticWebApp": "stapp-", - "virtualMachine": "vm", - "virtualMachineScaleSet": "vmss-", - "virtualMachineMaintenanceConfiguration": "mc-", - "virtualMachineStorageAccount": "stvm", - "webApp": "app-" - }, - "containers": { - "aksCluster": "aks-", - "aksSystemNodePool": "npsystem-", - "aksUserNodePool": "np-", - "containerApp": "ca-", - "containerAppsEnvironment": "cae-", - "containerRegistry": "cr", - "containerInstance": "ci", - "serviceFabricCluster": "sf-", - "serviceFabricManagedCluster": "sfmc-" - }, - "databases": { - "cosmosDBDatabase": "cosmos-", - "cosmosDBApacheCassandra": "coscas-", - "cosmosDBMongoDB": "cosmon-", - "cosmosDBNoSQL": "cosno-", - "cosmosDBTable": "costab-", - "cosmosDBGremlin": "cosgrm-", - "cosmosDBPostgreSQL": "cospos-", - "cacheForRedis": "redis-", - "sqlDatabaseServer": "sql-", - "sqlDatabase": "sqldb-", - "sqlElasticJobAgent": "sqlja-", - "sqlElasticPool": "sqlep-", - "mariaDBServer": "maria-", - "mariaDBDatabase": "mariadb-", - "mySQLDatabase": "mysql-", - "postgreSQLDatabase": "psql-", - "sqlServerStretchDatabase": "sqlstrdb-", - "sqlManagedInstance": "sqlmi-" - }, - "developerTools": { - "appConfigurationStore": "appcs-", - "mapsAccount": "map-", - "signalR": "sigr", - "webPubSub": "wps-" - }, - "devOps": { - "managedGrafana": "amg-" - }, - "integration": { - "apiManagementService": "apim-", - "integrationAccount": "ia-", - "logicApp": "logic-", - "serviceBusNamespace": "sbns-", - "serviceBusQueue": "sbq-", - "serviceBusTopic": "sbt-", - "serviceBusTopicSubscription": "sbts-" - }, - "managementGovernance": { - "automationAccount": "aa-", - "applicationInsights": "appi-", - "monitorActionGroup": "ag-", - "monitorDataCollectionRules": "dcr-", - "monitorAlertProcessingRule": "apr-", - "blueprint": "bp-", - "blueprintAssignment": "bpa-", - "dataCollectionEndpoint": "dce-", - "logAnalyticsWorkspace": "log-", - "logAnalyticsQueryPacks": "pack-", - "managementGroup": "mg-", - "purviewInstance": "pview-", - "resourceGroup": "rg-", - "templateSpecsName": "ts-" - }, - "migration": { - "migrateProject": "migr-", - "databaseMigrationService": "dms-", - "recoveryServicesVault": "rsv-" - }, - "networking": { - "applicationGateway": "agw-", - "applicationSecurityGroup": "asg-", - "cdnProfile": "cdnp-", - "cdnEndpoint": "cdne-", - "connections": "con-", - "dnsForwardingRuleset": "dnsfrs-", - "dnsPrivateResolver": "dnspr-", - "dnsPrivateResolverInboundEndpoint": "in-", - "dnsPrivateResolverOutboundEndpoint": "out-", - "firewall": "afw-", - "firewallPolicy": "afwp-", - "expressRouteCircuit": "erc-", - "expressRouteGateway": "ergw-", - "frontDoorProfile": "afd-", - "frontDoorEndpoint": "fde-", - "frontDoorFirewallPolicy": "fdfp-", - "ipGroups": "ipg-", - "loadBalancerInternal": "lbi-", - "loadBalancerExternal": "lbe-", - "loadBalancerRule": "rule-", - "localNetworkGateway": "lgw-", - "natGateway": "ng-", - "networkInterface": "nic-", - "networkSecurityGroup": "nsg-", - "networkSecurityGroupSecurityRules": "nsgsr-", - "networkWatcher": "nw-", - "privateLink": "pl-", - "privateEndpoint": "pep-", - "publicIPAddress": "pip-", - "publicIPAddressPrefix": "ippre-", - "routeFilter": "rf-", - "routeServer": "rtserv-", - "routeTable": "rt-", - "serviceEndpointPolicy": "se-", - "trafficManagerProfile": "traf-", - "userDefinedRoute": "udr-", - "virtualNetwork": "vnet-", - "virtualNetworkGateway": "vgw-", - "virtualNetworkManager": "vnm-", - "virtualNetworkPeering": "peer-", - "virtualNetworkSubnet": "snet-", - "virtualWAN": "vwan-", - "virtualWANHub": "vhub-" - }, - "security": { - "bastion": "bas-", - "keyVault": "kv-", - "keyVaultManagedHSM": "kvmhsm-", - "managedIdentity": "id-", - "sshKey": "sshkey-", - "vpnGateway": "vpng-", - "vpnConnection": "vcn-", - "vpnSite": "vst-", - "webApplicationFirewallPolicy": "waf", - "webApplicationFirewallPolicyRuleGroup": "wafrg" - }, - "storage": { - "storSimple": "ssimp", - "backupVault": "bvault-", - "backupVaultPolicy": "bkpol-", - "fileShare": "share-", - "storageAccount": "st", - "storageSyncService": "sss-" - }, - "virtualDesktop": { - "labServicesPlan": "lp-", - "virtualDesktopHostPool": "vdpool-", - "virtualDesktopApplicationGroup": "vdag-", - "virtualDesktopWorkspace": "vdws-", - "virtualDesktopScalingPlan": "vdscaling-" - } - }, - "abbrs": "[variables('$fxv#0')]", - "storageName": "[format('{0}{1}', variables('abbrs').storage.storageAccount, parameters('solutionName'))]", - "storageSkuName": "Standard_LRS", - "aiServicesName": "[format('{0}{1}', variables('abbrs').ai.aiServices, parameters('solutionName'))]", - "workspaceName": "[format('{0}{1}', variables('abbrs').managementGovernance.logAnalyticsWorkspace, parameters('solutionName'))]", - "keyvaultName": "[format('{0}{1}', variables('abbrs').security.keyVault, parameters('solutionName'))]", - "location": "[parameters('solutionLocation')]", - "azureAiHubName": "[format('{0}{1}', variables('abbrs').ai.aiHub, parameters('solutionName'))]", - "aiHubFriendlyName": "[variables('azureAiHubName')]", - "aiHubDescription": "AI Hub for KM template", - "aiProjectName": "[format('{0}{1}', variables('abbrs').ai.aiHubProject, parameters('solutionName'))]", - "aiProjectFriendlyName": "[variables('aiProjectName')]", - "aiSearchName": "[format('{0}{1}', variables('abbrs').ai.aiSearch, parameters('solutionName'))]", - "storageNameCleaned": "[replace(replace(replace(replace(format('{0}cast', variables('storageName')), '-', ''), '_', ''), '.', ''), '/', '')]" - }, - "resources": [ - { - "type": "Microsoft.MachineLearningServices/workspaces/connections", - "apiVersion": "2024-07-01-preview", - "name": "[format('{0}/{1}', variables('azureAiHubName'), format('{0}-connection-AzureOpenAI', variables('azureAiHubName')))]", - "properties": { - "category": "AIServices", - "target": "[parameters('aiServicesEndpoint')]", - "authType": "ApiKey", - "isSharedToAll": true, - "credentials": { - "key": "[parameters('aiServicesKey')]" - }, - "metadata": { - "ApiType": "Azure", - "ResourceId": "[parameters('aiServicesId')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.MachineLearningServices/workspaces', variables('azureAiHubName'))]" - ] - }, - { - "type": "Microsoft.OperationalInsights/workspaces", - "apiVersion": "2023-09-01", - "name": "[variables('workspaceName')]", - "location": "[variables('location')]", - "tags": {}, - "properties": { - "retentionInDays": 30, - "sku": { - "name": "PerGB2018" - } - } - }, - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2022-09-01", - "name": "[variables('storageNameCleaned')]", - "location": "[variables('location')]", - "sku": { - "name": "[variables('storageSkuName')]" - }, - "kind": "StorageV2", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "accessTier": "Hot", - "allowBlobPublicAccess": false, - "allowCrossTenantReplication": false, - "allowSharedKeyAccess": false, - "encryption": { - "keySource": "Microsoft.Storage", - "requireInfrastructureEncryption": false, - "services": { - "blob": { - "enabled": true, - "keyType": "Account" - }, - "file": { - "enabled": true, - "keyType": "Account" - }, - "queue": { - "enabled": true, - "keyType": "Service" - }, - "table": { - "enabled": true, - "keyType": "Service" - } - } - }, - "isHnsEnabled": false, - "isNfsV3Enabled": false, - "keyPolicy": { - "keyExpirationPeriodInDays": 7 - }, - "largeFileSharesState": "Disabled", - "minimumTlsVersion": "TLS1_2", - "networkAcls": { - "bypass": "AzureServices", - "defaultAction": "Allow" - }, - "supportsHttpsTrafficOnly": true - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Storage/storageAccounts/{0}', variables('storageNameCleaned'))]", - "name": "[guid(resourceGroup().id, parameters('managedIdentityObjectId'), subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'))]", - "properties": { - "principalId": "[parameters('managedIdentityObjectId')]", - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", - "principalType": "ServicePrincipal" - }, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', variables('storageNameCleaned'))]" - ] - }, - { - "type": "Microsoft.MachineLearningServices/workspaces", - "apiVersion": "2023-08-01-preview", - "name": "[variables('azureAiHubName')]", - "location": "[variables('location')]", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "friendlyName": "[variables('aiHubFriendlyName')]", - "description": "[variables('aiHubDescription')]", - "keyVault": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "storageAccount": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageNameCleaned'))]" - }, - "kind": "hub", - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', variables('storageNameCleaned'))]" - ] - }, - { - "type": "Microsoft.MachineLearningServices/workspaces", - "apiVersion": "2024-01-01-preview", - "name": "[variables('aiProjectName')]", - "location": "[variables('location')]", - "kind": "Project", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "friendlyName": "[variables('aiProjectFriendlyName')]", - "hubResourceId": "[resourceId('Microsoft.MachineLearningServices/workspaces', variables('azureAiHubName'))]" - }, - "dependsOn": [ - "[resourceId('Microsoft.MachineLearningServices/workspaces', variables('azureAiHubName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'TENANT-ID')]", - "properties": { - "value": "[subscription().tenantId]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-OPENAI-INFERENCE-ENDPOINT')]", - "properties": { - "value": "" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-OPENAI-INFERENCE-KEY')]", - "properties": { - "value": "" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-OPENAI-KEY')]", - "properties": { - "value": "[parameters('aiServicesKey')]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-OPEN-AI-DEPLOYMENT-MODEL')]", - "properties": { - "value": "[parameters('gptModelName')]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-OPENAI-PREVIEW-API-VERSION')]", - "properties": { - "value": "[parameters('gptModelVersion')]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-OPENAI-ENDPOINT')]", - "properties": { - "value": "[parameters('aiServicesEndpoint')]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-AI-PROJECT-CONN-STRING')]", - "properties": { - "value": "[format('{0};{1};{2};{3}', split(reference(resourceId('Microsoft.MachineLearningServices/workspaces', variables('aiProjectName')), '2024-01-01-preview').discoveryUrl, '/')[2], subscription().subscriptionId, resourceGroup().name, variables('aiProjectName'))]" - }, - "dependsOn": [ - "[resourceId('Microsoft.MachineLearningServices/workspaces', variables('aiProjectName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-OPENAI-CU-VERSION')]", - "properties": { - "value": "?api-version=2024-12-01-preview" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-SEARCH-INDEX')]", - "properties": { - "value": "transcripts_index" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'COG-SERVICES-ENDPOINT')]", - "properties": { - "value": "[parameters('aiServicesEndpoint')]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'COG-SERVICES-KEY')]", - "properties": { - "value": "[parameters('aiServicesKey')]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'COG-SERVICES-NAME')]", - "properties": { - "value": "[variables('aiServicesName')]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-SUBSCRIPTION-ID')]", - "properties": { - "value": "[subscription().subscriptionId]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-RESOURCE-GROUP')]", - "properties": { - "value": "[resourceGroup().name]" - } - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2021-11-01-preview", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-LOCATION')]", - "properties": { - "value": "[parameters('solutionLocation')]" - } - } - ], - "outputs": { - "keyvaultName": { - "type": "string", - "value": "[variables('keyvaultName')]" - }, - "keyvaultId": { - "type": "string", - "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - }, - "aiServicesName": { - "type": "string", - "value": "[variables('aiServicesName')]" - }, - "aiSearchName": { - "type": "string", - "value": "[variables('aiSearchName')]" - }, - "aiProjectName": { - "type": "string", - "value": "[variables('aiProjectName')]" - }, - "storageAccountName": { - "type": "string", - "value": "[variables('storageNameCleaned')]" - }, - "logAnalyticsId": { - "type": "string", - "value": "[resourceId('Microsoft.OperationalInsights/workspaces', variables('workspaceName'))]" - }, - "storageAccountId": { - "type": "string", - "value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageNameCleaned'))]" - }, - "projectConnectionString": { - "type": "string", - "value": "[format('{0};{1};{2};{3}', split(reference(resourceId('Microsoft.MachineLearningServices/workspaces', variables('aiProjectName')), '2024-01-01-preview').discoveryUrl, '/')[2], subscription().subscriptionId, resourceGroup().name, variables('aiProjectName'))]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', variables('azureAiServicesName'))]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_keyvault')]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[toLower(format('{0}conAppsEnv', variables('ResourcePrefix')))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "logAnalyticsWorkspaceResourceId": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.logAnalyticsId.value]" - }, - "name": { - "value": "[toLower(format('{0}manenv', variables('ResourcePrefix')))]" - }, - "location": { - "value": "[variables('location')]" - }, - "zoneRedundant": { - "value": false - }, - "managedIdentities": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity'), '2022-09-01')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.33.93.31351", - "templateHash": "9269440843859343895" - }, - "name": "App ManagedEnvironments", - "description": "This module deploys an App Managed Environment (also known as a Container App Environment)." - }, - "definitions": { - "managedIdentitiesType": { - "type": "object", - "properties": { - "systemAssigned": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. Enables system assigned managed identity on the resource." - } - }, - "userAssignedResourceIds": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. The resource ID(s) to assign to the resource." - } - } - }, - "nullable": true - }, - "lockType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Specify the name of lock." - } - }, - "kind": { - "type": "string", - "allowedValues": [ - "CanNotDelete", - "None", - "ReadOnly" - ], - "nullable": true, - "metadata": { - "description": "Optional. Specify the type of lock." - } - } - }, - "nullable": true - }, - "roleAssignmentType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name (as GUID) of the role assignment. If not provided, a GUID will be generated." - } - }, - "roleDefinitionIdOrName": { - "type": "string", - "metadata": { - "description": "Required. The role to assign. You can provide either the display name of the role definition, the role definition GUID, or its fully qualified ID in the following format: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Required. The principal ID of the principal (user/group/identity) to assign the role to." - } - }, - "principalType": { - "type": "string", - "allowedValues": [ - "Device", - "ForeignGroup", - "Group", - "ServicePrincipal", - "User" - ], - "nullable": true, - "metadata": { - "description": "Optional. The principal type of the assigned principal ID." - } - }, - "description": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The description of the role assignment." - } - }, - "condition": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The conditions on the role assignment. This limits the resources it can be assigned to. e.g.: @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:ContainerName] StringEqualsIgnoreCase \"foo_storage_container\"." - } - }, - "conditionVersion": { - "type": "string", - "allowedValues": [ - "2.0" - ], - "nullable": true, - "metadata": { - "description": "Optional. Version of the condition." - } - }, - "delegatedManagedIdentityResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The Resource Id of the delegated managed identity resource." - } - } - } - }, - "nullable": true - }, - "certificateKeyVaultPropertiesType": { - "type": "object", - "properties": { - "identityResourceId": { - "type": "string", - "metadata": { - "description": "Required. The resource ID of the identity. This is the identity that will be used to access the key vault." - } - }, - "keyVaultUrl": { - "type": "string", - "metadata": { - "description": "Required. A key vault URL referencing the wildcard certificate that will be used for the custom domain." - } - } - }, - "nullable": true - }, - "storageType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "accessMode": { - "type": "string", - "allowedValues": [ - "ReadOnly", - "ReadWrite" - ], - "metadata": { - "description": "Required. Access mode for storage: \"ReadOnly\" or \"ReadWrite\"." - } - }, - "kind": { - "type": "string", - "allowedValues": [ - "NFS", - "SMB" - ], - "metadata": { - "description": "Required. Type of storage: \"SMB\" or \"NFS\"." - } - }, - "storageAccountName": { - "type": "string", - "metadata": { - "description": "Required. Storage account name." - } - }, - "shareName": { - "type": "string", - "metadata": { - "description": "Required. File share name." - } - } - } - }, - "nullable": true - } - }, - "parameters": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the Container Apps Managed Environment." - } - }, - "logAnalyticsWorkspaceResourceId": { - "type": "string", - "metadata": { - "description": "Required. Existing Log Analytics Workspace resource ID. Note: This value is not required as per the resource type. However, not providing it currently causes an issue that is tracked [here](https://github.com/Azure/bicep/issues/9990)." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Optional. Location for all Resources." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags of the resource." - } - }, - "managedIdentities": { - "$ref": "#/definitions/managedIdentitiesType", - "metadata": { - "description": "Optional. The managed identity definition for this resource." - } - }, - "roleAssignments": { - "$ref": "#/definitions/roleAssignmentType", - "metadata": { - "description": "Optional. Array of role assignments to create." - } - }, - "logsDestination": { - "type": "string", - "defaultValue": "log-analytics", - "metadata": { - "description": "Optional. Logs destination." - } - }, - "enableTelemetry": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Enable/Disable usage telemetry for module." - } - }, - "appInsightsConnectionString": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional. Application Insights connection string." - } - }, - "daprAIConnectionString": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional. Application Insights connection string used by Dapr to export Service to Service communication telemetry." - } - }, - "daprAIInstrumentationKey": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional. Azure Monitor instrumentation key used by Dapr to export Service to Service communication telemetry." - } - }, - "dockerBridgeCidr": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Conditional. CIDR notation IP range assigned to the Docker bridge, network. It must not overlap with any other provided IP ranges and can only be used when the environment is deployed into a virtual network. If not provided, it will be set with a default value by the platform. Required if zoneRedundant is set to true to make the resource WAF compliant." - } - }, - "infrastructureSubnetId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Conditional. Resource ID of a subnet for infrastructure components. This is used to deploy the environment into a virtual network. Must not overlap with any other provided IP ranges. Required if \"internal\" is set to true. Required if zoneRedundant is set to true to make the resource WAF compliant." - } - }, - "internal": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Conditional. Boolean indicating the environment only has an internal load balancer. These environments do not have a public static IP resource. If set to true, then \"infrastructureSubnetId\" must be provided. Required if zoneRedundant is set to true to make the resource WAF compliant." - } - }, - "platformReservedCidr": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Conditional. IP range in CIDR notation that can be reserved for environment infrastructure IP addresses. It must not overlap with any other provided IP ranges and can only be used when the environment is deployed into a virtual network. If not provided, it will be set with a default value by the platform. Required if zoneRedundant is set to true to make the resource WAF compliant." - } - }, - "platformReservedDnsIP": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Conditional. An IP address from the IP range defined by \"platformReservedCidr\" that will be reserved for the internal DNS server. It must not be the first address in the range and can only be used when the environment is deployed into a virtual network. If not provided, it will be set with a default value by the platform. Required if zoneRedundant is set to true to make the resource WAF compliant." - } - }, - "peerTrafficEncryption": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Whether or not to encrypt peer traffic." - } - }, - "zoneRedundant": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Whether or not this Managed Environment is zone-redundant." - } - }, - "certificatePassword": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional. Password of the certificate used by the custom domain." - } - }, - "certificateValue": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Optional. Certificate to use for the custom domain. PFX or PEM." - } - }, - "certificateKeyVaultProperties": { - "$ref": "#/definitions/certificateKeyVaultPropertiesType", - "metadata": { - "description": "Optional. A key vault reference to the certificate to use for the custom domain." - } - }, - "dnsSuffix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional. DNS suffix for the environment domain." - } - }, - "lock": { - "$ref": "#/definitions/lockType", - "metadata": { - "description": "Optional. The lock settings of the service." - } - }, - "openTelemetryConfiguration": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional. Open Telemetry configuration." - } - }, - "workloadProfiles": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Conditional. Workload profiles configured for the Managed Environment. Required if zoneRedundant is set to true to make the resource WAF compliant." - } - }, - "infrastructureResourceGroupName": { - "type": "string", - "defaultValue": "[take(format('ME_{0}', parameters('name')), 63)]", - "metadata": { - "description": "Conditional. Name of the infrastructure resource group. If not provided, it will be set with a default value. Required if zoneRedundant is set to true to make the resource WAF compliant." - } - }, - "storages": { - "$ref": "#/definitions/storageType", - "metadata": { - "description": "Optional. The list of storages to mount on the environment." - } - } - }, - "variables": { - "copy": [ - { - "name": "formattedRoleAssignments", - "count": "[length(coalesce(parameters('roleAssignments'), createArray()))]", - "input": "[union(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')], createObject('roleDefinitionId', coalesce(tryGet(variables('builtInRoleNames'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName), if(contains(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, '/providers/Microsoft.Authorization/roleDefinitions/'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName)))))]" - } - ], - "formattedUserAssignedIdentities": "[reduce(map(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createArray()), lambda('id', createObject(format('{0}', lambdaVariables('id')), createObject()))), createObject(), lambda('cur', 'next', union(lambdaVariables('cur'), lambdaVariables('next'))))]", - "identity": "[if(not(empty(parameters('managedIdentities'))), createObject('type', if(coalesce(tryGet(parameters('managedIdentities'), 'systemAssigned'), false()), if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'SystemAssigned,UserAssigned', 'SystemAssigned'), if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'UserAssigned', 'None')), 'userAssignedIdentities', if(not(empty(variables('formattedUserAssignedIdentities'))), variables('formattedUserAssignedIdentities'), null())), null())]", - "builtInRoleNames": { - "Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", - "Owner": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635')]", - "Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", - "Role Based Access Control Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168')]", - "User Access Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9')]" - } - }, - "resources": { - "managedEnvironment::storage": { - "copy": { - "name": "managedEnvironment::storage", - "count": "[length(coalesce(parameters('storages'), createArray()))]" - }, - "type": "Microsoft.App/managedEnvironments/storages", - "apiVersion": "2024-02-02-preview", - "name": "[format('{0}/{1}', parameters('name'), coalesce(parameters('storages'), createArray())[copyIndex()].shareName)]", - "properties": { - "nfsAzureFile": "[if(equals(coalesce(parameters('storages'), createArray())[copyIndex()].kind, 'NFS'), createObject('accessMode', coalesce(parameters('storages'), createArray())[copyIndex()].accessMode, 'server', format('{0}.file.{1}', coalesce(parameters('storages'), createArray())[copyIndex()].storageAccountName, environment().suffixes.storage), 'shareName', format('/{0}/{1}', coalesce(parameters('storages'), createArray())[copyIndex()].storageAccountName, coalesce(parameters('storages'), createArray())[copyIndex()].shareName)), null())]", - "azureFile": "[if(equals(coalesce(parameters('storages'), createArray())[copyIndex()].kind, 'SMB'), createObject('accessMode', coalesce(parameters('storages'), createArray())[copyIndex()].accessMode, 'accountName', coalesce(parameters('storages'), createArray())[copyIndex()].storageAccountName, 'accountKey', listkeys(resourceId('Microsoft.Storage/storageAccounts', coalesce(parameters('storages'), createArray())[copyIndex()].storageAccountName), '2023-01-01').keys[0].value, 'shareName', coalesce(parameters('storages'), createArray())[copyIndex()].shareName), null())]" - }, - "dependsOn": [ - "managedEnvironment" - ] - }, - "avmTelemetry": { - "condition": "[parameters('enableTelemetry')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2024-03-01", - "name": "[format('46d3xbcp.res.app-managedenvironment.{0}.{1}', replace('0.9.1', '.', '-'), substring(uniqueString(deployment().name, parameters('location')), 0, 4))]", - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "resources": [], - "outputs": { - "telemetry": { - "type": "String", - "value": "For more information, see https://aka.ms/avm/TelemetryInfo" - } - } - } - } - }, - "logAnalyticsWorkspace": { - "condition": "[not(empty(parameters('logAnalyticsWorkspaceResourceId')))]", - "existing": true, - "type": "Microsoft.OperationalInsights/workspaces", - "apiVersion": "2023-09-01", - "subscriptionId": "[split(parameters('logAnalyticsWorkspaceResourceId'), '/')[2]]", - "resourceGroup": "[split(parameters('logAnalyticsWorkspaceResourceId'), '/')[4]]", - "name": "[last(split(parameters('logAnalyticsWorkspaceResourceId'), '/'))]" - }, - "managedEnvironment": { - "type": "Microsoft.App/managedEnvironments", - "apiVersion": "2024-02-02-preview", - "name": "[parameters('name')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "identity": "[variables('identity')]", - "properties": { - "appInsightsConfiguration": { - "connectionString": "[parameters('appInsightsConnectionString')]" - }, - "appLogsConfiguration": { - "destination": "[parameters('logsDestination')]", - "logAnalyticsConfiguration": { - "customerId": "[reference('logAnalyticsWorkspace').customerId]", - "sharedKey": "[listKeys(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', split(parameters('logAnalyticsWorkspaceResourceId'), '/')[2], split(parameters('logAnalyticsWorkspaceResourceId'), '/')[4]), 'Microsoft.OperationalInsights/workspaces', last(split(parameters('logAnalyticsWorkspaceResourceId'), '/'))), '2023-09-01').primarySharedKey]" - } - }, - "daprAIConnectionString": "[parameters('daprAIConnectionString')]", - "daprAIInstrumentationKey": "[parameters('daprAIInstrumentationKey')]", - "customDomainConfiguration": { - "certificatePassword": "[parameters('certificatePassword')]", - "certificateValue": "[if(not(empty(parameters('certificateValue'))), parameters('certificateValue'), null())]", - "dnsSuffix": "[parameters('dnsSuffix')]", - "certificateKeyVaultProperties": "[if(not(empty(parameters('certificateKeyVaultProperties'))), createObject('identity', parameters('certificateKeyVaultProperties').identityResourceId, 'keyVaultUrl', parameters('certificateKeyVaultProperties').keyVaultUrl), null())]" - }, - "openTelemetryConfiguration": "[if(not(empty(parameters('openTelemetryConfiguration'))), parameters('openTelemetryConfiguration'), null())]", - "peerTrafficConfiguration": { - "encryption": { - "enabled": "[parameters('peerTrafficEncryption')]" - } - }, - "vnetConfiguration": { - "internal": "[parameters('internal')]", - "infrastructureSubnetId": "[if(not(empty(parameters('infrastructureSubnetId'))), parameters('infrastructureSubnetId'), null())]", - "dockerBridgeCidr": "[if(not(empty(parameters('infrastructureSubnetId'))), parameters('dockerBridgeCidr'), null())]", - "platformReservedCidr": "[if(and(empty(parameters('workloadProfiles')), not(empty(parameters('infrastructureSubnetId')))), parameters('platformReservedCidr'), null())]", - "platformReservedDnsIP": "[if(and(empty(parameters('workloadProfiles')), not(empty(parameters('infrastructureSubnetId')))), parameters('platformReservedDnsIP'), null())]" - }, - "workloadProfiles": "[if(not(empty(parameters('workloadProfiles'))), parameters('workloadProfiles'), null())]", - "zoneRedundant": "[parameters('zoneRedundant')]", - "infrastructureResourceGroup": "[parameters('infrastructureResourceGroupName')]" - }, - "dependsOn": [ - "logAnalyticsWorkspace" - ] - }, - "managedEnvironment_roleAssignments": { - "copy": { - "name": "managedEnvironment_roleAssignments", - "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.App/managedEnvironments/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.App/managedEnvironments', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", - "properties": { - "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", - "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", - "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", - "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", - "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", - "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", - "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" - }, - "dependsOn": [ - "managedEnvironment" - ] - }, - "managedEnvironment_lock": { - "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", - "type": "Microsoft.Authorization/locks", - "apiVersion": "2020-05-01", - "scope": "[format('Microsoft.App/managedEnvironments/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", - "properties": { - "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", - "notes": "[if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.')]" - }, - "dependsOn": [ - "managedEnvironment" - ] - } - }, - "outputs": { - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the Managed Environment was deployed into." - }, - "value": "[resourceGroup().name]" - }, - "location": { - "type": "string", - "metadata": { - "description": "The location the resource was deployed into." - }, - "value": "[reference('managedEnvironment', '2024-02-02-preview', 'full').location]" - }, - "name": { - "type": "string", - "metadata": { - "description": "The name of the Managed Environment." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the Managed Environment." - }, - "value": "[resourceId('Microsoft.App/managedEnvironments', parameters('name'))]" - }, - "systemAssignedMIPrincipalId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "The principal ID of the system assigned identity." - }, - "value": "[tryGet(tryGet(reference('managedEnvironment', '2024-02-02-preview', 'full'), 'identity'), 'principalId')]" - }, - "defaultDomain": { - "type": "string", - "metadata": { - "description": "The Default domain of the Managed Environment." - }, - "value": "[reference('managedEnvironment').defaultDomain]" - }, - "staticIp": { - "type": "string", - "metadata": { - "description": "The IP address of the Managed Environment." - }, - "value": "[reference('managedEnvironment').staticIp]" - }, - "domainVerificationId": { - "type": "string", - "metadata": { - "description": "The domain verification id for custom domains." - }, - "value": "[reference('managedEnvironment').customDomainConfiguration.customDomainVerificationId]" - } - } - } - }, - "dependsOn": [ - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry')]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix')))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "[toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix')))]" - }, - "enableAnalyticalStorage": { - "value": true - }, - "location": { - "value": "[variables('dblocation')]" - }, - "managedIdentities": { - "value": { - "systemAssigned": true, - "userAssignedResourceIds": [ - "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity'), '2022-09-01').outputs.managedIdentityOutput.value.resourceId]" - ] - } - }, - "networkRestrictions": { - "value": { - "networkAclBypass": "AzureServices", - "publicNetworkAccess": "Enabled", - "ipRules": [], - "virtualNetworkRules": [] - } - }, - "disableKeyBasedMetadataWriteAccess": { - "value": false - }, - "locations": { - "value": [ - { - "failoverPriority": 0, - "isZoneRedundant": false, - "locationName": "[variables('dblocation')]" - } - ] - }, - "sqlDatabases": { - "value": [ - { - "containers": [ - { - "indexingPolicy": { - "automatic": true - }, - "name": "[variables('cosmosdbBatchContainer')]", - "paths": [ - "/batch_id" - ] - }, - { - "indexingPolicy": { - "automatic": true - }, - "name": "[variables('cosmosdbFileContainer')]", - "paths": [ - "/file_id" - ] - }, - { - "indexingPolicy": { - "automatic": true - }, - "name": "[variables('cosmosdbLogContainer')]", - "paths": [ - "/log_id" - ] - } - ], - "name": "[variables('cosmosdbDatabase')]" - } - ] - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "1366944588516308546" - }, - "name": "DocumentDB Database Accounts", - "description": "This module deploys a DocumentDB Database Account.", - "owner": "Azure/module-maintainers" - }, - "definitions": { - "managedIdentitiesType": { - "type": "object", - "properties": { - "systemAssigned": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. Enables system assigned managed identity on the resource." - } - }, - "userAssignedResourceIds": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. The resource ID(s) to assign to the resource." - } - } - }, - "nullable": true - }, - "lockType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Specify the name of lock." - } - }, - "kind": { - "type": "string", - "allowedValues": [ - "CanNotDelete", - "None", - "ReadOnly" - ], - "nullable": true, - "metadata": { - "description": "Optional. Specify the type of lock." - } - } - }, - "nullable": true - }, - "roleAssignmentType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name (as GUID) of the role assignment. If not provided, a GUID will be generated." - } - }, - "roleDefinitionIdOrName": { - "type": "string", - "metadata": { - "description": "Required. The role to assign. You can provide either the display name of the role definition, the role definition GUID, or its fully qualified ID in the following format: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Required. The principal ID of the principal (user/group/identity) to assign the role to." - } - }, - "principalType": { - "type": "string", - "allowedValues": [ - "Device", - "ForeignGroup", - "Group", - "ServicePrincipal", - "User" - ], - "nullable": true, - "metadata": { - "description": "Optional. The principal type of the assigned principal ID." - } - }, - "description": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The description of the role assignment." - } - }, - "condition": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The conditions on the role assignment. This limits the resources it can be assigned to. e.g.: @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:ContainerName] StringEqualsIgnoreCase \"foo_storage_container\"." - } - }, - "conditionVersion": { - "type": "string", - "allowedValues": [ - "2.0" - ], - "nullable": true, - "metadata": { - "description": "Optional. Version of the condition." - } - }, - "delegatedManagedIdentityResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The Resource Id of the delegated managed identity resource." - } - } - } - }, - "nullable": true - }, - "privateEndpointType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of the private endpoint." - } - }, - "location": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The location to deploy the private endpoint to." - } - }, - "privateLinkServiceConnectionName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of the private link connection to create." - } - }, - "service": { - "type": "string", - "metadata": { - "description": "Required. The subresource to deploy the private endpoint for. For example \"blob\", \"table\", \"queue\" or \"file\"." - } - }, - "subnetResourceId": { - "type": "string", - "metadata": { - "description": "Required. Resource ID of the subnet where the endpoint needs to be created." - } - }, - "privateDnsZoneGroup": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of the Private DNS Zone Group." - } - }, - "privateDnsZoneGroupConfigs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of the private DNS zone group config." - } - }, - "privateDnsZoneResourceId": { - "type": "string", - "metadata": { - "description": "Required. The resource id of the private DNS zone." - } - } - } - }, - "metadata": { - "description": "Required. The private DNS zone groups to associate the private endpoint. A DNS zone group can support up to 5 DNS zones." - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. The private DNS zone group to configure for the private endpoint." - } - }, - "isManualConnection": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. If Manual Private Link Connection is required." - } - }, - "manualConnectionRequestMessage": { - "type": "string", - "nullable": true, - "maxLength": 140, - "metadata": { - "description": "Optional. A message passed to the owner of the remote resource with the manual connection request." - } - }, - "customDnsConfigs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fqdn": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. FQDN that resolves to private endpoint IP address." - } - }, - "ipAddresses": { - "type": "array", - "items": { - "type": "string" - }, - "metadata": { - "description": "Required. A list of private ip addresses of the private endpoint." - } - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. Custom DNS configurations." - } - }, - "ipConfigurations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. The name of the resource that is unique within a resource group." - } - }, - "properties": { - "type": "object", - "properties": { - "groupId": { - "type": "string", - "metadata": { - "description": "Required. The ID of a group obtained from the remote resource that this private endpoint should connect to." - } - }, - "memberName": { - "type": "string", - "metadata": { - "description": "Required. The member name of a group obtained from the remote resource that this private endpoint should connect to." - } - }, - "privateIPAddress": { - "type": "string", - "metadata": { - "description": "Required. A private ip address obtained from the private endpoint's subnet." - } - } - }, - "metadata": { - "description": "Required. Properties of private endpoint IP configurations." - } - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. A list of IP configurations of the private endpoint. This will be used to map to the First Party Service endpoints." - } - }, - "applicationSecurityGroupResourceIds": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. Application security groups in which the private endpoint IP configuration is included." - } - }, - "customNetworkInterfaceName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The custom name of the network interface attached to the private endpoint." - } - }, - "lock": { - "$ref": "#/definitions/lockType", - "metadata": { - "description": "Optional. Specify the type of lock." - } - }, - "roleAssignments": { - "$ref": "#/definitions/roleAssignmentType", - "metadata": { - "description": "Optional. Array of role assignments to create." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags to be applied on all resources/resource groups in this deployment." - } - }, - "enableTelemetry": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. Enable/Disable usage telemetry for module." - } - }, - "resourceGroupName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Specify if you want to deploy the Private Endpoint into a different resource group than the main resource." - } - } - } - }, - "nullable": true - }, - "diagnosticSettingType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of diagnostic setting." - } - }, - "logCategoriesAndGroups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "category": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Name of a Diagnostic Log category for a resource type this setting is applied to. Set the specific logs to collect here." - } - }, - "categoryGroup": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Name of a Diagnostic Log category group for a resource type this setting is applied to. Set to `allLogs` to collect all logs." - } - }, - "enabled": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. Enable or disable the category explicitly. Default is `true`." - } - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. The name of logs that will be streamed. \"allLogs\" includes all possible logs for the resource. Set to `[]` to disable log collection." - } - }, - "metricCategories": { - "type": "array", - "items": { - "type": "object", - "properties": { - "category": { - "type": "string", - "metadata": { - "description": "Required. Name of a Diagnostic Metric category for a resource type this setting is applied to. Set to `AllMetrics` to collect all metrics." - } - }, - "enabled": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. Enable or disable the category explicitly. Default is `true`." - } - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. The name of metrics that will be streamed. \"allMetrics\" includes all possible metrics for the resource. Set to `[]` to disable metric collection." - } - }, - "logAnalyticsDestinationType": { - "type": "string", - "allowedValues": [ - "AzureDiagnostics", - "Dedicated" - ], - "nullable": true, - "metadata": { - "description": "Optional. A string indicating whether the export to Log Analytics should use the default destination type, i.e. AzureDiagnostics, or use a destination type." - } - }, - "workspaceResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Resource ID of the diagnostic log analytics workspace. For security reasons, it is recommended to set diagnostic settings to send data to either storage account, log analytics workspace or event hub." - } - }, - "storageAccountResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Resource ID of the diagnostic storage account. For security reasons, it is recommended to set diagnostic settings to send data to either storage account, log analytics workspace or event hub." - } - }, - "eventHubAuthorizationRuleResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Resource ID of the diagnostic event hub authorization rule for the Event Hubs namespace in which the event hub should be created or streamed to." - } - }, - "eventHubName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Name of the diagnostic event hub within the namespace to which logs are streamed. Without this, an event hub is created for each log category. For security reasons, it is recommended to set diagnostic settings to send data to either storage account, log analytics workspace or event hub." - } - }, - "marketplacePartnerResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The full ARM resource ID of the Marketplace resource to which you would like to send Diagnostic Logs." - } - } - } - }, - "nullable": true - }, - "failoverLocationsType": { - "type": "object", - "properties": { - "failoverPriority": { - "type": "int", - "metadata": { - "description": "Required. The failover priority of the region. A failover priority of 0 indicates a write region. The maximum value for a failover priority = (total number of regions - 1). Failover priority values must be unique for each of the regions in which the database account exists." - } - }, - "isZoneRedundant": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. Default to true. Flag to indicate whether or not this region is an AvailabilityZone region." - } - }, - "locationName": { - "type": "string", - "metadata": { - "description": "Required. The name of the region." - } - } - } - }, - "sqlRoleDefinitionsType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the SQL Role Definition." - } - }, - "dataAction": { - "type": "array", - "nullable": true, - "metadata": { - "description": "Optional. An array of data actions that are allowed." - } - }, - "roleName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. A user-friendly name for the Role Definition. Must be unique for the database account." - } - }, - "roleType": { - "type": "string", - "allowedValues": [ - "BuiltInRole", - "CustomRole" - ], - "nullable": true, - "metadata": { - "description": "Optional. Indicates whether the Role Definition was built-in or user created." - } - } - } - }, - "nullable": true - }, - "sqlDatabaseType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the SQL database ." - } - }, - "throughput": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Default to 400. Request units per second. Will be ignored if autoscaleSettingsMaxThroughput is used." - } - }, - "autoscaleSettingsMaxThroughput": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Specifies the Autoscale settings and represents maximum throughput, the resource can scale up to. The autoscale throughput should have valid throughput values between 1000 and 1000000 inclusive in increments of 1000. If value is set to null, then autoscale will be disabled." - } - }, - "containers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the container." - } - }, - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "minLength": 1, - "maxLength": 3, - "metadata": { - "description": "Required. List of paths using which data within the container can be partitioned. For kind=MultiHash it can be up to 3. For anything else it needs to be exactly 1." - } - }, - "analyticalStorageTtl": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Default to 0. Indicates how long data should be retained in the analytical store, for a container. Analytical store is enabled when ATTL is set with a value other than 0. If the value is set to -1, the analytical store retains all historical data, irrespective of the retention of the data in the transactional store." - } - }, - "autoscaleSettingsMaxThroughput": { - "type": "int", - "nullable": true, - "maxValue": 1000000, - "metadata": { - "description": "Optional. Specifies the Autoscale settings and represents maximum throughput, the resource can scale up to. The autoscale throughput should have valid throughput values between 1000 and 1000000 inclusive in increments of 1000. If value is set to null, then autoscale will be disabled." - } - }, - "conflictResolutionPolicy": { - "type": "object", - "properties": { - "conflictResolutionPath": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Conditional. The conflict resolution path in the case of LastWriterWins mode. Required if `mode` is set to 'LastWriterWins'." - } - }, - "conflictResolutionProcedure": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Conditional. The procedure to resolve conflicts in the case of custom mode. Required if `mode` is set to 'Custom'." - } - }, - "mode": { - "type": "string", - "allowedValues": [ - "Custom", - "LastWriterWins" - ], - "metadata": { - "description": "Required. Indicates the conflict resolution mode." - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. The conflict resolution policy for the container. Conflicts and conflict resolution policies are applicable if the Azure Cosmos DB account is configured with multiple write regions." - } - }, - "defaultTtl": { - "type": "int", - "nullable": true, - "minValue": -1, - "maxValue": 2147483647, - "metadata": { - "description": "Optional. Default to -1. Default time to live (in seconds). With Time to Live or TTL, Azure Cosmos DB provides the ability to delete items automatically from a container after a certain time period. If the value is set to \"-1\", it is equal to infinity, and items don't expire by default." - } - }, - "indexingPolicy": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Indexing policy of the container." - } - }, - "kind": { - "type": "string", - "allowedValues": [ - "Hash", - "MultiHash" - ], - "nullable": true, - "metadata": { - "description": "Optional. Default to Hash. Indicates the kind of algorithm used for partitioning." - } - }, - "version": { - "type": "int", - "allowedValues": [ - 1, - 2 - ], - "nullable": true, - "metadata": { - "description": "Optional. Default to 1 for Hash and 2 for MultiHash - 1 is not allowed for MultiHash. Version of the partition key definition." - } - }, - "throughput": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Default to 400. Request Units per second. Will be ignored if autoscaleSettingsMaxThroughput is used." - } - }, - "uniqueKeyPolicyKeys": { - "type": "array", - "items": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "metadata": { - "description": "Required. List of paths must be unique for each document in the Azure Cosmos DB service." - } - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. The unique key policy configuration containing a list of unique keys that enforces uniqueness constraint on documents in the collection in the Azure Cosmos DB service." - } - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. Array of containers to deploy in the SQL database." - } - } - } - }, - "secretsExportConfigurationType": { - "type": "object", - "properties": { - "keyVaultResourceId": { - "type": "string", - "metadata": { - "description": "Required. The resource ID of the key vault where to store the secrets of this module." - } - }, - "primaryWriteKeySecretName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The primary write key secret name to create." - } - }, - "primaryReadOnlyKeySecretName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The primary readonly key secret name to create." - } - }, - "primaryWriteConnectionStringSecretName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The primary write connection string secret name to create." - } - }, - "primaryReadonlyConnectionStringSecretName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The primary readonly connection string secret name to create." - } - }, - "secondaryWriteKeySecretName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The primary write key secret name to create." - } - }, - "secondaryReadonlyKeySecretName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The primary readonly key secret name to create." - } - }, - "secondaryWriteConnectionStringSecretName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The primary write connection string secret name to create." - } - }, - "secondaryReadonlyConnectionStringSecretName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The primary readonly connection string secret name to create." - } - } - } - }, - "secretsOutputType": { - "type": "object", - "properties": {}, - "additionalProperties": { - "$ref": "#/definitions/secretSetType", - "metadata": { - "description": "An exported secret's references." - } - } - }, - "networkRestrictionsType": { - "type": "object", - "properties": { - "ipRules": { - "type": "array", - "items": { - "type": "string" - }, - "metadata": { - "description": "Required. A single IPv4 address or a single IPv4 address range in CIDR format. Provided IPs must be well-formatted and cannot be contained in one of the following ranges: 10.0.0.0/8, 100.64.0.0/10, 172.16.0.0/12, 192.168.0.0/16, since these are not enforceable by the IP address filter. Example of valid inputs: \"23.40.210.245\" or \"23.40.210.0/8\"." - } - }, - "networkAclBypass": { - "type": "string", - "allowedValues": [ - "AzureServices", - "None" - ], - "nullable": true, - "metadata": { - "description": "Optional. Default to AzureServices. Specifies the network ACL bypass for Azure services." - } - }, - "publicNetworkAccess": { - "type": "string", - "allowedValues": [ - "Disabled", - "Enabled" - ], - "nullable": true, - "metadata": { - "description": "Optional. Default to Enabled. Whether requests from Public Network are allowed." - } - }, - "virtualNetworkRules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "subnetResourceId": { - "type": "string", - "metadata": { - "description": "Required. Resource ID of a subnet." - } - } - } - }, - "metadata": { - "description": "Required. List of Virtual Network ACL rules configured for the Cosmos DB account.." - } - } - } - }, - "secretSetType": { - "type": "object", - "properties": { - "secretResourceId": { - "type": "string", - "metadata": { - "description": "The resourceId of the exported secret." - } - }, - "secretUri": { - "type": "string", - "metadata": { - "description": "The secret URI of the exported secret." - } - } - }, - "metadata": { - "__bicep_imported_from!": { - "sourceTemplate": "modules/keyVaultExport.bicep" - } - } - } - }, - "parameters": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the Database Account." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Optional. Default to current resource group scope location. Location for all resources." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags of the Database Account resource." - } - }, - "managedIdentities": { - "$ref": "#/definitions/managedIdentitiesType", - "metadata": { - "description": "Optional. The managed identity definition for this resource." - } - }, - "databaseAccountOfferType": { - "type": "string", - "defaultValue": "Standard", - "allowedValues": [ - "Standard" - ], - "metadata": { - "description": "Optional. Default to Standard. The offer type for the Azure Cosmos DB database account." - } - }, - "locations": { - "type": "array", - "items": { - "$ref": "#/definitions/failoverLocationsType" - }, - "defaultValue": [], - "metadata": { - "description": "Optional. Default to the location where the account is deployed. Locations enabled for the Cosmos DB account." - } - }, - "defaultConsistencyLevel": { - "type": "string", - "defaultValue": "Session", - "allowedValues": [ - "Eventual", - "ConsistentPrefix", - "Session", - "BoundedStaleness", - "Strong" - ], - "metadata": { - "description": "Optional. Default to Session. The default consistency level of the Cosmos DB account." - } - }, - "disableLocalAuth": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Opt-out of local authentication and ensure only MSI and AAD can be used exclusively for authentication." - } - }, - "enableAnalyticalStorage": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Optional. Flag to indicate whether to enable storage analytics." - } - }, - "automaticFailover": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Enable automatic failover for regions." - } - }, - "enableFreeTier": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Optional. Flag to indicate whether Free Tier is enabled." - } - }, - "enableMultipleWriteLocations": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Optional. Enables the account to write in multiple locations. Periodic backup must be used if enabled." - } - }, - "disableKeyBasedMetadataWriteAccess": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Disable write operations on metadata resources (databases, containers, throughput) via account keys." - } - }, - "maxStalenessPrefix": { - "type": "int", - "defaultValue": 100000, - "minValue": 1, - "maxValue": 2147483647, - "metadata": { - "description": "Optional. Default to 100000. Max stale requests. Required for BoundedStaleness. Valid ranges, Single Region: 10 to 1000000. Multi Region: 100000 to 1000000." - } - }, - "maxIntervalInSeconds": { - "type": "int", - "defaultValue": 300, - "minValue": 5, - "maxValue": 86400, - "metadata": { - "description": "Optional. Default to 300. Max lag time (minutes). Required for BoundedStaleness. Valid ranges, Single Region: 5 to 84600. Multi Region: 300 to 86400." - } - }, - "serverVersion": { - "type": "string", - "defaultValue": "4.2", - "allowedValues": [ - "3.2", - "3.6", - "4.0", - "4.2", - "5.0", - "6.0", - "7.0" - ], - "metadata": { - "description": "Optional. Default to 4.2. Specifies the MongoDB server version to use." - } - }, - "sqlDatabases": { - "type": "array", - "items": { - "$ref": "#/definitions/sqlDatabaseType" - }, - "defaultValue": [], - "metadata": { - "description": "Optional. SQL Databases configurations." - } - }, - "sqlRoleAssignmentsPrincipalIds": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. SQL Role Definitions configurations." - } - }, - "sqlRoleDefinitions": { - "$ref": "#/definitions/sqlRoleDefinitionsType", - "metadata": { - "description": "Optional. SQL Role Definitions configurations." - } - }, - "mongodbDatabases": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. MongoDB Databases configurations." - } - }, - "gremlinDatabases": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Gremlin Databases configurations." - } - }, - "tables": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Table configurations." - } - }, - "enableTelemetry": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Enable/Disable usage telemetry for module." - } - }, - "lock": { - "$ref": "#/definitions/lockType", - "metadata": { - "description": "Optional. The lock settings of the service." - } - }, - "roleAssignments": { - "$ref": "#/definitions/roleAssignmentType", - "metadata": { - "description": "Optional. Array of role assignment objects that contain the 'roleDefinitionIdOrName' and 'principalIds' to define RBAC role assignments on this resource. In the roleDefinitionIdOrName attribute, you can provide either the display name of the role definition, or its fully qualified ID in the following format: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'." - } - }, - "diagnosticSettings": { - "$ref": "#/definitions/diagnosticSettingType", - "metadata": { - "description": "Optional. The diagnostic settings of the service." - } - }, - "capabilitiesToAdd": { - "type": "array", - "items": { - "type": "string" - }, - "defaultValue": [], - "allowedValues": [ - "EnableCassandra", - "EnableTable", - "EnableGremlin", - "EnableMongo", - "DisableRateLimitingResponses", - "EnableServerless", - "EnableNoSQLVectorSearch", - "EnableNoSQLFullTextSearch", - "EnableMaterializedViews" - ], - "metadata": { - "description": "Optional. List of Cosmos DB capabilities for the account." - } - }, - "backupPolicyType": { - "type": "string", - "defaultValue": "Continuous", - "allowedValues": [ - "Periodic", - "Continuous" - ], - "metadata": { - "description": "Optional. Default to Continuous. Describes the mode of backups. Periodic backup must be used if multiple write locations are used." - } - }, - "backupPolicyContinuousTier": { - "type": "string", - "defaultValue": "Continuous30Days", - "allowedValues": [ - "Continuous30Days", - "Continuous7Days" - ], - "metadata": { - "description": "Optional. Default to Continuous30Days. Configuration values for continuous mode backup." - } - }, - "backupIntervalInMinutes": { - "type": "int", - "defaultValue": 240, - "minValue": 60, - "maxValue": 1440, - "metadata": { - "description": "Optional. Default to 240. An integer representing the interval in minutes between two backups. Only applies to periodic backup type." - } - }, - "backupRetentionIntervalInHours": { - "type": "int", - "defaultValue": 8, - "minValue": 2, - "maxValue": 720, - "metadata": { - "description": "Optional. Default to 8. An integer representing the time (in hours) that each backup is retained. Only applies to periodic backup type." - } - }, - "backupStorageRedundancy": { - "type": "string", - "defaultValue": "Local", - "allowedValues": [ - "Geo", - "Local", - "Zone" - ], - "metadata": { - "description": "Optional. Default to Local. Enum to indicate type of backup residency. Only applies to periodic backup type." - } - }, - "privateEndpoints": { - "$ref": "#/definitions/privateEndpointType", - "metadata": { - "description": "Optional. Configuration details for private endpoints. For security reasons, it is recommended to use private endpoints whenever possible." - } - }, - "secretsExportConfiguration": { - "$ref": "#/definitions/secretsExportConfigurationType", - "nullable": true, - "metadata": { - "description": "Optional. Key vault reference and secret settings for the module's secrets export." - } - }, - "networkRestrictions": { - "$ref": "#/definitions/networkRestrictionsType", - "defaultValue": { - "ipRules": [], - "virtualNetworkRules": [], - "publicNetworkAccess": "Disabled" - }, - "metadata": { - "description": "Optional. The network configuration of this module. Defaults to `{ ipRules: [], virtualNetworkRules: [], publicNetworkAccess: 'Disabled' }`." - } - }, - "minimumTlsVersion": { - "type": "string", - "defaultValue": "Tls12", - "allowedValues": [ - "Tls", - "Tls11", - "Tls12" - ], - "metadata": { - "description": "Optional. Default to TLS 1.2. Enum to indicate the minimum allowed TLS version. Azure Cosmos DB for MongoDB RU and Apache Cassandra only work with TLS 1.2 or later." - } - } - }, - "variables": { - "copy": [ - { - "name": "databaseAccount_locations", - "count": "[length(parameters('locations'))]", - "input": { - "failoverPriority": "[parameters('locations')[copyIndex('databaseAccount_locations')].failoverPriority]", - "locationName": "[parameters('locations')[copyIndex('databaseAccount_locations')].locationName]", - "isZoneRedundant": "[coalesce(tryGet(parameters('locations')[copyIndex('databaseAccount_locations')], 'isZoneRedundant'), true())]" - } - }, - { - "name": "capabilities", - "count": "[length(parameters('capabilitiesToAdd'))]", - "input": { - "name": "[parameters('capabilitiesToAdd')[copyIndex('capabilities')]]" - } - }, - { - "name": "ipRules", - "count": "[length(coalesce(tryGet(parameters('networkRestrictions'), 'ipRules'), createArray()))]", - "input": { - "ipAddressOrRange": "[coalesce(tryGet(parameters('networkRestrictions'), 'ipRules'), createArray())[copyIndex('ipRules')]]" - } - }, - { - "name": "virtualNetworkRules", - "count": "[length(coalesce(tryGet(parameters('networkRestrictions'), 'virtualNetworkRules'), createArray()))]", - "input": { - "id": "[coalesce(tryGet(parameters('networkRestrictions'), 'virtualNetworkRules'), createArray())[copyIndex('virtualNetworkRules')].subnetResourceId]", - "ignoreMissingVnetServiceEndpoint": false - } - }, - { - "name": "formattedRoleAssignments", - "count": "[length(coalesce(parameters('roleAssignments'), createArray()))]", - "input": "[union(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')], createObject('roleDefinitionId', coalesce(tryGet(variables('builtInRoleNames'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName), if(contains(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, '/providers/Microsoft.Authorization/roleDefinitions/'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName)))))]" - } - ], - "formattedUserAssignedIdentities": "[reduce(map(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createArray()), lambda('id', createObject(format('{0}', lambdaVariables('id')), createObject()))), createObject(), lambda('cur', 'next', union(lambdaVariables('cur'), lambdaVariables('next'))))]", - "identity": "[if(not(empty(parameters('managedIdentities'))), createObject('type', if(coalesce(tryGet(parameters('managedIdentities'), 'systemAssigned'), false()), if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'SystemAssigned,UserAssigned', 'SystemAssigned'), if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'UserAssigned', null())), 'userAssignedIdentities', if(not(empty(variables('formattedUserAssignedIdentities'))), variables('formattedUserAssignedIdentities'), null())), null())]", - "consistencyPolicy": { - "Eventual": { - "defaultConsistencyLevel": "Eventual" - }, - "ConsistentPrefix": { - "defaultConsistencyLevel": "ConsistentPrefix" - }, - "Session": { - "defaultConsistencyLevel": "Session" - }, - "BoundedStaleness": { - "defaultConsistencyLevel": "BoundedStaleness", - "maxStalenessPrefix": "[parameters('maxStalenessPrefix')]", - "maxIntervalInSeconds": "[parameters('maxIntervalInSeconds')]" - }, - "Strong": { - "defaultConsistencyLevel": "Strong" - } - }, - "defaultFailoverLocation": [ - { - "failoverPriority": 0, - "locationName": "[parameters('location')]", - "isZoneRedundant": true - } - ], - "kind": "[if(or(not(empty(parameters('sqlDatabases'))), not(empty(parameters('gremlinDatabases')))), 'GlobalDocumentDB', if(not(empty(parameters('mongodbDatabases'))), 'MongoDB', 'GlobalDocumentDB'))]", - "backupPolicy": "[if(equals(parameters('backupPolicyType'), 'Continuous'), createObject('type', parameters('backupPolicyType'), 'continuousModeProperties', createObject('tier', parameters('backupPolicyContinuousTier'))), createObject('type', parameters('backupPolicyType'), 'periodicModeProperties', createObject('backupIntervalInMinutes', parameters('backupIntervalInMinutes'), 'backupRetentionIntervalInHours', parameters('backupRetentionIntervalInHours'), 'backupStorageRedundancy', parameters('backupStorageRedundancy'))))]", - "databaseAccountProperties": "[union(createObject('databaseAccountOfferType', parameters('databaseAccountOfferType'), 'backupPolicy', variables('backupPolicy'), 'minimalTlsVersion', parameters('minimumTlsVersion')), if(or(or(or(not(empty(parameters('sqlDatabases'))), not(empty(parameters('mongodbDatabases')))), not(empty(parameters('gremlinDatabases')))), not(empty(parameters('tables')))), createObject('consistencyPolicy', variables('consistencyPolicy')[parameters('defaultConsistencyLevel')], 'enableMultipleWriteLocations', parameters('enableMultipleWriteLocations'), 'locations', if(empty(variables('databaseAccount_locations')), variables('defaultFailoverLocation'), variables('databaseAccount_locations')), 'ipRules', variables('ipRules'), 'virtualNetworkRules', variables('virtualNetworkRules'), 'networkAclBypass', coalesce(tryGet(parameters('networkRestrictions'), 'networkAclBypass'), 'AzureServices'), 'publicNetworkAccess', coalesce(tryGet(parameters('networkRestrictions'), 'publicNetworkAccess'), 'Enabled'), 'isVirtualNetworkFilterEnabled', or(not(empty(variables('ipRules'))), not(empty(variables('virtualNetworkRules')))), 'capabilities', variables('capabilities'), 'enableFreeTier', parameters('enableFreeTier'), 'enableAutomaticFailover', parameters('automaticFailover'), 'enableAnalyticalStorage', parameters('enableAnalyticalStorage')), createObject()), if(or(not(empty(parameters('sqlDatabases'))), not(empty(parameters('tables')))), createObject('disableLocalAuth', parameters('disableLocalAuth'), 'disableKeyBasedMetadataWriteAccess', parameters('disableKeyBasedMetadataWriteAccess')), createObject()), if(not(empty(parameters('mongodbDatabases'))), createObject('apiProperties', createObject('serverVersion', parameters('serverVersion'))), createObject()))]", - "builtInRoleNames": { - "Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", - "Cosmos DB Account Reader Role": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'fbdf93bf-df7d-467e-a4d2-9458aa1360c8')]", - "Cosmos DB Operator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '230815da-be43-4aae-9cb4-875f7bd000aa')]", - "CosmosBackupOperator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'db7b14f2-5adf-42da-9f96-f2ee17bab5cb')]", - "CosmosRestoreOperator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5432c526-bc82-444a-b7ba-57c5b0b5b34f')]", - "DocumentDB Account Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5bd9cd88-fe45-4216-938b-f97437e15450')]", - "Owner": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635')]", - "Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", - "Role Based Access Control Administrator (Preview)": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168')]", - "User Access Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9')]" - } - }, - "resources": { - "avmTelemetry": { - "condition": "[parameters('enableTelemetry')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2024-03-01", - "name": "[format('46d3xbcp.res.documentdb-databaseaccount.{0}.{1}', replace('0.9.0', '.', '-'), substring(uniqueString(deployment().name, parameters('location')), 0, 4))]", - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "resources": [], - "outputs": { - "telemetry": { - "type": "String", - "value": "For more information, see https://aka.ms/avm/TelemetryInfo" - } - } - } - } - }, - "databaseAccount": { - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('name')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "identity": "[variables('identity')]", - "kind": "[variables('kind')]", - "properties": "[variables('databaseAccountProperties')]" - }, - "databaseAccount_lock": { - "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", - "type": "Microsoft.Authorization/locks", - "apiVersion": "2020-05-01", - "scope": "[format('Microsoft.DocumentDB/databaseAccounts/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", - "properties": { - "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", - "notes": "[if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.')]" - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount_diagnosticSettings": { - "copy": { - "name": "databaseAccount_diagnosticSettings", - "count": "[length(coalesce(parameters('diagnosticSettings'), createArray()))]" - }, - "type": "Microsoft.Insights/diagnosticSettings", - "apiVersion": "2021-05-01-preview", - "scope": "[format('Microsoft.DocumentDB/databaseAccounts/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'name'), format('{0}-diagnosticSettings', parameters('name')))]", - "properties": { - "copy": [ - { - "name": "metrics", - "count": "[length(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'metricCategories'), createArray(createObject('category', 'AllMetrics'))))]", - "input": { - "category": "[coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'metricCategories'), createArray(createObject('category', 'AllMetrics')))[copyIndex('metrics')].category]", - "enabled": "[coalesce(tryGet(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'metricCategories'), createArray(createObject('category', 'AllMetrics')))[copyIndex('metrics')], 'enabled'), true())]", - "timeGrain": null - } - }, - { - "name": "logs", - "count": "[length(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logCategoriesAndGroups'), createArray(createObject('categoryGroup', 'allLogs'))))]", - "input": { - "categoryGroup": "[tryGet(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logCategoriesAndGroups'), createArray(createObject('categoryGroup', 'allLogs')))[copyIndex('logs')], 'categoryGroup')]", - "category": "[tryGet(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logCategoriesAndGroups'), createArray(createObject('categoryGroup', 'allLogs')))[copyIndex('logs')], 'category')]", - "enabled": "[coalesce(tryGet(coalesce(tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logCategoriesAndGroups'), createArray(createObject('categoryGroup', 'allLogs')))[copyIndex('logs')], 'enabled'), true())]" - } - } - ], - "storageAccountId": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'storageAccountResourceId')]", - "workspaceId": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'workspaceResourceId')]", - "eventHubAuthorizationRuleId": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'eventHubAuthorizationRuleResourceId')]", - "eventHubName": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'eventHubName')]", - "marketplacePartnerId": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'marketplacePartnerResourceId')]", - "logAnalyticsDestinationType": "[tryGet(coalesce(parameters('diagnosticSettings'), createArray())[copyIndex()], 'logAnalyticsDestinationType')]" - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount_roleAssignments": { - "copy": { - "name": "databaseAccount_roleAssignments", - "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.DocumentDB/databaseAccounts/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", - "properties": { - "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", - "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", - "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", - "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", - "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", - "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", - "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount_sqlDatabases": { - "copy": { - "name": "databaseAccount_sqlDatabases", - "count": "[length(parameters('sqlDatabases'))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-sqldb-{1}', uniqueString(deployment().name, parameters('location')), parameters('sqlDatabases')[copyIndex()].name)]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "[parameters('sqlDatabases')[copyIndex()].name]" - }, - "containers": { - "value": "[tryGet(parameters('sqlDatabases')[copyIndex()], 'containers')]" - }, - "throughput": { - "value": "[tryGet(parameters('sqlDatabases')[copyIndex()], 'throughput')]" - }, - "databaseAccountName": { - "value": "[parameters('name')]" - }, - "autoscaleSettingsMaxThroughput": { - "value": "[tryGet(parameters('sqlDatabases')[copyIndex()], 'autoscaleSettingsMaxThroughput')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "14039021912249335209" - }, - "name": "DocumentDB Database Account SQL Databases", - "description": "This module deploys a SQL Database in a CosmosDB Account.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Database Account. Required if the template is used in a standalone deployment." - } - }, - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the SQL database ." - } - }, - "containers": { - "type": "array", - "items": { - "type": "object" - }, - "defaultValue": [], - "metadata": { - "description": "Optional. Array of containers to deploy in the SQL database." - } - }, - "throughput": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Request units per second. Will be ignored if autoscaleSettingsMaxThroughput is used." - } - }, - "autoscaleSettingsMaxThroughput": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Specifies the Autoscale settings and represents maximum throughput, the resource can scale up to. The autoscale throughput should have valid throughput values between 1000 and 1000000 inclusive in increments of 1000. If value is set to null, then autoscale will be disabled." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags of the SQL database resource." - } - } - }, - "resources": { - "databaseAccount": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('databaseAccountName')]" - }, - "sqlDatabase": { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('databaseAccountName'), parameters('name'))]", - "tags": "[parameters('tags')]", - "properties": { - "resource": { - "id": "[parameters('name')]" - }, - "options": "[if(contains(reference('databaseAccount').capabilities, createObject('name', 'EnableServerless')), null(), createObject('throughput', if(equals(parameters('autoscaleSettingsMaxThroughput'), null()), parameters('throughput'), null()), 'autoscaleSettings', if(not(equals(parameters('autoscaleSettingsMaxThroughput'), null())), createObject('maxThroughput', parameters('autoscaleSettingsMaxThroughput')), null())))]" - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "container": { - "copy": { - "name": "container", - "count": "[length(parameters('containers'))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-sqldb-{1}', uniqueString(deployment().name, parameters('name')), parameters('containers')[copyIndex()].name)]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "databaseAccountName": { - "value": "[parameters('databaseAccountName')]" - }, - "sqlDatabaseName": { - "value": "[parameters('name')]" - }, - "name": { - "value": "[parameters('containers')[copyIndex()].name]" - }, - "analyticalStorageTtl": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'analyticalStorageTtl')]" - }, - "autoscaleSettingsMaxThroughput": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'autoscaleSettingsMaxThroughput')]" - }, - "conflictResolutionPolicy": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'conflictResolutionPolicy')]" - }, - "defaultTtl": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'defaultTtl')]" - }, - "indexingPolicy": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'indexingPolicy')]" - }, - "kind": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'kind')]" - }, - "version": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'version')]" - }, - "paths": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'paths')]" - }, - "throughput": "[if(and(or(not(equals(parameters('throughput'), null())), not(equals(parameters('autoscaleSettingsMaxThroughput'), null()))), equals(tryGet(parameters('containers')[copyIndex()], 'throughput'), null())), createObject('value', -1), createObject('value', tryGet(parameters('containers')[copyIndex()], 'throughput')))]", - "uniqueKeyPolicyKeys": { - "value": "[tryGet(parameters('containers')[copyIndex()], 'uniqueKeyPolicyKeys')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "1471754747460263407" - }, - "name": "DocumentDB Database Account SQL Database Containers", - "description": "This module deploys a SQL Database Container in a CosmosDB Account.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Database Account. Required if the template is used in a standalone deployment." - } - }, - "sqlDatabaseName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent SQL Database. Required if the template is used in a standalone deployment." - } - }, - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the container." - } - }, - "analyticalStorageTtl": { - "type": "int", - "defaultValue": 0, - "metadata": { - "description": "Optional. Default to 0. Indicates how long data should be retained in the analytical store, for a container. Analytical store is enabled when ATTL is set with a value other than 0. If the value is set to -1, the analytical store retains all historical data, irrespective of the retention of the data in the transactional store." - } - }, - "conflictResolutionPolicy": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional. The conflict resolution policy for the container. Conflicts and conflict resolution policies are applicable if the Azure Cosmos DB account is configured with multiple write regions." - } - }, - "defaultTtl": { - "type": "int", - "defaultValue": -1, - "minValue": -1, - "maxValue": 2147483647, - "metadata": { - "description": "Optional. Default to -1. Default time to live (in seconds). With Time to Live or TTL, Azure Cosmos DB provides the ability to delete items automatically from a container after a certain time period. If the value is set to \"-1\", it is equal to infinity, and items don't expire by default." - } - }, - "throughput": { - "type": "int", - "defaultValue": 400, - "metadata": { - "description": "Optional. Default to 400. Request Units per second. Will be ignored if autoscaleSettingsMaxThroughput is used." - } - }, - "autoscaleSettingsMaxThroughput": { - "type": "int", - "nullable": true, - "maxValue": 1000000, - "metadata": { - "description": "Optional. Specifies the Autoscale settings and represents maximum throughput, the resource can scale up to. The autoscale throughput should have valid throughput values between 1000 and 1000000 inclusive in increments of 1000. If value is set to null, then autoscale will be disabled." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags of the SQL Database resource." - } - }, - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "minLength": 1, - "maxLength": 3, - "metadata": { - "description": "Required. List of paths using which data within the container can be partitioned. For kind=MultiHash it can be up to 3. For anything else it needs to be exactly 1." - } - }, - "indexingPolicy": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional. Indexing policy of the container." - } - }, - "uniqueKeyPolicyKeys": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. The unique key policy configuration containing a list of unique keys that enforces uniqueness constraint on documents in the collection in the Azure Cosmos DB service." - } - }, - "kind": { - "type": "string", - "defaultValue": "Hash", - "allowedValues": [ - "Hash", - "MultiHash" - ], - "metadata": { - "description": "Optional. Default to Hash. Indicates the kind of algorithm used for partitioning." - } - }, - "version": { - "type": "int", - "defaultValue": 1, - "allowedValues": [ - 1, - 2 - ], - "metadata": { - "description": "Optional. Default to 1 for Hash and 2 for MultiHash - 1 is not allowed for MultiHash. Version of the partition key definition." - } - } - }, - "variables": { - "copy": [ - { - "name": "partitionKeyPaths", - "count": "[length(parameters('paths'))]", - "input": "[if(startsWith(parameters('paths')[copyIndex('partitionKeyPaths')], '/'), parameters('paths')[copyIndex('partitionKeyPaths')], format('/{0}', parameters('paths')[copyIndex('partitionKeyPaths')]))]" - } - ], - "containerResourceParams": "[union(createObject('conflictResolutionPolicy', parameters('conflictResolutionPolicy'), 'defaultTtl', parameters('defaultTtl'), 'id', parameters('name'), 'indexingPolicy', if(not(empty(parameters('indexingPolicy'))), parameters('indexingPolicy'), null()), 'partitionKey', createObject('paths', variables('partitionKeyPaths'), 'kind', parameters('kind'), 'version', if(equals(parameters('kind'), 'MultiHash'), 2, parameters('version'))), 'uniqueKeyPolicy', if(not(empty(parameters('uniqueKeyPolicyKeys'))), createObject('uniqueKeys', parameters('uniqueKeyPolicyKeys')), null())), if(not(equals(parameters('analyticalStorageTtl'), 0)), createObject('analyticalStorageTtl', parameters('analyticalStorageTtl')), createObject()))]" - }, - "resources": { - "databaseAccount::sqlDatabase": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('databaseAccountName'), parameters('sqlDatabaseName'))]", - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('databaseAccountName')]" - }, - "container": { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', parameters('databaseAccountName'), parameters('sqlDatabaseName'), parameters('name'))]", - "tags": "[parameters('tags')]", - "properties": { - "resource": "[variables('containerResourceParams')]", - "options": "[if(contains(reference('databaseAccount').capabilities, createObject('name', 'EnableServerless')), null(), createObject('throughput', if(and(equals(parameters('autoscaleSettingsMaxThroughput'), null()), not(equals(parameters('throughput'), -1))), parameters('throughput'), null()), 'autoscaleSettings', if(not(equals(parameters('autoscaleSettingsMaxThroughput'), null())), createObject('maxThroughput', parameters('autoscaleSettingsMaxThroughput')), null())))]" - }, - "dependsOn": [ - "databaseAccount::sqlDatabase" - ] - } - }, - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the container." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the container." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers', parameters('databaseAccountName'), parameters('sqlDatabaseName'), parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the container was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "sqlDatabase" - ] - } - }, - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the SQL database." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the SQL database." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', parameters('databaseAccountName'), parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the SQL database was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount_sqlRoleDefinitions": { - "copy": { - "name": "databaseAccount_sqlRoleDefinitions", - "count": "[length(coalesce(parameters('sqlRoleDefinitions'), createArray()))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-sqlrd-{1}', uniqueString(deployment().name, parameters('location')), coalesce(parameters('sqlRoleDefinitions'), createArray())[copyIndex()].name)]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "[coalesce(parameters('sqlRoleDefinitions'), createArray())[copyIndex()].name]" - }, - "databaseAccountName": { - "value": "[parameters('name')]" - }, - "dataActions": { - "value": "[tryGet(coalesce(parameters('sqlRoleDefinitions'), createArray())[copyIndex()], 'dataActions')]" - }, - "roleName": { - "value": "[tryGet(coalesce(parameters('sqlRoleDefinitions'), createArray())[copyIndex()], 'roleName')]" - }, - "roleType": { - "value": "[tryGet(coalesce(parameters('sqlRoleDefinitions'), createArray())[copyIndex()], 'roleType')]" - }, - "principalIds": { - "value": "[parameters('sqlRoleAssignmentsPrincipalIds')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "3860121931480041680" - }, - "name": "DocumentDB Database Account SQL Role.", - "description": "This module deploys SQL Role Definision and Assignment in a CosmosDB Account.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Database Account. Required if the template is used in a standalone deployment." - } - }, - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the SQL Role." - } - }, - "dataActions": { - "type": "array", - "defaultValue": [ - "Microsoft.DocumentDB/databaseAccounts/readMetadata", - "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*", - "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*" - ], - "metadata": { - "description": "Optional. An array of data actions that are allowed." - } - }, - "principalIds": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Ids needs to be granted." - } - }, - "roleName": { - "type": "string", - "defaultValue": "Reader Writer", - "metadata": { - "description": "Optional. A user-friendly name for the Role Definition. Must be unique for the database account." - } - }, - "roleType": { - "type": "string", - "defaultValue": "CustomRole", - "allowedValues": [ - "CustomRole", - "BuiltInRole" - ], - "metadata": { - "description": "Optional. Indicates whether the Role Definition was built-in or user created." - } - } - }, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('sql-role-definition-{0}', uniqueString(parameters('name')))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "databaseAccountName": { - "value": "[parameters('databaseAccountName')]" - }, - "dataActions": { - "value": "[parameters('dataActions')]" - }, - "roleName": { - "value": "[parameters('roleName')]" - }, - "roleType": { - "value": "[parameters('roleType')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "2222650596260487600" - }, - "name": "DocumentDB Database Account SQL Role Definitions.", - "description": "This module deploys a SQL Role Definision in a CosmosDB Account.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Database Account. Required if the template is used in a standalone deployment." - } - }, - "dataActions": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. An array of data actions that are allowed." - } - }, - "roleName": { - "type": "string", - "defaultValue": "Reader Writer", - "metadata": { - "description": "Optional. A user-friendly name for the Role Definition. Must be unique for the database account." - } - }, - "roleType": { - "type": "string", - "defaultValue": "CustomRole", - "allowedValues": [ - "CustomRole", - "BuiltInRole" - ], - "metadata": { - "description": "Optional. Indicates whether the Role Definition was built-in or user created." - } - } - }, - "resources": [ - { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('databaseAccountName'), guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('databaseAccountName')), parameters('databaseAccountName'), 'sql-role'))]", - "properties": { - "assignableScopes": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('databaseAccountName'))]" - ], - "permissions": [ - { - "dataActions": "[parameters('dataActions')]" - } - ], - "roleName": "[parameters('roleName')]", - "type": "[parameters('roleType')]" - } - } - ], - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the SQL database." - }, - "value": "[guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('databaseAccountName')), parameters('databaseAccountName'), 'sql-role')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the SQL database." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions', parameters('databaseAccountName'), guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('databaseAccountName')), parameters('databaseAccountName'), 'sql-role'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the SQL database was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - } - }, - { - "copy": { - "name": "sqlRoleAssignment", - "count": "[length(parameters('principalIds'))]", - "mode": "serial", - "batchSize": 1 - }, - "condition": "[not(empty(parameters('principalIds')[copyIndex()]))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('sql-role-assign-{0}', uniqueString(parameters('principalIds')[copyIndex()]))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "[guid(reference(resourceId('Microsoft.Resources/deployments', format('sql-role-definition-{0}', uniqueString(parameters('name')))), '2022-09-01').outputs.resourceId.value, parameters('principalIds')[copyIndex()], resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('databaseAccountName')))]" - }, - "databaseAccountName": { - "value": "[parameters('databaseAccountName')]" - }, - "roleDefinitionId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('sql-role-definition-{0}', uniqueString(parameters('name')))), '2022-09-01').outputs.resourceId.value]" - }, - "principalId": { - "value": "[parameters('principalIds')[copyIndex()]]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "12993275952067538651" - }, - "name": "DocumentDB Database Account SQL Role Assignments.", - "description": "This module deploys a SQL Role Assignment in a CosmosDB Account.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Database Account. Required if the template is used in a standalone deployment." - } - }, - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the SQL Role Assignment." - } - }, - "principalId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional. Id needs to be granted." - } - }, - "roleDefinitionId": { - "type": "string", - "metadata": { - "description": "Required. Id of the SQL Role Definition." - } - } - }, - "resources": [ - { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('databaseAccountName'), parameters('name'))]", - "properties": { - "principalId": "[parameters('principalId')]", - "roleDefinitionId": "[parameters('roleDefinitionId')]", - "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('databaseAccountName'))]" - } - } - ], - "outputs": { - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the SQL Role Assignment was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('sql-role-definition-{0}', uniqueString(parameters('name'))))]" - ] - } - ], - "outputs": { - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the SQL Role Definition and Assignment were created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount_mongodbDatabases": { - "copy": { - "name": "databaseAccount_mongodbDatabases", - "count": "[length(parameters('mongodbDatabases'))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-mongodb-{1}', uniqueString(deployment().name, parameters('location')), parameters('mongodbDatabases')[copyIndex()].name)]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "databaseAccountName": { - "value": "[parameters('name')]" - }, - "name": { - "value": "[parameters('mongodbDatabases')[copyIndex()].name]" - }, - "tags": { - "value": "[coalesce(tryGet(parameters('mongodbDatabases')[copyIndex()], 'tags'), parameters('tags'))]" - }, - "collections": { - "value": "[tryGet(parameters('mongodbDatabases')[copyIndex()], 'collections')]" - }, - "throughput": { - "value": "[tryGet(parameters('mongodbDatabases')[copyIndex()], 'throughput')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "18295016247574474595" - }, - "name": "DocumentDB Database Account MongoDB Databases", - "description": "This module deploys a MongoDB Database within a CosmosDB Account.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Cosmos DB database account. Required if the template is used in a standalone deployment." - } - }, - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the mongodb database." - } - }, - "throughput": { - "type": "int", - "defaultValue": 400, - "metadata": { - "description": "Optional. Request Units per second." - } - }, - "collections": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Collections in the mongodb database." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags of the resource." - } - } - }, - "resources": { - "databaseAccount": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('databaseAccountName')]" - }, - "mongodbDatabase": { - "type": "Microsoft.DocumentDB/databaseAccounts/mongodbDatabases", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('databaseAccountName'), parameters('name'))]", - "tags": "[parameters('tags')]", - "properties": { - "resource": { - "id": "[parameters('name')]" - }, - "options": "[if(contains(reference('databaseAccount').capabilities, createObject('name', 'EnableServerless')), null(), createObject('throughput', parameters('throughput')))]" - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "mongodbDatabase_collections": { - "copy": { - "name": "mongodbDatabase_collections", - "count": "[length(parameters('collections'))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-collection-{1}', uniqueString(deployment().name, parameters('name')), parameters('collections')[copyIndex()].name)]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "databaseAccountName": { - "value": "[parameters('databaseAccountName')]" - }, - "mongodbDatabaseName": { - "value": "[parameters('name')]" - }, - "name": { - "value": "[parameters('collections')[copyIndex()].name]" - }, - "indexes": { - "value": "[parameters('collections')[copyIndex()].indexes]" - }, - "shardKey": { - "value": "[parameters('collections')[copyIndex()].shardKey]" - }, - "throughput": { - "value": "[tryGet(parameters('collections')[copyIndex()], 'throughput')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "9799909568020880663" - }, - "name": "DocumentDB Database Account MongoDB Database Collections", - "description": "This module deploys a MongoDB Database Collection.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Cosmos DB database account. Required if the template is used in a standalone deployment." - } - }, - "mongodbDatabaseName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent mongodb database. Required if the template is used in a standalone deployment." - } - }, - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the collection." - } - }, - "throughput": { - "type": "int", - "defaultValue": 400, - "metadata": { - "description": "Optional. Request Units per second." - } - }, - "indexes": { - "type": "array", - "metadata": { - "description": "Required. Indexes for the collection." - } - }, - "shardKey": { - "type": "object", - "metadata": { - "description": "Required. ShardKey for the collection." - } - } - }, - "resources": [ - { - "type": "Microsoft.DocumentDB/databaseAccounts/mongodbDatabases/collections", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', parameters('databaseAccountName'), parameters('mongodbDatabaseName'), parameters('name'))]", - "properties": { - "options": "[if(contains(reference(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('databaseAccountName')), '2023-04-15').capabilities, createObject('name', 'EnableServerless')), null(), createObject('throughput', parameters('throughput')))]", - "resource": { - "id": "[parameters('name')]", - "indexes": "[parameters('indexes')]", - "shardKey": "[parameters('shardKey')]" - } - } - } - ], - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the mongodb database collection." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the mongodb database collection." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/mongodbDatabases/collections', parameters('databaseAccountName'), parameters('mongodbDatabaseName'), parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the mongodb database collection was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "mongodbDatabase" - ] - } - }, - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the mongodb database." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the mongodb database." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/mongodbDatabases', parameters('databaseAccountName'), parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the mongodb database was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount_gremlinDatabases": { - "copy": { - "name": "databaseAccount_gremlinDatabases", - "count": "[length(parameters('gremlinDatabases'))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-gremlin-{1}', uniqueString(deployment().name, parameters('location')), parameters('gremlinDatabases')[copyIndex()].name)]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "databaseAccountName": { - "value": "[parameters('name')]" - }, - "name": { - "value": "[parameters('gremlinDatabases')[copyIndex()].name]" - }, - "tags": { - "value": "[coalesce(tryGet(parameters('gremlinDatabases')[copyIndex()], 'tags'), parameters('tags'))]" - }, - "graphs": { - "value": "[tryGet(parameters('gremlinDatabases')[copyIndex()], 'graphs')]" - }, - "maxThroughput": { - "value": "[tryGet(parameters('gremlinDatabases')[copyIndex()], 'maxThroughput')]" - }, - "throughput": { - "value": "[tryGet(parameters('gremlinDatabases')[copyIndex()], 'throughput')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "6528096364275148764" - }, - "name": "DocumentDB Database Account Gremlin Databases", - "description": "This module deploys a Gremlin Database within a CosmosDB Account.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the Gremlin database." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags of the Gremlin database resource." - } - }, - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Gremlin database. Required if the template is used in a standalone deployment." - } - }, - "graphs": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Array of graphs to deploy in the Gremlin database." - } - }, - "maxThroughput": { - "type": "int", - "defaultValue": 4000, - "metadata": { - "description": "Optional. Represents maximum throughput, the resource can scale up to. Cannot be set together with `throughput`. If `throughput` is set to something else than -1, this autoscale setting is ignored." - } - }, - "throughput": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Request Units per second (for example 10000). Cannot be set together with `maxThroughput`." - } - } - }, - "resources": { - "databaseAccount": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('databaseAccountName')]" - }, - "gremlinDatabase": { - "type": "Microsoft.DocumentDB/databaseAccounts/gremlinDatabases", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('databaseAccountName'), parameters('name'))]", - "tags": "[parameters('tags')]", - "properties": { - "options": "[if(contains(reference('databaseAccount').capabilities, createObject('name', 'EnableServerless')), createObject(), createObject('autoscaleSettings', if(equals(parameters('throughput'), null()), createObject('maxThroughput', parameters('maxThroughput')), null()), 'throughput', parameters('throughput')))]", - "resource": { - "id": "[parameters('name')]" - } - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "gremlinDatabase_gremlinGraphs": { - "copy": { - "name": "gremlinDatabase_gremlinGraphs", - "count": "[length(parameters('graphs'))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-gremlindb-{1}', uniqueString(deployment().name, parameters('name')), parameters('graphs')[copyIndex()].name)]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "[parameters('graphs')[copyIndex()].name]" - }, - "gremlinDatabaseName": { - "value": "[parameters('name')]" - }, - "databaseAccountName": { - "value": "[parameters('databaseAccountName')]" - }, - "indexingPolicy": { - "value": "[tryGet(parameters('graphs')[copyIndex()], 'indexingPolicy')]" - }, - "partitionKeyPaths": "[if(not(empty(parameters('graphs')[copyIndex()].partitionKeyPaths)), createObject('value', parameters('graphs')[copyIndex()].partitionKeyPaths), createObject('value', createArray()))]" - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "16994331830326213766" - }, - "name": "DocumentDB Database Accounts Gremlin Databases Graphs", - "description": "This module deploys a DocumentDB Database Accounts Gremlin Database Graph.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the graph." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags of the Gremlin graph resource." - } - }, - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Database Account. Required if the template is used in a standalone deployment." - } - }, - "gremlinDatabaseName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Gremlin Database. Required if the template is used in a standalone deployment." - } - }, - "indexingPolicy": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional. Indexing policy of the graph." - } - }, - "partitionKeyPaths": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. List of paths using which data within the container can be partitioned." - } - } - }, - "resources": { - "databaseAccount::gremlinDatabase": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts/gremlinDatabases", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('databaseAccountName'), parameters('gremlinDatabaseName'))]", - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('databaseAccountName')]" - }, - "gremlinGraph": { - "type": "Microsoft.DocumentDB/databaseAccounts/gremlinDatabases/graphs", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', parameters('databaseAccountName'), parameters('gremlinDatabaseName'), parameters('name'))]", - "tags": "[parameters('tags')]", - "properties": { - "resource": { - "id": "[parameters('name')]", - "indexingPolicy": "[if(not(empty(parameters('indexingPolicy'))), parameters('indexingPolicy'), null())]", - "partitionKey": { - "paths": "[if(not(empty(parameters('partitionKeyPaths'))), parameters('partitionKeyPaths'), null())]" - } - } - }, - "dependsOn": [ - "databaseAccount::gremlinDatabase" - ] - } - }, - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the graph." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the graph." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/gremlinDatabases/graphs', parameters('databaseAccountName'), parameters('gremlinDatabaseName'), parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the graph was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "gremlinDatabase" - ] - } - }, - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the Gremlin database." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the Gremlin database." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/gremlinDatabases', parameters('databaseAccountName'), parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the Gremlin database was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount_tables": { - "copy": { - "name": "databaseAccount_tables", - "count": "[length(parameters('tables'))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-table-{1}', uniqueString(deployment().name, parameters('location')), parameters('tables')[copyIndex()].name)]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "databaseAccountName": { - "value": "[parameters('name')]" - }, - "name": { - "value": "[parameters('tables')[copyIndex()].name]" - }, - "tags": { - "value": "[coalesce(tryGet(parameters('tables')[copyIndex()], 'tags'), parameters('tags'))]" - }, - "maxThroughput": { - "value": "[tryGet(parameters('tables')[copyIndex()], 'maxThroughput')]" - }, - "throughput": { - "value": "[tryGet(parameters('tables')[copyIndex()], 'throughput')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "6722170581524078621" - }, - "name": "Azure Cosmos DB account tables", - "description": "This module deploys a table within an Azure Cosmos DB Account.", - "owner": "Azure/module-maintainers" - }, - "parameters": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the table." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags for the table." - } - }, - "databaseAccountName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent Azure Cosmos DB account. Required if the template is used in a standalone deployment." - } - }, - "maxThroughput": { - "type": "int", - "defaultValue": 4000, - "metadata": { - "description": "Optional. Represents maximum throughput, the resource can scale up to. Cannot be set together with `throughput`. If `throughput` is set to something else than -1, this autoscale setting is ignored." - } - }, - "throughput": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Request Units per second (for example 10000). Cannot be set together with `maxThroughput`." - } - } - }, - "resources": { - "databaseAccount": { - "existing": true, - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[parameters('databaseAccountName')]" - }, - "table": { - "type": "Microsoft.DocumentDB/databaseAccounts/tables", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', parameters('databaseAccountName'), parameters('name'))]", - "tags": "[parameters('tags')]", - "properties": { - "options": "[if(contains(reference('databaseAccount').capabilities, createObject('name', 'EnableServerless')), createObject(), createObject('autoscaleSettings', if(equals(parameters('throughput'), null()), createObject('maxThroughput', parameters('maxThroughput')), null()), 'throughput', parameters('throughput')))]", - "resource": { - "id": "[parameters('name')]" - } - }, - "dependsOn": [ - "databaseAccount" - ] - } - }, - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the table." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the table." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/tables', parameters('databaseAccountName'), parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the table was created in." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "databaseAccount_privateEndpoints": { - "copy": { - "name": "databaseAccount_privateEndpoints", - "count": "[length(coalesce(parameters('privateEndpoints'), createArray()))]" - }, - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-databaseAccount-PrivateEndpoint-{1}', uniqueString(deployment().name, parameters('location')), copyIndex())]", - "resourceGroup": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'resourceGroupName'), '')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'name'), format('pep-{0}-{1}-{2}', last(split(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '/')), coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].service, copyIndex()))]" - }, - "privateLinkServiceConnections": "[if(not(equals(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'isManualConnection'), true())), createObject('value', createArray(createObject('name', coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'privateLinkServiceConnectionName'), format('{0}-{1}-{2}', last(split(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '/')), coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].service, copyIndex())), 'properties', createObject('privateLinkServiceId', resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), 'groupIds', createArray(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].service))))), createObject('value', null()))]", - "manualPrivateLinkServiceConnections": "[if(equals(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'isManualConnection'), true()), createObject('value', createArray(createObject('name', coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'privateLinkServiceConnectionName'), format('{0}-{1}-{2}', last(split(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '/')), coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].service, copyIndex())), 'properties', createObject('privateLinkServiceId', resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), 'groupIds', createArray(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].service), 'requestMessage', coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'manualConnectionRequestMessage'), 'Manual approval required.'))))), createObject('value', null()))]", - "subnetResourceId": { - "value": "[coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].subnetResourceId]" - }, - "enableTelemetry": { - "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'enableTelemetry'), parameters('enableTelemetry'))]" - }, - "location": { - "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'location'), reference(split(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()].subnetResourceId, '/subnets/')[0], '2020-06-01', 'Full').location)]" - }, - "lock": { - "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'lock'), parameters('lock'))]" - }, - "privateDnsZoneGroup": { - "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'privateDnsZoneGroup')]" - }, - "roleAssignments": { - "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'roleAssignments')]" - }, - "tags": { - "value": "[coalesce(tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'tags'), parameters('tags'))]" - }, - "customDnsConfigs": { - "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'customDnsConfigs')]" - }, - "ipConfigurations": { - "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'ipConfigurations')]" - }, - "applicationSecurityGroupResourceIds": { - "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'applicationSecurityGroupResourceIds')]" - }, - "customNetworkInterfaceName": { - "value": "[tryGet(coalesce(parameters('privateEndpoints'), createArray())[copyIndex()], 'customNetworkInterfaceName')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "1277254088602407590" - }, - "name": "Private Endpoints", - "description": "This module deploys a Private Endpoint.", - "owner": "Azure/module-maintainers" - }, - "definitions": { - "privateDnsZoneGroupType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of the Private DNS Zone Group." - } - }, - "privateDnsZoneGroupConfigs": { - "type": "array", - "items": { - "$ref": "#/definitions/privateDnsZoneGroupConfigType" - }, - "metadata": { - "description": "Required. The private DNS zone groups to associate the private endpoint. A DNS zone group can support up to 5 DNS zones." - } - } - } - }, - "roleAssignmentType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name (as GUID) of the role assignment. If not provided, a GUID will be generated." - } - }, - "roleDefinitionIdOrName": { - "type": "string", - "metadata": { - "description": "Required. The role to assign. You can provide either the display name of the role definition, the role definition GUID, or its fully qualified ID in the following format: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Required. The principal ID of the principal (user/group/identity) to assign the role to." - } - }, - "principalType": { - "type": "string", - "allowedValues": [ - "Device", - "ForeignGroup", - "Group", - "ServicePrincipal", - "User" - ], - "nullable": true, - "metadata": { - "description": "Optional. The principal type of the assigned principal ID." - } - }, - "description": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The description of the role assignment." - } - }, - "condition": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The conditions on the role assignment. This limits the resources it can be assigned to. e.g.: @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:ContainerName] StringEqualsIgnoreCase \"foo_storage_container\"." - } - }, - "conditionVersion": { - "type": "string", - "allowedValues": [ - "2.0" - ], - "nullable": true, - "metadata": { - "description": "Optional. Version of the condition." - } - }, - "delegatedManagedIdentityResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The Resource Id of the delegated managed identity resource." - } - } - } - }, - "nullable": true - }, - "lockType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Specify the name of lock." - } - }, - "kind": { - "type": "string", - "allowedValues": [ - "CanNotDelete", - "None", - "ReadOnly" - ], - "nullable": true, - "metadata": { - "description": "Optional. Specify the type of lock." - } - } - }, - "nullable": true - }, - "ipConfigurationsType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. The name of the resource that is unique within a resource group." - } - }, - "properties": { - "type": "object", - "properties": { - "groupId": { - "type": "string", - "metadata": { - "description": "Required. The ID of a group obtained from the remote resource that this private endpoint should connect to. If used with private link service connection, this property must be defined as empty string." - } - }, - "memberName": { - "type": "string", - "metadata": { - "description": "Required. The member name of a group obtained from the remote resource that this private endpoint should connect to. If used with private link service connection, this property must be defined as empty string." - } - }, - "privateIPAddress": { - "type": "string", - "metadata": { - "description": "Required. A private IP address obtained from the private endpoint's subnet." - } - } - }, - "metadata": { - "description": "Required. Properties of private endpoint IP configurations." - } - } - } - }, - "nullable": true - }, - "manualPrivateLinkServiceConnectionsType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. The name of the private link service connection." - } - }, - "properties": { - "type": "object", - "properties": { - "groupIds": { - "type": "array", - "items": { - "type": "string" - }, - "metadata": { - "description": "Required. The ID of a group obtained from the remote resource that this private endpoint should connect to. If used with private link service connection, this property must be defined as empty string array `[]`." - } - }, - "privateLinkServiceId": { - "type": "string", - "metadata": { - "description": "Required. The resource id of private link service." - } - }, - "requestMessage": { - "type": "string", - "metadata": { - "description": "Optional. A message passed to the owner of the remote resource with this connection request. Restricted to 140 chars." - } - } - }, - "metadata": { - "description": "Required. Properties of private link service connection." - } - } - } - }, - "nullable": true - }, - "privateLinkServiceConnectionsType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. The name of the private link service connection." - } - }, - "properties": { - "type": "object", - "properties": { - "groupIds": { - "type": "array", - "items": { - "type": "string" - }, - "metadata": { - "description": "Required. The ID of a group obtained from the remote resource that this private endpoint should connect to. If used with private link service connection, this property must be defined as empty string array `[]`." - } - }, - "privateLinkServiceId": { - "type": "string", - "metadata": { - "description": "Required. The resource id of private link service." - } - }, - "requestMessage": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. A message passed to the owner of the remote resource with this connection request. Restricted to 140 chars." - } - } - }, - "metadata": { - "description": "Required. Properties of private link service connection." - } - } - } - }, - "nullable": true - }, - "customDnsConfigType": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fqdn": { - "type": "string", - "metadata": { - "description": "Required. Fqdn that resolves to private endpoint IP address." - } - }, - "ipAddresses": { - "type": "array", - "items": { - "type": "string" - }, - "metadata": { - "description": "Required. A list of private IP addresses of the private endpoint." - } - } - } - }, - "nullable": true - }, - "privateDnsZoneGroupConfigType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of the private DNS zone group config." - } - }, - "privateDnsZoneResourceId": { - "type": "string", - "metadata": { - "description": "Required. The resource id of the private DNS zone." - } - } - }, - "metadata": { - "__bicep_imported_from!": { - "sourceTemplate": "private-dns-zone-group/main.bicep" - } - } - } - }, - "parameters": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the private endpoint resource to create." - } - }, - "subnetResourceId": { - "type": "string", - "metadata": { - "description": "Required. Resource ID of the subnet where the endpoint needs to be created." - } - }, - "applicationSecurityGroupResourceIds": { - "type": "array", - "nullable": true, - "metadata": { - "description": "Optional. Application security groups in which the private endpoint IP configuration is included." - } - }, - "customNetworkInterfaceName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The custom name of the network interface attached to the private endpoint." - } - }, - "ipConfigurations": { - "$ref": "#/definitions/ipConfigurationsType", - "metadata": { - "description": "Optional. A list of IP configurations of the private endpoint. This will be used to map to the First Party Service endpoints." - } - }, - "privateDnsZoneGroup": { - "$ref": "#/definitions/privateDnsZoneGroupType", - "nullable": true, - "metadata": { - "description": "Optional. The private DNS zone group to configure for the private endpoint." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Optional. Location for all Resources." - } - }, - "lock": { - "$ref": "#/definitions/lockType", - "metadata": { - "description": "Optional. The lock settings of the service." - } - }, - "roleAssignments": { - "$ref": "#/definitions/roleAssignmentType", - "metadata": { - "description": "Optional. Array of role assignments to create." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags to be applied on all resources/resource groups in this deployment." - } - }, - "customDnsConfigs": { - "$ref": "#/definitions/customDnsConfigType", - "metadata": { - "description": "Optional. Custom DNS configurations." - } - }, - "manualPrivateLinkServiceConnections": { - "$ref": "#/definitions/manualPrivateLinkServiceConnectionsType", - "metadata": { - "description": "Optional. A grouping of information about the connection to the remote resource. Used when the network admin does not have access to approve connections to the remote resource." - } - }, - "privateLinkServiceConnections": { - "$ref": "#/definitions/privateLinkServiceConnectionsType", - "metadata": { - "description": "Optional. A grouping of information about the connection to the remote resource." - } - }, - "enableTelemetry": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Enable/Disable usage telemetry for module." - } - } - }, - "variables": { - "copy": [ - { - "name": "formattedRoleAssignments", - "count": "[length(coalesce(parameters('roleAssignments'), createArray()))]", - "input": "[union(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')], createObject('roleDefinitionId', coalesce(tryGet(variables('builtInRoleNames'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName), if(contains(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, '/providers/Microsoft.Authorization/roleDefinitions/'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName)))))]" - } - ], - "builtInRoleNames": { - "Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", - "DNS Resolver Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0f2ebee7-ffd4-4fc0-b3b7-664099fdad5d')]", - "DNS Zone Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'befefa01-2a29-4197-83a8-272ff33ce314')]", - "Domain Services Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'eeaeda52-9324-47f6-8069-5d5bade478b2')]", - "Domain Services Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '361898ef-9ed1-48c2-849c-a832951106bb')]", - "Network Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4d97b98b-1d4f-4787-a291-c67834d212e7')]", - "Owner": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635')]", - "Private DNS Zone Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b12aa53e-6015-4669-85d0-8515ebb3ae7f')]", - "Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", - "Role Based Access Control Administrator (Preview)": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168')]" - } - }, - "resources": { - "avmTelemetry": { - "condition": "[parameters('enableTelemetry')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2024-03-01", - "name": "[format('46d3xbcp.res.network-privateendpoint.{0}.{1}', replace('0.7.1', '.', '-'), substring(uniqueString(deployment().name, parameters('location')), 0, 4))]", - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "resources": [], - "outputs": { - "telemetry": { - "type": "String", - "value": "For more information, see https://aka.ms/avm/TelemetryInfo" - } - } - } - } - }, - "privateEndpoint": { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2023-11-01", - "name": "[parameters('name')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "properties": { - "copy": [ - { - "name": "applicationSecurityGroups", - "count": "[length(coalesce(parameters('applicationSecurityGroupResourceIds'), createArray()))]", - "input": { - "id": "[coalesce(parameters('applicationSecurityGroupResourceIds'), createArray())[copyIndex('applicationSecurityGroups')]]" - } - } - ], - "customDnsConfigs": "[coalesce(parameters('customDnsConfigs'), createArray())]", - "customNetworkInterfaceName": "[coalesce(parameters('customNetworkInterfaceName'), '')]", - "ipConfigurations": "[coalesce(parameters('ipConfigurations'), createArray())]", - "manualPrivateLinkServiceConnections": "[coalesce(parameters('manualPrivateLinkServiceConnections'), createArray())]", - "privateLinkServiceConnections": "[coalesce(parameters('privateLinkServiceConnections'), createArray())]", - "subnet": { - "id": "[parameters('subnetResourceId')]" - } - } - }, - "privateEndpoint_lock": { - "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", - "type": "Microsoft.Authorization/locks", - "apiVersion": "2020-05-01", - "scope": "[format('Microsoft.Network/privateEndpoints/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", - "properties": { - "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", - "notes": "[if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.')]" - }, - "dependsOn": [ - "privateEndpoint" - ] - }, - "privateEndpoint_roleAssignments": { - "copy": { - "name": "privateEndpoint_roleAssignments", - "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Network/privateEndpoints/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.Network/privateEndpoints', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", - "properties": { - "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", - "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", - "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", - "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", - "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", - "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", - "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" - }, - "dependsOn": [ - "privateEndpoint" - ] - }, - "privateEndpoint_privateDnsZoneGroup": { - "condition": "[not(empty(parameters('privateDnsZoneGroup')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-PrivateEndpoint-PrivateDnsZoneGroup', uniqueString(deployment().name))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "name": { - "value": "[tryGet(parameters('privateDnsZoneGroup'), 'name')]" - }, - "privateEndpointName": { - "value": "[parameters('name')]" - }, - "privateDnsZoneConfigs": { - "value": "[parameters('privateDnsZoneGroup').privateDnsZoneGroupConfigs]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "5805178546717255803" - }, - "name": "Private Endpoint Private DNS Zone Groups", - "description": "This module deploys a Private Endpoint Private DNS Zone Group.", - "owner": "Azure/module-maintainers" - }, - "definitions": { - "privateDnsZoneGroupConfigType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of the private DNS zone group config." - } - }, - "privateDnsZoneResourceId": { - "type": "string", - "metadata": { - "description": "Required. The resource id of the private DNS zone." - } - } - }, - "metadata": { - "__bicep_export!": true - } - } - }, - "parameters": { - "privateEndpointName": { - "type": "string", - "metadata": { - "description": "Conditional. The name of the parent private endpoint. Required if the template is used in a standalone deployment." - } - }, - "privateDnsZoneConfigs": { - "type": "array", - "items": { - "$ref": "#/definitions/privateDnsZoneGroupConfigType" - }, - "minLength": 1, - "maxLength": 5, - "metadata": { - "description": "Required. Array of private DNS zone configurations of the private DNS zone group. A DNS zone group can support up to 5 DNS zones." - } - }, - "name": { - "type": "string", - "defaultValue": "default", - "metadata": { - "description": "Optional. The name of the private DNS zone group." - } - } - }, - "variables": { - "copy": [ - { - "name": "privateDnsZoneConfigsVar", - "count": "[length(parameters('privateDnsZoneConfigs'))]", - "input": { - "name": "[coalesce(tryGet(parameters('privateDnsZoneConfigs')[copyIndex('privateDnsZoneConfigsVar')], 'name'), last(split(parameters('privateDnsZoneConfigs')[copyIndex('privateDnsZoneConfigsVar')].privateDnsZoneResourceId, '/')))]", - "properties": { - "privateDnsZoneId": "[parameters('privateDnsZoneConfigs')[copyIndex('privateDnsZoneConfigsVar')].privateDnsZoneResourceId]" - } - } - } - ] - }, - "resources": { - "privateEndpoint": { - "existing": true, - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2023-11-01", - "name": "[parameters('privateEndpointName')]" - }, - "privateDnsZoneGroup": { - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2023-11-01", - "name": "[format('{0}/{1}', parameters('privateEndpointName'), parameters('name'))]", - "properties": { - "privateDnsZoneConfigs": "[variables('privateDnsZoneConfigsVar')]" - }, - "dependsOn": [ - "privateEndpoint" - ] - } - }, - "outputs": { - "name": { - "type": "string", - "metadata": { - "description": "The name of the private endpoint DNS zone group." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the private endpoint DNS zone group." - }, - "value": "[resourceId('Microsoft.Network/privateEndpoints/privateDnsZoneGroups', parameters('privateEndpointName'), parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The resource group the private endpoint DNS zone group was deployed into." - }, - "value": "[resourceGroup().name]" - } - } - } - }, - "dependsOn": [ - "privateEndpoint" - ] - } - }, - "outputs": { - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The resource group the private endpoint was deployed into." - }, - "value": "[resourceGroup().name]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the private endpoint." - }, - "value": "[resourceId('Microsoft.Network/privateEndpoints', parameters('name'))]" - }, - "name": { - "type": "string", - "metadata": { - "description": "The name of the private endpoint." - }, - "value": "[parameters('name')]" - }, - "location": { - "type": "string", - "metadata": { - "description": "The location the resource was deployed into." - }, - "value": "[reference('privateEndpoint', '2023-11-01', 'full').location]" - }, - "customDnsConfig": { - "$ref": "#/definitions/customDnsConfigType", - "metadata": { - "description": "The custom DNS configurations of the private endpoint." - }, - "value": "[reference('privateEndpoint').customDnsConfigs]" - }, - "networkInterfaceIds": { - "type": "array", - "metadata": { - "description": "The IDs of the network interfaces associated with the private endpoint." - }, - "value": "[reference('privateEndpoint').networkInterfaces]" - }, - "groupId": { - "type": "string", - "metadata": { - "description": "The group Id for the private endpoint Group." - }, - "value": "[if(and(not(empty(reference('privateEndpoint').manualPrivateLinkServiceConnections)), greater(length(tryGet(reference('privateEndpoint').manualPrivateLinkServiceConnections[0].properties, 'groupIds')), 0)), coalesce(tryGet(reference('privateEndpoint').manualPrivateLinkServiceConnections[0].properties, 'groupIds', 0), ''), if(and(not(empty(reference('privateEndpoint').privateLinkServiceConnections)), greater(length(tryGet(reference('privateEndpoint').privateLinkServiceConnections[0].properties, 'groupIds')), 0)), coalesce(tryGet(reference('privateEndpoint').privateLinkServiceConnections[0].properties, 'groupIds', 0), ''), ''))]" - } - } - } - }, - "dependsOn": [ - "databaseAccount" - ] - }, - "secretsExport": { - "condition": "[not(equals(parameters('secretsExportConfiguration'), null()))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[format('{0}-secrets-kv', uniqueString(deployment().name, parameters('location')))]", - "subscriptionId": "[split(coalesce(tryGet(parameters('secretsExportConfiguration'), 'keyVaultResourceId'), '//'), '/')[2]]", - "resourceGroup": "[split(coalesce(tryGet(parameters('secretsExportConfiguration'), 'keyVaultResourceId'), '////'), '/')[4]]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "keyVaultName": { - "value": "[last(split(coalesce(tryGet(parameters('secretsExportConfiguration'), 'keyVaultResourceId'), '//'), '/'))]" - }, - "secretsToSet": { - "value": "[union(createArray(), if(contains(parameters('secretsExportConfiguration'), 'primaryWriteKeySecretName'), createArray(createObject('name', parameters('secretsExportConfiguration').primaryWriteKeySecretName, 'value', listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '2023-04-15').primaryMasterKey)), createArray()), if(contains(parameters('secretsExportConfiguration'), 'primaryReadOnlyKeySecretName'), createArray(createObject('name', parameters('secretsExportConfiguration').primaryReadOnlyKeySecretName, 'value', listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '2023-04-15').primaryReadonlyMasterKey)), createArray()), if(contains(parameters('secretsExportConfiguration'), 'primaryWriteConnectionStringSecretName'), createArray(createObject('name', parameters('secretsExportConfiguration').primaryWriteConnectionStringSecretName, 'value', listConnectionStrings(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '2023-04-15').connectionStrings[0].connectionString)), createArray()), if(contains(parameters('secretsExportConfiguration'), 'primaryReadonlyConnectionStringSecretName'), createArray(createObject('name', parameters('secretsExportConfiguration').primaryReadonlyConnectionStringSecretName, 'value', listConnectionStrings(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '2023-04-15').connectionStrings[2].connectionString)), createArray()), if(contains(parameters('secretsExportConfiguration'), 'secondaryWriteKeySecretName'), createArray(createObject('name', parameters('secretsExportConfiguration').secondaryWriteKeySecretName, 'value', listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '2023-04-15').secondaryMasterKey)), createArray()), if(contains(parameters('secretsExportConfiguration'), 'secondaryReadonlyKeySecretName'), createArray(createObject('name', parameters('secretsExportConfiguration').secondaryReadonlyKeySecretName, 'value', listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '2023-04-15').secondaryReadonlyMasterKey)), createArray()), if(contains(parameters('secretsExportConfiguration'), 'secondaryWriteConnectionStringSecretName'), createArray(createObject('name', parameters('secretsExportConfiguration').secondaryWriteConnectionStringSecretName, 'value', listConnectionStrings(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '2023-04-15').connectionStrings[1].connectionString)), createArray()), if(contains(parameters('secretsExportConfiguration'), 'secondaryReadonlyConnectionStringSecretName'), createArray(createObject('name', parameters('secretsExportConfiguration').secondaryReadonlyConnectionStringSecretName, 'value', listConnectionStrings(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name')), '2023-04-15').connectionStrings[3].connectionString)), createArray()))]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.31.92.45157", - "templateHash": "7954388693868310378" - } - }, - "definitions": { - "secretSetType": { - "type": "object", - "properties": { - "secretResourceId": { - "type": "string", - "metadata": { - "description": "The resourceId of the exported secret." - } - }, - "secretUri": { - "type": "string", - "metadata": { - "description": "The secret URI of the exported secret." - } - } - }, - "metadata": { - "__bicep_export!": true - } - }, - "secretToSetType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. The name of the secret to set." - } - }, - "value": { - "type": "securestring", - "metadata": { - "description": "Required. The value of the secret to set." - } - } - } - } - }, - "parameters": { - "keyVaultName": { - "type": "string", - "metadata": { - "description": "Required. The name of the Key Vault to set the ecrets in." - } - }, - "secretsToSet": { - "type": "array", - "items": { - "$ref": "#/definitions/secretToSetType" - }, - "metadata": { - "description": "Required. The secrets to set in the Key Vault." - } - } - }, - "resources": { - "keyVault": { - "existing": true, - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2022-07-01", - "name": "[parameters('keyVaultName')]" - }, - "secrets": { - "copy": { - "name": "secrets", - "count": "[length(parameters('secretsToSet'))]" - }, - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretsToSet')[copyIndex()].name)]", - "properties": { - "value": "[parameters('secretsToSet')[copyIndex()].value]" - }, - "dependsOn": [ - "keyVault" - ] - } - }, - "outputs": { - "secretsSet": { - "type": "array", - "items": { - "$ref": "#/definitions/secretSetType" - }, - "metadata": { - "description": "The references to the secrets exported to the provided Key Vault." - }, - "copy": { - "count": "[length(range(0, length(coalesce(parameters('secretsToSet'), createArray()))))]", - "input": { - "secretResourceId": "[resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), parameters('secretsToSet')[range(0, length(coalesce(parameters('secretsToSet'), createArray())))[copyIndex()]].name)]", - "secretUri": "[reference(format('secrets[{0}]', range(0, length(coalesce(parameters('secretsToSet'), createArray())))[copyIndex()])).secretUri]" - } - } - } - } - } - }, - "dependsOn": [ - "databaseAccount" - ] - } - }, - "outputs": { - "exportedSecrets": { - "$ref": "#/definitions/secretsOutputType", - "metadata": { - "description": "The references to the secrets exported to the provided Key Vault." - }, - "value": "[if(not(equals(parameters('secretsExportConfiguration'), null())), toObject(reference('secretsExport').outputs.secretsSet.value, lambda('secret', last(split(lambdaVariables('secret').secretResourceId, '/'))), lambda('secret', lambdaVariables('secret'))), createObject())]" - }, - "name": { - "type": "string", - "metadata": { - "description": "The name of the database account." - }, - "value": "[parameters('name')]" - }, - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the database account." - }, - "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the database account was created in." - }, - "value": "[resourceGroup().name]" - }, - "systemAssignedMIPrincipalId": { - "type": "string", - "metadata": { - "description": "The principal ID of the system assigned identity." - }, - "value": "[coalesce(tryGet(tryGet(reference('databaseAccount', '2023-04-15', 'full'), 'identity'), 'principalId'), '')]" - }, - "location": { - "type": "string", - "metadata": { - "description": "The location the resource was deployed into." - }, - "value": "[reference('databaseAccount', '2023-04-15', 'full').location]" - }, - "endpoint": { - "type": "string", - "metadata": { - "description": "The endpoint of the database account." - }, - "value": "[reference('databaseAccount').documentEndpoint]" - }, - "privateEndpoints": { - "type": "array", - "metadata": { - "description": "The private endpoints of the database account." - }, - "copy": { - "count": "[length(if(not(empty(parameters('privateEndpoints'))), array(parameters('privateEndpoints')), createArray()))]", - "input": { - "name": "[reference(format('databaseAccount_privateEndpoints[{0}]', copyIndex())).outputs.name.value]", - "resourceId": "[reference(format('databaseAccount_privateEndpoints[{0}]', copyIndex())).outputs.resourceId.value]", - "groupId": "[reference(format('databaseAccount_privateEndpoints[{0}]', copyIndex())).outputs.groupId.value]", - "customDnsConfig": "[reference(format('databaseAccount_privateEndpoints[{0}]', copyIndex())).outputs.customDnsConfig.value]", - "networkInterfaceIds": "[reference(format('databaseAccount_privateEndpoints[{0}]', copyIndex())).outputs.networkInterfaceIds.value]" - } - } - } - } - } - }, - "dependsOn": [ - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "[toLower(format('{0}{1}containerAppFrontend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "managedIdentities": { - "value": { - "systemAssigned": true, - "userAssignedResourceIds": [ - "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity'), '2022-09-01').outputs.managedIdentityOutput.value.resourceId]" - ] - } - }, - "containers": { - "value": [ - { - "env": [ - { - "name": "API_URL", - "value": "[format('https://{0}', reference(resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))), '2023-05-01').configuration.ingress.fqdn)]" - } - ], - "image": "[format('cmsacontainerreg.azurecr.io/cmsafrontend:{0}', variables('imageVersion'))]", - "name": "cmsafrontend", - "resources": { - "cpu": "1", - "memory": "2.0Gi" - } - } - ] - }, - "ingressTargetPort": { - "value": 3000 - }, - "ingressExternal": { - "value": true - }, - "scaleMinReplicas": { - "value": 1 - }, - "scaleMaxReplicas": { - "value": 1 - }, - "environmentResourceId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', toLower(format('{0}conAppsEnv', variables('ResourcePrefix')))), '2022-09-01').outputs.resourceId.value]" - }, - "name": { - "value": "[toLower(format('{0}{1}Frontend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))]" - }, - "location": { - "value": "[variables('location')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.33.93.31351", - "templateHash": "1964938100334091991" - }, - "name": "Container Apps", - "description": "This module deploys a Container App." - }, - "definitions": { - "containerType": { - "type": "object", - "properties": { - "args": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. Container start command arguments." - } - }, - "command": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. Container start command." - } - }, - "env": { - "type": "array", - "items": { - "$ref": "#/definitions/environmentVarType" - }, - "nullable": true, - "metadata": { - "description": "Optional. Container environment variables." - } - }, - "image": { - "type": "string", - "metadata": { - "description": "Required. Container image tag." - } - }, - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Custom container name." - } - }, - "probes": { - "type": "array", - "items": { - "$ref": "#/definitions/containerAppProbeType" - }, - "nullable": true, - "metadata": { - "description": "Optional. List of probes for the container." - } - }, - "resources": { - "type": "object", - "metadata": { - "description": "Required. Container resource requirements." - } - }, - "volumeMounts": { - "type": "array", - "items": { - "$ref": "#/definitions/volumeMountType" - }, - "nullable": true, - "metadata": { - "description": "Optional. Container volume mounts." - } - } - }, - "metadata": { - "__bicep_export!": true, - "description": "The type for a container." - } - }, - "ingressPortMappingType": { - "type": "object", - "properties": { - "exposedPort": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Specifies the exposed port for the target port. If not specified, it defaults to target port." - } - }, - "external": { - "type": "bool", - "metadata": { - "description": "Required. Specifies whether the app port is accessible outside of the environment." - } - }, - "targetPort": { - "type": "int", - "metadata": { - "description": "Required. Specifies the port the container listens on." - } - } - }, - "metadata": { - "__bicep_export!": true, - "description": "The type for an ingress port mapping." - } - }, - "serviceBindingType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. The name of the service." - } - }, - "serviceId": { - "type": "string", - "metadata": { - "description": "Required. The service ID." - } - } - }, - "metadata": { - "description": "The type for a service binding." - } - }, - "environmentVarType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Environment variable name." - } - }, - "secretRef": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Name of the Container App secret from which to pull the environment variable value." - } - }, - "value": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Non-secret environment variable value." - } - } - }, - "metadata": { - "__bicep_export!": true, - "description": "The type for an environment variable." - } - }, - "containerAppProbeType": { - "type": "object", - "properties": { - "failureThreshold": { - "type": "int", - "nullable": true, - "minValue": 1, - "maxValue": 10, - "metadata": { - "description": "Optional. Minimum consecutive failures for the probe to be considered failed after having succeeded. Defaults to 3." - } - }, - "httpGet": { - "$ref": "#/definitions/containerAppProbeHttpGetType", - "nullable": true, - "metadata": { - "description": "Optional. HTTPGet specifies the http request to perform." - } - }, - "initialDelaySeconds": { - "type": "int", - "nullable": true, - "minValue": 1, - "maxValue": 60, - "metadata": { - "description": "Optional. Number of seconds after the container has started before liveness probes are initiated." - } - }, - "periodSeconds": { - "type": "int", - "nullable": true, - "minValue": 1, - "maxValue": 240, - "metadata": { - "description": "Optional. How often (in seconds) to perform the probe. Default to 10 seconds." - } - }, - "successThreshold": { - "type": "int", - "nullable": true, - "minValue": 1, - "maxValue": 10, - "metadata": { - "description": "Optional. Minimum consecutive successes for the probe to be considered successful after having failed. Defaults to 1. Must be 1 for liveness and startup." - } - }, - "tcpSocket": { - "$ref": "#/definitions/containerAppProbeTcpSocketType", - "nullable": true, - "metadata": { - "description": "Optional. The TCP socket specifies an action involving a TCP port. TCP hooks not yet supported." - } - }, - "terminationGracePeriodSeconds": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Optional duration in seconds the pod needs to terminate gracefully upon probe failure. The grace period is the duration in seconds after the processes running in the pod are sent a termination signal and the time when the processes are forcibly halted with a kill signal. Set this value longer than the expected cleanup time for your process. If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this value overrides the value provided by the pod spec. Value must be non-negative integer. The value zero indicates stop immediately via the kill signal (no opportunity to shut down). This is an alpha field and requires enabling ProbeTerminationGracePeriod feature gate. Maximum value is 3600 seconds (1 hour)." - } - }, - "timeoutSeconds": { - "type": "int", - "nullable": true, - "minValue": 1, - "maxValue": 240, - "metadata": { - "description": "Optional. Number of seconds after which the probe times out. Defaults to 1 second." - } - }, - "type": { - "type": "string", - "allowedValues": [ - "Liveness", - "Readiness", - "Startup" - ], - "nullable": true, - "metadata": { - "description": "Optional. The type of probe." - } - } - }, - "metadata": { - "description": "The type for a container app probe." - } - }, - "corsPolicyType": { - "type": "object", - "properties": { - "allowCredentials": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. Switch to determine whether the resource allows credentials." - } - }, - "allowedHeaders": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. Specifies the content for the access-control-allow-headers header." - } - }, - "allowedMethods": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. Specifies the content for the access-control-allow-methods header." - } - }, - "allowedOrigins": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. Specifies the content for the access-control-allow-origins header." - } - }, - "exposeHeaders": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. Specifies the content for the access-control-expose-headers header." - } - }, - "maxAge": { - "type": "int", - "nullable": true, - "metadata": { - "description": "Optional. Specifies the content for the access-control-max-age header." - } - } - }, - "metadata": { - "__bicep_export!": true, - "description": "The type for a CORS policy." - } - }, - "containerAppProbeHttpGetType": { - "type": "object", - "properties": { - "host": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Host name to connect to. Defaults to the pod IP." - } - }, - "httpHeaders": { - "type": "array", - "items": { - "$ref": "#/definitions/containerAppProbeHttpGetHeadersItemType" - }, - "nullable": true, - "metadata": { - "description": "Optional. HTTP headers to set in the request." - } - }, - "path": { - "type": "string", - "metadata": { - "description": "Required. Path to access on the HTTP server." - } - }, - "port": { - "type": "int", - "metadata": { - "description": "Required. Name or number of the port to access on the container." - } - }, - "scheme": { - "type": "string", - "allowedValues": [ - "HTTP", - "HTTPS" - ], - "nullable": true, - "metadata": { - "description": "Optional. Scheme to use for connecting to the host. Defaults to HTTP." - } - } - }, - "metadata": { - "description": "The type for a container app probe HTTP GET." - } - }, - "containerAppProbeHttpGetHeadersItemType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the header." - } - }, - "value": { - "type": "string", - "metadata": { - "description": "Required. Value of the header." - } - } - }, - "metadata": { - "description": "The type for a container app probe HTTP GET header." - } - }, - "containerAppProbeTcpSocketType": { - "type": "object", - "properties": { - "host": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Host name to connect to, defaults to the pod IP." - } - }, - "port": { - "type": "int", - "minValue": 1, - "maxValue": 65535, - "metadata": { - "description": "Required. Number of the port to access on the container. Name must be an IANA_SVC_NAME." - } - } - }, - "metadata": { - "description": "The type for a container app probe TCP socket." - } - }, - "volumeMountType": { - "type": "object", - "properties": { - "mountPath": { - "type": "string", - "metadata": { - "description": "Required. Path within the container at which the volume should be mounted.Must not contain ':'." - } - }, - "subPath": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Path within the volume from which the container's volume should be mounted. Defaults to \"\" (volume's root)." - } - }, - "volumeName": { - "type": "string", - "metadata": { - "description": "Required. This must match the Name of a Volume." - } - } - }, - "metadata": { - "description": "The type for a volume mount." - } - }, - "runtimeType": { - "type": "object", - "properties": { - "dotnet": { - "type": "object", - "properties": { - "autoConfigureDataProtection": { - "type": "bool", - "metadata": { - "description": "Required. Enable to auto configure the ASP.NET Core Data Protection feature." - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. Runtime configuration for ASP.NET Core." - } - }, - "java": { - "type": "object", - "properties": { - "enableMetrics": { - "type": "bool", - "metadata": { - "description": "Required. Enable JMX core metrics for the Java app." - } - }, - "enableJavaAgent": { - "type": "bool", - "metadata": { - "description": "Required. Enable Java agent injection for the Java app." - } - }, - "loggerSettings": { - "type": "array", - "items": { - "type": "object", - "properties": { - "logger": { - "type": "string", - "metadata": { - "description": "Required. Name of the logger." - } - }, - "level": { - "type": "string", - "allowedValues": [ - "debug", - "error", - "info", - "off", - "trace", - "warn" - ], - "metadata": { - "description": "Required. Java agent logging level." - } - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. Java agent logging configuration." - } - } - }, - "nullable": true, - "metadata": { - "description": "Optional. Runtime configuration for Java." - } - } - }, - "nullable": true, - "metadata": { - "__bicep_export!": true, - "description": "Optional. App runtime configuration for the Container App." - } - }, - "secretType": { - "type": "object", - "properties": { - "identity": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Resource ID of a managed identity to authenticate with Azure Key Vault, or System to use a system-assigned identity." - } - }, - "keyVaultUrl": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Conditional. Azure Key Vault URL pointing to the secret referenced by the Container App Job. Required if `value` is null." - } - }, - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name of the secret." - } - }, - "value": { - "type": "securestring", - "nullable": true, - "metadata": { - "description": "Conditional. The secret value, if not fetched from Key Vault. Required if `keyVaultUrl` is not null." - } - } - }, - "metadata": { - "__bicep_export!": true, - "description": "The type for a secret." - } - }, - "lockType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Specify the name of lock." - } - }, - "kind": { - "type": "string", - "allowedValues": [ - "CanNotDelete", - "None", - "ReadOnly" - ], - "nullable": true, - "metadata": { - "description": "Optional. Specify the type of lock." - } - } - }, - "metadata": { - "description": "An AVM-aligned type for a lock.", - "__bicep_imported_from!": { - "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.4.1" - } - } - }, - "managedIdentityAllType": { - "type": "object", - "properties": { - "systemAssigned": { - "type": "bool", - "nullable": true, - "metadata": { - "description": "Optional. Enables system assigned managed identity on the resource." - } - }, - "userAssignedResourceIds": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. The resource ID(s) to assign to the resource. Required if a user assigned identity is used for encryption." - } - } - }, - "metadata": { - "description": "An AVM-aligned type for a managed identity configuration. To be used if both a system-assigned & user-assigned identities are supported by the resource provider.", - "__bicep_imported_from!": { - "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.4.1" - } - } - }, - "roleAssignmentType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name (as GUID) of the role assignment. If not provided, a GUID will be generated." - } - }, - "roleDefinitionIdOrName": { - "type": "string", - "metadata": { - "description": "Required. The role to assign. You can provide either the display name of the role definition, the role definition GUID, or its fully qualified ID in the following format: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Required. The principal ID of the principal (user/group/identity) to assign the role to." - } - }, - "principalType": { - "type": "string", - "allowedValues": [ - "Device", - "ForeignGroup", - "Group", - "ServicePrincipal", - "User" - ], - "nullable": true, - "metadata": { - "description": "Optional. The principal type of the assigned principal ID." - } - }, - "description": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The description of the role assignment." - } - }, - "condition": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The conditions on the role assignment. This limits the resources it can be assigned to. e.g.: @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:ContainerName] StringEqualsIgnoreCase \"foo_storage_container\"." - } - }, - "conditionVersion": { - "type": "string", - "allowedValues": [ - "2.0" - ], - "nullable": true, - "metadata": { - "description": "Optional. Version of the condition." - } - }, - "delegatedManagedIdentityResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The Resource Id of the delegated managed identity resource." - } - } - }, - "metadata": { - "description": "An AVM-aligned type for a role assignment.", - "__bicep_imported_from!": { - "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.4.1" - } - } - } - }, - "parameters": { - "name": { - "type": "string", - "metadata": { - "description": "Required. Name of the Container App." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Optional. Location for all Resources." - } - }, - "disableIngress": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Optional. Bool to disable all ingress traffic for the container app." - } - }, - "ingressExternal": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Bool indicating if the App exposes an external HTTP endpoint." - } - }, - "clientCertificateMode": { - "type": "string", - "defaultValue": "ignore", - "allowedValues": [ - "accept", - "ignore", - "require" - ], - "metadata": { - "description": "Optional. Client certificate mode for mTLS." - } - }, - "corsPolicy": { - "$ref": "#/definitions/corsPolicyType", - "nullable": true, - "metadata": { - "description": "Optional. Object userd to configure CORS policy." - } - }, - "stickySessionsAffinity": { - "type": "string", - "defaultValue": "none", - "allowedValues": [ - "none", - "sticky" - ], - "metadata": { - "description": "Optional. Bool indicating if the Container App should enable session affinity." - } - }, - "ingressTransport": { - "type": "string", - "defaultValue": "auto", - "allowedValues": [ - "auto", - "http", - "http2", - "tcp" - ], - "metadata": { - "description": "Optional. Ingress transport protocol." - } - }, - "service": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional. Dev ContainerApp service type." - } - }, - "includeAddOns": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Optional. Toggle to include the service configuration." - } - }, - "additionalPortMappings": { - "type": "array", - "items": { - "$ref": "#/definitions/ingressPortMappingType" - }, - "nullable": true, - "metadata": { - "description": "Optional. Settings to expose additional ports on container app." - } - }, - "ingressAllowInsecure": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Bool indicating if HTTP connections to is allowed. If set to false HTTP connections are automatically redirected to HTTPS connections." - } - }, - "ingressTargetPort": { - "type": "int", - "defaultValue": 80, - "metadata": { - "description": "Optional. Target Port in containers for traffic from ingress." - } - }, - "scaleMaxReplicas": { - "type": "int", - "defaultValue": 10, - "metadata": { - "description": "Optional. Maximum number of container replicas. Defaults to 10 if not set." - } - }, - "scaleMinReplicas": { - "type": "int", - "defaultValue": 3, - "metadata": { - "description": "Optional. Minimum number of container replicas. Defaults to 3 if not set." - } - }, - "scaleRules": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Scaling rules." - } - }, - "serviceBinds": { - "type": "array", - "items": { - "$ref": "#/definitions/serviceBindingType" - }, - "nullable": true, - "metadata": { - "description": "Optional. List of container app services bound to the app." - } - }, - "activeRevisionsMode": { - "type": "string", - "defaultValue": "Single", - "allowedValues": [ - "Multiple", - "Single" - ], - "metadata": { - "description": "Optional. Controls how active revisions are handled for the Container app." - } - }, - "environmentResourceId": { - "type": "string", - "metadata": { - "description": "Required. Resource ID of environment." - } - }, - "lock": { - "$ref": "#/definitions/lockType", - "nullable": true, - "metadata": { - "description": "Optional. The lock settings of the service." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Tags of the resource." - } - }, - "registries": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Collection of private container registry credentials for containers used by the Container app." - } - }, - "managedIdentities": { - "$ref": "#/definitions/managedIdentityAllType", - "nullable": true, - "metadata": { - "description": "Optional. The managed identity definition for this resource." - } - }, - "roleAssignments": { - "type": "array", - "items": { - "$ref": "#/definitions/roleAssignmentType" - }, - "nullable": true, - "metadata": { - "description": "Optional. Array of role assignments to create." - } - }, - "enableTelemetry": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Enable/Disable usage telemetry for module." - } - }, - "customDomains": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Custom domain bindings for Container App hostnames." - } - }, - "exposedPort": { - "type": "int", - "defaultValue": 0, - "metadata": { - "description": "Optional. Exposed Port in containers for TCP traffic from ingress." - } - }, - "ipSecurityRestrictions": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. Rules to restrict incoming IP address." - } - }, - "trafficLabel": { - "type": "string", - "defaultValue": "label-1", - "metadata": { - "description": "Optional. Associates a traffic label with a revision. Label name should be consist of lower case alphanumeric characters or dashes." - } - }, - "trafficLatestRevision": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Indicates that the traffic weight belongs to a latest stable revision." - } - }, - "trafficRevisionName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional. Name of a revision." - } - }, - "trafficWeight": { - "type": "int", - "defaultValue": 100, - "metadata": { - "description": "Optional. Traffic weight assigned to a revision." - } - }, - "dapr": { - "type": "object", - "defaultValue": {}, - "metadata": { - "description": "Optional. Dapr configuration for the Container App." - } - }, - "maxInactiveRevisions": { - "type": "int", - "defaultValue": 0, - "metadata": { - "description": "Optional. Max inactive revisions a Container App can have." - } - }, - "runtime": { - "$ref": "#/definitions/runtimeType", - "metadata": { - "description": "Optional. Runtime configuration for the Container App." - } - }, - "containers": { - "type": "array", - "items": { - "$ref": "#/definitions/containerType" - }, - "metadata": { - "description": "Required. List of container definitions for the Container App." - } - }, - "initContainersTemplate": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. List of specialized containers that run before app containers." - } - }, - "secrets": { - "type": "array", - "items": { - "$ref": "#/definitions/secretType" - }, - "nullable": true, - "metadata": { - "description": "Optional. The secrets of the Container App." - } - }, - "revisionSuffix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional. User friendly suffix that is appended to the revision name." - } - }, - "volumes": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Optional. List of volume definitions for the Container App." - } - }, - "workloadProfileName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional. Workload profile name to pin for container app execution." - } - } - }, - "variables": { - "copy": [ - { - "name": "formattedRoleAssignments", - "count": "[length(coalesce(parameters('roleAssignments'), createArray()))]", - "input": "[union(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')], createObject('roleDefinitionId', coalesce(tryGet(variables('builtInRoleNames'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName), if(contains(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, '/providers/Microsoft.Authorization/roleDefinitions/'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName)))))]" - } - ], - "formattedUserAssignedIdentities": "[reduce(map(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createArray()), lambda('id', createObject(format('{0}', lambdaVariables('id')), createObject()))), createObject(), lambda('cur', 'next', union(lambdaVariables('cur'), lambdaVariables('next'))))]", - "identity": "[if(not(empty(parameters('managedIdentities'))), createObject('type', if(coalesce(tryGet(parameters('managedIdentities'), 'systemAssigned'), false()), if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'SystemAssigned,UserAssigned', 'SystemAssigned'), if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'UserAssigned', 'None')), 'userAssignedIdentities', if(not(empty(variables('formattedUserAssignedIdentities'))), variables('formattedUserAssignedIdentities'), null())), null())]", - "builtInRoleNames": { - "ContainerApp Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ad2dd5fb-cd4b-4fd4-a9b6-4fed3630980b')]", - "Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", - "Owner": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635')]", - "Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", - "Role Based Access Control Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168')]", - "User Access Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9')]" - } - }, - "resources": { - "avmTelemetry": { - "condition": "[parameters('enableTelemetry')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2024-03-01", - "name": "[format('46d3xbcp.res.app-containerapp.{0}.{1}', replace('0.13.0', '.', '-'), substring(uniqueString(deployment().name, parameters('location')), 0, 4))]", - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "resources": [], - "outputs": { - "telemetry": { - "type": "String", - "value": "For more information, see https://aka.ms/avm/TelemetryInfo" - } - } - } - } - }, - "containerApp": { - "type": "Microsoft.App/containerApps", - "apiVersion": "2024-10-02-preview", - "name": "[parameters('name')]", - "tags": "[parameters('tags')]", - "location": "[parameters('location')]", - "identity": "[variables('identity')]", - "properties": { - "environmentId": "[parameters('environmentResourceId')]", - "configuration": { - "activeRevisionsMode": "[parameters('activeRevisionsMode')]", - "dapr": "[if(not(empty(parameters('dapr'))), parameters('dapr'), null())]", - "ingress": "[if(parameters('disableIngress'), null(), createObject('additionalPortMappings', parameters('additionalPortMappings'), 'allowInsecure', if(not(equals(parameters('ingressTransport'), 'tcp')), parameters('ingressAllowInsecure'), false()), 'customDomains', if(not(empty(parameters('customDomains'))), parameters('customDomains'), null()), 'corsPolicy', if(and(not(equals(parameters('corsPolicy'), null())), not(equals(parameters('ingressTransport'), 'tcp'))), createObject('allowCredentials', coalesce(tryGet(parameters('corsPolicy'), 'allowCredentials'), false()), 'allowedHeaders', coalesce(tryGet(parameters('corsPolicy'), 'allowedHeaders'), createArray()), 'allowedMethods', coalesce(tryGet(parameters('corsPolicy'), 'allowedMethods'), createArray()), 'allowedOrigins', coalesce(tryGet(parameters('corsPolicy'), 'allowedOrigins'), createArray()), 'exposeHeaders', coalesce(tryGet(parameters('corsPolicy'), 'exposeHeaders'), createArray()), 'maxAge', tryGet(parameters('corsPolicy'), 'maxAge')), null()), 'clientCertificateMode', if(not(equals(parameters('ingressTransport'), 'tcp')), parameters('clientCertificateMode'), null()), 'exposedPort', parameters('exposedPort'), 'external', parameters('ingressExternal'), 'ipSecurityRestrictions', if(not(empty(parameters('ipSecurityRestrictions'))), parameters('ipSecurityRestrictions'), null()), 'targetPort', parameters('ingressTargetPort'), 'stickySessions', createObject('affinity', parameters('stickySessionsAffinity')), 'traffic', if(not(equals(parameters('ingressTransport'), 'tcp')), createArray(createObject('label', parameters('trafficLabel'), 'latestRevision', parameters('trafficLatestRevision'), 'revisionName', parameters('trafficRevisionName'), 'weight', parameters('trafficWeight'))), null()), 'transport', parameters('ingressTransport')))]", - "service": "[if(and(parameters('includeAddOns'), not(empty(parameters('service')))), parameters('service'), null())]", - "maxInactiveRevisions": "[parameters('maxInactiveRevisions')]", - "registries": "[if(not(empty(parameters('registries'))), parameters('registries'), null())]", - "secrets": "[parameters('secrets')]", - "runtime": { - "dotnet": "[if(not(empty(tryGet(parameters('runtime'), 'dotnet'))), createObject('autoConfigureDataProtection', tryGet(parameters('runtime'), 'dotnet', 'autoConfigureDataProtection')), null())]", - "java": "[if(not(empty(tryGet(parameters('runtime'), 'java'))), createObject('enableMetrics', tryGet(parameters('runtime'), 'java', 'enableMetrics'), 'javaAgent', createObject('enabled', tryGet(parameters('runtime'), 'java', 'enableJavaAgent'), 'logging', createObject('loggerSettings', tryGet(tryGet(parameters('runtime'), 'java'), 'loggerSettings')))), null())]" - } - }, - "template": { - "containers": "[parameters('containers')]", - "initContainers": "[if(not(empty(parameters('initContainersTemplate'))), parameters('initContainersTemplate'), null())]", - "revisionSuffix": "[parameters('revisionSuffix')]", - "scale": { - "maxReplicas": "[parameters('scaleMaxReplicas')]", - "minReplicas": "[parameters('scaleMinReplicas')]", - "rules": "[if(not(empty(parameters('scaleRules'))), parameters('scaleRules'), null())]" - }, - "serviceBinds": "[if(and(parameters('includeAddOns'), not(empty(parameters('serviceBinds')))), parameters('serviceBinds'), null())]", - "volumes": "[if(not(empty(parameters('volumes'))), parameters('volumes'), null())]" - }, - "workloadProfileName": "[parameters('workloadProfileName')]" - } - }, - "containerApp_lock": { - "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", - "type": "Microsoft.Authorization/locks", - "apiVersion": "2020-05-01", - "scope": "[format('Microsoft.App/containerApps/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", - "properties": { - "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", - "notes": "[if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.')]" - }, - "dependsOn": [ - "containerApp" - ] - }, - "containerApp_roleAssignments": { - "copy": { - "name": "containerApp_roleAssignments", - "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.App/containerApps/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.App/containerApps', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", - "properties": { - "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", - "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", - "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", - "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", - "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", - "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", - "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" - }, - "dependsOn": [ - "containerApp" - ] - } - }, - "outputs": { - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the Container App." - }, - "value": "[resourceId('Microsoft.App/containerApps', parameters('name'))]" - }, - "fqdn": { - "type": "string", - "metadata": { - "description": "The configuration of ingress fqdn." - }, - "value": "[if(parameters('disableIngress'), 'IngressDisabled', reference('containerApp').configuration.ingress.fqdn)]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The name of the resource group the Container App was deployed into." - }, - "value": "[resourceGroup().name]" - }, - "name": { - "type": "string", - "metadata": { - "description": "The name of the Container App." - }, - "value": "[parameters('name')]" - }, - "systemAssignedMIPrincipalId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "The principal ID of the system assigned identity." - }, - "value": "[tryGet(tryGet(reference('containerApp', '2024-10-02-preview', 'full'), 'identity'), 'principalId')]" - }, - "location": { - "type": "string", - "metadata": { - "description": "The location the resource was deployed into." - }, - "value": "[reference('containerApp', '2024-10-02-preview', 'full').location]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix'))))]", - "[resourceId('Microsoft.Resources/deployments', toLower(format('{0}conAppsEnv', variables('ResourcePrefix'))))]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2022-09-01", - "name": "deploymentScriptCLI", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "kind": { - "value": "AzureCLI" - }, - "name": { - "value": "rdsmin001" - }, - "azCliVersion": { - "value": "2.69.0" - }, - "location": { - "value": "[resourceGroup().location]" - }, - "managedIdentities": { - "value": { - "userAssignedResourceIds": [ - "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity'), '2022-09-01').outputs.managedIdentityId.value]" - ] - } - }, - "scriptContent": { - "value": "[format('az cosmosdb sql role assignment create --resource-group \"{0}\" --account-name \"{1}\" --role-definition-id \"{2}\" --scope \"{3}\" --principal-id \"{4}\"', resourceGroup().name, reference(resourceId('Microsoft.Resources/deployments', toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix')))), '2022-09-01').outputs.name.value, resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions', split(format('{0}/00000000-0000-0000-0000-000000000002', toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix')))), '/')[0], split(format('{0}/00000000-0000-0000-0000-000000000002', toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix')))), '/')[1]), reference(resourceId('Microsoft.Resources/deployments', toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix')))), '2022-09-01').outputs.resourceId.value, reference(resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix')))), '2023-05-01', 'full').identity.principalId)]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "languageVersion": "2.0", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.32.4.45862", - "templateHash": "8965217851411422458" - }, - "name": "Deployment Scripts", - "description": "This module deploys Deployment Scripts.", - "owner": "Azure/module-maintainers" - }, - "definitions": { - "environmentVariableType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "metadata": { - "description": "Required. The name of the environment variable." - } - }, - "secureValue": { - "type": "securestring", - "nullable": true, - "metadata": { - "description": "Conditional. The value of the secure environment variable. Required if `value` is null." - } - }, - "value": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Conditional. The value of the environment variable. Required if `secureValue` is null." - } - } - } - }, - "lockType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Specify the name of lock." - } - }, - "kind": { - "type": "string", - "allowedValues": [ - "CanNotDelete", - "None", - "ReadOnly" - ], - "nullable": true, - "metadata": { - "description": "Optional. Specify the type of lock." - } - } - }, - "metadata": { - "description": "An AVM-aligned type for a lock.", - "__bicep_imported_from!": { - "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.2.1" - } - } - }, - "managedIdentityOnlyUserAssignedType": { - "type": "object", - "properties": { - "userAssignedResourceIds": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. The resource ID(s) to assign to the resource. Required if a user assigned identity is used for encryption." - } - } - }, - "metadata": { - "description": "An AVM-aligned type for a managed identity configuration. To be used if only user-assigned identities are supported by the resource provider.", - "__bicep_imported_from!": { - "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.2.1" - } - } - }, - "roleAssignmentType": { - "type": "object", - "properties": { - "name": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The name (as GUID) of the role assignment. If not provided, a GUID will be generated." - } - }, - "roleDefinitionIdOrName": { - "type": "string", - "metadata": { - "description": "Required. The role to assign. You can provide either the display name of the role definition, the role definition GUID, or its fully qualified ID in the following format: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'." - } - }, - "principalId": { - "type": "string", - "metadata": { - "description": "Required. The principal ID of the principal (user/group/identity) to assign the role to." - } - }, - "principalType": { - "type": "string", - "allowedValues": [ - "Device", - "ForeignGroup", - "Group", - "ServicePrincipal", - "User" - ], - "nullable": true, - "metadata": { - "description": "Optional. The principal type of the assigned principal ID." - } - }, - "description": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The description of the role assignment." - } - }, - "condition": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The conditions on the role assignment. This limits the resources it can be assigned to. e.g.: @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:ContainerName] StringEqualsIgnoreCase \"foo_storage_container\"." - } - }, - "conditionVersion": { - "type": "string", - "allowedValues": [ - "2.0" - ], - "nullable": true, - "metadata": { - "description": "Optional. Version of the condition." - } - }, - "delegatedManagedIdentityResourceId": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. The Resource Id of the delegated managed identity resource." - } - } - }, - "metadata": { - "description": "An AVM-aligned type for a role assignment.", - "__bicep_imported_from!": { - "sourceTemplate": "br:mcr.microsoft.com/bicep/avm/utl/types/avm-common-types:0.2.1" - } - } - } - }, - "parameters": { - "name": { - "type": "string", - "maxLength": 90, - "metadata": { - "description": "Required. Name of the Deployment Script." - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Optional. Location for all resources." - } - }, - "kind": { - "type": "string", - "allowedValues": [ - "AzureCLI", - "AzurePowerShell" - ], - "metadata": { - "description": "Required. Specifies the Kind of the Deployment Script." - } - }, - "managedIdentities": { - "$ref": "#/definitions/managedIdentityOnlyUserAssignedType", - "nullable": true, - "metadata": { - "description": "Optional. The managed identity definition for this resource." - } - }, - "tags": { - "type": "object", - "nullable": true, - "metadata": { - "description": "Optional. Resource tags." - } - }, - "azPowerShellVersion": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Azure PowerShell module version to be used. See a list of supported Azure PowerShell versions: https://mcr.microsoft.com/v2/azuredeploymentscripts-powershell/tags/list." - } - }, - "azCliVersion": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Azure CLI module version to be used. See a list of supported Azure CLI versions: https://mcr.microsoft.com/v2/azure-cli/tags/list." - } - }, - "scriptContent": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Script body. Max length: 32000 characters. To run an external script, use primaryScriptURI instead." - } - }, - "primaryScriptUri": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Uri for the external script. This is the entry point for the external script. To run an internal script, use the scriptContent parameter instead." - } - }, - "environmentVariables": { - "type": "array", - "items": { - "$ref": "#/definitions/environmentVariableType" - }, - "nullable": true, - "metadata": { - "description": "Optional. The environment variables to pass over to the script." - } - }, - "supportingScriptUris": { - "type": "array", - "nullable": true, - "metadata": { - "description": "Optional. List of supporting files for the external script (defined in primaryScriptUri). Does not work with internal scripts (code defined in scriptContent)." - } - }, - "subnetResourceIds": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "metadata": { - "description": "Optional. List of subnet IDs to use for the container group. This is required if you want to run the deployment script in a private network. When using a private network, the `Storage File Data Privileged Contributor` role needs to be assigned to the user-assigned managed identity and the deployment principal needs to have permissions to list the storage account keys. Also, Shared-Keys must not be disabled on the used storage account [ref](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-script-vnet)." - } - }, - "arguments": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Command-line arguments to pass to the script. Arguments are separated by spaces." - } - }, - "retentionInterval": { - "type": "string", - "defaultValue": "P1D", - "metadata": { - "description": "Optional. Interval for which the service retains the script resource after it reaches a terminal state. Resource will be deleted when this duration expires. Duration is based on ISO 8601 pattern (for example P7D means one week)." - } - }, - "baseTime": { - "type": "string", - "defaultValue": "[utcNow('yyyy-MM-dd-HH-mm-ss')]", - "metadata": { - "description": "Generated. Do not provide a value! This date value is used to make sure the script run every time the template is deployed." - } - }, - "runOnce": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Optional. When set to false, script will run every time the template is deployed. When set to true, the script will only run once." - } - }, - "cleanupPreference": { - "type": "string", - "defaultValue": "Always", - "allowedValues": [ - "Always", - "OnSuccess", - "OnExpiration" - ], - "metadata": { - "description": "Optional. The clean up preference when the script execution gets in a terminal state. Specify the preference on when to delete the deployment script resources. The default value is Always, which means the deployment script resources are deleted despite the terminal state (Succeeded, Failed, canceled)." - } - }, - "containerGroupName": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Container group name, if not specified then the name will get auto-generated. Not specifying a 'containerGroupName' indicates the system to generate a unique name which might end up flagging an Azure Policy as non-compliant. Use 'containerGroupName' when you have an Azure Policy that expects a specific naming convention or when you want to fully control the name. 'containerGroupName' property must be between 1 and 63 characters long, must contain only lowercase letters, numbers, and dashes and it cannot start or end with a dash and consecutive dashes are not allowed." - } - }, - "storageAccountResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Optional. The resource ID of the storage account to use for this deployment script. If none is provided, the deployment script uses a temporary, managed storage account." - } - }, - "timeout": { - "type": "string", - "nullable": true, - "metadata": { - "description": "Optional. Maximum allowed script execution time specified in ISO 8601 format. Default value is PT1H - 1 hour; 'PT30M' - 30 minutes; 'P5D' - 5 days; 'P1Y' 1 year." - } - }, - "lock": { - "$ref": "#/definitions/lockType", - "nullable": true, - "metadata": { - "description": "Optional. The lock settings of the service." - } - }, - "roleAssignments": { - "type": "array", - "items": { - "$ref": "#/definitions/roleAssignmentType" - }, - "nullable": true, - "metadata": { - "description": "Optional. Array of role assignments to create." - } - }, - "enableTelemetry": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Optional. Enable/Disable usage telemetry for module." - } - } - }, - "variables": { - "copy": [ - { - "name": "formattedRoleAssignments", - "count": "[length(coalesce(parameters('roleAssignments'), createArray()))]", - "input": "[union(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')], createObject('roleDefinitionId', coalesce(tryGet(variables('builtInRoleNames'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName), if(contains(coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, '/providers/Microsoft.Authorization/roleDefinitions/'), coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', coalesce(parameters('roleAssignments'), createArray())[copyIndex('formattedRoleAssignments')].roleDefinitionIdOrName)))))]" - }, - { - "name": "subnetIds", - "count": "[length(coalesce(parameters('subnetResourceIds'), createArray()))]", - "input": { - "id": "[coalesce(parameters('subnetResourceIds'), createArray())[copyIndex('subnetIds')]]" - } - } - ], - "builtInRoleNames": { - "Contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", - "Owner": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635')]", - "Reader": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", - "Role Based Access Control Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168')]", - "User Access Administrator": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9')]" - }, - "containerSettings": { - "containerGroupName": "[parameters('containerGroupName')]", - "subnetIds": "[if(not(empty(coalesce(variables('subnetIds'), createArray()))), variables('subnetIds'), null())]" - }, - "formattedUserAssignedIdentities": "[reduce(map(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createArray()), lambda('id', createObject(format('{0}', lambdaVariables('id')), createObject()))), createObject(), lambda('cur', 'next', union(lambdaVariables('cur'), lambdaVariables('next'))))]", - "identity": "[if(not(empty(parameters('managedIdentities'))), createObject('type', if(not(empty(coalesce(tryGet(parameters('managedIdentities'), 'userAssignedResourceIds'), createObject()))), 'UserAssigned', null()), 'userAssignedIdentities', if(not(empty(variables('formattedUserAssignedIdentities'))), variables('formattedUserAssignedIdentities'), null())), null())]" - }, - "resources": { - "storageAccount": { - "condition": "[not(empty(parameters('storageAccountResourceId')))]", - "existing": true, - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2023-05-01", - "subscriptionId": "[split(if(not(empty(parameters('storageAccountResourceId'))), parameters('storageAccountResourceId'), '//'), '/')[2]]", - "resourceGroup": "[split(if(not(empty(parameters('storageAccountResourceId'))), parameters('storageAccountResourceId'), '////'), '/')[4]]", - "name": "[last(split(if(not(empty(parameters('storageAccountResourceId'))), parameters('storageAccountResourceId'), 'dummyAccount'), '/'))]" - }, - "avmTelemetry": { - "condition": "[parameters('enableTelemetry')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2024-03-01", - "name": "[format('46d3xbcp.res.resources-deploymentscript.{0}.{1}', replace('0.5.1', '.', '-'), substring(uniqueString(deployment().name, parameters('location')), 0, 4))]", - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "resources": [], - "outputs": { - "telemetry": { - "type": "String", - "value": "For more information, see https://aka.ms/avm/TelemetryInfo" - } - } - } - } - }, - "deploymentScript": { - "type": "Microsoft.Resources/deploymentScripts", - "apiVersion": "2023-08-01", - "name": "[parameters('name')]", - "location": "[parameters('location')]", - "tags": "[parameters('tags')]", - "identity": "[variables('identity')]", - "kind": "[parameters('kind')]", - "properties": { - "azPowerShellVersion": "[if(equals(parameters('kind'), 'AzurePowerShell'), parameters('azPowerShellVersion'), null())]", - "azCliVersion": "[if(equals(parameters('kind'), 'AzureCLI'), parameters('azCliVersion'), null())]", - "containerSettings": "[if(not(empty(variables('containerSettings'))), variables('containerSettings'), null())]", - "storageAccountSettings": "[if(not(empty(parameters('storageAccountResourceId'))), if(not(empty(parameters('storageAccountResourceId'))), createObject('storageAccountKey', if(empty(parameters('subnetResourceIds')), listKeys(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', split(if(not(empty(parameters('storageAccountResourceId'))), parameters('storageAccountResourceId'), '//'), '/')[2], split(if(not(empty(parameters('storageAccountResourceId'))), parameters('storageAccountResourceId'), '////'), '/')[4]), 'Microsoft.Storage/storageAccounts', last(split(if(not(empty(parameters('storageAccountResourceId'))), parameters('storageAccountResourceId'), 'dummyAccount'), '/'))), '2023-01-01').keys[0].value, null()), 'storageAccountName', last(split(parameters('storageAccountResourceId'), '/'))), null()), null())]", - "arguments": "[parameters('arguments')]", - "environmentVariables": "[parameters('environmentVariables')]", - "scriptContent": "[if(not(empty(parameters('scriptContent'))), parameters('scriptContent'), null())]", - "primaryScriptUri": "[if(not(empty(parameters('primaryScriptUri'))), parameters('primaryScriptUri'), null())]", - "supportingScriptUris": "[if(not(empty(parameters('supportingScriptUris'))), parameters('supportingScriptUris'), null())]", - "cleanupPreference": "[parameters('cleanupPreference')]", - "forceUpdateTag": "[if(parameters('runOnce'), resourceGroup().name, parameters('baseTime'))]", - "retentionInterval": "[parameters('retentionInterval')]", - "timeout": "[parameters('timeout')]" - } - }, - "deploymentScript_lock": { - "condition": "[and(not(empty(coalesce(parameters('lock'), createObject()))), not(equals(tryGet(parameters('lock'), 'kind'), 'None')))]", - "type": "Microsoft.Authorization/locks", - "apiVersion": "2020-05-01", - "scope": "[format('Microsoft.Resources/deploymentScripts/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(parameters('lock'), 'name'), format('lock-{0}', parameters('name')))]", - "properties": { - "level": "[coalesce(tryGet(parameters('lock'), 'kind'), '')]", - "notes": "[if(equals(tryGet(parameters('lock'), 'kind'), 'CanNotDelete'), 'Cannot delete resource or child resources.', 'Cannot delete or modify the resource or child resources.')]" - }, - "dependsOn": [ - "deploymentScript" - ] - }, - "deploymentScript_roleAssignments": { - "copy": { - "name": "deploymentScript_roleAssignments", - "count": "[length(coalesce(variables('formattedRoleAssignments'), createArray()))]" - }, - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Resources/deploymentScripts/{0}', parameters('name'))]", - "name": "[coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'name'), guid(resourceId('Microsoft.Resources/deploymentScripts', parameters('name')), coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId, coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId))]", - "properties": { - "roleDefinitionId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].roleDefinitionId]", - "principalId": "[coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()].principalId]", - "description": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'description')]", - "principalType": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'principalType')]", - "condition": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition')]", - "conditionVersion": "[if(not(empty(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'condition'))), coalesce(tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'conditionVersion'), '2.0'), null())]", - "delegatedManagedIdentityResourceId": "[tryGet(coalesce(variables('formattedRoleAssignments'), createArray())[copyIndex()], 'delegatedManagedIdentityResourceId')]" - }, - "dependsOn": [ - "deploymentScript" - ] - }, - "deploymentScriptLogs": { - "existing": true, - "type": "Microsoft.Resources/deploymentScripts/logs", - "apiVersion": "2023-08-01", - "name": "[format('{0}/{1}', parameters('name'), 'default')]", - "dependsOn": [ - "deploymentScript" - ] - } - }, - "outputs": { - "resourceId": { - "type": "string", - "metadata": { - "description": "The resource ID of the deployment script." - }, - "value": "[resourceId('Microsoft.Resources/deploymentScripts', parameters('name'))]" - }, - "resourceGroupName": { - "type": "string", - "metadata": { - "description": "The resource group the deployment script was deployed into." - }, - "value": "[resourceGroup().name]" - }, - "name": { - "type": "string", - "metadata": { - "description": "The name of the deployment script." - }, - "value": "[parameters('name')]" - }, - "location": { - "type": "string", - "metadata": { - "description": "The location the resource was deployed into." - }, - "value": "[reference('deploymentScript', '2023-08-01', 'full').location]" - }, - "outputs": { - "type": "object", - "metadata": { - "description": "The output of the deployment script." - }, - "value": "[coalesce(tryGet(reference('deploymentScript'), 'outputs'), createObject())]" - }, - "deploymentScriptLogs": { - "type": "array", - "items": { - "type": "string" - }, - "metadata": { - "description": "The logs of the deployment script." - }, - "value": "[split(reference('deploymentScriptLogs').log, '\n')]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.App/containerApps', toLower(format('{0}{1}Backend', variables('abbrs').containers.containerApp, variables('ResourcePrefix'))))]", - "[resourceId('Microsoft.Resources/deployments', toLower(format('{0}{1}databaseAccount', variables('abbrs').databases.cosmosDBDatabase, variables('ResourcePrefix'))))]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]" - ] - } - ] -} \ No newline at end of file diff --git a/infra/main.parameters.json b/infra/main.parameters.json deleted file mode 100644 index 112bae1..0000000 --- a/infra/main.parameters.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "location": { - "value": "${AZURE_LOCATION}" - }, - "backendExists": { - "value": "${SERVICE_BACKEND_RESOURCE_EXISTS=false}" - }, - "backendDefinition": { - "value": { - "settings": [ - { - "name": "", - "value": "${VAR}", - "_comment_name": "The name of the environment variable when running in Azure. If empty, ignored.", - "_comment_value": "The value to provide. This can be a fixed literal, or an expression like ${VAR} to use the value of 'VAR' from the current environment." - }, - { - "name": "", - "value": "${VAR_S}", - "secret": true, - "_comment_name": "The name of the environment variable when running in Azure. If empty, ignored.", - "_comment_value": "The value to provide. This can be a fixed literal, or an expression like ${VAR_S} to use the value of 'VAR_S' from the current environment." - } - ] - } - }, - "frontendExists": { - "value": "${SERVICE_FRONTEND_RESOURCE_EXISTS=false}" - }, - "frontendDefinition": { - "value": { - "settings": [ - { - "name": "", - "value": "${VAR}", - "_comment_name": "The name of the environment variable when running in Azure. If empty, ignored.", - "_comment_value": "The value to provide. This can be a fixed literal, or an expression like ${VAR} to use the value of 'VAR' from the current environment." - }, - { - "name": "", - "value": "${VAR_S}", - "secret": true, - "_comment_name": "The name of the environment variable when running in Azure. If empty, ignored.", - "_comment_value": "The value to provide. This can be a fixed literal, or an expression like ${VAR_S} to use the value of 'VAR_S' from the current environment." - } - ] - } - }, - "principalId": { - "value": "${AZURE_PRINCIPAL_ID}" - } - } -} diff --git a/infra/main.waf-aligned.bicepparam b/infra/main.waf-aligned.bicepparam new file mode 100644 index 0000000..f21e39a --- /dev/null +++ b/infra/main.waf-aligned.bicepparam @@ -0,0 +1,10 @@ +using './main.bicep' + +param solutionName = readEnvironmentVariable('AZURE_ENV_NAME') +param location = readEnvironmentVariable('AZURE_LOCATION') + +param enableMonitoring = true +param enableScaling = true +param enableRedundancy = true +//param secondaryLocation = 'uksouth' // TODO - test this +param enablePrivateNetworking = true diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index cbb21b6..5fd1936 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -38,6 +38,9 @@ param roleAssignments roleAssignmentType[]? @description('Optional. Tags to be applied to the resources.') param tags object = {} +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + module mlApiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?apiPrivateDnsZoneResourceId)) { name: take('${hubName}-mlapi-pdns-deployment', 64) params: { @@ -48,6 +51,7 @@ module mlApiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -61,6 +65,7 @@ module mlNotebooksPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7 } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -138,6 +143,7 @@ module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -159,6 +165,7 @@ module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] roleAssignments: roleAssignments tags: tags + enableTelemetry: enableTelemetry } } diff --git a/infra/modules/aiServices.bicep b/infra/modules/aiServices.bicep index 9256a6e..482bfe5 100644 --- a/infra/modules/aiServices.bicep +++ b/infra/modules/aiServices.bicep @@ -65,6 +65,9 @@ param privateNetworking aiServicesPrivateNetworkingType? @description('Optional. Tags to be applied to the resources.') param tags object = {} +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + module cognitiveServicesPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?cogServicesPrivateDnsZoneResourceId)) { name: take('${name}-cognitiveservices-pdns-deployment', 64) params: { @@ -75,6 +78,7 @@ module cognitiveServicesPrivateDnsZone 'br/public:avm/res/network/private-dns-zo } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -88,6 +92,7 @@ module openAiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -132,6 +137,7 @@ module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = subnetResourceId: privateNetworking.?subnetResourceId ?? '' } ] : [] + enableTelemetry: enableTelemetry } } diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep index 19680c9..3936a08 100644 --- a/infra/modules/cosmosDb.bicep +++ b/infra/modules/cosmosDb.bicep @@ -27,6 +27,9 @@ import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5 @description('Optional. Array of role assignments to create.') param roleAssignments roleAssignmentType[]? +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { name: take('${name}-documents-pdns-deployment', 64) params: { @@ -37,6 +40,7 @@ module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (p } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -142,6 +146,7 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { ] roleAssignments: roleAssignments tags: tags + enableTelemetry: enableTelemetry } } diff --git a/infra/modules/keyVault.bicep b/infra/modules/keyVault.bicep index d325bc0..ddd8fdd 100644 --- a/infra/modules/keyVault.bicep +++ b/infra/modules/keyVault.bicep @@ -29,6 +29,9 @@ import { secretType } from 'br/public:avm/res/key-vault/vault:0.12.1' @description('Optional. Array of secrets to create in the Key Vault.') param secrets secretType[]? +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { name: take('${name}-kv-pdns-deployment', 64) params: { @@ -39,6 +42,7 @@ module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (p } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -85,6 +89,7 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { ] : [] roleAssignments: roleAssignments secrets: secrets + enableTelemetry: enableTelemetry } } diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 332e577..f382644 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -11,6 +11,9 @@ param location string @description('Optional. Tags to be applied to the resources.') param tags object = {} +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + // Subnet Classless Inter-Doman Routing (CIDR) Sizing Reference Table (Best Practices) // | CIDR | # of Addresses | # of /24s | Notes | // |-----------|---------------|-----------|----------------------------------------| @@ -115,6 +118,7 @@ module network 'network/main.bicep' = { } } } + enableTelemetry: enableTelemetry } } @@ -123,13 +127,3 @@ output vnetResourceId string = network.outputs.vnetResourceId output subnetWebResourceId string = first(filter(network.outputs.subnets, s => s.name == 'web')).?resourceId ?? '' output subnetPrivateEndpointsResourceId string = first(filter(network.outputs.subnets, s => s.name == 'peps')).?resourceId ?? '' - -output bastionSubnetId string = network.outputs.bastionSubnetId -output bastionSubnetName string = network.outputs.bastionSubnetName -output bastionHostId string = network.outputs.bastionHostId -output bastionHostName string = network.outputs.bastionHostName - -output jumpboxSubnetName string = network.outputs.jumpboxSubnetName -output jumpboxSubnetId string = network.outputs.jumpboxSubnetId -output jumpboxName string = network.outputs.jumpboxName -output jumpboxResourceId string = network.outputs.jumpboxResourceId diff --git a/infra/modules/network/bastionHost.bicep b/infra/modules/network/bastionHost.bicep index c9a04a4..6a8abfb 100644 --- a/infra/modules/network/bastionHost.bicep +++ b/infra/modules/network/bastionHost.bicep @@ -23,6 +23,9 @@ param logAnalyticsWorkspaceId string @description('Optional. Tags to apply to the resources.') param tags object = {} +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + // 1. Create Azure Bastion Host using AVM Subnet Module with special config for Azure Bastion Subnet // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/virtual-network/subnet module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = if (!empty(subnetAddressPrefixes)) { @@ -31,6 +34,7 @@ module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = virtualNetworkName: vnetName name: 'AzureBastionSubnet' addressPrefixes: subnetAddressPrefixes + enableTelemetry: enableTelemetry } } @@ -57,6 +61,7 @@ module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = { } ] tags: tags + enableTelemetry: enableTelemetry } } diff --git a/infra/modules/network/jumpbox.bicep b/infra/modules/network/jumpbox.bicep index 163ae43..b1280a9 100644 --- a/infra/modules/network/jumpbox.bicep +++ b/infra/modules/network/jumpbox.bicep @@ -31,6 +31,9 @@ param tags object = {} @description('Log Analytics Workspace Resource ID for VM diagnostics.') param logAnalyticsWorkspaceId string +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + // 1. Create Jumpbox NSG // using AVM Network Security Group module // https://github.com/Azure/bicep-registry-modules/tree/main/avm/res/network/network-security-group @@ -41,6 +44,7 @@ module nsg 'br/public:avm/res/network/network-security-group:0.5.1' = if (!empty location: location securityRules: subnet.?networkSecurityGroup.securityRules tags: tags + enableTelemetry: enableTelemetry } } @@ -54,6 +58,7 @@ module subnetResource 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = name: subnet.?name ?? '' addressPrefixes: subnet.?addressPrefixes networkSecurityGroupResourceId: nsg.outputs.resourceId + enableTelemetry: enableTelemetry } } @@ -115,6 +120,7 @@ module vm 'br/public:avm/res/compute/virtual-machine:0.15.0' = { ] } ] + enableTelemetry: enableTelemetry } } diff --git a/infra/modules/network/main.bicep b/infra/modules/network/main.bicep index 919a8a8..9b61626 100644 --- a/infra/modules/network/main.bicep +++ b/infra/modules/network/main.bicep @@ -27,6 +27,9 @@ param bastionConfiguration bastionHostConfigurationType? @description('Optional. Tags to be applied to the resources.') param tags object = {} + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true // /****************************************************************************************************************************/ // Networking - NSGs, VNET and Subnets. Each subnet has its own NSG @@ -41,6 +44,7 @@ module virtualNetwork 'virtualNetwork.bicep' = { location: location tags: tags logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId + enableTelemetry: enableTelemetry } } @@ -58,6 +62,7 @@ module bastionHost 'bastionHost.bicep' = if (!empty(bastionConfiguration)) { logAnalyticsWorkspaceId: logAnalyticsWorkSpaceResourceId subnetAddressPrefixes: bastionConfiguration.?subnetAddressPrefixes tags: tags + enableTelemetry: enableTelemetry } } @@ -76,6 +81,8 @@ module jumpbox 'jumpbox.bicep' = if (!empty(jumpboxConfiguration)) { subnet: jumpboxConfiguration.?subnet username: jumpboxConfiguration.?username ?? '' // required password: jumpboxConfiguration.?password ?? '' // required + enableTelemetry: enableTelemetry + tags: tags } } diff --git a/infra/modules/network/virtualNetwork.bicep b/infra/modules/network/virtualNetwork.bicep index d9ed9bb..710cfe9 100644 --- a/infra/modules/network/virtualNetwork.bicep +++ b/infra/modules/network/virtualNetwork.bicep @@ -1,14 +1,27 @@ /****************************************************************************************************************************/ // 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 -param name string + +@description('Required. An Array of 1 or more IP Address Prefixes OR the resource ID of the IPAM pool to be used for the Virtual Network. When specifying an IPAM pool resource ID you must also set a value for the parameter called `ipamPoolNumberOfIpAddresses`.') param addressPrefixes array -param subnets subnetType[] = [] + +@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[] + +@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 + // 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 @@ -22,6 +35,7 @@ module nsgs 'br/public:avm/res/network/network-security-group:0.5.1' = [ location: location securityRules: subnet.?networkSecurityGroup.securityRules tags: tags + enableTelemetry: enableTelemetry } } ] @@ -65,6 +79,7 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.0' = { } ] tags: tags + enableTelemetry: enableTelemetry } } diff --git a/infra/modules/storageAccount.bicep b/infra/modules/storageAccount.bicep index 2d25ae8..4f63e47 100644 --- a/infra/modules/storageAccount.bicep +++ b/infra/modules/storageAccount.bicep @@ -33,6 +33,9 @@ param roleAssignments roleAssignmentType[]? @description('Optional. List of the blob storage containers to create in the Storage Account.') param containers array? +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + module blobPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?blobPrivateDnsZoneResourceId)) { name: take('${name}-blob-pdns-deployment', 64) params: { @@ -43,6 +46,7 @@ module blobPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = i } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -56,6 +60,7 @@ module filePrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = i } ] tags: tags + enableTelemetry: enableTelemetry } } @@ -121,6 +126,7 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { blobServices: { containers: containers ?? [] } + enableTelemetry: enableTelemetry } } From 28819ff1c73cb1cea3169856b72112ee1942645e Mon Sep 17 00:00:00 2001 From: Seth Date: Fri, 13 Jun 2025 10:39:54 -0400 Subject: [PATCH 072/124] WAF - naming cleanup --- infra/modules/network/bastionHost.bicep | 2 +- .../network-subnet-design.bicep} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename infra/{modules/network/test_network_modules.bicep => samples/network-subnet-design.bicep} (100%) diff --git a/infra/modules/network/bastionHost.bicep b/infra/modules/network/bastionHost.bicep index 6a8abfb..423fe57 100644 --- a/infra/modules/network/bastionHost.bicep +++ b/infra/modules/network/bastionHost.bicep @@ -32,7 +32,7 @@ module bastionSubnet 'br/public:avm/res/network/virtual-network/subnet:0.1.2' = name: take('bastionSubnet-${vnetName}', 64) params: { virtualNetworkName: vnetName - name: 'AzureBastionSubnet' + name: 'AzureBastionSubnet' // this name required as is for Azure Bastion Host subnet addressPrefixes: subnetAddressPrefixes enableTelemetry: enableTelemetry } diff --git a/infra/modules/network/test_network_modules.bicep b/infra/samples/network-subnet-design.bicep similarity index 100% rename from infra/modules/network/test_network_modules.bicep rename to infra/samples/network-subnet-design.bicep From 296a6eb6ab31051b246d831adf5fb4ed3eae3240 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 13 Jun 2025 19:35:17 -0400 Subject: [PATCH 073/124] network_subnet_design completed and tested --- infra/main.bicepparam | 10 + infra/modules/network/virtualNetwork.bicep | 8 +- infra/samples/network-subnet-design.bicep | 255 +++++++++------------ infra/samples/network_subnet_design.md | 69 ++++++ 4 files changed, 188 insertions(+), 154 deletions(-) create mode 100644 infra/samples/network_subnet_design.md diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 24f8799..3a440df 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -2,3 +2,13 @@ using './main.bicep' param solutionName = readEnvironmentVariable('AZURE_ENV_NAME') param location = readEnvironmentVariable('AZURE_LOCATION') + +//******************************************************************************* +// Uncomment the following lines to enable the WAF-aligned configuration +//******************************************************************************* + +param enableMonitoring = true +param enableScaling = true +param enableRedundancy = true +//param secondaryLocation = 'uksouth' // TODO - test this +param enablePrivateNetworking = true diff --git a/infra/modules/network/virtualNetwork.bicep b/infra/modules/network/virtualNetwork.bicep index 710cfe9..cd00249 100644 --- a/infra/modules/network/virtualNetwork.bicep +++ b/infra/modules/network/virtualNetwork.bicep @@ -118,11 +118,11 @@ type subnetType = { @description('Required. The Name of the subnet resource.') name: string - @description('Conditional. The address prefix for the subnet. Required if `addressPrefixes` is empty.') - addressPrefix: string? + // @description('Conditional. The address prefix for the subnet. Required if `addressPrefixes` is empty.') + // addressPrefix: string? - @description('Conditional. List of address prefixes for the subnet. Required if `addressPrefix` is empty.') - addressPrefixes: 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? diff --git a/infra/samples/network-subnet-design.bicep b/infra/samples/network-subnet-design.bicep index 7e4445a..2c782d5 100644 --- a/infra/samples/network-subnet-design.bicep +++ b/infra/samples/network-subnet-design.bicep @@ -1,46 +1,99 @@ // /******************************************************************************************************************/ -// This is an example test program to create private networking resources independently with sample inputs -// +// This is an example test program to create private networking resources independently with sample network design. +// It is an illustration of how to use the main.bicep in the infra/modules/network folder, with your own parameters. +// You can independently deploy this module to create a network with subnets, NSGs, Azure Bastion Host, and Jumpbox VM. +// Test them with this test program. Then integrate your design into the modules/network.bicep which is intended for +// a specific network design. +// +// All things in infra/modules/network are designed to be reusable and composable without the need to modify +// any code in the network folder. +// // Please review below modules to understand how things are wired together: -// infra/main.bicep -// infra/modules/network.bicep -// infra/moddules/network/main.bicep +// infra/main.bicep +// infra/modules/network.bicep +// infra/moddules/network/main.bicep // // /******************************************************************************************************************/ @minLength(6) @maxLength(25) -@description('Default name used for all resources.') -param resourcesName string = 'testNetwork' +@description('Name used for naming all network resources.') +param resourcesName string = 'nettest' @minLength(3) @description('Azure region for all services.') -param location string = 'eastus' +param location string = resourceGroup().location + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true @description('Optional. Tags to be applied to the resources.') param tags object = {} -var vnetName = 'vnet-${resourcesName}' -@description('Networking address prefix for the VNET only') -param addressPrefixes array = ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24 subnets) +import { bastionHostConfigurationType } from '../modules/network/bastionHost.bicep' +@description('Optional. Configuration for the Azure Bastion Host. Leave null to omit Bastion creation.') +param bastionConfiguration bastionHostConfigurationType = { + name: 'bastion-${resourcesName}' + subnetAddressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses +} -param enableBastionHost bool = true -var bastionHostName = 'bastionHost-${resourcesName}' +import { jumpBoxConfigurationType } from '../modules/network/jumpbox.bicep' +@description('Optional. Configuration for the Jumpbox VM. Leave null to omit Jumpbox creation.') +param jumpboxConfiguration jumpBoxConfigurationType = { + name: 'vm-jumpbox-${resourcesName}' + size: 'Standard_D2s_v3' // Default size, can be overridden + username: 'JumpboxAdminUser' + password: 'JumpboxAdminP@ssw0rd1234!' + subnet: { + name: 'jumpbox-subnet' + addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses + networkSecurityGroup: { + name: 'jumpbox-nsg' + securityRules: [ + { + name: 'AllowJumpboxInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefixes: [ + '10.0.10.0/23' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more + ] + destinationAddressPrefixes: ['10.0.12.0/23'] + } + } + ] + } + } +} -param jumpboxVM bool = true -param jumpboxAdminUser string = 'JumpboxAdminUser' -@secure() -param jumpboxAdminPassword string = 'JumpboxAdminP@ssw0rd1234!' -param jumpboxVmSize string = 'Standard_D2s_v3' -var jumpboxVmName = 'jumpboxVM-${resourcesName}' +// ==================================================================================================================== +// Below paremeters define default the VNET and subnets. You can customize them as needed. +// ==================================================================================================================== +@description('Networking address prefix for the VNET.') +param addressPrefixes array = ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24 subnets) +import { subnetType } from '../modules/network/virtualNetwork.bicep' @description('Array of subnets to be created within the VNET.') -param subnets array = [ - // Only one delegation per subnet is supported by the AVM module as of June 2025. - // For subnets that do not require delegation, leave the array empty. +param subnets subnetType[] = [ { - name: 'web' + name: 'peps' addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' + networkSecurityGroup: { + name: 'peps-nsg' + securityRules: [] + } + } + { + name: 'web' + addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' networkSecurityGroup: { name: 'web-nsg' securityRules: [ @@ -54,7 +107,7 @@ param subnets array = [ sourcePortRange: '*' destinationPortRange: '443' sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/23'] + destinationAddressPrefixes: ['10.0.2.0/23'] } } ] @@ -68,7 +121,9 @@ param subnets array = [ } { name: 'app' - addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses + addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' networkSecurityGroup: { name: 'app-nsg' securityRules: [ @@ -81,8 +136,8 @@ param subnets array = [ protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/23'] // web subnet - destinationAddressPrefixes: ['10.0.2.0/23'] + sourceAddressPrefixes: ['10.0.2.0/23'] // web subnet + destinationAddressPrefixes: ['10.0.4.0/23'] } } ] @@ -96,7 +151,9 @@ param subnets array = [ } { name: 'ai' - addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses + addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255), 512 addresses + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' networkSecurityGroup: { name: 'ai-nsg' securityRules: [ @@ -110,10 +167,10 @@ param subnets array = [ sourcePortRange: '*' destinationPortRange: '*' sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet + '10.0.2.0/23' // web subnet + '10.0.4.0/23' // app subnet ] - destinationAddressPrefixes: ['10.0.4.0/23'] + destinationAddressPrefixes: ['10.0.6.0/23'] } } ] @@ -122,7 +179,9 @@ param subnets array = [ } { name: 'data' - addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255) + addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' networkSecurityGroup: { name: 'data-nsg' securityRules: [ @@ -136,36 +195,9 @@ param subnets array = [ sourcePortRange: '*' destinationPortRange: '*' sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - '10.0.4.0/23' // ai subnet - ] - destinationAddressPrefixes: ['10.0.6.0/23'] - } - } - ] - } - delegations: [] // No delegation required for this subnet. - } - { - name: 'services' - addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses - networkSecurityGroup: { - name: 'services-nsg' - securityRules: [ - { - name: 'AllowWebAppAiToServices' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: [ - '10.0.0.0/23' // web subnet - '10.0.2.0/23' // app subnet - '10.0.4.0/23' // ai subnet + '10.0.2.0/23' // web subnet + '10.0.4.0/23' // app subnet + '10.0.6.0/23' // ai subnet ] destinationAddressPrefixes: ['10.0.8.0/23'] } @@ -176,39 +208,6 @@ param subnets array = [ } ] -// jumpbox parameters -param jumpboxSubnet object = { - name: 'jumpbox' - addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses - networkSecurityGroup: { - name: 'jumpbox-nsg' - securityRules: [ - { - name: 'AllowJumpboxInbound' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '22' - sourceAddressPrefixes: [ - '10.0.7.0/24' // Azure Bastion subnet as an example here. You can adjust this as needed by adding more - ] - destinationAddressPrefixes: ['10.0.12.0/23'] - } - } - ] - } -} - -// Azure Bastion Host parameters -param bastionSubnet object = { - addressPrefixes: ['10.0.10.0/23'] // /23 (10.0.10.0 - 10.0.11.255), 512 addresses - networkSecurityGroup: null // Azure Bastion subnet must NOT have custom NSG as it is managed by Azure -} - - // /******************************************************************************************************************/ // Create Log Analytics Workspace for monitoring and diagnostics // /******************************************************************************************************************/ @@ -224,65 +223,21 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 } } +// /******************************************************************************************************************/ +// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG // /******************************************************************************************************************/ -// Networking - NSGs, VNET and Subnets. Each subnet has its own NSG -// /******************************************************************************************************************/ -module virtualNetwork 'virtualNetwork.bicep' = { - name: '${resourcesName}-virtualNetwork' - params: { - name: vnetName - addressPrefixes: addressPrefixes - subnets: subnets - location: location - tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId - } -} -// /******************************************************************************************************************/ -// // Create Azure Bastion Subnet and Azure Bastion Host -// /******************************************************************************************************************/ -module bastionHost 'bastionHost.bicep' = if(enableBastionHost && !empty(bastionSubnet)) { - name: '${resourcesName}-bastionHost' +module network '../modules/network/main.bicep' = { + name: take('network-${resourcesName}-create', 64) params: { - subnet: bastionSubnet + resourcesName: resourcesName location: location - vnetName: virtualNetwork.outputs.name - vnetId: virtualNetwork.outputs.resourceId - name: bastionHostName - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId + logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId tags: tags + addressPrefixes: addressPrefixes + subnets: subnets + bastionConfiguration: bastionConfiguration + jumpboxConfiguration: jumpboxConfiguration + enableTelemetry: enableTelemetry } } - -// /******************************************************************************************************************/ -// // create Jumpbox NSG and Jumpbox Subnet, then create Jumpbox VM -// /******************************************************************************************************************/ -module jumpbox 'jumpbox.bicep' = if (jumpboxVM && !empty(jumpboxSubnet)) { - name: '${resourcesName}-jumpbox' - params: { - vmName: jumpboxVmName - location: location - vnetName: virtualNetwork.outputs.name - jumpboxVmSize: jumpboxVmSize - jumpboxSubnet: jumpboxSubnet - jumpboxAdminUser: jumpboxAdminUser - jumpboxAdminPassword: jumpboxAdminPassword - tags: tags - logAnalyticsWorkspaceId: logAnalyticsWorkspace.outputs.resourceId - } -} - -output vnetName string = virtualNetwork.outputs.name -output vnetResourceId string = virtualNetwork.outputs.resourceId -output subnets array = virtualNetwork.outputs.subnets // This one holds critical info for subnets, including NSGs - -output bastionSubnetId string = bastionHost.outputs.subnetId -output bastionSubnetName string = bastionHost.outputs.subnetName -output bastionHostId string = bastionHost.outputs.resourceId -output bastionHostName string = bastionHost.outputs.name - -output jumpboxSubnetName string = jumpbox.outputs.subnetId -output jumpboxSubnetId string = jumpbox.outputs.subnetId -output jumpboxVmName string = jumpbox.outputs.vmName -output jumpboxVmId string = jumpbox.outputs.vmId diff --git a/infra/samples/network_subnet_design.md b/infra/samples/network_subnet_design.md new file mode 100644 index 0000000..ebcd7a5 --- /dev/null +++ b/infra/samples/network_subnet_design.md @@ -0,0 +1,69 @@ +# Network Subnet Design Guide + +This guide explains how to use the sample Bicep program `network-subnet-design.bicep` to create and test your own Azure network design using reusable modules. The sample is designed to be a standalone test harness and a reference for integrating your network design into the solution. + +## Purpose and Approach + +- **Sample File:** `infra/samples/network-subnet-design.bicep` +- **Reusable Modules:** All code in `infra/modules/network` is designed to be composable and reusable. You should not need to modify code in the modules folder. +- **Custom Network File:** For your own solution, create a `network.bicep` in `infra/modules/` that represents your specific network design, using the modules as building blocks. + +## How to Use the Sample + +- The sample demonstrates how to deploy a virtual network, subnets, NSGs, Azure Bastion Host, and Jumpbox VM using parameters and reusable modules. +- You can deploy the sample independently to validate your network design before integrating it into your main solution, modify the infra/modules/network.bicep using the validated and tested design. +- The sample is parameterized, so you can easily adjust subnet names, address spaces, NSG rules, and delegations. + +## Key Features in the Sample + +- **Parameterization:** + - `resourcesName`, `location`, `addressPrefixes`, `subnets`, `bastionConfiguration`, and `jumpboxConfiguration` are all parameterized for flexibility. +- **Subnet Design:** + - Subnets are defined with clear address ranges and optional NSGs and delegations. + - Example subnets: `peps`, `web`, `app`, `ai`, `data`, and a dedicated subnet for Jumpbox. +- **Security:** + - Each subnet can have its own NSG with custom rules. + - Example: The `web` subnet allows HTTPS inbound from anywhere; the `app` subnet allows traffic from the `web` subnet, etc. +- **Bastion and Jumpbox:** + - Optional Azure Bastion Host and Jumpbox VM are included for secure management access. +- **Composability:** + - All modules in `infra/modules/network` are designed to be reused without modification. + +## Example Workflow + +1. **Review the Sample:** + - Open `infra/samples/network-subnet-design.bicep` and read the comments at the top for context and usage. +2. **Customize Parameters:** + - Adjust the subnet array, address prefixes, NSG rules, and other parameters to match your requirements. +3. **Deploy the Sample:** + - Use Azure Developer CLI (`azd`) or Azure CLI to deploy the sample and validate your design. +4. **Integrate into Solution:** + - Once validated, use the same approach to build your own `network.bicep` in `infra/modules/` for your production solution. + +## Example Directory Structure + +``` +infra/ + modules/ + network/ + ... (reusable modules) + network.bicep # <-- your custom network design + samples/ + network-subnet-design.bicep # <-- sample for reference +``` + +## Best Practices + +- Keep your customizations in your own `network.bicep` minimal and focused on your solution’s needs. +- Reuse modules as much as possible to reduce duplication and improve maintainability. +- Clearly document subnet address ranges, NSG rules, and any special configuration in comments. +- Validate your design with Azure best practices for security, scalability, and manageability. + +## Next Steps + +- Use this document as a living reference. Update it as your solution and requirements evolve. +- Refer to the sample and module documentation for advanced scenarios (e.g., private endpoints, delegations, Bastion, Jumpbox). + +--- + +*This guide is based on the latest comments and structure in `network-subnet-design.bicep`. Update as your solution matures.* From d4dcb4985f2052da4031690ef7d3771a8ab0f323 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 13 Jun 2025 19:37:56 -0400 Subject: [PATCH 074/124] addressPrefix: string? is no longer needed --- infra/modules/network/virtualNetwork.bicep | 3 --- 1 file changed, 3 deletions(-) diff --git a/infra/modules/network/virtualNetwork.bicep b/infra/modules/network/virtualNetwork.bicep index cd00249..c017850 100644 --- a/infra/modules/network/virtualNetwork.bicep +++ b/infra/modules/network/virtualNetwork.bicep @@ -118,9 +118,6 @@ type subnetType = { @description('Required. The Name of the subnet resource.') name: string - // @description('Conditional. The address prefix for the subnet. Required if `addressPrefixes` is empty.') - // addressPrefix: string? - @description('Required. Prefixes for the subnet.') // Required to ensure at least one prefix is provided addressPrefixes: string[] From 93492ca1d9c549ec007c9d1e2234e36b1d9e00e2 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 13 Jun 2025 19:39:18 -0400 Subject: [PATCH 075/124] deleted main.waf-aligned.bicepparam --- infra/main.waf-aligned.bicepparam | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 infra/main.waf-aligned.bicepparam diff --git a/infra/main.waf-aligned.bicepparam b/infra/main.waf-aligned.bicepparam deleted file mode 100644 index f21e39a..0000000 --- a/infra/main.waf-aligned.bicepparam +++ /dev/null @@ -1,10 +0,0 @@ -using './main.bicep' - -param solutionName = readEnvironmentVariable('AZURE_ENV_NAME') -param location = readEnvironmentVariable('AZURE_LOCATION') - -param enableMonitoring = true -param enableScaling = true -param enableRedundancy = true -//param secondaryLocation = 'uksouth' // TODO - test this -param enablePrivateNetworking = true From a15e91430c5315b7af26a91845b5c2c97a65b424 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 13 Jun 2025 20:28:48 -0400 Subject: [PATCH 076/124] added more output --- infra/samples/network-subnet-design.bicep | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/infra/samples/network-subnet-design.bicep b/infra/samples/network-subnet-design.bicep index 2c782d5..0e92a2c 100644 --- a/infra/samples/network-subnet-design.bicep +++ b/infra/samples/network-subnet-design.bicep @@ -241,3 +241,17 @@ module network '../modules/network/main.bicep' = { enableTelemetry: enableTelemetry } } + + +output vnetName string = network.outputs.vnetName +output vnetResourceId string = network.outputs.vnetResourceId + +output subnetWebResourceId string = first(filter(network.outputs.subnets, s => s.name == 'web')).?resourceId ?? '' +output subnetPrivateEndpointsResourceId string = first(filter(network.outputs.subnets, s => s.name == 'peps')).?resourceId ?? '' +output subnetAppResourceId string = first(filter(network.outputs.subnets, s => s.name == 'app')).?resourceId ?? '' +output subnetAiResourceId string = first(filter(network.outputs.subnets, s => s.name == 'ai')).?resourceId ?? '' +output subnetDataResourceId string = first(filter(network.outputs.subnets, s => s.name == 'data')).?resourceId ?? '' + +output bastionHostResourceId string = bastionConfiguration != null ? network.outputs.bastionHostId : '' +output bastionSubnetResourceId string = bastionConfiguration != null ? network.outputs.bastionSubnetId : '' + From 23d5609bb49c0b361c3eff449820e3b004585df2 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 13 Jun 2025 20:52:42 -0400 Subject: [PATCH 077/124] Added comments for network policies setting --- infra/samples/network-subnet-design.bicep | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/infra/samples/network-subnet-design.bicep b/infra/samples/network-subnet-design.bicep index 0e92a2c..fb688e3 100644 --- a/infra/samples/network-subnet-design.bicep +++ b/infra/samples/network-subnet-design.bicep @@ -82,8 +82,8 @@ param subnets subnetType[] = [ { name: 'peps' addressPrefixes: ['10.0.0.0/23'] // /23 (10.0.0.0 - 10.0.1.255), 512 addresses - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Disabled' + privateEndpointNetworkPolicies: 'Disabled' // 'Disabled': to use private endpoints in the subnet. + privateLinkServiceNetworkPolicies: 'Disabled' // 'Disabled': to deploy a private link service in the subnet. networkSecurityGroup: { name: 'peps-nsg' securityRules: [] @@ -92,8 +92,8 @@ param subnets subnetType[] = [ { name: 'web' addressPrefixes: ['10.0.2.0/23'] // /23 (10.0.2.0 - 10.0.3.255), 512 addresses - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Disabled' + privateEndpointNetworkPolicies: 'Enabled' // 'Disabled' only if you need to support private endpoints or private link services in the subnet. + privateLinkServiceNetworkPolicies: 'Enabled' // 'Disabled' only if you need to support private endpoints or private link services in the subnet. networkSecurityGroup: { name: 'web-nsg' securityRules: [ @@ -122,8 +122,8 @@ param subnets subnetType[] = [ { name: 'app' addressPrefixes: ['10.0.4.0/23'] // /23 (10.0.4.0 - 10.0.5.255), 512 addresses - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Disabled' + privateEndpointNetworkPolicies: 'Enabled' // 'Disabled' only if you need to support private endpoints or private link services in the subnet. + privateLinkServiceNetworkPolicies: 'Enabled' // 'Disabled' only if you need to support private endpoints or private link services in the subnet. networkSecurityGroup: { name: 'app-nsg' securityRules: [ @@ -152,8 +152,8 @@ param subnets subnetType[] = [ { name: 'ai' addressPrefixes: ['10.0.6.0/23'] // /23 (10.0.6.0 - 10.0.7.255), 512 addresses - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Disabled' + privateEndpointNetworkPolicies: 'Enabled' // 'Disabled' only if you need to support private endpoints or private link services in the subnet. + privateLinkServiceNetworkPolicies: 'Enabled' // 'Disabled' only if you need to support private endpoints or private link services in the subnet. networkSecurityGroup: { name: 'ai-nsg' securityRules: [ @@ -180,8 +180,8 @@ param subnets subnetType[] = [ { name: 'data' addressPrefixes: ['10.0.8.0/23'] // /23 (10.0.8.0 - 10.0.9.255), 512 addresses - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Disabled' + privateEndpointNetworkPolicies: 'Disabled' // 'Disabled': to use private endpoints in the subnet. + privateLinkServiceNetworkPolicies: 'Disabled' // 'Disabled': to deploy a private link service in the subnet. networkSecurityGroup: { name: 'data-nsg' securityRules: [ @@ -246,8 +246,10 @@ module network '../modules/network/main.bicep' = { output vnetName string = network.outputs.vnetName output vnetResourceId string = network.outputs.vnetResourceId -output subnetWebResourceId string = first(filter(network.outputs.subnets, s => s.name == 'web')).?resourceId ?? '' output subnetPrivateEndpointsResourceId string = first(filter(network.outputs.subnets, s => s.name == 'peps')).?resourceId ?? '' + + +output subnetWebResourceId string = first(filter(network.outputs.subnets, s => s.name == 'web')).?resourceId ?? '' output subnetAppResourceId string = first(filter(network.outputs.subnets, s => s.name == 'app')).?resourceId ?? '' output subnetAiResourceId string = first(filter(network.outputs.subnets, s => s.name == 'ai')).?resourceId ?? '' output subnetDataResourceId string = first(filter(network.outputs.subnets, s => s.name == 'data')).?resourceId ?? '' From 8f6cb8b42ada8d19151a12b97e5cec1c526d040a Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 16 Jun 2025 09:26:01 -0400 Subject: [PATCH 078/124] added CIDR Guide --- infra/samples/network-subnet-design.bicep | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/infra/samples/network-subnet-design.bicep b/infra/samples/network-subnet-design.bicep index fb688e3..cac4e1e 100644 --- a/infra/samples/network-subnet-design.bicep +++ b/infra/samples/network-subnet-design.bicep @@ -76,6 +76,35 @@ param jumpboxConfiguration jumpBoxConfigurationType = { @description('Networking address prefix for the VNET.') param addressPrefixes array = ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24 subnets) +// 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. + import { subnetType } from '../modules/network/virtualNetwork.bicep' @description('Array of subnets to be created within the VNET.') param subnets subnetType[] = [ From c9c7b47fc5135ad55b736c7ece675dccbcc29a4c Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Mon, 16 Jun 2025 17:01:03 -0400 Subject: [PATCH 079/124] simplified the content --- infra/samples/network_subnet_design.md | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/infra/samples/network_subnet_design.md b/infra/samples/network_subnet_design.md index ebcd7a5..6dfa3c8 100644 --- a/infra/samples/network_subnet_design.md +++ b/infra/samples/network_subnet_design.md @@ -5,14 +5,14 @@ This guide explains how to use the sample Bicep program `network-subnet-design.b ## Purpose and Approach - **Sample File:** `infra/samples/network-subnet-design.bicep` -- **Reusable Modules:** All code in `infra/modules/network` is designed to be composable and reusable. You should not need to modify code in the modules folder. +- **Reusable Modules:** All code in `infra/modules/network` is designed to be reusable. You should not need to modify code in the modules folder. - **Custom Network File:** For your own solution, create a `network.bicep` in `infra/modules/` that represents your specific network design, using the modules as building blocks. ## How to Use the Sample - The sample demonstrates how to deploy a virtual network, subnets, NSGs, Azure Bastion Host, and Jumpbox VM using parameters and reusable modules. -- You can deploy the sample independently to validate your network design before integrating it into your main solution, modify the infra/modules/network.bicep using the validated and tested design. - The sample is parameterized, so you can easily adjust subnet names, address spaces, NSG rules, and delegations. +- You can design and validate your network design with `infra/samples/network-subnet-design.bicep`, then integrate the code by updating `infra/modules/network.bicep` with your tested design. ## Key Features in the Sample @@ -26,8 +26,6 @@ This guide explains how to use the sample Bicep program `network-subnet-design.b - Example: The `web` subnet allows HTTPS inbound from anywhere; the `app` subnet allows traffic from the `web` subnet, etc. - **Bastion and Jumpbox:** - Optional Azure Bastion Host and Jumpbox VM are included for secure management access. -- **Composability:** - - All modules in `infra/modules/network` are designed to be reused without modification. ## Example Workflow @@ -38,7 +36,7 @@ This guide explains how to use the sample Bicep program `network-subnet-design.b 3. **Deploy the Sample:** - Use Azure Developer CLI (`azd`) or Azure CLI to deploy the sample and validate your design. 4. **Integrate into Solution:** - - Once validated, use the same approach to build your own `network.bicep` in `infra/modules/` for your production solution. + - Once validated, use the same approach to build your own `network.bicep` in `infra/modules/` for your solution. Test your solution with i`nfra/main.bicep`. ## Example Directory Structure @@ -49,7 +47,7 @@ infra/ ... (reusable modules) network.bicep # <-- your custom network design samples/ - network-subnet-design.bicep # <-- sample for reference + network-subnet-design.bicep # <-- reference ``` ## Best Practices @@ -58,12 +56,3 @@ infra/ - Reuse modules as much as possible to reduce duplication and improve maintainability. - Clearly document subnet address ranges, NSG rules, and any special configuration in comments. - Validate your design with Azure best practices for security, scalability, and manageability. - -## Next Steps - -- Use this document as a living reference. Update it as your solution and requirements evolve. -- Refer to the sample and module documentation for advanced scenarios (e.g., private endpoints, delegations, Bastion, Jumpbox). - ---- - -*This guide is based on the latest comments and structure in `network-subnet-design.bicep`. Update as your solution matures.* From 1386275009c876c293748c882136d1e858dff503 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Wed, 18 Jun 2025 10:44:19 -0400 Subject: [PATCH 080/124] simplifed --- infra/samples/network_subnet_design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/samples/network_subnet_design.md b/infra/samples/network_subnet_design.md index 6dfa3c8..e867e0d 100644 --- a/infra/samples/network_subnet_design.md +++ b/infra/samples/network_subnet_design.md @@ -55,4 +55,4 @@ infra/ - Keep your customizations in your own `network.bicep` minimal and focused on your solution’s needs. - Reuse modules as much as possible to reduce duplication and improve maintainability. - Clearly document subnet address ranges, NSG rules, and any special configuration in comments. -- Validate your design with Azure best practices for security, scalability, and manageability. +- Validate your design with Azure best practices for security, scalability, and manageability. \ No newline at end of file From fcb5815f861bae30423fef9d5c45232dabe417bf Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Wed, 18 Jun 2025 14:01:47 -0400 Subject: [PATCH 081/124] main.bicep now has params: vmAdminUsername and vmAdminPassword --- infra/main.bicep | 10 ++++++++++ infra/main.bicepparam | 2 ++ infra/modules/network.bicep | 16 ++++++++++++++-- infra/samples/network-subnet-design.bicep | 13 +++++++++++-- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index fb95327..2e5d476 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -57,6 +57,14 @@ param secondaryLocation string? @description('Optional. Enable private networking for the resources. Set to true to enable private networking. Defaults to false.') param enablePrivateNetworking bool = false +@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 + @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} @@ -133,6 +141,8 @@ module network 'modules/network.bicep' = if (enablePrivateNetworking) { params: { resourcesName: resourcesName logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId + vmAdminUsername: vmAdminUsername + vmAdminPassword: vmAdminPassword location: location tags: allTags enableTelemetry: enableTelemetry diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 3a440df..374a70a 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -12,3 +12,5 @@ param enableScaling = true param enableRedundancy = true //param secondaryLocation = 'uksouth' // TODO - test this param enablePrivateNetworking = true +param vmAdminUsername = 'JumpboxAdminUser' +param vmAdminPassword = 'JumpboxAdminP@ssw0rd1234!' diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index f382644..30c24ab 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -14,6 +14,18 @@ param tags object = {} @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true +@description('Admin username for the VM.') +@secure() +param vmAdminUsername string + +@description('Admin password for the VM.') +@secure() +param vmAdminPassword string + + + + + // Subnet Classless Inter-Doman Routing (CIDR) Sizing Reference Table (Best Practices) // | CIDR | # of Addresses | # of /24s | Notes | // |-----------|---------------|-----------|----------------------------------------| @@ -91,8 +103,8 @@ module network 'network/main.bicep' = { jumpboxConfiguration: { name: 'vm-jumpbox-${resourcesName}' size: 'Standard_D2s_v3' - username: 'JumpboxAdminUser' - password: 'JumpboxAdminP@ssw0rd1234!' + username: vmAdminUsername + password: vmAdminPassword subnet: { name: 'jumpbox' addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses diff --git a/infra/samples/network-subnet-design.bicep b/infra/samples/network-subnet-design.bicep index cac4e1e..0cf7b24 100644 --- a/infra/samples/network-subnet-design.bicep +++ b/infra/samples/network-subnet-design.bicep @@ -30,6 +30,15 @@ param enableTelemetry bool = true @description('Optional. Tags to be applied to the resources.') param tags object = {} +@description('Admin username for the VM.') +@secure() +param vmAdminUsername string = 'JumpboxAdminUser' + +@description('Admin password for the VM.') +@secure() +param vmAdminPassword string = 'JumpboxAdminP@ssw0rd1234!' + + import { bastionHostConfigurationType } from '../modules/network/bastionHost.bicep' @description('Optional. Configuration for the Azure Bastion Host. Leave null to omit Bastion creation.') param bastionConfiguration bastionHostConfigurationType = { @@ -42,8 +51,8 @@ import { jumpBoxConfigurationType } from '../modules/network/jumpbox.bicep' param jumpboxConfiguration jumpBoxConfigurationType = { name: 'vm-jumpbox-${resourcesName}' size: 'Standard_D2s_v3' // Default size, can be overridden - username: 'JumpboxAdminUser' - password: 'JumpboxAdminP@ssw0rd1234!' + username: vmAdminUsername + password: vmAdminPassword subnet: { name: 'jumpbox-subnet' addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses From 23ebdda42a1d8a7176c3c646d479f61b5630885c Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 19 Jun 2025 10:25:48 -0400 Subject: [PATCH 082/124] Made vmAdminUsername and vmAdminPassword optional --- infra/main.bicep | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 2e5d476..886acc6 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -59,11 +59,11 @@ param enablePrivateNetworking bool = false @description('Optional. Admin username for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') @secure() -param vmAdminUsername string +param vmAdminUsername string? @description('Optional. Admin password for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') @secure() -param vmAdminPassword string +param vmAdminPassword string? @description('Optional. Specifies the resource tags for all the resources. Tag "azd-env-name" is automatically added to all resources.') param tags object = {} From 4ed883194c1d8f131e4d9d89a06e8aeaf0fb47e5 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 19 Jun 2025 14:21:46 -0400 Subject: [PATCH 083/124] Eliminated Ai Foundry Hub - Revise 1 --- infra/main.bicep | 17 +++-- infra/modules/aiFoundry.bicep | 123 +++++++-------------------------- infra/modules/aiServices.bicep | 1 + 3 files changed, 34 insertions(+), 107 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 886acc6..d1c53d6 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -141,8 +141,8 @@ module network 'modules/network.bicep' = if (enablePrivateNetworking) { params: { resourcesName: resourcesName logAnalyticsWorkSpaceResourceId: logAnalyticsWorkspace.outputs.resourceId - vmAdminUsername: vmAdminUsername - vmAdminPassword: vmAdminPassword + vmAdminUsername: vmAdminUsername ?? 'JumpboxAdminUser' + vmAdminPassword: vmAdminPassword ?? 'JumpboxAdminP@ssw0rd1234!' location: location tags: allTags enableTelemetry: enableTelemetry @@ -165,11 +165,11 @@ module aiServices 'modules/aiServices.bicep' = { // Request: POST /api/start-processing // Response: ERROR:sql_agents.agents.agent_base:Error creating agent definition: (403) Public access is disabled. Please configure private endpoint. // --------------------- - // privateNetworking: enablePrivateNetworking ? { - // virtualNetworkResourceId: network.outputs.vnetResourceId - // subnetResourceId: first(filter(network.outputs.subnets, s => s.name == 'peps')).resourceId - // } : null - // --------------------- + privateNetworking: enablePrivateNetworking ? { + virtualNetworkResourceId: network.outputs.vnetResourceId + subnetResourceId: network.outputs.subnetPrivateEndpointsResourceId + } : null + roleAssignments: [ { principalId: appIdentity.outputs.principalId @@ -253,9 +253,8 @@ module azureAifoundry 'modules/aiFoundry.bicep' = { dependsOn: [logAnalyticsWorkspace, network] // required due to optional flags that could change dependency params: { location: azureAiServiceLocation - hubName: 'hub-${resourcesName}' - hubDescription: 'AI Hub for Modernize Your Code' projectName: 'proj-${resourcesName}' + projectDescription: 'AI Foundry Project for Modernize Your Code' storageAccountResourceId: storageAccount.outputs.resourceId keyVaultResourceId: keyVault.outputs.resourceId userAssignedIdentityResourceId: aiFoundryIdentity.outputs.resourceId diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep index 5fd1936..08ab34d 100644 --- a/infra/modules/aiFoundry.bicep +++ b/infra/modules/aiFoundry.bicep @@ -1,14 +1,17 @@ @description('The Azure region where resources will be deployed.') param location string -@description('The name of the AI Foundry Project workspace.') +@description('Required. The name of the AI Foundry project to create.') param projectName string -@description('The name of the AI Foundry Hub workspace.') -param hubName string +@description('Required. The description of the AI Foundry project to create.') +param projectDescription string -@description('The description of the AI Hub workspace.') -param hubDescription string = hubName +// @description('The name of the AI Foundry Hub workspace.') +// param hubName string + +// @description('The description of the AI Hub workspace.') +// param hubDescription string = hubName @description('The Resource Id of an existing storage account to attach to AI Foundry.') param storageAccountResourceId string @@ -42,7 +45,7 @@ param tags object = {} param enableTelemetry bool = true module mlApiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?apiPrivateDnsZoneResourceId)) { - name: take('${hubName}-mlapi-pdns-deployment', 64) + name: take('${projectName}-mlapi-pdns-deployment', 64) params: { name: 'privatelink.api.${toLower(environment().name) == 'azureusgovernment' ? 'ml.azure.us' : 'azureml.ms'}' virtualNetworkLinks: [ @@ -56,7 +59,7 @@ module mlApiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = } module mlNotebooksPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?notebooksPrivateDnsZoneResourceId)) { - name: take('${hubName}-mlnotebook-pdns-deployment', 64) + name: take('${projectName}-mlnotebook-pdns-deployment', 64) params: { name: 'privatelink.notebooks.${toLower(environment().name) == 'azureusgovernment' ? 'azureml.us' : 'azureml.net'}' virtualNetworkLinks: [ @@ -72,103 +75,27 @@ module mlNotebooksPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7 var apiPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?apiPrivateDnsZoneResourceId) ? mlApiPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?apiPrivateDnsZoneResourceId) : '' var notebooksPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?notebooksPrivateDnsZoneResourceId) ? mlNotebooksPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?notebooksPrivateDnsZoneResourceId) : '' +//AVM module uses 'Microsoft.CognitiveServices/accounts@2023-05-01' resource aiServices 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { name: aiServicesName } -module hub 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { - name: take('ai-foundry-${hubName}-deployment', 64) - #disable-next-line no-unnecessary-dependson - dependsOn: [mlApiPrivateDnsZone, mlNotebooksPrivateDnsZone] // required due to optional flags that could change dependency - params: { - name: hubName - location: location - sku: 'Standard' - kind: 'Hub' - description: hubDescription - associatedKeyVaultResourceId: keyVaultResourceId - associatedStorageAccountResourceId: storageAccountResourceId - associatedApplicationInsightsResourceId: applicationInsightsResourceId - publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' - managedNetworkSettings: { - isolationMode: privateNetworking != null ? 'AllowInternetOutbound' : 'Disabled' - outboundRules: privateNetworking != null ? { - cog_services_pep: { - category: 'UserDefined' - destination: { - serviceResourceId: aiServices.id - sparkEnabled: true - subresourceTarget: 'account' - } - type: 'PrivateEndpoint' - } - } : null - } - managedIdentities: { - systemAssigned: true - } - systemDatastoresAuthMode: 'Identity' - diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] - privateEndpoints: privateNetworking != null ? [ - { - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - privateDnsZoneResourceId: apiPrivateDnsZoneResourceId - } - { - privateDnsZoneResourceId: notebooksPrivateDnsZoneResourceId - } - ] - } - service: 'amlworkspace' - subnetResourceId: privateNetworking.?subnetResourceId ?? '' - } - ] : [] - connections: [ - { - name: aiServicesName - value: null - category: 'AIServices' - target: aiServices.properties.endpoint - connectionProperties: { - authType: 'AAD' - } - isSharedToAll: true - metadata: { - ApiType: 'Azure' - Kind: 'AIServices' - ResourceId: aiServices.id - } - } - ] - tags: tags - enableTelemetry: enableTelemetry +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { + parent: aiServices + name: projectName + tags: tags + location: location + identity: { + type: 'SystemAssigned' } -} - -module project 'br/public:avm/res/machine-learning-services/workspace:0.12.1' = { - name: take('ai-foundry-${projectName}-deployment', 64) - params: { - name: projectName - kind: 'Project' - sku: 'Standard' - location: location - hubResourceId: hub.outputs.resourceId - hbiWorkspace: false - systemDatastoresAuthMode: 'Identity' - publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' - managedIdentities: { - userAssignedResourceIds: [userAssignedIdentityResourceId] - } - primaryUserAssignedIdentity: userAssignedIdentityResourceId - diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] - roleAssignments: roleAssignments - tags: tags - enableTelemetry: enableTelemetry + properties: { + description: projectDescription + displayName: projectName } } + + // get reference to the AI Hub project to get access to the discovery URL property (not presently available on AVM) // adjust this logic if support on the AVM module is added resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10-01' existing = { @@ -176,8 +103,8 @@ resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10- dependsOn: [project] } -output projectName string = project.outputs.name -output hubName string = hub.outputs.name +output projectName string = project.name +//output hubName string = hub.outputs.name output projectConnectionString string = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' @export() diff --git a/infra/modules/aiServices.bicep b/infra/modules/aiServices.bicep index 482bfe5..157bc20 100644 --- a/infra/modules/aiServices.bicep +++ b/infra/modules/aiServices.bicep @@ -99,6 +99,7 @@ module openAiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = var cogServicesPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?cogServicesPrivateDnsZoneResourceId) ? cognitiveServicesPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?cogServicesPrivateDnsZoneResourceId) : '' var openAIPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?openAIPrivateDnsZoneResourceId) ? openAiPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?openAIPrivateDnsZoneResourceId) : '' +//AVM module uses 'Microsoft.CognitiveServices/accounts@2023-05-01' module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = { name: take('${name}-aiservices-deployment', 64) #disable-next-line no-unnecessary-dependson From ae71c25442933ff8900bae92dd3b376d7756e88e Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Thu, 19 Jun 2025 15:36:36 -0400 Subject: [PATCH 084/124] initial version --- docs/images/read_me/solArchitectureWAF.png | Bin 0 -> 92134 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/read_me/solArchitectureWAF.png diff --git a/docs/images/read_me/solArchitectureWAF.png b/docs/images/read_me/solArchitectureWAF.png new file mode 100644 index 0000000000000000000000000000000000000000..368c3f8d2fe17a6a456d43d5aa96c41fc5673303 GIT binary patch literal 92134 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

E_8M3(P-ygL4SI<0h#waP&X3 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 Date: Thu, 19 Jun 2025 16:03:38 -0400 Subject: [PATCH 085/124] initial version --- docs/Architecture_WAF.md | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/Architecture_WAF.md diff --git a/docs/Architecture_WAF.md b/docs/Architecture_WAF.md new file mode 100644 index 0000000..97a0388 --- /dev/null +++ b/docs/Architecture_WAF.md @@ -0,0 +1,51 @@ +# WAF-Aligned Solution Architecture + +This document describes the architecture and key features of the WAF-aligned (Well-Architected Framework) deployment option for the Modernize Your Code Solution Accelerator. + +![WAF-Aligned Architecture Diagram](../docs/images/read_me/solArchitectureWAF.png) + +## Overview +The WAF-aligned deployment is hardened architecture following Azure Well-Architected Framework pillars: +- Security +- Reliability +- Performance Efficiency +- Cost Optimization +- Operational Excellence + +## Key Features +- **Private Networking:** + - Critical resources are accessible only via private endpoints within a Virtual Network (VNet). + - Private DNS zones and virtual network links ensure secure, internal name resolution. +- **Monitoring & Diagnostics:** + - Log Analytics Workspace and Application Insights are enabled for observability and troubleshooting. +- **Scaling & Redundancy:** + - Resources can be configured for high availability and geo-redundancy. +- **Role-Based Access Control (RBAC):** + - Managed identities and least-privilege access for secure automation and resource access. +- **Parameterization:** + - All WAF features are controlled via parameters in `main.waf.bicepparam` for easy customization. + +## Architecture Components +- **Virtual Network (VNet):** Isolates all solution resources. +- **Private Endpoints:** Securely connect to Azure PaaS services (AI foundry, AI Services, Azure Storage, Cosmos DB, Key Vault), from within VNET. +- **Private DNS Zones:** Provide internal DNS resolution for private endpoints. +- **Jumpbox/Bastion Host:** Secure admin access to the VNet. +- **Monitoring:** Centralized logging and application insights. +- **App Services/Container Apps:** Deployed within the VNet, with private access to dependencies. + +## Deployment +- Use the `main.waf.bicepparam` parameter file to enable all WAF features. +- Deploy with: + ```sh + cp infra/main.waf.bicepparam infra/main.bicepparam + azd up + ``` + +## Customization +- Adjust parameters in `main.waf.bicepparam` to fit your organization's requirements (e.g., enable/disable redundancy, monitoring, private networking). + +## References +- [Azure Well-Architected Framework](https://learn.microsoft.com/azure/architecture/framework/) +- [Parameter file example](../infra/main.waf.bicepparam) +- [Main deployment Bicep](../infra/main.bicep) + From 1cb63cc02479938efa2d7991039387242d90d86b Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 20 Jun 2025 10:02:11 -0400 Subject: [PATCH 086/124] syntax error correction --- infra/modules/aiServices.bicep | 3 +++ 1 file changed, 3 insertions(+) diff --git a/infra/modules/aiServices.bicep b/infra/modules/aiServices.bicep index 157bc20..99d0243 100644 --- a/infra/modules/aiServices.bicep +++ b/infra/modules/aiServices.bicep @@ -117,6 +117,9 @@ module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = customSubDomainName: name disableLocalAuth: false publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' + apiProperties: { + allowProjectManagement: true + } diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ { workspaceResourceId: logAnalyticsWorkspaceResourceId From be7da481f758cf103e18b3c49a18642ce0692853 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 20 Jun 2025 16:22:39 -0400 Subject: [PATCH 087/124] deployment with new aiservices tested --- README.md | 25 +- ...Architecture_WAF.md => ArchitectureWAF.md} | 23 +- docs/DeploymentGuide.md | 47 +- docs/images/read_me/solArchitectureWAF.png | Bin 92134 -> 92884 bytes infra/main.bicep | 488 +++++++++--------- infra/main.bicepparam | 10 +- infra/main.waf.bicepparam | 18 + infra/modules/ai-services/main.bicep | 224 ++++++++ infra/modules/ai-services/project.bicep | 105 ++++ infra/modules/aiFoundry.bicep | 124 ----- infra/modules/aiServices.bicep | 168 ------ infra/modules/cosmosDb.bicep | 162 +++--- infra/modules/keyVault.bicep | 66 +-- infra/modules/network.bicep | 28 +- infra/modules/privateDnsZone.bicep | 44 ++ infra/modules/storageAccount.bicep | 107 ++-- 16 files changed, 898 insertions(+), 741 deletions(-) rename docs/{Architecture_WAF.md => ArchitectureWAF.md} (63%) create mode 100644 infra/main.waf.bicepparam create mode 100644 infra/modules/ai-services/main.bicep create mode 100644 infra/modules/ai-services/project.bicep delete mode 100644 infra/modules/aiFoundry.bicep delete mode 100644 infra/modules/aiServices.bicep create mode 100644 infra/modules/privateDnsZone.bicep diff --git a/README.md b/README.md index 78f922d..a2790cb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The Modernize your code solution accelerator allows users to specify a group of

- + [**SOLUTION OVERVIEW**](#solution-overview) \| [**QUICK DEPLOY**](#quick-deploy) \| [**BUSINESS SCENARIO**](#business-scenario) \| [**SUPPORTING DOCUMENTATION**](#supporting-documentation)
@@ -24,7 +24,10 @@ The solution leverages Azure AI Foundry, Azure OpenAI Service, Azure Container A |![image](./docs/images/read_me/solArchitecture.png)| |---| +This solution architecture is the default solution architecture with the default setting of our deployment process. Optionally you can deploy [Well-Architected Framework (WAF) aligned](https://learn.microsoft.com/en-us/azure/well-architected/) architecture, described in [WAF Aligned Solution Architecture](./docs/ArchitectureWAF.md), with deployment the WAF-Aligned option described in [Deployment Guide](./docs/DeploymentGuide.md). + ### Agentic architecture + |![image](./docs/images/read_me/agentArchitecture.png)| |---| @@ -51,16 +54,16 @@ If you'd like to customize the solution accelerator, here are some common areas Click to learn more about the key features this solution enables - **Code language modernization**
- Modernizing outdated code ensures compatibility with current technologies, reduces reliance on legacy expertise, and keeps businesses competitive. + Modernizing outdated code ensures compatibility with current technologies, reduces reliance on legacy expertise, and keeps businesses competitive. - **Summary and review of new code**
- Generating summaries and translating code files keeps humans in the loop, enhances their understanding, and facilitates timely interventions, ensuring the files are ready to export. + Generating summaries and translating code files keeps humans in the loop, enhances their understanding, and facilitates timely interventions, ensuring the files are ready to export. - **Business logic analysis**
- Leveraging AI to decipher business logic from legacy code helps minimizes the risk of human error. + Leveraging AI to decipher business logic from legacy code helps minimizes the risk of human error. - **Efficient code transformation**
- Streamlining the process of analyzing, converting, and iterative error testing reduces time and effort required to modernize the systems. + Streamlining the process of analyzing, converting, and iterative error testing reduces time and effort required to modernize the systems. @@ -77,7 +80,7 @@ Follow the quick deploy steps on the deployment guide to deploy this solution to | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/microsoft/Modernize-your-Code-Solution-Accelerator) | [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/microsoft/Modernize-your-Code-Solution-Accelerator) | |---|---| - +
> ⚠️ **Important: Check Azure OpenAI Quota Availability** @@ -141,19 +144,19 @@ The sample data used in this repository is synthetic and generated using Azure O Click to learn more about what value this solution provides - **Accelerated Migration**
- Automate the translation of SQL queries, significantly reducing migration time and effort. + Automate the translation of SQL queries, significantly reducing migration time and effort. - **Error Reduction**
- Multi-agent validation ensures accurate translations and maintains data integrity. + Multi-agent validation ensures accurate translations and maintains data integrity. - **Knowledge Preservation**
- Captures and preserves business logic during the modernization process. + Captures and preserves business logic during the modernization process. - **Cost Efficiency**
- Reduces reliance on specialized legacy system expertise and manual translation efforts. + Reduces reliance on specialized legacy system expertise and manual translation efforts. - **Standardization**
- Ensures consistent query translation across the organization. + Ensures consistent query translation across the organization. diff --git a/docs/Architecture_WAF.md b/docs/ArchitectureWAF.md similarity index 63% rename from docs/Architecture_WAF.md rename to docs/ArchitectureWAF.md index 97a0388..4c9d441 100644 --- a/docs/Architecture_WAF.md +++ b/docs/ArchitectureWAF.md @@ -1,11 +1,10 @@ # WAF-Aligned Solution Architecture -This document describes the architecture and key features of the WAF-aligned (Well-Architected Framework) deployment option for the Modernize Your Code Solution Accelerator. +This page describes the architecture and key features of the WAF-aligned (Well-Architected Framework) deployment option for the Modernize Your Code Solution Accelerator. ![WAF-Aligned Architecture Diagram](../docs/images/read_me/solArchitectureWAF.png) -## Overview -The WAF-aligned deployment is hardened architecture following Azure Well-Architected Framework pillars: +The [Well-Architected Framework (WAF) aligned](https://learn.microsoft.com/en-us/azure/well-architected/) architecture enables below pillars: - Security - Reliability - Performance Efficiency @@ -14,6 +13,7 @@ The WAF-aligned deployment is hardened architecture following Azure Well-Archite ## Key Features - **Private Networking:** + - Solution components are enclosed with Azure Virtual Network. - Critical resources are accessible only via private endpoints within a Virtual Network (VNet). - Private DNS zones and virtual network links ensure secure, internal name resolution. - **Monitoring & Diagnostics:** @@ -32,20 +32,3 @@ The WAF-aligned deployment is hardened architecture following Azure Well-Archite - **Jumpbox/Bastion Host:** Secure admin access to the VNet. - **Monitoring:** Centralized logging and application insights. - **App Services/Container Apps:** Deployed within the VNet, with private access to dependencies. - -## Deployment -- Use the `main.waf.bicepparam` parameter file to enable all WAF features. -- Deploy with: - ```sh - cp infra/main.waf.bicepparam infra/main.bicepparam - azd up - ``` - -## Customization -- Adjust parameters in `main.waf.bicepparam` to fit your organization's requirements (e.g., enable/disable redundancy, monitoring, private networking). - -## References -- [Azure Well-Architected Framework](https://learn.microsoft.com/azure/architecture/framework/) -- [Parameter file example](../infra/main.waf.bicepparam) -- [Main deployment Bicep](../infra/main.bicep) - diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 3c61018..bff90ad 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -18,7 +18,7 @@ Here are some example regions where the services are available: East US, East US | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/microsoft/Modernize-your-Code-Solution-Accelerator) | [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/microsoft/Modernize-your-Code-Solution-Accelerator) | |---|---| - + ### **Configurable Deployment Settings** When you start the deployment, most parameters will have **default values**, but you can update the following settings by following the steps [here](../docs/CustomizingAzdParameters.md): @@ -42,7 +42,29 @@ By default, the **GPT model capacity** in deployment is set to **5k tokens**. To adjust quota settings, follow these [steps](../docs/AzureGPTQuotaSettings.md) -### Deployment Options +### Deployment Options & Steps +### Sandbox or WAF Aligned Deployment Options + +The [`infra`](../infra) folder of the Multi Agent Solution Accelerator contains the [`main.bicep`](../infra/main.bicep) Bicep script, which defines all Azure infrastructure components for this solution. + +By default, the `azd up` command uses the [`main.bicepparam`](../infra/main.bicepparam) file to deploy the solution. This file is pre-configured for a **sandbox environment** — ideal for development and proof-of-concept scenarios, with minimal security and cost controls for rapid iteration. + +For **production deployments**, the repository also provides [`main.waf-aligned.bicepparam`](../infra/main.waf-aligned.bicepparam), which applies a [Well-Architected Framework (WAF) aligned](https://learn.microsoft.com/en-us/azure/well-architected/) configuration. This option enables additional Azure best practices for reliability, security, cost optimization, operational excellence, and performance efficiency, such as: + +- Enhanced network security (e.g., Network protection with private endpoints) +- Stricter access controls and managed identities +- Logging, monitoring, and diagnostics enabled by default +- Resource tagging and cost management recommendations + +**How to choose your deployment configuration:** + +- Use the default [`main.bicepparam`](../infra/main.bicepparam) for a sandbox/dev environment. +- For a WAF-aligned, production-ready deployment, copy the contents of [`main.waf-aligned.bicepparam`](../infra/main.waf-aligned.bicepparam) into `main.bicepparam` before running `azd up`. + +> [!TIP] +> Always review and adjust parameter values (such as region, capacity, security settings and log analytics workspace configuration) to match your organization’s requirements before deploying. For production, ensure you have sufficient quota and follow the principle of least privilege for all identities and role assignments. + + Pick from the options below to see step-by-step instructions for: GitHub Codespaces, VS Code Dev Containers, Local Environments, and Bicep deployments.
@@ -114,23 +136,28 @@ To change the azd parameters from the default values, follow the steps [here](.. 1. Login to Azure: - ```shell - azd auth login - ``` + ```shell + azd auth login + ``` + + #### Note: To authenticate with Azure Developer CLI (`azd`) to a specific tenant, use the previous command with your **Tenant ID**: + + ```sh + azd auth login --tenant-id + ``` - #### Note: To authenticate with Azure Developer CLI (`azd`) to a specific tenant, use the previous command with your **Tenant ID**: +2. Provide an `azd` environment name (like "cmsaapp") - ```sh - azd auth login --tenant-id + ```sh + azd env new ``` -2. Provision and deploy all the resources: +3. Provision and deploy all the resources: ```shell azd up ``` -3. Provide an `azd` environment name (like "cmsaapp") 4. Select a subscription from your Azure account, and select a location which has quota for all the resources. * This deployment will take *6-9 minutes* to provision the resources in your account and set up the solution with sample data. * If you get an error or timeout with deployment, changing the location can help, as there may be availability constraints for the resources. diff --git a/docs/images/read_me/solArchitectureWAF.png b/docs/images/read_me/solArchitectureWAF.png index 368c3f8d2fe17a6a456d43d5aa96c41fc5673303..34f73f01a946fe731ae488f8fd4e84c1701c707f 100644 GIT binary patch literal 92884 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 CI%ewt literal 92134 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

E_8M3(P-ygL4SI<0h#waP&X3 zxAX}6i~HsX^aA*vyI-c(Ey9`1TwFffHu}ELi~{a}JRr8Y6)Z#-5zh<)Ff0=sEz!_} zm97{zZxVNp=8xjA%hQZ8p*Xd!!p3FTFR8Hg9+85J921>3A4bQpp*>iLqT9t(7p}dL zF`mY2=92SRj0y&pDOgXn|CumARt9cF;gaC#1&%hu%LF(9NF6iI&1KgbeE@040scu} zpGr^@2PY>m-w^;MIYD;++rI#yTK&tl27b&=AW>8bV2i3tNlMQHKnzHDV4k+WKn8=p zmyi{w0RhRh+wkY-^|F4nKijb8w-3arMeTV)(EgdhU_pIhw0lL(#y~?Ut9gJnsZlzO z7DTRnu;fmX&BhCSeRCKI?EWrbeYlKs+e!}3(-4}ukM-&Qi}lTW6^;8huHXdNA-=sEgHr3Q#RZ9d)1N>Tibx#p^L8LjdEzU>nE>TFsARLU8}n2}h;~2J#G$ zj%hJ7KEbx(2~8tLAP@&ONm?MJO?f9Vzo~4QSHzhw&qx`GV=*@df+RZfnSu+_*$VjV zM^~6Q1LbVk{k8NC-{4$?I-+-0jdkF0(c+Hhxv^8MY=7F@hv=IL{l&KCegtcgoLZO5 z)6fnX=#FE+%CDV90{-kdU@d^eaDKZ5H21!|6-Tqxe$zOFCQAAdX{b0gvaS)TT{_A< z@ljAeP|zrw`x=3ShBn9O55`BnvNu3OBA}~n$BUXG3#RtvoAb5(1Sq>8Iv~XZc+PG7 zXt^Z#2_usDp3^lvC}&XDNkn*f)Y0>H{Eg=3H*GVUM`-8BQt$^deLvNEnhfouAJ^Y2*FSISW7o5SeE4ziDSiI$lHdT|>3r+(HF{dS_V6LLmdPKHh=kFUC7rt-MBrR{%U zAe{?Xq`Bb)U*UqrhqVNsOYl3)Rkz1Fvjn^vd#9r z9`Luj4SG*v%0Qr%sI8gb)$thc$3j`Cbcp|Pck=547N(2ypAMIP)87g%+&W3G z3hm4xZ>A2ZAyI3F>}*-i4Ih&lXUtU?R~t}I#EW|16Bm-;vn!KhF2DcLfAteTiskLfF+ncxGaw6EjaZdI=eN`43F`M@7@+c@YQbP>ewF3eeWnk=mkMI>3~`?l z|EC3_jalJLePSa z@@g)z%;qvaPsV@os^Ga`3fd_C)2E6wSAu{&cEM?xH7rE0x+-2#jpFVbjJe#O-o5c+ zg&H(uf&wU!pFZ7a4RkX_oE-g=vad1XBU=rQGb^SUD70!EBLp>G?-#Pp6onWn6mB!q-y@11NyHX%f2lI*=#R`&n;(0xDm^E|)r@Avy3$N%4P+((C7KHlT? zx~}VWUgve5E~6nz16!Z%c>*VuBi@XfUHh%`Sz^)j$<@DC*O&kt0>BTH*x8lS{VH*?Y2?Yv5f<&DCR_WVWM7;@IeGWrLVb5o#TasTrzhKCDNLrM0>IjJq23vipHD&az2gcUvhb%Y^NGz1uRWDG@>Hn>m7jnv6`3$OK`QV*}Fik%9ZtTbP=b4R+0n72+H}}$F@}4MPgMb;7 ztR)QYYnt7>2aIn%{E(2mdZp|U@qpt?xQ00sFu1 zH)VlaaIWn*r@X_jm+ylSO^wy{C<&4>U1{b~-L-!M&%z^%;1RP!&YATL}_cQZFVEKc*P(qV+gV;1b=ejY@Q03##+z zQs}DP%`h7~m@Jq}U();OHlh#*UqtTKept}u60O>rx!6?mjt(wv{^gyt$ET^|4aT(? z(%9CSp2KJP3#Py3Gkj!-a-5Vqtl81hvF?RC2=i?6eAc{oc6p~pv4Eke?Czx+l(&L# zup&<+yQdp#!{Wm%RFF(14w$y9kk9yJjo7+#@>aDEG3H=CfJM0DHoyLwklJH)|JKa{ zvEj{>TQ}!28$||aSNFmjyWHp3cL=F|+V-_q{rBbhW?s1FnDX@N`)E!I?rR~oLgwf@ z+?`XQoexPKA}`SL$~B7%s76?D$bB#6Fl&}@kD~)AkJs=d3?Q4B^#9Q{bA|8RS=+?+ z8(+QWNcr56zoYx__0FY(#Dn#T1lon6?XX7sdt;p;RHM#E6LVsAPICu@D~I3OyMkQr zNO0F{be~U?=|9!pVZv2nH(HilidC<1@M#jX!krCFM49!7FfhCyGZsHP*E=X>t6rs+ zcm>eZLTky>hj{^iAKtZS-0a!I{9y{rt^d83<^p(Wp8Ed2j>Y_a*FVv7Y=W9rw0er6 zq_2)w`S*Ih;Nv-e!d+ik*s4OtzHV-v~fTWBQ!tb!U&; z4Nr;yjsCwf*nIf(@bev50Zuk^PqE5j5^G0nVPvD>){vNuO$)4v4lRGvvia~TV zullr~nYyW^vR+X{NS=}^Ir#EkM$0J%CDu$F_jj;mcAKo~^OtB6a}&;A?!TFcsebG3 zs~kl|zVOi<<2OYyImzI683HKHU3%igiaZgjailfhStv36zwzJ{W`TJa0oYWY!o_p5 zjUwe+BIW68d)j&)tC6+3_Wf^a=fpm;$crhpz}woB?{e|t+Fg(bsSe+P$5b9)E=5lc3+5cM+sCQjYQj%=JAtHU3L`J z@-%RLKXrF^=j&F%P*)j{VQXA>69sHP&Db%8-yTN$`^q*&yL{W)E*CtjzbVMQK(qc4 ztJ=PQ+RN-$?lZgYxtnV-)#ICif0ia&noNdjT@hM6xsqp2%)15U+cFOr8A!oj$xbrK^NxP2*&FeuP|Mlyjp;c`$kiRliPAlXv4}@f-3lydg za5%muqFS9rIoLEQx0B55dgpfhsz=RPN%8&W^5q!Gaa?O^Z?8?K-xSC;FTg>w#GvMT zd=KmFJP|G1_wg}vpiI<|l)Zf*l7wY-|9u2bg%!{Fb3r0Z=%p_}rtwn0Qbgb(+vxWX zN(H)k8lX)7Y~^PuI9Y|WsKEF`xU4uZPaI>9OHsB}&RxeZwEuK{k}84xS>~Gn`BI(i z4iIteSfHYO`dW}>cv9~k5nka1hwgWGsLu*91DZ)e>A6Qdx?MCS&gM9}--lK4ew>@; z=E@ODJdFiAdvNYxFm`jeXs8U=9xcW$W-m8e!>c*J6X)tQLsn_ zjm7MH?@&a6RMg!a+A>u^DtFk~IK@cWb~jH$dj#`LL7D10{`!yk`E?i)l<_0kFyH>= zusR$)l1vqXDJ#RNOF=_TNqg}uHvH|(jf(g{79eLcTUxJopPu4;l_Ef~*-~#hjy^xv z3n&mZk^srk|INHbVw-Vbvr&fHqep+7B9YslP``5P3sAB+^&En$+-M5S{MstoU=qEC zgdy!BmyXj+L{OBUfAd*d`n&GBm)yr`jFBwS8^F3i@i0Z4IpX|HWo$L+1xury?EBL- zvSbxwJ4 z$elm_kI1L9hYLLWn>Pd;CMrtIJRBEW!?Qv<)m64=T@SayM9dESG5tG}D1}eHVAyh9 z0n(EgTLCtd%suJ<0r`_HH zAT^40X8H*S9;Tf%BICH;YzKO>U|-bfl$q(`Inb$1sx7{A>Vv}(e{i(=nzto{7RhL; zudi=v0tl*`w_dMc7k}*LSLgBX30P&PTJ*^Hd2ElCb)b^CELL^+qxRYE6W5cJ1X_V_ zyeu^*-JPqWRwuR&T|M5%yjdy)i8onRr7^&e7llIt{K$$PURw{2?fdl2s(8Ujc~#$0 zyN1r;LR71%sl6H>e&HzRRxgTbJY3pOgDXOtm=gS~%W=uCqppx`k=fykWS3f*N1%Ak zdxX%4xPW{WFx%qAJV)m?Gx{F6oZG6eN7SGT0li5w1%_yk8je_gHlqF6D!;Ch1#8(q zeP4Gn9WceByffxH?JIpy=Jw%Pi!5P>lBc6V3;scQzS~~&RF+DDfNk|j2OF%3msFm} zdfLEk?CdDCkYa|a<@v6>p`=cl0dz3Vy>mgur z8V3fU%;5lYM#w`%2G`A#(Ib{2xS7EHEc88@pwGD#Le9UM0-{2w#T+Lq!eu?5PR`)7 zgmm1h6S8$!E1F_?oh0Bm{X4sl1-b3)4)@@+;=N)1$4|#U@zF%DwEp9G6K(2Dkh$vt=}W7~KMDL4cX$q9v^ZjKH*#3CZ@qIW#{xZG0@-1lap zV0?4QL8winhLGy0=}Y*)ogw~7ngl5j;0>jrdNS<^uEcM93_#)^0lfo}YLqTSn!yQIs%5Ggfi5k#KW1{b^NM!O~#mO7lemTK7Hp7g@^n zh-XCPJp5gwuxu)mLqiK)YRvFIaOr~9*2_lr5! zVkbZ!gie0aS5xpT3T#gG!}4&LY;kA!hPHWAGB5#IYXdrK$V1!C^zC+$e4u*2be`=2aj(E8)Abu`Dm^(r-HtFqnnxhfyY zx!kFl=s0xse|DQcCqroH|8Gd(ZmPhKW$41=`PLA4=sy97Xt5=V$tDn8DMEIhfD_#aSb!h`{=@>6|Ti-?NKz{<*Xl^ovo7AQwc+2xo0LgI zB2bOhn(dC(aIHE|_`(!@z*)uV)u4|I;Y;XS@gb)-1Ugiydk!12=sq0|62qzmFN{1; zb(?4Sl(7&WuujkDA^+bFcYwHqT%tw={@Ebp)VoJs`YsC@P{gkND0&FWja@6ldpceD z&X49g6BB`4Z#h&3VML$wPrCG}V@~z}^OSi(+L|p#>9x(pOVYdxPout~rRk^$!VpGwKpJGc z-W%%IH&_zDaa{C>`u%=4i5i(sVN3{OjY^UzbUA_4wu+LeNnM9LktTpyFnj%Y!MW2@ z`Xb-yW2H=+yR#kn$W-K^*0VuHqCiu637w4lvz6@#gaKOM?oTjKdyp3?p8*tM$11`E z!K?)&>i|a1*ABo?OAsdXpc0E$HUn>L(~_i3z1m;S0R{W@f&(yN4VOVkE#v@FfTv=l z@|m96llx|rs)ui(Dyu{yCh&w#>FYQSJWV)ueyUtyoI!qf!IBE{v=P-cUf=f5OuYg+ z{51{;ao6P2s`(uj*cEIzxfvNr#$zcnhXX=iy~LWJ3?@mZ-UcAA!x@#fd!)AO3Mm_? z;v~BYiRtQ`Q4=0)fN^lwf4uGwT0TmF$gnk zw^XT5`Mv{OfYH!~b&y3H0PDyjCVb?B^UbcB%|R@Z|Fbn?s6tf2Kj`TzeMMG;P=6?9x$E_tkLXpwrf8orDuP*&%*;%wT?N_%>CsT*Q2fGop7H>P-iJe( z*XzQwt%izlK*g`cboS7`WI$BdkV=7-6@6OmIP1>3+sww+&8VI|zU!Ea)@^7>_?B1W zg7vY`p>p|y{F!gQR#p0g`wO9bKj~=1{R(K+7An7{@~7$>mQ=+SK{(y54KWtSk!Ma% zcjmj;Gqlcl(r7_IQr0W&6mouW)?EJUc}v7Sd+`{g0+5~7?sN0bEItb`Yqt0Gkq2jj z66yUOJh$EpFv~R@6&-#MHKM_Vt=r6095WbC8Y{uuLl>(j9w>oHAu(Jqd*xaNzIZt+ zhLL;8U~c0C6qDd^nA8PsOQ4+Jd1*3m;cqj>t?JlHR9%bln26>x#ehHWUPAk+Yu@uy zst-b(g>Ijdpy%eJWA=U4Yd*8JA3IFHi1!SAH_+z`ljZ_S)K{tVl^UFK~S1HnaR zT`p7h)YFKC-T!}Ks;C|DH;4J~vHg?{4CuqPe>Fw2v7P_;-2e5Y4z`@HzCZ#*$+|ry zBFh5myl6@jzifsc@3V`HF&$fkvStnhvt`=*@^va+Y{K7`XPsr~R@YIUa!}g(zKC&6 z?xMmEOxDrHD|*7H*XoNjEx}IJ!NI}U*x1c&c)|w4UcmsY7@%JWT0L)|ivfR%M(B3u zrv8(c>1x zknX~h6m?-rzU|cF z>ePBf!MmYf<3+_6H(O@9169nZ2G>h+*$%~tV%q>df+!`f5yaQS*Z`fq>zgGp+SI4a z2ibT3!Ty~B>U-Gwh1M&;ClW4%F-+WtNFw$cN3IH3V}m9TalnV~Ox+r3$83gKD2dMd zv7rK4cq=SW37F?RDNz4|86oP`wsJG|grc!d5uy-iZ$MZp9<($f`$dorMnW(H-)8(c z5|+m441nz?Z>?7MI{17dY@#G8;KMY~X+dxP)88CivStYD4}DQG>uPQN%JS>-;w0{F z4#}TKMtPx;KUp7v@XDCKq+8>_jm(27Nd9ZaAqNmYbvR+T>H^F}My5%oh;!KT$7STh zlq5580GL#Vw19oCX(aFf@OU?Rv+n_)zx4Z7!P$^S3q6|7Gp)N%dCebtXtUA7eTrud z{nb}Q6q26i-C2}MX*K}IfjN-~zwMqGdClE;foy($vj(SM_ynm98jX!fptydy@bd0V z$KyN=djYDZmdCRx%rW(`dStPC{oU`+oNR45c@@+?yEfxip8|7w+lp!ZUrikTjd!j@yo@p}XXVUsiCgOt-tjRBAAst_dTNPdbcT6YDOMhzI0(V6qJ6IBW!3 z(UJ!7Du!Q?Bx?gUL+sB9B*$N58Q}BPX8aOsR-x}UtE;?ftclm$hg>$1XYVgcl`u}) zjabd#00{S2AM#(7OqtUua}Z4D9IM<#Y4@T842!|FnIQ~mJy$gXy!z9zjmZ~4&IHzt z^UGVzY{uTzBtIr>(0wF~3fug^X)LLWUG%Sh(CKR$5g?3vHz(L!ipl08bDxXwq7pB2 zT!rnQy5}GFJ5M%;RpAA(59T3&6&TWTEB-yWlsstGL!WR6#+~5ZPNNlHNnfi2u_Ojis7&P zr1ffEr~-u3g(dBVcd?(-VXMuLTiM!%UaH3N{ba!H^}KXwMRG#E+nkTi3x%3+&zzh5 zl?(%imoR2CE^a!(`TBX3$PY&uKB$_;B?R7)ovH{06r6G4(8f+qf2GUtfpRGfn?tFV zgiydrBfW_q^2aGLhxb2zDwM#jafzx92DA*g=bb@@S0?QaD=F-J7>g-=&|QzfHc0D{ zBU5B1`R#xEF87z8Qyb$%=GjB~LQmp)OnR^my)CUYVr53RIehk|Q8>=CdTn|g-n@w} zL4?jGW$fV}u`&Tpt(qLO+b%1PRsD~B1vH)dE?!! zK?7#h&T;b<&??xpxXV7R%_ux-FrN_9x(cp_I?w~H5 zVm?*G9LnA^N92QhK20?!iYVlW154NxBBegC1x{u4GpyDt@Wg~9(8u?pnZDDoWDu{! z8Lnq&#g9%iIUYtfl=`|~4lMb8xP3ZS1ub#lNlV~R$BdE9xH)mO!x#b-M&q}CrK@H0sRUx- z075TJ6C#tA{PY&`l;)#OZ@}LBvXD)U(` zVREA=p>bO>q7zMUdD-p0)kOh?Wu(O<{L5McK^AweafX7%jMjqZ;yBo z+-4@`CZkZI_qmT}@OI=yt^R^IN<5a`kHMKjw$nxFVC`_-%7i&ppIe zb_6;G_wlV3X3H+syyczVovhZsEdf7=lV5TR9&hM#@!4mZF|;&qg1==cF7BL1Z^Oi} zp{egAKt6nVA~yu9K)R@jT6qYa96#&;ae|vwRQ$ehe{?U?$8{;sdq<%^=Mek%@A$s% zunyu86E@y!P(YQ4q$RC)rt?qGC5V$m{zggJ*>dUs`i-rIW>Jlq@O`56?uH@yH`=F< zj8-Dl@;~2G^;P-`=|y(=zc2QmKN_}Yb!XP!-HGa0rPCdQOB*D^ZJ&%!XHL9Cq-wu z{G-*aM$@KJ7*(!B5WhFFy(M?f2coxunLqN4GhvG&NU6>~heF5u+vo5Gmc{Dbx6Z3< zSpi3m1Fbm+A%`yhYMM^Z1hkH?As*Ke|1z;W-#Rgbcpczsv1vUCF3 zFL0ba4jsoGUxN?4VYP(TyHD1(-*b3>SSrQ+Rv?Zr`+R(Pf`hj24&~1TLUnuj=)U@D zyko7Hs)<*;MA%YkJ;pp*#uu;H-Tu!Tf$*U`f6vsH>8U>x{?7C3WWh3qUrc)5;hruR zF4a+sP5aklmlh7@l`Ca00U>r+XmMq;^8TL6r2$&rEy=8kJu8SCTGE6G~-v}wY6Q&4`wQC*C}Aa z4=4iwh+?Pvo<=F$ARFPMpp`yekA{t<4e8DTvTXf!-A`76d}G2~US002Q!(G`V-#gf z?8J&klyn*h)uX{kbfEn!jtsue^V0{twwx@HVkj8-aL+FCM3>=cXVsKEp)_<~^Yx4- z=OaPVFGMl4a20712Vc!N3-1#=OB8j78M2CzRWMgVS2j3}6c@?5R&K5x9Nj{r4s7Zy zAjuxfuIu_ibN)%Yp^MkWdfHgB`dhSLoF2EyN3|R{Ou7wJ^mv4*ky68q%aSZrCvU^8{Q zF_^#94ZrR2nJYBUBkNc+ zt>#LjOxM8eJsy%Ifwg1meO1%-TQLoUX74sB1ZYC)G|j54O*yv4c2q6}%Y?L`d5qIL z>HBaH{fQ-P%y3=;gwM*#>ej7BhnEBp$_H69u@I3aa6&Bqx+!Nr#`UvnM180-E~}yf zsh5aLA^;~7u#)cv~BtX4~$B-w6+^=D;o(Th%j8_8nb5audjABPj8&n4D=N(XyQT%ubYkh38;~s6A)dS5PZ4_Cs|NtY`)BLnl|-`a#yiK8@e#>&mZFG z2WxJ1rg_ZSog^OF%yyW>^hKE*ilk56!Jk9-Ypb{Zjt5geKUa1PxHb8D+Qat#kqsqXNo}m zL6zoiVpQ$m;6Gi6muDM$*Hf+d+kIXF1 z-eYUD2`Ob|ksJp{9=7VRuu`26PVc`q;?SY~2Js}&-GyE?ATM~0)$U*1e-dBrZ^|Pj zIE}nfV#CY&dPh%|e(4o{DsdrOa9uuWTRs$PE}N1ZI`%XzJJHQNK1obG!d)F3ZRZi8 zZ4+2};i~ES16_H%>V~lvhPdh#}k_hc7T# z;M3|CsH`)Fx81vff%>te#QqdJYck+IGz;EAep@SN?Ye*Rg9&D>2;@Gj?T9I4SxvM( zQ5#*ySVH~UmwJDGTjb!xgy2xW@4LW5d|nO09Z&o3zfKZq5AjyVRzr-V2_|S{zUe$L zkPG-w5J33}5?#+Z9UNrHVj3O1EO+NQD!D{xkXkn(BH5z>)!Pe(b$lhE#@0r78kf3M z&Qj?5e}VFzu0`>3AkJx!YNz5k-s_>J*_a9AHeo&qAgM8!st!JMpWQlX(c=1X(iuOQ zH|J?qdGaiC3H~}xED85a65J*n9-phdv^}&L){*Vm#>n;iewG>=HrChR=cB!S{r#Yf zqRV+P9fPy`K7XquvJ#cAF1Zz&ijKkkobF{3x@pOvQPIbdRReBT;(C`F zkB1@Kv;*@gzf9J0;xM;KrF|viZ)kGZ$tQlQVYK_iRc6l+x|WliqtY|^G)D3B_RkNi zOnvSlop|14_^!+hNdf&w`WNv$R*&UA>oGkM3)dRtNb?V^|R?qNGb3PutzTsg!d#?aQ<|7w{q)i43hdv6YnS1VQZ~GST zFn!*6OOwtsg^_4RGEF62FB>#6D=d078#l+G8+?4rbbXCC?}WE+zbvnZ+*Hp5%g~F*idLL z-erDzQ0!}>RdZ7rz3_(31iArg6y8EypKYSxls)x1G*L4a5n{|*xRR+|Y!bS2*M1Ju zd3kCm-{9km7qB$~iG>9RzeYpFdDW)V)ingG>%0$gbxqz%_kcDHG!oq>n*u=KDERg3 z2OMND370A!vhIZd;X*#8x@QiP3~Mp1Hq|f4c+Sr1bsn9KXI8Ba#6V!L z^OED?HChTD#qEB6;%xD2zyDVG{qi~w;K=1^0O1SJMs9MzVb`c=j#}U2^4S;$`$q1^ zC@t>HbR;&=N!7c&i#=aJ686jFZMn|)_5~r2i#Z;b3CPaZ7d&|cgbF!N&$=G-7mw;$ zy@D?M*PEC}8Q*l(eePUGLcg8Uw;)W@Rh9_G89Nk41-tH3PR}Ttdhq=lVb77{2_-0} zU>-+Vecl%Ng4$bu7Ll%Uw0m?fV4)l?)08_sBJa?{GoA&mit6<*F+$9HeJ^`NEKpd4G6l_i^} z5iX#~QzshM>kGvo*dn4OQ9B))qvw=(1z&`bU8SBiN4z|De$!y&<J-46DIv~L?{vJd%6XK)L01-Am~I9Jer2(+G!^G<4ZDt`X_30>j@QTLw)+~}y@ z#yrl9P5FgNH|+ecX}A`zu@W-sn2=}EnW8Glrsxn0`kQD`k}y096(05a_*vMAqBwoc z=vx6RelPTY?;xVEk;nQrM@MWm2oW+w)2XCi{**kV(nk*Q^c263MFCaw-sl=ffrh-d zc)c+X5Q1nytyQcGm`@<*)?sSTO>Rk$-a0FGjU`t0Eo^U)CbqJ+76W@ky@IYCL_7Xj zmLiKZAqi;e`XadDtdAk`KBMXSY{QtnM?J6Yp`#~b-*(um#Y?s`&Vtu0C(&&L-RET3 z&({l}SI(aX`Z7p8^q9!hBYg|@BqK3kT}DN@}`g&T|H$ z7r5Qet!n5K&TX$8+GH-S@#LM<-_}>HOE`%Z?2J2j=@dTrvU!RT^}F=|74U(*Q4WO? z2u6Rk9!^S;Awdw;Vq|!}h78XYtQcE}`eI8c{$$ch9vg(Wp;HKi$%)tM`xy=k<281? z_W+R(<#++eUpE8=AAxYB%9}$Bzta=jjN+9DwFjui6op@^$M1C4(sa(a9yZ2&scWY? z@_)2|yI4L&%iD5teTtIEnuiiCTq7FcHo*Tr*UnW}cO;BK7HA5=GHGLLQG4TV#%#!h zl`#wn3pUPaIiyL5ki>^A@q*%asltXS&L6#m*Ix*Tuxyc++};2#cKvd-(1wD$6?UE@#KN<&_DY+Po-}E61Gnzi&zH7hBPqXp6;O z`nZ-#0Ye-$s%99wYk{Rd-@ktkVQ6-Cwl7NsbXQ(^sc-$)`${H6(Blh({Vg{)C`Sqg z%b+c&m0;Mgl7bW#sJy@_1L!pJ8N~f-xu+G_xEjF6aYHLj=Y-RB#0AVV{q6T?YB=t0 zi2ax)w6Bbcs$IS_`atx{1IM5n6`?y447Ml7hPsWwmI5!Of$!h>AwBZP8KQFQy@MQf zR&}V+*GqF2S7=<2b+)ov$(^z9=X!0YDMMuJO}a|xZ*EX9U{NyoL0BWFe{M5?@9LfF zG$Ad2{l|4cY>rFjg)PVMGPb)*(8KNjkczqpq$Fl83-lg2j-X%?4&q1pX$83AkGH$XBQ* zRJEM-14DG&$k{NWv6$)|PxG8$=Onu&H2!2(q|(c0U!^- z1g>&v+plo~^=bC)kpbi(#M@X76yL|S+Pl68CwtQluEObh^1WpA-T3z1R#~2z6CaV^ zhRVCow?uxaEKhjG>K_TQ%{Iv6d|OM`dV+IyIjG%I9yHAzwksj#N$n{_ULI+kp2bLDP~Fi?zy7ml0O08QuV3Xo;7T&1SZ6b0~8_KheSh z=w#pe`gpHj2cc$Y#ZBntpOyFk?WT)s3`FD-V1}UzH)9uL<81gh>S4bP_3E-;K-YZQ z^Z1wS=_dPyH5yT$z7h4fR0<7HA(N*rO{FONq(i?{Ec?XlP(N<)(7O+2dKwYBzY=kr z%BA>WZZD=bgSzedOz|DS46%;svd z(KT3XJTV?xqAOT_d-XndOPYOm;wPonQan4}9ubyJ@F?%{yT1yr^%wn2b4czw{xJ78 z6?)FIVa3@ayOp}J`&wFX`soA_aB2~kt&SL8q8|KW+6(igfgDs&-qLX;=;}EJaNLgJ)*1!xX{zxnc6eV^W-6KY+rGmWG? z139q`m|?De$4OWyVYN-3DeI2eq-24JrJY&_1||CSzT?q^6Rx4(Jkdw1(wcxrad z)aLr=1lb@JUVJyEpJ$nqn>$?eumx0}0m5*DGY%|#5cb2v#C6CNvdcz>3Fn37U&VKg z1;hH;BKsTQ{N4Hjb&*mlQk|7FOzwptxv!)ljL9fxNu~hPhb{m*KiuRmgrsJkcl%~T zO;7sX+YU%8svtxl&VMl!FY+~NB_kt-9V#eiWc2}MaGKZnNIj=!mvChY`lt}R*fV-J!^E9LQFK~3ES78$?9w42Kk9F&yTuifG81T zSLE1Q`Jg34ad3R-vR}C``p}I$V1uLAW41#X&*9{tgI|F;44Ubj zHe577yik<8)JUdqZ$Nt*7n;&!WT+sQ*K$krjXI96WKKU2m!J!ahj|B~Fydi|LLopA zynp0THIlEE4Hbs=&k(W7M_$OQt$5WCA#*gkR(K#LT79sJ#ZJ|i8Z}VU*ZVAdqe$KA z!i5VE5a1c-LUaNh9}A16l@)4QTCml5=b=Dy2BN_}tCWl+sUC$XFz=0b<0$;(crs}{ zk4r&{Xnpm$&%onSxSsGfDbj*OS$Np@u0S57q%iGXa5(K!M=LUu)_Z+hxk#1lZOHMV zZ{gd513+PCCG?TD`jXGm{@Sp?$GD5-QiLZNivoA54cNawrgb}M(!a5cQNSfP2K-J) z#GRd;$HyKpOa=aade#znx5VuTU>yLGN{>-onQ#1*`z7f(n@p&*s<;>xwmfJ^f?gs} z^ec4ulz8z#er}leRO1nv%Yd-x6|mUd&+3Cj2*gpI81bud&Ulz;_eusW`IIic*a~mjsvj9 z#5SfEBgJwpoLnF3K7G24Bs;GTYuyEjz1>x~6+r4>b`MTBE)EX&vibxQl!2lZEYq{A z9sAFf@h5bsd1aAHkV&8%+0gMqvj~J+z-^ca`>bt^hv_b?s?1oFfTh#K&G*x^q(X-? zFjf4@%>-p20hbIeJKmJO<1J1{7StxT9D3GtAeAG)yPw=OXD^fhK$fNDGCX*pbOJqE(fpiK;4I&Q$x{@eHOtKfS8UdP+p+en!B(IZf~cLJ^b_4PuTw4w)j zd`kY3DZo~d&rm&zWd4)+tCvZ&l#QPTn*8g!&k-u9qL>YCejxtzf*FlLw#?NQIUV=-3af@F`)Tddo4skz27Y8@pY#rvP7c(vm3ZH=w7%|awbMMPPzBOhU+clE>iJb~rY0EBfA@a%TC!wCF&kCk+i+N@t9knr!zuM{T@ zG^rtoa(>qC7q4C|a6k<&3R8>|3176!)69pYsMuv>r*MI0R zmNfg*6Q-{&K~>u6^}C~%X10#;MO36@3Z07pu;bJ2eB?k$*}729;{$nL;Vnpj``5g* zU}%=Ky@C>R7d;_?JFUiP1eM6D^rMUv$6+Yv1H;SH>0!fa=mQZ{ty3{yr}8hNK(bSF z^U}XaFXqD=Jgi9~OlNdEWo(V5UCKHXr!YV=1z|>=ea;v0l1xyg{cU9dwBZHOCV64C zMzNt!*vvS8V10zb4}_-aPd>P6-(H*Be;hZocUlOqi7+Qg7hp}F%S%nc7P>hRzE7X{ z^I}okp-%6H&WBHut+yE}q@mPH$6{Cz6PpVTpQ{ckbL+vuz04BKfqq-%8o-D=Clg)@ z_)w&9Mk%DI0s>;d{SF?KW;WdL?P$nk3l5N&ksJM1hgoQ8A%YSU6(#fw3XLYb5pK(V zsGKEJr0B1O#Bqk4vMAs|_C@I2A-Y19k3YeFVZy*wwOUeq1E@rHE9b__>JKjVzP4d6 zCJJhPu?=370#umSIEHq4T8XtXWtpVppo|}0b>M8cT}i0+MprrVcBz(j>!`G2#k)_! zzJu+9d0*8Nb>&1mE63czhVE8NF^Z7iK3S=-5>{2M9`~~3;=$e$+=zvI8 ztD{_1%0vf(S2Q8sgNrhmv3-MFY@1Rcq=tzd-Hy4 zWR&qlYWrZE*?l@+`>S0)f9bfM?>~RF)qgl~l8Jtyp~Ho&Smm0a?I)8vN6YH=5oR~o zEBTAXI-e#OuPJ}z_}MHxGw3@#m3#2@>YGHPbo3%=ZP)6MGN)SCZ<{q_i&a;@J*$e* zkGXaAQ$?CIouA2ZBMJ&&fu|`75;2{H6@MJm0!Te-=@eBjVGUMNieMQy3ONX=ev(N$ z&tJ4&k9vr4)v8D{8P)R1^l3N-fjZ|ae;}z#Hk+St&%d0u zyN2cTdM)8czd0EXE_>L{q)PT|a2PvZc8z&Dq!n_xZhD85>-=p()9)p856Hg9Zl!@@p4`T0*dM%12YpwK!UJeTaP zwOs&g)MpYy?CQK`cpGCc(!SyMofZZT@BPqJKUO_8u(3z9e@ElE&Qk}Ku}<-L1=M^{ zuGvTf@sOMYq4j!v<(&Dw$rRs)O>c386bGMCoHs|K2KRTb5Ab*mNNf(@uv6u$h=hP- z9;BT31OA>)R3qLiNQ9q_A9pe-)Gq_@>rNiNj)jJ zalRzcH-n?Ae^&BKTk-lYBKk@@(-D4G=gU=z$VY)9VH|Wt$F_T~-zPtAwg_#f0Pn{D zjHg1we4c@rX@xRM8Tzg=Y24^Pp%>2q3fCw!2J5eYGoMNX$wMOtVSXVA96rwP?G~H#rCv;r2v1JfRn_4 z-9yqUfmWr%3C=8f#q|-GKATF+Bp*OPd%Tq3oB#O%-Ls82*&-uTZsdbmAeNIi!>*$FvXnM^e#9E(}1}Wh?Y0` zz%&*vh)RyXMcc!XM|;ctjx^QIu=;~){*M*M(7tpwtdOCVojteW3x5upymVfd{Jbx3 z(0NG&p~CFt>2#)3KWY?J>pj5o3Y6&#E(rJo{~Zdv=P}6xOF%?c%YG5UO@pG>6p9;- zr$( z06kYoAse~R;)ZH> zMmIKoSn|ha-a%YsZ{K>yNYZ4;WJWX)9!)>LEPey0e@b}f;?8yBQmIY8sr1v?$Nt(xBcW~ z<5r@o@CWqY^b_PqM@JVv$OB#R1n0OCqk74Df`j_bI~$q~O)IJZx>`Bv@)X%;yf_4C^YoZC;M z9^Z@@pb>j&4!HM|a;N|$jM&b5aKo|`D2&|OUcQyj?a*X4# zAiv6|G_(RS*b+eP9}z<3uzsi-`VY)R{zk+FZz zi1p2J3Xk20lM~IstW#mXn!(m954uI(briknd*{EG=TsvdQ;#T*=s zBYgjH6A~!ZQq!J+=LPM>w_77qEXWKk;ym1ze-wdTZ%{KhwU7k7_*T9=tmY<06w{AK#x?UV&%{$od0y67Fo}O>9E*s%qwo=umt7e?8 zj{HE|b%xlq2jnUjjy*fOQ zF^*x|PBpXURO*EI|{d3E0UJOp?Wb`lvLrnEQWMcQs#z3Xr6;84dGOzEL`0~cSP z$Zl)rlJ+wga3}2jlw#E$v*Ra=?a!yiS019XH&DbM{9!2L?LZ-L_V`W@E7Ewj0v&-L z`QKo)7vIG6JRS|mAu}vkJ63jgKl%&gzb`-qjtP_0t>nv;Nb)4n3u?AY3S9Bm5*3-J zHUyfK8NB<7vHHWUq!~z)<2izG+GbPtf8<nO3GTSaSKo&Q=KwR^??O%wNv`Th@krUoehrkNgs=Xm?rKq~Jib;{L7uL-4on&37 z9*kHo=B->23%DSanA(&_C(O~lD!loFQnom6wOSehqvkRn`}%cUbnXdIqHfypV}EvY9DdtS#ffuAV!X*7o2G6<9=@WR^o-eCh?gAN{ zM-~>8G&Hck>AW_QUdj1?b58&S=8~ichn_zjZl)Z%4R=h%>z6~1Ok7hwu4Hu$X%QCX zbUEdV-m?APqFrDw>pBO`Vdbn%Ct+gg=%&smm$-Y_^wUF`{jO41=ZDUuOXZh;t=VcB zQ}vv_zKy)ygh9&f~&$u%nLQY?g>FB7|mk}UxuUWWm zKmo(vZFTxpZa%vfH6A^Nv}a%oby&{8Jg2MrQAj~{-G^R>7XHOioY_^oE|vCoIU2Dq z2X@1)7oI$L==||#l@aB(P3i9Iyl?n9SEK44emtaZtm*fI<-j9AdD`yPQfe=`exGAF zanQM0oAN|eR~M-(@%r^^u-eM%3$XV(pB;w8lSGmDY5=0vs+BjW_$zU_f&QYSTlSEs zm99Y zoHR#k&V=1P-4}b$Q-( zZ~lMSdJk}_-~WFc*+ij`5E4a*LN-kaiR@YS-Xk2F5+PYxNs(-_kCigBlD+qK?1SSR zzbAdZ-|y#tUH|vx@~*BcSLc4c?)!c|AL~i5y4X9O`}OMptT2cZ0nda9MDj$uV}4;* z*I1QQ*#AeTew7Aa;!Qbc>n^gqMBhWsFgbS1b%b*F7gZ0v_+!-HK316Qdcyw$lI(7O z7Uf9x4|9o2MZ7qiKli;$>D!B`NB2TcPB!8we|D)~OrDEv`p*zE&vyFzhSu_p^RzAe z8Eebz9DJOry-h1o-?yGUO;-ljZsNBCm_T*{`(6cXe=DKx zgaqg@F&&`sDe$k!|9xjA_q-2#OffB&(?3{@w8&mP<)hSZlBxsTmHAMczh+~70SDDD zV-{1$4^-{^rZuRYyrr9{U23NE8s^7ujTDU`hE3<`$A$FwQ#KTM6fdRJ)^>aBTO3|-JKy>KnHY2bE}=dRY0)8F03OTwFVnMSLan<54#VHX?H-i;lp6np^?DOr%#k#z z(T%5d{$XUx$k_9(KZCO-nQbk%oept=OKZ8d&+S+?V%{FaFa^0(M@7D)Hzt11K>7UY zokr%f>sXw5F-BUr3b%i{UAi{S?@8xT;h;;~%c|xL&wcTvZ7jTh{75sT<(G{y@`I&k z8L$WQ^erl3DS~_Fs@fJ=ZCv%XYW6eGCD)P%mv;bw9;s2e{zNl&r}4c_UMh>@xO202 z^bLD!%;LCI+4467YaDN(nVWak=Js@BUE!UzM9V9+PhIy4fn6#oEj^|IDT#xP$Tw28 zG`%^BC{J7w$|}GO3CER7=3ATnT%C{jD(w>=zZ5#hX!Vo^v)?xga%Pw_>L;s?kdEtN z4++TG{I>>;kyl+i)%%kZlYfqxn?AwLMafl+joEBTt1Qpv5Qpg#d(M8O|1+a&dEt(h z3U{XaWC1TGtE?9`Y|ZQ`jGjpfoR{NchDU#gD}6jwF^gq8)aoAj?p(tgZSvzhX2=?N z+sBa|2eZ%SmdDGy>;k6O$PfIk&DA_Q=sW#(SwlzNS`7+(#DsseO$BEHRm!%6dzTAs zA=}Ziac&gLU(bW)CiwMd?c~k5In`{Kl$WD!b?M9knv>%-fdW$3OFHS(KYH;PYosKI zHyMPBL4Sn&{c6zEZs;9V!#LItm4_2E9^wMBD_QO0ka*&<(3J+%__A|lQm0_+$&JfL z9gs25DSUfvz>sTqNmd!T{nzen>hsqE-DJ(i#fN>49nfxoZH+yO`^j}Rum0gvC9$xz z4T@X6%o65P$Gg1NY_K+odS32kozx;#2;?v9B5xBvU#I1FOVmA=clUP8RkzHl>K0MzIsN;eWllI=@D62Y6598C&#C@eUX64( zbds}TNGv^2dCpT7gBq#Wny3$ee271mC6o*Gi4RIJz%JbacoO=r=dTqlx6{k(?S5NI)z=u;TT4hmV=~Tr)r#a59wOiETO^Ql zjOxv%q}rw>FR3%ip;Y zx@)?x*#JTe`qa%Gmdjm#be;X|Q?$!Jf7Cs8UzAwdxi>nWtoiB6Qb(l)Z7{)|!(&c{ zse?k!_dGm3_CYr+Dp7aub>BII4Zoc}bo)>Jk>yY>h3$$5CoHz9x@PP%vCS*KWfw0b z$HxmKJO)x_N}|zU^jF+Zc;z>e(ahPieW9=SPE0h5-jPn zAvvwlr&(LscmR($KfeS%wJUt;5>#To=eQTvvQ4?P(r%WyRwTF3vA550x1^-6s#O9` zd@t|Z)e^~om(?})otUSuG6OJ*<0CJS2=!%WoHkPSiqr261=-~M)dKG)lT@$%@)ut4 z9GKXEQvIE$)<_a1cM+@7)l2_%oq07c+|IWSnsU8A=wF^(IvOyaYwJ6ZX+P+xA}+G%n}qqxTZ(CoBjivKaF z9rD7Q7x`)+xWd>d;o}jL@X)}1AK?}-9g?Vb7ma{VzPv0NbvSzZttZcdT~0C9a0kY< zkv=wRUroM;yI444kX~)eLcwYC;{X@vmosGKRr$xH`Fu6|@Z96oGD6*(rz+x1_CnL* z5u%*_|8?aFh$wQ$96V}h)}1>4e)W6rwom08uUXZr7kJTz;eSLlRU&cc&Km&`ZMy9W8{BoZUtXi`G>14RyObO78}a z>YXFOGWJ1sW?tfWa8~XQ#g0mK+5M_Un?Y?i7M*-*pa{X*!|};vCpOI-A^IiYf7bzu zX0hZvjp5>-xiW*$C1f#s_moznREL!Vyo(ODxw1VobHAZedY_q?m<$7n24A2OfDvP> zuAbaXKAG#a&z;vjE`*TTp5 zvLmV{#b768qHpysJ*xe4{X_?=q&lLdLxYP|S86nb9or8}J(nJQMudh( zE3(|$k+!~pv{E&-(u=YwYGhdy(sk3#e43S`&T|YM-&H>{-baTw3kIB+E8$qtiUkH! z`Fq4!6HmYLTmtc5TAZ4lT-RQ?_ox7)U+ebe2 zgy~OapEyk0t z1@WwHMUAr(%lO=7=w~#>d+^qLS*^8Yu;>SHD5cXv2Nq z=mPE){=HuwelL{(K!`t5?b?oV{?+4VR=HH=@_H|{BtX2ASTTDwa&(}pHr^{MbJ=QJ z)y#QsrENsECa(uR9p9_lJ%{+c70J0_cuQ?chjl;}jfg8l+1Pv8rBA~h zcw7Y7I**lF9{zky8yy`D;VKZk?HU**k;S50SULYKNHhE3+`wm}Dfy>+3RtZ(b)cgb zx7iqHJSeMtA-97$h}@ZT<$P#Ra2%2;<^rI4>!f}2wK>=BL;FW0Cp|wHY89hM4c*_c zzWD`XbUV>IhnPNEp{ZR$Go4p1xZyd*VdtcQ64c7^d8nnO1=0#Q5}lm3vQ#r6KWcat z6g{_noRa>%%^`O~yh*e4kpO->2R`Lsl*NRK~EQ+C~wuLMp zReC3OO~_7}!jNwq_4qH(7oM7;9#3X|Ujr)>wY9qq=C7D`ksZRuud934s!gh^O)otR z=_#{u^8b8O**PP8qPIvAlhDrup;X_;mK=cJVRCYkhLNbfXhob^qjq%TOVB`T z$8z+dCbtq91#4dleVrjXezA6`Ge$Kb{QPM0=qb5Hic)BW;jh-$%f8k(=796`7>YPv zOBFIeIvJ@#5*63ZHC@Rc>27;H9M1d`=h9HPwJR$=U?$93Wuh?Mm#fp(+Io$L$I8+Y zS{8dyV8Gy{>|S!W_9W@}=Vm13r?SS^OxLcbzrwnTOjG^lGS5ufh^Q6>9dXKDleDte zflF^2NyBLlGKViuiSn}*2ng4yg)7cKw^MHHfBZ`)YjCu76!(~`p(gk7$ZeX=F;{gA zmV`tFuMJ-x44gszDIx4$BhLvW;Hl!f+E3&H{wSm$sjm7wr#}6@Q~e96zC9S<5zdSrheOaiO%w zHktNMHiUGhNLt;zpdgzF(*|o^&a?&%-?n;DmiJh5^W%M9UyT7F^pH^Jg0q37NsD@I zKXZRC2tq|fz}}*r-TUVz^P69xnE!=(j@K6eDVb1g@(nMn=0fGhy36$BsIz0ty94cp z^;#RzUH)b5&f%eSLyg`f@42wWND+@w_r0O0y8hwDqpDH__yGPH<=)L%{Oou;LG>A- zI=w%5p1yGWulrnoop=W^AEs@lwmRbgTffefyCI@I9nv_TZ6J0GhWFP0CT}ac6H6NQ zr21?1t+V`@f=oG!r&TwscXaX;B#a;D7ntFkZl+YP=Il!H{Fy zOYs@WgWo4zeY3G3b^mR)_1@l*zXn^e`bWN;PzWmY+}#Top0l#b4dH6AbeoFZ*^O;! z7Ki0d*gX&vK@^J(&HjUhRR(X}!C5=BdKV8sBV$wk#mW#&cgV-VQ<~zn*GW=S zZqKv4|EskWMWMj*MDLO>>bse~v#D^EP^Z=@I3{$sup+LE_Xy&DZUTmP$#=5u3N;>{ zS*!eASv8YQTAsJ8O7bVs3dctMtHk&hADbb}LTMH$cFWm>8RY|VKR6yaH19_DWu<;s zl5}c3Tnk+O&X1p+BJwLx2RkwFb1ga(CYRS^c~s8VpXf6dWU0!`} zrGHvih|X*O3F%*cOGE$vz`mmR)}eq1)SSUE@%z@RFTbmzl#i?lFa2wGOMX)qfiaix z9M)QOYAi!M~DWtW7 zjU!NOKd9hfQ1I!RwMz&^dj4)w-k!ZLK3>}5apY>*njo6>8fX57qOp3s<`;JB(Hz(C zS5}0fIf#kY)?yQdqhD!Ik8Mtkb@KmO(@0^fE#hKMpf*K>b!v&?BY&h+G+mGe3qgtn^u=?ezIh_mLG0e zXkU*(R9z3gp!w=Q3#b{(EO4);@qs|)*XkPx*`LXd*NX2ayX$%CANXUFOLWfu71c%Ev<$z>0%m;oGk<=uo%Y_=jy`T$G683kzK~Kv1|HjTte}dSR17dZ#%X zK(=wzcK77AgUfa`6>FGBaCe9F(8^HPIeX%#u<4KH)zQpI$vTROVG#znh?P<#AjQe# zOZ#Wo3iDR^UZr!PUk1FRlkgA&Q6Bx?^_}a3jQA2*;@0=b{Tx~%0mzp4pG1qG>%LZ! zWKq)^=5|9OP;L*q13BR1os?9ap#=E_mGTW+)Hqxov)h%v)6X#6Z-w7t8HNSd7a2iB*1QV zl46nROSGOH+gy@f@3AsWn`Yh3^Znd6`(5>)HHg03pZegH$?vC#_H%N<{61+^^l7LJ zHgPt6sGOTLRX&NmlZncwsC87o`pv#R%mC9ZlMkL{rOn)&Q#svjs(q*cr~!c>g9XiGK<5V z+11knu#54n4qY5zUBCZfU1{VKf*LDBMo0H-Ld2yQ<=ZVuAU)ALYSg`POj^o9IFT_J zOqviG&*j$j?^OEo9L;&;RK2l`T6jHLGDsKJj+TCKI?2FUOwY_Lvu&0prtRqEG+_ao zbMK>Rxs~3+1uEsaujn5G8vZ03&(!APc7NM6bg$T$m1L@m&{g8aLT^V3BqvM6(RVpa zOPMp=l@2`@D9U}s?yg_ou{pb$i`>}(s=j}|ol0Y;^E%T9}c!JXn&ajw4{92|g0PH)USI;Ai=_~mIaw@jAX2OhPK0XOwUTX$83 z6`W11jC0MAM=7zC;O*4$a?O^ut9^^*&K!U!UcY|*KF9n`9y-m~RiQwsBfd;mK1(~w zATu&t(rm;_>zgO%P$C-Y5rSBg>5aV&c{G&HdeyDlsXC)bJgL-!g%9ZV@zIHLlj_wg zi1n@h{=u5Uw}}TB8~6=c*nSC30tSF4b#!N|q>%o&#iFNM#XKO)^_ItZB9bP5=e|`2 zkWiC%);#{6KnHVzzfI>KI`)|PRNN-nbzfc0U%FXLK5_Jf2vJd$g+wBYiW~ty2D}3b zgv-?S4oA^v<)cm{a>o~-1ip1dX1?@t%>iYJ%<>NB6*>8+9Sm)M>bd@;HWu+AX>j}t zFu;)5%QW&=q7{%4k46VwTwQl}c7o*CHbM!ETF+vJsy<}vtI}d{+5zICsS?e7bzCLI zNFr|A&oz0bkgMp2M@+>c0bzn>-b5TCGI&0*)caLuJUcbOXmWe^R}EARAqREZ zO_V?3#gg#0LG+%?rS&gpwNwusAB~E61)lIoOdO#PuKPTCRi|P=V*-brd&%?|i{(q* zApMJ?HuI`=i&a$KKKE!Gz!VJ6LddrS3{L_{|1KDD{$OS zJr}|30P598UqBc0qivZT$HnoE-Nk^*1#^tXx)bcJojv%b-~VIvmfj^DynI38Vyg}d z$+s~!nZY3;AS!PVP*A4gbusJj?#8$LoSz?I#IUg>XZYuA)yDOmb4R(Ccf(^|(pye= zXid5N5(u-R0;kn|vls5}Sg zl!>sTr>Xc-w$7uIO;S8P6fr=c;U3N59@!cS<-05bREK`{2fan>?W4X{k(re3YCxp1 z`J@C5hZ^A8C5jmI%3#mfm=wG##e65b*;3CF1(54s z4$Cp(TSr)M?fv1?IPAL>rp#p8`oLzy2&yCRcEqT=`3&@w>FMd< zxj9pZRYrJhzlP$>AOSi{Ep!OjCNN=d){e>G9W5sgDU@XQGxF>>oT1(#U|I9?3OCmbdfJzBjw)gfJUF+W#L(011Jklxs@MUn zE1<|8JkmRH6r0Exrd2Pu+uqdkb30PHtKJ_&D{B2GFV@Cvcfu>3EsB3=7=xZ)ZgO22 zz&~36D>h>527ZVL;b?-X7a-mOU7cA+(&EYrsKh0UZXWBYM@GCL!5if4K#{LOA~K|- zA4|jkSqK;zyBD2dQtwxTedKfQ53EXy{|BiG;2#zjckqZ`kF$I>S9kZ4Qcm~T);uQ_ z&o715nVDvej(?6*2^x&kKu5Q$~#G4Cl9dy@m;dJ&lY9ITK*7UOHz5gYjt9c5&@MeGVDe2idT3Xtv zY)j(WasDxD3s4wBi~y*kxZ{qPlT8LyE9;u{`*}NT^PRl%qgwe4K1znd!pmIb&F|N7 zk@Ngj6>O!R0B`L6fi_dQlTSg*{*Fs&YK&)1S$zvMn+aHPT1+S<0!Jpjh@P}W!NrrR z;rJ+i>pjQQy01CgJgDI=t@QLkG834Bb1yn}DyG(cV zyvpe8?pE=xgHO_cYD3;qMQic9A!Fke)w%I{;Q?mZ0Qv15&vw+(antwjrY~TU(3g= zU){9NE(#=E-%Oo)!mXyGQw0q_j4@8*;Nd56f)6;HOQE~_?Hd1Xb1OE8&-V>60}(TM z&_${yo8_jO^^^@OD;uD@?1pD55heSunF0}vr`LnsX8Q5%qXP;e*X)$)J8U>#OXe?=7JBwW}^T|I^%$x+jrOMIN zQKuFnCcnBm^es|FU|vk)1{lR0T@v}Ke%xDajLnknWeF_5pq=Y_RfY4*+6runSKFN3 z1)s+(sN^$*ZUxAHGEB{*K5WZwoqJtSJ~na&+PV10pK>DKncozXkboJp_h$bc09b<< zj~mbq6U;C+2zaM|$R1BQ`17d8O?Uv6{n<|6s@iDBc{iOTJ6^~1hk@++JR>@vVx$)M zeo4()2p(f1^=6gc+)ra1M>X`0kw`yLS6A=r?Ul7<0WFXq9{-xuQ%zsuw@uUC<~J`= zfr7t|5`n)Sl|v)@4W_5;DfE1An}yU|Fh<#ysnX4&F))l6)Q5@FahHy2&T5&izl)@D zrTV!O`6oS+a6L{(P2(p2ADrk41NvT*cemS>(LpPEa}hWuEG?H-S3!_VcdvdV_ugUT z=d~3XJU15?G7dvHGIiOW*4lk<9JhUAk)_Z_?A`b`N?xtj;|{K$sjZ01#qVgpRZV|| z9_6@ojzSx+4Zy>TQ-r0%t6Uv#^|p5Z#%Uzf?BHkm)fU&|6YPO5V^wFQhrm638j~oB z|M)3uE|6q;lOA7(DE#uv%{!3?L{&cNYXP~v$JWPV!^_G=CMHn9fh<_}{Z#r@eHmgv zt!F4f6V*sN>o*INw6B5H5j5zxK#p`Qzk@`C((t?N^^C8!W43%s{*NC~2)Ff_atmH4 zyCOIKfz;bZf|LTCbqAxTdm#=~e0o3GQ^vO5YBqIV$_wH3^*>v8Dy4argp1xrq~vRG zd~>(G_?og2S^uXI{Oa!yI@JSpJJ$8@X#f;=YSB-l|$w@BnubsuyGntxF_5_-= zd#pYQzu!noaQITVv76t}v*T@(D|milywvt2&hviYv5J{X>>pgqHMAXHsxe7kO+&vMnE1!a8sze>Y|!hdM*oky=~<1OWcJ_!?T?02L2;ZoYu zYrb{|dTfi=XAkcgKddL6Fh~~k$}_UQs-Bjyr1;o%pL4K!_Vff{pZU$3iu|^COs=lQ z{!4DJCtO_QeS1(1`AUbZKH9dJs<;m8Vg2F1e_?Ke1znvWeqIRVb*Q{o&z5xD{4Fvr z*%a=hZ?zxMo(H=~yiJsP{nWEDl-&&Bx(yO9c%zeNufCI7O%Uvg)anj@=zmB#aisQB z*opdZs4Pw4LT+zd0iLhTAs5|?Sf zcym{9W7~9j>?zKN{SVtoQRj6OYb2C#vu*CDW{qrxL#>$eYJm@%Yg@sCMN^1rh^n6| zHTJ!CZg?HJKl&h?hgGQhDBfdddVUq#^!xaCn6E}XtdwG$pkOwd_&r7ZC6UG$kTb7GU_U z$qUzwmE8$d1+(_Pfk}C8&aD?y{dG$;3SB?e9kqkm!lz2_iuE#k9N%aL1@d%Pg+C?A`vN zaA&B^TB`LIb^EB)EX=;6(we%5k5n{z!7q0=uJ4w*y5o`?6cf-ERaaL-;o(}vyh%zo z!q|CTpx`#mYXQpiC)~W}-waxpYUk?ysbM_vl0hUc2$#-~$|!=F1dRbdZD5GR{`Dyn zXMOG(R;BLaAk93i(1(Ujb??=d&}H{3-0Pyn+Bd~!*)?O?5=oB=vz)Iar>OsMycGY~ z$L_aVd!*Z>D^|-baja1w>E(N)@5m2j%qU4qZEU&6YX0@*)B?o%XP1U(;peOp7JBGF zm-gvcG^aDJ>0=VX?owize@{wXp zAXd6`%u=|b#;%V&u7ja9OUuiNA3lhQi-Wn$Q1BP~So$pUe(KacysJ?Vu+=aLQ{JJ5 z8!SEAix`*0_VzZ~ZJsRvFt8k`zRY0>X&$u9+i}lpo_b=yvzHcCpr;C`6i2r6@EgcqG*FU&fEdboBx z{|Pq~H*#qoEk1nWJ~2HrQ<1zEkhl~)yV7!2<-K&$)O`>!t6)q!|MH6%Y#9G?(No<~ zEv@0y?&*qsP^P~>P|8oj(ejY@oWkemqV77LMyvsgAzyDE$|=lZ=gdugsr^5nCbt{u zMMY3U20Bvie=0ZRhcm_p$|Pn7Hs8Nkw!1wH|JzdCRje(v^ZHYYxKEt#foO$kD|Gj2 z6l9%)_rC}buG7)tw)2Dp3q@MpnEwlojBnzUn(~PHTAY}QTybXmc+h_C#C#sF~fhRPxm2R*kJx_5V7HyVW?VaOTs zPTbt6si>f!Jqc4baa^vx40>aFKLMi%cu?8BQ2fEbukn)FeH>c6c56&iopfQ5RMRb1 z1s>B#wd>4cZwSw|Ymialx;9b)bt1IyEO35VhoggC)=f!RmtIayL`ghj~p$O@=I3k zJfgjIRliJAxJ2^kO}K$`mr5%=I*2ApGt_hOJt}X7dZ;e0^W}N7aj7iT>|Ux6jx(gq zmz@tA>8-O_Me=?8qiYgVi|jDD=_?jy`$Bo4X1Xzlu*&37(gTgoO?| zFouZ(yHc*53(8fnxzbGj@IH6fC;kEX9Xua_=UC$%i-ZC1<%*^eIEo<73q-jv<{@j0 z>8e=On|E=^`gCfwxBr$}&d35W>jT^!w(8B>#!z64yAj$+BJkN;wd@w!Cj3HzsQ7P@oz+me!!V3dW` z23tBRRa$g!qV=ukn79L~H5y$zyubAKo7LR08|Ek=XCKsuo&qu*o4qW{xsK=dhiV-}rTjZ7bndUo83fH2nM zoih8yZdNGoo7w1aYPze0&)BzACk@Hvya|)_*vyMeJ}(T80$p~d=QiCs!otI0vcZRp z*TOCHi%}v$Y`#9;)|<5FBn1l-$McVKFHVxEz`%Bf;^1ALz<-`qhjQtGclxb7=hcg z@N0PR$8L99TU)R&FD=2OpZE_S1QXlo?)kl;>AZD`t`5T2)1HtL71SFFZ1^$|(-)qE zu3t;ZVuWp^lv#Z2cn;{fgF70NTV#qdxf81JeD<%%*axJPhG}?M2kAt1htiF7vDz^f zN6#0N-2A@3#0{iyIt{wLFAQFddcPeqrm__zK}kIzYqPUugiAFC@~37pJXY@TejE7E z)cz4shV4_SR~TpRRISoq|BICA$E_iIkuoT+`eN+uZ%bNku#s|Wll$sW65no*bisUD zu=i1XRrpO051_WH>}b3!<^5~D>d-guPo343r|MLw{PB5Vh3}ERs~C0hw{at@N4nHk zAJ4c}w-ZqYLhKckxa@0z^OxwzVX0OV0&;cA{*$1Vr3O%WY9~E;&`-tRc=eu_!fYlx zh_1g^MT$=R@SSJ?7eyl}EIg3Iki6sU=Ef?GK<4JIEoFE<_g08@NW0I|&ZFE5Z(jtQhG$FX{S+oQ=yz={pt#t(; zFaGHn6n!WrMD_hKJ7n%tvNSv>=GtvaPVI|_FrV(_>a6Hgx}lokJ4#D&U-dQetv}8* ze^r4im!hzin{i-<|IwWPv*fuuHcik!LwqNs$>W`#8s()(G?~$Hm`U@4Pt1_JoSxTYnv}3BGh*ePN(D6Mz_Bc&o#;Ad*S|qpW*)f`+)`zaMjQC zxhe6z9yHX|+KBPdR_%@sK{SoobqUED!H)TiPqZjZr52}+<75KeDKlpOH!ExB%q zv>3lLil3|St1pCeB{GPKKp|A8B2upQ`Z-b8SvL3%(@j?cSuM% z4WQCGC48f%jtCFG8uMsf9)juIsR`bWD^xz9lU#oT)-M74`sanv@^Vn`A%a+q4k7WN zf6-ZB6AZVsCDy`;dVFz^GWnjZ>tX$IVR{e4!&SRewz2GF(%9vJKXPM6>ed`%KjwPg zD`~I~ZDshZcF)8#DN=l|ef-ie8@}DmMpS1)B?G*C>#XA}&*hNuXkD~bTO*f^j>nGp z-z7+_Qg^9q+p7*cT6l!Qji;7zp!SeqsYRHWYQzvTgXqfj=_WZl_dmO1=^r5Q4PHFO z$B)7H$B-WCma27?5+Au6wrkK|*76gvsk9F{3d_sO$|(}JAwS2pivJc>EM2l*{aHoR z(qy@vM1}QAZE}HUS1_d{K@fycT)ef1^rbmg(pTt4Ub4v>`RufsUBKP9te7@o`{wc~ zo3M~DDqmv>n08=V46aM|KTuV+n+DXcp7{WQWp_TlD5q)0yqyxoD3EMM)~}ob91Nz| zNeC$7J=fJDtjjUv4m?7_36a?DT9uq(;ofa8FY=sF>k$xD~eKWvYb7MZ3>*YE=0_?SL&_RdJ_l?CTVhPd+IhAEtHTBxLK=2hXj2z3mDG z11KhUjgbcgr+x|UN4@_eSdj#J1E4sKpJeRoOm%R0X2dAM7P zc`>Ws@_e*<-nn5$5^v4U_7Lm==6T7NBdVDbtL->8c#iw-sq}+(vVS=nZ>}Uf{-*W~ zcKi1A_>`2Cni{Ev^P!%9tsIi3tikuhcnH@Pfghd&=^Zj|ozzYe3wJ}U1rad27HU4$ zgq(wB!B>+N`fuM;T^`VcC>{*9h7pX~4FZv9(!j#D*-t07Y?z8bq7HWNlK>jXgv7rm zdA*NS=`0i{Z5|ljL=U?eo&3})VGUEtU4u)$4`lH|yutNOz4pgq_XE?G?vU>Z6ZKVE)JlMh1eV`Qjc8AqqFo_;hCSqxafDPhtdq(`W`})Ku zA+}kF%_fE8;oj>jLTaQwu@$l>`4h$Fpb9YZUH;-Y_nR@;sCm8r6B13=bhU%{zEs0s zgJyXOEi){_M}Xlnfv5mvA-uZFe(5EC8ZkHg+m}wPKk`;+=iDG_1u>{ByXQQ)hAyly;(y5A}`?!29CbT(YEclL}@%_)1X1W7w)K zqKFVgkN!h0a!;%! zGG}rWDof#|y&m{q5hQQGCkyjv!XhG4#ocTT4L_EZq2V2eV(tt{c})%Ch!O~`@bGX5 zh|bN;O-@dR?i1XTKXKKcabU!GtC|$vU8X-y zzZQl!`H3Rwt1^ggc=L<=m)%vO;*M(N&C4`o?uvLQx(RRm9CoXgZ;+KQ6TS52|CYSX zbnL=mqYv63nkPC9&s3}QsiK~PJ^4zS>J{!IncI5q!Q^+yybh-$nWbgrzo)^7Hgiu$}hrxaS%q1;!X=ZXF z;>FSMbmpZeDjFJbPqRLAFFmbx=9B}x1=N-&lSq^@nHU&E5r=yqNup+v7=Zt}x}MbX z!fkfE@Q~?LU5y343R-h0^%*GjEt58q+*~QxAdD%N_MfhI%GZ|bLio#S{ls>da0b^c zG_e3+LmdoFtkgRKlA?(fh$4jB?8}$gnU?6wN2fE`xp@P-UqfBcZ4T(lnLTLHWRTC} zzmZd%Kr6mKg9@uyA|QmS+@sEK#a>iKPW&PjH>CT(1euIQnM+TmJPMk_%n2CT5%P7V zQ++1#XSc&2+P3U-){{-(><)K+->MJ{kWM9V&%>q3%^v7BFbo9fz5I$qSXLH9kjujQ zHu#>C!Wq>)g7r0{i0jEKDcKpRa1u^DE$5yzjx8!!WE_U-_qc75e$b11JGRB{wD8Pe zo_M?^91-^`pOtdkTFUOcNu9|?>d1n0O}*QCt#_4yvFipwx%GWKXf%%V7t_;UKq&j7 zv;Sv$RM8Ava+EK=K&;705-{j=oSl!8&!8O8li-RAf~FgaS@2m?UKcVg(kZ&>!(F=-C zNUrJRs%wJ7+O(LlHSH=*b(ZmXL}ZBe206Zh7LRi1N4vX@3LX4pvF*q~w=JG(gPO0o z7Y>bFjY=1{F8eBL(bk%8O8-oV7zj`tBS}P7HTe%WUQZnrr`(mvJTI&zJsJ2;%I(af ziRm`8_Vta~V$-5?152UbkQ5WG(xRmjzkd0QZ5)2b8$0qu`Nd&Fa0~^@*w(zdC}|=c z+(og?`L0Q6qMdmH;p(Fa*2B~MnBD51K|j8E_~jwq($+!ud9?bvqqVhlw92ilq~tU; zb%Tf;ev)E6si9FHAq-n8(VpxEU%VWy&O`<{xlKOawOQ{D@v5N6<7702v$}S2)CHrq zP(OjT6xzsRmgd3*n$Ck+#tRq3Al(Vb1Q76_WFf&&IWT;D`Ek`>HA%4)kC~|F(Tyw`g_`hpz zdb{0caewo$)MO@2zH?(`)4B(<$>Ez=Qdo7tt9S@A`Zrj1)y$Hl4uN<~l$gU*d`CW& z2agk$QRA~aGy7{tF03^sDR73aux=#CjEJl|lo!pPa{RmY-R(HIjmR`|Z zhapFF60n@Gu+&FazcOv>+>}bg8{6?GAzX(hecao^=S1m03bj2<7X}-V0D~^h?y?X0 zeY|KLQ}!shgdjk7c}GsBc{VV^;@#BqZWWRLJB!!p=Fii75vC)*K{@SM0r6BVKLqJ7 zg9P@HxRZAq=Sl=d6Nfv$M$9m^xJPU3?_by66{pF&Q`w>7%V zPT&)#miAhDP(e3^R<=%JJ=U4DFF1;q3N&iZug0-;Q z3=(fAmh*zpf%4pjQLpYY$Io{oEAvlY*L_r8;?aPIOKBF^w3Wt4+tvg`>`I%8wjz?j z+OXLu<0YobSCDsPTNKlg$cpdvySQtmep-;v?Kn^-sdy5a?a*33tF5Nzu}%V#Hr^u@b!;wqr&~IDE>o(s^g;Q@3EI)Qsyfh#u>|{)H)06Yt0T z=9lerjv3}kg`|QVq=ML&)ng|-NkIKa7&6|0S78bHreID_!xvy>_|8X}5IoR*_d48O z_RIka37@{QllpUB98%OjA5F74rKw3EpEeOrb2Y^J>E$*$FT!T^A4KnH$Q+-@vZ<67 zoNH-(Jj5o$ZTD)TdZAwTO}eI!=)JJNnY{C>PEfS>8?2Q-G7kBRG8If%P4BE8ap-OB zaRZt46-$MT7k17*nZrn-=$5}>M~JpA4xM0@!LB1;`#a1|!u$l~4%ZH+M^iw;_Dz+4 z&HCW3R{xaEonfBk+qvby@yEu-Li}EOH-s`weu>|Eayi&!FiXhK#*1Yl+25iL4qdf| z-%s99zrza%ckzgsXPCm}diaguE#>m>2x2PFs5n@jJd`N<90vnw+Y?-CCx^cY;QRUKEYqO@Cgk26UHS2r- zOigWNq@uX6a07}|&;>xLWpG3M(t;`W5#Jf-9jBjUzbz&U}1t zacNX9S17AdoTwD~%+Xo_+Ep_UAKc(K58V{71r0MLf%K>tNb80zMh2_A+`SVPUFo`>V>Dx9%f}{{yWt4>64sH zhB4hb1?8zuu6$WCfmigVQ)dh~xsAITBQM66%d}{J#mkNzL~|3Ze~8D+N;CA^t4<@7 zm|Tzg{F(VYsno9mu8NE1=ojfl5@+C-4 zRN9)5Q1{V~q)I{(vj~0R1^u}k9J;24BgK!eGlf2(a=iNt?%Nb&e=8SIkEOYwkgKeG z2^1ED5gvyyXy^LLe2o9`cqWqIeBDfp5RwVp*~Z&+jCuFOeN6J%LPPC_fyEPfJAJ4GkQ+l2*YM zFT6rArwwx*X-prdJ*m@9Rw7ZXpF8uD%Jez>Vdy6a7un~Wk0a#r9j-m7;?SV+EZ!n% zakl>Lv4Tyhf?`qr6xYkd7%PO^OzY9wp1L6Oa#sx5@*7?Q0{2eMiQ(bo;-QA+w-OZy zjCB6(m-4$C&)P+5^-GN?=&xA93LQI$=>jpaZ9kWwhQ0^%` zbuP8(p2H(e%MzXjn`_fHD21`SQ)+gJiCfs3i9y^&5sX_ybS3pze(+!|2!bYXP#MtO zo7|hP;5Nhu>`E>zQZCIN$k{ItqG$ZRX*!KN6d8J@j`Ze}Ujk3a$y-EuK4bvi`<+;| zf+5&X)=FVNXy!i$&v8;>Fdg~b@z66_25Fbi=drWCW;y>Xo^_*}?!t^drHBD6nAR!T z^mhY>6S+Y&>|wHl7buCLxOJbLlbkeP?8US z)=7y3RxT{*@W#$6s|9;=u(QKLx0&L)He==!^Sx^JTZblVWsC%wJ*%flIz|OZwI@|E42B&`Z(S-t*)s=+o+ZEa**_V94K>6lE^zWC|@+FMvI%x@_a1Yg--=na%iHM4<9=x za$gx$xO8+84_S`))!@umyuh|A*@25u@KM0x{t7SH@?+9#JYp5a&%C^^-U;x;JYx4{ zJ1=t4S0Gv_s$MuTJOcy*&4>R`57LQwD({VoI|g2>185yJCip6&t|85-SZD0surU6|*88baXRkBF<#E{^2`n9M=X$EXTd^*-lPXMaNkA zV8s?uXCA$TxQDpftogT+XOhc&^BmIoFRhK2`*MNAI!>%WuYrQp9EQQ@=R;BwyY@U< zp(Pg18i4eJB^!)c1fPT=)vn}6f>t!FfQBlLee0E>44q1wanTvZA{hP(xwj*{FpCAq z6#TH}GZMd=9iMjfGyQxN=p}Lavj7}Y-Y6eOxt&a>g?C@=TC*SJV1!Y@jg}Bf)}Vl-dJ8}VAsz$RwdPWRZ;blN zcWO+^W1`K6eFZj4_47k0e_u*VlkyeQj8ouMIrhJj7QyAf(?zz!b zg5&NLEV<#?tDBJqEl|X_JL9>fi0mKEZR(>UBG>H31U5=sUWnwgR(N2Wi*?@OY#uC& z2rD6+(l-;CV#WtVJ+^`!G%+PE>B@FuNZ?J6#fwm}!wvxS2cG6}D2DUGF-Qu^`M#bW zr+p1d!R0LOmp>Y@?Z8TTBAw&B!_c`Q(SF5LbFTf z+LKL8lFXQhrP15l+%XVB4wGvPKZU!CkSc?PP*C+1Wez(kEYpButIj)$u+ulLUOoHV z%>r{Lg0MM32Ff44j0*e7a0cBu18F-Z86WdSUO6zpAmy}3?neTI5x#^EXE@}2@6vF< zuweUr|Ks5hlns%hjrJk_^Q2uk05*OnqPF-K}OEL9>6rRqT zGU8oycz5bxOv7WVA5C|SSwQatfMJ^{!e@a;x!=c#PdslB*HM^i8rNxJiB3S0PUtn@ zmdi9vOAKso@3(eD56&9aj*JX~cyy*Y3S5EHO%ZN@=s`;I_VzY#c4#z6X5rsKihqWZ z*yIxezpF0l;Y`5z2Vp6T;cX_*Sxcv1iAf`z>Kl0#Q{FxQcw^7yLaA5YC2)rW(gZ&d ze#FV3Cu)vLlg(-v8$fQzzy&|=YADT|-etR>w7{?k|2l?s^?0OQuyNQ-2RRc>h(N*< zLjKRNMUdC;!!DrJ($hEznI}J>Yj_2tj(-bKCQzJ`4Tf=H30kLE*&g2*xgw+5eG_81 zU;l)D9zyEoz~DK(K^h=PKF#l+eOp|XPc#;vhPm?6Wk%6IY&J#1tLIzQ&Lo|gXMB3T z{=ut3S*amwVYrW-++cu3CQ~( z=24-OF@CGVmTkr)v)PgTeasXED@uJ3%NSz3>Sw?TNd- z4zjXoy$2pr70*q_T_VVy34Yj26uTYC1d-nvL4D>vxjy0|3io1}cjA$`4q+E zJ=6)I^kc7NNkBT)a`zlrZQXNdMwE2+svjt#q)qkz35%?JY_vd4G)k+kdHg4{FET_B zw~5vanoYenXIcPehnUcSRFl1G<9qO03qFy8!=jkTL*(%oVb9I$7f^Tg6(H+;Yrh%u zVp7Kl3{FfmWUCN4%14h3rwWo&U{#C!|6SjNyD%$0D2>)=jq;&^(Pbke-RDot1!OL4 z;=LghaJUj(J3)WWG@UbO9T6Kw66^q1O_^`wtUqz^w$eH^{&=~7P7$|{pw`mAJvI6xJe0F&TYD&p#iyuB(>2(-jzYuU(jd{qS`|>GK z63@NRD$|}3AL_=u-(&W6IfBa&CVSHjT!Cxj(6Kg~CZX;7h-C383~9Urk*%Dl;Iu#_ z+4)Dh4=H$G^6+|zP%6rLGe{DlitnWTzxK{N9LhHA|1)D7V;egqW*Cf}QkG;HOJiSB zA;oACCD|&99?MuujUm~0X|q=-Sq7o(ODKd;(PC+#RKHWt`#kUQzVGk&{r@|T*U>SD zqnerfzV7?JuJbz2@A>^;TlJm4pSK`JsN!$*+o7n_8D$#bxHA!io&ZD3CJGe;@|>I*ou9J zY`wscdKcufWB%3mhd17TShgqn{tugT zKu785>tFf!Dtg~dRNA=zG_M7=fnB75GiGD}5%SY&k|(*8wiR<y{y9;vs%2cBdhOi%#yKV(+V;x{9T@tFt^`{30?#Q0?ba`EgF(#nQEX zcAKXEhUe)=eyLTZLmfq@O=gY%B%bvSo@*&TcO*&a*>!~KZp*(G!k~RKxrT4g{RWLk zS$8L(T?7R5Ao>22v{cr%M%%c9|9cP~3O!)c4sZ+zr$AN*SyCuO$MQ*iTwIJAYpis- zYd4uSf1N2JHf9?;CQDn5%B_-Ar5N2Sttb$4%UZT0Ez6 zRw24F`uIel)yGpI25pu`Vvn5C_HmFzk`W06p#l(ybUY!%L3K3JT!Xr?BPCgRH#QiB znBz9irl>LdE@P?uGuliy>2R)(`KS2lFU#J3R4zNHEcBVLL=)WSAmw=k@oH`D>$AXP z7JDzUE|uZuUmZB%dH>}r-KE(VEMs%j4`BNbdTZO=5j!(r&H^7677pH_;bD-1mO}Ob zauFcufM}`w?p@Yzr@?Oz6jR-vN`0p@L=)arO6DkcmOAF!VHI{SqKV62on24-j86V% zY)Jn61^)b*UAAYV#$>rD{)bd{JPS}S10%h^8aLp^&T(AwBJ_ky*6iw*pc6QedJbo@Lwx|pDu@fEsd~k?Eltz@Pfhm z!Y$i|^9vEzez;AJFQsesUYtM8lJz*`Yvlb;umL!C8epElN7x)7`{s8+524rv#T;~Vn_x{AZWOA`!?)W0DaRZJb( z@5#Y#<^fym>ni#_oE_A(&R}z2V7+G^FdHZ5O0R9knsU-KHP~ zkNKXZg45h_@ymJhCe!778FW?sKO?(wc7*5Zfg?FeEXexX>y^hun&|u^a{<;i(Ec2< z0?u0S=sgPF>>^rR1f}(rRZtrPS`r%NKF;0(X}XV(9#kerN7KM7DVh{b#W2hex*%2h#_KZhMdV) zao3_xHlMcASM}!|(1VCOnQv^tv2{bZYCqAr_HDq|dGI~Hb8EeZp#s{6m5b_xF14b7 zVj^ax&k&)fJprm?G44oG%zd1rVFoEB7`%go^ZFLQI0&)vhv7X`hG;@+6;?E*R&$hl zqk4rn;|A%5L`N%oh?hC59Oz~3h&Th4_q;K$L1P77Ny*~+o6kOSG8Gz8yfiVL7fhD< zk#Qwt#y7fqLbE%zM~?L}I=01c(!9Z$5pW>wYOE$#zX)YbQ`53ca1D*Lq?mvDtyvHJOAynjCtCWp}VmLU{ zD2o(&gO>yZ;ZnQL-UawI&K8Qia4k9k(wD100YF!XK>&ZUYv;~wQ{JFHK6!F?*!O$D zqJ1<0R`G^=2cf(!*mhy637Iby3^F^|oo=cuVO;?ms<)x_CzZ2M!FSzoQv(qt;=uxJ zz1$%=2o7G({wkH<^$m zNx5ngx+W`gancHm&w7ZEWlSPkMvaLwuQA4Hh(-^i18nT#m6J%nr&Dl$k9Jjh$?el? za}Rpwp7QcK?x}xQ&3C#!^hh%o{Ios4X;Mr3z#f^4f8;<%h{1GQ0b+Py$6bCwQ$Y&tPF!erj=5^Ww21{xs#+_ZBc-9u zf*-oD0b=0X^YZvum!R;-Z;xkh0*=*x|H({kNi02{-5xt$(*Djo>Xn%(Dv2uH`Q^Zj zOb0);Ea4B`#&30a$?LvLZ7GO{tH-!CxF{+#6%eh1ZJ(XZ7ayF+Jpi;wNa&qeaD;&d zSPU$GFdst~#HP~>C?B9}-r*K8fzerQly8T)`h^Qw@T`fZnzo;~{vh3K;!h1yElPJ5SptU6x6!iGQB>X<54*Oc9FP? z$F@qFT3rN}&zRW_hWl98BMzyMf{pL;Pfv%2zO=r2>gNlY%*L-B^QZM5t}k9c(KsH| z-IA&Qdu{$uji%PUoHLSJ{mxvqi&89qA`qle>8IQl65jTsS9VE`KhlWwnKgq$PN|ue z0=rYvF7ybnNaFr-?kgAiV@P$j&J`vRSnwdgg1G?nz%L;ZGd4E9aN!4d#WlMFOH6Fn z41*Zn{yo}|wE|R`__(D|V3sP2aQRPQ>rU3ZoIb;E{+L{4XBY{9sRcjnsbTvbdAeN5zCTMHZl95z7K~B>@#RWMIpsrW2*lGyDfk*B=Yo! zPmhB97hnRPtMe{Shdr3^{k9&uJ)H#*$I=}~U&VDK|B2Ggi^s^yrt2OqhrGxiK8Syj zkP4{bs}(u5E$ZWI}{p@Kxs}tcrrJvjDvkUSXz$m^~QzJD8t+;xi8ituyH(MLI=Agi6KQAVS z57iTX7&sl0qW4;0s!lM+Bigox{ti%G4no4*?18^*WdGyV#Qc6Ns?zAydH0S;AYN)i$z&m`xVNF!A}9-L=F6B2=R3oZ7-*TC z5@8SHQi1Y(Bh^I2F+5_>PW~kex1ja(scaBY>Ou|l#C@inDj*reJpjP~_IG!N`;=#M z+Hg*2hrTJkk}0rzhoJOhl6JS*YXx1bRvl9?rYY~~h4Yc`ww2f>sysR0{%-Jf&?~~8 zqOUevewyt~TWO2jSbNjPsT!Pt61KPz8>hsHy?r2!K=K$P>9Gk^#aiJzefOm)%kAz! z4T*X>cKcEklnWFB2=seb3y8^`keUUdw4S!Mwx%Y?nP4WGPq?DL07%riXuR!Z_InVl2N*7xv|H4H%ppmF=dY7&>vOd{ggMWpop{z)t@3G-JeQ%xgeg1Ghz?U{DuZI>{C#(Fdz8VJn~{yclbPY#x#5q zgyb6BEJnzw@@I3>r`rwvm{xeocyxdF3D+ZR24!`M7$N9EaDRL8d%j>V@tV~tflkD*dig&92^SzLG{@p zAG|$F=y^mfeo6F7 zLdvBrw5wdX2d!vYnp`2Qak|1M9P>{^mqQ0HMX95c!+Ra)r=}-Br-hEG`mabm^3pbQ z5|KJrxla!zMk-EX&7{YhtPvEIBfOhtb;Kl3cxS{|Vo21w3EvpXrprMW(kwv|GLP$f z#NP5iV;fOOK+c&hgj4bQY*_SAIcZ8#CUh9{$$9#s40j#UI_WU|B|}|G8H1!7_Q9n)uaIL2f%A24HC zwO>T91w^}S%Q1|j%iY$0#QKX5$HcFP;;^_gv>A!*Q)mOGi?&aGhx`v|4@rwSa?bBd zLQ>#tNVyUGCvWGM#Q%!dbq|xnEjajCaL1scUOuy0Ekyqbv5%Y&eREl_qVR*BnRNwMcShO|Gn$y^~sj7m8t8b@kl@o5qzE$ry1=j?32NL|N z7ys)>)w85tI(Lm6K)NnyQ0b`Lyqhn1+>NxoQ;y-Y!DN~+lDn6#588KXj;>=AdS;HD(h$EqNjM-&GmXqF+-vR)ne~01XaBVpieoF2QrvVO5`5Y%O@ThcGZM|QrOIw zoF}iLRIpPrwe2_?tWe~0c*B#6OhtwT`xe?Rqtj6-KnR`o6MGWdv$}ISlN2W%*UuI2 zJryr4gyFYVb$qcjAaOhC6R#BBRLO_yw}$jN*!k6G$p1!;O5Edq&-DxQN+@o!r}y{a zgv$j2O;`-qMX}jEJRy>@EGjf9^LX_XiI983Hj3C!KAg%As}EuRA;7-*=^oc1MDmil3N<|*??!5%+$>*le3qaK%7^+iPrR|w$~FbXmJ8`DA(5Cxmo^oEFz zS?MKRX?2KD|HRKD>yDSOHuJdUr6-(dt27$SNTw`Pb(1m-pGl-tbm^Et#B-WUNOjDjJ~a8+fTN7nUubVp(4@1DgntKG7DJS@JC9}_ z<0z(txj^&$dGWXDaY8fJf9*!!_V>V51Fcj+XS0H}$!@DBgt0>u2l=80_~AG?4tL}0 z&)76fXl^`DYDjEQikal4hehnr6T+yN^oj}snxd1X*bl20BANX~MgeboXUw)zr*VtO z#F$F-zcS)vi|OUu(OJt< zE&Yz_S)XHTzaIa5R#s z*oF3c8Qy2M9*kRKkp*1!0G6Q{yCgdEBOJ#%XI?PL$ciJVNO!uT`&B7{h?!#yi96U- z#*KHEdXj{G{ib{(EarE6vZPxFl%w*J^Rd4;XDyrMOlG61*sQr;CDF7Zk9j3_eIpE$ z!wI90{)neHd;8+4#}gGj608v{GqHWqNsmiwCnPH0_V_A5?w{NVu)eK9s8(a8BZ)Y4 zz&jzMS8=ncrfvf24|F6fzejRj%^iH4qGq$crnkDW*Nxb$FpVy;|CvS2&hcH~lYbnc z-956*URQgd0at_h#m?+Bq*cE=HtF6XZV<@$+OD^8C6B+1?MOh&1Bx%Av~29 zp-blz{wyD7iUVTNcE?e4 zE~mLt9;)alt2iJ2;`Aamo9{1kUM`1j-ZXX=yjc1c zXtB6Uh`+SiVuwn6`ai6~RKDEXfGe?7aO-+~b)UX!U*)rBKfkOA2H}wUl)gK@a%Sru zDO&SX58jMRzh`GGW_%m;H@>XtoZ^1^{57~_46>uP{(6{}7I;4JV|lx1$xqIV@+Yjd z1oLU9?MYet5CBM>LB`-4)Ca_7Ws8JaO`&^hr@lV#K)<^>I3=eIEv&9XZGl_ydADJ} zqj0TjUfzC>Be$ks2Gw*oK~`~DGPz#8LWR5+=wo_K;3DPhmXu5$p|9Azy(wF8#lt56ECG}@zfsaVUWKjkE3v;>LXTmaz~)5Xe%Ey*uqEC5vnL!j zwa;^`wi;Dg|Her*Z@>0(;f~yo^kXYtC@RF+aq{Y(DD2tG_n=nhfY!JE=8+(ctj}k* zXQ|7bakb)p+PRlfZDJ7R!%lR|;v{TLQ?)ZP+z`fC<%}En64V3Dwx@A#;(_ra+tBib^{wVyJJft^?U^&ew>u^ay!$g+5!lDl1@6i)l-E8 z48Ycc!zmKtZ$p8@*8I@$Y~+GmhJ>S%0xObxuO+n(J*~u2*pJ>CdDJ;=a(~e*azGi# zMn2k1{s|&LL!5$+$KqDQM}+_DX*%ypwyFY!=qt62GZ$XtPG;9fo(a)8uGVq5TFK+A zsg5H(l-8RMk%Jh~}L%l;7?BvHa!k4~A!#j~vtbg!vjRu*&5g zSH+)bX4Fm^m3Q(3Jn~^^F&#d!Q;ZuVLqJy>J^@FF5{ncw?mHSz5hMG~E(hZtbD8+u z@ZNGmJgVwUovR&kA=xSxgrlsN1dMoX*T4&HEwq75*#0|>Qm{%nzavyZfQL&+Ej`$3 z9v^ju&0tr^PDJ>I+=6I>XmFj;H_WkmKc(Qk2sP0>-VLp(N9x=xzlYZwZIU(-?XV~r z(?jUPXJj{VCkOy!Dx>~gY4}juU7YT|yy^zXw5`+cm)j>A(lXuVW0Y1=qh^Pvu)B1b za>qyCCun=wJaWid)HdVL=GZQuAGTv)C+Y54ysZju%r@Ka z_b*5vP<*NrkxfZf@+p>9m78B~tMz{tDx_r4cugw}=aDwwa!do+;~ocDzUMaAIqD`t z)*J02q)=h}NadkTS&4~HC_CeprWp5jZWdKn@;27aSi=xZL?{m{;|-fW=A6+&tA~b| z1&{rGkIK>Y4{9_CG2~hQ{(k1zEB<@uao9ZXEi|Q@2k5u3Ll_J9A}#ZG7Kksr4z4$Q zjoJl5Yz4S3?ZTg-d3#JhQk&f>Eco9~TtnjW5}hXNbVPH`E)Lg$>io5?yi^l2x2_UP zQg90TpVJLz$ZLhMiRhZ}BlbyJ#Rx#MV!c>f9)+dLN zx%4h7tK|g{jj+^d>hDOAl|wBn8>z1+8#S$#oah>q=YIbp{kxHDqx@teN@hWK z^c;zHW`6HI{QK)`#nH^2hv>=H9n8C3g_D#+{it=KN$7anCaSw4rN_6QdTx&E-RH{s zil_OkMp#xOdZc@N&FM+Lu1@#-;=#dGi%RnbLei|Gzh;lZv|(B%=o95O@~K3IUim?` zL&Z~SPUoMjU3HC`E%X~+$7DUU=vBI9h!7Nh`bKjeaa=BU+S^5tfp3>?S3|q!?{g$> z5oNkM_+4y$H9HU?BfoO!ZG89Fc9K5DF!-Ly#_`iT`DTn2D1b(ekUe`A9f^ZKWd4fQ zj!B6-`}DNF z0S*ORxXnJi+NPmDbpdD&+irW!ahhm~5Z+O?d z?4A>xm)T*g(-ps;D1?0}ZlZSi61YKJY|OQc>f*3s$6KRzCuLM{XC`|cvO}He7(F_@ zm%>IWi!|kIGwA;i7$1u5^y?x`;b4nFdmvCMeKJ1H?K?$u^8q#V-M#zI^~4roJEwQx z<-5<0E#7C56>QTR_lAp2y|Hlh zp-U){3qi;w0;Stp*D;W?#gO1W8$fl79Zrcj( z^+cSSkk0$I?7K58Y<`j*6j*Ej46+bWCsd9#ER*aX7`ATMH*WW4^}H{yhrh3$3hSep zlt7R3g4^2;I_aca$nSIA=^&pyA8D^f+Ru-)8q}Qq0V4}cPLSH(cLB38J0dubP55%0 zdLN`4uPFEI55pH6=YZ;Y-vy*Z5@O1R2k@f+suwinDd2Yj!h{`9R&T}gxd7ldLa6bz z%#H|ptQK|XK};emA4Wja1^rRQ$%KL_H{=fii*rsf_wA61KzomE0eQi+6kEHwz0ms8 z3Qpo*arxO+a`p(8&8x%v@6|EfygJX6?s#QAJ32eJPs(4_$hYj%z1lf;&cOI9wSIkB zRT(|rtt!Ush3|Bi9W{G;E7;7lsISd^@lRs=BM-LIiZj*tZXkI1DqU_Smqo@#E{gw%q^1Hc;M!^W{ME|T4TAX28wR8ILOt4V=V}&5)BmxHNj?XW*ICm1f=ZDBRBc(6(FelbCY6H zT=xhvamU48zUK<4yP|h-`L4ZF+tNv$!?TGI{yDY~68c3q*r8||s79{l;o;2BXe2t9hz;gB6Ya7Q+l=Qf{a&J_+C z7{v^|3#t&lh|0b9M3s*h22mdxSTN*>7)(^*IP?%K#^ZN zV^k2OAN$E`S7viH&tLHb7)zJZ1EmLA44`qjsWEb`X<7<3FyW<{(d}7qCs2Cg66Yp( z{b2UCKNAJop>}p5-OmgW==_(C(c;yps$(es({#a_l&e49t~|-I>iRe>cm2g4dya1L zH|s@(gBGJwB`kxO^p)&`{r3z$F1JA>^=xbOjXeLqh~ifQylLxp12csgxk0)-x_yw3 z;ARIC5)t6*8Zje2iMHZz?v`GH2Ks(4R_mL?Cz?NaSC2IvygT@goQV^q9D01De6#E$ zOFWfmc2r%=5V6_)^7}-v``)QQII~SHE@b@cgOFBwO`h;h7J=(z-%$leM2V{N=6(Z} zQ;$a2?i`Z8;*S`g;*CA}Zs2j+=F}K4{Lj=FZONORQW|rbRoG-J;~Wwf4lv)#ksz;4Ss#Bc`|$cm&7g~@W@&P~jy==VDvf>v$)9K;0&_jkkRuY^0490tR{ubX)y- zVioKH)~q@_Yl|9|O>|p1nrF5(j(Z*7-Q9V3tML7OSu!JSt#S4Z+H;oz9a7VC_`0sG zK7IaS?!Yo5?d9|LGezb7{8hCwXZcD}^S7&oCnt%$K^~zYp#6=GC5mw~E}PI(;;75` zTVbL4QbQ44HPhnmhbSbO+}?I|Zd3ay9`jy@bj~$slOoO9ss+*_xiAs#N+{v8-H0 zyD{#F|9LT4krI|g#{ETFO}1m%oPli@qUzY5iWdI#pA?XEyUgh~7`5k@nAR6j<))v} z0q9Gn>N+}n6=U5z5%bq}yxOA0qs<>*BB0QrrL2E@9m$hrQ9M(fL?$pgsd|#JMYZ(a z1GTA+I6C)3+~sCdSg*|4OcYIm$(%-`(Njt%)TemTfcdyp(sI8jKF*%+=J+Ah(BHdQx}lXBPXsjQ28Jl8uTt?ga6K zfT-M~_1_;4X_BwAzH!Mhs{cqL@5J88qcyK`zBr>0ckY2^UN_h5k7(>LPXP1;!jipR zZ#NbVr(*YDMkR#6_}GSo+a8g<4MYKd|75J0`&U-DdL7$!t%tDBM>^f+Mr|uu;g?z? zcWO@+*MZ_%ksCZOrVl?=kZa zeM<1I^hCYI8bM#BBL`RV5Irno+ob;aDi`m~FXOD(QReQUzVqC4>ij-Tl)vhnW+u6E z3d3t!d$+M)TRyCL@tL^d-ML8aEIHm&{ybDl9>VPJRTXQ}bx(5MwK=O=)W_v}&)XAJ z9iv8H8$;yfzauML$NTWnnTf080uMuzNE~eHe=k*$xK*PkWEpaP6iV#xryX7uIHB|C z@1Pf6Wsw;7zoScdEh7BqC!!-$SRqyN&%e NOTE: This is the client ID of the managed identity, not the Entra application, and is needed for the App Service to access the Cosmos DB account. - } - ], enableMonitoring ? [ - { - name: 'APPLICATIONINSIGHTS_INSTRUMENTATION_KEY' - value: applicationInsights.outputs.instrumentationKey - } - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: applicationInsights.outputs.connectionString - } - ] : []) + env: concat( + [ + { + name: 'COSMOSDB_ENDPOINT' + value: cosmosDb.outputs.endpoint + } + { + name: 'COSMOSDB_DATABASE' + value: cosmosDb.outputs.databaseName + } + { + name: 'COSMOSDB_BATCH_CONTAINER' + value: cosmosDb.outputs.containerNames.batch + } + { + name: 'COSMOSDB_FILE_CONTAINER' + value: cosmosDb.outputs.containerNames.file + } + { + name: 'COSMOSDB_LOG_CONTAINER' + value: cosmosDb.outputs.containerNames.log + } + { + name: 'AZURE_BLOB_ACCOUNT_NAME' + value: storageAccount.outputs.name + } + { + name: 'AZURE_BLOB_CONTAINER_NAME' + value: appStorageContainerName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: 'https://${aiServices.outputs.name}.openai.azure.com/' + } + { + name: 'MIGRATOR_AGENT_MODEL_DEPLOY' + value: modelDeployment.name + } + { + name: 'PICKER_AGENT_MODEL_DEPLOY' + value: modelDeployment.name + } + { + name: 'FIXER_AGENT_MODEL_DEPLOY' + value: modelDeployment.name + } + { + name: 'SEMANTIC_VERIFIER_AGENT_MODEL_DEPLOY' + value: modelDeployment.name + } + { + name: 'SYNTAX_CHECKER_AGENT_MODEL_DEPLOY' + value: modelDeployment.name + } + { + name: 'SELECTION_MODEL_DEPLOY' + value: modelDeployment.name + } + { + name: 'TERMINATION_MODEL_DEPLOY' + value: modelDeployment.name + } + { + name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' + value: modelDeployment.name + } + { + name: 'AZURE_AI_AGENT_PROJECT_NAME' + value: aiServices.outputs.project.name + } + { + name: 'AZURE_AI_AGENT_RESOURCE_GROUP_NAME' + value: resourceGroup().name + } + { + name: 'AZURE_AI_AGENT_SUBSCRIPTION_ID' + value: subscription().subscriptionId + } + { + name: 'AZURE_AI_AGENT_ENDPOINT' + value: aiServices.outputs.project.apiEndpoint + } + { + name: 'AZURE_CLIENT_ID' + value: appIdentity.outputs.clientId // NOTE: This is the client ID of the managed identity, not the Entra application, and is needed for the App Service to access the Cosmos DB account. + } + ], + enableMonitoring + ? [ + { + name: 'APPLICATIONINSIGHTS_INSTRUMENTATION_KEY' + value: applicationInsights.outputs.instrumentationKey + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.outputs.connectionString + } + ] + : [] + ) resources: { cpu: 1 memory: '2.0Gi' } - probes: enableMonitoring ? [ - { - httpGet: { - path: '/health' - port: 8000 - } - initialDelaySeconds: 3 - periodSeconds: 3 - type: 'Liveness' - } - ] : [] + probes: enableMonitoring + ? [ + { + httpGet: { + path: '/health' + port: 8000 + } + initialDelaySeconds: 3 + periodSeconds: 3 + type: 'Liveness' + } + ] + : [] } ] ingressTargetPort: 8000 ingressExternal: true - // TODO - need way to set this CORS policy after frontend container app is deployed (issue is circular dependency since frontend needs backend to be deployed first) - // corsPolicy: { - // allowedOrigins: [ - // 'https://${containerAppFrontend.outputs.fqdn}' - // ] - // } scaleSettings: { maxReplicas: enableScaling ? 3 : 1 minReplicas: 1 - rules: enableScaling ? [ - { - name: 'http-scaler' - http: { - metadata: { - concurrentRequests: 100 + rules: enableScaling + ? [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: 100 + } + } } - } - } - ] : [] + ] + : [] } tags: allTags enableTelemetry: enableTelemetry } } + +@description('The resource group the resources were deployed into.') +output resourceGroupName string = resourceGroup().name diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 374a70a..8da3b15 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -3,14 +3,16 @@ using './main.bicep' param solutionName = readEnvironmentVariable('AZURE_ENV_NAME') param location = readEnvironmentVariable('AZURE_LOCATION') -//******************************************************************************* -// Uncomment the following lines to enable the WAF-aligned configuration -//******************************************************************************* +// //******************************************************************************* +// // Uncomment the following lines to enable the WAF-aligned configuration +// //******************************************************************************* param enableMonitoring = true param enableScaling = true param enableRedundancy = true -//param secondaryLocation = 'uksouth' // TODO - test this + param enablePrivateNetworking = true param vmAdminUsername = 'JumpboxAdminUser' param vmAdminPassword = 'JumpboxAdminP@ssw0rd1234!' + +//param secondaryLocation = 'uksouth' // TODO - test this diff --git a/infra/main.waf.bicepparam b/infra/main.waf.bicepparam new file mode 100644 index 0000000..8da3b15 --- /dev/null +++ b/infra/main.waf.bicepparam @@ -0,0 +1,18 @@ +using './main.bicep' + +param solutionName = readEnvironmentVariable('AZURE_ENV_NAME') +param location = readEnvironmentVariable('AZURE_LOCATION') + +// //******************************************************************************* +// // Uncomment the following lines to enable the WAF-aligned configuration +// //******************************************************************************* + +param enableMonitoring = true +param enableScaling = true +param enableRedundancy = true + +param enablePrivateNetworking = true +param vmAdminUsername = 'JumpboxAdminUser' +param vmAdminPassword = 'JumpboxAdminP@ssw0rd1234!' + +//param secondaryLocation = 'uksouth' // TODO - test this diff --git a/infra/modules/ai-services/main.bicep b/infra/modules/ai-services/main.bicep new file mode 100644 index 0000000..47cabd1 --- /dev/null +++ b/infra/modules/ai-services/main.bicep @@ -0,0 +1,224 @@ +metadata name = 'AI Services and Project Module' +metadata description = 'This module creates an AI Services resource and an AI Foundry project within it. It supports private networking, OpenAI deployments, and role assignments.' + +@description('Required. Name of the Cognitive Services resource. Must be unique in the resource group.') +param name string + +@description('Optional. The location of the Cognitive Services resource.') +param location string = resourceGroup().location + +@description('Optional. Kind of the Cognitive Services account. Use \'Get-AzCognitiveServicesAccountSku\' to determine a valid combinations of \'kind\' and \'SKU\' for your Azure region.') +@allowed([ + 'AIServices' + 'AnomalyDetector' + 'CognitiveServices' + 'ComputerVision' + 'ContentModerator' + 'ContentSafety' + 'ConversationalLanguageUnderstanding' + 'CustomVision.Prediction' + 'CustomVision.Training' + 'Face' + 'FormRecognizer' + 'HealthInsights' + 'ImmersiveReader' + 'Internal.AllInOne' + 'LUIS' + 'LUIS.Authoring' + 'LanguageAuthoring' + 'MetricsAdvisor' + 'OpenAI' + 'Personalizer' + 'QnAMaker.v2' + 'SpeechServices' + 'TextAnalytics' + 'TextTranslation' +]) +param kind string = 'AIServices' + +@description('Optional. The SKU of the Cognitive Services account. Use \'Get-AzCognitiveServicesAccountSku\' to determine a valid combinations of \'kind\' and \'SKU\' for your Azure region.') +@allowed([ + 'S' + 'S0' + 'S1' + 'S2' + 'S3' + 'S4' + 'S5' + 'S6' + 'S7' + 'S8' +]) +param sku string = 'S0' + +@description('Required. The name of the AI Foundry project to create.') +param projectName string + +@description('Optional. The description of the AI Foundry project to create.') +param projectDescription string = projectName + +@description('Optional. The resource ID of the Log Analytics workspace to use for diagnostic settings.') +param logAnalyticsWorkspaceResourceId string? + +import { deploymentType } from 'br/public:avm/res/cognitive-services/account:0.10.2' +@description('Optional. Specifies the OpenAI deployments to create.') +param deployments deploymentType[] = [] + +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' +@description('Optional. Array of role assignments to create.') +param roleAssignments roleAssignmentType[] = [] + +@description('Optional. Values to establish private networking for the AI Services resource.') +param privateNetworking aiServicesPrivateNetworkingType? + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +module cognitiveServicesPrivateDnsZone '../privateDnsZone.bicep' = if (privateNetworking != null && empty(privateNetworking.?cogServicesPrivateDnsZoneResourceId)) { + name: take('${name}-cognitiveservices-pdns-deployment', 64) + params: { + name: 'privatelink.cognitiveservices.${toLower(environment().name) == 'azureusgovernment' ? 'azure.us' : 'azure.com'}' + //location: location + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + tags: tags + } +} + +module openAiPrivateDnsZone '../privateDnsZone.bicep' = if (privateNetworking != null && empty(privateNetworking.?openAIPrivateDnsZoneResourceId)) { + name: take('${name}-openai-pdns-deployment', 64) + params: { + name: 'privatelink.openai.${toLower(environment().name) == 'azureusgovernment' ? 'azure.us' : 'azure.com'}' + // location: location + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' + tags: tags + } +} + +var cogServicesPrivateDnsZoneResourceId = privateNetworking != null + ? (empty(privateNetworking.?cogServicesPrivateDnsZoneResourceId) + ? cognitiveServicesPrivateDnsZone.outputs.resourceId ?? '' + : privateNetworking.?cogServicesPrivateDnsZoneResourceId) + : '' +var openAIPrivateDnsZoneResourceId = privateNetworking != null + ? (empty(privateNetworking.?openAIPrivateDnsZoneResourceId) + ? openAiPrivateDnsZone.outputs.resourceId ?? '' + : privateNetworking.?openAIPrivateDnsZoneResourceId) + : '' + +module cognitiveService 'br/public:avm/res/cognitive-services/account:0.11.0' = { + name: take('${name}-aiservices-deployment', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [cognitiveServicesPrivateDnsZone, openAiPrivateDnsZone] // required due to optional flags that could change dependency + params: { + name: name + location: location + tags: tags + sku: sku + kind: kind + allowProjectManagement: true + managedIdentities: { + systemAssigned: true + } + deployments: deployments + customSubDomainName: name + disableLocalAuth: false + publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) + ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] + : [] + roleAssignments: roleAssignments + privateEndpoints: privateNetworking != null + ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: cogServicesPrivateDnsZoneResourceId + } + { + privateDnsZoneResourceId: openAIPrivateDnsZoneResourceId + } + ] + } + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] + : [] + enableTelemetry: enableTelemetry + } +} + +module aiProject 'project.bicep' = { + name: take('${name}-ai-project-${projectName}-deployment', 64) + params: { + name: projectName + desc: projectDescription + aiServicesName: cognitiveService.outputs.name + location: location + roleAssignments: roleAssignments + tags: tags + enableTelemetry: enableTelemetry + } +} + +@description('The resource group the resources were deployed into.') +output resourceGroupName string = resourceGroup().name + +@description('Name of the Cognitive Services resource.') +output name string = cognitiveService.outputs.name + +@description('Resource ID of the Cognitive Services resource.') +output resourceId string = cognitiveService.outputs.resourceId + +@description('Principal ID of the system assigned managed identity for the Cognitive Services resource. This is only available if the resource has a system assigned managed identity.') +output systemAssignedMIPrincipalId string? = cognitiveService.outputs.?systemAssignedMIPrincipalId + +@description('The endpoint of the Cognitive Services resource.') +output endpoint string = cognitiveService.outputs.endpoint + +import { aiProjectOutputType } from 'project.bicep' +@description('AI Foundry Project information.') +output project aiProjectOutputType = { + name: aiProject.name + resourceId: aiProject.outputs.resourceId + apiEndpoint: aiProject.outputs.apiEndpoint +} + +@export() +@description('A custom AVM-aligned type for a role assignment for AI Services and Project.') +type aiServicesRoleAssignmentType = { + @description('Optional. The name (as GUID) of the role assignment. If not provided, a GUID will be generated.') + name: string? + + @description('Required. The role to assign. You can provide either the role definition GUID or its fully qualified ID in the following format: \'/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11\'.') + roleDefinitionId: string + + @description('Required. The principal ID of the principal (user/group/identity) to assign the role to.') + principalId: string + + @description('Optional. The principal type of the assigned principal ID.') + principalType: ('ServicePrincipal' | 'Group' | 'User' | 'ForeignGroup' | 'Device')? +} + +@export() +@description('Values to establish private networking for resources that support createing private endpoints.') +type aiServicesPrivateNetworkingType = { + @description('Required. The Resource ID of the virtual network.') + virtualNetworkResourceId: string + + @description('Required. The Resource ID of the subnet to establish the Private Endpoint(s).') + subnetResourceId: string + + @description('Optional. The Resource ID of an existing "cognitiveservices" Private DNS Zone Resource to link to the virtual network. If not provided, a new "cognitiveservices" Private DNS Zone(s) will be created.') + cogServicesPrivateDnsZoneResourceId: string? + + @description('Optional. The Resource ID of an existing "openai" Private DNS Zone Resource to link to the virtual network. If not provided, a new "openai" Private DNS Zone(s) will be created.') + openAIPrivateDnsZoneResourceId: string? +} diff --git a/infra/modules/ai-services/project.bicep b/infra/modules/ai-services/project.bicep new file mode 100644 index 0000000..17b4475 --- /dev/null +++ b/infra/modules/ai-services/project.bicep @@ -0,0 +1,105 @@ +@description('Required. Name of the AI Services project.') +param name string + +@description('Required. The location of the Project resource.') +param location string = resourceGroup().location + +@description('Optional. The description of the AI Foundry project to create. Defaults to the project name.') +param desc string = name + +@description('Required. Name of the existing Cognitive Services resource to create the AI Foundry project in.') +param aiServicesName string + +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' +@description('Optional. Array of role assignments to create.') +param roleAssignments roleAssignmentType[] = [] + +@description('Optional. Tags to be applied to the resources.') +param tags object = {} + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +// using a few built-in roles here that makes sense for Foundry projects only +var builtInRoleNames = { + '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' + ) + 'Azure AI Developer': subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '64702f94-c441-49e6-a78b-ef80e0188fee' + ) + 'Azure AI User': subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '53ca6127-db72-4b80-b1b0-d745d6d5456d' + ) +} + +var formattedRoleAssignments = [ + for (roleAssignment, index) in (roleAssignments ?? []): union(roleAssignment, { + roleDefinitionId: builtInRoleNames[?roleAssignment.roleDefinitionIdOrName] ?? (contains( + roleAssignment.roleDefinitionIdOrName, + '/providers/Microsoft.Authorization/roleDefinitions/' + ) + ? roleAssignment.roleDefinitionIdOrName + : subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleAssignment.roleDefinitionIdOrName)) + }) +] + +resource cogServiceReference 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { + name: aiServicesName +} + +resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { + parent: cogServiceReference + name: name + tags: tags + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: desc + displayName: name + } +} + +module aiProjectRoleAssignement 'br/public:avm/ptn/authorization/resource-role-assignment:0.1.2' = [ + for (roleAssignment, i) in formattedRoleAssignments: { + name: 'avm.ptn.authorization.resource-role-assignment.${uniqueString(name, roleAssignment.roleDefinitionId, roleAssignment.principalId)}' + params: { + roleDefinitionId: roleAssignment.roleDefinitionId + principalId: roleAssignment.principalId + principalType: 'ServicePrincipal' + resourceId: aiProject.id + enableTelemetry: enableTelemetry + } + } +] + +@description('Name of the AI Foundry project.') +output name string = aiProject.name + +@description('Resource ID of the AI Foundry project.') +output resourceId string = aiProject.id + +@description('API endpoint for the AI Foundry project.') +output apiEndpoint string = aiProject.properties.endpoints['AI Foundry API'] + +@export() +@description('Output type representing AI project information.') +type aiProjectOutputType = { + @description('Required. Name of the AI project.') + name: string + + @description('Required. Resource ID of the AI project.') + resourceId: string + + @description('Required. API endpoint for the AI project.') + apiEndpoint: string +} diff --git a/infra/modules/aiFoundry.bicep b/infra/modules/aiFoundry.bicep deleted file mode 100644 index 08ab34d..0000000 --- a/infra/modules/aiFoundry.bicep +++ /dev/null @@ -1,124 +0,0 @@ -@description('The Azure region where resources will be deployed.') -param location string - -@description('Required. The name of the AI Foundry project to create.') -param projectName string - -@description('Required. The description of the AI Foundry project to create.') -param projectDescription string - -// @description('The name of the AI Foundry Hub workspace.') -// param hubName string - -// @description('The description of the AI Hub workspace.') -// param hubDescription string = hubName - -@description('The Resource Id of an existing storage account to attach to AI Foundry.') -param storageAccountResourceId string - -@description('The resource ID of the Azure Key Vault to associate with AI Foundry.') -param keyVaultResourceId string - -@description('The Resource ID of the managed identity to assign to the AI Foundry Project workspace.') -param userAssignedIdentityResourceId string - -@description('Optional. The resource ID of an existing Log Analytics workspace to associate with AI Foundry for monitoring.') -param logAnalyticsWorkspaceResourceId string? - -@description('Optional. The resource ID of an existing Application Insights resource to associate with AI Foundry for monitoring.') -param applicationInsightsResourceId string? - -@description('The name of an existing Azure Cognitive Services account.') -param aiServicesName string - -@description('Optional. Values to establish private networking for the AI Foundry resources.') -param privateNetworking machineLearningPrivateNetworkingType? - -import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' -@description('Optional. Array of role assignments to create.') -param roleAssignments roleAssignmentType[]? - -@description('Optional. Tags to be applied to the resources.') -param tags object = {} - -@description('Optional. Enable/Disable usage telemetry for module.') -param enableTelemetry bool = true - -module mlApiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?apiPrivateDnsZoneResourceId)) { - name: take('${projectName}-mlapi-pdns-deployment', 64) - params: { - name: 'privatelink.api.${toLower(environment().name) == 'azureusgovernment' ? 'ml.azure.us' : 'azureml.ms'}' - virtualNetworkLinks: [ - { - virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' - } - ] - tags: tags - enableTelemetry: enableTelemetry - } -} - -module mlNotebooksPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?notebooksPrivateDnsZoneResourceId)) { - name: take('${projectName}-mlnotebook-pdns-deployment', 64) - params: { - name: 'privatelink.notebooks.${toLower(environment().name) == 'azureusgovernment' ? 'azureml.us' : 'azureml.net'}' - virtualNetworkLinks: [ - { - virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' - } - ] - tags: tags - enableTelemetry: enableTelemetry - } -} - -var apiPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?apiPrivateDnsZoneResourceId) ? mlApiPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?apiPrivateDnsZoneResourceId) : '' -var notebooksPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?notebooksPrivateDnsZoneResourceId) ? mlNotebooksPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?notebooksPrivateDnsZoneResourceId) : '' - -//AVM module uses 'Microsoft.CognitiveServices/accounts@2023-05-01' -resource aiServices 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { - name: aiServicesName -} - -resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { - parent: aiServices - name: projectName - tags: tags - location: location - identity: { - type: 'SystemAssigned' - } - properties: { - description: projectDescription - displayName: projectName - } -} - - - -// get reference to the AI Hub project to get access to the discovery URL property (not presently available on AVM) -// adjust this logic if support on the AVM module is added -resource projectReference 'Microsoft.MachineLearningServices/workspaces@2024-10-01' existing = { - name: projectName - dependsOn: [project] -} - -output projectName string = project.name -//output hubName string = hub.outputs.name -output projectConnectionString string = '${split(projectReference.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${projectReference.name}' - -@export() -@description('Values to establish private networking for resources that support createing private endpoints.') -type machineLearningPrivateNetworkingType = { - @description('Required. The Resource ID of the virtual network.') - virtualNetworkResourceId: string - - @description('Required. The Resource ID of the subnet to establish the Private Endpoint(s).') - subnetResourceId: string - - @description('Optional. The Resource ID of an existing "api" Private DNS Zone Resource to link to the virtual network. If not provided, a new "api" Private DNS Zone(s) will be created.') - apiPrivateDnsZoneResourceId: string? - - @description('Optional. The Resource ID of an existing "notebooks" Private DNS Zone Resource to link to the virtual network. If not provided, a new "notebooks" Private DNS Zone(s) will be created.') - notebooksPrivateDnsZoneResourceId: string? -} diff --git a/infra/modules/aiServices.bicep b/infra/modules/aiServices.bicep deleted file mode 100644 index 99d0243..0000000 --- a/infra/modules/aiServices.bicep +++ /dev/null @@ -1,168 +0,0 @@ -@description('Name of the Cognitive Services resource. Must be unique in the resource group.') -param name string - -@description('The location of the Cognitive Services resource.') -param location string - -@description('Required. Kind of the Cognitive Services account. Use \'Get-AzCognitiveServicesAccountSku\' to determine a valid combinations of \'kind\' and \'SKU\' for your Azure region.') -@allowed([ - 'AIServices' - 'AnomalyDetector' - 'CognitiveServices' - 'ComputerVision' - 'ContentModerator' - 'ContentSafety' - 'ConversationalLanguageUnderstanding' - 'CustomVision.Prediction' - 'CustomVision.Training' - 'Face' - 'FormRecognizer' - 'HealthInsights' - 'ImmersiveReader' - 'Internal.AllInOne' - 'LUIS' - 'LUIS.Authoring' - 'LanguageAuthoring' - 'MetricsAdvisor' - 'OpenAI' - 'Personalizer' - 'QnAMaker.v2' - 'SpeechServices' - 'TextAnalytics' - 'TextTranslation' -]) -param kind string = 'AIServices' - -@description('Required. The SKU of the Cognitive Services account. Use \'Get-AzCognitiveServicesAccountSku\' to determine a valid combinations of \'kind\' and \'SKU\' for your Azure region.') -@allowed([ - 'S' - 'S0' - 'S1' - 'S2' - 'S3' - 'S4' - 'S5' - 'S6' - 'S7' - 'S8' -]) -param sku string = 'S0' - -@description('Optional. The resource ID of the Log Analytics workspace to use for diagnostic settings.') -param logAnalyticsWorkspaceResourceId string? - -import { deploymentType } from 'br/public:avm/res/cognitive-services/account:0.10.2' -@description('Optional. Specifies the OpenAI deployments to create.') -param deployments deploymentType[] = [] - -import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' -@description('Optional. Array of role assignments to create.') -param roleAssignments roleAssignmentType[]? - -@description('Optional. Values to establish private networking for the AI Services resource.') -param privateNetworking aiServicesPrivateNetworkingType? - -@description('Optional. Tags to be applied to the resources.') -param tags object = {} - -@description('Optional. Enable/Disable usage telemetry for module.') -param enableTelemetry bool = true - -module cognitiveServicesPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?cogServicesPrivateDnsZoneResourceId)) { - name: take('${name}-cognitiveservices-pdns-deployment', 64) - params: { - name: 'privatelink.cognitiveservices.${toLower(environment().name) == 'azureusgovernment' ? 'azure.us' : 'azure.com'}' - virtualNetworkLinks: [ - { - virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' - } - ] - tags: tags - enableTelemetry: enableTelemetry - } -} - -module openAiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?openAIPrivateDnsZoneResourceId)) { - name: take('${name}-openai-pdns-deployment', 64) - params: { - name: 'privatelink.openai.${toLower(environment().name) == 'azureusgovernment' ? 'azure.us' : 'azure.com'}' - virtualNetworkLinks: [ - { - virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' - } - ] - tags: tags - enableTelemetry: enableTelemetry - } -} - -var cogServicesPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?cogServicesPrivateDnsZoneResourceId) ? cognitiveServicesPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?cogServicesPrivateDnsZoneResourceId) : '' -var openAIPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?openAIPrivateDnsZoneResourceId) ? openAiPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?openAIPrivateDnsZoneResourceId) : '' - -//AVM module uses 'Microsoft.CognitiveServices/accounts@2023-05-01' -module cognitiveService 'br/public:avm/res/cognitive-services/account:0.10.2' = { - name: take('${name}-aiservices-deployment', 64) - #disable-next-line no-unnecessary-dependson - dependsOn: [cognitiveServicesPrivateDnsZone, openAiPrivateDnsZone] // required due to optional flags that could change dependency - params: { - name: name - location: location - tags: tags - sku: sku - kind: kind - managedIdentities: { - systemAssigned: true - } - deployments: deployments - customSubDomainName: name - disableLocalAuth: false - publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' - apiProperties: { - allowProjectManagement: true - } - diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ - { - workspaceResourceId: logAnalyticsWorkspaceResourceId - } - ] : [] - roleAssignments: roleAssignments - privateEndpoints: privateNetworking != null ? [ - { - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - privateDnsZoneResourceId: cogServicesPrivateDnsZoneResourceId - } - { - privateDnsZoneResourceId: openAIPrivateDnsZoneResourceId - } - ] - } - subnetResourceId: privateNetworking.?subnetResourceId ?? '' - } - ] : [] - enableTelemetry: enableTelemetry - } -} - -output resourceId string = cognitiveService.outputs.resourceId -output name string = cognitiveService.outputs.name -output systemAssignedMIPrincipalId string? = cognitiveService.outputs.?systemAssignedMIPrincipalId -output endpoint string = cognitiveService.outputs.endpoint - -@export() -@description('Values to establish private networking for resources that support createing private endpoints.') -type aiServicesPrivateNetworkingType = { - @description('Required. The Resource ID of the virtual network.') - virtualNetworkResourceId: string - - @description('Required. The Resource ID of the subnet to establish the Private Endpoint(s).') - subnetResourceId: string - - @description('Optional. The Resource ID of an existing "cognitiveservices" Private DNS Zone Resource to link to the virtual network. If not provided, a new "cognitiveservices" Private DNS Zone(s) will be created.') - cogServicesPrivateDnsZoneResourceId: string? - - @description('Optional. The Resource ID of an existing "openai" Private DNS Zone Resource to link to the virtual network. If not provided, a new "openai" Private DNS Zone(s) will be created.') - openAIPrivateDnsZoneResourceId: string? -} - diff --git a/infra/modules/cosmosDb.bicep b/infra/modules/cosmosDb.bicep index 3936a08..3a3565f 100644 --- a/infra/modules/cosmosDb.bicep +++ b/infra/modules/cosmosDb.bicep @@ -1,19 +1,19 @@ -@description('Name of the Cosmos DB Account.') +@description('Required. Name of the Cosmos DB Account.') param name string -@description('Specifies the location for all the Azure resources.') +@description('Required. Specifies the location for all the Azure resources.') param location string @description('Optional. Tags to be applied to the resources.') param tags object = {} -@description('Managed Identity princpial to assign data plane roles for the Cosmos DB Account.') +@description('Required. Managed Identity princpial to assign data plane roles for the Cosmos DB Account.') param dataAccessIdentityPrincipalId string @description('Optional. The resource ID of an existing Log Analytics workspace to associate with AI Foundry for monitoring.') param logAnalyticsWorkspaceResourceId string? -@description('Indicates whether the single-region account is zone redundant. This property is ignored for multi-region accounts.') +@description('Required. Indicates whether the single-region account is zone redundant. This property is ignored for multi-region accounts.') param zoneRedundant bool @description('Optional. The secondary location for the Cosmos DB Account for failover and multiple writes.') @@ -30,30 +30,29 @@ param roleAssignments roleAssignmentType[]? @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { +module privateDnsZone 'privateDnsZone.bicep' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { name: take('${name}-documents-pdns-deployment', 64) params: { name: 'privatelink.documents.azure.com' - virtualNetworkLinks: [ - { - virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' - } - ] + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' tags: tags - enableTelemetry: enableTelemetry } } -var privateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?privateDnsZoneResourceId) ? privateDnsZone.outputs.resourceId ?? '' : privateNetworking.?privateDnsZoneResourceId ?? '') : '' +var privateDnsZoneResourceId = privateNetworking != null + ? (empty(privateNetworking.?privateDnsZoneResourceId) + ? privateDnsZone.outputs.resourceId ?? '' + : privateNetworking.?privateDnsZoneResourceId ?? '') + : '' resource sqlContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' existing = { name: '${name}/00000000-0000-0000-0000-000000000002' } -var databaseName = 'cmsadb' -var batchContainerName = 'cmsabatch' -var fileContainerName = 'cmsafile' -var logContainerName = 'cmsalog' +var databaseName = 'cmsadb' +var batchContainerName = 'cmsabatch' +var fileContainerName = 'cmsafile' +var logContainerName = 'cmsalog' module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { name: take('${name}-account-deployment', 64) @@ -68,72 +67,78 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { networkRestrictions: { networkAclBypass: 'AzureServices' publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' - ipRules: [] + ipRules: [] virtualNetworkRules: [] } zoneRedundant: zoneRedundant automaticFailover: !empty(secondaryLocation) - failoverLocations: !empty(secondaryLocation) ? [ - { - failoverPriority: 0 - isZoneRedundant: zoneRedundant - locationName: location - } - { - failoverPriority: 0 - isZoneRedundant: zoneRedundant - locationName: secondaryLocation! - } - ] : [] + failoverLocations: !empty(secondaryLocation) + ? [ + { + failoverPriority: 0 + isZoneRedundant: zoneRedundant + locationName: location + } + { + failoverPriority: 0 + isZoneRedundant: zoneRedundant + locationName: secondaryLocation! + } + ] + : [] enableMultipleWriteLocations: !empty(secondaryLocation) backupPolicyType: !empty(secondaryLocation) ? 'Periodic' : 'Continuous' backupStorageRedundancy: zoneRedundant ? 'Zone' : 'Local' disableKeyBasedMetadataWriteAccess: false disableLocalAuthentication: privateNetworking != null - diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [{workspaceResourceId: logAnalyticsWorkspaceResourceId}] : [] - privateEndpoints: privateNetworking != null ? [ - { - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - privateDnsZoneResourceId: privateDnsZoneResourceId + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) + ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] + : [] + privateEndpoints: privateNetworking != null + ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: privateDnsZoneResourceId + } + ] } - ] - } - service: 'Sql' - subnetResourceId: privateNetworking.?subnetResourceId ?? '' - } - ] : [] + service: 'Sql' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] + : [] sqlDatabases: [ { containers: [ { - indexingPolicy: { - automatic: true + indexingPolicy: { + automatic: true + } + name: batchContainerName + paths: [ + '/batch_id' + ] } - name: batchContainerName - paths:[ - '/batch_id' - ] - } - { - indexingPolicy: { - automatic: true + { + indexingPolicy: { + automatic: true + } + name: fileContainerName + paths: [ + '/file_id' + ] } - name: fileContainerName - paths:[ - '/file_id' - ] - } - { - indexingPolicy: { - automatic: true + { + indexingPolicy: { + automatic: true + } + name: logContainerName + paths: [ + '/log_id' + ] } - name: logContainerName - paths:[ - '/log_id' - ] - } ] name: databaseName } @@ -150,22 +155,21 @@ module cosmosAccount 'br/public:avm/res/document-db/database-account:0.15.0' = { } } -output resourceId string = cosmosAccount.outputs.resourceId +@description('Name of the Cosmos DB Account resource.') output name string = cosmosAccount.outputs.name + +@description('Resource ID of the Cosmos DB Account.') +output resourceId string = cosmosAccount.outputs.resourceId + +@description('Endpoint of the Cosmos DB Account.') output endpoint string = cosmosAccount.outputs.endpoint + +@description('Name of the Cosmos DB database.') output databaseName string = databaseName -output containers object = { - batch: { - name: batchContainerName - resourceId: '${cosmosAccount.outputs.resourceId}/sqlDatabases/${databaseName}/containers/${batchContainerName}' - } - file: { - name: fileContainerName - resourceId: '${cosmosAccount.outputs.resourceId}/sqlDatabases/${databaseName}/containers/${fileContainerName}' - } - log: { - name: logContainerName - resourceId: '${cosmosAccount.outputs.resourceId}/sqlDatabases/${databaseName}/containers/${logContainerName}' - } +@description('Complex object containing the names of the Cosmos DB containers.') +output containerNames object = { + batch: batchContainerName + file: fileContainerName + log: logContainerName } diff --git a/infra/modules/keyVault.bicep b/infra/modules/keyVault.bicep index ddd8fdd..880f3d0 100644 --- a/infra/modules/keyVault.bicep +++ b/infra/modules/keyVault.bicep @@ -1,7 +1,7 @@ -@description('Name of the Key Vault.') +@description('Required. Name of the Key Vault.') param name string -@description('Specifies the location for all the Azure resources.') +@description('Required. Specifies the location for all the Azure resources.') param location string @description('Optional. Tags to be applied to the resources.') @@ -32,21 +32,20 @@ param secrets secretType[]? @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { +module privateDnsZone 'privateDnsZone.bicep' = if (privateNetworking != null && empty(privateNetworking.?privateDnsZoneResourceId)) { name: take('${name}-kv-pdns-deployment', 64) params: { name: 'privatelink.${toLower(environment().name) == 'azureusgovernment' ? 'vaultcore.usgovcloudapi.net' : 'vaultcore.azure.net'}' - virtualNetworkLinks: [ - { - virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' - } - ] + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' tags: tags - enableTelemetry: enableTelemetry } } -var privateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?privateDnsZoneResourceId) ? privateDnsZone.outputs.resourceId ?? '' : privateNetworking.?privateDnsZoneResourceId ?? '') : '' +var privateDnsZoneResourceId = privateNetworking != null + ? (empty(privateNetworking.?privateDnsZoneResourceId) + ? privateDnsZone.outputs.resourceId ?? '' + : privateNetworking.?privateDnsZoneResourceId ?? '') + : '' module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { name: take('${name}-kv-deployment', 64) @@ -58,9 +57,9 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { tags: tags createMode: 'default' sku: sku - publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' + publicNetworkAccess: privateNetworking != null ? 'Disabled' : 'Enabled' networkAcls: { - defaultAction: 'Allow' + defaultAction: 'Allow' } enableVaultForDeployment: true enableVaultForDiskEncryption: true @@ -69,29 +68,36 @@ module keyvault 'br/public:avm/res/key-vault/vault:0.12.1' = { enableRbacAuthorization: true enableSoftDelete: true softDeleteRetentionInDays: 7 - diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ - { - workspaceResourceId: logAnalyticsWorkspaceResourceId - } - ] : [] - privateEndpoints: privateNetworking != null ? [ - { - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - privateDnsZoneResourceId: privateDnsZoneResourceId + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) + ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] + : [] + privateEndpoints: privateNetworking != null + ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: privateDnsZoneResourceId + } + ] } - ] - } - service: 'vault' - subnetResourceId: privateNetworking.?subnetResourceId ?? '' - } - ] : [] + service: 'vault' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] + : [] roleAssignments: roleAssignments secrets: secrets enableTelemetry: enableTelemetry } } -output resourceId string = keyvault.outputs.resourceId +@description('Name of the Key Vault resource.') output name string = keyvault.outputs.name + +@description('Resource ID of the Key Vault.') +output resourceId string = keyvault.outputs.resourceId diff --git a/infra/modules/network.bicep b/infra/modules/network.bicep index 30c24ab..91d9375 100644 --- a/infra/modules/network.bicep +++ b/infra/modules/network.bicep @@ -1,11 +1,11 @@ -@description('Named used for all resource naming.') +@description('Required. Named used for all resource naming.') param resourcesName string -@description('Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') +@description('Required. Resource ID of the Log Analytics Workspace for monitoring and diagnostics.') param logAnalyticsWorkSpaceResourceId string @minLength(3) -@description('Azure region for all services.') +@description('Required. Azure region for all services.') param location string @description('Optional. Tags to be applied to the resources.') @@ -14,17 +14,13 @@ param tags object = {} @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Admin username for the VM.') +@description('Required. Admin username for the VM.') @secure() -param vmAdminUsername string +param vmAdminUsername string -@description('Admin password for the VM.') +@description('Required. Admin password for the VM.') @secure() -param vmAdminPassword string - - - - +param vmAdminPassword string // Subnet Classless Inter-Doman Routing (CIDR) Sizing Reference Table (Best Practices) // | CIDR | # of Addresses | # of /24s | Notes | @@ -55,7 +51,7 @@ param vmAdminPassword string // - Document subnet usage and purpose in code comments. // - For AVM modules, ensure only one delegation per subnet and leave delegations empty if not required. -module network 'network/main.bicep' = { +module network 'network/main.bicep' = { name: take('network-${resourcesName}-create', 64) params: { resourcesName: resourcesName @@ -105,7 +101,7 @@ module network 'network/main.bicep' = { size: 'Standard_D2s_v3' username: vmAdminUsername password: vmAdminPassword - subnet: { + subnet: { name: 'jumpbox' addressPrefixes: ['10.0.12.0/23'] // /23 (10.0.12.0 - 10.0.13.255), 512 addresses networkSecurityGroup: { @@ -134,8 +130,14 @@ module network 'network/main.bicep' = { } } +@description('Name of the Virtual Network resource.') output vnetName string = network.outputs.vnetName + +@description('Resource ID of the Virtual Network.') output vnetResourceId string = network.outputs.vnetResourceId +@description('Resource ID of the "web" subnet.') output subnetWebResourceId string = first(filter(network.outputs.subnets, s => s.name == 'web')).?resourceId ?? '' + +@description('Resource ID of the "peps" subnet for Private Endpoints.') output subnetPrivateEndpointsResourceId string = first(filter(network.outputs.subnets, s => s.name == 'peps')).?resourceId ?? '' diff --git a/infra/modules/privateDnsZone.bicep b/infra/modules/privateDnsZone.bicep new file mode 100644 index 0000000..225ab75 --- /dev/null +++ b/infra/modules/privateDnsZone.bicep @@ -0,0 +1,44 @@ +// This module is here solely to reduce the size of the main bicep file for meet the 4MB limit +// The AVM Module 'br/public:avm/res/network/private-dns-zone' should be used if size is available. + +@description('Required. Private DNS zone name.') +param name string + +@description('Required. The resource ID of the virtual network to link.') +param virtualNetworkResourceId string + +@description('Optional. Tags of the resource.') +param tags object? + +// Private DNS Zones are global resources and may not support all regions, even if those regions support the underlying services. +// The Private DNS Zone creation should use 'global' as the location. +resource privateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: name + location: 'global' // Private DNS zones must use 'global' as location + tags: tags +} + +resource virtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: '${last(split(virtualNetworkResourceId, '/'))}-vnetlink' + parent: privateDnsZone + location: 'global' // Virtual Network Links must also use 'global' as location + tags: tags + properties: { + registrationEnabled: false + virtualNetwork: { + id: virtualNetworkResourceId + } + } +} + +@description('The resource group the private DNS zone was deployed into.') +output resourceGroupName string = resourceGroup().name + +@description('The name of the private DNS zone.') +output name string = privateDnsZone.name + +@description('The resource ID of the private DNS zone.') +output resourceId string = privateDnsZone.id + +@description('The location the resource was deployed into.') +output location string = privateDnsZone.location diff --git a/infra/modules/storageAccount.bicep b/infra/modules/storageAccount.bicep index 4f63e47..b109de4 100644 --- a/infra/modules/storageAccount.bicep +++ b/infra/modules/storageAccount.bicep @@ -1,7 +1,7 @@ -@description('Name of the Storage Account.') +@description('Required. Name of the Storage Account.') param name string -@description('Specifies the location for all the Azure resources.') +@description('Required. Specifies the location for all the Azure resources.') param location string @allowed([ @@ -14,7 +14,7 @@ param location string 'Standard_GZRS' 'Standard_RAGZRS' ]) -@description('Storage Account Sku Name. Defaults to Standard_LRS.') +@description('Optional. Storage Account Sku Name. Defaults to Standard_LRS.') param skuName string = 'Standard_LRS' @description('Optional. Tags to be applied to the resources.') @@ -36,36 +36,36 @@ param containers array? @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -module blobPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?blobPrivateDnsZoneResourceId)) { - name: take('${name}-blob-pdns-deployment', 64) +module blobPrivateDnsZone 'privateDnsZone.bicep' = if (privateNetworking != null && empty(privateNetworking.?blobPrivateDnsZoneResourceId)) { + name: take('${name}-blob-pdns-deployment', 64) params: { name: 'privatelink.blob.${environment().suffixes.storage}' - virtualNetworkLinks: [ - { - virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' - } - ] + //location: location + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' tags: tags - enableTelemetry: enableTelemetry } } -module filePrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (privateNetworking != null && empty(privateNetworking.?filePrivateDnsZoneResourceId)) { - name: take('${name}-file-pdns-deployment', 64) +module filePrivateDnsZone 'privateDnsZone.bicep' = if (privateNetworking != null && empty(privateNetworking.?filePrivateDnsZoneResourceId)) { + name: take('${name}-file-pdns-deployment', 64) params: { name: 'privatelink.file.${environment().suffixes.storage}' - virtualNetworkLinks: [ - { - virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' - } - ] + //location: location + virtualNetworkResourceId: privateNetworking.?virtualNetworkResourceId ?? '' tags: tags - enableTelemetry: enableTelemetry } } -var blobPrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?blobPrivateDnsZoneResourceId) ? blobPrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?blobPrivateDnsZoneResourceId) : '' -var filePrivateDnsZoneResourceId = privateNetworking != null ? (empty(privateNetworking.?filePrivateDnsZoneResourceId) ? filePrivateDnsZone.outputs.resourceId ?? '' : privateNetworking.?filePrivateDnsZoneResourceId) : '' +var blobPrivateDnsZoneResourceId = privateNetworking != null + ? (empty(privateNetworking.?blobPrivateDnsZoneResourceId) + ? blobPrivateDnsZone.outputs.resourceId ?? '' + : privateNetworking.?blobPrivateDnsZoneResourceId) + : '' +var filePrivateDnsZoneResourceId = privateNetworking != null + ? (empty(privateNetworking.?filePrivateDnsZoneResourceId) + ? filePrivateDnsZone.outputs.resourceId ?? '' + : privateNetworking.?filePrivateDnsZoneResourceId) + : '' module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { name: take('${name}-sa-deployment', 64) @@ -89,48 +89,55 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { enableNfsV3: false largeFileSharesState: 'Disabled' networkAcls: { - defaultAction: 'Allow' + defaultAction: privateNetworking != null ? 'Deny' : 'Allow' bypass: 'AzureServices' } supportsHttpsTrafficOnly: true - diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) ? [ - { - workspaceResourceId: logAnalyticsWorkspaceResourceId - } - ] : [] - privateEndpoints: privateNetworking != null ? [ - { - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - privateDnsZoneResourceId: blobPrivateDnsZoneResourceId + diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) + ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] + : [] + privateEndpoints: privateNetworking != null + ? [ + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: blobPrivateDnsZoneResourceId + } + ] } - ] - } - service: 'blob' - subnetResourceId: privateNetworking.?subnetResourceId ?? '' - } - { - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - privateDnsZoneResourceId: filePrivateDnsZoneResourceId + service: 'blob' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + { + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: filePrivateDnsZoneResourceId + } + ] } - ] - } - service: 'file' - subnetResourceId: privateNetworking.?subnetResourceId ?? '' - } - ] : [] + service: 'file' + subnetResourceId: privateNetworking.?subnetResourceId ?? '' + } + ] + : [] roleAssignments: roleAssignments - blobServices: { + blobServices: { containers: containers ?? [] } enableTelemetry: enableTelemetry } } +@description('Name of the Storage Account.') output name string = storageAccount.outputs.name + +@description('Resource ID of the Storage Account.') output resourceId string = storageAccount.outputs.resourceId @export() From eed953923a92778a5d666550609a43279fb0894a Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 20 Jun 2025 16:46:02 -0400 Subject: [PATCH 088/124] updated document --- README.md | 2 +- docs/ArchitectureWAF.md | 72 ++++++++++++++++++++++++----------------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a2790cb..12dd340 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The solution leverages Azure AI Foundry, Azure OpenAI Service, Azure Container A |![image](./docs/images/read_me/solArchitecture.png)| |---| -This solution architecture is the default solution architecture with the default setting of our deployment process. Optionally you can deploy [Well-Architected Framework (WAF) aligned](https://learn.microsoft.com/en-us/azure/well-architected/) architecture, described in [WAF Aligned Solution Architecture](./docs/ArchitectureWAF.md), with deployment the WAF-Aligned option described in [Deployment Guide](./docs/DeploymentGuide.md). +This architecture will be deployed with the 'sandbox' setting of our deployment process. Optionally you can deploy [Well-Architected Framework (WAF) aligned](https://learn.microsoft.com/en-us/azure/well-architected/) architecture, described in [WAF Aligned Solution Architecture](./docs/ArchitectureWAF.md), with the WAF-Aligned deployment option described in [Deployment Guide](./docs/DeploymentGuide.md). ### Agentic architecture diff --git a/docs/ArchitectureWAF.md b/docs/ArchitectureWAF.md index 4c9d441..176a733 100644 --- a/docs/ArchitectureWAF.md +++ b/docs/ArchitectureWAF.md @@ -1,34 +1,48 @@ # WAF-Aligned Solution Architecture -This page describes the architecture and key features of the WAF-aligned (Well-Architected Framework) deployment option for the Modernize Your Code Solution Accelerator. +This architecture implements [Azure Well-Architected Framework (WAF)](https://learn.microsoft.com/en-us/azure/well-architected/) principles for enterprise-grade deployments, deployed with the WAF-Aligned deployment option: ![WAF-Aligned Architecture Diagram](../docs/images/read_me/solArchitectureWAF.png) -The [Well-Architected Framework (WAF) aligned](https://learn.microsoft.com/en-us/azure/well-architected/) architecture enables below pillars: -- Security -- Reliability -- Performance Efficiency -- Cost Optimization -- Operational Excellence - -## Key Features -- **Private Networking:** - - Solution components are enclosed with Azure Virtual Network. - - Critical resources are accessible only via private endpoints within a Virtual Network (VNet). - - Private DNS zones and virtual network links ensure secure, internal name resolution. -- **Monitoring & Diagnostics:** - - Log Analytics Workspace and Application Insights are enabled for observability and troubleshooting. -- **Scaling & Redundancy:** - - Resources can be configured for high availability and geo-redundancy. -- **Role-Based Access Control (RBAC):** - - Managed identities and least-privilege access for secure automation and resource access. -- **Parameterization:** - - All WAF features are controlled via parameters in `main.waf.bicepparam` for easy customization. - -## Architecture Components -- **Virtual Network (VNet):** Isolates all solution resources. -- **Private Endpoints:** Securely connect to Azure PaaS services (AI foundry, AI Services, Azure Storage, Cosmos DB, Key Vault), from within VNET. -- **Private DNS Zones:** Provide internal DNS resolution for private endpoints. -- **Jumpbox/Bastion Host:** Secure admin access to the VNet. -- **Monitoring:** Centralized logging and application insights. -- **App Services/Container Apps:** Deployed within the VNet, with private access to dependencies. +## WAF Pillars Implementation + +### 🔐 Security +- **Zero Trust Network:** Private VNet with private endpoints for all PaaS services +- **Identity & Access:** Managed identities with RBAC and least-privilege access +- **Secure Admin Access:** Azure Bastion + Jumpbox for internal administration +- **Secrets Management:** Azure Key Vault integration + +### 📊 Operational Excellence +- **Observability:** Centralized logging via Log Analytics Workspace +- **Application Monitoring:** Application Insights for telemetry and diagnostics +- **Infrastructure as Code:** Bicep templates with parameterized configurations + +### 🚀 Performance Efficiency +- **Auto-scaling:** Container Apps with configurable scaling policies +- **Regional Proximity:** Resources deployed in optimal Azure regions + +### 💰 Cost Optimization +- **Right-sizing:** Parameterized SKUs and capacity settings +- **Resource Sharing:** Shared networking and monitoring infrastructure + +### 🛡️ Reliability +- **High Availability:** Multi-zone deployment options +- **Data Redundancy:** Configurable geo-replication for critical data stores +- **Private Connectivity:** Eliminates internet dependencies + +## Core Architecture Components + +| Component | Purpose | WAF Alignment | +|-----------|---------|---------------| +| **Virtual Network** | Network isolation boundary | Security, Reliability | +| **Private Endpoints** | Secure PaaS connectivity (AI Services, Storage, Cosmos DB, Key Vault) | Security | +| **Private DNS Zones** | Internal name resolution | Security, Reliability | +| **Azure Bastion + Jumpbox** | Secure administrative access | Security | +| **Container Apps** | Application hosting with VNet integration | Performance, Reliability | +| **Log Analytics + App Insights** | Centralized monitoring and diagnostics | Operational Excellence | + +## Deployment Configuration +- **Parameter File:** `infra/main.waf.bicepparam` - Controls all WAF features +- **Network-first Design:** All components deployed within private network boundaries +- **Enterprise-ready:** Production-grade security and monitoring enabled + From c222633ecefdc8c936e4f0a1f272a78ba5b1cda8 Mon Sep 17 00:00:00 2001 From: Gaiye Zhou Date: Fri, 20 Jun 2025 16:58:03 -0400 Subject: [PATCH 089/124] image border reduced --- docs/images/read_me/solArchitectureWAF.png | Bin 92884 -> 232815 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/images/read_me/solArchitectureWAF.png b/docs/images/read_me/solArchitectureWAF.png index 34f73f01a946fe731ae488f8fd4e84c1701c707f..847fb665ae05582f8a53d5dcc2da120fb4fe422a 100644 GIT binary patch literal 232815 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≧hr*+qo!c+aLB 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