diff --git a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs index 15a086038e9..cd0d4b0d3d8 100644 --- a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs +++ b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIExtensions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Azure.CognitiveServices; @@ -38,6 +40,11 @@ public static IResourceBuilder AddAzureOpenAI(this IDistrib var configureInfrastructure = (AzureResourceInfrastructure infrastructure) => { + var azureResource = (AzureOpenAIResource)infrastructure.AspireResource; + + // Check if this Azure OpenAI has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var cogServicesAccount = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -55,7 +62,9 @@ public static IResourceBuilder AddAzureOpenAI(this IDistrib Properties = new CognitiveServicesAccountProperties() { CustomSubDomainName = ToLower(Take(Concat(infrastructure.AspireResource.Name, GetUniqueString(GetResourceGroup().Id)), 24)), - PublicNetworkAccess = ServiceAccountPublicNetworkAccess.Enabled, + PublicNetworkAccess = hasPrivateEndpoint + ? ServiceAccountPublicNetworkAccess.Disabled + : ServiceAccountPublicNetworkAccess.Enabled, // Disable local auth for AOAI since managed identity is used DisableLocalAuth = true }, diff --git a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs index b99eae89c97..a741f3cc192 100644 --- a/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs +++ b/src/Aspire.Hosting.Azure.CognitiveServices/AzureOpenAIResource.cs @@ -17,7 +17,7 @@ namespace Aspire.Hosting.ApplicationModel; [AspireExport] public class AzureOpenAIResource(string name, Action configureInfrastructure) : AzureProvisioningResource(name, configureInfrastructure), - IResourceWithConnectionString, IAzureNspAssociationTarget + IResourceWithConnectionString, IAzurePrivateEndpointTarget, IAzureNspAssociationTarget { [Obsolete("Use AzureOpenAIDeploymentResource instead.")] private readonly List _deployments = []; @@ -111,6 +111,10 @@ public override ProvisionableResource AddAsExistingResource(AzureResourceInfrast return account; } + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["account"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.openai.azure.com"; + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { yield return new("Uri", UriExpression); diff --git a/src/Aspire.Hosting.Foundry/FoundryExtensions.cs b/src/Aspire.Hosting.Foundry/FoundryExtensions.cs index 5adbef6d418..40a684fd8c3 100644 --- a/src/Aspire.Hosting.Foundry/FoundryExtensions.cs +++ b/src/Aspire.Hosting.Foundry/FoundryExtensions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Foundry; @@ -408,6 +410,11 @@ await rns.PublishUpdateAsync(deployment, state => state with private static void ConfigureInfrastructure(AzureResourceInfrastructure infrastructure) { + var azureResource = (FoundryResource)infrastructure.AspireResource; + + // Check if this Foundry resource has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var cogServicesAccount = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -427,7 +434,9 @@ private static void ConfigureInfrastructure(AzureResourceInfrastructure infrastr // Until this bug is fixed, CustomSubDomainName must be set to the // account's name: https://msdata.visualstudio.com/Vienna/_workitems/edit/4866592 CustomSubDomainName = ToLower(Take(Concat(infrastructure.AspireResource.Name, GetUniqueString(GetResourceGroup().Id)), 24)), - PublicNetworkAccess = ServiceAccountPublicNetworkAccess.Enabled, + PublicNetworkAccess = hasPrivateEndpoint + ? ServiceAccountPublicNetworkAccess.Disabled + : ServiceAccountPublicNetworkAccess.Enabled, DisableLocalAuth = true, AllowProjectManagement = true }, diff --git a/src/Aspire.Hosting.Foundry/FoundryResource.cs b/src/Aspire.Hosting.Foundry/FoundryResource.cs index 96257ba0c94..cf5f7b64290 100644 --- a/src/Aspire.Hosting.Foundry/FoundryResource.cs +++ b/src/Aspire.Hosting.Foundry/FoundryResource.cs @@ -17,7 +17,7 @@ namespace Aspire.Hosting.Foundry; /// Configures the underlying Azure resource using Azure.Provisioning. [AspireExport] public class FoundryResource(string name, Action configureInfrastructure) : - AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzureNspAssociationTarget + AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzurePrivateEndpointTarget, IAzureNspAssociationTarget { internal Uri? EmulatorServiceUri { get; set; } @@ -132,6 +132,10 @@ IEnumerable> IResourceWithConnectionSt yield return new("Key", ReferenceExpression.Create($"{ApiKey}")); } } + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["account"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.cognitiveservices.azure.com"; } /// diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointLockdownTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointLockdownTests.cs index a478ea46ea8..7d731f5b178 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointLockdownTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointLockdownTests.cs @@ -184,4 +184,36 @@ public async Task AddAzureWebPubSub_WithPrivateEndpoint_GeneratesCorrectBicep() await Verify(manifest.BicepText, extension: "bicep"); } + + [Fact] + public async Task AddAzureOpenAI_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var openai = builder.AddAzureOpenAI("openai"); + + subnet.AddPrivateEndpoint(openai); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(openai.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddFoundry_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var foundry = builder.AddFoundry("foundry"); + + subnet.AddPrivateEndpoint(foundry); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(foundry.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureOpenAI_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureOpenAI_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..e69650ffecf --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureOpenAI_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,27 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource openai 'Microsoft.CognitiveServices/accounts@2025-09-01' = { + name: take('openai-${uniqueString(resourceGroup().id)}', 64) + location: location + kind: 'OpenAI' + properties: { + customSubDomainName: toLower(take(concat('openai', uniqueString(resourceGroup().id)), 24)) + publicNetworkAccess: 'Disabled' + disableLocalAuth: true + } + sku: { + name: 'S0' + } + tags: { + 'aspire-resource-name': 'openai' + } +} + +output connectionString string = 'Endpoint=${openai.properties.endpoint}' + +output endpoint string = openai.properties.endpoint + +output name string = openai.name + +output id string = openai.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddFoundry_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddFoundry_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..e8120eb90ab --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddFoundry_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,40 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource foundry 'Microsoft.CognitiveServices/accounts@2025-09-01' = { + name: take('foundry-${uniqueString(resourceGroup().id)}', 64) + location: location + identity: { + type: 'SystemAssigned' + } + kind: 'AIServices' + properties: { + customSubDomainName: toLower(take(concat('foundry', uniqueString(resourceGroup().id)), 24)) + publicNetworkAccess: 'Disabled' + disableLocalAuth: true + allowProjectManagement: true + } + sku: { + name: 'S0' + } + tags: { + 'aspire-resource-name': 'foundry' + } +} + +resource foundry_caphost 'Microsoft.CognitiveServices/accounts/capabilityHosts@2025-10-01-preview' = { + name: 'foundry-caphost' + properties: { + capabilityHostKind: 'Agents' + enablePublicHostingEnvironment: true + } + parent: foundry +} + +output aiFoundryApiEndpoint string = foundry.properties.endpoints['AI Foundry API'] + +output endpoint string = foundry.properties.endpoint + +output name string = foundry.name + +output id string = foundry.id \ No newline at end of file