diff --git a/infra/avm/modules/ai/ai-foundry-model-deployment.bicep b/infra/avm/modules/ai/ai-foundry-model-deployment.bicep index 96595e093..1c534fd88 100644 --- a/infra/avm/modules/ai/ai-foundry-model-deployment.bicep +++ b/infra/avm/modules/ai/ai-foundry-model-deployment.bicep @@ -1,35 +1,64 @@ -@description('Required. Name of the AI Services account.') -param aiServicesName string +// ============================================================================ +// Module: Model Deployment +// Description: Deploys a single AI model to an existing AI Services account. +// Called repetitively from main.bicep for each model in the array. +// Generic, reusable across GSAs. +// ============================================================================ -@description('Required. Array of model deployments to create.') -param deployments array = [] +@description('Required. Name of the parent AI Services account.') +param aiServicesAccountName string -// Reference AI Services account (module is scoped to the correct resource group) -resource aiServices 'Microsoft.CognitiveServices/accounts@2025-12-01' existing = { - name: aiServicesName +@description('Required. Name for this model deployment.') +param deploymentName string + +@description('Optional. Model format (e.g., OpenAI).') +param modelFormat string = 'OpenAI' + +@description('Required. Model name (e.g., gpt-4o, text-embedding-ada-002).') +param modelName string + +@description('Optional. Model version. Empty string means latest.') +param modelVersion string = '' + +@description('Optional. RAI policy name.') +param raiPolicyName string = 'Microsoft.Default' + +@description('Required. SKU name (e.g., Standard, GlobalStandard).') +param skuName string + +@description('Required. SKU capacity (tokens per minute in thousands).') +param skuCapacity int + +// ============================================================================ +// Model Deployment +// ============================================================================ +resource aiServicesAccount 'Microsoft.CognitiveServices/accounts@2025-12-01' existing = { + name: aiServicesAccountName } -// Deploy models to AI Services account -// Using batchSize(1) to avoid concurrent deployment issues -@batchSize(1) -resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-12-01' = [ - for (deployment, index) in deployments: { - parent: aiServices - name: deployment.name - properties: { - model: { - format: deployment.format - name: deployment.model - version: deployment.version - } - raiPolicyName: deployment.raiPolicyName - } - sku: { - name: deployment.sku.name - capacity: deployment.sku.capacity +resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-12-01' = { + parent: aiServicesAccount + name: deploymentName + properties: { + model: { + format: modelFormat + name: modelName + version: !empty(modelVersion) ? modelVersion : null } + raiPolicyName: raiPolicyName + } + sku: { + name: skuName + capacity: skuCapacity } -] +} + +// ============================================================================ +// Outputs +// ============================================================================ + +@description('Name of the deployed model.') +output name string = modelDeployment.name -@description('The names of the deployed models.') -output deployedModelNames array = [for (deployment, i) in deployments: modelDeployments[i].name] +@description('Resource ID of the model deployment.') +output resourceId string = modelDeployment.id diff --git a/infra/avm/modules/ai/ai-foundry-project.bicep b/infra/avm/modules/ai/ai-foundry-project.bicep index 4c425e99e..69fc4fa7c 100644 --- a/infra/avm/modules/ai/ai-foundry-project.bicep +++ b/infra/avm/modules/ai/ai-foundry-project.bicep @@ -1,58 +1,141 @@ -@description('Required. Name of the AI Services project.') -param name string +// ============================================================================ +// Module: AI Foundry Project (Account + Project) +// Description: AVM wrapper for Azure AI Services account creation and +// AI Foundry project provisioning. Generic, reusable across GSAs. +// AVM Module: avm/res/cognitive-services/account +// WAF: https://learn.microsoft.com/azure/well-architected/service-guides/azure-openai +// ============================================================================ -@description('Required. The location of the Project resource.') -param location string = resourceGroup().location +@description('Required. Solution name suffix used to generate resource names.') +param solutionName string -@description('Optional. The description of the AI Foundry project to create. Defaults to the project name.') -param desc string = name +@description('Optional. Override name for the AI Services account. Defaults to aif-{solutionName}.') +param name string = 'aif-${solutionName}' -@description('Required. Name of the existing Cognitive Services resource to create the AI Foundry project in.') -param aiServicesName string +@description('Optional. Override name for the AI Foundry project. Defaults to proj-{solutionName}.') +param projectName string = 'proj-${solutionName}' -@description('Required. Azure Existing AI Project ResourceID.') -param azureExistingAIProjectResourceId string = '' +@description('Required. Azure region for the resources.') +param location string -@description('Optional. Tags to be applied to the resources.') +@description('Optional. Tags to apply to resources.') param tags object = {} -var useExistingAiFoundryAiProject = !empty(azureExistingAIProjectResourceId) -var existingOpenAIEndpoint = useExistingAiFoundryAiProject - ? format('https://{0}.openai.azure.com/', split(azureExistingAIProjectResourceId, '/')[8]) - : '' +@description('Optional. SKU name for the AI Services account.') +param skuName string = 'S0' -// Reference to cognitive service in current resource group for new projects -resource cogServiceReference 'Microsoft.CognitiveServices/accounts@2025-12-01' existing = { - name: aiServicesName +@description('Optional. Whether to disable local (key-based) authentication.') +param disableLocalAuth bool = true + +@description('Optional. Whether to allow project management (AI Foundry hub).') +param allowProjectManagement bool = true + +@description('Optional. Public network access setting.') +param publicNetworkAccess string = 'Enabled' + +@description('Optional. Managed identity type for the resources.') +@allowed(['SystemAssigned', 'UserAssigned', 'SystemAssigned, UserAssigned', 'None']) +param identityType string = 'SystemAssigned' + +@description('Optional. Network ACLs default action.') +@allowed(['Allow', 'Deny']) +param networkAclsDefaultAction string = 'Allow' + +// --- WAF: Monitoring --- +@description('Optional. Diagnostic settings for the resource.') +param diagnosticSettings array? + +// --- WAF: Telemetry --- +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +// --- Role Assignments --- +@description('Optional. Array of role assignments to create on the AI Services account.') +param roleAssignments array? + +// ============================================================================ +// AI Services Account (AVM Module) +// ============================================================================ +module aiServicesAccount 'br/public:avm/res/cognitive-services/account:0.14.2' = { + name: take('avm.res.cognitive-services.account.${name}', 64) + params: { + name: name + location: location + tags: tags + enableTelemetry: enableTelemetry + sku: skuName + kind: 'AIServices' + disableLocalAuth: disableLocalAuth + allowProjectManagement: allowProjectManagement + customSubDomainName: name + networkAcls: { + defaultAction: networkAclsDefaultAction + virtualNetworkRules: [] + ipRules: [] + } + publicNetworkAccess: publicNetworkAccess + managedIdentities: { + systemAssigned: true + } + diagnosticSettings: diagnosticSettings + deployments: [] + roleAssignments: roleAssignments + // Private endpoints deployed separately to avoid AccountProvisioningStateInvalid + privateEndpoints: [] + } } -resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-12-01' = { - parent: cogServiceReference +// ============================================================================ +// AI Foundry Project +// ============================================================================ +resource aiServices 'Microsoft.CognitiveServices/accounts@2025-12-01' existing = { name: name - tags: tags + dependsOn: [aiServicesAccount] +} + +resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-12-01' = { + parent: aiServices + name: projectName location: location + tags: tags + kind: 'AIServices' identity: { - type: 'SystemAssigned' - } - properties: { - description: desc - displayName: name + type: identityType } + properties: {} + dependsOn: [aiServicesAccount] } -@description('Required. Name of the AI project.') -output name string = aiProject.name +// ============================================================================ +// Outputs +// ============================================================================ + +@description('Resource ID of the AI Services account.') +output resourceId string = aiServices.id + +@description('Name of the AI Services account.') +output name string = aiServices.name + +@description('Endpoint of the AI Services account (OpenAI Language Model Instance API).') +output endpoint string = aiServices.properties.endpoints['OpenAI Language Model Instance API'] + +@description('Endpoint of the AI Services account (Cognitive Services).') +output cognitiveServicesEndpoint string = aiServices.properties.endpoint + +@description('Azure OpenAI Content Understanding endpoint URL.') +output azureOpenAiCuEndpoint string = aiServices.properties.endpoints['Content Understanding'] + +@description('System-assigned identity principal ID of the AI Services account.') +output principalId string = aiServices.identity.principalId -@description('Required. Resource ID of the AI project.') -output resourceId string = aiProject.id +@description('Resource ID of the AI Foundry project.') +output projectResourceId string = aiProject.id -@description('Required. API endpoint for the AI project.') -output apiEndpoint string = aiProject!.properties.endpoints['AI Foundry API'] +@description('Name of the AI Foundry project.') +output projectName string = aiProject.name -@description('Contains AI Endpoint.') -output aoaiEndpoint string = !empty(existingOpenAIEndpoint) - ? existingOpenAIEndpoint - : cogServiceReference.properties.endpoints['OpenAI Language Model Instance API'] +@description('AI Foundry project endpoint.') +output projectEndpoint string = aiProject.properties.endpoints['AI Foundry API'] -@description('Required. Principal ID of the AI project system-assigned managed identity.') -output systemAssignedMIPrincipalId string = aiProject.identity.principalId +@description('System-assigned identity principal ID of the project.') +output projectIdentityPrincipalId string = aiProject.identity.principalId diff --git a/infra/avm/modules/ai/ai-search.bicep b/infra/avm/modules/ai/ai-search.bicep index 105392478..aa0843542 100644 --- a/infra/avm/modules/ai/ai-search.bicep +++ b/infra/avm/modules/ai/ai-search.bicep @@ -1,67 +1,129 @@ // ============================================================================ // Module: AI Search -// Description: AVM wrapper for an Azure AI Search service. +// Description: Deploys Azure AI Search with a two-step pattern: +// Step 1: Plain Bicep resource for fast initial creation (name, location, SKU) +// Step 2: AVM module update to enable managed identity & full configuration +// This reduces deployment time by making the resource available immediately +// while identity enablement proceeds separately. // AVM Module: avm/res/search/search-service:0.12.0 +// WAF: https://learn.microsoft.com/azure/well-architected/service-guides/azure-cognitive-search // ============================================================================ -@description('Required. Name of the AI Search service.') -param name string +@description('Solution name suffix used to derive the resource name.') +@minLength(3) +param solutionName string -@description('Required. Azure region for the resource.') +@description('Optional. Override name for the search service. Defaults to srch-{solutionName}.') +param name string = 'srch-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} +@description('SKU name for the search service.') +@allowed(['free', 'basic', 'standard', 'standard2', 'standard3', 'storage_optimized_l1', 'storage_optimized_l2']) +param skuName string = 'basic' + +@description('Number of replicas.') +param replicaCount int = 1 + +@description('Number of partitions.') +param partitionCount int = 1 + +@description('Hosting mode.') +@allowed(['Default', 'HighDensity']) +param hostingMode string = 'Default' + +@description('Semantic search tier.') +@allowed(['disabled', 'free', 'standard']) +param semanticSearch string = 'free' + +@description('Whether to disable local authentication.') +param disableLocalAuth bool = true + +@description('Optional. Authentication options for the search service (e.g., aadOrApiKey).') +param authOptions object = {} + +@description('Optional. Network rule set for the search service (e.g., bypass: AzureServices).') +param networkRuleSet object = {} + +@description('Managed identity type for the search service.') +param managedIdentityType string = 'SystemAssigned' + +@description('Public network access setting.') +param publicNetworkAccess string = 'Enabled' + +// --- WAF: Telemetry --- @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Optional. SKU of the AI Search service.') -param sku string = 'basic' +// --- WAF: Monitoring --- +@description('Diagnostic settings for monitoring.') +param diagnosticSettings array = [] -@description('Optional. Number of replicas.') -param replicaCount int = 1 +// --- WAF: Private Networking --- +@description('Private endpoint configurations.') +param privateEndpoints array = [] -@description('Required. Principal ID of the managed identity to grant search data/service roles.') -param principalId string +// --- Role Assignments --- +@description('Optional. Array of role assignments to create on the AI Search service.') +param roleAssignments array = [] -@description('Optional. Diagnostic settings for monitoring.') -param diagnosticSettings array = [] +// ============================================================================ +// Step 1: Initial resource creation (plain Bicep — fast) +// ============================================================================ +resource searchService 'Microsoft.Search/searchServices@2025-05-01' = { + name: name + location: location + sku: { + name: skuName + } +} -module search 'br/public:avm/res/search/search-service:0.12.0' = { - name: take('avm.res.search.search-service.${name}', 64) +// ============================================================================ +// Step 2: AVM update — enables identity & full configuration +// ============================================================================ +module searchServiceUpdate 'br/public:avm/res/search/search-service:0.12.0' = { + name: take('avm.res.search.update.${name}', 64) params: { name: name location: location tags: tags enableTelemetry: enableTelemetry - sku: sku + sku: skuName replicaCount: replicaCount - partitionCount: 1 - hostingMode: 'Default' - semanticSearch: 'free' - managedIdentities: { systemAssigned: true } - disableLocalAuth: true - roleAssignments: [ - { - principalId: principalId - roleDefinitionIdOrName: 'Search Index Data Contributor' - principalType: 'ServicePrincipal' - } - { - principalId: principalId - roleDefinitionIdOrName: 'Search Service Contributor' - principalType: 'ServicePrincipal' - } - ] - diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : null - // AI Search remains publicly accessible - accessed from ACI via managed identity - publicNetworkAccess: 'Enabled' + partitionCount: partitionCount + hostingMode: hostingMode + semanticSearch: semanticSearch + authOptions: !empty(authOptions) ? authOptions : null + disableLocalAuth: disableLocalAuth + networkRuleSet: !empty(networkRuleSet) ? networkRuleSet : null + publicNetworkAccess: publicNetworkAccess + managedIdentities: { + systemAssigned: managedIdentityType == 'SystemAssigned' + } + diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : [] + privateEndpoints: privateEndpoints + roleAssignments: !empty(roleAssignments) ? roleAssignments : [] } + dependsOn: [ + searchService + ] } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the AI Search service.') -output resourceId string = search.outputs.resourceId +output resourceId string = searchService.id @description('Name of the AI Search service.') -output name string = search.outputs.name +output name string = searchService.name + +@description('Endpoint URL of the AI Search service.') +output endpoint string = 'https://${searchService.name}.search.windows.net' + +@description('System-assigned identity principal ID.') +output identityPrincipalId string = searchServiceUpdate.outputs.?systemAssignedMIPrincipalId ?? '' diff --git a/infra/avm/modules/ai/existing-project-setup.bicep b/infra/avm/modules/ai/existing-project-setup.bicep index efdc5753f..cd0fe1f2c 100644 --- a/infra/avm/modules/ai/existing-project-setup.bicep +++ b/infra/avm/modules/ai/existing-project-setup.bicep @@ -37,6 +37,9 @@ output name string = aiServices.name @description('Endpoint of the AI Services account (OpenAI Language Model Instance API).') output endpoint string = aiServices.properties.endpoints['OpenAI Language Model Instance API'] +@description('Endpoint of the AI Services account (Cognitive Services).') +output cognitiveServicesEndpoint string = aiServices.properties.endpoint + @description('Azure OpenAI Content Understanding endpoint URL.') output azureOpenAiCuEndpoint string = aiServices.properties.endpoints['Content Understanding'] diff --git a/infra/avm/modules/compute/app-service-plan.bicep b/infra/avm/modules/compute/app-service-plan.bicep index 8a704cf4f..6e9e72d0c 100644 --- a/infra/avm/modules/compute/app-service-plan.bicep +++ b/infra/avm/modules/compute/app-service-plan.bicep @@ -1,51 +1,67 @@ // ============================================================================ // Module: App Service Plan -// Description: AVM wrapper for an Azure App Service Plan (Linux). +// Description: AVM wrapper for Azure App Service Plan // AVM Module: avm/res/web/serverfarm:0.7.0 // ============================================================================ -@description('Required. Name of the App Service Plan.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the App Service Plan.') +param name string = 'asp-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} +@description('SKU name for the App Service Plan.') +@allowed(['F1', 'D1', 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1', 'P2', 'P3', 'P4', 'P0v3', 'P0v4', 'P1v3', 'P1v4', 'P2v3', 'P3v3']) +param skuName string = 'B2' + +@description('Whether the plan is Linux-based.') +param reserved bool = true + +@description('Kind of the App Service Plan.') +param kind string = 'linux' + @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Optional. SKU name of the App Service Plan.') -param skuName string = 'B1' - -@description('Optional. Number of instances.') +@description('Number of instances (workers).') param skuCapacity int = 1 -@description('Optional. Enable zone redundancy.') -param zoneRedundant bool = false - -@description('Optional. Diagnostic settings for monitoring.') +@description('Diagnostic settings for monitoring.') param diagnosticSettings array = [] -module serverFarm 'br/public:avm/res/web/serverfarm:0.7.0' = { +@description('Enable zone redundancy. Requires Premium SKU (P1v3+).') +param zoneRedundant bool = false + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module appServicePlan 'br/public:avm/res/web/serverfarm:0.7.0' = { name: take('avm.res.web.serverfarm.${name}', 64) params: { name: name + location: location tags: tags enableTelemetry: enableTelemetry - location: location - reserved: true - kind: 'linux' - diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : null skuName: skuName skuCapacity: skuCapacity + reserved: reserved + kind: kind + diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : [] zoneRedundant: zoneRedundant } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the App Service Plan.') -output resourceId string = serverFarm.outputs.resourceId +output resourceId string = appServicePlan.outputs.resourceId @description('Name of the App Service Plan.') -output name string = serverFarm.outputs.name +output name string = appServicePlan.outputs.name diff --git a/infra/avm/modules/compute/app-service.bicep b/infra/avm/modules/compute/app-service.bicep index b9c2ac36b..bbbcbf68b 100644 --- a/infra/avm/modules/compute/app-service.bicep +++ b/infra/avm/modules/compute/app-service.bicep @@ -1,374 +1,169 @@ -@description('Required. Name of the site.') -param name string +// ============================================================================ +// Module: App Service +// Description: AVM wrapper for Azure App Service (Web App) +// AVM Module: avm/res/web/site:0.23.1 +// ============================================================================ -@description('Optional. Location for all Resources.') -param location string = resourceGroup().location +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Type of site to deploy.') -@allowed([ - 'functionapp' - 'functionapp,linux' - 'functionapp,workflowapp' - 'functionapp,workflowapp,linux' - 'functionapp,linux,container' - 'functionapp,linux,container,azurecontainerapps' - 'app,linux' - 'app' - 'linux,api' - 'api' - 'app,linux,container' - 'app,container,windows' -]) -param kind string +@description('Name of the App Service.') +param name string = solutionName + +@description('Azure region for the resource.') +param location string -@description('Required. The resource ID of the app service plan to use for the site.') +@description('Tags to apply to the resource.') +param tags object = {} + +@description('Resource ID of the App Service Plan.') param serverFarmResourceId string -@description('Optional. Azure Resource Manager ID of the customers selected Managed Environment on which to host this app.') -param managedEnvironmentId string? +@description('Docker image name (e.g., DOCKER|registry.azurecr.io/image:tag).') +param linuxFxVersion string -@description('Optional. Configures a site to accept only HTTPS requests.') -param httpsOnly bool = true +@description('Application settings key-value pairs.') +param appSettings object = {} -@description('Optional. If client affinity is enabled.') -param clientAffinityEnabled bool = true +@description('Optional. Resource ID of Application Insights for monitoring integration.') +param applicationInsightResourceId string = '' -@description('Optional. The resource ID of the app service environment to use for this resource.') -param appServiceEnvironmentResourceId string? +@description('Whether to enable Always On.') +param alwaysOn bool = true -import { managedIdentityAllType } from 'br/public:avm/utl/types/avm-common-types:0.7.0' -@description('Optional. The managed identity definition for this resource.') -param managedIdentities managedIdentityAllType? +@description('Optional. Health check path for the app.') +param healthCheckPath string = '' -@description('Optional. The resource ID of the assigned identity to be used to access a key vault with.') -param keyVaultAccessIdentityResourceId string? +@description('Optional. Whether to enable WebSockets.') +param webSocketsEnabled bool = false -@description('Optional. Checks if Customer provided storage account is required.') -param storageAccountRequired bool = false +@description('Optional. Command line for the application.') +param appCommandLine string = '' -@description('Optional. Enable monitoring and logging configuration.') -param enableMonitoring bool = false +@description('Required. Type of site to deploy.') +@allowed([ + 'functionapp' // function app windows os + 'functionapp,linux' // function app linux os + 'functionapp,workflowapp' // logic app workflow + 'functionapp,workflowapp,linux' // logic app docker container + 'functionapp,linux,container' // function app linux container + 'functionapp,linux,container,azurecontainerapps' // function app linux container azure container apps + 'app,linux' // linux web app + 'app' // windows web app + 'linux,api' // linux api app + 'api' // windows api app + 'app,linux,container' // linux container app + 'app,container,windows' // windows container app +]) +param kind string = 'app,linux' @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Optional. Azure Resource Manager ID of the Virtual network and subnet to be joined by Regional VNET Integration.') -param virtualNetworkSubnetId string? +@description('Diagnostic settings for monitoring.') +param diagnosticSettings array = [] -@description('Optional. To enable accessing content over virtual network.') -param vnetContentShareEnabled bool = false +@description('Subnet resource ID for VNet integration.') +param virtualNetworkSubnetId string = '' -@description('Optional. To enable pulling image over Virtual Network.') -param vnetImagePullEnabled bool = false +@description('Public network access setting.') +param publicNetworkAccess string = 'Enabled' -@description('Optional. Virtual Network Route All enabled.') +@description('Optional. Whether to route all outbound traffic through the virtual network.') param vnetRouteAllEnabled bool = false -@description('Optional. Stop SCM (KUDU) site when the app is stopped.') -param scmSiteAlsoStopped bool = false +@description('Optional. Whether to route image pull traffic through the virtual network.') +param imagePullTraffic bool = false -@description('Optional. The site config object.') -param siteConfig resourceInput<'Microsoft.Web/sites@2025-03-01'>.properties.siteConfig = { - alwaysOn: true - minTlsVersion: '1.2' - ftpsState: 'FtpsOnly' -} - -@description('Optional. The web site config.') -param configs appSettingsConfigType[]? +@description('Optional. Whether to route content share traffic through the virtual network.') +param contentShareTraffic bool = false -@description('Optional. The Function App configuration object.') -param functionAppConfig resourceInput<'Microsoft.Web/sites@2025-03-01'>.properties.functionAppConfig? - -import { privateEndpointSingleServiceType } from 'br/public:avm/utl/types/avm-common-types:0.7.0' -@description('Optional. Configuration details for private endpoints.') +import { privateEndpointSingleServiceType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' +@description('Optional. Configuration details for private endpoints. For security reasons, it is recommended to use private endpoints whenever possible.') param privateEndpoints privateEndpointSingleServiceType[]? -@description('Optional. Tags of the resource.') -param tags object? - -import { diagnosticSettingFullType } from 'br/public:avm/utl/types/avm-common-types:0.7.0' -@description('Optional. The diagnostic settings of the service.') -param diagnosticSettings diagnosticSettingFullType[]? - -@description('Optional. To enable client certificate authentication (TLS mutual authentication).') -param clientCertEnabled bool = false - -@description('Optional. Client certificate authentication comma-separated exclusion paths.') -param clientCertExclusionPaths string? - -@description('Optional. Client certificate mode.') -@allowed([ - 'Optional' - 'OptionalInteractiveUser' - 'Required' -]) -param clientCertMode string = 'Optional' - -@description('Optional. If specified during app creation, the app is cloned from a source app.') -param cloningInfo resourceInput<'Microsoft.Web/sites@2025-03-01'>.properties.cloningInfo? - -@description('Optional. Size of the function container.') -param containerSize int? - -@description('Optional. Maximum allowed daily memory-time quota (applicable on dynamic apps only).') -param dailyMemoryTimeQuota int? - -@description('Optional. Setting this value to false disables the app (takes the app offline).') -param enabled bool = true - -@description('Optional. Hostname SSL states are used to manage the SSL bindings for app\'s hostnames.') -param hostNameSslStates resourceInput<'Microsoft.Web/sites@2025-03-01'>.properties.hostNameSslStates? - -@description('Optional. Hyper-V sandbox.') -param hyperV bool = false - -@description('Optional. Site redundancy mode.') -@allowed([ - 'ActiveActive' - 'Failover' - 'GeoRedundant' - 'Manual' - 'None' -]) -param redundancyMode string = 'None' - -@description('Optional. Whether or not public network access is allowed for this resource.') -@allowed([ - 'Enabled' - 'Disabled' -]) -param publicNetworkAccess string? - -@description('Optional. End to End Encryption Setting.') -param e2eEncryptionEnabled bool? - -@description('Optional. Property to configure various DNS related settings for a site.') -param dnsConfiguration resourceInput<'Microsoft.Web/sites@2025-03-01'>.properties.dnsConfiguration? - -@description('Optional. Specifies the scope of uniqueness for the default hostname during resource creation.') -@allowed([ - 'NoReuse' - 'ResourceGroupReuse' - 'SubscriptionReuse' - 'TenantReuse' -]) -param autoGeneratedDomainNameLabelScope string? - -var formattedUserAssignedIdentities = reduce( - map((managedIdentities.?userAssignedResourceIds ?? []), (id) => { '${id}': {} }), - {}, - (cur, next) => union(cur, next) -) - -var identity = !empty(managedIdentities) - ? { - type: (managedIdentities.?systemAssigned ?? false) - ? (!empty(managedIdentities.?userAssignedResourceIds ?? {}) ? 'SystemAssigned, UserAssigned' : 'SystemAssigned') - : (!empty(managedIdentities.?userAssignedResourceIds ?? {}) ? 'UserAssigned' : 'None') - userAssignedIdentities: !empty(formattedUserAssignedIdentities) ? formattedUserAssignedIdentities : null +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module appService 'br/public:avm/res/web/site:0.23.1' = { + name: take('avm.res.web.site.${name}', 64) + params: { + name: name + location: location + tags: tags + kind: kind + enableTelemetry: enableTelemetry + serverFarmResourceId: serverFarmResourceId + managedIdentities: { + systemAssigned: true } - : null - -// Merge vnet properties into siteConfig (these properties moved from top-level to siteConfig in newer API versions) -var mergedSiteConfig = union(siteConfig, { - vnetRouteAllEnabled: vnetRouteAllEnabled - vnetImagePullEnabled: vnetImagePullEnabled - vnetContentShareEnabled: vnetContentShareEnabled -}) - -resource app 'Microsoft.Web/sites@2025-03-01' = { - name: name - location: location - kind: kind - tags: tags - identity: identity - properties: { - managedEnvironmentId: !empty(managedEnvironmentId) ? managedEnvironmentId : null - serverFarmId: serverFarmResourceId - clientAffinityEnabled: clientAffinityEnabled - httpsOnly: httpsOnly - hostingEnvironmentProfile: !empty(appServiceEnvironmentResourceId) - ? { - id: appServiceEnvironmentResourceId - } - : null - storageAccountRequired: storageAccountRequired - keyVaultReferenceIdentity: keyVaultAccessIdentityResourceId - virtualNetworkSubnetId: virtualNetworkSubnetId - siteConfig: mergedSiteConfig - functionAppConfig: functionAppConfig - clientCertEnabled: clientCertEnabled - clientCertExclusionPaths: clientCertExclusionPaths - clientCertMode: clientCertMode - cloningInfo: cloningInfo - containerSize: containerSize - dailyMemoryTimeQuota: dailyMemoryTimeQuota - enabled: enabled - hostNameSslStates: hostNameSslStates - hyperV: hyperV - redundancyMode: redundancyMode - publicNetworkAccess: !empty(publicNetworkAccess) - ? any(publicNetworkAccess) - : (!empty(privateEndpoints) ? 'Disabled' : 'Enabled') - scmSiteAlsoStopped: scmSiteAlsoStopped - endToEndEncryptionEnabled: e2eEncryptionEnabled - dnsConfiguration: dnsConfiguration - autoGeneratedDomainNameLabelScope: autoGeneratedDomainNameLabelScope - } -} - -module app_config 'app-service.config.bicep' = [ - for (config, index) in (configs ?? []): { - name: '${uniqueString(deployment().name, location)}-Site-Config-${index}' - params: { - appName: app.name - name: config.name - applicationInsightResourceId: config.?applicationInsightResourceId - storageAccountResourceId: config.?storageAccountResourceId - storageAccountUseIdentityAuthentication: config.?storageAccountUseIdentityAuthentication - properties: config.?properties - currentAppSettings: config.?retainCurrentAppSettings ?? true && config.name == 'appsettings' - ? list('${app.id}/config/appsettings', '2023-12-01').properties - : {} - enableMonitoring: enableMonitoring + siteConfig: { + alwaysOn: alwaysOn + ftpsState: 'Disabled' + linuxFxVersion: linuxFxVersion + minTlsVersion: '1.2' + healthCheckPath: !empty(healthCheckPath) ? healthCheckPath : null + webSocketsEnabled: webSocketsEnabled + appCommandLine: appCommandLine } - } -] - -#disable-next-line use-recent-api-versions -resource app_diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = [ - for (diagnosticSetting, index) in (diagnosticSettings ?? []): { - name: diagnosticSetting.?name ?? '${name}-diagnosticSettings' - properties: { - storageAccountId: diagnosticSetting.?storageAccountResourceId - workspaceId: diagnosticSetting.?workspaceResourceId - eventHubAuthorizationRuleId: diagnosticSetting.?eventHubAuthorizationRuleResourceId - eventHubName: diagnosticSetting.?eventHubName - metrics: [ - for group in (diagnosticSetting.?metricCategories ?? [{ category: 'AllMetrics' }]): { - category: group.category - enabled: group.?enabled ?? true - timeGrain: null - } - ] - logs: [ - for group in (diagnosticSetting.?logCategoriesAndGroups ?? [{ categoryGroup: 'allLogs' }]): { - categoryGroup: group.?categoryGroup - category: group.?category - enabled: group.?enabled ?? true + e2eEncryptionEnabled: true + configs: [ + { + name: 'appsettings' + properties: appSettings + applicationInsightResourceId: !empty(applicationInsightResourceId) ? applicationInsightResourceId : null + } + { + name: 'logs' + properties: { + applicationLogs: { fileSystem: { level: 'Verbose' } } + detailedErrorMessages: { enabled: true } + failedRequestsTracing: { enabled: true } + httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } } - ] - marketplacePartnerId: diagnosticSetting.?marketplacePartnerResourceId - logAnalyticsDestinationType: diagnosticSetting.?logAnalyticsDestinationType + } + { + name:'web' + properties: { + vnetRouteAllEnabled: vnetRouteAllEnabled + } + } + ] + outboundVnetRouting: { + contentShareTraffic: contentShareTraffic + imagePullTraffic: imagePullTraffic } - scope: app + publicNetworkAccess: publicNetworkAccess + privateEndpoints: privateEndpoints + virtualNetworkSubnetResourceId: !empty(virtualNetworkSubnetId) ? virtualNetworkSubnetId : null + basicPublishingCredentialsPolicies: [ + { + name: 'ftp' + allow: false + } + { + name: 'scm' + allow: false + } + ] + diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : [] } -] - -module app_privateEndpoints 'br/public:avm/res/network/private-endpoint:0.12.0' = [ - for (privateEndpoint, index) in (privateEndpoints ?? []): { - name: '${uniqueString(deployment().name, location)}-app-PrivateEndpoint-${index}' - scope: resourceGroup( - split(privateEndpoint.?resourceGroupResourceId ?? resourceGroup().id, '/')[2], - split(privateEndpoint.?resourceGroupResourceId ?? resourceGroup().id, '/')[4] - ) - params: { - name: privateEndpoint.?name ?? 'pep-${last(split(app.id, '/'))}-${privateEndpoint.?service ?? 'sites'}-${index}' - privateLinkServiceConnections: privateEndpoint.?isManualConnection != true - ? [ - { - name: privateEndpoint.?privateLinkServiceConnectionName ?? '${last(split(app.id, '/'))}-${privateEndpoint.?service ?? 'sites'}-${index}' - properties: { - privateLinkServiceId: app.id - groupIds: [ - privateEndpoint.?service ?? 'sites' - ] - } - } - ] - : null - manualPrivateLinkServiceConnections: privateEndpoint.?isManualConnection == true - ? [ - { - name: privateEndpoint.?privateLinkServiceConnectionName ?? '${last(split(app.id, '/'))}-${privateEndpoint.?service ?? 'sites'}-${index}' - properties: { - privateLinkServiceId: app.id - groupIds: [ - privateEndpoint.?service ?? 'sites' - ] - requestMessage: privateEndpoint.?manualConnectionRequestMessage ?? 'Manual approval required.' - } - } - ] - : null - subnetResourceId: privateEndpoint.subnetResourceId - enableTelemetry: enableTelemetry - location: privateEndpoint.?location ?? reference( - split(privateEndpoint.subnetResourceId, '/subnets/')[0], - '2020-06-01', - 'Full' - ).location - lock: privateEndpoint.?lock ?? null - privateDnsZoneGroup: privateEndpoint.?privateDnsZoneGroup - roleAssignments: privateEndpoint.?roleAssignments - tags: privateEndpoint.?tags ?? tags - customDnsConfigs: privateEndpoint.?customDnsConfigs - ipConfigurations: privateEndpoint.?ipConfigurations - applicationSecurityGroupResourceIds: privateEndpoint.?applicationSecurityGroupResourceIds - customNetworkInterfaceName: privateEndpoint.?customNetworkInterfaceName - } - } -] - -@description('The name of the site.') -output name string = app.name - -@description('The resource ID of the site.') -output resourceId string = app.id - -@description('The resource group the site was deployed into.') -output resourceGroupName string = resourceGroup().name - -@description('The principal ID of the system assigned identity.') -output systemAssignedMIPrincipalId string? = app.?identity.?principalId - -@description('The location the resource was deployed into.') -output location string = app.location - -@description('Default hostname of the app.') -output defaultHostname string = 'https://${name}.azurewebsites.net' - -@description('Unique identifier that verifies the custom domains assigned to the app.') -output customDomainVerificationId string = app.properties.customDomainVerificationId - -@description('The outbound IP addresses of the app.') -output outboundIpAddresses string = app.properties.outboundIpAddresses - -// ================ // -// Definitions // -// ================ // -@export() -@description('The type of an app settings configuration.') -type appSettingsConfigType = { - @description('Required. The type of config.') - name: 'appsettings' | 'logs' +} - @description('Optional. If the provided storage account requires Identity based authentication.') - storageAccountUseIdentityAuthentication: bool? +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the App Service.') +output resourceId string = appService.outputs.resourceId - @description('Optional. Required if app of kind functionapp. Resource ID of the storage account to manage triggers and logging function executions.') - storageAccountResourceId: string? +@description('Name of the App Service.') +output name string = appService.outputs.name - @description('Optional. Resource ID of the application insight to leverage for this resource.') - applicationInsightResourceId: string? +@description('Default hostname of the App Service.') +output defaultHostname string = appService.outputs.defaultHostname - @description('Optional. The retain the current app settings. Defaults to true.') - retainCurrentAppSettings: bool? +@description('URL of the App Service.') +output appUrl string = 'https://${appService.outputs.defaultHostname}' - @description('Optional. The app settings key-value pairs.') - properties: { - @description('Required. An app settings key-value pair.') - *: string - }? -} +@description('System-assigned identity principal ID.') +output identityPrincipalId string = appService.outputs.?systemAssignedMIPrincipalId ?? '' diff --git a/infra/avm/modules/compute/container-instance.bicep b/infra/avm/modules/compute/container-instance.bicep index 9a6767a76..c840be20d 100644 --- a/infra/avm/modules/compute/container-instance.bicep +++ b/infra/avm/modules/compute/container-instance.bicep @@ -1,97 +1,96 @@ -// ========== container-instance.bicep ========== // -// Azure Container Instance module for backend API deployment +// ============================================================================ +// Module: Azure Container Instance (AVM) +// AVM Module: avm/res/container-instance/container-group:0.7.0 +// ============================================================================ -@description('Required. Name of the container group.') +@description('Name of the container group.') param name string -@description('Required. Location for the container instance.') +@description('Azure region for deployment.') param location string -@description('Optional. Tags for all resources.') +@description('Resource tags.') param tags object = {} -@description('Required. Container image to deploy.') +@description('Container image to deploy.') param containerImage string -@description('Optional. CPU cores for the container.') +@description('CPU cores for the container.') param cpu int = 2 -@description('Optional. Memory in GB for the container.') +@description('Memory in GB for the container.') param memoryInGB int = 4 -@description('Optional. Port to expose.') +@description('Port to expose.') param port int = 8000 -@description('Optional. Subnet resource ID for VNet integration. If empty, public IP will be used.') +@description('Environment variables for the container.') +param environmentVariables array = [] + +@description('Operating system type.') +@allowed(['Linux', 'Windows']) +param osType string = 'Linux' + +@description('Restart policy.') +@allowed(['Always', 'OnFailure', 'Never']) +param restartPolicy string = 'Always' + +@description('Managed identity configuration.') +param managedIdentities object = {} + +@description('Image registry credentials.') +param imageRegistryCredentials array = [] + +@description('Subnet resource ID for VNet integration. If empty, public IP is used.') param subnetResourceId string = '' -@description('Required. Environment variables for the container.') -param environmentVariables array +@description('Availability zone for the container group. Use -1 for no zone.') +param availabilityZone int = -1 -@description('Optional. Enable telemetry.') +@description('Enable Azure telemetry collection.') param enableTelemetry bool = true -@description('Required. User-assigned managed identity resource ID for ACR pull.') -param userAssignedIdentityResourceId string - +// ============================================================================ +// Variables +// ============================================================================ var isPrivateNetworking = !empty(subnetResourceId) -// ============== // -// Resources // -// ============== // - -#disable-next-line no-deployments-resources -resource avmTelemetry 'Microsoft.Resources/deployments@2025-04-01' = if (enableTelemetry) { - name: '46d3xbcp.res.containerinstance.${replace('-..--..-', '.', '-')}.${substring(uniqueString(deployment().name, 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: [] - } - } -} - -resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2025-09-01' = { - name: name - location: location - tags: tags - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${userAssignedIdentityResourceId}': {} - } - } - properties: { - containers: [ - { - name: name - properties: { - image: containerImage - resources: { - requests: { - cpu: cpu - memoryInGB: memoryInGB - } - } - ports: [ - { - port: port - protocol: 'TCP' - } - ] - environmentVariables: environmentVariables +var containers = [ + { + name: name + properties: { + image: containerImage + resources: { + requests: { + cpu: cpu + memoryInGB: string(memoryInGB) } } - ] - osType: 'Linux' - restartPolicy: 'Always' - subnetIds: isPrivateNetworking ? [ - { - id: subnetResourceId - } - ] : null + ports: [ + { + port: port + protocol: 'TCP' + } + ] + environmentVariables: environmentVariables + } + } +] + +// ============================================================================ +// Container Instance (AVM) +// ============================================================================ +module containerGroup 'br/public:avm/res/container-instance/container-group:0.7.0' = { + name: take('avm.res.containerinstance.${name}', 64) + params: { + name: name + location: location + tags: tags + enableTelemetry: enableTelemetry + containers: containers + osType: osType + restartPolicy: restartPolicy + managedIdentities: !empty(managedIdentities) ? managedIdentities : {} ipAddress: { type: isPrivateNetworking ? 'Private' : 'Public' ports: [ @@ -102,23 +101,20 @@ resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2025-09-01' ] dnsNameLabel: isPrivateNetworking ? null : name } - // Removed imageRegistryCredentials - ACR is public with anonymous pull enabled - // If you need managed identity auth, add AcrPull role to the managed identity on the ACR + imageRegistryCredentials: !empty(imageRegistryCredentials) ? imageRegistryCredentials : [] + subnets: isPrivateNetworking ? [{ subnetResourceId: subnetResourceId }] : [] + availabilityZone: availabilityZone } } -// ============== // -// Outputs // -// ============== // - +// ============================================================================ +// Outputs +// ============================================================================ @description('The name of the container group.') -output name string = containerGroup.name +output name string = containerGroup.outputs.name @description('The resource ID of the container group.') -output resourceId string = containerGroup.id - -@description('The IP address of the container (private or public depending on mode).') -output ipAddress string = containerGroup.properties.ipAddress.ip +output resourceId string = containerGroup.outputs.resourceId -@description('The FQDN of the container (only available for public mode).') -output fqdn string = isPrivateNetworking ? '' : containerGroup.properties.ipAddress.fqdn +@description('The IP address of the container group.') +output ipAddress string = containerGroup.outputs.?iPv4Address ?? '' diff --git a/infra/avm/modules/compute/container-registry.bicep b/infra/avm/modules/compute/container-registry.bicep index f2485e2ca..9e1783be4 100644 --- a/infra/avm/modules/compute/container-registry.bicep +++ b/infra/avm/modules/compute/container-registry.bicep @@ -1,53 +1,109 @@ // ============================================================================ -// Module: Container Registry -// Description: AVM wrapper for an Azure Container Registry used by the Docker -// build (bicep) deployment flavor. AZD builds and pushes the -// application images here. -// AVM Module: avm/res/container-registry/registry:0.9.0 +// Module: Azure Container Registry (AVM) +// AVM Module: avm/res/container-registry/registry:0.12.1 // ============================================================================ -@description('Required. Name of the container registry.') -param name string +@description('Solution name used for naming convention.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the container registry.') +param name string = replace('cr${solutionName}', '-', '') + +@description('Azure region for deployment.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Resource tags.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') +@description('SKU for the container registry.') +@allowed(['Basic', 'Standard', 'Premium']) +param sku string = 'Premium' + +@description('Enable admin user for the registry.') +param adminUserEnabled bool = false + +@description('Public network access setting.') +@allowed(['Enabled', 'Disabled']) +param publicNetworkAccess string = 'Enabled' + +@description('Export policy status. Must be "enabled" when publicNetworkAccess is "Enabled".') +param exportPolicyStatus string = 'enabled' + +@description('Principal IDs to assign AcrPull role.') +param acrPullPrincipalIds array = [] + +@description('Enable private networking.') +param enablePrivateNetworking bool = false + +@description('Subnet resource ID for private endpoint.') +param privateEndpointSubnetId string = '' + +@description('Private DNS zone resource IDs for private endpoint.') +param privateDnsZoneResourceIds array = [] + +@description('Default action for the network rule set. Use Allow when no private endpoint is in place; Deny for private-only.') +@allowed(['Allow', 'Deny']) +param networkRuleSetDefaultAction string = 'Allow' + +@description('Enable Azure telemetry collection.') param enableTelemetry bool = true -@description('Required. Principal ID of the managed identity to grant AcrPull.') -param principalId string +// ============================================================================ +// Role Assignments +// ============================================================================ +var acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' + +var roleAssignments = [for principalId in acrPullPrincipalIds: { + principalId: principalId + roleDefinitionIdOrName: acrPullRoleId + principalType: 'ServicePrincipal' +}] + +// ============================================================================ +// Private Endpoint Config +// ============================================================================ +var dnsZoneConfigs = [for (zoneId, i) in privateDnsZoneResourceIds: { + name: 'config${i}' + privateDnsZoneResourceId: zoneId +}] + +var privateEndpointConfig = enablePrivateNetworking && !empty(privateEndpointSubnetId) ? [ + { + subnetResourceId: privateEndpointSubnetId + privateDnsZoneGroup: !empty(privateDnsZoneResourceIds) ? { + privateDnsZoneGroupConfigs: dnsZoneConfigs + } : null + } +] : [] -module registry 'br/public:avm/res/container-registry/registry:0.9.0' = { - name: take('avm.res.container-registry.registry.${name}', 64) +// ============================================================================ +// Container Registry (AVM) +// ============================================================================ +module containerRegistry 'br/public:avm/res/container-registry/registry:0.12.1' = { + name: take('avm.res.containerregistry.${name}', 64) params: { name: name location: location tags: tags enableTelemetry: enableTelemetry - acrSku: 'Standard' - acrAdminUserEnabled: false - anonymousPullEnabled: false - publicNetworkAccess: 'Enabled' - networkRuleBypassOptions: 'AzureServices' - roleAssignments: [ - { - principalId: principalId - roleDefinitionIdOrName: '7f951dda-4ed3-4680-a7ca-43fe172d538d' // AcrPull - principalType: 'ServicePrincipal' - } - ] + acrSku: sku + acrAdminUserEnabled: adminUserEnabled + publicNetworkAccess: publicNetworkAccess + exportPolicyStatus: exportPolicyStatus + roleAssignments: !empty(acrPullPrincipalIds) ? roleAssignments : [] + privateEndpoints: privateEndpointConfig + networkRuleSetDefaultAction: networkRuleSetDefaultAction } } -@description('Resource ID of the container registry.') -output resourceId string = registry.outputs.resourceId +// ============================================================================ +// Outputs +// ============================================================================ +@description('The name of the container registry.') +output name string = containerRegistry.outputs.name -@description('Name of the container registry.') -output name string = registry.outputs.name +@description('The login server URL.') +output loginServer string = containerRegistry.outputs.loginServer -@description('Login server of the container registry.') -output loginServer string = registry.outputs.loginServer +@description('The resource ID of the container registry.') +output resourceId string = containerRegistry.outputs.resourceId diff --git a/infra/avm/modules/compute/kubernetes.bicep b/infra/avm/modules/compute/kubernetes.bicep index 54053dd4a..a15a362ef 100644 --- a/infra/avm/modules/compute/kubernetes.bicep +++ b/infra/avm/modules/compute/kubernetes.bicep @@ -156,3 +156,9 @@ output resourceId string = aksCluster.outputs.resourceId @description('FQDN of the AKS cluster.') output fqdn string = aksCluster.outputs.?fqdn ?? '' + +@description('Object ID of the AKS kubelet system-assigned managed identity (used by pods at runtime via IMDS).') +output kubeletIdentityObjectId string = aksCluster.outputs.?kubeletIdentityObjectId ?? '' + +@description('Principal ID of the AKS control-plane system-assigned managed identity.') +output systemAssignedMIPrincipalId string = aksCluster.outputs.?systemAssignedMIPrincipalId ?? '' diff --git a/infra/avm/modules/compute/virtual-machine.bicep b/infra/avm/modules/compute/virtual-machine.bicep index 3f13b1fd4..8b9c2a7e0 100644 --- a/infra/avm/modules/compute/virtual-machine.bicep +++ b/infra/avm/modules/compute/virtual-machine.bicep @@ -1,108 +1,157 @@ // ============================================================================ // Module: Virtual Machine (Jumpbox) -// Description: AVM wrapper for a Windows jumpbox VM used for private network -// administration via Azure Bastion. -// AVM Module: avm/res/compute/virtual-machine:0.21.0 +// Description: AVM wrapper for Azure Virtual Machine with Entra ID authentication +// AVM Module: avm/res/compute/virtual-machine +// Ref: https://learn.microsoft.com/azure/bastion/bastion-entra-id-authentication // ============================================================================ -@description('Required. Name of the virtual machine (max 15 chars).') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the virtual machine.') +param name string = 'vm-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -param enableTelemetry bool = true - -@description('Optional. VM size.') +@description('VM size.') param vmSize string = 'Standard_D2s_v5' -@description('Required. Admin username for the VM.') +@secure() +@description('Local admin username. Required by Azure at provisioning time but not used for login when Entra ID is enabled.') param adminUsername string @secure() -@description('Required. Admin password for the VM.') +@description('Local admin password. Required by Azure at provisioning time but not used for login when Entra ID is enabled.') param adminPassword string -@description('Required. Resource ID of the user assigned managed identity.') -param userAssignedIdentityResourceId string - -@description('Required. Resource ID of the subnet to attach the NIC to.') +@description('Resource ID of the subnet for the VM NIC.') param subnetResourceId string -@description('Optional. Availability zone (-1 to disable).') -param availabilityZone int = -1 +@description('OS type for the VM.') +param osType string = 'Windows' + +@description('Availability zone for the VM.') +param availabilityZone int = 1 + +@description('Image reference for the VM.') +param imageReference object = { + publisher: 'microsoft-dsvm' + offer: 'dsvm-win-2022' + sku: 'winserver-2022' + version: 'latest' +} -@description('Optional. Enable monitoring agent and DCR association.') -param enableMonitoring bool = false +@description('OS disk size in GB.') +param osDiskSizeGB int = 128 -@description('Optional. Resource ID of the Data Collection Rule to associate.') -param dataCollectionRuleResourceId string = '' +@description('Resource ID of the maintenance configuration.') +param maintenanceConfigurationResourceId string? -module vm 'br/public:avm/res/compute/virtual-machine:0.21.0' = { +@description('Resource ID of the proximity placement group.') +param proximityPlacementGroupResourceId string? + +@description('Monitoring agent extension configuration (data collection rule associations).') +param extensionMonitoringAgentConfig object? + +@description('Diagnostic settings for the resource.') +param diagnosticSettings array? + +@description('Enable Azure telemetry collection.') +param enableTelemetry bool = true + +@description('Deploying user principal ID. Used for default role assignment to grant the deploying user login access to the VM. This is required because with Entra ID authentication enabled, local accounts cannot be used to access the VM, including the local admin account created at provisioning.') +param deployingUserPrincipalId string + +@description('Deploying user principal type. Used for default role assignment to grant the deploying user login access to the VM. This is required because with Entra ID authentication enabled, local accounts cannot be used to access the VM, including the local admin account created at provisioning.') +param deployingUserPrincipalType string = 'User' + +@description('Role assignments to apply to the virtual machine.') +param roleAssignments array = [ + { + roleDefinitionIdOrName: '1c0163c0-47e6-4577-8991-ea5c82e286e4' // Virtual Machine Administrator Login + principalId: deployingUserPrincipalId + principalType: deployingUserPrincipalType + } +] + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.22.0' = { name: take('avm.res.compute.virtual-machine.${name}', 64) params: { - name: take(name, 15) + name: name + location: location + tags: tags enableTelemetry: enableTelemetry computerName: take(name, 15) - osType: 'Windows' + osType: osType vmSize: vmSize adminUsername: adminUsername adminPassword: adminPassword - managedIdentities: { - userAssignedResourceIds: [ - userAssignedIdentityResourceId - ] - } + managedIdentities: { systemAssigned: true } + patchMode: 'AutomaticByPlatform' + bypassPlatformSafetyChecksOnUserSchedule: true + maintenanceConfigurationResourceId: maintenanceConfigurationResourceId + enableAutomaticUpdates: true + encryptionAtHost: true availabilityZone: availabilityZone - imageReference: { - publisher: 'microsoft-dsvm' - offer: 'dsvm-win-2022' - sku: 'winserver-2022' - version: 'latest' + proximityPlacementGroupResourceId: proximityPlacementGroupResourceId + imageReference: imageReference + osDisk: { + name: 'osdisk-${name}' + caching: 'ReadWrite' + createOption: 'FromImage' + deleteOption: 'Delete' + diskSizeGB: osDiskSizeGB + managedDisk: { storageAccountType: 'Premium_LRS' } } nicConfigurations: [ { name: 'nic-${name}' - enableAcceleratedNetworking: true + tags: tags + deleteOption: 'Delete' + diagnosticSettings: diagnosticSettings ipConfigurations: [ { - name: 'ipconfig01' + name: '${name}-nic01-ipconfig01' subnetResourceId: subnetResourceId + diagnosticSettings: diagnosticSettings } ] } ] - osDisk: { - caching: 'ReadWrite' - diskSizeGB: 128 - managedDisk: { - storageAccountType: 'Premium_LRS' - } + roleAssignments: roleAssignments + extensionAadJoinConfig: { + enabled: true + tags: tags + typeHandlerVersion: '2.0' + settings: { mdmId: '' } } - encryptionAtHost: false // Some Azure subscriptions do not support encryption at host - extensionMonitoringAgentConfig: { - enabled: enableMonitoring - dataCollectionRuleAssociations: enableMonitoring && !empty(dataCollectionRuleResourceId) - ? [ - { - name: 'dcra-${name}' - dataCollectionRuleResourceId: dataCollectionRuleResourceId - description: 'Associates the Windows security event DCR with the jumpbox VM.' - } - ] - : [] + extensionAntiMalwareConfig: { + enabled: true + settings: { + AntimalwareEnabled: 'true' + Exclusions: {} + RealtimeProtectionEnabled: 'true' + ScheduledScanSettings: { day: '7', isEnabled: 'true', scanType: 'Quick', time: '120' } + } + tags: tags } - location: location - tags: tags + extensionMonitoringAgentConfig: extensionMonitoringAgentConfig + extensionNetworkWatcherAgentConfig: { enabled: true, tags: tags } } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the virtual machine.') -output resourceId string = vm.outputs.resourceId +output resourceId string = virtualMachine.outputs.resourceId @description('Name of the virtual machine.') -output name string = vm.outputs.name +output name string = virtualMachine.outputs.name diff --git a/infra/avm/modules/data/cosmos-db-nosql.bicep b/infra/avm/modules/data/cosmos-db-nosql.bicep index bccef9194..49c39f760 100644 --- a/infra/avm/modules/data/cosmos-db-nosql.bicep +++ b/infra/avm/modules/data/cosmos-db-nosql.bicep @@ -1,94 +1,110 @@ // ============================================================================ -// Module: Cosmos DB (NoSQL) -// Description: AVM wrapper for an Azure Cosmos DB account (SQL/NoSQL API) -// used for conversation history and product metadata. +// Module: Cosmos DB +// Description: AVM wrapper for Azure Cosmos DB (NoSQL) with WAF alignment // AVM Module: avm/res/document-db/database-account:0.19.0 +// WAF: https://learn.microsoft.com/azure/well-architected/service-guides/cosmos-db // ============================================================================ -@description('Required. Name of the Cosmos DB account.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the Cosmos DB account.') +param name string = 'cosmos-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} +@description('Database name.') +param databaseName string = 'db_conversation_history' + +@description('Container definitions.') +param containers array = [ + { + name: 'conversations' + partitionKeyPath: '/userId' + } +] + @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Required. Name of the SQL database.') -param databaseName string +// --- WAF: Monitoring --- +@description('Diagnostic settings for monitoring.') +param diagnosticSettings array = [] -@description('Required. Containers to create in the SQL database.') -param containers array +// --- WAF: Private Networking --- +@description('Public network access setting.') +param publicNetworkAccess string = 'Enabled' -@description('Required. Principal ID of the managed identity to grant data contributor.') -param principalId string +@description('Whether to enable private networking.') +param enablePrivateNetworking bool = false -@description('Required. Principal ID of the deploying user/service principal.') -param deployerPrincipalId string +@description('Subnet resource ID for the private endpoint.') +param privateEndpointSubnetId string = '' -@description('Optional. Enable redundancy (zone redundancy + automatic failover).') -param enableRedundancy bool = false +@description('Private DNS zone resource IDs for Cosmos DB.') +param privateDnsZoneResourceIds array = [] -@description('Optional. High-availability failover region used when redundancy is enabled.') -param haLocation string = '' +var privateDnsZoneConfigs = [for (zoneId, i) in privateDnsZoneResourceIds: { + name: 'dns-zone-${i}' + privateDnsZoneResourceId: zoneId +}] -@description('Optional. Enable private networking (disables public access, adds private endpoint).') -param enablePrivateNetworking bool = false +// --- WAF: Redundancy --- +@description('Enable zone redundancy.') +param zoneRedundant bool = false -@description('Optional. Subnet resource ID for the private endpoint.') -param subnetResourceId string = '' +@description('Enable automatic failover.') +param enableAutomaticFailover bool = false -@description('Optional. Resource ID of the Cosmos DB private DNS zone.') -param cosmosDnsZoneResourceId string = '' - -@description('Optional. Diagnostic settings for monitoring.') -param diagnosticSettings array = [] +@description('Optional. HA paired region for multi-region failover when redundancy is enabled.') +param haLocation string = '' -module cosmos 'br/public:avm/res/document-db/database-account:0.19.0' = { +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module cosmosAccount 'br/public:avm/res/document-db/database-account:0.19.0' = { name: take('avm.res.document-db.database-account.${name}', 64) params: { name: name location: location tags: tags enableTelemetry: enableTelemetry + capabilitiesToAdd: zoneRedundant ? [] : ['EnableServerless'] sqlDatabases: [ { name: databaseName - containers: containers - } - ] - sqlRoleDefinitions: [ - { - roleName: 'contentgen-data-contributor' - dataActions: [ - 'Microsoft.DocumentDB/databaseAccounts/readMetadata' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' - ] + containers: [for container in containers: { + name: container.name + paths: [container.partitionKeyPath] + kind: 'Hash' + version: 2 + }] } ] - sqlRoleAssignments: [ - { - principalId: principalId - roleDefinitionId: '00000000-0000-0000-0000-000000000002' // Built-in Cosmos DB Data Contributor - } - { - principalId: deployerPrincipalId - roleDefinitionId: '00000000-0000-0000-0000-000000000002' // Built-in Cosmos DB Data Contributor to the deployer - } - ] - diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : null + sqlRoleAssignments: [] + diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : [] networkRestrictions: { - networkAclBypass: 'AzureServices' - publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + networkAclBypass: 'None' + publicNetworkAccess: publicNetworkAccess } - zoneRedundant: enableRedundancy - capabilitiesToAdd: enableRedundancy ? null : ['EnableServerless'] - enableAutomaticFailover: enableRedundancy - failoverLocations: enableRedundancy + privateEndpoints: enablePrivateNetworking ? [ + { + name: 'pep-${name}' + customNetworkInterfaceName: 'nic-${name}' + subnetResourceId: privateEndpointSubnetId + service: 'Sql' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: privateDnsZoneConfigs + } + } + ] : [] + zoneRedundant: zoneRedundant + enableAutomaticFailover: enableAutomaticFailover + failoverLocations: zoneRedundant ? [ { failoverPriority: 0 @@ -108,24 +124,23 @@ module cosmos 'br/public:avm/res/document-db/database-account:0.19.0' = { isZoneRedundant: false } ] - privateEndpoints: enablePrivateNetworking - ? [ - { - service: 'Sql' - subnetResourceId: subnetResourceId - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { privateDnsZoneResourceId: cosmosDnsZoneResourceId } - ] - } - } - ] - : null } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the Cosmos DB account.') -output resourceId string = cosmos.outputs.resourceId +output resourceId string = cosmosAccount.outputs.resourceId @description('Name of the Cosmos DB account.') -output name string = cosmos.outputs.name +output name string = cosmosAccount.outputs.name + +@description('Endpoint of the Cosmos DB account.') +output endpoint string = 'https://${name}.documents.azure.com:443/' + +@description('Database name.') +output databaseName string = databaseName + +@description('Container name (first container).') +output containerName string = containers[0].name diff --git a/infra/avm/modules/data/storage-account.bicep b/infra/avm/modules/data/storage-account.bicep index 8e0a11daf..329a532be 100644 --- a/infra/avm/modules/data/storage-account.bicep +++ b/infra/avm/modules/data/storage-account.bicep @@ -1,95 +1,139 @@ // ============================================================================ // Module: Storage Account -// Description: AVM wrapper for an Azure Storage Account (blob) used for -// product and generated image storage. +// Description: AVM wrapper for Azure Storage Account with WAF alignment // AVM Module: avm/res/storage/storage-account:0.32.0 +// WAF: https://learn.microsoft.com/azure/well-architected/service-guides/storage-accounts // ============================================================================ -@description('Required. Name of the storage account.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the storage account.') +param name string = take('st${toLower(replace(solutionName, '-', ''))}', 24) + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} +@description('Storage account SKU.') +param skuName string = 'Standard_LRS' + +@description('Storage account kind.') +param kind string = 'StorageV2' + +@description('Access tier.') +@allowed(['Hot', 'Cool']) +param accessTier string = 'Hot' + +@description('Allow blob public access.') +param allowBlobPublicAccess bool = false + +@description('Allow shared key access.') +param allowSharedKeyAccess bool = true + +@description('Enable hierarchical namespace (Data Lake Storage Gen2).') +param enableHierarchicalNamespace bool = false + @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Optional. Storage account SKU.') -param skuName string = 'Standard_LRS' +@description('Blob containers to create.') +param containers array = [ + { + name: 'default' + publicAccess: 'None' + } +] -@description('Required. Blob containers to create.') -param containers array +// --- WAF: Monitoring --- +@description('Diagnostic settings for monitoring.') +param diagnosticSettings array = [] -@description('Required. Principal ID of the managed identity to grant Storage Blob Data Contributor.') -param principalId string +// --- WAF: Private Networking --- +@description('Public network access setting.') +param publicNetworkAccess string = 'Enabled' -@description('Optional. Enable private networking (disables public access, adds private endpoint).') +@description('Network ACLs for the storage account.') +param networkAcls object = { + defaultAction: 'Allow' + bypass: 'AzureServices' +} + +@description('Whether to enable private networking.') param enablePrivateNetworking bool = false -@description('Optional. Subnet resource ID for the private endpoint.') -param subnetResourceId string = '' +@description('Subnet resource ID for the private endpoint.') +param privateEndpointSubnetId string = '' -@description('Optional. Resource ID of the blob private DNS zone.') -param blobDnsZoneResourceId string = '' +@description('Private DNS zone resource IDs for Storage (blob).') +param privateDnsZoneResourceIds array = [] -@description('Optional. Diagnostic settings for monitoring.') -param diagnosticSettings array = [] +var privateDnsZoneConfigs = [for (zoneId, i) in privateDnsZoneResourceIds: { + name: 'dns-zone-${i}' + privateDnsZoneResourceId: zoneId +}] -module storageAccount 'br/public:avm/res/storage/storage-account:0.32.0' = { +// --- Role Assignments --- +@description('Optional. Array of role assignments to create on the Storage Account.') +param roleAssignments array = [] + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module storage 'br/public:avm/res/storage/storage-account:0.32.0' = { name: take('avm.res.storage.storage-account.${name}', 64) params: { name: name location: location + tags: tags + enableTelemetry: enableTelemetry skuName: skuName - managedIdentities: { systemAssigned: true } + kind: kind + accessTier: accessTier + allowBlobPublicAccess: allowBlobPublicAccess + allowSharedKeyAccess: allowSharedKeyAccess + enableHierarchicalNamespace: enableHierarchicalNamespace minimumTlsVersion: 'TLS1_2' - requireInfrastructureEncryption: true - enableTelemetry: enableTelemetry - tags: tags - accessTier: 'Hot' supportsHttpsTrafficOnly: true + requireInfrastructureEncryption: true + publicNetworkAccess: publicNetworkAccess + networkAcls: networkAcls blobServices: { - containerDeleteRetentionPolicyEnabled: true - containerDeleteRetentionPolicyDays: 7 - deleteRetentionPolicyEnabled: true - deleteRetentionPolicyDays: 7 - containers: containers + containers: [for container in containers: { + name: container.name + publicAccess: container.publicAccess + }] + diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : [] } - roleAssignments: [ + diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : [] + privateEndpoints: enablePrivateNetworking ? [ { - principalId: principalId - roleDefinitionIdOrName: 'Storage Blob Data Contributor' - principalType: 'ServicePrincipal' + name: 'pep-${name}' + customNetworkInterfaceName: 'nic-${name}' + subnetResourceId: privateEndpointSubnetId + service: 'blob' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: privateDnsZoneConfigs + } } - ] - networkAcls: { - bypass: 'AzureServices' - defaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' - } - allowBlobPublicAccess: false - publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - privateEndpoints: enablePrivateNetworking - ? [ - { - service: 'blob' - subnetResourceId: subnetResourceId - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { privateDnsZoneResourceId: blobDnsZoneResourceId } - ] - } - } - ] - : null - diagnosticSettings: !empty(diagnosticSettings) ? diagnosticSettings : null + ] : [] + roleAssignments: !empty(roleAssignments) ? roleAssignments : [] } } -@description('Resource ID of the storage account.') -output resourceId string = storageAccount.outputs.resourceId +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the Storage Account.') +output resourceId string = storage.outputs.resourceId -@description('Name of the storage account.') -output name string = storageAccount.outputs.name +@description('Name of the Storage Account.') +output name string = storage.outputs.name + +@description('Primary blob endpoint.') +output blobEndpoint string = storage.outputs.primaryBlobEndpoint + +@description('Service endpoints.') +output serviceEndpoints object = storage.outputs.serviceEndpoints diff --git a/infra/avm/modules/monitoring/app-insights.bicep b/infra/avm/modules/monitoring/app-insights.bicep index aac01169b..b726ae81d 100644 --- a/infra/avm/modules/monitoring/app-insights.bicep +++ b/infra/avm/modules/monitoring/app-insights.bicep @@ -1,44 +1,76 @@ // ============================================================================ // Module: Application Insights -// Description: AVM wrapper for Azure Application Insights component. +// Description: AVM wrapper for Application Insights with WAF alignment // AVM Module: avm/res/insights/component:0.7.1 +// WAF: https://learn.microsoft.com/azure/well-architected/service-guides/application-insights // ============================================================================ -@description('Required. Name of the Application Insights component.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Optional. Override name for the Application Insights instance. Defaults to appi-{solutionName}.') +param name string = 'appi-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} +@description('Resource ID of the Log Analytics workspace to link to.') +param workspaceResourceId string + +@description('Application type.') +param applicationType string = 'web' + +@description('Retention period in days. WAF recommends 365.') +param retentionInDays int = 365 + +@description('Disable IP masking for security. WAF recommends false.') +param disableIpMasking bool = false + +@description('Flow type for Application Insights.') +param flowType string = 'Bluefield' + @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Required. Resource ID of the Log Analytics workspace to link.') -param workspaceResourceId string +@description('Kind of Application Insights resource.') +param kind string = 'web' -module component 'br/public:avm/res/insights/component:0.7.1' = { +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module appInsights 'br/public:avm/res/insights/component:0.7.1' = { name: take('avm.res.insights.component.${name}', 64) params: { name: name - tags: tags location: location - enableTelemetry: enableTelemetry - retentionInDays: 365 - kind: 'web' - disableIpMasking: false - flowType: 'Bluefield' + tags: tags workspaceResourceId: workspaceResourceId + kind: kind + applicationType: applicationType + enableTelemetry: enableTelemetry + retentionInDays: retentionInDays + disableIpMasking: disableIpMasking + flowType: flowType } } -@description('Resource ID of the Application Insights component.') -output resourceId string = component.outputs.resourceId +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the Application Insights instance.') +output resourceId string = appInsights.outputs.resourceId + +@description('Name of the Application Insights instance.') +output name string = appInsights.outputs.name + +@description('Instrumentation key for the Application Insights instance.') +output instrumentationKey string = appInsights.outputs.instrumentationKey -@description('Name of the Application Insights component.') -output name string = component.outputs.name +@description('Connection string for the Application Insights instance.') +output connectionString string = appInsights.outputs.connectionString -@description('Connection string of the Application Insights component.') -output connectionString string = component.outputs.connectionString +@description('Application ID of the Application Insights instance.') +output applicationId string = appInsights.outputs.applicationId diff --git a/infra/avm/modules/monitoring/data-collection-rule.bicep b/infra/avm/modules/monitoring/data-collection-rule.bicep index ea2bba495..c1fd7606c 100644 --- a/infra/avm/modules/monitoring/data-collection-rule.bicep +++ b/infra/avm/modules/monitoring/data-collection-rule.bicep @@ -1,46 +1,112 @@ // ============================================================================ -// Module: Data Collection Rule (Windows Security Events) -// Description: AVM wrapper for an Azure Monitor Data Collection Rule that -// collects Windows Security audit events from the jumpbox VM -// (SFI-AzTBv17 compliance). -// AVM Module: avm/res/insights/data-collection-rule:0.11.0 +// Module: Data Collection Rule +// Description: AVM wrapper for Azure Monitor Data Collection Rule +// AVM Module: avm/res/insights/data-collection-rule +// WAF: Monitoring for VM observability // ============================================================================ -@description('Required. Name of the data collection rule.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Optional. Override name for the data collection rule. Defaults to dcr-{solutionName}.') +param name string = 'dcr-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} +@description('Resource ID of the Log Analytics workspace destination.') +param logAnalyticsWorkspaceResourceId string + +@description('Name of the Log Analytics workspace (used for destination naming).') +param logAnalyticsWorkspaceName string = '' + @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Required. Resource ID of the Log Analytics workspace destination.') -param workspaceResourceId string +var dcrLogAnalyticsDestinationName = !empty(logAnalyticsWorkspaceName) ? 'la-${logAnalyticsWorkspaceName}-destination' : 'la-${name}-destination' -@description('Optional. Name of the Log Analytics destination.') -param destinationName string = 'la-destination' - -module dcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = { +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module dataCollectionRule 'br/public:avm/res/insights/data-collection-rule:0.11.0' = { name: take('avm.res.insights.data-collection-rule.${name}', 64) params: { name: name - location: location tags: tags enableTelemetry: enableTelemetry + location: location dataCollectionRuleProperties: { kind: 'Windows' - description: 'Collects Windows Security audit success/failure events from jumpbox VM (SFI-AzTBv17 compliance).' dataSources: { + performanceCounters: [ + { + streams: ['Microsoft-Perf'] + samplingFrequencyInSeconds: 60 + counterSpecifiers: [ + '\\Processor Information(_Total)\\% Processor Time' + '\\Processor Information(_Total)\\% Privileged Time' + '\\Processor Information(_Total)\\% User Time' + '\\Processor Information(_Total)\\Processor Frequency' + '\\System\\Processes' + '\\Process(_Total)\\Thread Count' + '\\Process(_Total)\\Handle Count' + '\\System\\System Up Time' + '\\System\\Context Switches/sec' + '\\System\\Processor Queue Length' + '\\Memory\\% Committed Bytes In Use' + '\\Memory\\Available Bytes' + '\\Memory\\Committed Bytes' + '\\Memory\\Cache Bytes' + '\\Memory\\Pool Paged Bytes' + '\\Memory\\Pool Nonpaged Bytes' + '\\Memory\\Pages/sec' + '\\Memory\\Page Faults/sec' + '\\Process(_Total)\\Working Set' + '\\Process(_Total)\\Working Set - Private' + '\\LogicalDisk(_Total)\\% Disk Time' + '\\LogicalDisk(_Total)\\% Disk Read Time' + '\\LogicalDisk(_Total)\\% Disk Write Time' + '\\LogicalDisk(_Total)\\% Idle Time' + '\\LogicalDisk(_Total)\\Disk Bytes/sec' + '\\LogicalDisk(_Total)\\Disk Read Bytes/sec' + '\\LogicalDisk(_Total)\\Disk Write Bytes/sec' + '\\LogicalDisk(_Total)\\Disk Transfers/sec' + '\\LogicalDisk(_Total)\\Disk Reads/sec' + '\\LogicalDisk(_Total)\\Disk Writes/sec' + '\\LogicalDisk(_Total)\\Avg. Disk sec/Transfer' + '\\LogicalDisk(_Total)\\Avg. Disk sec/Read' + '\\LogicalDisk(_Total)\\Avg. Disk sec/Write' + '\\LogicalDisk(_Total)\\Avg. Disk Queue Length' + '\\LogicalDisk(_Total)\\Avg. Disk Read Queue Length' + '\\LogicalDisk(_Total)\\Avg. Disk Write Queue Length' + '\\LogicalDisk(_Total)\\% Free Space' + '\\LogicalDisk(_Total)\\Free Megabytes' + '\\Network Interface(*)\\Bytes Total/sec' + '\\Network Interface(*)\\Bytes Sent/sec' + '\\Network Interface(*)\\Bytes Received/sec' + '\\Network Interface(*)\\Packets/sec' + '\\Network Interface(*)\\Packets Sent/sec' + '\\Network Interface(*)\\Packets Received/sec' + '\\Network Interface(*)\\Packets Outbound Errors' + '\\Network Interface(*)\\Packets Received Errors' + ] + name: 'perfCounterDataSource60' + } + ] windowsEventLogs: [ { - name: 'securityEventLogsDataSource' - streams: [ - 'Microsoft-SecurityEvent' + name: 'SecurityAuditEvents' + streams: ['Microsoft-WindowsEvent'] + xPathQueries: [ + 'Security!*[System[(EventID=4624 or EventID=4625)]]' ] + } + { + name: 'AuditSuccessFailure' + streams: ['Microsoft-Event'] xPathQueries: [ 'Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]' ] @@ -50,27 +116,34 @@ module dcr 'br/public:avm/res/insights/data-collection-rule:0.11.0' = { destinations: { logAnalytics: [ { - name: destinationName - workspaceResourceId: workspaceResourceId + workspaceResourceId: logAnalyticsWorkspaceResourceId + name: dcrLogAnalyticsDestinationName } ] } dataFlows: [ { - streams: [ - 'Microsoft-SecurityEvent' - ] - destinations: [ - destinationName - ] + streams: ['Microsoft-Perf'] + destinations: [dcrLogAnalyticsDestinationName] + transformKql: 'source' + outputStream: 'Microsoft-Perf' + } + { + streams: ['Microsoft-Event'] + destinations: [dcrLogAnalyticsDestinationName] + transformKql: 'source' + outputStream: 'Microsoft-Event' } ] } } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the data collection rule.') -output resourceId string = dcr.outputs.resourceId +output resourceId string = dataCollectionRule.outputs.resourceId @description('Name of the data collection rule.') -output name string = dcr.outputs.name +output name string = dataCollectionRule.outputs.name diff --git a/infra/avm/modules/monitoring/log-analytics.bicep b/infra/avm/modules/monitoring/log-analytics.bicep index b93a4f342..3b231240c 100644 --- a/infra/avm/modules/monitoring/log-analytics.bicep +++ b/infra/avm/modules/monitoring/log-analytics.bicep @@ -1,55 +1,90 @@ // ============================================================================ // Module: Log Analytics Workspace -// Description: AVM wrapper for Azure Log Analytics Workspace. +// Description: AVM wrapper for Log Analytics Workspace with WAF alignment // AVM Module: avm/res/operational-insights/workspace:0.15.0 +// WAF: https://learn.microsoft.com/azure/well-architected/service-guides/azure-log-analytics +// Note: This module only handles NEW workspace creation. +// Existing workspace logic is handled in main.bicep. // ============================================================================ -@description('Required. Name of the Log Analytics workspace.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Optional. Override name for the Log Analytics workspace. Defaults to log-{solutionName}.') +param name string = 'log-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} +@description('Retention period in days. WAF recommends 365.') +param retentionInDays int = 365 + +@description('SKU name for the workspace.') +param skuName string = 'PerGB2018' + @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Optional. Enable redundancy (cross-region replication and daily quota).') -param enableRedundancy bool = false +// --- WAF: Private Networking --- +@description('Public network access for ingestion.') +param publicNetworkAccessForIngestion string = 'Enabled' + +@description('Public network access for query.') +param publicNetworkAccessForQuery string = 'Enabled' -@description('Optional. Replica region used when redundancy is enabled.') -param replicaLocation string = '' +// --- WAF: Redundancy --- +@description('Enable workspace replication for redundancy.') +param enableReplication bool = false -@description('Optional. Disable public network access (private networking).') -param enablePrivateNetworking bool = false +@description('Replication location (paired region).') +param replicationLocation string = '' +@description('Daily quota in GB. WAF recommends 150 GB/day as starting point.') +param dailyQuotaGb string = '' + +// --- WAF: Monitoring (VM data sources for private networking) --- +@description('Data sources for VM monitoring (Windows events, perf counters).') +param dataSources array = [] + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ module workspace 'br/public:avm/res/operational-insights/workspace:0.15.0' = { name: take('avm.res.operational-insights.workspace.${name}', 64) params: { name: name - tags: tags location: location + tags: tags + dataRetention: retentionInDays + skuName: skuName enableTelemetry: enableTelemetry - skuName: 'PerGB2018' - dataRetention: 365 features: { enableLogAccessUsingOnlyResourcePermissions: true } diagnosticSettings: [{ useThisWorkspace: true }] - dailyQuotaGb: enableRedundancy ? '10' : null - replication: enableRedundancy - ? { - enabled: true - location: replicaLocation - } - : null - publicNetworkAccessForIngestion: enablePrivateNetworking ? 'Disabled' : 'Enabled' - publicNetworkAccessForQuery: enablePrivateNetworking ? 'Disabled' : 'Enabled' + publicNetworkAccessForIngestion: publicNetworkAccessForIngestion + publicNetworkAccessForQuery: publicNetworkAccessForQuery + dailyQuotaGb: !empty(dailyQuotaGb) ? dailyQuotaGb : null + replication: enableReplication ? { + enabled: true + location: replicationLocation + } : null + dataSources: !empty(dataSources) ? dataSources : null } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the Log Analytics workspace.') output resourceId string = workspace.outputs.resourceId @description('Name of the Log Analytics workspace.') output name string = workspace.outputs.name + +@description('Location of the workspace.') +output location string = location + +@description('Log Analytics workspace customer ID.') +output logAnalyticsWorkspaceId string = workspace.outputs.logAnalyticsWorkspaceId diff --git a/infra/avm/modules/networking/bastion-host.bicep b/infra/avm/modules/networking/bastion-host.bicep index d1c804987..bf524087e 100644 --- a/infra/avm/modules/networking/bastion-host.bicep +++ b/infra/avm/modules/networking/bastion-host.bicep @@ -1,58 +1,85 @@ // ============================================================================ // Module: Bastion Host -// Description: AVM wrapper for an Azure Bastion host (Standard SKU). -// AVM Module: avm/res/network/bastion-host:0.8.2 +// Description: AVM wrapper for Azure Bastion Host +// AVM Module: avm/res/network/bastion-host // ============================================================================ -@description('Required. Name of the Bastion host.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +var name = 'bas-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Required. Resource ID of the virtual network to attach the Bastion host to.') +@description('Resource ID of the virtual network.') param virtualNetworkResourceId string -@description('Optional. Resource ID of the Log Analytics workspace for diagnostics. Empty disables diagnostics.') -param logAnalyticsWorkspaceResourceId string = '' +@description('Optional. Diagnostic settings for the resource.') +param diagnosticSettings array? + +@description('SKU name for the Bastion Host.') +param skuName string = 'Standard' + +@description('Number of scale units.') +param scaleUnits int = 4 + +@description('Disable copy/paste functionality.') +param disableCopyPaste bool = true + +@description('Enable file copy functionality.') +param enableFileCopy bool = false + +@description('Enable IP Connect functionality.') +param enableIpConnect bool = false + +@description('Enable shareable link functionality.') +param enableShareableLink bool = false + +@description('Availability zones for the Bastion Host public IP. Pass empty array to disable zone redundancy.') +param availabilityZones array = [] -module bastion 'br/public:avm/res/network/bastion-host:0.8.2' = { +@description('Optional. Diagnostic settings for the public IP address.') +param publicIPDiagnosticSettings array? + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module bastionHost 'br/public:avm/res/network/bastion-host:0.8.2' = { name: take('avm.res.network.bastion-host.${name}', 64) params: { name: name - skuName: 'Standard' location: location - virtualNetworkResourceId: virtualNetworkResourceId - diagnosticSettings: !empty(logAnalyticsWorkspaceResourceId) - ? [ - { - name: 'bastionDiagnostics' - workspaceResourceId: logAnalyticsWorkspaceResourceId - logCategoriesAndGroups: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - } - ] - : [] tags: tags enableTelemetry: enableTelemetry + skuName: skuName + virtualNetworkResourceId: virtualNetworkResourceId + availabilityZones: availabilityZones publicIPAddressObject: { name: 'pip-${name}' + diagnosticSettings: publicIPDiagnosticSettings + tags: tags } + disableCopyPaste: disableCopyPaste + enableFileCopy: enableFileCopy + enableIpConnect: enableIpConnect + enableShareableLink: enableShareableLink + scaleUnits: scaleUnits + diagnosticSettings: diagnosticSettings } } -@description('Resource ID of the Bastion host.') -output resourceId string = bastion.outputs.resourceId +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the Bastion Host.') +output resourceId string = bastionHost.outputs.resourceId -@description('Name of the Bastion host.') -output name string = bastion.outputs.name +@description('Name of the Bastion Host.') +output name string = bastionHost.outputs.name diff --git a/infra/avm/modules/networking/private-dns-zone.bicep b/infra/avm/modules/networking/private-dns-zone.bicep index 319afb17c..be1f69733 100644 --- a/infra/avm/modules/networking/private-dns-zone.bicep +++ b/infra/avm/modules/networking/private-dns-zone.bicep @@ -1,38 +1,44 @@ // ============================================================================ // Module: Private DNS Zone -// Description: AVM wrapper for a single Private DNS Zone linked to a VNet. -// AVM Module: avm/res/network/private-dns-zone:0.8.1 +// Description: AVM wrapper for Azure Private DNS Zone +// AVM Module: avm/res/network/private-dns-zone +// Usage: Call once per DNS zone from main.bicep // ============================================================================ -@description('Required. Name of the private DNS zone (e.g. privatelink.blob.core.windows.net).') +@description('Name of the private DNS zone (e.g., privatelink.cognitiveservices.azure.com).') param name string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Required. Resource ID of the virtual network to link.') -param virtualNetworkResourceId string +@description('Virtual network links to associate with the DNS zone.') +param virtualNetworkLinks array = [] -module zone 'br/public:avm/res/network/private-dns-zone:0.8.1' = { - name: take('avm.res.network.private-dns-zone.${replace(name, '.', '-')}', 64) +@description('Optional. Array of A records.') +param a array = [] + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.8.1' = { + name: take('avm.res.network.private-dns-zone.${split(name, '.')[1]}', 64) params: { name: name tags: tags enableTelemetry: enableTelemetry - virtualNetworkLinks: [ - { - virtualNetworkResourceId: virtualNetworkResourceId - registrationEnabled: false - } - ] + virtualNetworkLinks: virtualNetworkLinks + a: a } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the private DNS zone.') -output resourceId string = zone.outputs.resourceId +output resourceId string = privateDnsZone.outputs.resourceId @description('Name of the private DNS zone.') -output name string = zone.outputs.name +output name string = privateDnsZone.outputs.name diff --git a/infra/avm/modules/networking/private-endpoint.bicep b/infra/avm/modules/networking/private-endpoint.bicep index 72196b4e0..04bfff07c 100644 --- a/infra/avm/modules/networking/private-endpoint.bicep +++ b/infra/avm/modules/networking/private-endpoint.bicep @@ -1,58 +1,50 @@ // ============================================================================ // Module: Private Endpoint -// Description: AVM wrapper for an Azure Private Endpoint with DNS zone group. -// AVM Module: avm/res/network/private-endpoint:0.12.0 +// Description: AVM wrapper for Azure Private Endpoint +// AVM Module: avm/res/network/private-endpoint +// Usage: Call once per private endpoint from main.bicep // ============================================================================ -@description('Required. Name of the private endpoint.') +@description('Name of the private endpoint.') param name string -@description('Required. Azure region for the resource.') +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -param enableTelemetry bool = true +@description('Optional. Custom NIC name for the private endpoint.') +param customNetworkInterfaceName string = '' -@description('Required. Resource ID of the subnet to deploy the private endpoint into.') +@description('Resource ID of the subnet for the private endpoint.') param subnetResourceId string -@description('Required. Resource ID of the target resource (private link service).') -param targetResourceId string - -@description('Required. Group IDs (sub-resources) the private endpoint connects to.') -param groupIds array +@description('Private link service connections configuration.') +param privateLinkServiceConnections array -@description('Optional. Private DNS zone group configurations.') -param privateDnsZoneConfigs array = [] +@description('Optional. Private DNS zone group configuration.') +param privateDnsZoneGroup object? +// ============================================================================ +// AVM Module Deployment +// ============================================================================ module privateEndpoint 'br/public:avm/res/network/private-endpoint:0.12.0' = { name: take('avm.res.network.private-endpoint.${name}', 64) params: { name: name location: location tags: tags - enableTelemetry: enableTelemetry + customNetworkInterfaceName: !empty(customNetworkInterfaceName) ? customNetworkInterfaceName : 'nic-${name}' subnetResourceId: subnetResourceId - privateLinkServiceConnections: [ - { - name: name - properties: { - privateLinkServiceId: targetResourceId - groupIds: groupIds - } - } - ] - privateDnsZoneGroup: !empty(privateDnsZoneConfigs) - ? { - privateDnsZoneGroupConfigs: privateDnsZoneConfigs - } - : null + privateLinkServiceConnections: privateLinkServiceConnections + privateDnsZoneGroup: privateDnsZoneGroup } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the private endpoint.') output resourceId string = privateEndpoint.outputs.resourceId diff --git a/infra/avm/modules/networking/virtual-network.bicep b/infra/avm/modules/networking/virtual-network.bicep index 008056bc9..ca6f68946 100644 --- a/infra/avm/modules/networking/virtual-network.bicep +++ b/infra/avm/modules/networking/virtual-network.bicep @@ -1,119 +1,122 @@ -/****************************************************************************************************************************/ -// Networking - NSGs, VNET and Subnets for Content Generation Solution -/****************************************************************************************************************************/ -@description('Name of the virtual network.') -param vnetName string +// ============================================================================ +// Module: Virtual Network +// Description: VNet, Subnets, and NSGs using AVM modules. +// Each subnet gets its own NSG. Subnet config is passed as param. +// AVM Modules: +// - avm/res/network/network-security-group:0.5.3 +// - avm/res/network/virtual-network:0.8.0 +// ============================================================================ -@description('Azure region to deploy resources.') -param location string = resourceGroup().location +@description('Solution name suffix used to derive the resource name.') +param solutionName string + +var name = 'vnet-${solutionName}' -@description('Required. An Array of 1 or more IP Address Prefixes for the Virtual Network.') -param addressPrefixes array = ['10.0.0.0/20'] +@description('Azure region for the resource.') +param location string = resourceGroup().location -@description('Optional. Deploy Azure Bastion and Jumpbox subnets for VM-based administration.') -param deployBastionAndJumpbox bool = false +@description('Address prefixes for the virtual network.') +param addressPrefixes array -@description('An array of subnets to be created within the virtual network.') -// Core subnets: web (App Service), peps (Private Endpoints), aci (Container Instance) -// Optional: AzureBastionSubnet and jumpbox (only when deployBastionAndJumpbox is true) -var coreSubnets = [ +@description('Subnet configurations.') +param subnets subnetType[] = [ { - name: 'web' - addressPrefixes: ['10.0.0.0/23'] - delegation: 'Microsoft.Web/serverFarms' + name: 'backend' + addressPrefixes: ['10.0.0.0/27'] networkSecurityGroup: { - name: 'nsg-web' + name: 'nsg-backend' securityRules: [ { - name: 'AllowHttpsInbound' + name: 'deny-hop-outbound' properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: ['22', '3389'] + direction: 'Outbound' + priority: 200 protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/23'] } } + ] + } + } + { + name: 'containers' + addressPrefixes: ['10.0.2.0/23'] + delegation: 'Microsoft.App/environments' + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' + networkSecurityGroup: { + name: 'nsg-containers' + securityRules: [ { - name: 'AllowIntraSubnetTraffic' + name: 'deny-hop-outbound' properties: { - access: 'Allow' - direction: 'Inbound' + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: ['22', '3389'] + direction: 'Outbound' priority: 200 - protocol: '*' + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/23'] - destinationAddressPrefixes: ['10.0.0.0/23'] } } + ] + } + } + { + name: 'webserverfarm' + addressPrefixes: ['10.0.4.0/27'] + delegation: 'Microsoft.Web/serverfarms' + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' + networkSecurityGroup: { + name: 'nsg-webserverfarm' + securityRules: [ { - name: 'AllowAzureLoadBalancer' + name: 'deny-hop-outbound' properties: { - access: 'Allow' - direction: 'Inbound' - priority: 300 - protocol: '*' + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: ['22', '3389'] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: 'AzureLoadBalancer' - destinationAddressPrefix: '10.0.0.0/23' } } ] } } { - name: 'peps' - addressPrefixes: ['10.0.2.0/23'] - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Disabled' + name: 'administration' + addressPrefixes: ['10.0.0.32/27'] networkSecurityGroup: { - name: 'nsg-peps' - securityRules: [] - } - } - { - name: 'aci' - addressPrefixes: ['10.0.4.0/24'] - delegation: 'Microsoft.ContainerInstance/containerGroups' - networkSecurityGroup: { - name: 'nsg-aci' + name: 'nsg-administration' securityRules: [ { - name: 'AllowHttpsInbound' + name: 'deny-hop-outbound' properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: ['22', '3389'] + direction: 'Outbound' + priority: 200 protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' sourcePortRange: '*' - destinationPortRange: '8000' - sourceAddressPrefixes: ['10.0.0.0/20'] - destinationAddressPrefixes: ['10.0.4.0/24'] } } ] } } -] - -// Bastion and Jumpbox subnets (only deployed when deployBastionAndJumpbox is true) -// VM Size Notes: -// 1 B-series VMs (like Standard_B2ms) do not support accelerated networking. -// 2 Pick a VM size that supports accelerated networking + Premium SSD (the usual jump-box candidates): -// Standard_D2s_v5 (2 vCPU, 8 GiB RAM, Premium SSD/v2/Ultra) // DEFAULT - current-gen Intel, broad regional availability. -// Standard_D2as_v5 (2 vCPU, 8 GiB RAM, Premium SSD/Ultra) // AMD alternative, typically ~15% cheaper. -// Standard_D2s_v4 (2 vCPU, 8 GiB RAM, Premium SSD) // Previous gen, also broadly available. -// Standard_DS2_v2 (2 vCPU, 7 GiB RAM, Premium SSD) // Legacy SKU, being retired from some regions - avoid for new deployments. -// 3 A-series (Av2) is NOT suitable: no Premium SSD support, no accelerated networking. -var bastionSubnets = deployBastionAndJumpbox ? [ { name: 'AzureBastionSubnet' - addressPrefixes: ['10.0.10.0/26'] + addressPrefixes: ['10.0.0.64/26'] networkSecurityGroup: { name: 'nsg-bastion' securityRules: [ @@ -172,49 +175,27 @@ var bastionSubnets = deployBastionAndJumpbox ? [ ] } } - { - name: 'jumpbox' - addressPrefixes: ['10.0.12.0/23'] - networkSecurityGroup: { - name: 'nsg-jumpbox' - securityRules: [ - { - name: 'AllowRdpFromBastion' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '3389' - sourceAddressPrefixes: ['10.0.10.0/26'] - destinationAddressPrefixes: ['10.0.12.0/23'] - } - } - ] - } - } -] : [] - -var vnetSubnets = concat(coreSubnets, bastionSubnets) +] -@description('Optional. Tags to be applied to the resources.') +@description('Tags to apply 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('Resource ID of the Log Analytics Workspace for diagnostics.') +param logAnalyticsWorkspaceId string @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true -@description('Required. Suffix for resource naming.') +@description('Suffix for resource naming.') param resourceSuffix string -// Create NSGs for subnets using AVM Network Security Group module +// ============================================================================ +// NSGs — one per subnet +// ============================================================================ @batchSize(1) module nsgs 'br/public:avm/res/network/network-security-group:0.5.3' = [ - for (subnet, i) in vnetSubnets: if (!empty(subnet.?networkSecurityGroup)) { - name: take('avm.res.network.network-security-group.${subnet.?networkSecurityGroup.name}.${resourceSuffix}', 64) + for (subnet, i) in subnets: if (!empty(subnet.?networkSecurityGroup)) { + name: take('avm.res.network.nsg.${subnet.?networkSecurityGroup.name}.${resourceSuffix}', 64) params: { name: '${subnet.?networkSecurityGroup.name}-${resourceSuffix}' location: location @@ -225,15 +206,17 @@ module nsgs 'br/public:avm/res/network/network-security-group:0.5.3' = [ } ] -// Create VNet and subnets using AVM Virtual Network module +// ============================================================================ +// Virtual Network + Subnets +// ============================================================================ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.8.0' = { - name: take('avm.res.network.virtual-network.${vnetName}', 64) + name: take('avm.res.network.virtual-network.${name}', 64) params: { - name: vnetName + name: name location: location addressPrefixes: addressPrefixes subnets: [ - for (subnet, i) in vnetSubnets: { + for (subnet, i) in subnets: { name: subnet.name addressPrefixes: subnet.?addressPrefixes networkSecurityGroupResourceId: !empty(subnet.?networkSecurityGroup) ? nsgs[i]!.outputs.resourceId : null @@ -242,7 +225,7 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.8.0' = { delegation: subnet.?delegation } ] - diagnosticSettings: !empty(logAnalyticsWorkspaceId) ? [ + diagnosticSettings: [ { name: 'vnetDiagnostics' workspaceResourceId: logAnalyticsWorkspaceId @@ -259,20 +242,90 @@ module virtualNetwork 'br/public:avm/res/network/virtual-network:0.8.0' = { } ] } - ] : [] + ] tags: tags enableTelemetry: enableTelemetry } } +// ============================================================================ +// Outputs +// ============================================================================ output name string = virtualNetwork.outputs.name output resourceId string = virtualNetwork.outputs.resourceId -// Core subnet outputs (always present) -output webSubnetResourceId string = contains(map(vnetSubnets, subnet => subnet.name), 'web') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(vnetSubnets, subnet => subnet.name), 'web')] : '' -output pepsSubnetResourceId string = contains(map(vnetSubnets, subnet => subnet.name), 'peps') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(vnetSubnets, subnet => subnet.name), 'peps')] : '' -output aciSubnetResourceId string = contains(map(vnetSubnets, subnet => subnet.name), 'aci') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(vnetSubnets, subnet => subnet.name), 'aci')] : '' +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 + } +] -// Bastion/jumpbox subnet outputs (always declared; will be empty when those subnets are not deployed) -output bastionSubnetResourceId string = contains(map(vnetSubnets, subnet => subnet.name), 'AzureBastionSubnet') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(vnetSubnets, subnet => subnet.name), 'AzureBastionSubnet')] : '' -output jumpboxSubnetResourceId string = contains(map(vnetSubnets, subnet => subnet.name), 'jumpbox') ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(vnetSubnets, subnet => subnet.name), 'jumpbox')] : '' +// Individual subnet outputs for backward compatibility +output backendSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'backend') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'backend')] + : '' +output containerSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'containers') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'containers')] + : '' +output webserverfarmSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'webserverfarm') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'webserverfarm')] + : '' +output administrationSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'administration') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'administration')] + : '' +output bastionSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'AzureBastionSubnet') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'AzureBastionSubnet')] + : '' + +// ============================================================================ +// Custom Types +// ============================================================================ +@export() +@description('Subnet output type') +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 NSG, if any.') + nsgName: string? + @description('The resource ID of the associated NSG, if any.') + nsgResourceId: string? +} + +@export() +@description('Subnet configuration type') +type subnetType = { + @description('Required. The name of the subnet.') + name: string + @description('Required. Address prefixes for the subnet.') + addressPrefixes: string[] + @description('Optional. Delegation for the subnet.') + delegation: string? + @description('Optional. Private endpoint network policies.') + privateEndpointNetworkPolicies: ('Disabled' | 'Enabled' | 'NetworkSecurityGroupEnabled' | 'RouteTableEnabled')? + @description('Optional. Private link service network policies.') + privateLinkServiceNetworkPolicies: ('Disabled' | 'Enabled')? + @description('Optional. NSG configuration for the subnet.') + networkSecurityGroup: networkSecurityGroupType? + @description('Optional. Route table resource ID.') + routeTableResourceId: string? + @description('Optional. Service endpoint policies.') + serviceEndpointPolicies: object[]? + @description('Optional. Service endpoints to enable.') + serviceEndpoints: string[]? + @description('Optional. Disable default outbound connectivity.') + defaultOutboundAccess: bool? +} + +@export() +@description('NSG configuration type') +type networkSecurityGroupType = { + @description('Required. The name of the NSG.') + name: string + @description('Required. Security rules for the NSG.') + securityRules: object[] +} diff --git a/infra/bicep/main.bicep b/infra/bicep/main.bicep index 22bf3b095..c85d272ab 100644 --- a/infra/bicep/main.bicep +++ b/infra/bicep/main.bicep @@ -122,7 +122,7 @@ param frontendImageName string = 'content-gen-app' param backendImageName string = 'content-gen-api' @description('Optional. Image tag. ACI is only deployed when a real tag (not empty / not "none") is provided.') -param imageTag string = '' +param imageTag string = 'latest' @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true @@ -529,7 +529,7 @@ module webSite 'modules/compute/app-service.bicep' = { // ========== Container Instance (Backend API) ========== // // Docker (bicep) flavor: inline ACI definition with managed identity auth for the created ACR. var containerInstanceName = 'aci-${solutionSuffix}' -var backendImageUrl = '${containerRegistry.outputs.loginServer}/${backendImageName}:${imageTag}' +var backendImageUrl = '${acrName}/${backendImageName}:${imageTag}' var aciPort = 8000 // Construct identity resource ID from known values (required for deployment-time calculation) var userAssignedIdentityResourceIdForACI = '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/${userAssignedIdentityResourceName}' @@ -722,7 +722,7 @@ output CONTAINER_INSTANCE_NAME string = shouldDeployACI ? containerInstance!.nam output CONTAINER_INSTANCE_FQDN string = shouldDeployACI ? containerInstance!.properties.ipAddress.fqdn : '' @description('Contains ACR Name') -output AZURE_ENV_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name +output AZURE_ENV_CONTAINER_REGISTRY_NAME string = acrName @description('Contains flag for Azure AI Foundry usage') output USE_FOUNDRY bool = useFoundryMode ? true : false diff --git a/infra/bicep/modules/ai/ai-foundry-model-deployment.bicep b/infra/bicep/modules/ai/ai-foundry-model-deployment.bicep index 96595e093..4ed69a72c 100644 --- a/infra/bicep/modules/ai/ai-foundry-model-deployment.bicep +++ b/infra/bicep/modules/ai/ai-foundry-model-deployment.bicep @@ -1,35 +1,66 @@ -@description('Required. Name of the AI Services account.') -param aiServicesName string +// ============================================================================ +// Module: Model Deployment — Vanilla Bicep +// Description: Deploys a single AI model to an existing AI Services account. +// Called repetitively from main.bicep for each model in the array. +// Generic, reusable across GSAs. +// ============================================================================ -@description('Required. Array of model deployments to create.') -param deployments array = [] +targetScope = 'resourceGroup' -// Reference AI Services account (module is scoped to the correct resource group) -resource aiServices 'Microsoft.CognitiveServices/accounts@2025-12-01' existing = { - name: aiServicesName +@description('Required. Name of the parent AI Services account.') +param aiServicesAccountName string + +@description('Required. Name for this model deployment.') +param deploymentName string + +@description('Optional. Model format (e.g., OpenAI).') +param modelFormat string = 'OpenAI' + +@description('Required. Model name (e.g., gpt-4o, text-embedding-ada-002).') +param modelName string + +@description('Optional. Model version. Empty string means latest.') +param modelVersion string = '' + +@description('Optional. RAI policy name.') +param raiPolicyName string = 'Microsoft.Default' + +@description('Required. SKU name (e.g., Standard, GlobalStandard).') +param skuName string + +@description('Required. SKU capacity (tokens per minute in thousands).') +param skuCapacity int + +// ============================================================================ +// Model Deployment +// ============================================================================ +resource aiServicesAccount 'Microsoft.CognitiveServices/accounts@2025-12-01' existing = { + name: aiServicesAccountName } -// Deploy models to AI Services account -// Using batchSize(1) to avoid concurrent deployment issues -@batchSize(1) -resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2025-12-01' = [ - for (deployment, index) in deployments: { - parent: aiServices - name: deployment.name - properties: { - model: { - format: deployment.format - name: deployment.model - version: deployment.version - } - raiPolicyName: deployment.raiPolicyName - } - sku: { - name: deployment.sku.name - capacity: deployment.sku.capacity +resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-12-01' = { + parent: aiServicesAccount + name: deploymentName + properties: { + model: { + format: modelFormat + name: modelName + version: !empty(modelVersion) ? modelVersion : null } + raiPolicyName: raiPolicyName + } + sku: { + name: skuName + capacity: skuCapacity } -] +} + +// ============================================================================ +// Outputs +// ============================================================================ + +@description('Name of the deployed model.') +output name string = modelDeployment.name -@description('The names of the deployed models.') -output deployedModelNames array = [for (deployment, i) in deployments: modelDeployments[i].name] +@description('Resource ID of the model deployment.') +output resourceId string = modelDeployment.id diff --git a/infra/bicep/modules/ai/ai-foundry-project.bicep b/infra/bicep/modules/ai/ai-foundry-project.bicep index 4c425e99e..362dbbad9 100644 --- a/infra/bicep/modules/ai/ai-foundry-project.bicep +++ b/infra/bicep/modules/ai/ai-foundry-project.bicep @@ -1,58 +1,117 @@ -@description('Required. Name of the AI Services project.') -param name string +// ============================================================================ +// Module: AI Foundry Project (Account + Project) — Vanilla Bicep +// Description: Creates an Azure AI Services account and AI Foundry project. +// Generic, reusable across GSAs — no app-specific parameters. +// ============================================================================ -@description('Required. The location of the Project resource.') -param location string = resourceGroup().location +targetScope = 'resourceGroup' -@description('Optional. The description of the AI Foundry project to create. Defaults to the project name.') -param desc string = name +@description('Required. Solution name suffix used to generate resource names.') +param solutionName string -@description('Required. Name of the existing Cognitive Services resource to create the AI Foundry project in.') -param aiServicesName string +@description('Optional. Override name for the AI Services account. Defaults to aif-{solutionName}.') +param name string = 'aif-${solutionName}' -@description('Required. Azure Existing AI Project ResourceID.') -param azureExistingAIProjectResourceId string = '' +@description('Optional. Override name for the AI Foundry project. Defaults to proj-{solutionName}.') +param projectName string = 'proj-${solutionName}' -@description('Optional. Tags to be applied to the resources.') +@description('Required. Azure region for the resources.') +param location string + +@description('Optional. Tags to apply to resources.') param tags object = {} -var useExistingAiFoundryAiProject = !empty(azureExistingAIProjectResourceId) -var existingOpenAIEndpoint = useExistingAiFoundryAiProject - ? format('https://{0}.openai.azure.com/', split(azureExistingAIProjectResourceId, '/')[8]) - : '' +@description('Optional. SKU name for the AI Services account.') +param skuName string = 'S0' -// Reference to cognitive service in current resource group for new projects -resource cogServiceReference 'Microsoft.CognitiveServices/accounts@2025-12-01' existing = { - name: aiServicesName -} +@description('Optional. Whether to disable local (key-based) authentication.') +param disableLocalAuth bool = true -resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-12-01' = { - parent: cogServiceReference +@description('Optional. Whether to allow project management (AI Foundry hub).') +param allowProjectManagement bool = true + +@description('Optional. Public network access setting.') +param publicNetworkAccess string = 'Enabled' + +@description('Optional. Managed identity type for the resources.') +@allowed(['SystemAssigned', 'UserAssigned', 'SystemAssigned, UserAssigned', 'None']) +param identityType string = 'SystemAssigned' + +@description('Optional. Network ACLs default action.') +@allowed(['Allow', 'Deny']) +param networkAclsDefaultAction string = 'Allow' + +// ============================================================================ +// AI Services Account +// ============================================================================ +resource aiServices 'Microsoft.CognitiveServices/accounts@2025-12-01' = { name: name - tags: tags location: location + tags: tags + sku: { + name: skuName + } + kind: 'AIServices' identity: { - type: 'SystemAssigned' + type: identityType } properties: { - description: desc - displayName: name + allowProjectManagement: allowProjectManagement + customSubDomainName: name + networkAcls: { + defaultAction: networkAclsDefaultAction + virtualNetworkRules: [] + ipRules: [] + } + publicNetworkAccess: publicNetworkAccess + disableLocalAuth: disableLocalAuth + } +} + +// ============================================================================ +// AI Foundry Project +// ============================================================================ +resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-12-01' = { + parent: aiServices + name: projectName + location: location + kind: 'AIServices' + identity: { + type: identityType } + properties: {} } -@description('Required. Name of the AI project.') -output name string = aiProject.name +// ============================================================================ +// Outputs +// ============================================================================ + +@description('Resource ID of the AI Services account.') +output resourceId string = aiServices.id + +@description('Name of the AI Services account.') +output name string = aiServices.name + +@description('Endpoint of the AI Services account (OpenAI Language Model Instance API).') +output endpoint string = aiServices.properties.endpoints['OpenAI Language Model Instance API'] + +@description('Endpoint of the AI Services account (Cognitive Services).') +output cognitiveServicesEndpoint string = aiServices.properties.endpoint + +@description('Azure OpenAI Content Understanding endpoint URL.') +output azureOpenAiCuEndpoint string = aiServices.properties.endpoints['Content Understanding'] + +@description('System-assigned identity principal ID of the AI Services account.') +output principalId string = aiServices.identity.principalId -@description('Required. Resource ID of the AI project.') -output resourceId string = aiProject.id +@description('Resource ID of the AI Foundry project.') +output projectResourceId string = aiProject.id -@description('Required. API endpoint for the AI project.') -output apiEndpoint string = aiProject!.properties.endpoints['AI Foundry API'] +@description('Name of the AI Foundry project.') +output projectName string = aiProject.name -@description('Contains AI Endpoint.') -output aoaiEndpoint string = !empty(existingOpenAIEndpoint) - ? existingOpenAIEndpoint - : cogServiceReference.properties.endpoints['OpenAI Language Model Instance API'] +@description('AI Foundry project endpoint.') +output projectEndpoint string = aiProject.properties.endpoints['AI Foundry API'] -@description('Required. Principal ID of the AI project system-assigned managed identity.') -output systemAssignedMIPrincipalId string = aiProject.identity.principalId +@description('System-assigned identity principal ID of the project.') +output projectIdentityPrincipalId string = aiProject.identity.principalId diff --git a/infra/bicep/modules/ai/ai-search-identity.bicep b/infra/bicep/modules/ai/ai-search-identity.bicep index da2309b81..ceb2dbaa5 100644 --- a/infra/bicep/modules/ai/ai-search-identity.bicep +++ b/infra/bicep/modules/ai/ai-search-identity.bicep @@ -35,6 +35,12 @@ param semanticSearch string = 'free' @description('Whether to disable local authentication.') param disableLocalAuth bool = true +@description('Optional. Authentication options for the search service.') +param authOptions object = {} + +@description('Optional. Network rule set for the search service.') +param networkRuleSet object = {} + @description('Managed identity type for the search service.') param managedIdentityType string = 'SystemAssigned' @@ -58,6 +64,8 @@ resource searchServiceUpdate 'Microsoft.Search/searchServices@2025-05-01' = { semanticSearch: semanticSearch disableLocalAuth: disableLocalAuth publicNetworkAccess: publicNetworkAccess + authOptions: !empty(authOptions) ? authOptions : null + networkRuleSet: !empty(networkRuleSet) ? networkRuleSet : null } } diff --git a/infra/bicep/modules/ai/ai-search.bicep b/infra/bicep/modules/ai/ai-search.bicep index 816bfce8b..798a0f74c 100644 --- a/infra/bicep/modules/ai/ai-search.bicep +++ b/infra/bicep/modules/ai/ai-search.bicep @@ -1,74 +1,105 @@ // ============================================================================ // Module: AI Search -// Description: Vanilla Bicep module for an Azure AI Search service. -// Resource: Microsoft.Search/searchServices@2024-06-01-preview +// Description: Deploys Azure AI Search with a two-step pattern: +// Step 1: Plain Bicep resource for fast initial creation (name, location, SKU) +// Step 2: Separate module deployment to enable managed identity & full config +// This reduces deployment time by making the resource available immediately +// while identity enablement proceeds as a separate ARM deployment. // ============================================================================ -@description('Required. Name of the AI Search service.') -param name string +targetScope = 'resourceGroup' -@description('Required. Azure region for the resource.') +@description('Solution name suffix used to derive the resource name.') +@minLength(3) +param solutionName string + +@description('Optional. Override name for the search service. Defaults to srch-{solutionName}.') +param name string = 'srch-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true - -@description('Optional. SKU of the AI Search service.') -param sku string = 'basic' +@description('SKU name for the search service.') +@allowed(['free', 'basic', 'standard', 'standard2', 'standard3', 'storage_optimized_l1', 'storage_optimized_l2']) +param skuName string = 'basic' -@description('Optional. Number of replicas.') +@description('Number of replicas.') param replicaCount int = 1 -@description('Required. Principal ID of the managed identity to grant search data/service roles.') -param principalId string +@description('Number of partitions.') +param partitionCount int = 1 + +@description('Hosting mode.') +@allowed(['Default', 'HighDensity']) +param hostingMode string = 'Default' + +@description('Semantic search tier.') +@allowed(['disabled', 'free', 'standard']) +param semanticSearch string = 'free' + +@description('Whether to disable local authentication.') +param disableLocalAuth bool = true -resource search 'Microsoft.Search/searchServices@2024-06-01-preview' = { +@description('Optional. Authentication options for the search service.') +param authOptions object = {} + +@description('Optional. Network rule set for the search service.') +param networkRuleSet object = {} + +@description('Managed identity type for the search service.') +param managedIdentityType string = 'SystemAssigned' + +@description('Public network access setting.') +param publicNetworkAccess string = 'Enabled' + +// ============================================================================ +// Step 1: Initial resource creation (fast — no identity) +// ============================================================================ +resource aiSearch 'Microsoft.Search/searchServices@2025-05-01' = { name: name location: location - tags: tags sku: { - name: sku - } - identity: { - type: 'SystemAssigned' - } - properties: { - replicaCount: replicaCount - partitionCount: 1 - hostingMode: 'default' - semanticSearch: 'free' - disableLocalAuth: true - // AI Search remains publicly accessible - accessed from ACI via managed identity - publicNetworkAccess: 'enabled' + name: skuName } } -resource searchIndexDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(search.id, principalId, '8ebe5a00-799e-43f5-93ac-243d3dce84a7') - scope: search - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor - principalId: principalId - principalType: 'ServicePrincipal' +// ============================================================================ +// Step 2: Separate deployment — enables identity & full configuration +// ============================================================================ +module searchServiceUpdate 'ai-search-identity.bicep' = { + name: 'searchServiceUpdate' + params: { + name: aiSearch.name + location: location + tags: tags + skuName: skuName + replicaCount: replicaCount + partitionCount: partitionCount + hostingMode: hostingMode + semanticSearch: semanticSearch + disableLocalAuth: disableLocalAuth + authOptions: authOptions + networkRuleSet: networkRuleSet + managedIdentityType: managedIdentityType + publicNetworkAccess: publicNetworkAccess } } -resource searchServiceContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(search.id, principalId, '7ca78c08-252a-4471-8644-bb5ff32d4ba0') - scope: search - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0') // Search Service Contributor - principalId: principalId - principalType: 'ServicePrincipal' - } -} +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the AI Search service.') -output resourceId string = search.id +output resourceId string = aiSearch.id @description('Name of the AI Search service.') -output name string = search.name +output name string = aiSearch.name + +@description('Endpoint URL of the AI Search service.') +output endpoint string = 'https://${aiSearch.name}.search.windows.net' + +@description('System-assigned identity principal ID.') +output identityPrincipalId string = searchServiceUpdate.outputs.systemAssignedMIPrincipalId diff --git a/infra/bicep/modules/ai/existing-project-setup.bicep b/infra/bicep/modules/ai/existing-project-setup.bicep index 60b44539e..df0acdc5e 100644 --- a/infra/bicep/modules/ai/existing-project-setup.bicep +++ b/infra/bicep/modules/ai/existing-project-setup.bicep @@ -38,6 +38,9 @@ output name string = aiServices.name @description('Endpoint of the AI Services account (OpenAI Language Model Instance API).') output endpoint string = aiServices.properties.endpoints['OpenAI Language Model Instance API'] +@description('Endpoint of the AI Services account (Cognitive Services).') +output cognitiveServicesEndpoint string = aiServices.properties.endpoint + @description('Azure OpenAI Content Understanding endpoint URL.') output azureOpenAiCuEndpoint string = aiServices.properties.endpoints['Content Understanding'] diff --git a/infra/bicep/modules/compute/app-service-plan.bicep b/infra/bicep/modules/compute/app-service-plan.bicep index cbd7abd67..f9409f0cf 100644 --- a/infra/bicep/modules/compute/app-service-plan.bicep +++ b/infra/bicep/modules/compute/app-service-plan.bicep @@ -1,44 +1,60 @@ // ============================================================================ // Module: App Service Plan -// Description: Vanilla Bicep module for an Azure App Service Plan (Linux). -// Resource: Microsoft.Web/serverfarms@2024-04-01 +// Description: Creates an Azure App Service Plan +// API: Microsoft.Web/serverfarms@2025-05-01 // ============================================================================ -@description('Required. Name of the App Service Plan.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the App Service Plan.') +param name string = 'asp-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true +@description('SKU name for the App Service Plan.') +@allowed(['F1', 'D1', 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1', 'P2', 'P3', 'P4', 'P0v3', 'P0v4', 'P1v3', 'P1v4', 'P2v3', 'P3v3']) +param skuName string = 'B2' -@description('Optional. SKU name of the App Service Plan.') -param skuName string = 'B1' +@description('Whether the plan is Linux-based.') +param reserved bool = true -@description('Optional. Number of instances.') +@description('Kind of the App Service Plan.') +param kind string = 'linux' + +@description('Number of instances (workers).') param skuCapacity int = 1 -resource serverFarm 'Microsoft.Web/serverfarms@2024-04-01' = { +@description('Enable zone redundancy. Requires Premium SKU (P1v3+).') +param zoneRedundant bool = false + +// ============================================================================ +// Resource Deployment +// ============================================================================ +resource appServicePlan 'Microsoft.Web/serverfarms@2025-05-01' = { name: name location: location tags: tags - kind: 'linux' + kind: kind sku: { name: skuName capacity: skuCapacity } properties: { - reserved: true + reserved: reserved + zoneRedundant: zoneRedundant } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the App Service Plan.') -output resourceId string = serverFarm.id +output resourceId string = appServicePlan.id @description('Name of the App Service Plan.') -output name string = serverFarm.name +output name string = appServicePlan.name diff --git a/infra/bicep/modules/compute/app-service.bicep b/infra/bicep/modules/compute/app-service.bicep index 0fae4797c..39cd9565c 100644 --- a/infra/bicep/modules/compute/app-service.bicep +++ b/infra/bicep/modules/compute/app-service.bicep @@ -1,175 +1,134 @@ -@description('Required. Name of the site.') -param name string +// ============================================================================ +// Module: App Service +// Description: Creates an Azure App Service (Web App) +// API: Microsoft.Web/sites@2025-05-01 +// ============================================================================ -@description('Optional. Location for all Resources.') -param location string = resourceGroup().location +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Type of site to deploy.') -@allowed([ - 'functionapp' - 'functionapp,linux' - 'functionapp,workflowapp' - 'functionapp,workflowapp,linux' - 'functionapp,linux,container' - 'functionapp,linux,container,azurecontainerapps' - 'app,linux' - 'app' - 'linux,api' - 'api' - 'app,linux,container' - 'app,container,windows' -]) -param kind string - -@description('Required. The resource ID of the app service plan to use for the site.') -param serverFarmResourceId string - -@description('Optional. Configures a site to accept only HTTPS requests.') -param httpsOnly bool = true +@description('Name of the App Service.') +param name string = solutionName -@description('Optional. If client affinity is enabled.') -param clientAffinityEnabled bool = true +@description('Azure region for the resource.') +param location string -@description('Optional. The managed identity definition for this resource.') -param managedIdentities { - @description('Optional. Enables system assigned managed identity on the resource.') - systemAssigned: bool? +@description('Tags to apply to the resource.') +param tags object = {} - @description('Optional. The resource ID(s) to assign to the resource.') - userAssignedResourceIds: string[]? -}? +@description('Resource ID of the App Service Plan.') +param serverFarmResourceId string -@description('Optional. The resource ID of the assigned identity to be used to access a key vault with.') -param keyVaultAccessIdentityResourceId string? +@description('Docker image name (e.g., DOCKER|registry.azurecr.io/image:tag).') +param linuxFxVersion string -@description('Optional. Checks if Customer provided storage account is required.') -param storageAccountRequired bool = false +@description('Application settings key-value pairs.') +param appSettings object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true +@description('Whether to enable Always On.') +param alwaysOn bool = true -@description('Optional. The site config object.') -param siteConfig resourceInput<'Microsoft.Web/sites@2025-03-01'>.properties.siteConfig = { - alwaysOn: true - minTlsVersion: '1.2' - ftpsState: 'FtpsOnly' -} +@description('Optional. Health check path for the app.') +param healthCheckPath string = '' -@description('Optional. The web site config.') -param configs appSettingsConfigType[]? +@description('Optional. Whether to enable WebSockets.') +param webSocketsEnabled bool = false -@description('Optional. Tags of the resource.') -param tags object? +@description('Optional. Command line for the application.') +param appCommandLine string = '' -@description('Optional. Whether or not public network access is allowed for this resource.') +@description('Required. Type of site to deploy.') @allowed([ - 'Enabled' - 'Disabled' + 'functionapp' // function app windows os + 'functionapp,linux' // function app linux os + 'functionapp,workflowapp' // logic app workflow + 'functionapp,workflowapp,linux' // logic app docker container + 'functionapp,linux,container' // function app linux container + 'functionapp,linux,container,azurecontainerapps' // function app linux container azure container apps + 'app,linux' // linux web app + 'app' // windows web app + 'linux,api' // linux api app + 'api' // windows api app + 'app,linux,container' // linux container app + 'app,container,windows' // windows container app ]) -param publicNetworkAccess string? - -@description('Optional. End to End Encryption Setting.') -param e2eEncryptionEnabled bool? - -var formattedUserAssignedIdentities = reduce( - map((managedIdentities.?userAssignedResourceIds ?? []), (id) => { '${id}': {} }), - {}, - (cur, next) => union(cur, next) -) - -var identity = !empty(managedIdentities) - ? { - type: (managedIdentities.?systemAssigned ?? false) - ? (!empty(managedIdentities.?userAssignedResourceIds ?? {}) ? 'SystemAssigned, UserAssigned' : 'SystemAssigned') - : (!empty(managedIdentities.?userAssignedResourceIds ?? {}) ? 'UserAssigned' : 'None') - userAssignedIdentities: !empty(formattedUserAssignedIdentities) ? formattedUserAssignedIdentities : null - } - : null +param kind string = 'app,linux' -resource app 'Microsoft.Web/sites@2025-03-01' = { +@description('Public network access setting.') +param publicNetworkAccess string = 'Enabled' + +// ============================================================================ +// Resource Deployment +// ============================================================================ +resource appService 'Microsoft.Web/sites@2025-05-01' = { name: name location: location - kind: kind tags: tags - identity: identity + kind: kind + identity: { + type: 'SystemAssigned' + } properties: { serverFarmId: serverFarmResourceId - clientAffinityEnabled: clientAffinityEnabled - httpsOnly: httpsOnly - storageAccountRequired: storageAccountRequired - keyVaultReferenceIdentity: keyVaultAccessIdentityResourceId - siteConfig: siteConfig - publicNetworkAccess: !empty(publicNetworkAccess) ? any(publicNetworkAccess) : 'Enabled' - endToEndEncryptionEnabled: e2eEncryptionEnabled + publicNetworkAccess: publicNetworkAccess + siteConfig: { + alwaysOn: alwaysOn + ftpsState: 'Disabled' + linuxFxVersion: linuxFxVersion + minTlsVersion: '1.2' + healthCheckPath: !empty(healthCheckPath) ? healthCheckPath : null + webSocketsEnabled: webSocketsEnabled + appCommandLine: appCommandLine + } + endToEndEncryptionEnabled: true } -} -module app_config 'app-service.config.bicep' = [ - for (config, index) in (configs ?? []): { - name: '${uniqueString(deployment().name, location)}-Site-Config-${index}' - params: { - appName: app.name - name: config.name - applicationInsightResourceId: config.?applicationInsightResourceId - storageAccountResourceId: config.?storageAccountResourceId - storageAccountUseIdentityAuthentication: config.?storageAccountUseIdentityAuthentication - properties: config.?properties - currentAppSettings: config.?retainCurrentAppSettings ?? true && config.name == 'appsettings' - ? list('${app.id}/config/appsettings', '2023-12-01').properties - : {} + resource basicPublishingCredentialsPoliciesFtp 'basicPublishingCredentialsPolicies' = { + name: 'ftp' + properties: { + allow: false } } -] - -@description('The name of the site.') -output name string = app.name - -@description('The resource ID of the site.') -output resourceId string = app.id - -@description('The resource group the site was deployed into.') -output resourceGroupName string = resourceGroup().name - -@description('The principal ID of the system assigned identity.') -output systemAssignedMIPrincipalId string? = app.?identity.?principalId - -@description('The location the resource was deployed into.') -output location string = app.location - -@description('Default hostname of the app.') -output defaultHostname string = 'https://${name}.azurewebsites.net' - -@description('Unique identifier that verifies the custom domains assigned to the app.') -output customDomainVerificationId string = app.properties.customDomainVerificationId + resource basicPublishingCredentialsPoliciesScm 'basicPublishingCredentialsPolicies' = { + name: 'scm' + properties: { + allow: false + } + } +} -@description('The outbound IP addresses of the app.') -output outboundIpAddresses string = app.properties.outboundIpAddresses +resource configAppSettings 'Microsoft.Web/sites/config@2025-05-01' = { + name: 'appsettings' + parent: appService + properties: appSettings +} -// ================ // -// Definitions // -// ================ // -@export() -@description('The type of an app settings configuration.') -type appSettingsConfigType = { - @description('Required. The type of config.') - name: 'appsettings' | 'logs' +resource configLogs 'Microsoft.Web/sites/config@2025-05-01' = { + name: 'logs' + parent: appService + properties: { + applicationLogs: { fileSystem: { level: 'Verbose' } } + detailedErrorMessages: { enabled: true } + failedRequestsTracing: { enabled: true } + httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } + } + dependsOn: [configAppSettings] +} - @description('Optional. If the provided storage account requires Identity based authentication.') - storageAccountUseIdentityAuthentication: bool? +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the App Service.') +output resourceId string = appService.id - @description('Optional. Required if app of kind functionapp. Resource ID of the storage account to manage triggers and logging function executions.') - storageAccountResourceId: string? +@description('Name of the App Service.') +output name string = appService.name - @description('Optional. Resource ID of the application insight to leverage for this resource.') - applicationInsightResourceId: string? +@description('Default hostname of the App Service.') +output defaultHostname string = appService.properties.defaultHostName - @description('Optional. The retain the current app settings. Defaults to true.') - retainCurrentAppSettings: bool? +@description('URL of the App Service.') +output appUrl string = 'https://${appService.properties.defaultHostName}' - @description('Optional. The app settings key-value pairs.') - properties: { - @description('Required. An app settings key-value pair.') - *: string - }? -} +@description('System-assigned identity principal ID.') +output identityPrincipalId string = appService.identity.principalId diff --git a/infra/bicep/modules/compute/container-instance.bicep b/infra/bicep/modules/compute/container-instance.bicep index 02ccf94c2..e3d690ff6 100644 --- a/infra/bicep/modules/compute/container-instance.bicep +++ b/infra/bicep/modules/compute/container-instance.bicep @@ -1,71 +1,75 @@ -// ========== container-instance.bicep ========== // -// Azure Container Instance module for backend API deployment. -// NOTE: Not used by the lean main.bicep (which inlines the ACI resource); retained -// for module parity with the AVM flavor across GSA. +// ============================================================================ +// Module: Azure Container Instance +// Description: Creates an Azure Container Instance group +// API: Microsoft.ContainerInstance/containerGroups@2025-09-01 +// ============================================================================ -@description('Required. Name of the container group.') +@description('Name of the container group.') param name string -@description('Required. Location for the container instance.') +@description('Azure region for deployment.') param location string -@description('Optional. Tags for all resources.') +@description('Resource tags.') param tags object = {} -@description('Required. Container image to deploy.') +@description('Container image to deploy.') param containerImage string -@description('Optional. CPU cores for the container.') +@description('CPU cores for the container.') param cpu int = 2 -@description('Optional. Memory in GB for the container.') +@description('Memory in GB for the container.') param memoryInGB int = 4 -@description('Optional. Port to expose.') +@description('Port to expose.') param port int = 8000 -@description('Optional. Subnet resource ID for VNet integration. If empty, public IP will be used.') -param subnetResourceId string = '' +@description('Environment variables for the container.') +param environmentVariables array = [] -@description('Required. Environment variables for the container.') -param environmentVariables array +@description('Operating system type.') +@allowed(['Linux', 'Windows']) +param osType string = 'Linux' -@description('Optional. Enable/Disable usage telemetry for module.') -param enableTelemetry bool = true +@description('Restart policy.') +@allowed(['Always', 'OnFailure', 'Never']) +param restartPolicy string = 'Always' -@description('Required. User-assigned managed identity resource ID for ACR pull.') -param userAssignedIdentityResourceId string +@description('Managed identity configuration.') +param managedIdentities object = {} -var isPrivateNetworking = !empty(subnetResourceId) +@description('Image registry credentials.') +param imageRegistryCredentials array = [] -// ============== // -// Resources // -// ============== // +@description('Subnet resource ID for VNet integration. If empty, public IP is used.') +param subnetResourceId string = '' -#disable-next-line no-deployments-resources -resource avmTelemetry 'Microsoft.Resources/deployments@2025-04-01' = if (enableTelemetry) { - name: '46d3xbcp.res.containerinstance.${replace('-..--..-', '.', '-')}.${substring(uniqueString(deployment().name, 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: [] - } - } +@description('Availability zone for the container group. Use -1 for no zone.') +param availabilityZone int = -1 + +// ============================================================================ +// Variables +// ============================================================================ +var isPrivateNetworking = !empty(subnetResourceId) + +var identityConfig = empty(managedIdentities) ? { type: 'None' } : { + type: contains(managedIdentities, 'userAssignedResourceIds') ? 'UserAssigned' : 'SystemAssigned' + userAssignedIdentities: contains(managedIdentities, 'userAssignedResourceIds') ? reduce(managedIdentities.userAssignedResourceIds, {}, (cur, id) => union(cur, { '${id}': {} })) : null } +// ============================================================================ +// Resource Deployment +// ============================================================================ resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2025-09-01' = { name: name location: location tags: tags - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${userAssignedIdentityResourceId}': {} - } - } + identity: identityConfig + zones: availabilityZone != -1 ? [string(availabilityZone)] : null properties: { + osType: osType + restartPolicy: restartPolicy containers: [ { name: name @@ -87,13 +91,8 @@ resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2025-09-01' } } ] - osType: 'Linux' - restartPolicy: 'Always' - subnetIds: isPrivateNetworking ? [ - { - id: subnetResourceId - } - ] : null + imageRegistryCredentials: imageRegistryCredentials + subnetIds: isPrivateNetworking ? [{ id: subnetResourceId }] : null ipAddress: { type: isPrivateNetworking ? 'Private' : 'Public' ports: [ @@ -104,27 +103,17 @@ resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2025-09-01' ] dnsNameLabel: isPrivateNetworking ? null : name } - imageRegistryCredentials: [ - { - server: split(containerImage, '/')[0] - identity: userAssignedIdentityResourceId - } - ] } } -// ============== // -// Outputs // -// ============== // - +// ============================================================================ +// Outputs +// ============================================================================ @description('The name of the container group.') output name string = containerGroup.name @description('The resource ID of the container group.') output resourceId string = containerGroup.id -@description('The IP address of the container (private or public depending on mode).') +@description('The IP address of the container group.') output ipAddress string = containerGroup.properties.ipAddress.ip - -@description('The FQDN of the container (only available for public mode).') -output fqdn string = isPrivateNetworking ? '' : containerGroup.properties.ipAddress.fqdn diff --git a/infra/bicep/modules/compute/container-registry.bicep b/infra/bicep/modules/compute/container-registry.bicep index d78b34829..9566d2182 100644 --- a/infra/bicep/modules/compute/container-registry.bicep +++ b/infra/bicep/modules/compute/container-registry.bicep @@ -1,58 +1,75 @@ // ============================================================================ -// Module: Container Registry -// Description: Vanilla Bicep module for an Azure Container Registry used by the -// Docker build (bicep) deployment flavor. AZD builds and pushes the -// application images here. -// Resource: Microsoft.ContainerRegistry/registries@2023-11-01-preview +// Module: Azure Container Registry +// Description: Creates an Azure Container Registry +// API: Microsoft.ContainerRegistry/registries@2025-04-01 // ============================================================================ -@description('Required. Name of the container registry.') -param name string +@description('Solution name used for naming convention.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the container registry.') +param name string = replace('cr${solutionName}', '-', '') + +@description('Azure region for deployment.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Resource tags.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true +@description('SKU for the container registry.') +@allowed(['Basic', 'Standard', 'Premium']) +param sku string = 'Premium' + +@description('Enable admin user.') +param adminUserEnabled bool = false + +@description('Public network access setting.') +@allowed(['Enabled', 'Disabled']) +param publicNetworkAccess string = 'Enabled' -@description('Required. Principal ID of the managed identity to grant AcrPull.') -param principalId string +@description('Export policy status.') +param exportPolicyStatus string = 'enabled' -resource registry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { +// ============================================================================ +// Resource Deployment +// ============================================================================ +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2025-04-01' = { name: name location: location tags: tags sku: { - name: 'Standard' + name: sku } properties: { - adminUserEnabled: false - anonymousPullEnabled: false - publicNetworkAccess: 'Enabled' + adminUserEnabled: adminUserEnabled + publicNetworkAccess: publicNetworkAccess + dataEndpointEnabled: false networkRuleBypassOptions: 'AzureServices' + policies: { + exportPolicy: { + status: exportPolicyStatus + } + retentionPolicy: { + status: 'enabled' + days: 7 + } + trustPolicy: { + status: 'disabled' + type: 'Notary' + } + } + zoneRedundancy: 'Disabled' } } -// AcrPull role assignment for the managed identity. -resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(registry.id, principalId, '7f951dda-4ed3-4680-a7ca-43fe172d538d') - scope: registry - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') // AcrPull - principalId: principalId - principalType: 'ServicePrincipal' - } -} - -@description('Resource ID of the container registry.') -output resourceId string = registry.id +// ============================================================================ +// Outputs +// ============================================================================ +@description('The name of the container registry.') +output name string = containerRegistry.name -@description('Name of the container registry.') -output name string = registry.name +@description('The login server URL.') +output loginServer string = containerRegistry.properties.loginServer -@description('Login server of the container registry.') -output loginServer string = registry.properties.loginServer +@description('The resource ID of the container registry.') +output resourceId string = containerRegistry.id \ No newline at end of file diff --git a/infra/bicep/modules/compute/kubernetes.bicep b/infra/bicep/modules/compute/kubernetes.bicep index a12df945e..44e294404 100644 --- a/infra/bicep/modules/compute/kubernetes.bicep +++ b/infra/bicep/modules/compute/kubernetes.bicep @@ -132,3 +132,9 @@ output resourceId string = aksCluster.id @description('FQDN of the AKS cluster.') output fqdn string = aksCluster.properties.fqdn + +@description('Object ID of the AKS kubelet system-assigned managed identity (used by pods at runtime via IMDS).') +output kubeletIdentityObjectId string = aksCluster.properties.?identityProfile.?kubeletidentity.?objectId ?? '' + +@description('Principal ID of the AKS control-plane system-assigned managed identity.') +output systemAssignedMIPrincipalId string = aksCluster.identity.?principalId ?? '' diff --git a/infra/bicep/modules/compute/maintenance-configuration.bicep b/infra/bicep/modules/compute/maintenance-configuration.bicep new file mode 100644 index 000000000..2683939d1 --- /dev/null +++ b/infra/bicep/modules/compute/maintenance-configuration.bicep @@ -0,0 +1,84 @@ +// ============================================================================ +// Module: Maintenance Configuration +// Description: AVM wrapper for Azure Maintenance Configuration +// AVM Module: avm/res/maintenance/maintenance-configuration +// WAF: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-machines +// ============================================================================ + +@description('Solution name suffix used to derive the resource name.') +param solutionName string + +@description('Name of the maintenance configuration.') +param name string = 'mc-${solutionName}' + +@description('Azure region for the resource.') +param location string + +@description('Tags to apply to the resource.') +param tags object = {} + +@description('Maintenance scope.') +param maintenanceScope string = 'InGuestPatch' + +@description('Visibility of the configuration.') +param visibility string = 'Custom' + +@description('Extension properties.') +param extensionProperties object = { + InGuestPatchMode: 'User' +} + +@description('Maintenance window configuration.') +param maintenanceWindow object = { + startDateTime: '2024-06-16 00:00' + duration: '03:55' + timeZone: 'W. Europe Standard Time' + recurEvery: '1Day' +} + +@description('Install patches configuration.') +param installPatches object = { + rebootSetting: 'IfRequired' + windowsParameters: { + classificationsToInclude: [ + 'Critical' + 'Security' + ] + } + linuxParameters: { + classificationsToInclude: [ + 'Critical' + 'Security' + ] + } +} + +@description('Enable Azure telemetry collection.') +param enableTelemetry bool = true + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module maintenanceConfiguration 'br/public:avm/res/maintenance/maintenance-configuration:0.4.0' = { + name: take('avm.res.maintenance.maintenance-configuration.${name}', 64) + params: { + name: name + location: location + tags: tags + enableTelemetry: enableTelemetry + extensionProperties: extensionProperties + maintenanceScope: maintenanceScope + maintenanceWindow: maintenanceWindow + visibility: visibility + installPatches: installPatches + } +} + +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the maintenance configuration.') +output resourceId string = maintenanceConfiguration.outputs.resourceId + +@description('Name of the maintenance configuration.') +output name string = maintenanceConfiguration.outputs.name diff --git a/infra/bicep/modules/compute/proximity-placement-group.bicep b/infra/bicep/modules/compute/proximity-placement-group.bicep new file mode 100644 index 000000000..f1a3e2796 --- /dev/null +++ b/infra/bicep/modules/compute/proximity-placement-group.bicep @@ -0,0 +1,51 @@ +// ============================================================================ +// Module: Proximity Placement Group +// Description: AVM wrapper for Azure Proximity Placement Group +// AVM Module: avm/res/compute/proximity-placement-group +// WAF: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-machines +// ============================================================================ + +@description('Solution name suffix used to derive the resource name.') +param solutionName string + +@description('Name of the proximity placement group.') +param name string = 'ppg-${solutionName}' + +@description('Azure region for the resource.') +param location string + +@description('Tags to apply to the resource.') +param tags object = {} + +@description('Availability zone for the proximity placement group.') +param availabilityZone int = 1 + +@description('VM sizes intent for the proximity placement group.') +param vmSizes array = [] + +@description('Enable Azure telemetry collection.') +param enableTelemetry bool = true + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module proximityPlacementGroup 'br/public:avm/res/compute/proximity-placement-group:0.4.1' = { + name: take('avm.res.compute.proximity-placement-group.${name}', 64) + params: { + name: name + location: location + tags: tags + enableTelemetry: enableTelemetry + availabilityZone: availabilityZone + intent: !empty(vmSizes) ? { vmSizes: vmSizes } : null + } +} + +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the proximity placement group.') +output resourceId string = proximityPlacementGroup.outputs.resourceId + +@description('Name of the proximity placement group.') +output name string = proximityPlacementGroup.outputs.name diff --git a/infra/bicep/modules/compute/virtual-machine.bicep b/infra/bicep/modules/compute/virtual-machine.bicep index a0e8c1dac..8b9c2a7e0 100644 --- a/infra/bicep/modules/compute/virtual-machine.bicep +++ b/infra/bicep/modules/compute/virtual-machine.bicep @@ -1,149 +1,157 @@ // ============================================================================ // Module: Virtual Machine (Jumpbox) -// Description: Vanilla Bicep module for a Windows jumpbox VM used for private -// network administration via Azure Bastion. -// Resources: Microsoft.Compute/virtualMachines, Microsoft.Network/networkInterfaces -// NOTE: Not used by the lean main.bicep; retained for module parity with the -// AVM flavor across GSA. +// Description: AVM wrapper for Azure Virtual Machine with Entra ID authentication +// AVM Module: avm/res/compute/virtual-machine +// Ref: https://learn.microsoft.com/azure/bastion/bastion-entra-id-authentication // ============================================================================ -@description('Required. Name of the virtual machine (max 15 chars).') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the virtual machine.') +param name string = 'vm-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true - -@description('Optional. VM size.') +@description('VM size.') param vmSize string = 'Standard_D2s_v5' -@description('Required. Admin username for the VM.') +@secure() +@description('Local admin username. Required by Azure at provisioning time but not used for login when Entra ID is enabled.') param adminUsername string @secure() -@description('Required. Admin password for the VM.') +@description('Local admin password. Required by Azure at provisioning time but not used for login when Entra ID is enabled.') param adminPassword string -@description('Required. Resource ID of the user assigned managed identity.') -param userAssignedIdentityResourceId string - -@description('Required. Resource ID of the subnet to attach the NIC to.') +@description('Resource ID of the subnet for the VM NIC.') param subnetResourceId string -@description('Optional. Availability zone (-1 to disable).') -param availabilityZone int = -1 +@description('OS type for the VM.') +param osType string = 'Windows' + +@description('Availability zone for the VM.') +param availabilityZone int = 1 + +@description('Image reference for the VM.') +param imageReference object = { + publisher: 'microsoft-dsvm' + offer: 'dsvm-win-2022' + sku: 'winserver-2022' + version: 'latest' +} + +@description('OS disk size in GB.') +param osDiskSizeGB int = 128 -@description('Optional. Enable monitoring agent and DCR association.') -param enableMonitoring bool = false +@description('Resource ID of the maintenance configuration.') +param maintenanceConfigurationResourceId string? -@description('Optional. Resource ID of the Data Collection Rule to associate.') -param dataCollectionRuleResourceId string = '' +@description('Resource ID of the proximity placement group.') +param proximityPlacementGroupResourceId string? -var vmName = take(name, 15) +@description('Monitoring agent extension configuration (data collection rule associations).') +param extensionMonitoringAgentConfig object? -resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = { - name: 'nic-${vmName}' - location: location - tags: tags - properties: { - enableAcceleratedNetworking: true - ipConfigurations: [ +@description('Diagnostic settings for the resource.') +param diagnosticSettings array? + +@description('Enable Azure telemetry collection.') +param enableTelemetry bool = true + +@description('Deploying user principal ID. Used for default role assignment to grant the deploying user login access to the VM. This is required because with Entra ID authentication enabled, local accounts cannot be used to access the VM, including the local admin account created at provisioning.') +param deployingUserPrincipalId string + +@description('Deploying user principal type. Used for default role assignment to grant the deploying user login access to the VM. This is required because with Entra ID authentication enabled, local accounts cannot be used to access the VM, including the local admin account created at provisioning.') +param deployingUserPrincipalType string = 'User' + +@description('Role assignments to apply to the virtual machine.') +param roleAssignments array = [ + { + roleDefinitionIdOrName: '1c0163c0-47e6-4577-8991-ea5c82e286e4' // Virtual Machine Administrator Login + principalId: deployingUserPrincipalId + principalType: deployingUserPrincipalType + } +] + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.22.0' = { + name: take('avm.res.compute.virtual-machine.${name}', 64) + params: { + name: name + location: location + tags: tags + enableTelemetry: enableTelemetry + computerName: take(name, 15) + osType: osType + vmSize: vmSize + adminUsername: adminUsername + adminPassword: adminPassword + managedIdentities: { systemAssigned: true } + patchMode: 'AutomaticByPlatform' + bypassPlatformSafetyChecksOnUserSchedule: true + maintenanceConfigurationResourceId: maintenanceConfigurationResourceId + enableAutomaticUpdates: true + encryptionAtHost: true + availabilityZone: availabilityZone + proximityPlacementGroupResourceId: proximityPlacementGroupResourceId + imageReference: imageReference + osDisk: { + name: 'osdisk-${name}' + caching: 'ReadWrite' + createOption: 'FromImage' + deleteOption: 'Delete' + diskSizeGB: osDiskSizeGB + managedDisk: { storageAccountType: 'Premium_LRS' } + } + nicConfigurations: [ { - name: 'ipconfig01' - properties: { - privateIPAllocationMethod: 'Dynamic' - subnet: { - id: subnetResourceId + name: 'nic-${name}' + tags: tags + deleteOption: 'Delete' + diagnosticSettings: diagnosticSettings + ipConfigurations: [ + { + name: '${name}-nic01-ipconfig01' + subnetResourceId: subnetResourceId + diagnosticSettings: diagnosticSettings } - } + ] } ] - } -} - -resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { - name: vmName - location: location - tags: tags - zones: availabilityZone == -1 ? null : [string(availabilityZone)] - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${userAssignedIdentityResourceId}': {} + roleAssignments: roleAssignments + extensionAadJoinConfig: { + enabled: true + tags: tags + typeHandlerVersion: '2.0' + settings: { mdmId: '' } } - } - properties: { - hardwareProfile: { - vmSize: vmSize - } - osProfile: { - computerName: vmName - adminUsername: adminUsername - adminPassword: adminPassword - } - storageProfile: { - imageReference: { - publisher: 'microsoft-dsvm' - offer: 'dsvm-win-2022' - sku: 'winserver-2022' - version: 'latest' + extensionAntiMalwareConfig: { + enabled: true + settings: { + AntimalwareEnabled: 'true' + Exclusions: {} + RealtimeProtectionEnabled: 'true' + ScheduledScanSettings: { day: '7', isEnabled: 'true', scanType: 'Quick', time: '120' } } - osDisk: { - createOption: 'FromImage' - caching: 'ReadWrite' - diskSizeGB: 128 - managedDisk: { - storageAccountType: 'Premium_LRS' - } - } - } - networkProfile: { - networkInterfaces: [ - { - id: nic.id - } - ] - } - securityProfile: { - encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + tags: tags } + extensionMonitoringAgentConfig: extensionMonitoringAgentConfig + extensionNetworkWatcherAgentConfig: { enabled: true, tags: tags } } } -resource monitoringAgent 'Microsoft.Compute/virtualMachines/extensions@2024-07-01' = if (enableMonitoring) { - parent: vm - name: 'AzureMonitorWindowsAgent' - location: location - tags: tags - properties: { - publisher: 'Microsoft.Azure.Monitor' - type: 'AzureMonitorWindowsAgent' - typeHandlerVersion: '1.0' - autoUpgradeMinorVersion: true - enableAutomaticUpgrade: true - } -} - -resource dcrAssociation 'Microsoft.Insights/dataCollectionRuleAssociations@2023-03-11' = if (enableMonitoring && !empty(dataCollectionRuleResourceId)) { - name: 'dcra-${vmName}' - scope: vm - properties: { - description: 'Associates the Windows security event DCR with the jumpbox VM.' - dataCollectionRuleId: dataCollectionRuleResourceId - } - dependsOn: [ - monitoringAgent - ] -} - +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the virtual machine.') -output resourceId string = vm.id +output resourceId string = virtualMachine.outputs.resourceId @description('Name of the virtual machine.') -output name string = vm.name +output name string = virtualMachine.outputs.name diff --git a/infra/bicep/modules/data/cosmos-db-nosql.bicep b/infra/bicep/modules/data/cosmos-db-nosql.bicep index 6cd311f18..9c758c2ec 100644 --- a/infra/bicep/modules/data/cosmos-db-nosql.bicep +++ b/infra/bicep/modules/data/cosmos-db-nosql.bicep @@ -1,47 +1,42 @@ // ============================================================================ -// Module: Cosmos DB (NoSQL) -// Description: Vanilla Bicep module for an Azure Cosmos DB account (SQL/NoSQL API) -// used for conversation history and product metadata. -// Resource: Microsoft.DocumentDB/databaseAccounts@2024-11-15 +// Module: Cosmos DB +// Description: Creates an Azure Cosmos DB (NoSQL) account with database/container +// API: Microsoft.DocumentDB/databaseAccounts@2025-10-15 // ============================================================================ -@description('Required. Name of the Cosmos DB account.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the Cosmos DB account.') +param name string = 'cosmos-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true - -@description('Required. Name of the SQL database.') -param databaseName string - -@description('Required. Containers to create in the SQL database. Each item: { name, paths }.') -param containers array - -@description('Required. Principal ID of the managed identity to grant data contributor.') -param principalId string - -@description('Required. Principal ID of the deploying user/service principal.') -param deployerPrincipalId string +@description('Database name.') +param databaseName string = 'db_conversation_history' -var cosmosDataContributorRoleId = '00000000-0000-0000-0000-000000000002' // Built-in Cosmos DB Data Contributor +@description('Container definitions.') +param containers array = [ + { + name: 'conversations' + partitionKeyPath: '/userId' + } +] -resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' = { +// ============================================================================ +// Resource Deployment +// ============================================================================ +resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = { name: name location: location tags: tags kind: 'GlobalDocumentDB' properties: { - databaseAccountOfferType: 'Standard' - consistencyPolicy: { - defaultConsistencyLevel: 'Session' - } + consistencyPolicy: { defaultConsistencyLevel: 'Session' } locations: [ { locationName: location @@ -49,86 +44,47 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' = { isZoneRedundant: false } ] + databaseAccountOfferType: 'Standard' enableAutomaticFailover: false - capabilities: [ - { - name: 'EnableServerless' - } - ] - publicNetworkAccess: 'Enabled' - networkAclBypass: 'AzureServices' + enableMultipleWriteLocations: false + disableLocalAuth: true + capabilities: [ { name: 'EnableServerless' } ] } } -resource sqlDatabase 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-11-15' = { +resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2025-10-15' = { parent: cosmos name: databaseName properties: { - resource: { - id: databaseName - } + resource: { id: databaseName } } -} -resource sqlContainers 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-11-15' = [ - for container in containers: { - parent: sqlDatabase + resource list 'containers' = [for container in containers: { name: container.name properties: { resource: { id: container.name - partitionKey: { - paths: container.paths - kind: 'Hash' - } + partitionKey: { paths: [ container.partitionKeyPath ] } } + options: {} } - } -] - -resource dataContributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-11-15' = { - parent: cosmos - name: guid(cosmos.id, 'contentgen-data-contributor') - properties: { - roleName: 'contentgen-data-contributor' - type: 'CustomRole' - assignableScopes: [ - cosmos.id - ] - permissions: [ - { - dataActions: [ - 'Microsoft.DocumentDB/databaseAccounts/readMetadata' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' - ] - } - ] - } -} - -resource identityRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2024-11-15' = { - parent: cosmos - name: guid(cosmos.id, principalId, cosmosDataContributorRoleId) - properties: { - roleDefinitionId: '${cosmos.id}/sqlRoleDefinitions/${cosmosDataContributorRoleId}' - principalId: principalId - scope: cosmos.id - } -} - -resource deployerRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2024-11-15' = { - parent: cosmos - name: guid(cosmos.id, deployerPrincipalId, cosmosDataContributorRoleId) - properties: { - roleDefinitionId: '${cosmos.id}/sqlRoleDefinitions/${cosmosDataContributorRoleId}' - principalId: deployerPrincipalId - scope: cosmos.id - } + }] } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the Cosmos DB account.') output resourceId string = cosmos.id @description('Name of the Cosmos DB account.') output name string = cosmos.name + +@description('Endpoint of the Cosmos DB account.') +output endpoint string = 'https://${name}.documents.azure.com:443/' + +@description('Database name.') +output databaseName string = databaseName + +@description('Container name (first container).') +output containerName string = containers[0].name diff --git a/infra/bicep/modules/data/storage-account.bicep b/infra/bicep/modules/data/storage-account.bicep index 3a4a1d46d..dc3e93f18 100644 --- a/infra/bicep/modules/data/storage-account.bicep +++ b/infra/bicep/modules/data/storage-account.bicep @@ -1,52 +1,67 @@ // ============================================================================ // Module: Storage Account -// Description: Vanilla Bicep module for an Azure Storage Account (blob) used for -// product and generated image storage. -// Resource: Microsoft.Storage/storageAccounts@2024-01-01 +// Description: Creates an Azure Storage Account with blob container +// API: Microsoft.Storage/storageAccounts@2025-08-01 // ============================================================================ -@description('Required. Name of the storage account.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Name of the storage account.') +param name string = take('st${toLower(replace(solutionName, '-', ''))}', 24) + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true - -@description('Optional. Storage account SKU.') +@description('Storage account SKU.') param skuName string = 'Standard_LRS' -@description('Required. Blob containers to create. Each item: { name, publicAccess }.') -param containers array +@description('Storage account kind.') +param kind string = 'StorageV2' + +@description('Access tier.') +@allowed(['Hot', 'Cool']) +param accessTier string = 'Hot' -@description('Required. Principal ID of the managed identity to grant Storage Blob Data Contributor.') -param principalId string +@description('Allow blob public access.') +param allowBlobPublicAccess bool = false + +@description('Allow shared key access.') +param allowSharedKeyAccess bool = true + +@description('Enable hierarchical namespace (Data Lake Storage Gen2).') +param enableHierarchicalNamespace bool = false + +@description('Blob containers to create.') +param containers array = [ + { + name: 'default' + publicAccess: 'None' + } +] -resource storageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' = { +// ============================================================================ +// Resource Deployment +// ============================================================================ +resource storageAccount 'Microsoft.Storage/storageAccounts@2025-08-01' = { name: name location: location tags: tags - kind: 'StorageV2' + kind: kind sku: { name: skuName } - identity: { - type: 'SystemAssigned' - } properties: { - accessTier: 'Hot' + accessTier: accessTier + allowBlobPublicAccess: allowBlobPublicAccess + allowSharedKeyAccess: allowSharedKeyAccess minimumTlsVersion: 'TLS1_2' supportsHttpsTrafficOnly: true - allowBlobPublicAccess: false - publicNetworkAccess: 'Enabled' + isHnsEnabled: enableHierarchicalNamespace encryption: { - requireInfrastructureEncryption: true - keySource: 'Microsoft.Storage' services: { blob: { enabled: true @@ -55,52 +70,36 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' = { enabled: true } } - } - networkAcls: { - bypass: 'AzureServices' - defaultAction: 'Allow' + keySource: 'Microsoft.Storage' + requireInfrastructureEncryption: true } } } -resource blobServices 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2025-08-01' = { parent: storageAccount name: 'default' - properties: { - containerDeleteRetentionPolicy: { - enabled: true - days: 7 - } - deleteRetentionPolicy: { - enabled: true - days: 7 - } - } } -resource blobContainers 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = [ - for container in containers: { - parent: blobServices - name: container.name - properties: { - publicAccess: container.?publicAccess ?? 'None' - } - } -] - -// Storage Blob Data Contributor role assignment for the managed identity. -resource blobDataContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storageAccount.id, principalId, 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') - scope: storageAccount +resource blobContainers 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-08-01' = [for container in containers: { + parent: blobService + name: container.name properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor - principalId: principalId - principalType: 'ServicePrincipal' + publicAccess: container.publicAccess } -} +}] -@description('Resource ID of the storage account.') +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the Storage Account.') output resourceId string = storageAccount.id -@description('Name of the storage account.') +@description('Name of the Storage Account.') output name string = storageAccount.name + +@description('Primary blob endpoint.') +output blobEndpoint string = storageAccount.properties.primaryEndpoints.blob + +@description('All service endpoints.') +output serviceEndpoints object = storageAccount.properties.primaryEndpoints diff --git a/infra/bicep/modules/monitoring/app-insights.bicep b/infra/bicep/modules/monitoring/app-insights.bicep index e9c4bbf75..21109d756 100644 --- a/infra/bicep/modules/monitoring/app-insights.bicep +++ b/infra/bicep/modules/monitoring/app-insights.bicep @@ -1,47 +1,75 @@ // ============================================================================ // Module: Application Insights -// Description: Vanilla Bicep module for an Azure Application Insights component. +// Description: Vanilla Bicep module for Application Insights // Resource: Microsoft.Insights/components@2020-02-02 +// Docs: https://learn.microsoft.com/azure/templates/microsoft.insights/components // ============================================================================ -@description('Required. Name of the Application Insights component.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Optional. Override name for the Application Insights instance. Defaults to appi-{solutionName}.') +param name string = 'appi-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true - -@description('Required. Resource ID of the Log Analytics workspace to link.') +@description('Resource ID of the Log Analytics workspace to link to.') param workspaceResourceId string -resource component 'Microsoft.Insights/components@2020-02-02' = { +@description('Application type.') +param applicationType string = 'web' + +@description('Retention period in days.') +param retentionInDays int = 365 + +@description('Disable IP masking for security.') +param disableIpMasking bool = false + +@description('Flow type for Application Insights.') +param flowType string = 'Bluefield' + +@description('Kind of Application Insights resource.') +param kind string = 'web' + +// ============================================================================ +// Resource +// ============================================================================ + +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { name: name location: location tags: tags - kind: 'web' + kind: kind properties: { - Application_Type: 'web' - Flow_Type: 'Bluefield' - RetentionInDays: 365 - DisableIpMasking: false - IngestionMode: 'LogAnalytics' + Application_Type: applicationType + Flow_Type: flowType WorkspaceResourceId: workspaceResourceId + RetentionInDays: retentionInDays + DisableIpMasking: disableIpMasking publicNetworkAccessForIngestion: 'Enabled' publicNetworkAccessForQuery: 'Enabled' } } -@description('Resource ID of the Application Insights component.') -output resourceId string = component.id +// ============================================================================ +// Outputs +// ============================================================================ + +@description('Resource ID of the Application Insights instance.') +output resourceId string = appInsights.id + +@description('Name of the Application Insights instance.') +output name string = appInsights.name + +@description('Instrumentation key for the Application Insights instance.') +output instrumentationKey string = appInsights.properties.InstrumentationKey -@description('Name of the Application Insights component.') -output name string = component.name +@description('Connection string for the Application Insights instance.') +output connectionString string = appInsights.properties.ConnectionString -@description('Connection string of the Application Insights component.') -output connectionString string = component.properties.ConnectionString +@description('Application ID of the Application Insights instance.') +output applicationId string = appInsights.properties.AppId diff --git a/infra/bicep/modules/monitoring/data-collection-rule.bicep b/infra/bicep/modules/monitoring/data-collection-rule.bicep index 70b19639d..c1fd7606c 100644 --- a/infra/bicep/modules/monitoring/data-collection-rule.bicep +++ b/infra/bicep/modules/monitoring/data-collection-rule.bicep @@ -1,75 +1,149 @@ // ============================================================================ -// Module: Data Collection Rule (Windows Security Events) -// Description: Vanilla Bicep module for an Azure Monitor Data Collection Rule that -// collects Windows Security audit events from the jumpbox VM -// (SFI-AzTBv17 compliance). -// Resource: Microsoft.Insights/dataCollectionRules@2023-03-11 -// NOTE: Not used by the lean main.bicep; retained for module parity with the -// AVM flavor across GSA. +// Module: Data Collection Rule +// Description: AVM wrapper for Azure Monitor Data Collection Rule +// AVM Module: avm/res/insights/data-collection-rule +// WAF: Monitoring for VM observability // ============================================================================ -@description('Required. Name of the data collection rule.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Optional. Override name for the data collection rule. Defaults to dcr-{solutionName}.') +param name string = 'dcr-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} +@description('Resource ID of the Log Analytics workspace destination.') +param logAnalyticsWorkspaceResourceId string + +@description('Name of the Log Analytics workspace (used for destination naming).') +param logAnalyticsWorkspaceName string = '' + @description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params param enableTelemetry bool = true -@description('Required. Resource ID of the Log Analytics workspace destination.') -param workspaceResourceId string +var dcrLogAnalyticsDestinationName = !empty(logAnalyticsWorkspaceName) ? 'la-${logAnalyticsWorkspaceName}-destination' : 'la-${name}-destination' -@description('Optional. Name of the Log Analytics destination.') -param destinationName string = 'la-destination' - -resource dcr 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { - name: name - location: location - tags: tags - kind: 'Windows' - properties: { - description: 'Collects Windows Security audit success/failure events from jumpbox VM (SFI-AzTBv17 compliance).' - dataSources: { - windowsEventLogs: [ +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module dataCollectionRule 'br/public:avm/res/insights/data-collection-rule:0.11.0' = { + name: take('avm.res.insights.data-collection-rule.${name}', 64) + params: { + name: name + tags: tags + enableTelemetry: enableTelemetry + location: location + dataCollectionRuleProperties: { + kind: 'Windows' + dataSources: { + performanceCounters: [ + { + streams: ['Microsoft-Perf'] + samplingFrequencyInSeconds: 60 + counterSpecifiers: [ + '\\Processor Information(_Total)\\% Processor Time' + '\\Processor Information(_Total)\\% Privileged Time' + '\\Processor Information(_Total)\\% User Time' + '\\Processor Information(_Total)\\Processor Frequency' + '\\System\\Processes' + '\\Process(_Total)\\Thread Count' + '\\Process(_Total)\\Handle Count' + '\\System\\System Up Time' + '\\System\\Context Switches/sec' + '\\System\\Processor Queue Length' + '\\Memory\\% Committed Bytes In Use' + '\\Memory\\Available Bytes' + '\\Memory\\Committed Bytes' + '\\Memory\\Cache Bytes' + '\\Memory\\Pool Paged Bytes' + '\\Memory\\Pool Nonpaged Bytes' + '\\Memory\\Pages/sec' + '\\Memory\\Page Faults/sec' + '\\Process(_Total)\\Working Set' + '\\Process(_Total)\\Working Set - Private' + '\\LogicalDisk(_Total)\\% Disk Time' + '\\LogicalDisk(_Total)\\% Disk Read Time' + '\\LogicalDisk(_Total)\\% Disk Write Time' + '\\LogicalDisk(_Total)\\% Idle Time' + '\\LogicalDisk(_Total)\\Disk Bytes/sec' + '\\LogicalDisk(_Total)\\Disk Read Bytes/sec' + '\\LogicalDisk(_Total)\\Disk Write Bytes/sec' + '\\LogicalDisk(_Total)\\Disk Transfers/sec' + '\\LogicalDisk(_Total)\\Disk Reads/sec' + '\\LogicalDisk(_Total)\\Disk Writes/sec' + '\\LogicalDisk(_Total)\\Avg. Disk sec/Transfer' + '\\LogicalDisk(_Total)\\Avg. Disk sec/Read' + '\\LogicalDisk(_Total)\\Avg. Disk sec/Write' + '\\LogicalDisk(_Total)\\Avg. Disk Queue Length' + '\\LogicalDisk(_Total)\\Avg. Disk Read Queue Length' + '\\LogicalDisk(_Total)\\Avg. Disk Write Queue Length' + '\\LogicalDisk(_Total)\\% Free Space' + '\\LogicalDisk(_Total)\\Free Megabytes' + '\\Network Interface(*)\\Bytes Total/sec' + '\\Network Interface(*)\\Bytes Sent/sec' + '\\Network Interface(*)\\Bytes Received/sec' + '\\Network Interface(*)\\Packets/sec' + '\\Network Interface(*)\\Packets Sent/sec' + '\\Network Interface(*)\\Packets Received/sec' + '\\Network Interface(*)\\Packets Outbound Errors' + '\\Network Interface(*)\\Packets Received Errors' + ] + name: 'perfCounterDataSource60' + } + ] + windowsEventLogs: [ + { + name: 'SecurityAuditEvents' + streams: ['Microsoft-WindowsEvent'] + xPathQueries: [ + 'Security!*[System[(EventID=4624 or EventID=4625)]]' + ] + } + { + name: 'AuditSuccessFailure' + streams: ['Microsoft-Event'] + xPathQueries: [ + 'Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]' + ] + } + ] + } + destinations: { + logAnalytics: [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + name: dcrLogAnalyticsDestinationName + } + ] + } + dataFlows: [ { - name: 'securityEventLogsDataSource' - streams: [ - 'Microsoft-SecurityEvent' - ] - xPathQueries: [ - 'Security!*[System[(band(Keywords,13510798882111488)) and (EventID != 4624)]]' - ] + streams: ['Microsoft-Perf'] + destinations: [dcrLogAnalyticsDestinationName] + transformKql: 'source' + outputStream: 'Microsoft-Perf' } - ] - } - destinations: { - logAnalytics: [ { - name: destinationName - workspaceResourceId: workspaceResourceId + streams: ['Microsoft-Event'] + destinations: [dcrLogAnalyticsDestinationName] + transformKql: 'source' + outputStream: 'Microsoft-Event' } ] } - dataFlows: [ - { - streams: [ - 'Microsoft-SecurityEvent' - ] - destinations: [ - destinationName - ] - } - ] } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the data collection rule.') -output resourceId string = dcr.id +output resourceId string = dataCollectionRule.outputs.resourceId @description('Name of the data collection rule.') -output name string = dcr.name +output name string = dataCollectionRule.outputs.name diff --git a/infra/bicep/modules/monitoring/log-analytics.bicep b/infra/bicep/modules/monitoring/log-analytics.bicep index 971e01121..87d79740c 100644 --- a/infra/bicep/modules/monitoring/log-analytics.bicep +++ b/infra/bicep/modules/monitoring/log-analytics.bicep @@ -1,39 +1,58 @@ // ============================================================================ // Module: Log Analytics Workspace -// Description: Vanilla Bicep module for an Azure Log Analytics Workspace. -// Resource: Microsoft.OperationalInsights/workspaces@2025-02-01 +// Description: Vanilla Bicep module for Log Analytics Workspace +// Resource: Microsoft.OperationalInsights/workspaces@2023-09-01 +// Docs: https://learn.microsoft.com/azure/templates/microsoft.operationalinsights/workspaces +// Note: This module only handles NEW workspace creation. +// Existing workspace logic is handled in main.bicep. // ============================================================================ -@description('Required. Name of the Log Analytics workspace.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +@description('Optional. Override name for the Log Analytics workspace. Defaults to log-{solutionName}.') +param name string = 'log-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true +@description('Retention period in days.') +param retentionInDays int = 365 + +@description('SKU name for the workspace.') +param skuName string = 'PerGB2018' + +// ============================================================================ +// Resource +// ============================================================================ -resource workspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { name: name location: location tags: tags properties: { + retentionInDays: retentionInDays sku: { - name: 'PerGB2018' - } - retentionInDays: 365 - features: { - enableLogAccessUsingOnlyResourcePermissions: true + name: skuName } } } +// ============================================================================ +// Outputs +// ============================================================================ + @description('Resource ID of the Log Analytics workspace.') -output resourceId string = workspace.id +output resourceId string = logAnalytics.id @description('Name of the Log Analytics workspace.') -output name string = workspace.name +output name string = logAnalytics.name + +@description('Location of the workspace.') +output location string = logAnalytics.location + +@description('Log Analytics workspace customer ID.') +output logAnalyticsWorkspaceId string = logAnalytics.properties.customerId diff --git a/infra/bicep/modules/networking/bastion-host.bicep b/infra/bicep/modules/networking/bastion-host.bicep index 8aaefb0ee..bf524087e 100644 --- a/infra/bicep/modules/networking/bastion-host.bicep +++ b/infra/bicep/modules/networking/bastion-host.bicep @@ -1,82 +1,85 @@ // ============================================================================ // Module: Bastion Host -// Description: Vanilla Bicep module for an Azure Bastion host (Standard SKU). -// Resources: Microsoft.Network/bastionHosts, Microsoft.Network/publicIPAddresses -// NOTE: Not used by the lean main.bicep; retained for module parity with the -// AVM flavor across GSA. +// Description: AVM wrapper for Azure Bastion Host +// AVM Module: avm/res/network/bastion-host // ============================================================================ -@description('Required. Name of the Bastion host.') -param name string +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Required. Azure region for the resource.') +var name = 'bas-${solutionName}' + +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} @description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params param enableTelemetry bool = true -@description('Required. Resource ID of the virtual network to attach the Bastion host to.') +@description('Resource ID of the virtual network.') param virtualNetworkResourceId string -@description('Optional. Resource ID of the Log Analytics workspace for diagnostics. Empty disables diagnostics.') -param logAnalyticsWorkspaceResourceId string = '' +@description('Optional. Diagnostic settings for the resource.') +param diagnosticSettings array? -resource publicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { - name: 'pip-${name}' - location: location - tags: tags - sku: { - name: 'Standard' - } - properties: { - publicIPAllocationMethod: 'Static' - } -} +@description('SKU name for the Bastion Host.') +param skuName string = 'Standard' -resource bastion 'Microsoft.Network/bastionHosts@2024-05-01' = { - name: name - location: location - tags: tags - sku: { - name: 'Standard' - } - properties: { - ipConfigurations: [ - { - name: 'IpConf' - properties: { - subnet: { - id: '${virtualNetworkResourceId}/subnets/AzureBastionSubnet' - } - publicIPAddress: { - id: publicIp.id - } - } - } - ] - } -} +@description('Number of scale units.') +param scaleUnits int = 4 + +@description('Disable copy/paste functionality.') +param disableCopyPaste bool = true + +@description('Enable file copy functionality.') +param enableFileCopy bool = false -resource bastionDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(logAnalyticsWorkspaceResourceId)) { - name: 'bastionDiagnostics' - scope: bastion - properties: { - workspaceId: logAnalyticsWorkspaceResourceId - logs: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] +@description('Enable IP Connect functionality.') +param enableIpConnect bool = false + +@description('Enable shareable link functionality.') +param enableShareableLink bool = false + +@description('Availability zones for the Bastion Host public IP. Pass empty array to disable zone redundancy.') +param availabilityZones array = [] + +@description('Optional. Diagnostic settings for the public IP address.') +param publicIPDiagnosticSettings array? + +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module bastionHost 'br/public:avm/res/network/bastion-host:0.8.2' = { + name: take('avm.res.network.bastion-host.${name}', 64) + params: { + name: name + location: location + tags: tags + enableTelemetry: enableTelemetry + skuName: skuName + virtualNetworkResourceId: virtualNetworkResourceId + availabilityZones: availabilityZones + publicIPAddressObject: { + name: 'pip-${name}' + diagnosticSettings: publicIPDiagnosticSettings + tags: tags + } + disableCopyPaste: disableCopyPaste + enableFileCopy: enableFileCopy + enableIpConnect: enableIpConnect + enableShareableLink: enableShareableLink + scaleUnits: scaleUnits + diagnosticSettings: diagnosticSettings } } -@description('Resource ID of the Bastion host.') -output resourceId string = bastion.id +// ============================================================================ +// Outputs +// ============================================================================ +@description('Resource ID of the Bastion Host.') +output resourceId string = bastionHost.outputs.resourceId -@description('Name of the Bastion host.') -output name string = bastion.name +@description('Name of the Bastion Host.') +output name string = bastionHost.outputs.name diff --git a/infra/bicep/modules/networking/private-dns-zone.bicep b/infra/bicep/modules/networking/private-dns-zone.bicep index 74154583f..be1f69733 100644 --- a/infra/bicep/modules/networking/private-dns-zone.bicep +++ b/infra/bicep/modules/networking/private-dns-zone.bicep @@ -1,45 +1,44 @@ // ============================================================================ // Module: Private DNS Zone -// Description: Vanilla Bicep module for a single Private DNS Zone linked to a VNet. -// Resource: Microsoft.Network/privateDnsZones@2024-06-01 -// NOTE: Not used by the lean main.bicep; retained for module parity with the -// AVM flavor across GSA. +// Description: AVM wrapper for Azure Private DNS Zone +// AVM Module: avm/res/network/private-dns-zone +// Usage: Call once per DNS zone from main.bicep // ============================================================================ -@description('Required. Name of the private DNS zone (e.g. privatelink.blob.core.windows.net).') +@description('Name of the private DNS zone (e.g., privatelink.cognitiveservices.azure.com).') param name string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} @description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params param enableTelemetry bool = true -@description('Required. Resource ID of the virtual network to link.') -param virtualNetworkResourceId string +@description('Virtual network links to associate with the DNS zone.') +param virtualNetworkLinks array = [] -resource zone 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: name - location: 'global' - tags: tags -} +@description('Optional. Array of A records.') +param a array = [] -resource virtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - parent: zone - name: '${last(split(virtualNetworkResourceId, '/'))}-link' - location: 'global' - tags: tags - properties: { - registrationEnabled: false - virtualNetwork: { - id: virtualNetworkResourceId - } +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.8.1' = { + name: take('avm.res.network.private-dns-zone.${split(name, '.')[1]}', 64) + params: { + name: name + tags: tags + enableTelemetry: enableTelemetry + virtualNetworkLinks: virtualNetworkLinks + a: a } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the private DNS zone.') -output resourceId string = zone.id +output resourceId string = privateDnsZone.outputs.resourceId @description('Name of the private DNS zone.') -output name string = zone.name +output name string = privateDnsZone.outputs.name diff --git a/infra/bicep/modules/networking/private-endpoint.bicep b/infra/bicep/modules/networking/private-endpoint.bicep index 95ada914e..04bfff07c 100644 --- a/infra/bicep/modules/networking/private-endpoint.bicep +++ b/infra/bicep/modules/networking/private-endpoint.bicep @@ -1,73 +1,52 @@ // ============================================================================ // Module: Private Endpoint -// Description: Vanilla Bicep module for an Azure Private Endpoint with DNS zone group. -// Resource: Microsoft.Network/privateEndpoints@2024-05-01 -// NOTE: Not used by the lean main.bicep; retained for module parity with the -// AVM flavor across GSA. +// Description: AVM wrapper for Azure Private Endpoint +// AVM Module: avm/res/network/private-endpoint +// Usage: Call once per private endpoint from main.bicep // ============================================================================ -@description('Required. Name of the private endpoint.') +@description('Name of the private endpoint.') param name string -@description('Required. Azure region for the resource.') +@description('Azure region for the resource.') param location string -@description('Optional. Tags to apply to the resource.') +@description('Tags to apply to the resource.') param tags object = {} -@description('Optional. Enable/Disable usage telemetry for module.') -#disable-next-line no-unused-params -param enableTelemetry bool = true +@description('Optional. Custom NIC name for the private endpoint.') +param customNetworkInterfaceName string = '' -@description('Required. Resource ID of the subnet to deploy the private endpoint into.') +@description('Resource ID of the subnet for the private endpoint.') param subnetResourceId string -@description('Required. Resource ID of the target resource (private link service).') -param targetResourceId string - -@description('Required. Group IDs (sub-resources) the private endpoint connects to.') -param groupIds array - -@description('Optional. Private DNS zone group configurations. Each item: { name, privateDnsZoneResourceId }.') -param privateDnsZoneConfigs array = [] +@description('Private link service connections configuration.') +param privateLinkServiceConnections array -resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { - name: name - location: location - tags: tags - properties: { - subnet: { - id: subnetResourceId - } - privateLinkServiceConnections: [ - { - name: name - properties: { - privateLinkServiceId: targetResourceId - groupIds: groupIds - } - } - ] - } -} +@description('Optional. Private DNS zone group configuration.') +param privateDnsZoneGroup object? -resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = if (!empty(privateDnsZoneConfigs)) { - parent: privateEndpoint - name: 'default' - properties: { - privateDnsZoneConfigs: [ - for (config, i) in privateDnsZoneConfigs: { - name: config.?name ?? 'config-${i}' - properties: { - privateDnsZoneId: config.privateDnsZoneResourceId - } - } - ] +// ============================================================================ +// AVM Module Deployment +// ============================================================================ +module privateEndpoint 'br/public:avm/res/network/private-endpoint:0.12.0' = { + name: take('avm.res.network.private-endpoint.${name}', 64) + params: { + name: name + location: location + tags: tags + customNetworkInterfaceName: !empty(customNetworkInterfaceName) ? customNetworkInterfaceName : 'nic-${name}' + subnetResourceId: subnetResourceId + privateLinkServiceConnections: privateLinkServiceConnections + privateDnsZoneGroup: privateDnsZoneGroup } } +// ============================================================================ +// Outputs +// ============================================================================ @description('Resource ID of the private endpoint.') -output resourceId string = privateEndpoint.id +output resourceId string = privateEndpoint.outputs.resourceId @description('Name of the private endpoint.') -output name string = privateEndpoint.name +output name string = privateEndpoint.outputs.name diff --git a/infra/bicep/modules/networking/virtual-network.bicep b/infra/bicep/modules/networking/virtual-network.bicep index 65e276d91..ca6f68946 100644 --- a/infra/bicep/modules/networking/virtual-network.bicep +++ b/infra/bicep/modules/networking/virtual-network.bicep @@ -1,125 +1,122 @@ -/****************************************************************************************************************************/ -// Networking - NSGs, VNET and Subnets for Content Generation Solution -// Vanilla Bicep implementation (raw resources, no AVM registry dependency). -// NOTE: Not used by the lean main.bicep; retained for module parity with the -// AVM flavor across GSA. -/****************************************************************************************************************************/ -@description('Name of the virtual network.') -param vnetName string +// ============================================================================ +// Module: Virtual Network +// Description: VNet, Subnets, and NSGs using AVM modules. +// Each subnet gets its own NSG. Subnet config is passed as param. +// AVM Modules: +// - avm/res/network/network-security-group:0.5.3 +// - avm/res/network/virtual-network:0.8.0 +// ============================================================================ -@description('Azure region to deploy resources.') -param location string = resourceGroup().location - -@description('Required. An Array of 1 or more IP Address Prefixes for the Virtual Network.') -param addressPrefixes array = ['10.0.0.0/20'] +@description('Solution name suffix used to derive the resource name.') +param solutionName string -@description('Optional. Deploy Azure Bastion and Jumpbox subnets for VM-based administration.') -param deployBastionAndJumpbox bool = false - -@description('Optional. Tags to be applied to the resources.') -param tags object = {} +var name = 'vnet-${solutionName}' -@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.') -#disable-next-line no-unused-params -param enableTelemetry bool = true +@description('Azure region for the resource.') +param location string = resourceGroup().location -@description('Required. Suffix for resource naming.') -param resourceSuffix string +@description('Address prefixes for the virtual network.') +param addressPrefixes array -// Core subnets: web (App Service), peps (Private Endpoints), aci (Container Instance) -// Optional: AzureBastionSubnet and jumpbox (only when deployBastionAndJumpbox is true) -var coreSubnets = [ +@description('Subnet configurations.') +param subnets subnetType[] = [ { - name: 'web' - addressPrefixes: ['10.0.0.0/23'] - delegation: 'Microsoft.Web/serverFarms' + name: 'backend' + addressPrefixes: ['10.0.0.0/27'] networkSecurityGroup: { - name: 'nsg-web' + name: 'nsg-backend' securityRules: [ { - name: 'AllowHttpsInbound' + name: 'deny-hop-outbound' properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: ['22', '3389'] + direction: 'Outbound' + priority: 200 protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefixes: ['0.0.0.0/0'] - destinationAddressPrefixes: ['10.0.0.0/23'] } } + ] + } + } + { + name: 'containers' + addressPrefixes: ['10.0.2.0/23'] + delegation: 'Microsoft.App/environments' + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' + networkSecurityGroup: { + name: 'nsg-containers' + securityRules: [ { - name: 'AllowIntraSubnetTraffic' + name: 'deny-hop-outbound' properties: { - access: 'Allow' - direction: 'Inbound' + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: ['22', '3389'] + direction: 'Outbound' priority: 200 - protocol: '*' + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefixes: ['10.0.0.0/23'] - destinationAddressPrefixes: ['10.0.0.0/23'] } } + ] + } + } + { + name: 'webserverfarm' + addressPrefixes: ['10.0.4.0/27'] + delegation: 'Microsoft.Web/serverfarms' + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' + networkSecurityGroup: { + name: 'nsg-webserverfarm' + securityRules: [ { - name: 'AllowAzureLoadBalancer' + name: 'deny-hop-outbound' properties: { - access: 'Allow' - direction: 'Inbound' - priority: 300 - protocol: '*' + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: ['22', '3389'] + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' sourcePortRange: '*' - destinationPortRange: '*' - sourceAddressPrefix: 'AzureLoadBalancer' - destinationAddressPrefix: '10.0.0.0/23' } } ] } } { - name: 'peps' - addressPrefixes: ['10.0.2.0/23'] - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Disabled' + name: 'administration' + addressPrefixes: ['10.0.0.32/27'] networkSecurityGroup: { - name: 'nsg-peps' - securityRules: [] - } - } - { - name: 'aci' - addressPrefixes: ['10.0.4.0/24'] - delegation: 'Microsoft.ContainerInstance/containerGroups' - networkSecurityGroup: { - name: 'nsg-aci' + name: 'nsg-administration' securityRules: [ { - name: 'AllowHttpsInbound' + name: 'deny-hop-outbound' properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRanges: ['22', '3389'] + direction: 'Outbound' + priority: 200 protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' sourcePortRange: '*' - destinationPortRange: '8000' - sourceAddressPrefixes: ['10.0.0.0/20'] - destinationAddressPrefixes: ['10.0.4.0/24'] } } ] } } -] - -var bastionSubnets = deployBastionAndJumpbox ? [ { name: 'AzureBastionSubnet' - addressPrefixes: ['10.0.10.0/26'] + addressPrefixes: ['10.0.0.64/26'] networkSecurityGroup: { name: 'nsg-bastion' securityRules: [ @@ -178,107 +175,157 @@ var bastionSubnets = deployBastionAndJumpbox ? [ ] } } - { - name: 'jumpbox' - addressPrefixes: ['10.0.12.0/23'] - networkSecurityGroup: { - name: 'nsg-jumpbox' - securityRules: [ - { - name: 'AllowRdpFromBastion' - properties: { - access: 'Allow' - direction: 'Inbound' - priority: 100 - protocol: 'Tcp' - sourcePortRange: '*' - destinationPortRange: '3389' - sourceAddressPrefixes: ['10.0.10.0/26'] - destinationAddressPrefixes: ['10.0.12.0/23'] - } - } - ] - } - } -] : [] +] + +@description('Tags to apply to the resources.') +param tags object = {} + +@description('Resource ID of the Log Analytics Workspace for diagnostics.') +param logAnalyticsWorkspaceId string + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true -var vnetSubnets = concat(coreSubnets, bastionSubnets) -var subnetNames = map(vnetSubnets, subnet => subnet.name) +@description('Suffix for resource naming.') +param resourceSuffix string -// Create NSGs for subnets +// ============================================================================ +// NSGs — one per subnet +// ============================================================================ @batchSize(1) -resource nsgs 'Microsoft.Network/networkSecurityGroups@2024-05-01' = [ - for (subnet, i) in vnetSubnets: { - name: '${subnet.networkSecurityGroup.name}-${resourceSuffix}' - location: location - tags: tags - properties: { - securityRules: subnet.networkSecurityGroup.securityRules +module nsgs 'br/public:avm/res/network/network-security-group:0.5.3' = [ + for (subnet, i) in subnets: if (!empty(subnet.?networkSecurityGroup)) { + name: take('avm.res.network.nsg.${subnet.?networkSecurityGroup.name}.${resourceSuffix}', 64) + params: { + name: '${subnet.?networkSecurityGroup.name}-${resourceSuffix}' + location: location + securityRules: subnet.?networkSecurityGroup.securityRules + tags: tags + enableTelemetry: enableTelemetry } } ] -// Create VNet and subnets -resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = { - name: vnetName - location: location - tags: tags - properties: { - addressSpace: { - addressPrefixes: addressPrefixes - } +// ============================================================================ +// Virtual Network + Subnets +// ============================================================================ +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.8.0' = { + name: take('avm.res.network.virtual-network.${name}', 64) + params: { + name: name + location: location + addressPrefixes: addressPrefixes subnets: [ - for (subnet, i) in vnetSubnets: { + for (subnet, i) in subnets: { name: subnet.name - properties: { - addressPrefixes: subnet.addressPrefixes - networkSecurityGroup: { - id: nsgs[i].id - } - privateEndpointNetworkPolicies: subnet.?privateEndpointNetworkPolicies - privateLinkServiceNetworkPolicies: subnet.?privateLinkServiceNetworkPolicies - delegations: empty(subnet.?delegation) ? [] : [ - { - name: 'delegation' - properties: { - serviceName: subnet.?delegation - } - } - ] - } - } - ] - } -} - -resource vnetDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(logAnalyticsWorkspaceId)) { - name: 'vnetDiagnostics' - scope: virtualNetwork - properties: { - workspaceId: logAnalyticsWorkspaceId - logs: [ - { - categoryGroup: 'allLogs' - enabled: true + addressPrefixes: subnet.?addressPrefixes + networkSecurityGroupResourceId: !empty(subnet.?networkSecurityGroup) ? nsgs[i]!.outputs.resourceId : null + privateEndpointNetworkPolicies: subnet.?privateEndpointNetworkPolicies + privateLinkServiceNetworkPolicies: subnet.?privateLinkServiceNetworkPolicies + delegation: subnet.?delegation } ] - metrics: [ + diagnosticSettings: [ { - category: 'AllMetrics' - enabled: true + name: 'vnetDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] } ] + tags: tags + enableTelemetry: enableTelemetry } } -output name string = virtualNetwork.name -output resourceId string = virtualNetwork.id +// ============================================================================ +// Outputs +// ============================================================================ +output name string = virtualNetwork.outputs.name +output resourceId string = virtualNetwork.outputs.resourceId -// Core subnet outputs (always present) -output webSubnetResourceId string = contains(subnetNames, 'web') ? virtualNetwork.properties.subnets[indexOf(subnetNames, 'web')].id : '' -output pepsSubnetResourceId string = contains(subnetNames, 'peps') ? virtualNetwork.properties.subnets[indexOf(subnetNames, 'peps')].id : '' -output aciSubnetResourceId string = contains(subnetNames, 'aci') ? virtualNetwork.properties.subnets[indexOf(subnetNames, 'aci')].id : '' +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 + } +] -// Bastion/jumpbox subnet outputs (always declared; will be empty when those subnets are not deployed) -output bastionSubnetResourceId string = contains(subnetNames, 'AzureBastionSubnet') ? virtualNetwork.properties.subnets[indexOf(subnetNames, 'AzureBastionSubnet')].id : '' -output jumpboxSubnetResourceId string = contains(subnetNames, 'jumpbox') ? virtualNetwork.properties.subnets[indexOf(subnetNames, 'jumpbox')].id : '' +// Individual subnet outputs for backward compatibility +output backendSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'backend') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'backend')] + : '' +output containerSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'containers') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'containers')] + : '' +output webserverfarmSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'webserverfarm') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'webserverfarm')] + : '' +output administrationSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'administration') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'administration')] + : '' +output bastionSubnetResourceId string = contains(map(subnets, subnet => subnet.name), 'AzureBastionSubnet') + ? virtualNetwork.outputs.subnetResourceIds[indexOf(map(subnets, subnet => subnet.name), 'AzureBastionSubnet')] + : '' + +// ============================================================================ +// Custom Types +// ============================================================================ +@export() +@description('Subnet output type') +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 NSG, if any.') + nsgName: string? + @description('The resource ID of the associated NSG, if any.') + nsgResourceId: string? +} + +@export() +@description('Subnet configuration type') +type subnetType = { + @description('Required. The name of the subnet.') + name: string + @description('Required. Address prefixes for the subnet.') + addressPrefixes: string[] + @description('Optional. Delegation for the subnet.') + delegation: string? + @description('Optional. Private endpoint network policies.') + privateEndpointNetworkPolicies: ('Disabled' | 'Enabled' | 'NetworkSecurityGroupEnabled' | 'RouteTableEnabled')? + @description('Optional. Private link service network policies.') + privateLinkServiceNetworkPolicies: ('Disabled' | 'Enabled')? + @description('Optional. NSG configuration for the subnet.') + networkSecurityGroup: networkSecurityGroupType? + @description('Optional. Route table resource ID.') + routeTableResourceId: string? + @description('Optional. Service endpoint policies.') + serviceEndpointPolicies: object[]? + @description('Optional. Service endpoints to enable.') + serviceEndpoints: string[]? + @description('Optional. Disable default outbound connectivity.') + defaultOutboundAccess: bool? +} + +@export() +@description('NSG configuration type') +type networkSecurityGroupType = { + @description('Required. The name of the NSG.') + name: string + @description('Required. Security rules for the NSG.') + securityRules: object[] +}