diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/README.md b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/README.md new file mode 100644 index 000000000..fa84ae669 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/README.md @@ -0,0 +1,165 @@ +# 46 - Standard Agent Setup with BYO VNet and GSA Proxy + +This sample deploys an Azure AI Foundry agent setup with: + +- **BYO Virtual Network** with agent subnet delegation (no private endpoints) +- **GSA AI Connector proxy** VM for egress traffic control +- **UDR (User Defined Route)** that routes `0.0.0.0/0` traffic from the agent subnet through the GSA proxy +- **Service tag exceptions** in the UDR so critical Azure traffic bypasses the proxy + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Virtual Network (10.0.0.0/16) │ +│ │ +│ ┌───────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ agent-subnet (10.0.0.0/24)│ │ gsa-proxy-subnet │ │ +│ │ │ │ (10.0.1.0/24) │ │ +│ │ Delegated to │ │ │ │ +│ │ Microsoft.App/environments│ │ ┌──────────────────────┐ │ │ +│ │ │ │ │ GSA Proxy VM │ │ │ +│ │ UDR: 0/0 → Proxy IP │ │ │ - Managed Identity │ │ │ +│ │ (with service tag │──│──│ - IP Forwarding: ON │ │ │ +│ │ exceptions) │ │ │ - NSG: VNet allowed │ │ │ +│ │ │ │ └──────────────────────┘ │ │ +│ └───────────────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ├── AI Services (publicNetworkAccess: Enabled) + │ └── networkInjections: agent subnet + ├── AI Project + ├── Cosmos DB + ├── AI Search + └── Storage Account +``` + +## Key Differences from Other Setups + +| Feature | 41-standard-agent | 15-private-network | **46-byovnet-gsa-proxy** | +|---|---|---|---| +| VNet | ❌ | ✅ | ✅ | +| Private Endpoints | ❌ | ✅ | ❌ | +| DNS Zones | ❌ | ✅ | ❌ | +| Public Network Access | Enabled | Disabled | **Enabled** | +| Agent Subnet Delegation | ❌ | ✅ | ✅ | +| GSA Proxy | ❌ | ❌ | ✅ | +| UDR (egress control) | ❌ | ❌ | ✅ | + +## GSA Proxy Details + +The GSA AI Connector is deployed from the Azure Marketplace: +- **Publisher**: `microsoftcorporation1687208452115` +- **Offer**: `gsaaiconnector1-preview` +- **Plan**: `gsaaiconnectorplan1` + +### VM Configuration +- **Managed Identity**: System-assigned (enabled) +- **IP Forwarding**: Enabled on the NIC (required for routing) +- **NSG Rules**: + - Inbound: Allow `VirtualNetwork` and `AzureLoadBalancer`; deny all other + - Outbound: Allow `VirtualNetwork` and `Internet` + +## UDR (User Defined Routes) + +The agent subnet has a route table that sends all default (`0.0.0.0/0`) traffic through the GSA proxy VM as a virtual appliance. The following Azure service tags are **excepted** (routed directly to Internet): + +| Service Tag | Purpose | +|---|---| +| `AzureActiveDirectory` | Entra ID authentication | +| `AzureResourceManager` | ARM API calls | +| `AzureMonitor` | Monitoring and diagnostics | +| `GuestAndHybridManagement` | VM guest agent management | +| `AzureContainerRegistry` | Container image pulls | +| `AzureKeyVault` | Key Vault access | +| `Storage` | Azure Storage access | +| `AzureFrontDoor.FirstParty` | Azure Front Door first-party services | +| `ContainerAppsManagement` | Container Apps management plane | + +## Prerequisites + +1. **Accept the marketplace terms** for the GSA AI Connector image before deploying: + ```bash + az vm image terms accept \ + --publisher microsoftcorporation1687208452115 \ + --offer gsaaiconnector1-preview \ + --plan gsaaiconnectorplan1 + ``` + +2. **Generate an SSH key pair** for the proxy VM: + ```bash + ssh-keygen -t rsa -b 4096 -f ~/.ssh/gsa-proxy-key -N "" + ``` + +## Deployment + +### Using Azure CLI with Bicep parameter file + +```bash +# Set your SSH public key +export GSA_PROXY_SSH_PUBLIC_KEY=$(cat ~/.ssh/gsa-proxy-key.pub) + +# Create resource group +az group create --name rg-ai-agent-gsa-proxy --location eastus + +# Deploy +az deployment group create \ + --resource-group rg-ai-agent-gsa-proxy \ + --template-file main.bicep \ + --parameters main.bicepparam +``` + +### Using inline parameters + +```bash +az deployment group create \ + --resource-group rg-ai-agent-gsa-proxy \ + --template-file main.bicep \ + --parameters \ + location=eastus \ + aiServices=foundry \ + gsaProxySshPublicKey="$(cat ~/.ssh/gsa-proxy-key.pub)" +``` + +## Module Structure + +``` +46-standard-agent-byovnet-gsa-proxy-setup/ +├── main.bicep # Orchestrator - deploys everything +├── main.bicepparam # Parameter file +├── README.md # This file +└── modules/ + ├── vnet.bicep # VNet with agent + proxy subnets + ├── gsa-proxy.bicep # GSA proxy VM, NIC, NSG, UDR + ├── agent-subnet-udr-association.bicep # Associates UDR to agent subnet + ├── ai-account-identity.bicep # AI Services with subnet injection + ├── ai-project-identity.bicep # AI Project + ├── standard-dependent-resources.bicep # Cosmos DB, AI Search, Storage + ├── add-project-capability-host.bicep # Capability host configuration + ├── validate-existing-resources.bicep # Validates BYO resources + ├── format-project-workspace-id.bicep # Workspace ID formatting + ├── ai-search-role-assignments.bicep # AI Search RBAC + ├── azure-storage-account-role-assignment.bicep # Storage RBAC + ├── blob-storage-container-role-assignments.bicep # Blob container RBAC + ├── cosmos-container-role-assignments.bicep # Cosmos container RBAC + └── cosmosdb-account-role-assignment.bicep # Cosmos account RBAC +``` + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `location` | string | `eastus` | Azure region | +| `aiServices` | string | `foundry` | AI Services resource name (max 9 chars) | +| `firstProjectName` | string | `project` | Project resource name | +| `vnetName` | string | `agent-vnet` | Virtual network name | +| `vnetAddressPrefix` | string | `10.0.0.0/16` | VNet CIDR | +| `agentSubnetPrefix` | string | `10.0.0.0/24` | Agent subnet CIDR | +| `gsaProxySubnetPrefix` | string | `10.0.1.0/24` | GSA proxy subnet CIDR | +| `gsaProxyVmSize` | string | `Standard_D2s_v3` | Proxy VM size | +| `gsaProxySshPublicKey` | secure string | *(required)* | SSH public key for VM | +| `modelName` | string | `gpt-4.1` | Model to deploy | +| `modelCapacity` | int | `30` | TPM for model deployment | +| `aiSearchResourceId` | string | `''` | Optional existing AI Search ARM ID | +| `azureStorageAccountResourceId` | string | `''` | Optional existing Storage ARM ID | +| `azureCosmosDBAccountResourceId` | string | `''` | Optional existing Cosmos DB ARM ID | \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/main.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/main.bicep new file mode 100644 index 000000000..9c705d7a7 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/main.bicep @@ -0,0 +1,325 @@ +// ---- Standard Agent Setup with BYO VNet and GSA Proxy ---- +// Based on 41-standard-agent-setup with the following additions: +// - BYO VNet with agent subnet delegation (no private endpoints) +// - GSA AI Connector proxy for egress traffic control +// - UDR routing agent subnet 0/0 through the proxy with Azure service tag exceptions +// +// This setup keeps publicNetworkAccess: 'Enabled' on all resources since +// there are no private endpoints. The GSA proxy controls egress only. + +@description('Location for all resources.') +@allowed([ + 'australiaeast' + 'brazilsouth' + 'canadaeast' + 'eastus' + 'eastus2' + 'francecentral' + 'germanywestcentral' + 'italynorth' + 'japaneast' + 'koreacentral' + 'norwayeast' + 'polandcentral' + 'southafricanorth' + 'southcentralus' + 'southindia' + 'swedencentral' + 'switzerlandnorth' + 'uaenorth' + 'uksouth' + 'westeurope' + 'westus' + 'westus3' +]) +param location string = 'eastus' + +// ---- AI Services parameters ---- +@maxLength(9) +@description('Name for your AI Services resource.') +param aiServices string = 'foundry' + +@description('The name of the model you want to deploy') +param modelName string = 'gpt-4.1' +@description('The provider of your model') +param modelFormat string = 'OpenAI' +@description('The version of your model') +param modelVersion string = '2025-04-14' +@description('The sku of your model deployment') +param modelSkuName string = 'GlobalStandard' +@description('The tokens per minute (TPM) of your model deployment') +param modelCapacity int = 30 + +// ---- Project parameters ---- +@description('Name for your project resource.') +param firstProjectName string = 'project' +@description('Description of the project') +param projectDescription string = 'AI Foundry Agent project with BYO VNet and GSA proxy' +@description('The display name of the project') +param displayName string = 'BYO VNet GSA Proxy Agent Project' +@description('The name of the project capability host') +param projectCapHost string = 'caphostproj' + +// ---- VNet parameters ---- +@description('Name of the virtual network') +param vnetName string = 'agent-vnet' +@description('Address space for the VNet') +param vnetAddressPrefix string = '10.0.0.0/16' +@description('Name of the agent subnet') +param agentSubnetName string = 'agent-subnet' +@description('Address prefix for the agent subnet (recommended /24)') +param agentSubnetPrefix string = '10.0.0.0/24' +@description('Name of the GSA proxy subnet') +param gsaProxySubnetName string = 'gsa-proxy-subnet' +@description('Address prefix for the GSA proxy subnet') +param gsaProxySubnetPrefix string = '10.0.1.0/24' + +// ---- GSA Proxy parameters ---- +@description('VM size for the GSA proxy VM') +param gsaProxyVmSize string = 'Standard_D2s_v3' +@description('Admin username for the GSA proxy VM') +param gsaProxyAdminUsername string = 'azureuser' +@description('SSH public key for GSA proxy VM authentication') +@secure() +param gsaProxySshPublicKey string + +// ---- Cosmos DB options ---- +@description('Whether Cosmos DB should be zone redundant. Set to false in regions with limited AZ capacity (e.g. southcentralus).') +param cosmosDBZoneRedundant bool = true + +// ---- Optional: bring existing resources ---- +@description('The AI Search Service full ARM Resource ID. If not provided, a new one will be created.') +param aiSearchResourceId string = '' +@description('The AI Storage Account full ARM Resource ID. If not provided, a new one will be created.') +param azureStorageAccountResourceId string = '' +@description('The Cosmos DB Account full ARM Resource ID. If not provided, a new one will be created.') +param azureCosmosDBAccountResourceId string = '' + +// ---- Computed variables ---- +param deploymentTimestamp string = utcNow('yyyyMMddHHmmss') +var uniqueSuffix = substring(uniqueString('${resourceGroup().id}-${deploymentTimestamp}'), 0, 4) +var accountName = toLower('${aiServices}${uniqueSuffix}') +var projectName = toLower('${firstProjectName}${uniqueSuffix}') +var cosmosDBName = toLower('${uniqueSuffix}cosmosdb') +var aiSearchName = toLower('${uniqueSuffix}search') +var azureStorageName = toLower('${uniqueSuffix}storage') + +var storagePassedIn = azureStorageAccountResourceId != '' +var searchPassedIn = aiSearchResourceId != '' +var cosmosPassedIn = azureCosmosDBAccountResourceId != '' + +var acsParts = split(aiSearchResourceId, '/') +var aiSearchServiceSubscriptionId = searchPassedIn ? acsParts[2] : subscription().subscriptionId +var aiSearchServiceResourceGroupName = searchPassedIn ? acsParts[4] : resourceGroup().name + +var cosmosParts = split(azureCosmosDBAccountResourceId, '/') +var cosmosDBSubscriptionId = cosmosPassedIn ? cosmosParts[2] : subscription().subscriptionId +var cosmosDBResourceGroupName = cosmosPassedIn ? cosmosParts[4] : resourceGroup().name + +var storageParts = split(azureStorageAccountResourceId, '/') +var azureStorageSubscriptionId = storagePassedIn ? storageParts[2] : subscription().subscriptionId +var azureStorageResourceGroupName = storagePassedIn ? storageParts[4] : resourceGroup().name + +// =========================================================================== +// Step 1: Create Virtual Network with Agent and GSA Proxy subnets +// =========================================================================== +module vnet 'modules/vnet.bicep' = { + name: 'vnet-${uniqueSuffix}-deployment' + params: { + location: location + vnetName: vnetName + vnetAddressPrefix: vnetAddressPrefix + agentSubnetName: agentSubnetName + agentSubnetPrefix: agentSubnetPrefix + gsaProxySubnetName: gsaProxySubnetName + gsaProxySubnetPrefix: gsaProxySubnetPrefix + } +} + +// =========================================================================== +// Step 2: Deploy GSA Proxy (VM, NIC, NSG, UDR) +// =========================================================================== +module gsaProxy 'modules/gsa-proxy.bicep' = { + name: 'gsa-proxy-${uniqueSuffix}-deployment' + params: { + location: location + name: accountName + vnetName: vnet.outputs.virtualNetworkName + gsaProxySubnetId: vnet.outputs.gsaProxySubnetId + agentSubnetName: vnet.outputs.agentSubnetName + vmSize: gsaProxyVmSize + adminUsername: gsaProxyAdminUsername + sshPublicKey: gsaProxySshPublicKey + } +} + +// =========================================================================== +// Step 3: Validate existing resources +// =========================================================================== +module validateExistingResources 'modules/validate-existing-resources.bicep' = { + name: 'validate-existing-resources-${uniqueSuffix}-deployment' + params: { + aiSearchResourceId: aiSearchResourceId + azureStorageAccountResourceId: azureStorageAccountResourceId + azureCosmosDBAccountResourceId: azureCosmosDBAccountResourceId + } +} + +// =========================================================================== +// Step 4: Create dependent resources (Cosmos DB, AI Search, Storage) +// =========================================================================== +module aiDependencies 'modules/standard-dependent-resources.bicep' = { + name: 'dependencies-${uniqueSuffix}-deployment' + params: { + location: location + azureStorageName: azureStorageName + aiSearchName: aiSearchName + cosmosDBName: cosmosDBName + aiSearchResourceId: aiSearchResourceId + aiSearchExists: validateExistingResources.outputs.aiSearchExists + azureStorageAccountResourceId: azureStorageAccountResourceId + azureStorageExists: validateExistingResources.outputs.azureStorageExists + cosmosDBResourceId: azureCosmosDBAccountResourceId + cosmosDBExists: validateExistingResources.outputs.cosmosDBExists + cosmosDBZoneRedundant: cosmosDBZoneRedundant + } +} + +// =========================================================================== +// Step 5: Create AI Services account with BYO VNet subnet injection +// =========================================================================== +module aiAccount 'modules/ai-account-identity.bicep' = { + name: 'ai-${accountName}-${uniqueSuffix}-deployment' + params: { + accountName: accountName + location: location + modelName: modelName + modelFormat: modelFormat + modelVersion: modelVersion + modelSkuName: modelSkuName + modelCapacity: modelCapacity + agentSubnetId: vnet.outputs.agentSubnetId + } + dependsOn: [ + validateExistingResources + aiDependencies + gsaProxy // Ensure UDR is in place before AI account uses the subnet + ] +} + +// =========================================================================== +// Step 6: Create AI Project +// =========================================================================== +module aiProject 'modules/ai-project-identity.bicep' = { + name: 'ai-${projectName}-${uniqueSuffix}-deployment' + params: { + projectName: projectName + projectDescription: projectDescription + displayName: displayName + location: location + aiSearchName: aiDependencies.outputs.aiSearchName + aiSearchServiceResourceGroupName: aiDependencies.outputs.aiSearchServiceResourceGroupName + aiSearchServiceSubscriptionId: aiDependencies.outputs.aiSearchServiceSubscriptionId + cosmosDBName: aiDependencies.outputs.cosmosDBName + cosmosDBSubscriptionId: aiDependencies.outputs.cosmosDBSubscriptionId + cosmosDBResourceGroupName: aiDependencies.outputs.cosmosDBResourceGroupName + azureStorageName: aiDependencies.outputs.azureStorageName + azureStorageSubscriptionId: aiDependencies.outputs.azureStorageSubscriptionId + azureStorageResourceGroupName: aiDependencies.outputs.azureStorageResourceGroupName + accountName: aiAccount.outputs.accountName + } +} + +module formatProjectWorkspaceId 'modules/format-project-workspace-id.bicep' = { + name: 'format-project-workspace-id-${uniqueSuffix}-deployment' + params: { + projectWorkspaceId: aiProject.outputs.projectWorkspaceId + } +} + +// =========================================================================== +// Step 7: Role assignments +// =========================================================================== +module storageAccountRoleAssignment 'modules/azure-storage-account-role-assignment.bicep' = { + name: 'storage-${azureStorageName}-${uniqueSuffix}-deployment' + scope: resourceGroup(azureStorageSubscriptionId, azureStorageResourceGroupName) + params: { + azureStorageName: aiDependencies.outputs.azureStorageName + projectPrincipalId: aiProject.outputs.projectPrincipalId + } +} + +module cosmosAccountRoleAssignments 'modules/cosmosdb-account-role-assignment.bicep' = { + name: 'cosmos-account-ra-${uniqueSuffix}-deployment' + scope: resourceGroup(cosmosDBSubscriptionId, cosmosDBResourceGroupName) + params: { + cosmosDBName: aiDependencies.outputs.cosmosDBName + projectPrincipalId: aiProject.outputs.projectPrincipalId + } + dependsOn: [ + storageAccountRoleAssignment + ] +} + +module aiSearchRoleAssignments 'modules/ai-search-role-assignments.bicep' = { + name: 'ai-search-ra-${uniqueSuffix}-deployment' + scope: resourceGroup(aiSearchServiceSubscriptionId, aiSearchServiceResourceGroupName) + params: { + aiSearchName: aiDependencies.outputs.aiSearchName + projectPrincipalId: aiProject.outputs.projectPrincipalId + } + dependsOn: [ + cosmosAccountRoleAssignments + storageAccountRoleAssignment + ] +} + +// =========================================================================== +// Step 8: Create capability host +// =========================================================================== +module addProjectCapabilityHost 'modules/add-project-capability-host.bicep' = { + name: 'capabilityHost-configuration-${uniqueSuffix}-deployment' + params: { + accountName: aiAccount.outputs.accountName + projectName: aiProject.outputs.projectName + cosmosDBConnection: aiProject.outputs.cosmosDBConnection + azureStorageConnection: aiProject.outputs.azureStorageConnection + aiSearchConnection: aiProject.outputs.aiSearchConnection + projectCapHost: projectCapHost + } + dependsOn: [ + aiSearchRoleAssignments + cosmosAccountRoleAssignments + storageAccountRoleAssignment + ] +} + +// =========================================================================== +// Step 9: Post-caphost role assignments +// =========================================================================== +module storageContainersRoleAssignment 'modules/blob-storage-container-role-assignments.bicep' = { + name: 'storage-containers-${uniqueSuffix}-deployment' + scope: resourceGroup(azureStorageSubscriptionId, azureStorageResourceGroupName) + params: { + aiProjectPrincipalId: aiProject.outputs.projectPrincipalId + storageName: aiDependencies.outputs.azureStorageName + workspaceId: formatProjectWorkspaceId.outputs.projectWorkspaceIdGuid + } + dependsOn: [ + addProjectCapabilityHost + ] +} + +module cosmosContainerRoleAssignments 'modules/cosmos-container-role-assignments.bicep' = { + name: 'cosmos-ra-${uniqueSuffix}-deployment' + scope: resourceGroup(cosmosDBSubscriptionId, cosmosDBResourceGroupName) + params: { + cosmosAccountName: aiDependencies.outputs.cosmosDBName + projectWorkspaceId: formatProjectWorkspaceId.outputs.projectWorkspaceIdGuid + projectPrincipalId: aiProject.outputs.projectPrincipalId + } + dependsOn: [ + addProjectCapabilityHost + storageContainersRoleAssignment + ] +} diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/main.bicepparam b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/main.bicepparam new file mode 100644 index 000000000..10ffd44ea --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/main.bicepparam @@ -0,0 +1,32 @@ +using './main.bicep' + +param location = 'eastus' +param aiServices = 'foundry' +param firstProjectName = 'project' +param projectDescription = 'AI Foundry Agent project with BYO VNet and GSA proxy' +param displayName = 'BYO VNet GSA Proxy Agent Project' + +// VNet parameters +param vnetName = 'agent-vnet' +param vnetAddressPrefix = '10.0.0.0/16' +param agentSubnetName = 'agent-subnet' +param agentSubnetPrefix = '10.0.0.0/24' +param gsaProxySubnetName = 'gsa-proxy-subnet' +param gsaProxySubnetPrefix = '10.0.1.0/24' + +// GSA Proxy parameters +param gsaProxyVmSize = 'Standard_D2s_v3' +param gsaProxyAdminUsername = 'azureuser' +param gsaProxySshPublicKey = readEnvironmentVariable('GSA_PROXY_SSH_PUBLIC_KEY', '') + +// Model deployment parameters +param modelName = 'gpt-4.1' +param modelFormat = 'OpenAI' +param modelVersion = '2025-04-14' +param modelSkuName = 'GlobalStandard' +param modelCapacity = 30 + +// Optional: bring existing resources (leave empty to create new) +param aiSearchResourceId = '' +param azureStorageAccountResourceId = '' +param azureCosmosDBAccountResourceId = '' diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/add-project-capability-host.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/add-project-capability-host.bicep new file mode 100644 index 000000000..f6b4ce685 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/add-project-capability-host.bicep @@ -0,0 +1,32 @@ +param cosmosDBConnection string +param azureStorageConnection string +param aiSearchConnection string +param projectName string +param accountName string +param projectCapHost string + +var threadConnections = ['${cosmosDBConnection}'] +var storageConnections = ['${azureStorageConnection}'] +var vectorStoreConnections = ['${aiSearchConnection}'] + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = { + name: projectName + parent: account +} + +resource projectCapabilityHost 'Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview' = { + name: projectCapHost + parent: project + properties: { + capabilityHostKind: 'Agents' + vectorStoreConnections: vectorStoreConnections + storageConnections: storageConnections + threadStorageConnections: threadConnections + } +} + +output projectCapHost string = projectCapabilityHost.name diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/agent-subnet-udr-association.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/agent-subnet-udr-association.bicep new file mode 100644 index 000000000..ebdc74d37 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/agent-subnet-udr-association.bicep @@ -0,0 +1,42 @@ +// ---- Agent Subnet UDR Association Module ---- +// Updates the existing agent subnet to associate it with the route table +// that routes 0/0 traffic through the GSA proxy. +// IMPORTANT: Preserves the existing subnet delegation (e.g., Microsoft.App/environments) +// and NSG association when adding the route table. + +@description('Name of the virtual network') +param vnetName string + +@description('Name of the agent subnet') +param agentSubnetName string + +@description('Address prefix of the agent subnet') +param agentSubnetAddressPrefix string + +@description('Resource ID of the existing NSG on the agent subnet (empty string if none)') +param existingNsgId string + +@description('Existing delegations on the agent subnet to preserve') +param existingDelegations array = [] + +@description('Resource ID of the route table to associate') +param routeTableId string + +resource vnet 'Microsoft.Network/virtualNetworks@2024-01-01' existing = { + name: vnetName +} + +resource agentSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-01-01' = { + parent: vnet + name: agentSubnetName + properties: { + addressPrefix: agentSubnetAddressPrefix + networkSecurityGroup: !empty(existingNsgId) ? { + id: existingNsgId + } : null + delegations: existingDelegations + routeTable: { + id: routeTableId + } + } +} diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-account-identity.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-account-identity.bicep new file mode 100644 index 000000000..b0f09f821 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-account-identity.bicep @@ -0,0 +1,64 @@ +// AI Account with BYO VNet subnet delegation (no private endpoints) +// Based on 41-standard-agent-setup but adds agentSubnetId for network injection + +param accountName string +param location string +param modelName string +param modelFormat string +param modelVersion string +param modelSkuName string +param modelCapacity int +param agentSubnetId string + +#disable-next-line BCP036 +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { + name: accountName + location: location + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: accountName + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + } + publicNetworkAccess: 'Enabled' + networkInjections: [ + { + scenario: 'agent' + subnetArmId: agentSubnetId + useMicrosoftManagedNetwork: false + } + ] + // API-key based auth is not supported for the Agent service + disableLocalAuth: false + } +} + +resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview' = { + parent: account + name: modelName + sku: { + capacity: modelCapacity + name: modelSkuName + } + properties: { + model: { + name: modelName + format: modelFormat + version: modelVersion + } + } +} + +output accountName string = account.name +output accountID string = account.id +output accountTarget string = account.properties.endpoint +output accountPrincipalId string = account.identity.principalId diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-project-identity.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-project-identity.bicep new file mode 100644 index 000000000..65afe3377 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-project-identity.bicep @@ -0,0 +1,103 @@ +param accountName string +param location string +param projectName string +param projectDescription string +param displayName string + +param aiSearchName string +param aiSearchServiceResourceGroupName string +param aiSearchServiceSubscriptionId string + +param cosmosDBName string +param cosmosDBSubscriptionId string +param cosmosDBResourceGroupName string + +param azureStorageName string +param azureStorageSubscriptionId string +param azureStorageResourceGroupName string + +resource searchService 'Microsoft.Search/searchServices@2024-06-01-preview' existing = { + name: aiSearchName + scope: resourceGroup(aiSearchServiceSubscriptionId, aiSearchServiceResourceGroupName) +} +resource cosmosDBAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = { + name: cosmosDBName + scope: resourceGroup(cosmosDBSubscriptionId, cosmosDBResourceGroupName) +} +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { + name: azureStorageName + scope: resourceGroup(azureStorageSubscriptionId, azureStorageResourceGroupName) +} + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName + scope: resourceGroup() +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { + parent: account + name: projectName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: projectDescription + displayName: displayName + } + + resource project_connection_cosmosdb_account 'connections@2025-04-01-preview' = { + name: cosmosDBName + properties: { + category: 'CosmosDB' + target: cosmosDBAccount.properties.documentEndpoint + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: cosmosDBAccount.id + location: cosmosDBAccount.location + } + } + } + + resource project_connection_azure_storage 'connections@2025-04-01-preview' = { + name: azureStorageName + properties: { + category: 'AzureStorageAccount' + target: storageAccount.properties.primaryEndpoints.blob + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: storageAccount.id + location: storageAccount.location + } + } + } + + resource project_connection_azureai_search 'connections@2025-04-01-preview' = { + name: aiSearchName + properties: { + category: 'CognitiveSearch' + target: 'https://${aiSearchName}.search.windows.net' + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: searchService.id + location: searchService.location + } + } + } + +} + +output projectName string = project.name +output projectId string = project.id +output projectPrincipalId string = project.identity.principalId + +#disable-next-line BCP053 +output projectWorkspaceId string = project.properties.internalId + +// BYO connection names +output cosmosDBConnection string = cosmosDBName +output azureStorageConnection string = azureStorageName +output aiSearchConnection string = aiSearchName diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-search-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-search-role-assignments.bicep new file mode 100644 index 000000000..715663a6c --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/ai-search-role-assignments.bicep @@ -0,0 +1,43 @@ +// Assigns the necessary roles to the AI project + +@description('Name of the AI Search resource') +param aiSearchName string + +@description('Principal ID of the AI project') +param projectPrincipalId string + +resource searchService 'Microsoft.Search/searchServices@2024-06-01-preview' existing = { + name: aiSearchName + scope: resourceGroup() +} + +// search roles +resource searchIndexDataContributorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '8ebe5a00-799e-43f5-93ac-243d3dce84a7' + scope: resourceGroup() +} + +resource searchIndexDataContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: searchService + name: guid(projectPrincipalId, searchIndexDataContributorRole.id, searchService.id) + properties: { + principalId: projectPrincipalId + roleDefinitionId: searchIndexDataContributorRole.id + principalType: 'ServicePrincipal' + } +} + +resource searchServiceContributorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' + scope: resourceGroup() +} + +resource searchServiceContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: searchService + name: guid(projectPrincipalId, searchServiceContributorRole.id, searchService.id) + properties: { + principalId: projectPrincipalId + roleDefinitionId: searchServiceContributorRole.id + principalType: 'ServicePrincipal' + } +} diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/azure-storage-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/azure-storage-account-role-assignment.bicep new file mode 100644 index 000000000..afc355a48 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/azure-storage-account-role-assignment.bicep @@ -0,0 +1,24 @@ +param azureStorageName string +param projectPrincipalId string + +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { + name: azureStorageName + scope: resourceGroup() +} + +// Blob Storage Owner: b7e6dc6d-f1e8-4753-8033-0f276bb0955b +// Blob Storage Contributor: ba92f5b4-2d11-453d-a403-e96b0029c9fe +resource storageBlobDataContributor 'Microsoft.Authorization/roleDefinitions@2022-05-01-preview' existing = { + name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + scope: resourceGroup() +} + +resource storageBlobDataContributorRoleAssignmentProject 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: storageAccount + name: guid(projectPrincipalId, storageBlobDataContributor.id, storageAccount.id) + properties: { + principalId: projectPrincipalId + roleDefinitionId: storageBlobDataContributor.id + principalType: 'ServicePrincipal' + } +} diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/blob-storage-container-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/blob-storage-container-role-assignments.bicep new file mode 100644 index 000000000..817db115e --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/blob-storage-container-role-assignments.bicep @@ -0,0 +1,36 @@ +@description('Name of the storage account') +param storageName string + +@description('Principal ID of the AI Project') +param aiProjectPrincipalId string + +@description('Workspace Id of the AI Project') +param workspaceId string + + +// Reference existing storage account +resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' existing = { + name: storageName + scope: resourceGroup() +} + +// Storage Blob Data Owner Role +resource storageBlobDataOwner 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' // Built-in role ID + scope: resourceGroup() +} + +var conditionStr= '((!(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/read\'}) AND !(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action\'}) AND !(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/write\'}) ) OR (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringStartsWithIgnoreCase \'${workspaceId}\' AND @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringLikeIgnoreCase \'*-azureml-agent\'))' + +// Assign Storage Blob Data Owner role +resource storageBlobDataOwnerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: storage + name: guid(storage.id, aiProjectPrincipalId, storageBlobDataOwner.id, workspaceId) + properties: { + principalId: aiProjectPrincipalId + roleDefinitionId: storageBlobDataOwner.id + principalType: 'ServicePrincipal' + conditionVersion: '2.0' + condition: conditionStr + } +} diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/cosmos-container-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/cosmos-container-role-assignments.bicep new file mode 100644 index 000000000..a5fbbf70d --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/cosmos-container-role-assignments.bicep @@ -0,0 +1,35 @@ +// Assigns the necessary roles to the AI project + +@description('Name of the AI Search resource') +param cosmosAccountName string + +@description('Project name') +param projectPrincipalId string + +param projectWorkspaceId string + +resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = { + name: cosmosAccountName + scope: resourceGroup() +} + +var roleDefinitionId = resourceId( + 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions', + cosmosAccountName, + '00000000-0000-0000-0000-000000000002' +) + +var accountScope = '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosAccountName}/dbs/enterprise_memory' + +resource containerRoleAssignmentUserContainer 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-05-15' = { + parent: cosmosAccount + name: guid(projectWorkspaceId, cosmosAccountName, roleDefinitionId, projectPrincipalId) + properties: { + principalId: projectPrincipalId + roleDefinitionId: roleDefinitionId + scope: accountScope + } +} + + + diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/cosmosdb-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/cosmosdb-account-role-assignment.bicep new file mode 100644 index 000000000..076211ea3 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/cosmosdb-account-role-assignment.bicep @@ -0,0 +1,27 @@ +// Assigns Role Cosmos DB Operator to the Project Principal ID +@description('Name of the AI Search resource') +param cosmosDBName string + +@description('Principal ID of the AI project') +param projectPrincipalId string + + +resource cosmosDBAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = { + name: cosmosDBName + scope: resourceGroup() +} + +resource cosmosDBOperatorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '230815da-be43-4aae-9cb4-875f7bd000aa' + scope: resourceGroup() +} + +resource cosmosDBOperatorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: cosmosDBAccount + name: guid(projectPrincipalId, cosmosDBOperatorRole.id, cosmosDBAccount.id) + properties: { + principalId: projectPrincipalId + roleDefinitionId: cosmosDBOperatorRole.id + principalType: 'ServicePrincipal' + } +} diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/format-project-workspace-id.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/format-project-workspace-id.bicep new file mode 100644 index 000000000..ac7d0c3f2 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/format-project-workspace-id.bicep @@ -0,0 +1,12 @@ + +param projectWorkspaceId string + +var part1 = substring(projectWorkspaceId, 0, 8) // First 8 characters +var part2 = substring(projectWorkspaceId, 8, 4) // Next 4 characters +var part3 = substring(projectWorkspaceId, 12, 4) // Next 4 characters +var part4 = substring(projectWorkspaceId, 16, 4) // Next 4 characters +var part5 = substring(projectWorkspaceId, 20, 12) // Remaining 12 characters + +var formattedGuid = '${part1}-${part2}-${part3}-${part4}-${part5}' + +output projectWorkspaceIdGuid string = formattedGuid diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/gsa-proxy.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/gsa-proxy.bicep new file mode 100644 index 000000000..bf8b20092 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/gsa-proxy.bicep @@ -0,0 +1,315 @@ +// ---- GSA Proxy Deployment Module ---- +// Deploys a GSA AI Connector proxy VM from the Azure Marketplace offering +// with managed identity, IP forwarding, and NSG rules. +// Also creates a UDR on the agent subnet to route 0/0 traffic through the proxy, +// with exceptions for required Azure service tags. + +@description('Azure region for all resources') +param location string + +@description('Name prefix for resources') +param name string + +@description('Name of the existing virtual network') +param vnetName string + +@description('Resource ID of the GSA proxy subnet') +param gsaProxySubnetId string + +@description('Name of the agent subnet to apply the UDR to') +param agentSubnetName string + +@description('VM size for the GSA proxy') +param vmSize string = 'Standard_D2s_v3' + +@description('Admin username for the proxy VM') +param adminUsername string = 'azureuser' + +@description('SSH public key for the proxy VM') +@secure() +param sshPublicKey string + +@description('Tags to apply to all resources') +param tags object = {} + +// ---- Variables ---- +var gsaProxyNsgName = '${name}-gsa-proxy-nsg' +var gsaProxyNicName = '${name}-gsa-proxy-nic' +var gsaProxyVmName = '${name}-gsa-proxy-vm' +var gsaProxyIpConfigName = '${name}-gsa-proxy-ipconfig' +var agentSubnetUdrName = '${name}-agent-udr' + +// ---- Reference existing VNet ---- +resource vnet 'Microsoft.Network/virtualNetworks@2024-01-01' existing = { + name: vnetName +} + +// ---- NSG for GSA Proxy ---- +// Inbound: Allow all VNet traffic (agent subnet sends traffic here via UDR) +// Outbound: Allow VNet and Internet traffic (core proxy function) +resource gsaProxyNsg 'Microsoft.Network/networkSecurityGroups@2024-01-01' = { + name: gsaProxyNsgName + location: location + tags: tags + properties: { + securityRules: [ + { + name: 'AllowVNetInbound' + properties: { + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: '*' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '*' + } + } + { + name: 'AllowAzureLoadBalancerInbound' + properties: { + priority: 110 + direction: 'Inbound' + access: 'Allow' + protocol: '*' + sourceAddressPrefix: 'AzureLoadBalancer' + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '*' + } + } + { + name: 'DenyAllOtherInbound' + properties: { + priority: 4096 + direction: 'Inbound' + access: 'Deny' + protocol: '*' + sourceAddressPrefix: '*' + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '*' + } + } + { + name: 'AllowVNetOutbound' + properties: { + priority: 100 + direction: 'Outbound' + access: 'Allow' + protocol: '*' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + destinationAddressPrefix: '*' + destinationPortRange: '*' + } + } + { + name: 'AllowInternetOutbound' + properties: { + priority: 110 + direction: 'Outbound' + access: 'Allow' + protocol: '*' + sourceAddressPrefix: '*' + sourcePortRange: '*' + destinationAddressPrefix: 'Internet' + destinationPortRange: '*' + } + } + ] + } +} + +// ---- NIC for GSA Proxy VM (IP Forwarding enabled) ---- +resource gsaProxyNic 'Microsoft.Network/networkInterfaces@2024-01-01' = { + name: gsaProxyNicName + location: location + tags: tags + properties: { + enableIPForwarding: true + ipConfigurations: [ + { + name: gsaProxyIpConfigName + properties: { + subnet: { + id: gsaProxySubnetId + } + privateIPAllocationMethod: 'Dynamic' + } + } + ] + networkSecurityGroup: { + id: gsaProxyNsg.id + } + } +} + +// ---- GSA Proxy VM (Marketplace Image with Managed Identity) ---- +resource gsaProxyVm 'Microsoft.Compute/virtualMachines@2024-07-01' = { + name: gsaProxyVmName + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + plan: { + name: 'gsaaiconnectorplan1' + publisher: 'microsoftcorporation1687208452115' + product: 'gsaaiconnector1-preview' + } + properties: { + hardwareProfile: { + vmSize: vmSize + } + osProfile: { + computerName: take(gsaProxyVmName, 15) + adminUsername: adminUsername + linuxConfiguration: { + disablePasswordAuthentication: true + ssh: { + publicKeys: [ + { + path: '/home/${adminUsername}/.ssh/authorized_keys' + keyData: sshPublicKey + } + ] + } + } + } + storageProfile: { + imageReference: { + publisher: 'microsoftcorporation1687208452115' + offer: 'gsaaiconnector1-preview' + sku: 'gsaaiconnectorplan1' + version: 'latest' + } + osDisk: { + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Premium_LRS' + } + } + } + networkProfile: { + networkInterfaces: [ + { + id: gsaProxyNic.id + properties: { + primary: true + } + } + ] + } + } +} + +// ---- UDR for Agent Subnet ---- +// Routes all internet-bound traffic (0.0.0.0/0) through the GSA proxy, +// with explicit exceptions for required Azure service tags. +resource agentSubnetUdr 'Microsoft.Network/routeTables@2024-01-01' = { + name: agentSubnetUdrName + location: location + tags: tags + properties: { + disableBgpRoutePropagation: false + routes: [ + { + name: 'DefaultToProxy' + properties: { + addressPrefix: '0.0.0.0/0' + nextHopType: 'VirtualAppliance' + nextHopIpAddress: gsaProxyNic.properties.ipConfigurations[0].properties.privateIPAddress + } + } + { + name: 'AllowAzureActiveDirectory' + properties: { + addressPrefix: 'AzureActiveDirectory' + nextHopType: 'Internet' + } + } + { + name: 'AllowAzureResourceManager' + properties: { + addressPrefix: 'AzureResourceManager' + nextHopType: 'Internet' + } + } + { + name: 'AllowAzureMonitor' + properties: { + addressPrefix: 'AzureMonitor' + nextHopType: 'Internet' + } + } + { + name: 'AllowGuestAndHybridManagement' + properties: { + addressPrefix: 'GuestAndHybridManagement' + nextHopType: 'Internet' + } + } + { + name: 'AllowAzureContainerRegistry' + properties: { + addressPrefix: 'AzureContainerRegistry' + nextHopType: 'Internet' + } + } + { + name: 'AllowAzureKeyVault' + properties: { + addressPrefix: 'AzureKeyVault' + nextHopType: 'Internet' + } + } + { + name: 'AllowStorage' + properties: { + addressPrefix: 'Storage' + nextHopType: 'Internet' + } + } + { + name: 'AllowAzureFrontDoorFirstParty' + properties: { + addressPrefix: 'AzureFrontDoor.FirstParty' + nextHopType: 'Internet' + } + } + { + name: 'AllowContainerAppsManagement' + properties: { + addressPrefix: 'ContainerAppsManagement' + nextHopType: 'Internet' + } + } + ] + } +} + +// ---- Apply UDR to Agent Subnet ---- +resource agentSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-01-01' existing = { + parent: vnet + name: agentSubnetName +} + +module agentSubnetUdrAssociation 'agent-subnet-udr-association.bicep' = { + name: 'agent-subnet-udr-association' + params: { + vnetName: vnetName + agentSubnetName: agentSubnetName + agentSubnetAddressPrefix: agentSubnet.properties.addressPrefix + existingNsgId: contains(agentSubnet.properties, 'networkSecurityGroup') && agentSubnet.properties.networkSecurityGroup != null ? agentSubnet.properties.networkSecurityGroup.id : '' + existingDelegations: contains(agentSubnet.properties, 'delegations') ? agentSubnet.properties.delegations : [] + routeTableId: agentSubnetUdr.id + } +} + +// ---- Outputs ---- +output gsaProxyPrivateIp string = gsaProxyNic.properties.ipConfigurations[0].properties.privateIPAddress +output gsaProxyVmId string = gsaProxyVm.id +output gsaProxyVmPrincipalId string = gsaProxyVm.identity.principalId +output routeTableId string = agentSubnetUdr.id diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/standard-dependent-resources.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/standard-dependent-resources.bicep new file mode 100644 index 000000000..f9afc60fe --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/standard-dependent-resources.bicep @@ -0,0 +1,138 @@ +// Creates Azure dependent resources for Azure AI Agent Service standard agent setup + +@description('Azure region of the deployment') +param location string + +@description('The name of the AI Search resource') +param aiSearchName string + +@description('Name of the storage account') +param azureStorageName string + +@description('Name of the new Cosmos DB account') +param cosmosDBName string + +@description('The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param aiSearchResourceId string + +@description('The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param azureStorageAccountResourceId string + +@description('The Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') +param cosmosDBResourceId string + +// param aiServiceExists bool +param aiSearchExists bool +param azureStorageExists bool +param cosmosDBExists bool + +@description('Whether Cosmos DB should be zone redundant. Set to false in regions with limited AZ capacity.') +param cosmosDBZoneRedundant bool = true + + +var cosmosParts = split(cosmosDBResourceId, '/') + +resource existingCosmosDB 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' existing = if (cosmosDBExists) { + name: cosmosParts[8] + scope: resourceGroup(cosmosParts[2], cosmosParts[4]) +} + +var canaryRegions = ['eastus2euap', 'centraluseuap'] +// Regions where Cosmos DB AZ capacity is constrained - redirect to a fallback region +var cosmosDbAZConstrainedRegions = ['southcentralus'] +var cosmosDbRegion = contains(canaryRegions, location) ? 'westus' : (contains(cosmosDbAZConstrainedRegions, location) && !cosmosDBZoneRedundant ? 'westus' : location) +resource cosmosDB 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' = if(!cosmosDBExists) { + name: cosmosDBName + location: cosmosDbRegion + kind: 'GlobalDocumentDB' + properties: { + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + disableLocalAuth: true + enableAutomaticFailover: false + enableMultipleWriteLocations: false + enableFreeTier: false + locations: [ + { + locationName: cosmosDbRegion + failoverPriority: 0 + isZoneRedundant: cosmosDBZoneRedundant + } + ] + databaseAccountOfferType: 'Standard' + } +} + +var acsParts = split(aiSearchResourceId, '/') + +resource existingSearchService 'Microsoft.Search/searchServices@2024-06-01-preview' existing = if (aiSearchExists) { + name: acsParts[8] + scope: resourceGroup(acsParts[2], acsParts[4]) +} +resource aiSearch 'Microsoft.Search/searchServices@2024-06-01-preview' = if(!aiSearchExists) { + name: aiSearchName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + disableLocalAuth: false + authOptions: { aadOrApiKey: { aadAuthFailureMode: 'http401WithBearerChallenge'}} + encryptionWithCmk: { + enforcement: 'Unspecified' + } + hostingMode: 'default' + partitionCount: 1 + publicNetworkAccess: 'enabled' + replicaCount: 1 + semanticSearch: 'disabled' + } + sku: { + name: 'standard' + } +} + +var azureStorageParts = split(azureStorageAccountResourceId, '/') + +resource existingAzureStorageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = if (azureStorageExists) { + name: azureStorageParts[8] + scope: resourceGroup(azureStorageParts[2], azureStorageParts[4]) +} + +// Some regions doesn't support Standard Zone-Redundant storage, need to use Geo-redundant storage +param noZRSRegions array = ['southindia', 'westus'] +param sku object = contains(noZRSRegions, location) ? { name: 'Standard_GRS' } : { name: 'Standard_ZRS' } + +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = if(!azureStorageExists) { + name: azureStorageName + location: location + kind: 'StorageV2' + sku: sku + properties: { + minimumTlsVersion: 'TLS1_2' + allowBlobPublicAccess: false + publicNetworkAccess: 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Allow' + virtualNetworkRules: [] + } + allowSharedKeyAccess: false + } +} + +output aiSearchName string = aiSearchExists ? existingSearchService.name : aiSearch.name +output aiSearchID string = aiSearchExists ? existingSearchService.id : aiSearch.id +output aiSearchServiceResourceGroupName string = aiSearchExists ? acsParts[4] : resourceGroup().name +output aiSearchServiceSubscriptionId string = aiSearchExists ? acsParts[2] : subscription().subscriptionId + +output azureStorageName string = azureStorageExists ? existingAzureStorageAccount.name : storage.name +output azureStorageId string = azureStorageExists ? existingAzureStorageAccount.id : storage.id +output azureStorageResourceGroupName string = azureStorageExists ? azureStorageParts[4] : resourceGroup().name +output azureStorageSubscriptionId string = azureStorageExists ? azureStorageParts[2] : subscription().subscriptionId + +output cosmosDBName string = cosmosDBExists ? existingCosmosDB.name : cosmosDB.name +output cosmosDBId string = cosmosDBExists ? existingCosmosDB.id : cosmosDB.id +output cosmosDBResourceGroupName string = cosmosDBExists ? cosmosParts[4] : resourceGroup().name +output cosmosDBSubscriptionId string = cosmosDBExists ? cosmosParts[2] : subscription().subscriptionId diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/validate-existing-resources.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/validate-existing-resources.bicep new file mode 100644 index 000000000..d5e15ced8 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/validate-existing-resources.bicep @@ -0,0 +1,60 @@ +// @description('Resource ID of the AI Service Account. ') +// param aiServiceAccountResourceId string + +@description('Resource ID of the AI Search Service.') +param aiSearchResourceId string + +@description('Resource ID of the Azure Storage Account.') +param azureStorageAccountResourceId string + +@description('ResourceId of Cosmos DB Account') +param azureCosmosDBAccountResourceId string + +// Check if existing resources have been passed in +var storagePassedIn = azureStorageAccountResourceId != '' +var searchPassedIn = aiSearchResourceId != '' +var cosmosPassedIn = azureCosmosDBAccountResourceId != '' + +var storageParts = split(azureStorageAccountResourceId, '/') +var azureStorageSubscriptionId = storagePassedIn && length(storageParts) > 2 ? storageParts[2] : subscription().subscriptionId +var azureStorageResourceGroupName = storagePassedIn && length(storageParts) > 4 ? storageParts[4] : resourceGroup().name + +var acsParts = split(aiSearchResourceId, '/') +var aiSearchServiceSubscriptionId = searchPassedIn && length(acsParts) > 2 ? acsParts[2] : subscription().subscriptionId +var aiSearchServiceResourceGroupName = searchPassedIn && length(acsParts) > 4 ? acsParts[4] : resourceGroup().name + +var cosmosParts = split(azureCosmosDBAccountResourceId, '/') +var cosmosDBSubscriptionId = cosmosPassedIn && length(cosmosParts) > 2 ? cosmosParts[2] : subscription().subscriptionId +var cosmosDBResourceGroupName = cosmosPassedIn && length(cosmosParts) > 4 ? cosmosParts[4] : resourceGroup().name + +// Validate AI Search +resource aiSearch 'Microsoft.Search/searchServices@2024-06-01-preview' existing = if (searchPassedIn) { + name: last(split(aiSearchResourceId, '/')) + scope: resourceGroup(aiSearchServiceSubscriptionId, aiSearchServiceResourceGroupName) +} + +// Validate Cosmos DB Account +resource cosmosDBAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = if (cosmosPassedIn) { + name: last(split(azureCosmosDBAccountResourceId, '/')) + scope: resourceGroup(cosmosDBSubscriptionId,cosmosDBResourceGroupName) +} + +// Validate Storage Account +resource azureStorageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' existing = if (storagePassedIn) { + name: last(split(azureStorageAccountResourceId, '/')) + scope: resourceGroup(azureStorageSubscriptionId,azureStorageResourceGroupName) +} + +// output aiServiceExists bool = aiServicesPassedIn && (aiServiceAccount.name == aiServiceParts[8]) +output aiSearchExists bool = searchPassedIn && (aiSearch.name == acsParts[8]) +output cosmosDBExists bool = cosmosPassedIn && (cosmosDBAccount.name == cosmosParts[8]) +output azureStorageExists bool = storagePassedIn && (azureStorageAccount.name == storageParts[8]) + +output aiSearchServiceSubscriptionId string = aiSearchServiceSubscriptionId +output aiSearchServiceResourceGroupName string = aiSearchServiceResourceGroupName + +output cosmosDBSubscriptionId string = cosmosDBSubscriptionId +output cosmosDBResourceGroupName string = cosmosDBResourceGroupName + +output azureStorageSubscriptionId string = azureStorageSubscriptionId +output azureStorageResourceGroupName string = azureStorageResourceGroupName diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/vnet.bicep b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/vnet.bicep new file mode 100644 index 000000000..345823e37 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/modules/vnet.bicep @@ -0,0 +1,65 @@ +// ---- Virtual Network with Agent Subnet ---- +// Creates a VNet with a single agent subnet delegated to Microsoft.App/environments. +// No PE subnet is needed since this setup does not use private endpoints. + +@description('Azure region for the VNet') +param location string + +@description('Name of the virtual network') +param vnetName string + +@description('Address space for the VNet') +param vnetAddressPrefix string = '10.0.0.0/16' + +@description('Name of the agent subnet') +param agentSubnetName string = 'agent-subnet' + +@description('Address prefix for the agent subnet (recommended /24)') +param agentSubnetPrefix string = '10.0.0.0/24' + +@description('Name of the GSA proxy subnet') +param gsaProxySubnetName string = 'gsa-proxy-subnet' + +@description('Address prefix for the GSA proxy subnet') +param gsaProxySubnetPrefix string = '10.0.1.0/24' + +resource vnet 'Microsoft.Network/virtualNetworks@2024-01-01' = { + name: vnetName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + vnetAddressPrefix + ] + } + subnets: [ + { + name: agentSubnetName + properties: { + addressPrefix: agentSubnetPrefix + delegations: [ + { + name: 'Microsoft.App.environments' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } + } + { + name: gsaProxySubnetName + properties: { + addressPrefix: gsaProxySubnetPrefix + } + } + ] + } +} + +output virtualNetworkName string = vnet.name +output virtualNetworkId string = vnet.id +output agentSubnetName string = agentSubnetName +output agentSubnetId string = '${vnet.id}/subnets/${agentSubnetName}' +output gsaProxySubnetName string = gsaProxySubnetName +output gsaProxySubnetId string = '${vnet.id}/subnets/${gsaProxySubnetName}' diff --git a/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/test-deploy.parameters.json b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/test-deploy.parameters.json new file mode 100644 index 000000000..b9f04cdae --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/46-standard-agent-byovnet-gsa-proxy-setup/test-deploy.parameters.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "location": { "value": "southcentralus" }, + "aiServices": { "value": "foundry" }, + "cosmosDBZoneRedundant": { "value": false }, + "gsaProxySshPublicKey": { "value": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCb0hCfUaYIgrAo5ASOI1hWxNnW1lXpF6EaklfjtkXxKQ42KN+ezp35bEMteqeblIIsPr7xWbDSHr7qg7j7id4eZGWWdMZUmONn+jYL5UIyVyzxPAvS1WzOI4IvCXFcrSALQqLBe4kgTuAmpibrI0ofN+9YiRG6BpW/aevNNEvDBS0MBhAZRm7bQKrad76YgK3ps/QWIZaqyhHdvdLAhVgKRfF7aU719yHp7RTHfjzrKdsUnfTinMCpJr/PLktPPTvW2NHX71ztIYUVZVkuCw9w8kgD5TMoTgtIPqGPP3s/W+Sopv9WPPHigZY1n0VLhEqWAmQcxgILMxbTJi6LDlb2816eCu38iKPogpjGV/kBbqhO8sIJf7WI8bXMKY+hPGKROqMOZ95ReXb4P1bqt6RAryfyGLJ5F7r+1gJVlmeicI8KVUPr3BWPZyyFy/ScHWI1Bg/x5h+b8bf4ZEtI0gKGu+aS4q6GUwwQz0ESOppxT4yp1J1PSLMefAbezlxxS9UYIN2M1mKCWsamDz5Da1p+XPPgRzEMnSP+OjNP8BxGtq0MFMqI8vEdRcKNG1S4a3BIeTYUlAef0LTxH70yBJFUmNRelFM/QL++RgWqsRMnQOAxSurIV0H5Ep0MZuE+9WvnIpdM/YPPvjNSAbVAYJxFEk05RFfDRkUIQ2ycHXtggQ== redmond\\shdharma@DESKTOP-U0KS807" } + } +} \ No newline at end of file