From 7e173397f39cf45aa81249bae9b17a79f0e384ef Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 21 Nov 2025 17:59:39 -0600 Subject: [PATCH 01/19] Add initial Aspire.Hosting.Azure.Network integration This is the first round of supporting Azure virtual networks in Aspire. --- Aspire.slnx | 1 + Directory.Packages.props | 1 + .../Aspire.Hosting.Azure.Network.csproj | 21 + .../AzureNatGatewayResource.cs | 63 +++ .../AzurePublicIpResource.cs | 76 ++++ .../AzureSubnetResource.cs | 85 ++++ .../AzureVirtualNetworkExtensions.cs | 382 ++++++++++++++++++ .../AzureVirtualNetworkResource.cs | 58 +++ src/Aspire.Hosting.Azure.Network/README.md | 105 +++++ 9 files changed, 792 insertions(+) create mode 100644 src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj create mode 100644 src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/README.md diff --git a/Aspire.slnx b/Aspire.slnx index 316fc691811..c0a9090b282 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -91,6 +91,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 5c01540e93c..8add11acd90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,6 +48,7 @@ + diff --git a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj new file mode 100644 index 00000000000..d6c3f09fd64 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj @@ -0,0 +1,21 @@ + + + + $(DefaultTargetFramework) + true + aspire integration hosting azure network vnet virtual-network subnet nat-gateway public-ip cloud + Azure Virtual Network resource types for Aspire. + $(SharedDir)Azure_256x.png + + + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs new file mode 100644 index 00000000000..9ef2b9dcb8f --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning.Primitives; +using Azure.Provisioning.Network; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure NAT Gateway resource. +/// +/// The name of the resource. +/// Callback to configure the Azure NAT Gateway resource. +public class AzureNatGatewayResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + internal List PublicIpAddresses { get; } = []; + + /// + /// Gets the "id" output reference from the Azure NAT Gateway resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + /// Gets or sets the idle timeout in minutes for the NAT Gateway (4-120 minutes). + /// + public int? IdleTimeoutInMinutes { get; set; } + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + // Check if a NatGateway with the same identifier already exists + var existingNatGw = resources.OfType().SingleOrDefault(natgw => natgw.BicepIdentifier == bicepIdentifier); + + if (existingNatGw is not null) + { + return existingNatGw; + } + + // Create and add new resource if it doesn't exist + var natGw = NatGateway.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation( + this, + infra, + natGw)) + { + natGw.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(natGw); + return natGw; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs new file mode 100644 index 00000000000..b1284df1dac --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning.Primitives; +using Azure.Provisioning.Network; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Public IP Address resource. +/// +/// The name of the resource. +/// Callback to configure the Azure Public IP Address resource. +public class AzurePublicIpResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + /// + /// Gets the "id" output reference from the Azure Public IP Address resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + /// Gets the "ipAddress" output reference from the Azure Public IP Address resource. + /// + public BicepOutputReference IpAddress => new("ipAddress", this); + + /// + /// Gets or sets the public IP allocation method. + /// + public string? AllocationMethod { get; set; } + + /// + /// Gets or sets the SKU for the public IP address. + /// + public string? Sku { get; set; } + + /// + /// Gets or sets the DNS name for the public IP address. + /// + public string? DnsName { get; set; } + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + // Check if a PublicIPAddress with the same identifier already exists + var existingIp = resources.OfType().SingleOrDefault(ip => ip.BicepIdentifier == bicepIdentifier); + + if (existingIp is not null) + { + return existingIp; + } + + // Create and add new resource if it doesn't exist + var publicIp = PublicIPAddress.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation( + this, + infra, + publicIp)) + { + publicIp.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(publicIp); + return publicIp; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs new file mode 100644 index 00000000000..01fedca87ce --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Subnet resource. +/// +/// The name of the resource. +/// The subnet name. +/// The parent Virtual Network resource. +/// +/// Use to configure specific properties. +/// +public class AzureSubnetResource(string name, string subnetName, AzureVirtualNetworkResource parent) + : Resource(name), IResourceWithParent +{ + private string _subnetName = ThrowIfNullOrEmpty(subnetName); + private string? _addressPrefix; + + /// + /// The subnet name. + /// + public string SubnetName + { + get => _subnetName; + set => _subnetName = ThrowIfNullOrEmpty(value); + } + + /// + /// The address prefix for the subnet (e.g., "10.0.1.0/24"). + /// + public string? AddressPrefix + { + get => _addressPrefix; + set => _addressPrefix = value; + } + + /// + /// Gets the parent Azure Virtual Network resource. + /// + public AzureVirtualNetworkResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); + + /// + /// Gets the NAT Gateway resource associated with this subnet, if any. + /// + public AzureNatGatewayResource? NatGateway { get; internal set; } + + private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + => !string.IsNullOrEmpty(argument) ? argument : throw new ArgumentNullException(paramName); + + /// + /// Converts the current instance to a provisioning entity. + /// + /// A instance. + internal global::Azure.Provisioning.Network.Subnet ToProvisioningEntity() + { + var subnet = new global::Azure.Provisioning.Network.Subnet(Infrastructure.NormalizeBicepIdentifier(Name)); + + if (SubnetName != null) + { + subnet.Name = SubnetName; + } + + if (AddressPrefix != null) + { + subnet.AddressPrefix = AddressPrefix; + } + + if (NatGateway != null) + { + subnet.NatGateway = new global::Azure.Provisioning.Network.NetworkSubResource + { + Id = NatGateway.Id + }; + } + + return subnet; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs new file mode 100644 index 00000000000..732d383729b --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -0,0 +1,382 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Expressions; +using Azure.Provisioning.Network; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure Virtual Network resources to the application model. +/// +public static class AzureVirtualNetworkExtensions +{ + /// + /// Adds an Azure Virtual Network resource to the application model. + /// + /// The builder for the distributed application. + /// The name of the Azure Virtual Network resource. + /// A reference to the . + /// + /// By default references to the Azure Virtual Network resource will be assigned the following roles: + /// + /// - + /// + /// These can be replaced by calling . + /// + public static IResourceBuilder AddAzureVirtualNetwork( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + return builder.AddAzureVirtualNetwork(name, null); + } + + /// + /// Adds an Azure Virtual Network resource to the application model with a specified address prefix. + /// + /// The builder for the distributed application. + /// The name of the Azure Virtual Network resource. + /// The address prefix for the virtual network (e.g., "10.0.0.0/16"). If null, defaults to "10.0.0.0/16". + /// A reference to the . + /// + /// By default references to the Azure Virtual Network resource will be assigned the following roles: + /// + /// - + /// + /// These can be replaced by calling . + /// + public static IResourceBuilder AddAzureVirtualNetwork( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + string? addressPrefix) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.AddAzureProvisioning(); + + AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork); + return builder.AddResource(resource) + .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, + NetworkBuiltInRole.NetworkContributor); + + void ConfigureVirtualNetwork(AzureResourceInfrastructure infrastructure) + { + var vnet = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, + (identifier, name) => + { + var resource = VirtualNetwork.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var vnet = new VirtualNetwork(infrastructure.AspireResource.GetBicepIdentifier()) + { + AddressSpace = new AddressSpace() + { + AddressPrefixes = { addressPrefix ?? "10.0.0.0/16" } + }, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + return vnet; + }); + + var azureResource = (AzureVirtualNetworkResource)infrastructure.AspireResource; + + // Add subnets + if (azureResource.Subnets.Count > 0) + { + foreach (var subnet in azureResource.Subnets) + { + var cdkSubnet = subnet.ToProvisioningEntity(); + cdkSubnet.Parent = vnet; + infrastructure.Add(cdkSubnet); + } + } + + // Output the VNet ID for references + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = vnet.Id + }); + + // We need to output name to externalize role assignments. + infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = vnet.Name }); + } + } + + /// + /// Adds an Azure Subnet to the Virtual Network. + /// + /// The Virtual Network resource builder. + /// The name of the subnet resource. + /// The address prefix for the subnet (e.g., "10.0.1.0/24"). + /// A reference to the . + public static IResourceBuilder AddSubnet( + this IResourceBuilder builder, + [ResourceName] string name, + string addressPrefix) + { + return builder.AddSubnet(name, null, addressPrefix); + } + + /// + /// Adds an Azure Subnet to the Virtual Network. + /// + /// The Virtual Network resource builder. + /// The name of the subnet resource. + /// The subnet name in Azure. If null, the resource name is used. + /// The address prefix for the subnet (e.g., "10.0.1.0/24"). + /// A reference to the . + public static IResourceBuilder AddSubnet( + this IResourceBuilder builder, + [ResourceName] string name, + string? subnetName, + string addressPrefix) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(addressPrefix); + + subnetName ??= name; + + var subnet = new AzureSubnetResource(name, subnetName, builder.Resource) + { + AddressPrefix = addressPrefix + }; + + builder.Resource.Subnets.Add(subnet); + return builder.ApplicationBuilder.AddResource(subnet); + } + + /// + /// Adds an Azure Public IP Address resource to the application model. + /// + /// The builder for the distributed application. + /// The name of the Azure Public IP Address resource. + /// A reference to the . + /// + /// By default references to the Azure Public IP Address resource will be assigned the following roles: + /// + /// - + /// + /// These can be replaced by calling . + /// + public static IResourceBuilder AddAzurePublicIP( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.AddAzureProvisioning(); + + AzurePublicIpResource resource = new(name, ConfigurePublicIp); + return builder.AddResource(resource) + .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, + NetworkBuiltInRole.NetworkContributor); + + void ConfigurePublicIp(AzureResourceInfrastructure infrastructure) + { + var publicIp = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, + (identifier, name) => + { + var resource = PublicIPAddress.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var azureResource = (AzurePublicIpResource)infrastructure.AspireResource; + var publicIp = new PublicIPAddress(infrastructure.AspireResource.GetBicepIdentifier()) + { + PublicIPAllocationMethod = azureResource.AllocationMethod != null + ? BicepValue.DefineProperty(publicIp, nameof(PublicIPAddress.PublicIPAllocationMethod), ["properties", "publicIPAllocationMethod"], defaultValue: new BicepString(azureResource.AllocationMethod)) + : BicepValue.DefineProperty(publicIp, nameof(PublicIPAddress.PublicIPAllocationMethod), ["properties", "publicIPAllocationMethod"], defaultValue: NetworkIPAllocationMethod.Static), + Sku = azureResource.Sku != null + ? new PublicIPAddressSku { Name = new BicepString(azureResource.Sku) } + : new PublicIPAddressSku { Name = PublicIPAddressSkuName.Standard }, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + if (azureResource.DnsName != null) + { + publicIp.DnsSettings = new PublicIPAddressDnsSettings + { + DomainNameLabel = azureResource.DnsName + }; + } + + return publicIp; + }); + + // Output the Public IP ID and IP Address for references + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = publicIp.Id + }); + + infrastructure.Add(new ProvisioningOutput("ipAddress", typeof(string)) + { + Value = publicIp.IPAddress + }); + + // We need to output name to externalize role assignments. + infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = publicIp.Name }); + } + } + + /// + /// Adds an Azure NAT Gateway resource to the application model. + /// + /// The builder for the distributed application. + /// The name of the Azure NAT Gateway resource. + /// A reference to the . + /// + /// By default references to the Azure NAT Gateway resource will be assigned the following roles: + /// + /// - + /// + /// These can be replaced by calling . + /// + public static IResourceBuilder AddAzureNatGateway( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.AddAzureProvisioning(); + + AzureNatGatewayResource resource = new(name, ConfigureNatGateway); + return builder.AddResource(resource) + .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, + NetworkBuiltInRole.NetworkContributor); + + void ConfigureNatGateway(AzureResourceInfrastructure infrastructure) + { + var natGateway = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, + (identifier, name) => + { + var resource = NatGateway.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var azureResource = (AzureNatGatewayResource)infrastructure.AspireResource; + var natGw = new NatGateway(infrastructure.AspireResource.GetBicepIdentifier()) + { + Sku = new NatGatewaySku { Name = NatGatewaySkuName.Standard }, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + if (azureResource.IdleTimeoutInMinutes.HasValue) + { + natGw.IdleTimeoutInMinutes = azureResource.IdleTimeoutInMinutes.Value; + } + + // Add public IP addresses if configured + if (azureResource.PublicIpAddresses.Count > 0) + { + foreach (var publicIp in azureResource.PublicIpAddresses) + { + natGw.PublicIPAddresses.Add(new WritableSubResource + { + Id = publicIp.Id + }); + } + } + + return natGw; + }); + + // Output the NAT Gateway ID for references + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = natGateway.Id + }); + + // We need to output name to externalize role assignments. + infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = natGateway.Name }); + } + } + + /// + /// Associates a NAT Gateway with the subnet. + /// + /// The subnet resource builder. + /// The NAT Gateway resource builder. + /// A reference to the . + public static IResourceBuilder WithNatGateway( + this IResourceBuilder builder, + IResourceBuilder natGateway) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(natGateway); + + builder.Resource.NatGateway = natGateway.Resource; + return builder; + } + + /// + /// Associates a Public IP Address with the NAT Gateway. + /// + /// The NAT Gateway resource builder. + /// The Public IP Address resource builder. + /// A reference to the . + public static IResourceBuilder WithPublicIP( + this IResourceBuilder builder, + IResourceBuilder publicIp) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(publicIp); + + builder.Resource.PublicIpAddresses.Add(publicIp.Resource); + return builder; + } + + /// + /// Assigns the specified roles to the given resource, granting it the necessary permissions + /// on the target Azure Virtual Network resource. + /// + public static IResourceBuilder WithRoleAssignments( + this IResourceBuilder builder, + IResourceBuilder target, + params NetworkBuiltInRole[] roles) + where T : IResource + { + return builder.WithRoleAssignments(target, NetworkBuiltInRole.GetBuiltInRoleName, roles); + } + + /// + /// Assigns the specified roles to the given resource, granting it the necessary permissions + /// on the target Azure Public IP Address resource. + /// + public static IResourceBuilder WithRoleAssignments( + this IResourceBuilder builder, + IResourceBuilder target, + params NetworkBuiltInRole[] roles) + where T : IResource + { + return builder.WithRoleAssignments(target, NetworkBuiltInRole.GetBuiltInRoleName, roles); + } + + /// + /// Assigns the specified roles to the given resource, granting it the necessary permissions + /// on the target Azure NAT Gateway resource. + /// + public static IResourceBuilder WithRoleAssignments( + this IResourceBuilder builder, + IResourceBuilder target, + params NetworkBuiltInRole[] roles) + where T : IResource + { + return builder.WithRoleAssignments(target, NetworkBuiltInRole.GetBuiltInRoleName, roles); + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs new file mode 100644 index 00000000000..e3b7b5d34ca --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning.Primitives; +using Azure.Provisioning.Network; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Virtual Network resource. +/// +/// The name of the resource. +/// Callback to configure the Azure Virtual Network resource. +public class AzureVirtualNetworkResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + internal List Subnets { get; } = []; + + /// + /// Gets the "id" output reference from the Azure Virtual Network resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + // Check if a VirtualNetwork with the same identifier already exists + var existingVNet = resources.OfType().SingleOrDefault(vnet => vnet.BicepIdentifier == bicepIdentifier); + + if (existingVNet is not null) + { + return existingVNet; + } + + // Create and add new resource if it doesn't exist + var vnet = VirtualNetwork.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation( + this, + infra, + vnet)) + { + vnet.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(vnet); + return vnet; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md new file mode 100644 index 00000000000..cc296ce34e5 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/README.md @@ -0,0 +1,105 @@ +# Aspire.Hosting.Azure.Network library + +Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, NAT Gateways, and Public IP Addresses. + +## Getting started + +### Prerequisites + +- Azure subscription - [create one for free](https://azure.microsoft.com/free/) + +### Install the package + +Install the Aspire Azure Virtual Network Hosting library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Azure.Network +``` + +## Configure Azure Provisioning for local development + +Adding Azure resources to the Aspire application model will automatically enable development-time provisioning +for Azure resources so that you don't need to configure them manually. Provisioning requires a number of settings +to be available via .NET configuration. Set these values in user secrets in order to allow resources to be configured +automatically. + +```json +{ + "Azure": { + "SubscriptionId": "", + "ResourceGroupPrefix": "", + "Location": "" + } +} +``` + +> NOTE: Developers must have Owner access to the target subscription so that role assignments +> can be configured for the provisioned resources. + +## Usage examples + +### Adding a Virtual Network + +In the _AppHost.cs_ file of `AppHost`, add a Virtual Network using the following method: + +```csharp +var vnet = builder.AddAzureVirtualNetwork("vnet"); +``` + +By default, the virtual network will use the address prefix `10.0.0.0/16`. You can specify a custom address prefix: + +```csharp +var vnet = builder.AddAzureVirtualNetwork("vnet", "10.1.0.0/16"); +``` + +### Adding Subnets + +You can add subnets to your virtual network: + +```csharp +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var subnet = vnet.AddSubnet("subnet", "10.0.1.0/24"); +``` + +### Adding NAT Gateway with Public IP + +Create a NAT Gateway with a Public IP and associate it with a subnet: + +```csharp +var publicIp = builder.AddAzurePublicIP("natip"); +var natGateway = builder.AddAzureNatGateway("natgw") + .WithPublicIP(publicIp); + +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var subnet = vnet.AddSubnet("subnet", "10.0.1.0/24") + .WithNatGateway(natGateway); +``` + +### Complete example with outbound connectivity + +This example creates a Virtual Network with a subnet that has outbound internet connectivity via a NAT Gateway: + +```csharp +// Create a public IP for the NAT Gateway +var publicIp = builder.AddAzurePublicIP("natip"); + +// Create a NAT Gateway and attach the public IP +var natGateway = builder.AddAzureNatGateway("natgw") + .WithPublicIP(publicIp); + +// Create a Virtual Network with custom address space +var vnet = builder.AddAzureVirtualNetwork("vnet", "10.0.0.0/16"); + +// Add a subnet with NAT Gateway for outbound connectivity +var subnet = vnet.AddSubnet("appsubnet", "10.0.1.0/24") + .WithNatGateway(natGateway); +``` + +## Additional documentation + +* https://learn.microsoft.com/azure/virtual-network/ +* https://learn.microsoft.com/azure/nat-gateway/ + +## Feedback & contributing + +https://github.com/dotnet/aspire From 7441a6ff1590de1c7111490dd8060a7ccf2ae6da Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 26 Nov 2025 18:22:01 -0600 Subject: [PATCH 02/19] Get some stuff working --- .../AzureStorageEndToEnd.AppHost.csproj | 3 + .../AzureStorageEndToEnd.AppHost/Program.cs | 36 +- .../api-containerapp.module.bicep | 105 +++++ .../api-identity.module.bicep | 17 + .../api-roles-storage.module.bicep | 40 ++ .../aspire-manifest.json | 60 +-- .../env.module.bicep | 92 +++++ .../vnet.module.bicep | 31 ++ .../Aspire.Hosting.Azure.Network.csproj | 8 +- .../AzureNatGatewayResource.cs | 5 +- .../AzurePublicIpResource.cs | 9 +- .../AzureSubnetResource.cs | 40 +- .../AzureVirtualNetworkExtensions.cs | 381 ++++++++---------- .../AzureVirtualNetworkResource.cs | 9 +- .../Aspire.Playground.Tests.csproj | 1 + 15 files changed, 535 insertions(+), 302 deletions(-) create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-identity.module.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-roles-storage.module.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj index 02b413b3c2e..3c3b026798a 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj @@ -14,7 +14,10 @@ + + + diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index 1a7877b3d95..bf71c19237c 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -1,8 +1,26 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Azure.Provisioning.AppContainers; + var builder = DistributedApplication.CreateBuilder(args); +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var subnet1 = vnet.AddSubnet("subnet1", subnetName: null, "10.0.1.0/24"); + +builder.AddAzureContainerAppEnvironment("env") + .ConfigureInfrastructure(infra => + { + var env = infra.GetProvisionableResources() + .OfType() + .Single(); + + env.VnetConfiguration = new ContainerAppVnetConfiguration + { + InfrastructureSubnetId = subnet1.Resource.Id.AsProvisioningParameter(infra) + }; + }); + var storage = builder.AddAzureStorage("storage").RunAsEmulator(container => { container.WithDataBindMount(); @@ -14,28 +32,10 @@ var myqueue = storage.AddQueue("myqueue", queueName: "my-queue"); -var storage2 = builder.AddAzureStorage("storage2").RunAsEmulator(container => -{ - container.WithDataBindMount(); -}); - -var blobContainer2 = storage2.AddBlobContainer("foocontainer", blobContainerName: "foo-container"); - builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(blobs).WaitFor(blobs) - .WithReference(blobContainer2).WaitFor(blobContainer2) .WithReference(myqueue).WaitFor(myqueue); -#if !SKIP_DASHBOARD_REFERENCE -// This project is only added in playground projects to support development/debugging -// of the dashboard. It is not required in end developer code. Comment out this code -// or build with `/p:SkipDashboardReference=true`, to test end developer -// dashboard launch experience, Refer to Directory.Build.props for the path to -// the dashboard binary (defaults to the Aspire.Dashboard bin output in the -// artifacts dir). -builder.AddProject(KnownResourceNames.AspireDashboard); -#endif - builder.Build().Run(); diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep new file mode 100644 index 00000000000..75fe2785fce --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep @@ -0,0 +1,105 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param api_containerimage string + +param api_identity_outputs_id string + +param api_containerport string + +param storage_outputs_blobendpoint string + +param storage_outputs_queueendpoint string + +param api_identity_outputs_clientid string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: true + targetPort: int(api_containerport) + transport: 'http' + } + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: api_containerport + } + { + name: 'ConnectionStrings__blobs' + value: storage_outputs_blobendpoint + } + { + name: 'ConnectionStrings__myqueue' + value: 'Endpoint=${storage_outputs_queueendpoint};QueueName=my-queue' + } + { + name: 'AZURE_CLIENT_ID' + value: api_identity_outputs_clientid + } + { + name: 'AZURE_TOKEN_CREDENTIALS' + value: 'ManagedIdentityCredential' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${api_identity_outputs_id}': { } + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-identity.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-identity.module.bicep new file mode 100644 index 00000000000..e2d7908d230 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-identity.module.bicep @@ -0,0 +1,17 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-roles-storage.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-roles-storage.module.bicep new file mode 100644 index 00000000000..b3f1171c933 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-roles-storage.module.bicep @@ -0,0 +1,40 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param storage_outputs_name string + +param principalId string + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: storage_outputs_name +} + +resource storage_StorageBlobDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') + principalType: 'ServicePrincipal' + } + scope: storage +} + +resource storage_StorageTableDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') + principalType: 'ServicePrincipal' + } + scope: storage +} + +resource storage_StorageQueueDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') + principalType: 'ServicePrincipal' + } + scope: storage +} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json index 09ec76ae168..24d5e1f32fb 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json @@ -1,6 +1,18 @@ { "$schema": "https://json.schemastore.org/aspire-8.0.json", "resources": { + "vnet": { + "type": "azure.bicep.v0", + "path": "vnet.module.bicep" + }, + "env": { + "type": "azure.bicep.v0", + "path": "env.module.bicep", + "params": { + "vnet_outputs_subnet1_subnetid": "{vnet.outputs.subnet1_SubnetId}", + "userPrincipalId": "" + } + }, "storage": { "type": "azure.bicep.v0", "path": "storage.module.bicep" @@ -29,21 +41,25 @@ "type": "value.v0", "connectionString": "Endpoint={storage.outputs.queueEndpoint};QueueName=my-queue" }, - "storage2": { - "type": "azure.bicep.v0", - "path": "storage2.module.bicep" - }, - "storage2-blobs": { - "type": "value.v0", - "connectionString": "{storage2.outputs.blobEndpoint}" - }, - "foocontainer": { - "type": "value.v0", - "connectionString": "Endpoint={storage2.outputs.blobEndpoint};ContainerName=foo-container" - }, "api": { - "type": "project.v0", + "type": "project.v1", "path": "../AzureStorageEndToEnd.ApiService/AzureStorageEndToEnd.ApiService.csproj", + "deployment": { + "type": "azure.bicep.v0", + "path": "api-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "api_containerimage": "{api.containerImage}", + "api_identity_outputs_id": "{api-identity.outputs.id}", + "api_containerport": "{api.containerPort}", + "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", + "storage_outputs_queueendpoint": "{storage.outputs.queueEndpoint}", + "api_identity_outputs_clientid": "{api-identity.outputs.clientId}" + } + }, "env": { "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", @@ -72,22 +88,16 @@ } } }, - "storage-roles": { + "api-identity": { "type": "azure.bicep.v0", - "path": "storage-roles.module.bicep", - "params": { - "storage_outputs_name": "{storage.outputs.name}", - "principalType": "", - "principalId": "" - } + "path": "api-identity.module.bicep" }, - "storage2-roles": { + "api-roles-storage": { "type": "azure.bicep.v0", - "path": "storage2-roles.module.bicep", + "path": "api-roles-storage.module.bicep", "params": { - "storage2_outputs_name": "{storage2.outputs.name}", - "principalType": "", - "principalId": "" + "storage_outputs_name": "{storage.outputs.name}", + "principalId": "{api-identity.outputs.principalId}" } } } diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep new file mode 100644 index 00000000000..cb4e4f14c85 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep @@ -0,0 +1,92 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param vnet_outputs_subnet1_subnetid string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: tags +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + vnetConfiguration: { + infrastructureSubnetId: vnet_outputs_subnet1_subnetid + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep new file mode 100644 index 00000000000..6c743817dd2 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = { + name: take('vnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'vnet' + } +} + +resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = { + name: 'subnet1' + properties: { + addressPrefix: '10.0.1.0/24' + } + parent: vnet +} + +output subnet1_SubnetId string = subnet1.id + +output id string = vnet.id + +output name string = vnet.name \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj index d6c3f09fd64..7329fab2836 100644 --- a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj +++ b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj @@ -6,15 +6,13 @@ aspire integration hosting azure network vnet virtual-network subnet nat-gateway public-ip cloud Azure Virtual Network resource types for Aspire. $(SharedDir)Azure_256x.png + true + $(NoWarn);AZPROVISION001 - - - - - + diff --git a/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs index 9ef2b9dcb8f..e955e169fed 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.ApplicationModel; using Azure.Provisioning.Primitives; using Azure.Provisioning.Network; @@ -37,10 +36,10 @@ public override ProvisionableResource AddAsExistingResource(AzureResourceInfrast { var bicepIdentifier = this.GetBicepIdentifier(); var resources = infra.GetProvisionableResources(); - + // Check if a NatGateway with the same identifier already exists var existingNatGw = resources.OfType().SingleOrDefault(natgw => natgw.BicepIdentifier == bicepIdentifier); - + if (existingNatGw is not null) { return existingNatGw; diff --git a/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs index b1284df1dac..4b148846f0c 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs @@ -1,9 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.ApplicationModel; -using Azure.Provisioning.Primitives; using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; namespace Aspire.Hosting.Azure; @@ -50,15 +49,15 @@ public override ProvisionableResource AddAsExistingResource(AzureResourceInfrast { var bicepIdentifier = this.GetBicepIdentifier(); var resources = infra.GetProvisionableResources(); - + // Check if a PublicIPAddress with the same identifier already exists var existingIp = resources.OfType().SingleOrDefault(ip => ip.BicepIdentifier == bicepIdentifier); - + if (existingIp is not null) { return existingIp; } - + // Create and add new resource if it doesn't exist var publicIp = PublicIPAddress.FromExisting(bicepIdentifier); diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index 01fedca87ce..a230259d7db 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Azure.Provisioning; +using Azure.Provisioning.Network; namespace Aspire.Hosting.Azure; @@ -13,15 +14,16 @@ namespace Aspire.Hosting.Azure; /// /// The name of the resource. /// The subnet name. +/// The address prefix for the subnet. /// The parent Virtual Network resource. /// /// Use to configure specific properties. /// -public class AzureSubnetResource(string name, string subnetName, AzureVirtualNetworkResource parent) +public class AzureSubnetResource(string name, string subnetName, string addressPrefix, AzureVirtualNetworkResource parent) : Resource(name), IResourceWithParent { private string _subnetName = ThrowIfNullOrEmpty(subnetName); - private string? _addressPrefix; + private string _addressPrefix = ThrowIfNullOrEmpty(addressPrefix); /// /// The subnet name. @@ -35,12 +37,17 @@ public string SubnetName /// /// The address prefix for the subnet (e.g., "10.0.1.0/24"). /// - public string? AddressPrefix + public string AddressPrefix { get => _addressPrefix; set => _addressPrefix = value; } + /// + /// Gets the subnet Id output reference. + /// + public BicepOutputReference Id => new($"{subnetName}_Id", parent); + /// /// Gets the parent Azure Virtual Network resource. /// @@ -57,28 +64,25 @@ private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgu /// /// Converts the current instance to a provisioning entity. /// - /// A instance. - internal global::Azure.Provisioning.Network.Subnet ToProvisioningEntity() + internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra) { - var subnet = new global::Azure.Provisioning.Network.Subnet(Infrastructure.NormalizeBicepIdentifier(Name)); - - if (SubnetName != null) + var subnet = new SubnetResource(Infrastructure.NormalizeBicepIdentifier(Name)) { - subnet.Name = SubnetName; - } + Name = SubnetName, + AddressPrefix = AddressPrefix, + // TODO: DefaultOutboundAccess = DefaultOutboundAccess + }; - if (AddressPrefix != null) + if (NatGateway is not null) { - subnet.AddressPrefix = AddressPrefix; + subnet.NatGatewayId = NatGateway.Id.AsProvisioningParameter(infra); } - if (NatGateway != null) + // add a provisioning output for the subnet ID so it can be referenced by other resources + infra.Add(new ProvisioningOutput(Id.Name, typeof(string)) { - subnet.NatGateway = new global::Azure.Provisioning.Network.NetworkSubResource - { - Id = NatGateway.Id - }; - } + Value = subnet.Id + }); return subnet; } diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 732d383729b..229331d2a14 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -4,7 +4,6 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; -using Azure.Provisioning.Expressions; using Azure.Provisioning.Network; namespace Aspire.Hosting; @@ -20,13 +19,6 @@ public static class AzureVirtualNetworkExtensions /// The builder for the distributed application. /// The name of the Azure Virtual Network resource. /// A reference to the . - /// - /// By default references to the Azure Virtual Network resource will be assigned the following roles: - /// - /// - - /// - /// These can be replaced by calling . - /// public static IResourceBuilder AddAzureVirtualNetwork( this IDistributedApplicationBuilder builder, [ResourceName] string name) @@ -41,13 +33,6 @@ public static IResourceBuilder AddAzureVirtualNetwo /// The name of the Azure Virtual Network resource. /// The address prefix for the virtual network (e.g., "10.0.0.0/16"). If null, defaults to "10.0.0.0/16". /// A reference to the . - /// - /// By default references to the Azure Virtual Network resource will be assigned the following roles: - /// - /// - - /// - /// These can be replaced by calling . - /// public static IResourceBuilder AddAzureVirtualNetwork( this IDistributedApplicationBuilder builder, [ResourceName] string name, @@ -59,13 +44,11 @@ public static IResourceBuilder AddAzureVirtualNetwo builder.AddAzureProvisioning(); AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork); - return builder.AddResource(resource) - .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, - NetworkBuiltInRole.NetworkContributor); + return builder.AddResource(resource); - void ConfigureVirtualNetwork(AzureResourceInfrastructure infrastructure) + void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) { - var vnet = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, + var vnet = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, (identifier, name) => { var resource = VirtualNetwork.FromExisting(identifier); @@ -76,7 +59,7 @@ void ConfigureVirtualNetwork(AzureResourceInfrastructure infrastructure) { var vnet = new VirtualNetwork(infrastructure.AspireResource.GetBicepIdentifier()) { - AddressSpace = new AddressSpace() + AddressSpace = new VirtualNetworkAddressSpace() { AddressPrefixes = { addressPrefix ?? "10.0.0.0/16" } }, @@ -86,27 +69,27 @@ void ConfigureVirtualNetwork(AzureResourceInfrastructure infrastructure) return vnet; }); - var azureResource = (AzureVirtualNetworkResource)infrastructure.AspireResource; + var azureResource = (AzureVirtualNetworkResource)infra.AspireResource; // Add subnets if (azureResource.Subnets.Count > 0) { foreach (var subnet in azureResource.Subnets) { - var cdkSubnet = subnet.ToProvisioningEntity(); + var cdkSubnet = subnet.ToProvisioningEntity(infra); cdkSubnet.Parent = vnet; - infrastructure.Add(cdkSubnet); + infra.Add(cdkSubnet); } } // Output the VNet ID for references - infrastructure.Add(new ProvisioningOutput("id", typeof(string)) + infra.Add(new ProvisioningOutput("id", typeof(string)) { Value = vnet.Id }); - // We need to output name to externalize role assignments. - infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = vnet.Name }); + // We need to output name so it can be referenced by others. + infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = vnet.Name }); } } @@ -145,167 +128,158 @@ public static IResourceBuilder AddSubnet( subnetName ??= name; - var subnet = new AzureSubnetResource(name, subnetName, builder.Resource) - { - AddressPrefix = addressPrefix - }; + var subnet = new AzureSubnetResource(name, subnetName, addressPrefix, builder.Resource); builder.Resource.Subnets.Add(subnet); - return builder.ApplicationBuilder.AddResource(subnet); + return builder.ApplicationBuilder.AddResource(subnet) + .ExcludeFromManifest(); } - /// - /// Adds an Azure Public IP Address resource to the application model. - /// - /// The builder for the distributed application. - /// The name of the Azure Public IP Address resource. - /// A reference to the . - /// - /// By default references to the Azure Public IP Address resource will be assigned the following roles: - /// - /// - - /// - /// These can be replaced by calling . - /// - public static IResourceBuilder AddAzurePublicIP( - this IDistributedApplicationBuilder builder, - [ResourceName] string name) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrEmpty(name); - - builder.AddAzureProvisioning(); - - AzurePublicIpResource resource = new(name, ConfigurePublicIp); - return builder.AddResource(resource) - .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, - NetworkBuiltInRole.NetworkContributor); - - void ConfigurePublicIp(AzureResourceInfrastructure infrastructure) - { - var publicIp = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, - (identifier, name) => - { - var resource = PublicIPAddress.FromExisting(identifier); - resource.Name = name; - return resource; - }, - (infrastructure) => - { - var azureResource = (AzurePublicIpResource)infrastructure.AspireResource; - var publicIp = new PublicIPAddress(infrastructure.AspireResource.GetBicepIdentifier()) - { - PublicIPAllocationMethod = azureResource.AllocationMethod != null - ? BicepValue.DefineProperty(publicIp, nameof(PublicIPAddress.PublicIPAllocationMethod), ["properties", "publicIPAllocationMethod"], defaultValue: new BicepString(azureResource.AllocationMethod)) - : BicepValue.DefineProperty(publicIp, nameof(PublicIPAddress.PublicIPAllocationMethod), ["properties", "publicIPAllocationMethod"], defaultValue: NetworkIPAllocationMethod.Static), - Sku = azureResource.Sku != null - ? new PublicIPAddressSku { Name = new BicepString(azureResource.Sku) } - : new PublicIPAddressSku { Name = PublicIPAddressSkuName.Standard }, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } - }; - - if (azureResource.DnsName != null) - { - publicIp.DnsSettings = new PublicIPAddressDnsSettings - { - DomainNameLabel = azureResource.DnsName - }; - } - - return publicIp; - }); - - // Output the Public IP ID and IP Address for references - infrastructure.Add(new ProvisioningOutput("id", typeof(string)) - { - Value = publicIp.Id - }); - - infrastructure.Add(new ProvisioningOutput("ipAddress", typeof(string)) - { - Value = publicIp.IPAddress - }); - - // We need to output name to externalize role assignments. - infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = publicIp.Name }); - } - } - - /// - /// Adds an Azure NAT Gateway resource to the application model. - /// - /// The builder for the distributed application. - /// The name of the Azure NAT Gateway resource. - /// A reference to the . - /// - /// By default references to the Azure NAT Gateway resource will be assigned the following roles: - /// - /// - - /// - /// These can be replaced by calling . - /// - public static IResourceBuilder AddAzureNatGateway( - this IDistributedApplicationBuilder builder, - [ResourceName] string name) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrEmpty(name); - - builder.AddAzureProvisioning(); - - AzureNatGatewayResource resource = new(name, ConfigureNatGateway); - return builder.AddResource(resource) - .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, - NetworkBuiltInRole.NetworkContributor); - - void ConfigureNatGateway(AzureResourceInfrastructure infrastructure) - { - var natGateway = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, - (identifier, name) => - { - var resource = NatGateway.FromExisting(identifier); - resource.Name = name; - return resource; - }, - (infrastructure) => - { - var azureResource = (AzureNatGatewayResource)infrastructure.AspireResource; - var natGw = new NatGateway(infrastructure.AspireResource.GetBicepIdentifier()) - { - Sku = new NatGatewaySku { Name = NatGatewaySkuName.Standard }, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } - }; - - if (azureResource.IdleTimeoutInMinutes.HasValue) - { - natGw.IdleTimeoutInMinutes = azureResource.IdleTimeoutInMinutes.Value; - } - - // Add public IP addresses if configured - if (azureResource.PublicIpAddresses.Count > 0) - { - foreach (var publicIp in azureResource.PublicIpAddresses) - { - natGw.PublicIPAddresses.Add(new WritableSubResource - { - Id = publicIp.Id - }); - } - } - - return natGw; - }); - - // Output the NAT Gateway ID for references - infrastructure.Add(new ProvisioningOutput("id", typeof(string)) - { - Value = natGateway.Id - }); - - // We need to output name to externalize role assignments. - infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = natGateway.Name }); - } - } + ///// + ///// Adds an Azure Public IP Address resource to the application model. + ///// + ///// The builder for the distributed application. + ///// The name of the Azure Public IP Address resource. + ///// A reference to the . + ///// + ///// By default references to the Azure Public IP Address resource will be assigned the following roles: + ///// + ///// - + ///// + ///// These can be replaced by calling . + ///// + //public static IResourceBuilder AddAzurePublicIP( + // this IDistributedApplicationBuilder builder, + // [ResourceName] string name) + //{ + // ArgumentNullException.ThrowIfNull(builder); + // ArgumentException.ThrowIfNullOrEmpty(name); + + // builder.AddAzureProvisioning(); + + // AzurePublicIpResource resource = new(name, ConfigurePublicIp); + // return builder.AddResource(resource) + // .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, + // NetworkBuiltInRole.NetworkContributor); + + // void ConfigurePublicIp(AzureResourceInfrastructure infra) + // { + // var publicIp = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + // (identifier, name) => + // { + // var resource = PublicIPAddress.FromExisting(identifier); + // resource.Name = name; + // return resource; + // }, + // (infra) => + // { + // var azureResource = (AzurePublicIpResource)infra.AspireResource; + // var publicIp = new PublicIPAddress(infra.AspireResource.GetBicepIdentifier()) + // { + // PublicIPAllocationMethod = azureResource.AllocationMethod != null + // ? BicepValue.DefineProperty(publicIp, nameof(PublicIPAddress.PublicIPAllocationMethod), ["properties", "publicIPAllocationMethod"], defaultValue: new BicepString(azureResource.AllocationMethod)) + // : BicepValue.DefineProperty(publicIp, nameof(PublicIPAddress.PublicIPAllocationMethod), ["properties", "publicIPAllocationMethod"], defaultValue: NetworkIPAllocationMethod.Static), + // Sku = azureResource.Sku != null + // ? new PublicIPAddressSku { Name = new BicepString(azureResource.Sku) } + // : new PublicIPAddressSku { Name = PublicIPAddressSkuName.Standard }, + // Tags = { { "aspire-resource-name", infra.AspireResource.Name } } + // }; + + // if (azureResource.DnsName != null) + // { + // publicIp.DnsSettings = new PublicIPAddressDnsSettings + // { + // DomainNameLabel = azureResource.DnsName + // }; + // } + + // return publicIp; + // }); + + // // Output the Public IP ID and IP Address for references + // infra.Add(new ProvisioningOutput("id", typeof(string)) + // { + // Value = publicIp.Id + // }); + + // infra.Add(new ProvisioningOutput("ipAddress", typeof(string)) + // { + // Value = publicIp.IPAddress + // }); + + // // We need to output name to externalize role assignments. + // infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = publicIp.Name }); + // } + //} + + ///// + ///// Adds an Azure NAT Gateway resource to the application model. + ///// + ///// The builder for the distributed application. + ///// The name of the Azure NAT Gateway resource. + ///// A reference to the . + //public static IResourceBuilder AddAzureNatGateway( + // this IDistributedApplicationBuilder builder, + // [ResourceName] string name) + //{ + // ArgumentNullException.ThrowIfNull(builder); + // ArgumentException.ThrowIfNullOrEmpty(name); + + // builder.AddAzureProvisioning(); + + // AzureNatGatewayResource resource = new(name, ConfigureNatGateway); + // return builder.AddResource(resource) + // .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, + // NetworkBuiltInRole.NetworkContributor); + + // void ConfigureNatGateway(AzureResourceInfrastructure infra) + // { + // var natGateway = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + // (identifier, name) => + // { + // var resource = NatGateway.FromExisting(identifier); + // resource.Name = name; + // return resource; + // }, + // (infra) => + // { + // var azureResource = (AzureNatGatewayResource)infra.AspireResource; + // var natGw = new NatGateway(infra.AspireResource.GetBicepIdentifier()) + // { + // Sku = new NatGatewaySku { Name = NatGatewaySkuName.Standard }, + // Tags = { { "aspire-resource-name", infra.AspireResource.Name } } + // }; + + // if (azureResource.IdleTimeoutInMinutes.HasValue) + // { + // natGw.IdleTimeoutInMinutes = azureResource.IdleTimeoutInMinutes.Value; + // } + + // // Add public IP addresses if configured + // if (azureResource.PublicIpAddresses.Count > 0) + // { + // foreach (var publicIp in azureResource.PublicIpAddresses) + // { + // natGw.PublicIPAddresses.Add(new WritableSubResource + // { + // Id = publicIp.Id + // }); + // } + // } + + // return natGw; + // }); + + // // Output the NAT Gateway ID for references + // infra.Add(new ProvisioningOutput("id", typeof(string)) + // { + // Value = natGateway.Id + // }); + + // // We need to output name to externalize role assignments. + // infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = natGateway.Name }); + // } + //} /// /// Associates a NAT Gateway with the subnet. @@ -340,43 +314,4 @@ public static IResourceBuilder WithPublicIP( builder.Resource.PublicIpAddresses.Add(publicIp.Resource); return builder; } - - /// - /// Assigns the specified roles to the given resource, granting it the necessary permissions - /// on the target Azure Virtual Network resource. - /// - public static IResourceBuilder WithRoleAssignments( - this IResourceBuilder builder, - IResourceBuilder target, - params NetworkBuiltInRole[] roles) - where T : IResource - { - return builder.WithRoleAssignments(target, NetworkBuiltInRole.GetBuiltInRoleName, roles); - } - - /// - /// Assigns the specified roles to the given resource, granting it the necessary permissions - /// on the target Azure Public IP Address resource. - /// - public static IResourceBuilder WithRoleAssignments( - this IResourceBuilder builder, - IResourceBuilder target, - params NetworkBuiltInRole[] roles) - where T : IResource - { - return builder.WithRoleAssignments(target, NetworkBuiltInRole.GetBuiltInRoleName, roles); - } - - /// - /// Assigns the specified roles to the given resource, granting it the necessary permissions - /// on the target Azure NAT Gateway resource. - /// - public static IResourceBuilder WithRoleAssignments( - this IResourceBuilder builder, - IResourceBuilder target, - params NetworkBuiltInRole[] roles) - where T : IResource - { - return builder.WithRoleAssignments(target, NetworkBuiltInRole.GetBuiltInRoleName, roles); - } } diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs index e3b7b5d34ca..203393fc420 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs @@ -1,9 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.ApplicationModel; -using Azure.Provisioning.Primitives; using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; namespace Aspire.Hosting.Azure; @@ -32,15 +31,15 @@ public override ProvisionableResource AddAsExistingResource(AzureResourceInfrast { var bicepIdentifier = this.GetBicepIdentifier(); var resources = infra.GetProvisionableResources(); - + // Check if a VirtualNetwork with the same identifier already exists var existingVNet = resources.OfType().SingleOrDefault(vnet => vnet.BicepIdentifier == bicepIdentifier); - + if (existingVNet is not null) { return existingVNet; } - + // Create and add new resource if it doesn't exist var vnet = VirtualNetwork.FromExisting(bicepIdentifier); diff --git a/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj b/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj index 3560642d7b1..a7b982f0493 100644 --- a/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj +++ b/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj @@ -76,6 +76,7 @@ + From bf7170bf3ed45ac055094358eff52709b2b85579 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 8 Dec 2025 12:09:34 -0600 Subject: [PATCH 03/19] Don't add VNet resources in run mode. Add a delegation annotation so things like ACA can delegate subnets. --- .../Program.cs | 5 +---- .../AzureStorageEndToEnd.AppHost/Program.cs | 5 ++++- .../aspire-manifest.json | 2 +- .../env.module.bicep | 4 ++-- .../vnet.module.bicep | 12 ++++++++-- .../AzureSubnetResource.cs | 10 +++++++++ .../AzureSubnetServiceDelegationAnnotation.cs | 22 +++++++++++++++++++ .../AzureVirtualNetworkExtensions.cs | 14 ++++++++++++ 8 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs index 13b9d3b9135..f17ac81d17b 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs @@ -18,18 +18,15 @@ app.MapDefaultEndpoints(); -app.MapGet("/", async (BlobServiceClient bsc, [FromKeyedServices("myqueue")] QueueClient queue, [FromKeyedServices("foocontainer")] BlobContainerClient keyedContainerClient1) => +app.MapGet("/", async (BlobServiceClient bsc, [FromKeyedServices("myqueue")] QueueClient queue) => { var blobNames = new List(); var blobNameAndContent = Guid.NewGuid().ToString(); - await keyedContainerClient1.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); - var directContainerClient = bsc.GetBlobContainerClient(blobContainerName: "test-container-1"); await directContainerClient.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); await ReadBlobsAsync(directContainerClient, blobNames); - await ReadBlobsAsync(keyedContainerClient1, blobNames); await queue.SendMessageAsync("Hello, world!"); diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index bf71c19237c..7c965166090 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -1,12 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Azure.Network; using Azure.Provisioning.AppContainers; var builder = DistributedApplication.CreateBuilder(args); var vnet = builder.AddAzureVirtualNetwork("vnet"); -var subnet1 = vnet.AddSubnet("subnet1", subnetName: null, "10.0.1.0/24"); +var subnet1 = vnet.AddSubnet("subnet1", subnetName: null, "10.0.0.0/23") + .WithAnnotation( + new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); builder.AddAzureContainerAppEnvironment("env") .ConfigureInfrastructure(infra => diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json index 24d5e1f32fb..51f8db316d5 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json @@ -9,7 +9,7 @@ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { - "vnet_outputs_subnet1_subnetid": "{vnet.outputs.subnet1_SubnetId}", + "vnet_outputs_subnet1_id": "{vnet.outputs.subnet1_Id}", "userPrincipalId": "" } }, diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep index cb4e4f14c85..68d002d6529 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep @@ -5,7 +5,7 @@ param userPrincipalId string = '' param tags object = { } -param vnet_outputs_subnet1_subnetid string +param vnet_outputs_subnet1_id string resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) @@ -55,7 +55,7 @@ resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { } } vnetConfiguration: { - infrastructureSubnetId: vnet_outputs_subnet1_subnetid + infrastructureSubnetId: vnet_outputs_subnet1_id } workloadProfiles: [ { diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep index 6c743817dd2..e3e7c0bcac6 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep @@ -19,12 +19,20 @@ resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = { resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = { name: 'subnet1' properties: { - addressPrefix: '10.0.1.0/24' + addressPrefix: '10.0.0.0/23' + delegations: [ + { + properties: { + serviceName: 'Microsoft.App/environments' + } + name: 'ContainerAppsDelegation' + } + ] } parent: vnet } -output subnet1_SubnetId string = subnet1.id +output subnet1_Id string = subnet1.id output id string = vnet.id diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index a230259d7db..33c8fcfcb5e 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.Network; using Azure.Provisioning; using Azure.Provisioning.Network; @@ -78,6 +79,15 @@ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra) subnet.NatGatewayId = NatGateway.Id.AsProvisioningParameter(infra); } + if (this.TryGetLastAnnotation(out var serviceDelegationAnnotation)) + { + subnet.Delegations.Add(new ServiceDelegation() + { + Name = serviceDelegationAnnotation.Name, + ServiceName = serviceDelegationAnnotation.ServiceName + }); + } + // add a provisioning output for the subnet ID so it can be referenced by other resources infra.Add(new ProvisioningOutput(Id.Name, typeof(string)) { diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs new file mode 100644 index 00000000000..6174524ac29 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.Network; + +/// +/// Anotation to specify a service delegation for an Azure Subnet. +/// +public sealed class AzureSubnetServiceDelegationAnnotation(string name, string serviceName) : IResourceAnnotation +{ + /// + /// Gets or sets the name associated with the service delegation. + /// + public string Name { get; set; } = name; + + /// + /// Gets or sets the name of the service associated with the service delegation. + /// + public string ServiceName { get; set; } = serviceName; +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 229331d2a14..0a91f3df853 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -44,6 +44,13 @@ public static IResourceBuilder AddAzureVirtualNetwo builder.AddAzureProvisioning(); AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork); + + if (builder.ExecutionContext.IsRunMode) + { + // In run mode, we don't want to add the resource to the builder. + return builder.CreateResourceBuilder(resource); + } + return builder.AddResource(resource); void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) @@ -131,6 +138,13 @@ public static IResourceBuilder AddSubnet( var subnet = new AzureSubnetResource(name, subnetName, addressPrefix, builder.Resource); builder.Resource.Subnets.Add(subnet); + + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + // In run mode, we don't want to add the resource to the builder. + return builder.ApplicationBuilder.CreateResourceBuilder(subnet); + } + return builder.ApplicationBuilder.AddResource(subnet) .ExcludeFromManifest(); } From 127a621cd199616d47efcb92983a213927ee42af Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 19 Dec 2025 17:51:45 -0600 Subject: [PATCH 04/19] Initial support for private endpoints. --- .../AzureStorageEndToEnd.AppHost/Program.cs | 6 +- .../aspire-manifest.json | 8 ++ .../private-endpoints-blobs-pe.module.bicep | 34 ++++++ .../storage.module.bicep | 4 +- .../vnet.module.bicep | 13 +++ .../AzurePrivateEndpointExtensions.cs | 107 ++++++++++++++++++ .../AzurePrivateEndpointResource.cs | 65 +++++++++++ .../AzureSubnetResource.cs | 10 +- .../AzureVirtualNetworkExtensions.cs | 9 +- .../AzureBlobStorageResource.cs | 7 +- .../AzureStorageExtensions.cs | 1 + .../AzureStorageResource.cs | 5 + .../IAzurePrivateEndpointTarget.cs | 23 ++++ 13 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs create mode 100644 src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index 7c965166090..5c0dd8a549e 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -7,10 +7,12 @@ var builder = DistributedApplication.CreateBuilder(args); var vnet = builder.AddAzureVirtualNetwork("vnet"); -var subnet1 = vnet.AddSubnet("subnet1", subnetName: null, "10.0.0.0/23") +var subnet1 = vnet.AddSubnet("subnet1", subnetName: null, "10.0.1.0/24") // should be 10.0.0.0/23, but can't change it since I deployed with the wrong address space .WithAnnotation( new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); +var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", subnetName: null, "10.0.2.0/24"); + builder.AddAzureContainerAppEnvironment("env") .ConfigureInfrastructure(infra => { @@ -33,6 +35,8 @@ storage.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); storage.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); +builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, blobs); + var myqueue = storage.AddQueue("myqueue", queueName: "my-queue"); builder.AddProject("api") diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json index 51f8db316d5..f04a34cf0f2 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json @@ -33,6 +33,14 @@ "type": "value.v0", "connectionString": "Endpoint={storage.outputs.blobEndpoint};ContainerName=test-container-2" }, + "private-endpoints-blobs-pe": { + "type": "azure.bicep.v0", + "path": "private-endpoints-blobs-pe.module.bicep", + "params": { + "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", + "storage_outputs_id": "{storage.outputs.id}" + } + }, "storage-queues": { "type": "value.v0", "connectionString": "{storage.outputs.queueEndpoint}" diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep new file mode 100644 index 00000000000..e01a90354a1 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep @@ -0,0 +1,34 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_private_endpoints_id string + +param storage_outputs_id string + +resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-01-01' = { + name: take('private_endpoints_blobs_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'private-endpoints-blobs-pe-connection' + } + ] + subnet: { + id: vnet_outputs_private_endpoints_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-blobs-pe' + } +} + +output id string = private_endpoints_blobs_pe.id + +output name string = private_endpoints_blobs_pe.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep index 354128b8f35..01065520a13 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep @@ -52,4 +52,6 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table -output name string = storage.name \ No newline at end of file +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep index e3e7c0bcac6..8d37a10f9ab 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep @@ -32,8 +32,21 @@ resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = { parent: vnet } +resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = { + name: 'private-endpoints' + properties: { + addressPrefix: '10.0.2.0/24' + } + parent: vnet + dependsOn: [ + subnet1 + ] +} + output subnet1_Id string = subnet1.id +output private_endpoints_Id string = private_endpoints.id + output id string = vnet.id output name string = vnet.name \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs new file mode 100644 index 00000000000..8400200e480 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Network; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure Private Endpoint resources to the application model. +/// +public static class AzurePrivateEndpointExtensions +{ + /// + /// Adds an Azure Private Endpoint resource to the application model. + /// + /// The builder for the distributed application. + /// The subnet associated with the private endpoint. + /// The name of the Azure Private Endpoint resource. + /// A reference to the . + public static IResourceBuilder AddAzurePrivateEndpoint( + this IDistributedApplicationBuilder builder, + IResourceBuilder subnet, + IResourceBuilder target) + { + ArgumentNullException.ThrowIfNull(builder); + var name = $"{subnet.Resource.Name}-{target.Resource.Name}-pe"; + + var resource = new AzurePrivateEndpointResource(name, ConfigurePrivateEndpoint) + { + Subnet = subnet.Resource, + Target = target.Resource + }; + + if (builder.ExecutionContext.IsRunMode) + { + // In run mode, we don't want to add the resource to the builder. + return builder.CreateResourceBuilder(resource); + } + + return builder.AddResource(resource); + + static void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) + { + var endpoint = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + (identifier, name) => + { + var resource = PrivateEndpoint.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var azureResource = (AzurePrivateEndpointResource)infrastructure.AspireResource; + var endpoint = new PrivateEndpoint(infrastructure.AspireResource.GetBicepIdentifier()) + { + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // Configure subnet if specified + if (azureResource.Subnet is not null) + { + endpoint.Subnet.Id = azureResource.Subnet.Id.AsProvisioningParameter(infrastructure); + } + + // Configure private link service connection if target resource is specified + if (azureResource.Target is not null) + { + endpoint.PrivateLinkServiceConnections.Add( + new NetworkPrivateLinkServiceConnection + { + Name = $"{azureResource.Name}-connection", + PrivateLinkServiceId = azureResource.Target.Id.AsProvisioningParameter(infrastructure), + GroupIds = [.. azureResource.Target.GetPrivateLinkGroupIds()] + }); + } + + return endpoint; + }); + + var azureResource = (AzurePrivateEndpointResource)infra.AspireResource; + + // Add private DNS zone groups + // TODO: Enable this once Private DNS Zone Groups are supported in the provisioning library + //if (azureResource.PrivateDnsZoneGroups.Count > 0) + //{ + // foreach (var group in azureResource.PrivateDnsZoneGroups) + // { + // var cdkGroup = group.ToProvisioningEntity(infra); + // cdkGroup.Parent = endpoint; + // infra.Add(cdkGroup); + // } + //} + + // Output the Private Endpoint ID for references + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = endpoint.Id + }); + + // We need to output name so it can be referenced by others. + infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = endpoint.Name }); + } + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs new file mode 100644 index 00000000000..78f9532f7e5 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Private Endpoint resource. +/// +/// The name of the resource. +/// Callback to configure the Azure Private Endpoint resource. +public class AzurePrivateEndpointResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + /// + /// Gets the "id" output reference from the Azure Private Endpoint resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + /// Gets or sets the subnet where the private endpoint will be created. + /// + public AzureSubnetResource? Subnet { get; set; } + + /// + /// Gets or sets the target Azure resource to connect via private link. + /// + public IAzurePrivateEndpointTarget? Target { get; set; } + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + // Check if a PrivateEndpoint with the same identifier already exists + var existingEndpoint = resources.OfType().SingleOrDefault(endpoint => endpoint.BicepIdentifier == bicepIdentifier); + + if (existingEndpoint is not null) + { + return existingEndpoint; + } + + // Create and add new resource if it doesn't exist + var endpoint = PrivateEndpoint.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation( + this, + infra, + endpoint)) + { + endpoint.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(endpoint); + return endpoint; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index 33c8fcfcb5e..6093f4f58db 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -7,6 +7,7 @@ using Aspire.Hosting.Azure.Network; using Azure.Provisioning; using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; namespace Aspire.Hosting.Azure; @@ -47,7 +48,7 @@ public string AddressPrefix /// /// Gets the subnet Id output reference. /// - public BicepOutputReference Id => new($"{subnetName}_Id", parent); + public BicepOutputReference Id => new($"{Infrastructure.NormalizeBicepIdentifier(subnetName)}_Id", parent); /// /// Gets the parent Azure Virtual Network resource. @@ -65,7 +66,7 @@ private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgu /// /// Converts the current instance to a provisioning entity. /// - internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra) + internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, ProvisionableResource? dependsOn) { var subnet = new SubnetResource(Infrastructure.NormalizeBicepIdentifier(Name)) { @@ -74,6 +75,11 @@ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra) // TODO: DefaultOutboundAccess = DefaultOutboundAccess }; + if (dependsOn is not null) + { + subnet.DependsOn.Add(dependsOn); + } + if (NatGateway is not null) { subnet.NatGatewayId = NatGateway.Id.AsProvisioningParameter(infra); diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 0a91f3df853..fc226a86e0e 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.Azure; using Azure.Provisioning; using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; namespace Aspire.Hosting; @@ -81,11 +82,17 @@ void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) // Add subnets if (azureResource.Subnets.Count > 0) { + // Chain subnet provisioning to ensure deployment doesn't fail + // due to parallel creation of subnets within the VNet. + // TODO: verify this is necessary + ProvisionableResource? dependsOn = null; foreach (var subnet in azureResource.Subnets) { - var cdkSubnet = subnet.ToProvisioningEntity(infra); + var cdkSubnet = subnet.ToProvisioningEntity(infra, dependsOn); cdkSubnet.Parent = vnet; infra.Add(cdkSubnet); + + dependsOn = cdkSubnet; } } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index fb6baaa9398..cfba08ee003 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs @@ -13,7 +13,8 @@ namespace Aspire.Hosting.Azure; public class AzureBlobStorageResource(string name, AzureStorageResource storage) : Resource(name), IResourceWithConnectionString, IResourceWithParent, - IResourceWithAzureFunctionsConfig + IResourceWithAzureFunctionsConfig, + IAzurePrivateEndpointTarget { /// /// Gets the parent AzureStorageResource of this AzureBlobStorageResource. @@ -81,6 +82,10 @@ void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDiction } } + BicepOutputReference IAzurePrivateEndpointTarget.Id => Parent.Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["blob"]; + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { yield return new("Uri", UriExpression); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 139b06caa03..9c71abdcd7c 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -135,6 +135,7 @@ public static IResourceBuilder AddAzureStorage(this IDistr // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = storageAccount.Name.ToBicepExpression() }); + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = storageAccount.Id }); }; var resource = new AzureStorageResource(name, configureInfrastructure); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs index d300c713654..f8b7c3901c1 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs @@ -57,6 +57,11 @@ public class AzureStorageResource(string name, Action public BicepOutputReference DataLakeEndpoint => new("dataLakeEndpoint", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets the "name" output reference for the resource. /// diff --git a/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs new file mode 100644 index 00000000000..d642d3932f0 --- /dev/null +++ b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure resource that can be connected to via a private endpoint. +/// +public interface IAzurePrivateEndpointTarget : IResource +{ + /// + /// Gets the "id" output reference from the Azure resource. + /// + BicepOutputReference Id { get; } + + /// + /// Gets the group IDs for the private link service connection (e.g., "blob", "file" for storage). + /// + /// A collection of group IDs for the private link service connection. + IEnumerable GetPrivateLinkGroupIds(); +} From f87231000d76d846e542815342ddb8becfb844ef Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 29 Jan 2026 14:32:08 -0600 Subject: [PATCH 05/19] Add Azure.Provisioning.PrivateDns and update to latest --- Directory.Packages.props | 3 ++- .../Aspire.Hosting.Azure.Network.csproj | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8add11acd90..36db9b75c0e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,9 +48,10 @@ - + + diff --git a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj index 7329fab2836..e0d71eab28f 100644 --- a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj +++ b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj @@ -12,8 +12,8 @@ - + From f56f06063c8fb5b1dfdf74df75822b8bbd2b29f5 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 29 Jan 2026 15:56:03 -0600 Subject: [PATCH 06/19] Remove unncessary workaround --- .../AzureStorageEndToEnd.AppHost.csproj | 1 - tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj | 1 - 2 files changed, 2 deletions(-) diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj index 3c3b026798a..9b304242d26 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj @@ -14,7 +14,6 @@ - diff --git a/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj b/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj index a7b982f0493..3560642d7b1 100644 --- a/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj +++ b/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj @@ -76,7 +76,6 @@ - From 272df3d1c1e3d6bbda05ff61a1430b1b836e317f Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 29 Jan 2026 15:58:06 -0600 Subject: [PATCH 07/19] Temporarily checking in generated bicep --- .../api-identity/api-identity.bicep | 17 +++ .../api-roles-storage/api-roles-storage.bicep | 40 +++++++ .../.generated/api/api.bicep | 109 ++++++++++++++++++ .../.generated/env-acr/env-acr.bicep | 17 +++ .../.generated/env/env.bicep | 89 ++++++++++++++ .../.generated/main.bicep | 91 +++++++++++++++ .../private-endpoints-blobs-pe.bicep | 34 ++++++ .../.generated/storage/storage.bicep | 60 ++++++++++ .../.generated/vnet/vnet.bicep | 52 +++++++++ 9 files changed, 509 insertions(+) create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-identity/api-identity.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-roles-storage/api-roles-storage.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api/api.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env-acr/env-acr.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env/env.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/vnet/vnet.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-identity/api-identity.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-identity/api-identity.bicep new file mode 100644 index 00000000000..e2d7908d230 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-identity/api-identity.bicep @@ -0,0 +1,17 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-roles-storage/api-roles-storage.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-roles-storage/api-roles-storage.bicep new file mode 100644 index 00000000000..b3f1171c933 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-roles-storage/api-roles-storage.bicep @@ -0,0 +1,40 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param storage_outputs_name string + +param principalId string + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: storage_outputs_name +} + +resource storage_StorageBlobDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') + principalType: 'ServicePrincipal' + } + scope: storage +} + +resource storage_StorageTableDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') + principalType: 'ServicePrincipal' + } + scope: storage +} + +resource storage_StorageQueueDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') + principalType: 'ServicePrincipal' + } + scope: storage +} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api/api.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api/api.bicep new file mode 100644 index 00000000000..7e3a6527256 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api/api.bicep @@ -0,0 +1,109 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param api_containerimage string + +param api_identity_outputs_id string + +param api_containerport string + +param storage_outputs_blobendpoint string + +param storage_outputs_queueendpoint string + +param api_identity_outputs_clientid string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: true + targetPort: int(api_containerport) + transport: 'http' + } + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: api_containerport + } + { + name: 'ConnectionStrings__blobs' + value: storage_outputs_blobendpoint + } + { + name: 'BLOBS_URI' + value: storage_outputs_blobendpoint + } + { + name: 'ConnectionStrings__myqueue' + value: 'Endpoint=${storage_outputs_queueendpoint};QueueName=my-queue' + } + { + name: 'MYQUEUE_URI' + value: storage_outputs_queueendpoint + } + { + name: 'MYQUEUE_QUEUENAME' + value: 'my-queue' + } + { + name: 'AZURE_CLIENT_ID' + value: api_identity_outputs_clientid + } + { + name: 'AZURE_TOKEN_CREDENTIALS' + value: 'ManagedIdentityCredential' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${api_identity_outputs_id}': { } + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env-acr/env-acr.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env-acr/env-acr.bicep new file mode 100644 index 00000000000..e2bf0b77f72 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env-acr/env-acr.bicep @@ -0,0 +1,17 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env/env.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env/env.bicep new file mode 100644 index 00000000000..314c95831bf --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env/env.bicep @@ -0,0 +1,89 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +param vnet_outputs_subnet1_id string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + vnetConfiguration: { + infrastructureSubnetId: vnet_outputs_subnet1_id + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep new file mode 100644 index 00000000000..ca16f2a4699 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep @@ -0,0 +1,91 @@ +targetScope = 'subscription' + +param resourceGroupName string + +param location string + +param principalId string + +resource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = { + name: resourceGroupName + location: location +} + +module vnet 'vnet/vnet.bicep' = { + name: 'vnet' + scope: rg + params: { + location: location + } +} + +module env_acr 'env-acr/env-acr.bicep' = { + name: 'env-acr' + scope: rg + params: { + location: location + } +} + +module env 'env/env.bicep' = { + name: 'env' + scope: rg + params: { + location: location + env_acr_outputs_name: env_acr.outputs.name + vnet_outputs_subnet1_id: vnet.outputs.subnet1_Id + userPrincipalId: principalId + } +} + +module storage 'storage/storage.bicep' = { + name: 'storage' + scope: rg + params: { + location: location + } +} + +module private_endpoints_blobs_pe 'private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep' = { + name: 'private-endpoints-blobs-pe' + scope: rg + params: { + location: location + vnet_outputs_private_endpoints_id: vnet.outputs.private_endpoints_Id + storage_outputs_id: storage.outputs.id + } +} + +module api_identity 'api-identity/api-identity.bicep' = { + name: 'api-identity' + scope: rg + params: { + location: location + } +} + +module api_roles_storage 'api-roles-storage/api-roles-storage.bicep' = { + name: 'api-roles-storage' + scope: rg + params: { + location: location + storage_outputs_name: storage.outputs.name + principalId: api_identity.outputs.principalId + } +} + +output env_AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN + +output env_AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID + +output env_AZURE_CONTAINER_REGISTRY_ENDPOINT string = env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT + +output env_AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID + +output api_identity_id string = api_identity.outputs.id + +output storage_blobEndpoint string = storage.outputs.blobEndpoint + +output storage_queueEndpoint string = storage.outputs.queueEndpoint + +output api_identity_clientId string = api_identity.outputs.clientId \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep new file mode 100644 index 00000000000..115ed683dcd --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep @@ -0,0 +1,34 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_private_endpoints_id string + +param storage_outputs_id string + +resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('private_endpoints_blobs_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'private-endpoints-blobs-pe-connection' + } + ] + subnet: { + id: vnet_outputs_private_endpoints_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-blobs-pe' + } +} + +output id string = private_endpoints_blobs_pe.id + +output name string = private_endpoints_blobs_pe.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep new file mode 100644 index 00000000000..a6bbd75eee9 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep @@ -0,0 +1,60 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('storage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: false + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Allow' + } + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { + name: 'default' + parent: storage +} + +resource mycontainer1 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { + name: 'test-container-1' + parent: blobs +} + +resource mycontainer2 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { + name: 'test-container-2' + parent: blobs +} + +resource queues 'Microsoft.Storage/storageAccounts/queueServices@2024-01-01' = { + name: 'default' + parent: storage +} + +resource myqueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2024-01-01' = { + name: 'my-queue' + parent: queues +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + +output queueEndpoint string = storage.properties.primaryEndpoints.queue + +output tableEndpoint string = storage.properties.primaryEndpoints.table + +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/vnet/vnet.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/vnet/vnet.bicep new file mode 100644 index 00000000000..1c7d49a0312 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/vnet/vnet.bicep @@ -0,0 +1,52 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('vnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'vnet' + } +} + +resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'subnet1' + properties: { + addressPrefix: '10.0.1.0/24' + delegations: [ + { + properties: { + serviceName: 'Microsoft.App/environments' + } + name: 'ContainerAppsDelegation' + } + ] + } + parent: vnet +} + +resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'private-endpoints' + properties: { + addressPrefix: '10.0.2.0/24' + } + parent: vnet + dependsOn: [ + subnet1 + ] +} + +output subnet1_Id string = subnet1.id + +output private_endpoints_Id string = private_endpoints.id + +output id string = vnet.id + +output name string = vnet.name \ No newline at end of file From d49cfa17bfdd481f29efa3a4b2dc08df42510729 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 30 Jan 2026 16:25:35 -0600 Subject: [PATCH 08/19] Finish implementing private endpoints. Gets AzureStorageEndToEnd working with a vnet and private endpoints. --- .../.generated/main.bicep | 12 ++ .../private-endpoints-blobs-pe.bicep | 40 ++++++ .../private-endpoints-queues-pe.bicep | 74 +++++++++++ .../.generated/storage/storage.bicep | 3 +- .../AzureStorageEndToEnd.AppHost/Program.cs | 3 + .../AzurePrivateEndpointExtensions.cs | 122 +++++++++++++----- .../AzureBlobStorageResource.cs | 2 + .../AzureQueueStorageResource.cs | 9 +- .../AzureStorageExtensions.cs | 52 +++++--- .../IAzurePrivateEndpointTarget.cs | 6 + .../PrivateEndpointTargetAnnotation.cs | 14 ++ ...dAzureStorageViaPublishMode.verified.bicep | 2 + ...AccessOverridesDefaultFalse.verified.bicep | 2 + ...s.AddAzureStorageViaRunMode.verified.bicep | 2 + ...AccessOverridesDefaultFalse.verified.bicep | 2 + ...sts.ResourceNamesBicepValid.verified.bicep | 2 + 16 files changed, 293 insertions(+), 54 deletions(-) create mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-queues-pe/private-endpoints-queues-pe.bicep create mode 100644 src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep index ca16f2a4699..e2b7feaca5e 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep @@ -51,6 +51,18 @@ module private_endpoints_blobs_pe 'private-endpoints-blobs-pe/private-endpoints- scope: rg params: { location: location + vnet_outputs_id: vnet.outputs.id + vnet_outputs_private_endpoints_id: vnet.outputs.private_endpoints_Id + storage_outputs_id: storage.outputs.id + } +} + +module private_endpoints_queues_pe 'private-endpoints-queues-pe/private-endpoints-queues-pe.bicep' = { + name: 'private-endpoints-queues-pe' + scope: rg + params: { + location: location + vnet_outputs_id: vnet.outputs.id vnet_outputs_private_endpoints_id: vnet.outputs.private_endpoints_Id storage_outputs_id: storage.outputs.id } diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep index 115ed683dcd..ebbacec96f4 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep @@ -1,10 +1,35 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location +param vnet_outputs_id string + param vnet_outputs_private_endpoints_id string param storage_outputs_id string +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.blob.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'private-endpoints-blobs-pe-dns' + } +} + +resource privatelink_blob_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-blobs-pe-vnetlink' + } + parent: privatelink_blob_core_windows_net +} + resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { name: take('private_endpoints_blobs_pe-${uniqueString(resourceGroup().id)}', 64) location: location @@ -29,6 +54,21 @@ resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05- } } +resource private_endpoints_blobs_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: private_endpoints_blobs_pe +} + output id string = private_endpoints_blobs_pe.id output name string = private_endpoints_blobs_pe.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-queues-pe/private-endpoints-queues-pe.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-queues-pe/private-endpoints-queues-pe.bicep new file mode 100644 index 00000000000..390b131e2f1 --- /dev/null +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-queues-pe/private-endpoints-queues-pe.bicep @@ -0,0 +1,74 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_id string + +param vnet_outputs_private_endpoints_id string + +param storage_outputs_id string + +resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.queue.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'private-endpoints-queues-pe-dns' + } +} + +resource privatelink_queue_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-queues-pe-vnetlink' + } + parent: privatelink_queue_core_windows_net +} + +resource private_endpoints_queues_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('private_endpoints_queues_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'queue' + ] + } + name: 'private-endpoints-queues-pe-connection' + } + ] + subnet: { + id: vnet_outputs_private_endpoints_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-queues-pe' + } +} + +resource private_endpoints_queues_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_queue_core_windows_net' + properties: { + privateDnsZoneId: privatelink_queue_core_windows_net.id + } + } + ] + } + parent: private_endpoints_queues_pe +} + +output id string = private_endpoints_queues_pe.id + +output name string = private_endpoints_queues_pe.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep index a6bbd75eee9..f963143c3b7 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep @@ -14,8 +14,9 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { isHnsEnabled: false minimumTlsVersion: 'TLS1_2' networkAcls: { - defaultAction: 'Allow' + defaultAction: 'Deny' } + publicNetworkAccess: 'Disabled' } tags: { 'aspire-resource-name': 'storage' diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index 5c0dd8a549e..c71abf7656f 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -37,6 +37,9 @@ builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, blobs); +var queues = storage.AddQueues("queues"); +builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, queues); + var myqueue = storage.AddQueue("myqueue", queueName: "my-queue"); builder.AddProject("api") diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index 8400200e480..82f28902e3d 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -5,6 +5,8 @@ using Aspire.Hosting.Azure; using Azure.Provisioning; using Azure.Provisioning.Network; +using Azure.Core; +using Azure.Provisioning.PrivateDns; namespace Aspire.Hosting; @@ -18,14 +20,29 @@ public static class AzurePrivateEndpointExtensions /// /// The builder for the distributed application. /// The subnet associated with the private endpoint. - /// The name of the Azure Private Endpoint resource. + /// The target Azure resource to connect via private link. /// A reference to the . + /// + /// + /// This method automatically creates the Private DNS Zone, VNet Link, and DNS Zone Group + /// required for private endpoint DNS resolution. + /// + /// + /// When a private endpoint is added, the target resource (or its parent) is automatically + /// configured to deny public network access. To override this behavior, use + /// to customize + /// the network settings. + /// + /// public static IResourceBuilder AddAzurePrivateEndpoint( this IDistributedApplicationBuilder builder, IResourceBuilder subnet, IResourceBuilder target) { ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(subnet); + ArgumentNullException.ThrowIfNull(target); + var name = $"{subnet.Resource.Name}-{target.Resource.Name}-pe"; var resource = new AzurePrivateEndpointResource(name, ConfigurePrivateEndpoint) @@ -34,6 +51,19 @@ public static IResourceBuilder AddAzurePrivateEndp Target = target.Resource }; + // Add annotation to the target's parent (e.g., storage account) to signal + // that it should deny public network access + var targetResource = target.Resource; + if (targetResource is IResourceWithParent parentedResource) + { + parentedResource.Parent.Annotations.Add(new PrivateEndpointTargetAnnotation()); + } + else + { + // If the target itself is the top-level resource, annotate it directly + targetResource.Annotations.Add(new PrivateEndpointTargetAnnotation()); + } + if (builder.ExecutionContext.IsRunMode) { // In run mode, we don't want to add the resource to the builder. @@ -42,8 +72,36 @@ public static IResourceBuilder AddAzurePrivateEndp return builder.AddResource(resource); - static void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) + void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) { + var azureResource = (AzurePrivateEndpointResource)infra.AspireResource; + + // Create Private DNS Zone for the target service + var dnsZoneName = azureResource.Target!.GetPrivateDnsZoneName(); + var dnsZoneIdentifier = Infrastructure.NormalizeBicepIdentifier(dnsZoneName.Replace(".", "_")); + + var privateDnsZone = new PrivateDnsZone(dnsZoneIdentifier) + { + Name = dnsZoneName, + Location = new AzureLocation("global"), + Tags = { { "aspire-resource-name", $"{azureResource.Name}-dns" } } + }; + infra.Add(privateDnsZone); + + // Create VNet Link to connect DNS zone to the VNet + var vnetLinkIdentifier = $"{dnsZoneIdentifier}_vnetlink"; + var vnetLink = new VirtualNetworkLink(vnetLinkIdentifier) + { + Name = $"{azureResource.Subnet!.Parent.Name}-link", + Location = new AzureLocation("global"), + RegistrationEnabled = false, + VirtualNetworkId = azureResource.Subnet.Parent.Id.AsProvisioningParameter(infra), + Tags = { { "aspire-resource-name", $"{azureResource.Name}-vnetlink" } } + }; + vnetLink.Parent = privateDnsZone; + infra.Add(vnetLink); + + // Create the Private Endpoint var endpoint = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, (identifier, name) => { @@ -53,46 +111,42 @@ static void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) }, (infrastructure) => { - var azureResource = (AzurePrivateEndpointResource)infrastructure.AspireResource; - var endpoint = new PrivateEndpoint(infrastructure.AspireResource.GetBicepIdentifier()) + var pe = new PrivateEndpoint(infrastructure.AspireResource.GetBicepIdentifier()) { Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } }; - // Configure subnet if specified - if (azureResource.Subnet is not null) - { - endpoint.Subnet.Id = azureResource.Subnet.Id.AsProvisioningParameter(infrastructure); - } + // Configure subnet + pe.Subnet.Id = azureResource.Subnet.Id.AsProvisioningParameter(infrastructure); - // Configure private link service connection if target resource is specified - if (azureResource.Target is not null) - { - endpoint.PrivateLinkServiceConnections.Add( - new NetworkPrivateLinkServiceConnection - { - Name = $"{azureResource.Name}-connection", - PrivateLinkServiceId = azureResource.Target.Id.AsProvisioningParameter(infrastructure), - GroupIds = [.. azureResource.Target.GetPrivateLinkGroupIds()] - }); - } + // Configure private link service connection + pe.PrivateLinkServiceConnections.Add( + new NetworkPrivateLinkServiceConnection + { + Name = $"{azureResource.Name}-connection", + PrivateLinkServiceId = azureResource.Target.Id.AsProvisioningParameter(infrastructure), + GroupIds = [.. azureResource.Target.GetPrivateLinkGroupIds()] + }); - return endpoint; + return pe; }); - var azureResource = (AzurePrivateEndpointResource)infra.AspireResource; - - // Add private DNS zone groups - // TODO: Enable this once Private DNS Zone Groups are supported in the provisioning library - //if (azureResource.PrivateDnsZoneGroups.Count > 0) - //{ - // foreach (var group in azureResource.PrivateDnsZoneGroups) - // { - // var cdkGroup = group.ToProvisioningEntity(infra); - // cdkGroup.Parent = endpoint; - // infra.Add(cdkGroup); - // } - //} + // Create DNS Zone Group on the Private Endpoint + var dnsZoneGroupIdentifier = $"{endpoint.BicepIdentifier}_dnsgroup"; + var dnsZoneGroup = new PrivateDnsZoneGroup(dnsZoneGroupIdentifier) + { + Name = "default", + PrivateDnsZoneConfigs = + { + new PrivateDnsZoneConfig + { + Name = dnsZoneIdentifier, + PrivateDnsZoneId = privateDnsZone.Id + } + } + }; + dnsZoneGroup.Parent = endpoint; + infra.Add(dnsZoneGroup); // Output the Private Endpoint ID for references infra.Add(new ProvisioningOutput("id", typeof(string)) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index cfba08ee003..45cdadd2dbf 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs @@ -86,6 +86,8 @@ void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDiction IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["blob"]; + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.blob.core.windows.net"; + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { yield return new("Uri", UriExpression); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs index b53e5df530e..36dc1c79562 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs @@ -13,7 +13,8 @@ namespace Aspire.Hosting.Azure; public class AzureQueueStorageResource(string name, AzureStorageResource storage) : Resource(name), IResourceWithConnectionString, IResourceWithParent, - IResourceWithAzureFunctionsConfig + IResourceWithAzureFunctionsConfig, + IAzurePrivateEndpointTarget { /// /// Gets the parent AzureStorageResource of this AzureQueueStorageResource. @@ -74,6 +75,12 @@ void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDiction } } + BicepOutputReference IAzurePrivateEndpointTarget.Id => Parent.Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["queue"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.queue.core.windows.net"; + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { yield return new("Uri", UriExpression); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 9c71abdcd7c..c0067c8028c 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -53,26 +53,42 @@ public static IResourceBuilder AddAzureStorage(this IDistr resource.Name = name; return resource; }, - (infrastructure) => new StorageAccount(infrastructure.AspireResource.GetBicepIdentifier()) + (infrastructure) => { - Kind = StorageKind.StorageV2, - AccessTier = StorageAccountAccessTier.Hot, - Sku = new StorageSku() { Name = StorageSkuName.StandardGrs }, - IsHnsEnabled = azureResource.IsHnsEnabled, - NetworkRuleSet = new StorageAccountNetworkRuleSet() + // Check if this storage has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.Annotations.OfType().Any(); + + var storageAccount = new StorageAccount(infrastructure.AspireResource.GetBicepIdentifier()) + { + Kind = StorageKind.StorageV2, + AccessTier = StorageAccountAccessTier.Hot, + Sku = new StorageSku() { Name = StorageSkuName.StandardGrs }, + IsHnsEnabled = azureResource.IsHnsEnabled, + NetworkRuleSet = new StorageAccountNetworkRuleSet() + { + // When using private endpoints, deny public access. + // Otherwise, we need to allow it since Azure Storage does not list ACA + // as one of the resource types in which the AzureServices firewall policy works. + DefaultAction = hasPrivateEndpoint + ? StorageNetworkDefaultAction.Deny + : StorageNetworkDefaultAction.Allow + }, + // Set the minimum TLS version to 1.2 to ensure resources provisioned are compliant + // with the pending deprecation of TLS 1.0 and 1.1. + MinimumTlsVersion = StorageMinimumTlsVersion.Tls1_2, + // Disable shared key access to the storage account as managed identity is configured + // to access the storage account by default. + AllowSharedKeyAccess = false, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // When using private endpoints, completely disable public network access. + if (hasPrivateEndpoint) { - // Unfortunately Azure Storage does not list ACA as one of the resource types in which - // the AzureServices firewall policy works. This means that we need this Azure Storage - // account to have its default action set to Allow. - DefaultAction = StorageNetworkDefaultAction.Allow - }, - // Set the minimum TLS version to 1.2 to ensure resources provisioned are compliant - // with the pending deprecation of TLS 1.0 and 1.1. - MinimumTlsVersion = StorageMinimumTlsVersion.Tls1_2, - // Disable shared key access to the storage account as managed identity is configured - // to access the storage account by default. - AllowSharedKeyAccess = false, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + storageAccount.PublicNetworkAccess = StoragePublicNetworkAccess.Disabled; + } + + return storageAccount; }); if (azureResource.BlobContainers.Count > 0 || azureResource.DataLakeFileSystems.Count > 0) diff --git a/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs index d642d3932f0..780a2e5ce1b 100644 --- a/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs +++ b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs @@ -20,4 +20,10 @@ public interface IAzurePrivateEndpointTarget : IResource /// /// A collection of group IDs for the private link service connection. IEnumerable GetPrivateLinkGroupIds(); + + /// + /// Gets the private DNS zone name for this resource type (e.g., "privatelink.blob.core.windows.net" for blob storage). + /// + /// The private DNS zone name for the private endpoint. + string GetPrivateDnsZoneName(); } diff --git a/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs b/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs new file mode 100644 index 00000000000..08445faabf0 --- /dev/null +++ b/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// An annotation that indicates a resource is the target of a private endpoint. +/// When this annotation is present, the resource should be configured to deny public network access. +/// +public sealed class PrivateEndpointTargetAnnotation : IResourceAnnotation +{ +} diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishMode.verified.bicep index 9bb466a01be..7bff864ed91 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishMode.verified.bicep @@ -33,3 +33,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep index e7f3b9d0f28..006c7cbb14d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep @@ -33,3 +33,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunMode.verified.bicep index 9bb466a01be..7bff864ed91 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunMode.verified.bicep @@ -33,3 +33,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep index e7f3b9d0f28..006c7cbb14d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep @@ -33,3 +33,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep index bb552f5c909..e21d1603196 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep @@ -51,3 +51,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file From 4b96a9829ffa0d205b49fef66fb8565b1e93b244 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 30 Jan 2026 16:27:59 -0600 Subject: [PATCH 09/19] Remove .generated folder and update the generated manifest for the Storage playground app. --- .../api-identity/api-identity.bicep | 17 --- .../api-roles-storage/api-roles-storage.bicep | 40 ------- .../.generated/api/api.bicep | 109 ------------------ .../.generated/env/env.bicep | 89 -------------- .../.generated/main.bicep | 103 ----------------- .../private-endpoints-blobs-pe.bicep | 74 ------------ .../.generated/storage/storage.bicep | 61 ---------- .../.generated/vnet/vnet.bicep | 52 --------- .../api-containerapp.module.bicep | 20 ++-- .../aspire-manifest.json | 22 +++- .../env-acr.bicep => env-acr.module.bicep} | 0 .../env.module.bicep | 11 +- .../private-endpoints-blobs-pe.module.bicep | 42 ++++++- ... private-endpoints-queues-pe.module.bicep} | 0 .../storage.module.bicep | 6 +- .../vnet.module.bicep | 8 +- 16 files changed, 85 insertions(+), 569 deletions(-) delete mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-identity/api-identity.bicep delete mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-roles-storage/api-roles-storage.bicep delete mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api/api.bicep delete mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env/env.bicep delete mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep delete mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep delete mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep delete mode 100644 playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/vnet/vnet.bicep rename playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/{.generated/env-acr/env-acr.bicep => env-acr.module.bicep} (100%) rename playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/{.generated/private-endpoints-queues-pe/private-endpoints-queues-pe.bicep => private-endpoints-queues-pe.module.bicep} (100%) diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-identity/api-identity.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-identity/api-identity.bicep deleted file mode 100644 index e2d7908d230..00000000000 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-identity/api-identity.bicep +++ /dev/null @@ -1,17 +0,0 @@ -@description('The location for the resource(s) to be deployed.') -param location string = resourceGroup().location - -resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) - location: location -} - -output id string = api_identity.id - -output clientId string = api_identity.properties.clientId - -output principalId string = api_identity.properties.principalId - -output principalName string = api_identity.name - -output name string = api_identity.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-roles-storage/api-roles-storage.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-roles-storage/api-roles-storage.bicep deleted file mode 100644 index b3f1171c933..00000000000 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api-roles-storage/api-roles-storage.bicep +++ /dev/null @@ -1,40 +0,0 @@ -@description('The location for the resource(s) to be deployed.') -param location string = resourceGroup().location - -param storage_outputs_name string - -param principalId string - -resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { - name: storage_outputs_name -} - -resource storage_StorageBlobDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')) - properties: { - principalId: principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') - principalType: 'ServicePrincipal' - } - scope: storage -} - -resource storage_StorageTableDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3')) - properties: { - principalId: principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') - principalType: 'ServicePrincipal' - } - scope: storage -} - -resource storage_StorageQueueDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')) - properties: { - principalId: principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') - principalType: 'ServicePrincipal' - } - scope: storage -} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api/api.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api/api.bicep deleted file mode 100644 index 7e3a6527256..00000000000 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/api/api.bicep +++ /dev/null @@ -1,109 +0,0 @@ -@description('The location for the resource(s) to be deployed.') -param location string = resourceGroup().location - -param env_outputs_azure_container_apps_environment_default_domain string - -param env_outputs_azure_container_apps_environment_id string - -param api_containerimage string - -param api_identity_outputs_id string - -param api_containerport string - -param storage_outputs_blobendpoint string - -param storage_outputs_queueendpoint string - -param api_identity_outputs_clientid string - -param env_outputs_azure_container_registry_endpoint string - -param env_outputs_azure_container_registry_managed_identity_id string - -resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { - name: 'api' - location: location - properties: { - configuration: { - activeRevisionsMode: 'Single' - ingress: { - external: true - targetPort: int(api_containerport) - transport: 'http' - } - registries: [ - { - server: env_outputs_azure_container_registry_endpoint - identity: env_outputs_azure_container_registry_managed_identity_id - } - ] - runtime: { - dotnet: { - autoConfigureDataProtection: true - } - } - } - environmentId: env_outputs_azure_container_apps_environment_id - template: { - containers: [ - { - image: api_containerimage - name: 'api' - env: [ - { - name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' - value: 'in_memory' - } - { - name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' - value: 'true' - } - { - name: 'HTTP_PORTS' - value: api_containerport - } - { - name: 'ConnectionStrings__blobs' - value: storage_outputs_blobendpoint - } - { - name: 'BLOBS_URI' - value: storage_outputs_blobendpoint - } - { - name: 'ConnectionStrings__myqueue' - value: 'Endpoint=${storage_outputs_queueendpoint};QueueName=my-queue' - } - { - name: 'MYQUEUE_URI' - value: storage_outputs_queueendpoint - } - { - name: 'MYQUEUE_QUEUENAME' - value: 'my-queue' - } - { - name: 'AZURE_CLIENT_ID' - value: api_identity_outputs_clientid - } - { - name: 'AZURE_TOKEN_CREDENTIALS' - value: 'ManagedIdentityCredential' - } - ] - } - ] - scale: { - minReplicas: 1 - } - } - } - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${api_identity_outputs_id}': { } - '${env_outputs_azure_container_registry_managed_identity_id}': { } - } - } -} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env/env.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env/env.bicep deleted file mode 100644 index 314c95831bf..00000000000 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env/env.bicep +++ /dev/null @@ -1,89 +0,0 @@ -@description('The location for the resource(s) to be deployed.') -param location string = resourceGroup().location - -param userPrincipalId string = '' - -param tags object = { } - -param env_acr_outputs_name string - -param vnet_outputs_subnet1_id string - -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - -resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { - name: env_acr_outputs_name -} - -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - -resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { - name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) - location: location - properties: { - sku: { - name: 'PerGB2018' - } - } - tags: tags -} - -resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { - name: take('env${uniqueString(resourceGroup().id)}', 24) - location: location - properties: { - appLogsConfiguration: { - destination: 'log-analytics' - logAnalyticsConfiguration: { - customerId: env_law.properties.customerId - sharedKey: env_law.listKeys().primarySharedKey - } - } - vnetConfiguration: { - infrastructureSubnetId: vnet_outputs_subnet1_id - } - workloadProfiles: [ - { - name: 'consumption' - workloadProfileType: 'Consumption' - } - ] - } - tags: tags -} - -resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { - name: 'aspire-dashboard' - properties: { - componentType: 'AspireDashboard' - } - parent: env -} - -output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name - -output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id - -output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name - -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer - -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id - -output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name - -output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id - -output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep deleted file mode 100644 index e2b7feaca5e..00000000000 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/main.bicep +++ /dev/null @@ -1,103 +0,0 @@ -targetScope = 'subscription' - -param resourceGroupName string - -param location string - -param principalId string - -resource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = { - name: resourceGroupName - location: location -} - -module vnet 'vnet/vnet.bicep' = { - name: 'vnet' - scope: rg - params: { - location: location - } -} - -module env_acr 'env-acr/env-acr.bicep' = { - name: 'env-acr' - scope: rg - params: { - location: location - } -} - -module env 'env/env.bicep' = { - name: 'env' - scope: rg - params: { - location: location - env_acr_outputs_name: env_acr.outputs.name - vnet_outputs_subnet1_id: vnet.outputs.subnet1_Id - userPrincipalId: principalId - } -} - -module storage 'storage/storage.bicep' = { - name: 'storage' - scope: rg - params: { - location: location - } -} - -module private_endpoints_blobs_pe 'private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep' = { - name: 'private-endpoints-blobs-pe' - scope: rg - params: { - location: location - vnet_outputs_id: vnet.outputs.id - vnet_outputs_private_endpoints_id: vnet.outputs.private_endpoints_Id - storage_outputs_id: storage.outputs.id - } -} - -module private_endpoints_queues_pe 'private-endpoints-queues-pe/private-endpoints-queues-pe.bicep' = { - name: 'private-endpoints-queues-pe' - scope: rg - params: { - location: location - vnet_outputs_id: vnet.outputs.id - vnet_outputs_private_endpoints_id: vnet.outputs.private_endpoints_Id - storage_outputs_id: storage.outputs.id - } -} - -module api_identity 'api-identity/api-identity.bicep' = { - name: 'api-identity' - scope: rg - params: { - location: location - } -} - -module api_roles_storage 'api-roles-storage/api-roles-storage.bicep' = { - name: 'api-roles-storage' - scope: rg - params: { - location: location - storage_outputs_name: storage.outputs.name - principalId: api_identity.outputs.principalId - } -} - -output env_AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN - -output env_AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID - -output env_AZURE_CONTAINER_REGISTRY_ENDPOINT string = env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT - -output env_AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID - -output api_identity_id string = api_identity.outputs.id - -output storage_blobEndpoint string = storage.outputs.blobEndpoint - -output storage_queueEndpoint string = storage.outputs.queueEndpoint - -output api_identity_clientId string = api_identity.outputs.clientId \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep deleted file mode 100644 index ebbacec96f4..00000000000 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-blobs-pe/private-endpoints-blobs-pe.bicep +++ /dev/null @@ -1,74 +0,0 @@ -@description('The location for the resource(s) to be deployed.') -param location string = resourceGroup().location - -param vnet_outputs_id string - -param vnet_outputs_private_endpoints_id string - -param storage_outputs_id string - -resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: 'privatelink.blob.core.windows.net' - location: 'global' - tags: { - 'aspire-resource-name': 'private-endpoints-blobs-pe-dns' - } -} - -resource privatelink_blob_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'vnet-link' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet_outputs_id - } - } - tags: { - 'aspire-resource-name': 'private-endpoints-blobs-pe-vnetlink' - } - parent: privatelink_blob_core_windows_net -} - -resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { - name: take('private_endpoints_blobs_pe-${uniqueString(resourceGroup().id)}', 64) - location: location - properties: { - privateLinkServiceConnections: [ - { - properties: { - privateLinkServiceId: storage_outputs_id - groupIds: [ - 'blob' - ] - } - name: 'private-endpoints-blobs-pe-connection' - } - ] - subnet: { - id: vnet_outputs_private_endpoints_id - } - } - tags: { - 'aspire-resource-name': 'private-endpoints-blobs-pe' - } -} - -resource private_endpoints_blobs_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { - name: 'default' - properties: { - privateDnsZoneConfigs: [ - { - name: 'privatelink_blob_core_windows_net' - properties: { - privateDnsZoneId: privatelink_blob_core_windows_net.id - } - } - ] - } - parent: private_endpoints_blobs_pe -} - -output id string = private_endpoints_blobs_pe.id - -output name string = private_endpoints_blobs_pe.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep deleted file mode 100644 index f963143c3b7..00000000000 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/storage/storage.bicep +++ /dev/null @@ -1,61 +0,0 @@ -@description('The location for the resource(s) to be deployed.') -param location string = resourceGroup().location - -resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { - name: take('storage${uniqueString(resourceGroup().id)}', 24) - kind: 'StorageV2' - location: location - sku: { - name: 'Standard_GRS' - } - properties: { - accessTier: 'Hot' - allowSharedKeyAccess: false - isHnsEnabled: false - minimumTlsVersion: 'TLS1_2' - networkAcls: { - defaultAction: 'Deny' - } - publicNetworkAccess: 'Disabled' - } - tags: { - 'aspire-resource-name': 'storage' - } -} - -resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { - name: 'default' - parent: storage -} - -resource mycontainer1 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { - name: 'test-container-1' - parent: blobs -} - -resource mycontainer2 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { - name: 'test-container-2' - parent: blobs -} - -resource queues 'Microsoft.Storage/storageAccounts/queueServices@2024-01-01' = { - name: 'default' - parent: storage -} - -resource myqueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2024-01-01' = { - name: 'my-queue' - parent: queues -} - -output blobEndpoint string = storage.properties.primaryEndpoints.blob - -output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs - -output queueEndpoint string = storage.properties.primaryEndpoints.queue - -output tableEndpoint string = storage.properties.primaryEndpoints.table - -output name string = storage.name - -output id string = storage.id \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/vnet/vnet.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/vnet/vnet.bicep deleted file mode 100644 index 1c7d49a0312..00000000000 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/vnet/vnet.bicep +++ /dev/null @@ -1,52 +0,0 @@ -@description('The location for the resource(s) to be deployed.') -param location string = resourceGroup().location - -resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { - name: take('vnet-${uniqueString(resourceGroup().id)}', 64) - properties: { - addressSpace: { - addressPrefixes: [ - '10.0.0.0/16' - ] - } - } - location: location - tags: { - 'aspire-resource-name': 'vnet' - } -} - -resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { - name: 'subnet1' - properties: { - addressPrefix: '10.0.1.0/24' - delegations: [ - { - properties: { - serviceName: 'Microsoft.App/environments' - } - name: 'ContainerAppsDelegation' - } - ] - } - parent: vnet -} - -resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { - name: 'private-endpoints' - properties: { - addressPrefix: '10.0.2.0/24' - } - parent: vnet - dependsOn: [ - subnet1 - ] -} - -output subnet1_Id string = subnet1.id - -output private_endpoints_Id string = private_endpoints.id - -output id string = vnet.id - -output name string = vnet.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep index 75fe2785fce..7e3a6527256 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep @@ -51,14 +51,6 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { image: api_containerimage name: 'api' env: [ - { - name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' - value: 'true' - } - { - name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' - value: 'true' - } { name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' value: 'in_memory' @@ -75,10 +67,22 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { name: 'ConnectionStrings__blobs' value: storage_outputs_blobendpoint } + { + name: 'BLOBS_URI' + value: storage_outputs_blobendpoint + } { name: 'ConnectionStrings__myqueue' value: 'Endpoint=${storage_outputs_queueendpoint};QueueName=my-queue' } + { + name: 'MYQUEUE_URI' + value: storage_outputs_queueendpoint + } + { + name: 'MYQUEUE_QUEUENAME' + value: 'my-queue' + } { name: 'AZURE_CLIENT_ID' value: api_identity_outputs_clientid diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json index f04a34cf0f2..f4acf50d1a0 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json @@ -5,10 +5,15 @@ "type": "azure.bicep.v0", "path": "vnet.module.bicep" }, + "env-acr": { + "type": "azure.bicep.v0", + "path": "env-acr.module.bicep" + }, "env": { "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_outputs_name": "{env-acr.outputs.name}", "vnet_outputs_subnet1_id": "{vnet.outputs.subnet1_Id}", "userPrincipalId": "" } @@ -37,6 +42,20 @@ "type": "azure.bicep.v0", "path": "private-endpoints-blobs-pe.module.bicep", "params": { + "vnet_outputs_id": "{vnet.outputs.id}", + "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", + "storage_outputs_id": "{storage.outputs.id}" + } + }, + "queues": { + "type": "value.v0", + "connectionString": "{storage.outputs.queueEndpoint}" + }, + "private-endpoints-queues-pe": { + "type": "azure.bicep.v0", + "path": "private-endpoints-queues-pe.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}", "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", "storage_outputs_id": "{storage.outputs.id}" } @@ -74,9 +93,6 @@ "HTTP_PORTS": "{api.bindings.http.targetPort}", "ConnectionStrings__blobs": "{blobs.connectionString}", "BLOBS_URI": "{storage.outputs.blobEndpoint}", - "ConnectionStrings__foocontainer": "{foocontainer.connectionString}", - "FOOCONTAINER_URI": "{storage2.outputs.blobEndpoint}", - "FOOCONTAINER_BLOBCONTAINERNAME": "foo-container", "ConnectionStrings__myqueue": "{myqueue.connectionString}", "MYQUEUE_URI": "{storage.outputs.queueEndpoint}", "MYQUEUE_QUEUENAME": "my-queue" diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env-acr/env-acr.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env-acr.module.bicep similarity index 100% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/env-acr/env-acr.bicep rename to playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env-acr.module.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep index 68d002d6529..314c95831bf 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep @@ -5,6 +5,8 @@ param userPrincipalId string = '' param tags object = { } +param env_acr_outputs_name string + param vnet_outputs_subnet1_id string resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { @@ -13,13 +15,8 @@ resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = tags: tags } -resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { - name: take('envacr${uniqueString(resourceGroup().id)}', 50) - location: location - sku: { - name: 'Basic' - } - tags: tags +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name } resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep index e01a90354a1..ebbacec96f4 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep @@ -1,11 +1,36 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location +param vnet_outputs_id string + param vnet_outputs_private_endpoints_id string param storage_outputs_id string -resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-01-01' = { +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.blob.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'private-endpoints-blobs-pe-dns' + } +} + +resource privatelink_blob_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-blobs-pe-vnetlink' + } + parent: privatelink_blob_core_windows_net +} + +resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { name: take('private_endpoints_blobs_pe-${uniqueString(resourceGroup().id)}', 64) location: location properties: { @@ -29,6 +54,21 @@ resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-01- } } +resource private_endpoints_blobs_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: private_endpoints_blobs_pe +} + output id string = private_endpoints_blobs_pe.id output name string = private_endpoints_blobs_pe.name \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-queues-pe/private-endpoints-queues-pe.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep similarity index 100% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/.generated/private-endpoints-queues-pe/private-endpoints-queues-pe.bicep rename to playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep index 01065520a13..f963143c3b7 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep @@ -11,10 +11,12 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { properties: { accessTier: 'Hot' allowSharedKeyAccess: false + isHnsEnabled: false minimumTlsVersion: 'TLS1_2' networkAcls: { - defaultAction: 'Allow' + defaultAction: 'Deny' } + publicNetworkAccess: 'Disabled' } tags: { 'aspire-resource-name': 'storage' @@ -48,6 +50,8 @@ resource myqueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2024-01 output blobEndpoint string = storage.properties.primaryEndpoints.blob +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep index 8d37a10f9ab..1c7d49a0312 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep @@ -1,7 +1,7 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = { +resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { name: take('vnet-${uniqueString(resourceGroup().id)}', 64) properties: { addressSpace: { @@ -16,10 +16,10 @@ resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = { } } -resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = { +resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { name: 'subnet1' properties: { - addressPrefix: '10.0.0.0/23' + addressPrefix: '10.0.1.0/24' delegations: [ { properties: { @@ -32,7 +32,7 @@ resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = { parent: vnet } -resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = { +resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { name: 'private-endpoints' properties: { addressPrefix: '10.0.2.0/24' From 060aac61c47f94ca603945f768f5c3bfea3e3e4d Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 30 Jan 2026 17:23:50 -0600 Subject: [PATCH 10/19] Make new Aspire.Hosting.Azure APIs experimental --- .../AzureStorageEndToEnd.AppHost/Program.cs | 2 ++ .../AzurePrivateEndpointExtensions.cs | 2 ++ .../AzurePrivateEndpointResource.cs | 2 ++ src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs | 2 ++ src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs | 2 ++ src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs | 2 ++ src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs | 2 ++ src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs | 2 ++ 8 files changed, 16 insertions(+) diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index c71abf7656f..dc1f5ec8214 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.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.Azure.Network; using Azure.Provisioning.AppContainers; diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index 82f28902e3d..0d590b8bff5 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.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 Azure.Provisioning; diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs index 78f9532f7e5..39c870da811 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.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 Azure.Provisioning.Network; using Azure.Provisioning.Primitives; diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index 45cdadd2dbf..d65f5cc6f73 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.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; namespace Aspire.Hosting.Azure; diff --git a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs index 36dc1c79562..877c8acabd3 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.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; namespace Aspire.Hosting.Azure; diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index c0067c8028c..e08e0597a3c 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.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.Storage; diff --git a/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs index 780a2e5ce1b..883989c79e0 100644 --- a/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs +++ b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Azure; @@ -8,6 +9,7 @@ namespace Aspire.Hosting.Azure; /// /// Represents an Azure resource that can be connected to via a private endpoint. /// +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] public interface IAzurePrivateEndpointTarget : IResource { /// diff --git a/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs b/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs index 08445faabf0..2a9e630fe81 100644 --- a/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs +++ b/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Azure; @@ -9,6 +10,7 @@ namespace Aspire.Hosting.Azure; /// An annotation that indicates a resource is the target of a private endpoint. /// When this annotation is present, the resource should be configured to deny public network access. /// +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] public sealed class PrivateEndpointTargetAnnotation : IResourceAnnotation { } From 82d1a3d772d42f020021170c3af136254cfa1a73 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 2 Feb 2026 17:45:40 -0600 Subject: [PATCH 11/19] Add unit tests. Remove NatGateway (for now). This will come in a separate PR. Clean up some code. --- .../AzureStorageEndToEnd.AppHost/Program.cs | 2 - .../AzureNatGatewayResource.cs | 62 ------ .../AzureSubnetResource.cs | 12 +- .../AzureVirtualNetworkExtensions.cs | 180 ------------------ src/Aspire.Hosting.Azure.Network/README.md | 48 ++--- .../Aspire.Hosting.Azure.Tests.csproj | 1 + .../AzurePrivateEndpointExtensionsTests.cs | 146 ++++++++++++++ ...zureStoragePrivateEndpointLockdownTests.cs | 54 ++++++ .../AzureVirtualNetworkExtensionsTests.cs | 108 +++++++++++ ...nt_ForQueues_GeneratesBicep.verified.bicep | 74 +++++++ ...vateEndpoint_GeneratesBicep.verified.bicep | 74 +++++++ ...point_GeneratesCorrectBicep.verified.bicep | 36 ++++ ..._WithSubnets_GeneratesBicep.verified.bicep | 52 +++++ 13 files changed, 570 insertions(+), 279 deletions(-) delete mode 100644 src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithSubnets_GeneratesBicep.verified.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index dc1f5ec8214..c71abf7656f 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -1,8 +1,6 @@ // 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.Azure.Network; using Azure.Provisioning.AppContainers; diff --git a/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs deleted file mode 100644 index e955e169fed..00000000000 --- a/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Azure.Provisioning.Primitives; -using Azure.Provisioning.Network; - -namespace Aspire.Hosting.Azure; - -/// -/// Represents an Azure NAT Gateway resource. -/// -/// The name of the resource. -/// Callback to configure the Azure NAT Gateway resource. -public class AzureNatGatewayResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure) -{ - internal List PublicIpAddresses { get; } = []; - - /// - /// Gets the "id" output reference from the Azure NAT Gateway resource. - /// - public BicepOutputReference Id => new("id", this); - - /// - /// Gets the "name" output reference for the resource. - /// - public BicepOutputReference NameOutput => new("name", this); - - /// - /// Gets or sets the idle timeout in minutes for the NAT Gateway (4-120 minutes). - /// - public int? IdleTimeoutInMinutes { get; set; } - - /// - public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) - { - var bicepIdentifier = this.GetBicepIdentifier(); - var resources = infra.GetProvisionableResources(); - - // Check if a NatGateway with the same identifier already exists - var existingNatGw = resources.OfType().SingleOrDefault(natgw => natgw.BicepIdentifier == bicepIdentifier); - - if (existingNatGw is not null) - { - return existingNatGw; - } - - // Create and add new resource if it doesn't exist - var natGw = NatGateway.FromExisting(bicepIdentifier); - - if (!TryApplyExistingResourceAnnotation( - this, - infra, - natGw)) - { - natGw.Name = NameOutput.AsProvisioningParameter(infra); - } - - infra.Add(natGw); - return natGw; - } -} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index 6093f4f58db..851c62c79e6 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -48,18 +48,13 @@ public string AddressPrefix /// /// Gets the subnet Id output reference. /// - public BicepOutputReference Id => new($"{Infrastructure.NormalizeBicepIdentifier(subnetName)}_Id", parent); + public BicepOutputReference Id => new($"{Infrastructure.NormalizeBicepIdentifier(Name)}_Id", parent); /// /// Gets the parent Azure Virtual Network resource. /// public AzureVirtualNetworkResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); - /// - /// Gets the NAT Gateway resource associated with this subnet, if any. - /// - public AzureNatGatewayResource? NatGateway { get; internal set; } - private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) => !string.IsNullOrEmpty(argument) ? argument : throw new ArgumentNullException(paramName); @@ -80,11 +75,6 @@ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, subnet.DependsOn.Add(dependsOn); } - if (NatGateway is not null) - { - subnet.NatGatewayId = NatGateway.Id.AsProvisioningParameter(infra); - } - if (this.TryGetLastAnnotation(out var serviceDelegationAnnotation)) { subnet.Delegations.Add(new ServiceDelegation() diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index fc226a86e0e..90eedc02717 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -155,184 +155,4 @@ public static IResourceBuilder AddSubnet( return builder.ApplicationBuilder.AddResource(subnet) .ExcludeFromManifest(); } - - ///// - ///// Adds an Azure Public IP Address resource to the application model. - ///// - ///// The builder for the distributed application. - ///// The name of the Azure Public IP Address resource. - ///// A reference to the . - ///// - ///// By default references to the Azure Public IP Address resource will be assigned the following roles: - ///// - ///// - - ///// - ///// These can be replaced by calling . - ///// - //public static IResourceBuilder AddAzurePublicIP( - // this IDistributedApplicationBuilder builder, - // [ResourceName] string name) - //{ - // ArgumentNullException.ThrowIfNull(builder); - // ArgumentException.ThrowIfNullOrEmpty(name); - - // builder.AddAzureProvisioning(); - - // AzurePublicIpResource resource = new(name, ConfigurePublicIp); - // return builder.AddResource(resource) - // .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, - // NetworkBuiltInRole.NetworkContributor); - - // void ConfigurePublicIp(AzureResourceInfrastructure infra) - // { - // var publicIp = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, - // (identifier, name) => - // { - // var resource = PublicIPAddress.FromExisting(identifier); - // resource.Name = name; - // return resource; - // }, - // (infra) => - // { - // var azureResource = (AzurePublicIpResource)infra.AspireResource; - // var publicIp = new PublicIPAddress(infra.AspireResource.GetBicepIdentifier()) - // { - // PublicIPAllocationMethod = azureResource.AllocationMethod != null - // ? BicepValue.DefineProperty(publicIp, nameof(PublicIPAddress.PublicIPAllocationMethod), ["properties", "publicIPAllocationMethod"], defaultValue: new BicepString(azureResource.AllocationMethod)) - // : BicepValue.DefineProperty(publicIp, nameof(PublicIPAddress.PublicIPAllocationMethod), ["properties", "publicIPAllocationMethod"], defaultValue: NetworkIPAllocationMethod.Static), - // Sku = azureResource.Sku != null - // ? new PublicIPAddressSku { Name = new BicepString(azureResource.Sku) } - // : new PublicIPAddressSku { Name = PublicIPAddressSkuName.Standard }, - // Tags = { { "aspire-resource-name", infra.AspireResource.Name } } - // }; - - // if (azureResource.DnsName != null) - // { - // publicIp.DnsSettings = new PublicIPAddressDnsSettings - // { - // DomainNameLabel = azureResource.DnsName - // }; - // } - - // return publicIp; - // }); - - // // Output the Public IP ID and IP Address for references - // infra.Add(new ProvisioningOutput("id", typeof(string)) - // { - // Value = publicIp.Id - // }); - - // infra.Add(new ProvisioningOutput("ipAddress", typeof(string)) - // { - // Value = publicIp.IPAddress - // }); - - // // We need to output name to externalize role assignments. - // infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = publicIp.Name }); - // } - //} - - ///// - ///// Adds an Azure NAT Gateway resource to the application model. - ///// - ///// The builder for the distributed application. - ///// The name of the Azure NAT Gateway resource. - ///// A reference to the . - //public static IResourceBuilder AddAzureNatGateway( - // this IDistributedApplicationBuilder builder, - // [ResourceName] string name) - //{ - // ArgumentNullException.ThrowIfNull(builder); - // ArgumentException.ThrowIfNullOrEmpty(name); - - // builder.AddAzureProvisioning(); - - // AzureNatGatewayResource resource = new(name, ConfigureNatGateway); - // return builder.AddResource(resource) - // .WithDefaultRoleAssignments(NetworkBuiltInRole.GetBuiltInRoleName, - // NetworkBuiltInRole.NetworkContributor); - - // void ConfigureNatGateway(AzureResourceInfrastructure infra) - // { - // var natGateway = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, - // (identifier, name) => - // { - // var resource = NatGateway.FromExisting(identifier); - // resource.Name = name; - // return resource; - // }, - // (infra) => - // { - // var azureResource = (AzureNatGatewayResource)infra.AspireResource; - // var natGw = new NatGateway(infra.AspireResource.GetBicepIdentifier()) - // { - // Sku = new NatGatewaySku { Name = NatGatewaySkuName.Standard }, - // Tags = { { "aspire-resource-name", infra.AspireResource.Name } } - // }; - - // if (azureResource.IdleTimeoutInMinutes.HasValue) - // { - // natGw.IdleTimeoutInMinutes = azureResource.IdleTimeoutInMinutes.Value; - // } - - // // Add public IP addresses if configured - // if (azureResource.PublicIpAddresses.Count > 0) - // { - // foreach (var publicIp in azureResource.PublicIpAddresses) - // { - // natGw.PublicIPAddresses.Add(new WritableSubResource - // { - // Id = publicIp.Id - // }); - // } - // } - - // return natGw; - // }); - - // // Output the NAT Gateway ID for references - // infra.Add(new ProvisioningOutput("id", typeof(string)) - // { - // Value = natGateway.Id - // }); - - // // We need to output name to externalize role assignments. - // infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = natGateway.Name }); - // } - //} - - /// - /// Associates a NAT Gateway with the subnet. - /// - /// The subnet resource builder. - /// The NAT Gateway resource builder. - /// A reference to the . - public static IResourceBuilder WithNatGateway( - this IResourceBuilder builder, - IResourceBuilder natGateway) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(natGateway); - - builder.Resource.NatGateway = natGateway.Resource; - return builder; - } - - /// - /// Associates a Public IP Address with the NAT Gateway. - /// - /// The NAT Gateway resource builder. - /// The Public IP Address resource builder. - /// A reference to the . - public static IResourceBuilder WithPublicIP( - this IResourceBuilder builder, - IResourceBuilder publicIp) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(publicIp); - - builder.Resource.PublicIpAddresses.Add(publicIp.Resource); - return builder; - } } diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md index cc296ce34e5..85f30861113 100644 --- a/src/Aspire.Hosting.Azure.Network/README.md +++ b/src/Aspire.Hosting.Azure.Network/README.md @@ -1,6 +1,6 @@ # Aspire.Hosting.Azure.Network library -Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, NAT Gateways, and Public IP Addresses. +Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, and Private Endpoints. ## Getting started @@ -61,44 +61,44 @@ var vnet = builder.AddAzureVirtualNetwork("vnet"); var subnet = vnet.AddSubnet("subnet", "10.0.1.0/24"); ``` -### Adding NAT Gateway with Public IP +### Adding Private Endpoints -Create a NAT Gateway with a Public IP and associate it with a subnet: +Create a private endpoint to securely connect to Azure resources over a private network: ```csharp -var publicIp = builder.AddAzurePublicIP("natip"); -var natGateway = builder.AddAzureNatGateway("natgw") - .WithPublicIP(publicIp); - var vnet = builder.AddAzureVirtualNetwork("vnet"); -var subnet = vnet.AddSubnet("subnet", "10.0.1.0/24") - .WithNatGateway(natGateway); -``` +var peSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/24"); -### Complete example with outbound connectivity +var storage = builder.AddAzureStorage("storage"); +var blobs = storage.AddBlobs("blobs"); -This example creates a Virtual Network with a subnet that has outbound internet connectivity via a NAT Gateway: +// Add a private endpoint for the blob storage +builder.AddAzurePrivateEndpoint(peSubnet, blobs); +``` -```csharp -// Create a public IP for the NAT Gateway -var publicIp = builder.AddAzurePublicIP("natip"); +When you add a private endpoint to an Azure resource: -// Create a NAT Gateway and attach the public IP -var natGateway = builder.AddAzureNatGateway("natgw") - .WithPublicIP(publicIp); +1. A Private DNS Zone is automatically created for the service (e.g., `privatelink.blob.core.windows.net`) +2. A Virtual Network Link connects the DNS zone to your VNet +3. A DNS Zone Group is created on the private endpoint for automatic DNS registration +4. The target resource is automatically configured to deny public network access -// Create a Virtual Network with custom address space -var vnet = builder.AddAzureVirtualNetwork("vnet", "10.0.0.0/16"); +To override the automatic network lockdown, use `ConfigureInfrastructure`: -// Add a subnet with NAT Gateway for outbound connectivity -var subnet = vnet.AddSubnet("appsubnet", "10.0.1.0/24") - .WithNatGateway(natGateway); +```csharp +storage.ConfigureInfrastructure(infra => +{ + var storageAccount = infra.GetProvisionableResources() + .OfType() + .Single(); + storageAccount.PublicNetworkAccess = StoragePublicNetworkAccess.Enabled; +}); ``` ## Additional documentation * https://learn.microsoft.com/azure/virtual-network/ -* https://learn.microsoft.com/azure/nat-gateway/ +* https://learn.microsoft.com/azure/private-link/ ## Feedback & contributing diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index a8a46d0e2b5..8d5c2093659 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs new file mode 100644 index 00000000000..47e6b700b62 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs @@ -0,0 +1,146 @@ +// 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.Utils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzurePrivateEndpointExtensionsTests +{ + [Fact] + public void AddAzurePrivateEndpoint_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + var pe = builder.AddAzurePrivateEndpoint(subnet, blobs); + + Assert.NotNull(pe); + Assert.Equal("pesubnet-blobs-pe", pe.Resource.Name); + Assert.IsType(pe.Resource); + Assert.Same(subnet.Resource, pe.Resource.Subnet); + Assert.Same(blobs.Resource, pe.Resource.Target); + } + + [Fact] + public void AddAzurePrivateEndpoint_AddsAnnotationToParentStorage() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + // Before adding PE, no annotation + Assert.Empty(storage.Resource.Annotations.OfType()); + + builder.AddAzurePrivateEndpoint(subnet, blobs); + + // After adding PE, annotation should be on parent storage + var annotation = storage.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + } + + [Fact] + public void AddAzurePrivateEndpoint_ForQueues_AddsAnnotationToParentStorage() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var queues = storage.AddQueues("queues"); + + builder.AddAzurePrivateEndpoint(subnet, queues); + + var annotation = storage.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + } + + [Fact] + public async Task AddAzurePrivateEndpoint_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + var pe = builder.AddAzurePrivateEndpoint(subnet, blobs); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(pe.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzurePrivateEndpoint_ForQueues_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var queues = storage.AddQueues("queues"); + + var pe = builder.AddAzurePrivateEndpoint(subnet, queues); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(pe.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public void AddAzurePrivateEndpoint_InRunMode_DoesNotAddToBuilder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + var pe = builder.AddAzurePrivateEndpoint(subnet, blobs); + + // In run mode, the PE resource should not be added to the builder's resources + Assert.DoesNotContain(pe.Resource, builder.Resources); + } + + [Fact] + public void AzureBlobStorageResource_ImplementsIAzurePrivateEndpointTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + Assert.IsAssignableFrom(blobs.Resource); + + var target = (IAzurePrivateEndpointTarget)blobs.Resource; + Assert.Equal(["blob"], target.GetPrivateLinkGroupIds()); + Assert.Equal("privatelink.blob.core.windows.net", target.GetPrivateDnsZoneName()); + } + + [Fact] + public void AzureQueueStorageResource_ImplementsIAzurePrivateEndpointTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var storage = builder.AddAzureStorage("storage"); + var queues = storage.AddQueues("queues"); + + Assert.IsAssignableFrom(queues.Resource); + + var target = (IAzurePrivateEndpointTarget)queues.Resource; + Assert.Equal(["queue"], target.GetPrivateLinkGroupIds()); + Assert.Equal("privatelink.queue.core.windows.net", target.GetPrivateDnsZoneName()); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs new file mode 100644 index 00000000000..66522fbc74d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; +using Azure.Provisioning.Storage; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureStoragePrivateEndpointLockdownTests +{ + [Fact] + public async Task AddAzureStorage_WithPrivateEndpoint_CanOverrideWithConfigureInfrastructure() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage") + .ConfigureInfrastructure(infra => + { + var storageAccount = infra.GetProvisionableResources().OfType().Single(); + storageAccount.PublicNetworkAccess = StoragePublicNetworkAccess.Enabled; + storageAccount.NetworkRuleSet!.DefaultAction = StorageNetworkDefaultAction.Allow; + }); + var blobs = storage.AddBlobs("blobs"); + + builder.AddAzurePrivateEndpoint(subnet, blobs); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); + + // Override should result in Allow/Enabled + Assert.Contains("defaultAction: 'Allow'", manifest.BicepText); + Assert.Contains("publicNetworkAccess: 'Enabled'", manifest.BicepText); + } + + [Fact] + public async Task AddAzureStorage_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 storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + var queues = storage.AddQueues("queues"); + + builder.AddAzurePrivateEndpoint(subnet, blobs); + builder.AddAzurePrivateEndpoint(subnet, queues); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs new file mode 100644 index 00000000000..6944b7b4f6a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -0,0 +1,108 @@ +// 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.Azure.Network; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureVirtualNetworkExtensionsTests +{ + [Fact] + public void AddAzureVirtualNetwork_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + + Assert.NotNull(vnet); + Assert.Equal("myvnet", vnet.Resource.Name); + Assert.IsType(vnet.Resource); + } + + [Fact] + public void AddAzureVirtualNetwork_WithCustomAddressPrefix() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet", "10.1.0.0/16"); + + Assert.NotNull(vnet); + Assert.Equal("myvnet", vnet.Resource.Name); + } + + [Fact] + public void AddSubnet_CreatesSubnetResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.1.0/24"); + + Assert.NotNull(subnet); + Assert.Equal("mysubnet", subnet.Resource.Name); + Assert.Equal("mysubnet", subnet.Resource.SubnetName); + Assert.Equal("10.0.1.0/24", subnet.Resource.AddressPrefix); + Assert.Same(vnet.Resource, subnet.Resource.Parent); + } + + [Fact] + public void AddSubnet_WithCustomSubnetName() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "custom-subnet-name", "10.0.1.0/24"); + + Assert.Equal("mysubnet", subnet.Resource.Name); + Assert.Equal("custom-subnet-name", subnet.Resource.SubnetName); + Assert.Equal("10.0.1.0/24", subnet.Resource.AddressPrefix); + } + + [Fact] + public void AddSubnet_MultipleSubnets_HaveDifferentParentReferences() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet1 = vnet.AddSubnet("subnet1", "10.0.1.0/24"); + var subnet2 = vnet.AddSubnet("subnet2", "10.0.2.0/24"); + + // Both subnets should have the same parent VNet + Assert.Same(vnet.Resource, subnet1.Resource.Parent); + Assert.Same(vnet.Resource, subnet2.Resource.Parent); + // But they should be different resources + Assert.NotSame(subnet1.Resource, subnet2.Resource); + } + + [Fact] + public async Task AddAzureVirtualNetwork_WithSubnets_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + vnet.AddSubnet("subnet1", "10.0.1.0/24") + .WithAnnotation(new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); + vnet.AddSubnet("subnet2", "custom-subnet-name", "10.0.2.0/24"); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public void AddAzureVirtualNetwork_InRunMode_DoesNotAddToBuilder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.1.0/24"); + + // In run mode, the resource should not be added to the builder's resources + Assert.DoesNotContain(vnet.Resource, builder.Resources); + // In run mode, the subnet should not be added to the builder's resources + Assert.DoesNotContain(subnet.Resource, builder.Resources); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..c38f0d4afba --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep @@ -0,0 +1,74 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +param myvnet_outputs_pesubnet_id string + +param storage_outputs_id string + +resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.queue.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'pesubnet-queues-pe-dns' + } +} + +resource privatelink_queue_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-queues-pe-vnetlink' + } + parent: privatelink_queue_core_windows_net +} + +resource pesubnet_queues_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_queues_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'queue' + ] + } + name: 'pesubnet-queues-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-queues-pe' + } +} + +resource pesubnet_queues_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_queue_core_windows_net' + properties: { + privateDnsZoneId: privatelink_queue_core_windows_net.id + } + } + ] + } + parent: pesubnet_queues_pe +} + +output id string = pesubnet_queues_pe.id + +output name string = pesubnet_queues_pe.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..849b0e6e05f --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep @@ -0,0 +1,74 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +param myvnet_outputs_pesubnet_id string + +param storage_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.blob.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'pesubnet-blobs-pe-dns' + } +} + +resource privatelink_blob_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-blobs-pe-vnetlink' + } + parent: privatelink_blob_core_windows_net +} + +resource pesubnet_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_blobs_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'pesubnet-blobs-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-blobs-pe' + } +} + +resource pesubnet_blobs_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: pesubnet_blobs_pe +} + +output id string = pesubnet_blobs_pe.id + +output name string = pesubnet_blobs_pe.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..84e807f8a64 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('storage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: false + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + +output queueEndpoint string = storage.properties.primaryEndpoints.queue + +output tableEndpoint string = storage.properties.primaryEndpoints.table + +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithSubnets_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithSubnets_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..79ef0e88d7a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithSubnets_GeneratesBicep.verified.bicep @@ -0,0 +1,52 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'subnet1' + properties: { + addressPrefix: '10.0.1.0/24' + delegations: [ + { + properties: { + serviceName: 'Microsoft.App/environments' + } + name: 'ContainerAppsDelegation' + } + ] + } + parent: myvnet +} + +resource subnet2 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'custom-subnet-name' + properties: { + addressPrefix: '10.0.2.0/24' + } + parent: myvnet + dependsOn: [ + subnet1 + ] +} + +output subnet1_Id string = subnet1.id + +output subnet2_Id string = subnet2.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file From f8c3fbed0309f14887bcdc5ce5959a44bbf7ae6c Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 3 Feb 2026 12:44:06 -0600 Subject: [PATCH 12/19] More clean up --- .../private-endpoints-blobs-pe.module.bicep | 2 +- .../private-endpoints-queues-pe.module.bicep | 2 +- .../AzurePrivateEndpointExtensions.cs | 8 +- .../AzurePublicIpResource.cs | 75 ------------------- .../AzureSubnetResource.cs | 1 - .../AzureVirtualNetworkExtensions.cs | 1 - ...tsCorrectly_WithSnapshot#01.verified.bicep | 4 +- ...nt_ForQueues_GeneratesBicep.verified.bicep | 2 +- ...vateEndpoint_GeneratesBicep.verified.bicep | 2 +- ...ageAccountWithResourceGroup.verified.bicep | 4 +- ...urceGroupAndStaticArguments.verified.bicep | 2 + 11 files changed, 16 insertions(+), 87 deletions(-) delete mode 100644 src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep index ebbacec96f4..f324ec93c9d 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep @@ -16,7 +16,7 @@ resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@20 } resource privatelink_blob_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'vnet-link' + name: 'private-endpoints-blobs-pe-vnet-link' location: 'global' properties: { registrationEnabled: false diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep index 390b131e2f1..a7c2697206f 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep @@ -16,7 +16,7 @@ resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2 } resource privatelink_queue_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'vnet-link' + name: 'private-endpoints-queues-pe-vnet-link' location: 'global' properties: { registrationEnabled: false diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index 0d590b8bff5..e7b0a330671 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -94,13 +94,13 @@ void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) var vnetLinkIdentifier = $"{dnsZoneIdentifier}_vnetlink"; var vnetLink = new VirtualNetworkLink(vnetLinkIdentifier) { - Name = $"{azureResource.Subnet!.Parent.Name}-link", + Name = $"{azureResource.Name}-vnet-link", + Parent = privateDnsZone, Location = new AzureLocation("global"), RegistrationEnabled = false, - VirtualNetworkId = azureResource.Subnet.Parent.Id.AsProvisioningParameter(infra), + VirtualNetworkId = azureResource.Subnet!.Parent.Id.AsProvisioningParameter(infra), Tags = { { "aspire-resource-name", $"{azureResource.Name}-vnetlink" } } }; - vnetLink.Parent = privateDnsZone; infra.Add(vnetLink); // Create the Private Endpoint @@ -138,6 +138,7 @@ void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) var dnsZoneGroup = new PrivateDnsZoneGroup(dnsZoneGroupIdentifier) { Name = "default", + Parent = endpoint, PrivateDnsZoneConfigs = { new PrivateDnsZoneConfig @@ -147,7 +148,6 @@ void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) } } }; - dnsZoneGroup.Parent = endpoint; infra.Add(dnsZoneGroup); // Output the Private Endpoint ID for references diff --git a/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs deleted file mode 100644 index 4b148846f0c..00000000000 --- a/src/Aspire.Hosting.Azure.Network/AzurePublicIpResource.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Azure.Provisioning.Network; -using Azure.Provisioning.Primitives; - -namespace Aspire.Hosting.Azure; - -/// -/// Represents an Azure Public IP Address resource. -/// -/// The name of the resource. -/// Callback to configure the Azure Public IP Address resource. -public class AzurePublicIpResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure) -{ - /// - /// Gets the "id" output reference from the Azure Public IP Address resource. - /// - public BicepOutputReference Id => new("id", this); - - /// - /// Gets the "name" output reference for the resource. - /// - public BicepOutputReference NameOutput => new("name", this); - - /// - /// Gets the "ipAddress" output reference from the Azure Public IP Address resource. - /// - public BicepOutputReference IpAddress => new("ipAddress", this); - - /// - /// Gets or sets the public IP allocation method. - /// - public string? AllocationMethod { get; set; } - - /// - /// Gets or sets the SKU for the public IP address. - /// - public string? Sku { get; set; } - - /// - /// Gets or sets the DNS name for the public IP address. - /// - public string? DnsName { get; set; } - - /// - public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) - { - var bicepIdentifier = this.GetBicepIdentifier(); - var resources = infra.GetProvisionableResources(); - - // Check if a PublicIPAddress with the same identifier already exists - var existingIp = resources.OfType().SingleOrDefault(ip => ip.BicepIdentifier == bicepIdentifier); - - if (existingIp is not null) - { - return existingIp; - } - - // Create and add new resource if it doesn't exist - var publicIp = PublicIPAddress.FromExisting(bicepIdentifier); - - if (!TryApplyExistingResourceAnnotation( - this, - infra, - publicIp)) - { - publicIp.Name = NameOutput.AsProvisioningParameter(infra); - } - - infra.Add(publicIp); - return publicIp; - } -} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index 851c62c79e6..4aa964e70b0 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -67,7 +67,6 @@ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, { Name = SubnetName, AddressPrefix = AddressPrefix, - // TODO: DefaultOutboundAccess = DefaultOutboundAccess }; if (dependsOn is not null) diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 90eedc02717..9fec5761f9a 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -84,7 +84,6 @@ void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) { // Chain subnet provisioning to ensure deployment doesn't fail // due to parallel creation of subnets within the VNet. - // TODO: verify this is necessary ProvisionableResource? dependsOn = null; foreach (var subnet in azureResource.Subnets) { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep index 75055d8eff0..8cfebedff1e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep @@ -36,4 +36,6 @@ output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name -output description string = sku_description +output id string = storage.id + +output description string = sku_description \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep index c38f0d4afba..433d47da9ba 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep @@ -16,7 +16,7 @@ resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2 } resource privatelink_queue_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'myvnet-link' + name: 'pesubnet-queues-pe-vnet-link' location: 'global' properties: { registrationEnabled: false diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep index 849b0e6e05f..c1b4109c02c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep @@ -16,7 +16,7 @@ resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@20 } resource privatelink_blob_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'myvnet-link' + name: 'pesubnet-blobs-pe-vnet-link' location: 'global' properties: { registrationEnabled: false diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep index 5a341095402..77d7e251326 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep @@ -15,4 +15,6 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table -output name string = storage.name \ No newline at end of file +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep index b1a4aa32ca2..7682943603b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep @@ -14,3 +14,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file From fa6eccfeecf5adb82cec1aa71acca2df8a6f3396 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 3 Feb 2026 14:52:41 -0600 Subject: [PATCH 13/19] Move to a new playground app specific to VNets --- Aspire.slnx | 4 + .../Program.cs | 5 +- .../AzureStorageEndToEnd.AppHost.csproj | 2 - .../AzureStorageEndToEnd.AppHost/Program.cs | 44 +++--- .../aspire-manifest.json | 88 ++++-------- .../storage.module.bicep | 3 +- .../storage2.module.bicep | 7 +- ...reVirtualNetworkEndToEnd.ApiService.csproj | 15 +++ .../Program.cs | 43 ++++++ .../Properties/launchSettings.json | 14 ++ .../appsettings.json | 9 ++ ...AzureVirtualNetworkEndToEnd.AppHost.csproj | 22 +++ .../Program.cs | 56 ++++++++ .../Properties/launchSettings.json | 46 +++++++ .../api-containerapp.module.bicep | 14 +- .../api-identity.module.bicep | 0 .../api-roles-storage.module.bicep | 0 .../appsettings.json | 12 ++ .../aspire-manifest.json | 125 ++++++++++++++++++ .../env-acr.module.bicep | 0 .../env.module.bicep | 4 +- .../private-endpoints-blobs-pe.module.bicep | 0 .../private-endpoints-queues-pe.module.bicep | 0 .../storage.module.bicep | 56 ++++++++ .../vnet.module.bicep | 12 +- 25 files changed, 474 insertions(+), 107 deletions(-) create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Properties/launchSettings.json create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/appsettings.json create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Properties/launchSettings.json rename playground/{AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost => AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost}/api-containerapp.module.bicep (88%) rename playground/{AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost => AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost}/api-identity.module.bicep (100%) rename playground/{AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost => AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost}/api-roles-storage.module.bicep (100%) create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/appsettings.json create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json rename playground/{AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost => AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost}/env-acr.module.bicep (100%) rename playground/{AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost => AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost}/env.module.bicep (96%) rename playground/{AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost => AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost}/private-endpoints-blobs-pe.module.bicep (100%) rename playground/{AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost => AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost}/private-endpoints-queues-pe.module.bicep (100%) create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/storage.module.bicep rename playground/{AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost => AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost}/vnet.module.bicep (78%) diff --git a/Aspire.slnx b/Aspire.slnx index c0a9090b282..c89c9907d9e 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -160,6 +160,10 @@ + + + + diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs index f17ac81d17b..13b9d3b9135 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs @@ -18,15 +18,18 @@ app.MapDefaultEndpoints(); -app.MapGet("/", async (BlobServiceClient bsc, [FromKeyedServices("myqueue")] QueueClient queue) => +app.MapGet("/", async (BlobServiceClient bsc, [FromKeyedServices("myqueue")] QueueClient queue, [FromKeyedServices("foocontainer")] BlobContainerClient keyedContainerClient1) => { var blobNames = new List(); var blobNameAndContent = Guid.NewGuid().ToString(); + await keyedContainerClient1.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + var directContainerClient = bsc.GetBlobContainerClient(blobContainerName: "test-container-1"); await directContainerClient.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); await ReadBlobsAsync(directContainerClient, blobNames); + await ReadBlobsAsync(keyedContainerClient1, blobNames); await queue.SendMessageAsync("Hello, world!"); diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj index 9b304242d26..02b413b3c2e 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AzureStorageEndToEnd.AppHost.csproj @@ -15,8 +15,6 @@ - - diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index c71abf7656f..1a7877b3d95 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -1,31 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.Azure.Network; -using Azure.Provisioning.AppContainers; - var builder = DistributedApplication.CreateBuilder(args); -var vnet = builder.AddAzureVirtualNetwork("vnet"); -var subnet1 = vnet.AddSubnet("subnet1", subnetName: null, "10.0.1.0/24") // should be 10.0.0.0/23, but can't change it since I deployed with the wrong address space - .WithAnnotation( - new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); - -var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", subnetName: null, "10.0.2.0/24"); - -builder.AddAzureContainerAppEnvironment("env") - .ConfigureInfrastructure(infra => - { - var env = infra.GetProvisionableResources() - .OfType() - .Single(); - - env.VnetConfiguration = new ContainerAppVnetConfiguration - { - InfrastructureSubnetId = subnet1.Resource.Id.AsProvisioningParameter(infra) - }; - }); - var storage = builder.AddAzureStorage("storage").RunAsEmulator(container => { container.WithDataBindMount(); @@ -35,17 +12,30 @@ storage.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); storage.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); -builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, blobs); +var myqueue = storage.AddQueue("myqueue", queueName: "my-queue"); -var queues = storage.AddQueues("queues"); -builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, queues); +var storage2 = builder.AddAzureStorage("storage2").RunAsEmulator(container => +{ + container.WithDataBindMount(); +}); -var myqueue = storage.AddQueue("myqueue", queueName: "my-queue"); +var blobContainer2 = storage2.AddBlobContainer("foocontainer", blobContainerName: "foo-container"); builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(blobs).WaitFor(blobs) + .WithReference(blobContainer2).WaitFor(blobContainer2) .WithReference(myqueue).WaitFor(myqueue); +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + builder.Build().Run(); diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json index f4acf50d1a0..09ec76ae168 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/aspire-manifest.json @@ -1,23 +1,6 @@ { "$schema": "https://json.schemastore.org/aspire-8.0.json", "resources": { - "vnet": { - "type": "azure.bicep.v0", - "path": "vnet.module.bicep" - }, - "env-acr": { - "type": "azure.bicep.v0", - "path": "env-acr.module.bicep" - }, - "env": { - "type": "azure.bicep.v0", - "path": "env.module.bicep", - "params": { - "env_acr_outputs_name": "{env-acr.outputs.name}", - "vnet_outputs_subnet1_id": "{vnet.outputs.subnet1_Id}", - "userPrincipalId": "" - } - }, "storage": { "type": "azure.bicep.v0", "path": "storage.module.bicep" @@ -38,61 +21,38 @@ "type": "value.v0", "connectionString": "Endpoint={storage.outputs.blobEndpoint};ContainerName=test-container-2" }, - "private-endpoints-blobs-pe": { - "type": "azure.bicep.v0", - "path": "private-endpoints-blobs-pe.module.bicep", - "params": { - "vnet_outputs_id": "{vnet.outputs.id}", - "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", - "storage_outputs_id": "{storage.outputs.id}" - } - }, - "queues": { + "storage-queues": { "type": "value.v0", "connectionString": "{storage.outputs.queueEndpoint}" }, - "private-endpoints-queues-pe": { + "myqueue": { + "type": "value.v0", + "connectionString": "Endpoint={storage.outputs.queueEndpoint};QueueName=my-queue" + }, + "storage2": { "type": "azure.bicep.v0", - "path": "private-endpoints-queues-pe.module.bicep", - "params": { - "vnet_outputs_id": "{vnet.outputs.id}", - "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", - "storage_outputs_id": "{storage.outputs.id}" - } + "path": "storage2.module.bicep" }, - "storage-queues": { + "storage2-blobs": { "type": "value.v0", - "connectionString": "{storage.outputs.queueEndpoint}" + "connectionString": "{storage2.outputs.blobEndpoint}" }, - "myqueue": { + "foocontainer": { "type": "value.v0", - "connectionString": "Endpoint={storage.outputs.queueEndpoint};QueueName=my-queue" + "connectionString": "Endpoint={storage2.outputs.blobEndpoint};ContainerName=foo-container" }, "api": { - "type": "project.v1", + "type": "project.v0", "path": "../AzureStorageEndToEnd.ApiService/AzureStorageEndToEnd.ApiService.csproj", - "deployment": { - "type": "azure.bicep.v0", - "path": "api-containerapp.module.bicep", - "params": { - "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", - "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", - "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", - "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", - "api_containerimage": "{api.containerImage}", - "api_identity_outputs_id": "{api-identity.outputs.id}", - "api_containerport": "{api.containerPort}", - "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", - "storage_outputs_queueendpoint": "{storage.outputs.queueEndpoint}", - "api_identity_outputs_clientid": "{api-identity.outputs.clientId}" - } - }, "env": { "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", "HTTP_PORTS": "{api.bindings.http.targetPort}", "ConnectionStrings__blobs": "{blobs.connectionString}", "BLOBS_URI": "{storage.outputs.blobEndpoint}", + "ConnectionStrings__foocontainer": "{foocontainer.connectionString}", + "FOOCONTAINER_URI": "{storage2.outputs.blobEndpoint}", + "FOOCONTAINER_BLOBCONTAINERNAME": "foo-container", "ConnectionStrings__myqueue": "{myqueue.connectionString}", "MYQUEUE_URI": "{storage.outputs.queueEndpoint}", "MYQUEUE_QUEUENAME": "my-queue" @@ -112,16 +72,22 @@ } } }, - "api-identity": { + "storage-roles": { "type": "azure.bicep.v0", - "path": "api-identity.module.bicep" + "path": "storage-roles.module.bicep", + "params": { + "storage_outputs_name": "{storage.outputs.name}", + "principalType": "", + "principalId": "" + } }, - "api-roles-storage": { + "storage2-roles": { "type": "azure.bicep.v0", - "path": "api-roles-storage.module.bicep", + "path": "storage2-roles.module.bicep", "params": { - "storage_outputs_name": "{storage.outputs.name}", - "principalId": "{api-identity.outputs.principalId}" + "storage2_outputs_name": "{storage2.outputs.name}", + "principalType": "", + "principalId": "" } } } diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep index f963143c3b7..a6bbd75eee9 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep @@ -14,9 +14,8 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { isHnsEnabled: false minimumTlsVersion: 'TLS1_2' networkAcls: { - defaultAction: 'Deny' + defaultAction: 'Allow' } - publicNetworkAccess: 'Disabled' } tags: { 'aspire-resource-name': 'storage' diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage2.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage2.module.bicep index 32838ffeb1c..42f3049b006 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage2.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage2.module.bicep @@ -11,6 +11,7 @@ resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = { properties: { accessTier: 'Hot' allowSharedKeyAccess: false + isHnsEnabled: false minimumTlsVersion: 'TLS1_2' networkAcls: { defaultAction: 'Allow' @@ -33,8 +34,12 @@ resource foocontainer 'Microsoft.Storage/storageAccounts/blobServices/containers output blobEndpoint string = storage2.properties.primaryEndpoints.blob +output dataLakeEndpoint string = storage2.properties.primaryEndpoints.dfs + output queueEndpoint string = storage2.properties.primaryEndpoints.queue output tableEndpoint string = storage2.properties.primaryEndpoints.table -output name string = storage2.name \ No newline at end of file +output name string = storage2.name + +output id string = storage2.id \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj new file mode 100644 index 00000000000..66e0c13e3e3 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj @@ -0,0 +1,15 @@ + + + + $(DefaultTargetFramework) + enable + enable + + + + + + + + + diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs new file mode 100644 index 00000000000..9c42c6603c2 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Storage.Blobs; +using Azure.Storage.Queues; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddAzureBlobContainerClient("mycontainer"); + +builder.AddKeyedAzureQueue("myqueue"); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.MapGet("/", async (BlobContainerClient containerClient, [FromKeyedServices("myqueue")] QueueClient queue) => +{ + var blobNames = new List(); + var blobNameAndContent = Guid.NewGuid().ToString(); + + await containerClient.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + + await ReadBlobsAsync(containerClient, blobNames); + + await queue.SendMessageAsync("Hello, world!"); + + return blobNames; +}); + +app.Run(); + +static async Task ReadBlobsAsync(BlobContainerClient containerClient, List output) +{ + output.Add(containerClient.Uri.ToString()); + var blobs = containerClient.GetBlobsAsync(); + await foreach (var blob in blobs) + { + output.Add(blob.Name); + } +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Properties/launchSettings.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000000..de23e4696cf --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5193", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/appsettings.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj new file mode 100644 index 00000000000..5b4082c956a --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + d3e0c7a8-1f5b-4c2d-8e9a-6b7c8d9e0f1a + + + + + + + + + + + + + diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs new file mode 100644 index 00000000000..e9b4943b9e8 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Azure.Network; +using Azure.Provisioning.AppContainers; + +var builder = DistributedApplication.CreateBuilder(args); + +// Create a virtual network with two subnets: +// - One for the Container App Environment (with service delegation) +// - One for private endpoints +var vnet = builder.AddAzureVirtualNetwork("vnet"); + +var containerAppsSubnet = vnet.AddSubnet("container-apps", subnetName: null, "10.0.0.0/23") + .WithAnnotation( + new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); + +var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", subnetName: null, "10.0.2.0/27"); + +// Configure the Container App Environment to use the VNet +builder.AddAzureContainerAppEnvironment("env") + .ConfigureInfrastructure(infra => + { + var env = infra.GetProvisionableResources() + .OfType() + .Single(); + + env.VnetConfiguration = new ContainerAppVnetConfiguration + { + InfrastructureSubnetId = containerAppsSubnet.Resource.Id.AsProvisioningParameter(infra) + }; + }); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + +var blobs = storage.AddBlobs("blobs"); +var mycontainer = storage.AddBlobContainer("mycontainer"); + +var queues = storage.AddQueues("queues"); +var myqueue = storage.AddQueue("myqueue"); + +// Add private endpoints for blob and queue storage +// This automatically: +// - Creates Private DNS Zones for each service +// - Links the DNS zones to the VNet +// - Creates the Private Endpoints +// - Locks down public access to the storage account +builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, blobs); +builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, queues); + +builder.AddProject("api") + .WithExternalHttpEndpoints() + .WithReference(mycontainer).WaitFor(mycontainer) + .WithReference(myqueue).WaitFor(myqueue); + +builder.Build().Run(); diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Properties/launchSettings.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..457e26b8af3 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Properties/launchSettings.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:16129;http://localhost:16130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17049", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18026", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17049", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:16130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17050", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18027", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17050", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:16130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17050" + } + } + } +} diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep similarity index 88% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep rename to playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep index 7e3a6527256..775868cc85d 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-containerapp.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep @@ -64,16 +64,20 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { value: api_containerport } { - name: 'ConnectionStrings__blobs' - value: storage_outputs_blobendpoint + name: 'ConnectionStrings__mycontainer' + value: 'Endpoint=${storage_outputs_blobendpoint};ContainerName=mycontainer' } { - name: 'BLOBS_URI' + name: 'MYCONTAINER_URI' value: storage_outputs_blobendpoint } + { + name: 'MYCONTAINER_BLOBCONTAINERNAME' + value: 'mycontainer' + } { name: 'ConnectionStrings__myqueue' - value: 'Endpoint=${storage_outputs_queueendpoint};QueueName=my-queue' + value: 'Endpoint=${storage_outputs_queueendpoint};QueueName=myqueue' } { name: 'MYQUEUE_URI' @@ -81,7 +85,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'MYQUEUE_QUEUENAME' - value: 'my-queue' + value: 'myqueue' } { name: 'AZURE_CLIENT_ID' diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-identity.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-identity.module.bicep similarity index 100% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-identity.module.bicep rename to playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-identity.module.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-roles-storage.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-storage.module.bicep similarity index 100% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/api-roles-storage.module.bicep rename to playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-storage.module.bicep diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/appsettings.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/appsettings.json new file mode 100644 index 00000000000..39ba62a688d --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "Parameters": { + "insertionrows": "1" + } +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json new file mode 100644 index 00000000000..2186f38e955 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json @@ -0,0 +1,125 @@ +{ + "$schema": "https://json.schemastore.org/aspire-8.0.json", + "resources": { + "vnet": { + "type": "azure.bicep.v0", + "path": "vnet.module.bicep" + }, + "env-acr": { + "type": "azure.bicep.v0", + "path": "env-acr.module.bicep" + }, + "env": { + "type": "azure.bicep.v0", + "path": "env.module.bicep", + "params": { + "env_acr_outputs_name": "{env-acr.outputs.name}", + "vnet_outputs_container_apps_id": "{vnet.outputs.container_apps_Id}", + "userPrincipalId": "" + } + }, + "storage": { + "type": "azure.bicep.v0", + "path": "storage.module.bicep" + }, + "blobs": { + "type": "value.v0", + "connectionString": "{storage.outputs.blobEndpoint}" + }, + "storage-blobs": { + "type": "value.v0", + "connectionString": "{storage.outputs.blobEndpoint}" + }, + "mycontainer": { + "type": "value.v0", + "connectionString": "Endpoint={storage.outputs.blobEndpoint};ContainerName=mycontainer" + }, + "queues": { + "type": "value.v0", + "connectionString": "{storage.outputs.queueEndpoint}" + }, + "storage-queues": { + "type": "value.v0", + "connectionString": "{storage.outputs.queueEndpoint}" + }, + "myqueue": { + "type": "value.v0", + "connectionString": "Endpoint={storage.outputs.queueEndpoint};QueueName=myqueue" + }, + "private-endpoints-blobs-pe": { + "type": "azure.bicep.v0", + "path": "private-endpoints-blobs-pe.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}", + "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", + "storage_outputs_id": "{storage.outputs.id}" + } + }, + "private-endpoints-queues-pe": { + "type": "azure.bicep.v0", + "path": "private-endpoints-queues-pe.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}", + "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", + "storage_outputs_id": "{storage.outputs.id}" + } + }, + "api": { + "type": "project.v1", + "path": "../AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj", + "deployment": { + "type": "azure.bicep.v0", + "path": "api-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "api_containerimage": "{api.containerImage}", + "api_identity_outputs_id": "{api-identity.outputs.id}", + "api_containerport": "{api.containerPort}", + "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", + "storage_outputs_queueendpoint": "{storage.outputs.queueEndpoint}", + "api_identity_outputs_clientid": "{api-identity.outputs.clientId}" + } + }, + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "HTTP_PORTS": "{api.bindings.http.targetPort}", + "ConnectionStrings__mycontainer": "{mycontainer.connectionString}", + "MYCONTAINER_URI": "{storage.outputs.blobEndpoint}", + "MYCONTAINER_BLOBCONTAINERNAME": "mycontainer", + "ConnectionStrings__myqueue": "{myqueue.connectionString}", + "MYQUEUE_URI": "{storage.outputs.queueEndpoint}", + "MYQUEUE_QUEUENAME": "myqueue" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "external": true + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http", + "external": true + } + } + }, + "api-identity": { + "type": "azure.bicep.v0", + "path": "api-identity.module.bicep" + }, + "api-roles-storage": { + "type": "azure.bicep.v0", + "path": "api-roles-storage.module.bicep", + "params": { + "storage_outputs_name": "{storage.outputs.name}", + "principalId": "{api-identity.outputs.principalId}" + } + } + } +} \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env-acr.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.bicep similarity index 100% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env-acr.module.bicep rename to playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep similarity index 96% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep rename to playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep index 314c95831bf..cc7b2008ed2 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/env.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep @@ -7,7 +7,7 @@ param tags object = { } param env_acr_outputs_name string -param vnet_outputs_subnet1_id string +param vnet_outputs_container_apps_id string resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) @@ -52,7 +52,7 @@ resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { } } vnetConfiguration: { - infrastructureSubnetId: vnet_outputs_subnet1_id + infrastructureSubnetId: vnet_outputs_container_apps_id } workloadProfiles: [ { diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep similarity index 100% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep rename to playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep similarity index 100% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep rename to playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/storage.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/storage.module.bicep new file mode 100644 index 00000000000..49e8b890132 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/storage.module.bicep @@ -0,0 +1,56 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('storage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: false + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { + name: 'default' + parent: storage +} + +resource mycontainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { + name: 'mycontainer' + parent: blobs +} + +resource queues 'Microsoft.Storage/storageAccounts/queueServices@2024-01-01' = { + name: 'default' + parent: storage +} + +resource myqueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2024-01-01' = { + name: 'myqueue' + parent: queues +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + +output queueEndpoint string = storage.properties.primaryEndpoints.queue + +output tableEndpoint string = storage.properties.primaryEndpoints.table + +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep similarity index 78% rename from playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep rename to playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep index 1c7d49a0312..1a355c2f726 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/vnet.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep @@ -16,10 +16,10 @@ resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { } } -resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { - name: 'subnet1' +resource container_apps 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'container-apps' properties: { - addressPrefix: '10.0.1.0/24' + addressPrefix: '10.0.0.0/23' delegations: [ { properties: { @@ -35,15 +35,15 @@ resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { name: 'private-endpoints' properties: { - addressPrefix: '10.0.2.0/24' + addressPrefix: '10.0.2.0/27' } parent: vnet dependsOn: [ - subnet1 + container_apps ] } -output subnet1_Id string = subnet1.id +output container_apps_Id string = container_apps.id output private_endpoints_Id string = private_endpoints.id From b89e6498ec9c82a2dd206259738a5139d8dacbd6 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 3 Feb 2026 14:57:50 -0600 Subject: [PATCH 14/19] Fix AddSubnet API --- .../Program.cs | 4 ++-- .../AzureVirtualNetworkExtensions.cs | 19 ++----------------- .../AzureVirtualNetworkExtensionsTests.cs | 4 ++-- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs index e9b4943b9e8..f4a31862939 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -11,11 +11,11 @@ // - One for private endpoints var vnet = builder.AddAzureVirtualNetwork("vnet"); -var containerAppsSubnet = vnet.AddSubnet("container-apps", subnetName: null, "10.0.0.0/23") +var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23") .WithAnnotation( new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); -var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", subnetName: null, "10.0.2.0/27"); +var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27"); // Configure the Container App Environment to use the VNet builder.AddAzureContainerAppEnvironment("env") diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 9fec5761f9a..9a28ab27af2 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -112,28 +112,13 @@ void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) /// The Virtual Network resource builder. /// The name of the subnet resource. /// The address prefix for the subnet (e.g., "10.0.1.0/24"). - /// A reference to the . - public static IResourceBuilder AddSubnet( - this IResourceBuilder builder, - [ResourceName] string name, - string addressPrefix) - { - return builder.AddSubnet(name, null, addressPrefix); - } - - /// - /// Adds an Azure Subnet to the Virtual Network. - /// - /// The Virtual Network resource builder. - /// The name of the subnet resource. /// The subnet name in Azure. If null, the resource name is used. - /// The address prefix for the subnet (e.g., "10.0.1.0/24"). /// A reference to the . public static IResourceBuilder AddSubnet( this IResourceBuilder builder, [ResourceName] string name, - string? subnetName, - string addressPrefix) + string addressPrefix, + string? subnetName = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs index 6944b7b4f6a..e3fc7975be0 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -54,7 +54,7 @@ public void AddSubnet_WithCustomSubnetName() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var vnet = builder.AddAzureVirtualNetwork("myvnet"); - var subnet = vnet.AddSubnet("mysubnet", "custom-subnet-name", "10.0.1.0/24"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.1.0/24", subnetName: "custom-subnet-name"); Assert.Equal("mysubnet", subnet.Resource.Name); Assert.Equal("custom-subnet-name", subnet.Resource.SubnetName); @@ -85,7 +85,7 @@ public async Task AddAzureVirtualNetwork_WithSubnets_GeneratesBicep() var vnet = builder.AddAzureVirtualNetwork("myvnet"); vnet.AddSubnet("subnet1", "10.0.1.0/24") .WithAnnotation(new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); - vnet.AddSubnet("subnet2", "custom-subnet-name", "10.0.2.0/24"); + vnet.AddSubnet("subnet2", "10.0.2.0/24", subnetName: "custom-subnet-name"); var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); From 3f38b039b6025f90fe186a85b0434c6c3eefa658 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 3 Feb 2026 16:33:03 -0600 Subject: [PATCH 15/19] Rename AddAzurePrivateEndpoint and hang it off of AzureSubnetResource. --- .../Program.cs | 4 ++-- .../AzurePrivateEndpointExtensions.cs | 12 ++++------ src/Aspire.Hosting.Azure.Network/README.md | 2 +- .../AzurePrivateEndpointExtensionsTests.cs | 24 +++++++++---------- ...zureStoragePrivateEndpointLockdownTests.cs | 6 ++--- ...t_ForQueues_GeneratesBicep.verified.bicep} | 0 ...ateEndpoint_GeneratesBicep.verified.bicep} | 0 7 files changed, 23 insertions(+), 25 deletions(-) rename tests/Aspire.Hosting.Azure.Tests/Snapshots/{AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep => AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep} (100%) rename tests/Aspire.Hosting.Azure.Tests/Snapshots/{AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep => AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep} (100%) diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs index f4a31862939..8ce930a72b2 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -45,8 +45,8 @@ // - Links the DNS zones to the VNet // - Creates the Private Endpoints // - Locks down public access to the storage account -builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, blobs); -builder.AddAzurePrivateEndpoint(privateEndpointsSubnet, queues); +privateEndpointsSubnet.AddPrivateEndpoint(blobs); +privateEndpointsSubnet.AddPrivateEndpoint(queues); builder.AddProject("api") .WithExternalHttpEndpoints() diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index e7b0a330671..2428457b76b 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -18,10 +18,9 @@ namespace Aspire.Hosting; public static class AzurePrivateEndpointExtensions { /// - /// Adds an Azure Private Endpoint resource to the application model. + /// Adds an Azure Private Endpoint resource to the subnet. /// - /// The builder for the distributed application. - /// The subnet associated with the private endpoint. + /// The subnet to add the private endpoint to. /// The target Azure resource to connect via private link. /// A reference to the . /// @@ -36,15 +35,14 @@ public static class AzurePrivateEndpointExtensions /// the network settings. /// /// - public static IResourceBuilder AddAzurePrivateEndpoint( - this IDistributedApplicationBuilder builder, - IResourceBuilder subnet, + public static IResourceBuilder AddPrivateEndpoint( + this IResourceBuilder subnet, IResourceBuilder target) { - ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(subnet); ArgumentNullException.ThrowIfNull(target); + var builder = subnet.ApplicationBuilder; var name = $"{subnet.Resource.Name}-{target.Resource.Name}-pe"; var resource = new AzurePrivateEndpointResource(name, ConfigurePrivateEndpoint) diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md index 85f30861113..fa165aa39ce 100644 --- a/src/Aspire.Hosting.Azure.Network/README.md +++ b/src/Aspire.Hosting.Azure.Network/README.md @@ -73,7 +73,7 @@ var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobs("blobs"); // Add a private endpoint for the blob storage -builder.AddAzurePrivateEndpoint(peSubnet, blobs); +builder.AddPrivateEndpoint(peSubnet, blobs); ``` When you add a private endpoint to an Azure resource: diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs index 47e6b700b62..42c5583e317 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting.Azure.Tests; public class AzurePrivateEndpointExtensionsTests { [Fact] - public void AddAzurePrivateEndpoint_CreatesResource() + public void AddPrivateEndpoint_CreatesResource() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); @@ -19,7 +19,7 @@ public void AddAzurePrivateEndpoint_CreatesResource() var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobs("blobs"); - var pe = builder.AddAzurePrivateEndpoint(subnet, blobs); + var pe = subnet.AddPrivateEndpoint(blobs); Assert.NotNull(pe); Assert.Equal("pesubnet-blobs-pe", pe.Resource.Name); @@ -29,7 +29,7 @@ public void AddAzurePrivateEndpoint_CreatesResource() } [Fact] - public void AddAzurePrivateEndpoint_AddsAnnotationToParentStorage() + public void AddPrivateEndpoint_AddsAnnotationToParentStorage() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); @@ -41,7 +41,7 @@ public void AddAzurePrivateEndpoint_AddsAnnotationToParentStorage() // Before adding PE, no annotation Assert.Empty(storage.Resource.Annotations.OfType()); - builder.AddAzurePrivateEndpoint(subnet, blobs); + subnet.AddPrivateEndpoint(blobs); // After adding PE, annotation should be on parent storage var annotation = storage.Resource.Annotations.OfType().SingleOrDefault(); @@ -49,7 +49,7 @@ public void AddAzurePrivateEndpoint_AddsAnnotationToParentStorage() } [Fact] - public void AddAzurePrivateEndpoint_ForQueues_AddsAnnotationToParentStorage() + public void AddPrivateEndpoint_ForQueues_AddsAnnotationToParentStorage() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); @@ -58,14 +58,14 @@ public void AddAzurePrivateEndpoint_ForQueues_AddsAnnotationToParentStorage() var storage = builder.AddAzureStorage("storage"); var queues = storage.AddQueues("queues"); - builder.AddAzurePrivateEndpoint(subnet, queues); + subnet.AddPrivateEndpoint(queues); var annotation = storage.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); } [Fact] - public async Task AddAzurePrivateEndpoint_GeneratesBicep() + public async Task AddPrivateEndpoint_GeneratesBicep() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); @@ -74,7 +74,7 @@ public async Task AddAzurePrivateEndpoint_GeneratesBicep() var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobs("blobs"); - var pe = builder.AddAzurePrivateEndpoint(subnet, blobs); + var pe = subnet.AddPrivateEndpoint(blobs); var manifest = await AzureManifestUtils.GetManifestWithBicep(pe.Resource); @@ -82,7 +82,7 @@ public async Task AddAzurePrivateEndpoint_GeneratesBicep() } [Fact] - public async Task AddAzurePrivateEndpoint_ForQueues_GeneratesBicep() + public async Task AddPrivateEndpoint_ForQueues_GeneratesBicep() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); @@ -91,7 +91,7 @@ public async Task AddAzurePrivateEndpoint_ForQueues_GeneratesBicep() var storage = builder.AddAzureStorage("storage"); var queues = storage.AddQueues("queues"); - var pe = builder.AddAzurePrivateEndpoint(subnet, queues); + var pe = subnet.AddPrivateEndpoint(queues); var manifest = await AzureManifestUtils.GetManifestWithBicep(pe.Resource); @@ -99,7 +99,7 @@ public async Task AddAzurePrivateEndpoint_ForQueues_GeneratesBicep() } [Fact] - public void AddAzurePrivateEndpoint_InRunMode_DoesNotAddToBuilder() + public void AddPrivateEndpoint_InRunMode_DoesNotAddToBuilder() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); @@ -108,7 +108,7 @@ public void AddAzurePrivateEndpoint_InRunMode_DoesNotAddToBuilder() var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobs("blobs"); - var pe = builder.AddAzurePrivateEndpoint(subnet, blobs); + var pe = subnet.AddPrivateEndpoint(blobs); // In run mode, the PE resource should not be added to the builder's resources Assert.DoesNotContain(pe.Resource, builder.Resources); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs index 66522fbc74d..e5b8bfcba07 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs @@ -24,7 +24,7 @@ public async Task AddAzureStorage_WithPrivateEndpoint_CanOverrideWithConfigureIn }); var blobs = storage.AddBlobs("blobs"); - builder.AddAzurePrivateEndpoint(subnet, blobs); + subnet.AddPrivateEndpoint(blobs); var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); @@ -44,8 +44,8 @@ public async Task AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep() var blobs = storage.AddBlobs("blobs"); var queues = storage.AddQueues("queues"); - builder.AddAzurePrivateEndpoint(subnet, blobs); - builder.AddAzurePrivateEndpoint(subnet, queues); + subnet.AddPrivateEndpoint(blobs); + subnet.AddPrivateEndpoint(queues); var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep similarity index 100% rename from tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep rename to tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep similarity index 100% rename from tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddAzurePrivateEndpoint_GeneratesBicep.verified.bicep rename to tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep From ecf401bb568f7f1260db17fffe8918a2191941f5 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 3 Feb 2026 16:45:16 -0600 Subject: [PATCH 16/19] Don't add PrivateEndpointTargetAnnotation in run mode. Also walk the hierarchy until you reach the root resource. --- .../AzurePrivateEndpointExtensions.cs | 24 +++++++++---------- .../AzureStorageExtensions.cs | 2 +- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index 2428457b76b..e871830281f 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -51,25 +51,23 @@ public static IResourceBuilder AddPrivateEndpoint( Target = target.Resource }; - // Add annotation to the target's parent (e.g., storage account) to signal - // that it should deny public network access - var targetResource = target.Resource; - if (targetResource is IResourceWithParent parentedResource) - { - parentedResource.Parent.Annotations.Add(new PrivateEndpointTargetAnnotation()); - } - else - { - // If the target itself is the top-level resource, annotate it directly - targetResource.Annotations.Add(new PrivateEndpointTargetAnnotation()); - } - if (builder.ExecutionContext.IsRunMode) { // In run mode, we don't want to add the resource to the builder. return builder.CreateResourceBuilder(resource); } + // Add annotation to the target's root parent (e.g., storage account) to signal + // that it should deny public network access. + // This should only be done in publish mode. In run mode, the target resource + // needs to be accessible over the public internet so the local app can reach it. + IResource rootResource = target.Resource; + while (rootResource is IResourceWithParent parentedResource) + { + rootResource = parentedResource.Parent; + } + rootResource.Annotations.Add(new PrivateEndpointTargetAnnotation()); + return builder.AddResource(resource); void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index e08e0597a3c..f2513ccd5af 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -58,7 +58,7 @@ public static IResourceBuilder AddAzureStorage(this IDistr (infrastructure) => { // Check if this storage has a private endpoint (via annotation) - var hasPrivateEndpoint = azureResource.Annotations.OfType().Any(); + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); var storageAccount = new StorageAccount(infrastructure.AspireResource.GetBicepIdentifier()) { From 68ae80fd8f3359cc23ad70d608c2e21dd2eabd91 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 3 Feb 2026 17:24:58 -0600 Subject: [PATCH 17/19] Add WithSubnet API for ACA environments and improve delegation naming - Add IAzureDelegatedSubnetResource interface and DelegatedSubnetAnnotation to Aspire.Hosting.Azure - Implement IAzureDelegatedSubnetResource on AzureContainerAppEnvironmentResource - Add WithSubnet extension method in Network package for configuring subnet delegation - Update ACA Bicep generation to read DelegatedSubnetAnnotation and configure VnetConfiguration - Use serviceName for delegation name in Bicep output (e.g., 'Microsoft.App/environments') - Mark new public APIs with [Experimental("ASPIREAZURE003")] - Add unit test verifying ACA and VNet Bicep output with subnet delegation --- .../Program.cs | 20 +---- .../vnet.module.bicep | 2 +- .../AzureContainerAppEnvironmentResource.cs | 6 +- .../AzureContainerAppExtensions.cs | 11 +++ .../Aspire.Hosting.Azure.Network.csproj | 2 +- .../AzurePrivateEndpointExtensions.cs | 2 - .../AzurePrivateEndpointResource.cs | 2 - .../AzureSubnetResource.cs | 1 - .../AzureSubnetServiceDelegationAnnotation.cs | 8 +- .../AzureVirtualNetworkExtensions.cs | 33 +++++++ .../DelegatedSubnetAnnotation.cs | 20 +++++ .../IAzureDelegatedSubnetResource.cs | 19 ++++ ...eContainerAppEnvironmentExtensionsTests.cs | 19 ++++ .../AzureVirtualNetworkExtensionsTests.cs | 1 - ...guresVnetConfiguration#vnet.verified.bicep | 39 ++++++++ ...ConfiguresVnetConfiguration.verified.bicep | 89 +++++++++++++++++++ 16 files changed, 244 insertions(+), 30 deletions(-) create mode 100644 src/Aspire.Hosting.Azure/DelegatedSubnetAnnotation.cs create mode 100644 src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration.verified.bicep diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs index 8ce930a72b2..0addf39ab82 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.Azure.Network; -using Azure.Provisioning.AppContainers; - var builder = DistributedApplication.CreateBuilder(args); // Create a virtual network with two subnets: @@ -11,25 +8,12 @@ // - One for private endpoints var vnet = builder.AddAzureVirtualNetwork("vnet"); -var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23") - .WithAnnotation( - new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); - +var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23"); var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27"); // Configure the Container App Environment to use the VNet builder.AddAzureContainerAppEnvironment("env") - .ConfigureInfrastructure(infra => - { - var env = infra.GetProvisionableResources() - .OfType() - .Single(); - - env.VnetConfiguration = new ContainerAppVnetConfiguration - { - InfrastructureSubnetId = containerAppsSubnet.Resource.Id.AsProvisioningParameter(infra) - }; - }); + .WithSubnet(containerAppsSubnet); var storage = builder.AddAzureStorage("storage").RunAsEmulator(); diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep index 1a355c2f726..10c4439e46a 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep @@ -25,7 +25,7 @@ resource container_apps 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = properties: { serviceName: 'Microsoft.App/environments' } - name: 'ContainerAppsDelegation' + name: 'Microsoft.App/environments' } ] } diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index e3c48d0e18b..1ed6696850f 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREAZURE001 +#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 System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; @@ -18,9 +19,12 @@ namespace Aspire.Hosting.Azure.AppContainers; /// #pragma warning disable CS0618 // Type or member is obsolete public class AzureContainerAppEnvironmentResource : - AzureProvisioningResource, IAzureComputeEnvironmentResource, IAzureContainerRegistry + AzureProvisioningResource, IAzureComputeEnvironmentResource, IAzureContainerRegistry, IAzureDelegatedSubnetResource #pragma warning restore CS0618 // Type or member is obsolete { + /// + string IAzureDelegatedSubnetResource.DelegatedSubnetServiceName => "Microsoft.App/environments"; + /// /// Initializes a new instance of the class. /// diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index b000795d993..b86febc57ec 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.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 System.Diagnostics; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; @@ -151,6 +153,15 @@ public static IResourceBuilder AddAzureCon Tags = tags }; + // Configure VNet integration if a subnet is specified + if (appEnvResource.TryGetLastAnnotation(out var subnetAnnotation)) + { + containerAppEnvironment.VnetConfiguration = new ContainerAppVnetConfiguration + { + InfrastructureSubnetId = subnetAnnotation.SubnetId.AsProvisioningParameter(infra) + }; + } + infra.Add(containerAppEnvironment); if (appEnvResource.EnableDashboard) diff --git a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj index e0d71eab28f..0b01d07ca2a 100644 --- a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj +++ b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj @@ -7,7 +7,7 @@ Azure Virtual Network resource types for Aspire. $(SharedDir)Azure_256x.png true - $(NoWarn);AZPROVISION001 + $(NoWarn);AZPROVISION001;ASPIREAZURE003 diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index e871830281f..af0d5894f1c 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -1,8 +1,6 @@ // 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 Azure.Provisioning; diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs index 39c870da811..78f9532f7e5 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs @@ -1,8 +1,6 @@ // 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 Azure.Provisioning.Network; using Azure.Provisioning.Primitives; diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index 4aa964e70b0..0594bd67908 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Azure.Network; using Azure.Provisioning; using Azure.Provisioning.Network; using Azure.Provisioning.Primitives; diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs index 6174524ac29..fe10e5f6d3a 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs @@ -3,12 +3,14 @@ using Aspire.Hosting.ApplicationModel; -namespace Aspire.Hosting.Azure.Network; +namespace Aspire.Hosting.Azure; /// -/// Anotation to specify a service delegation for an Azure Subnet. +/// Annotation to specify a service delegation for an Azure Subnet. /// -public sealed class AzureSubnetServiceDelegationAnnotation(string name, string serviceName) : IResourceAnnotation +/// The name of the service delegation. +/// The service name for the delegation (e.g., "Microsoft.App/environments"). +internal sealed class AzureSubnetServiceDelegationAnnotation(string name, string serviceName) : IResourceAnnotation { /// /// Gets or sets the name associated with the service delegation. diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 9a28ab27af2..6ac2fe9aeda 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -139,4 +139,37 @@ public static IResourceBuilder AddSubnet( return builder.ApplicationBuilder.AddResource(subnet) .ExcludeFromManifest(); } + + /// + /// Configures the resource to use the specified subnet with appropriate service delegation. + /// + /// The type of resource that requires subnet delegation. + /// The resource builder. + /// The subnet to associate with the resource. + /// A reference to the . + /// + /// This method automatically configures the subnet with the appropriate service delegation + /// for the target resource type (e.g., "Microsoft.App/environments" for Azure Container Apps). + /// + public static IResourceBuilder WithSubnet( + this IResourceBuilder builder, + IResourceBuilder subnet) + where T : IAzureDelegatedSubnetResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(subnet); + + var target = builder.Resource; + + // Store the subnet ID reference on the target resource via annotation + builder.WithAnnotation( + new DelegatedSubnetAnnotation(ReferenceExpression.Create($"{subnet.Resource.Id}"))); + + // Add service delegation annotation to the subnet + subnet.WithAnnotation(new AzureSubnetServiceDelegationAnnotation( + target.DelegatedSubnetServiceName, + target.DelegatedSubnetServiceName)); + + return builder; + } } diff --git a/src/Aspire.Hosting.Azure/DelegatedSubnetAnnotation.cs b/src/Aspire.Hosting.Azure/DelegatedSubnetAnnotation.cs new file mode 100644 index 00000000000..8326cd75564 --- /dev/null +++ b/src/Aspire.Hosting.Azure/DelegatedSubnetAnnotation.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// Annotation that stores a reference to a subnet for an Azure resource that implements . +/// +/// The subnet ID reference expression. +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] +public sealed class DelegatedSubnetAnnotation(ReferenceExpression subnetId) : IResourceAnnotation +{ + /// + /// Gets the subnet ID reference expression. + /// + public ReferenceExpression SubnetId { get; } = subnetId; +} diff --git a/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs b/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs new file mode 100644 index 00000000000..be556dc7e9c --- /dev/null +++ b/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure resource that requires a subnet with service delegation. +/// +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] +public interface IAzureDelegatedSubnetResource : IResource +{ + /// + /// Gets the service delegation service name (e.g., "Microsoft.App/environments"). + /// + string DelegatedSubnetServiceName { get; } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs index a6218a43f6e..cab0e426dee 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIRECOMPUTE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.AppContainers; @@ -133,4 +134,22 @@ public void ContainerRegistry_ThrowsWhenNonAzureRegistryConfigured() Assert.Contains("not an Azure Container Registry", exception.Message); Assert.Contains("env", exception.Message); } + + [Fact] + public async Task WithSubnet_ConfiguresVnetConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23"); + + var containerAppEnvironment = builder.AddAzureContainerAppEnvironment("env") + .WithSubnet(subnet); + + var (_, envBicep) = await AzureManifestUtils.GetManifestWithBicep(containerAppEnvironment.Resource); + var (_, vnetBicep) = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(envBicep, extension: "bicep") + .AppendContentAsFile(vnetBicep, "bicep", "vnet"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs index e3fc7975be0..3283a8e5e14 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -3,7 +3,6 @@ #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.Azure.Network; using Aspire.Hosting.Utils; namespace Aspire.Hosting.Azure.Tests; diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep new file mode 100644 index 00000000000..e9c6c13ec83 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep @@ -0,0 +1,39 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource container_apps_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'container-apps-subnet' + properties: { + addressPrefix: '10.0.0.0/23' + delegations: [ + { + properties: { + serviceName: 'Microsoft.App/environments' + } + name: 'Microsoft.App/environments' + } + ] + } + parent: myvnet +} + +output container_apps_subnet_Id string = container_apps_subnet.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration.verified.bicep new file mode 100644 index 00000000000..89b970e4830 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration.verified.bicep @@ -0,0 +1,89 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +param myvnet_outputs_container_apps_subnet_id string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + vnetConfiguration: { + infrastructureSubnetId: myvnet_outputs_container_apps_subnet_id + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain \ No newline at end of file From 53c5ca52e0d2adc81e41b628cefa5a52671c9016 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 3 Feb 2026 17:47:14 -0600 Subject: [PATCH 18/19] Separate and reuse Private DNS Zones across private endpoints - Add AzurePrivateDnsZoneResource for shared DNS Zone management - Add AzurePrivateDnsZoneVNetLinkResource for VNet-to-zone linking - DNS Zones are cached at builder level and reused per zone name - VNet Links tracked on DNS Zone to avoid duplicates - PE Bicep now references existing DNS Zone instead of creating inline - Add tests for DNS Zone reuse scenarios - Add InternalsVisibleTo for test access to internal types --- .../aspire-manifest.json | 18 ++- .../private-endpoints-blobs-pe.module.bicep | 25 +--- .../private-endpoints-queues-pe.module.bicep | 25 +--- ...atelink-blob-core-windows-net.module.bicep | 31 +++++ ...telink-queue-core-windows-net.module.bicep | 31 +++++ .../Aspire.Hosting.Azure.Network.csproj | 4 + .../AzurePrivateDnsZoneResource.cs | 117 ++++++++++++++++++ .../AzurePrivateDnsZoneVNetLinkResource.cs | 25 ++++ .../AzurePrivateEndpointExtensions.cs | 82 ++++++++---- .../AzurePrivateEndpointResource.cs | 5 + .../AzurePrivateEndpointExtensionsTests.cs | 64 ++++++++++ ...nt_ForQueues_GeneratesBicep.verified.bicep | 25 +--- ...vateEndpoint_GeneratesBicep.verified.bicep | 25 +--- ...DnsZone_ForSameZoneName#pe1.verified.bicep | 55 ++++++++ ...DnsZone_ForSameZoneName#pe2.verified.bicep | 55 ++++++++ ...usesDnsZone_ForSameZoneName.verified.bicep | 31 +++++ 16 files changed, 501 insertions(+), 117 deletions(-) create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-blob-core-windows-net.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-queue-core-windows-net.module.bicep create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneVNetLinkResource.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe1.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe2.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName.verified.bicep diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json index 2186f38e955..2c8a2db9647 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json @@ -46,20 +46,34 @@ "type": "value.v0", "connectionString": "Endpoint={storage.outputs.queueEndpoint};QueueName=myqueue" }, + "privatelink-blob-core-windows-net": { + "type": "azure.bicep.v0", + "path": "privatelink-blob-core-windows-net.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}" + } + }, "private-endpoints-blobs-pe": { "type": "azure.bicep.v0", "path": "private-endpoints-blobs-pe.module.bicep", "params": { - "vnet_outputs_id": "{vnet.outputs.id}", + "privatelink_blob_core_windows_net_outputs_name": "{privatelink-blob-core-windows-net.outputs.name}", "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", "storage_outputs_id": "{storage.outputs.id}" } }, + "privatelink-queue-core-windows-net": { + "type": "azure.bicep.v0", + "path": "privatelink-queue-core-windows-net.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}" + } + }, "private-endpoints-queues-pe": { "type": "azure.bicep.v0", "path": "private-endpoints-queues-pe.module.bicep", "params": { - "vnet_outputs_id": "{vnet.outputs.id}", + "privatelink_queue_core_windows_net_outputs_name": "{privatelink-queue-core-windows-net.outputs.name}", "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", "storage_outputs_id": "{storage.outputs.id}" } diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep index f324ec93c9d..18c7dfa25f5 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep @@ -1,33 +1,14 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param vnet_outputs_id string +param privatelink_blob_core_windows_net_outputs_name string param vnet_outputs_private_endpoints_id string param storage_outputs_id string -resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: 'privatelink.blob.core.windows.net' - location: 'global' - tags: { - 'aspire-resource-name': 'private-endpoints-blobs-pe-dns' - } -} - -resource privatelink_blob_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'private-endpoints-blobs-pe-vnet-link' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet_outputs_id - } - } - tags: { - 'aspire-resource-name': 'private-endpoints-blobs-pe-vnetlink' - } - parent: privatelink_blob_core_windows_net +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_blob_core_windows_net_outputs_name } resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep index a7c2697206f..a05692409d5 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep @@ -1,33 +1,14 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param vnet_outputs_id string +param privatelink_queue_core_windows_net_outputs_name string param vnet_outputs_private_endpoints_id string param storage_outputs_id string -resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: 'privatelink.queue.core.windows.net' - location: 'global' - tags: { - 'aspire-resource-name': 'private-endpoints-queues-pe-dns' - } -} - -resource privatelink_queue_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'private-endpoints-queues-pe-vnet-link' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet_outputs_id - } - } - tags: { - 'aspire-resource-name': 'private-endpoints-queues-pe-vnetlink' - } - parent: privatelink_queue_core_windows_net +resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_queue_core_windows_net_outputs_name } resource private_endpoints_queues_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-blob-core-windows-net.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-blob-core-windows-net.module.bicep new file mode 100644 index 00000000000..fc0adaad306 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-blob-core-windows-net.module.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.blob.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-blob-core-windows-net' + } +} + +resource vnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-blob-core-windows-net-vnet-link' + } + parent: privatelink_blob_core_windows_net +} + +output id string = privatelink_blob_core_windows_net.id + +output name string = 'privatelink.blob.core.windows.net' \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-queue-core-windows-net.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-queue-core-windows-net.module.bicep new file mode 100644 index 00000000000..60e104c75e1 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-queue-core-windows-net.module.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_id string + +resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.queue.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-queue-core-windows-net' + } +} + +resource vnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-queue-core-windows-net-vnet-link' + } + parent: privatelink_queue_core_windows_net +} + +output id string = privatelink_queue_core_windows_net.id + +output name string = 'privatelink.queue.core.windows.net' \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj index 0b01d07ca2a..485c5baba74 100644 --- a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj +++ b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj @@ -16,4 +16,8 @@ + + + + diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneResource.cs new file mode 100644 index 00000000000..c0eae36a973 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneResource.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Core; +using Azure.Provisioning; +using Azure.Provisioning.Primitives; +using Azure.Provisioning.PrivateDns; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Private DNS Zone resource. +/// +internal sealed class AzurePrivateDnsZoneResource : AzureProvisioningResource +{ + /// + /// Initializes a new instance of . + /// + /// The Aspire resource name. + /// The DNS zone name (e.g., "privatelink.blob.core.windows.net"). + public AzurePrivateDnsZoneResource(string name, string zoneName) + : base(name, ConfigureDnsZone) + { + ZoneName = zoneName; + } + + /// + /// Gets the DNS zone name (e.g., "privatelink.blob.core.windows.net"). + /// + public string ZoneName { get; } + + /// + /// Gets the "id" output reference from the Private DNS Zone resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference from the Private DNS Zone resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + /// Tracks VNet Links for this DNS Zone, keyed by VNet resource. + /// + internal Dictionary VNetLinks { get; } = []; + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + // Check if a PrivateDnsZone with the same identifier already exists + var existingZone = resources.OfType().SingleOrDefault(z => z.BicepIdentifier == bicepIdentifier); + + if (existingZone is not null) + { + return existingZone; + } + + // Create and add new resource if it doesn't exist + var dnsZone = PrivateDnsZone.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation( + this, + infra, + dnsZone)) + { + dnsZone.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(dnsZone); + return dnsZone; + } + + private static void ConfigureDnsZone(AzureResourceInfrastructure infra) + { + var resource = (AzurePrivateDnsZoneResource)infra.AspireResource; + + var dnsZone = new PrivateDnsZone(infra.AspireResource.GetBicepIdentifier()) + { + Name = resource.ZoneName, + Location = new AzureLocation("global"), + Tags = { { "aspire-resource-name", resource.Name } } + }; + infra.Add(dnsZone); + + // Create VNet Links for all linked VNets + foreach (var vnetLinkEntry in resource.VNetLinks) + { + var vnetLink = vnetLinkEntry.Value; + var linkIdentifier = Infrastructure.NormalizeBicepIdentifier($"{vnetLink.VNet.Name}_link"); + + var link = new VirtualNetworkLink(linkIdentifier) + { + Name = $"{vnetLink.VNet.Name}-link", + Parent = dnsZone, + Location = new AzureLocation("global"), + RegistrationEnabled = false, + VirtualNetworkId = vnetLink.VNet.Id.AsProvisioningParameter(infra), + Tags = { { "aspire-resource-name", vnetLink.Name } } + }; + infra.Add(link); + } + + // Output the DNS Zone ID for references + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = dnsZone.Id + }); + + infra.Add(new ProvisioningOutput("name", typeof(string)) + { + Value = dnsZone.Name + }); + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneVNetLinkResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneVNetLinkResource.cs new file mode 100644 index 00000000000..48bcfd391f6 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneVNetLinkResource.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Private DNS Zone VNet Link resource. +/// +internal sealed class AzurePrivateDnsZoneVNetLinkResource( + string name, + AzurePrivateDnsZoneResource dnsZone, + AzureVirtualNetworkResource vnet) : Resource(name), IResourceWithParent +{ + /// + /// Gets the parent DNS Zone resource. + /// + public AzurePrivateDnsZoneResource Parent { get; } = dnsZone; + + /// + /// Gets the VNet resource linked to the DNS Zone. + /// + public AzureVirtualNetworkResource VNet { get; } = vnet; +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index af0d5894f1c..eb2ea091ffa 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -5,7 +5,6 @@ using Aspire.Hosting.Azure; using Azure.Provisioning; using Azure.Provisioning.Network; -using Azure.Core; using Azure.Provisioning.PrivateDns; namespace Aspire.Hosting; @@ -24,7 +23,8 @@ public static class AzurePrivateEndpointExtensions /// /// /// This method automatically creates the Private DNS Zone, VNet Link, and DNS Zone Group - /// required for private endpoint DNS resolution. + /// required for private endpoint DNS resolution. Private DNS Zones are shared across + /// multiple private endpoints that use the same zone name. /// /// /// When a private endpoint is added, the target resource (or its parent) is automatically @@ -42,6 +42,7 @@ public static IResourceBuilder AddPrivateEndpoint( var builder = subnet.ApplicationBuilder; var name = $"{subnet.Resource.Name}-{target.Resource.Name}-pe"; + var vnet = subnet.Resource.Parent; var resource = new AzurePrivateEndpointResource(name, ConfigurePrivateEndpoint) { @@ -55,6 +56,11 @@ public static IResourceBuilder AddPrivateEndpoint( return builder.CreateResourceBuilder(resource); } + // Get or create the shared Private DNS Zone for this zone name + var zoneName = target.Resource.GetPrivateDnsZoneName(); + var dnsZone = GetOrCreatePrivateDnsZone(builder, zoneName, vnet); + resource.DnsZone = dnsZone; + // Add annotation to the target's root parent (e.g., storage account) to signal // that it should deny public network access. // This should only be done in publish mode. In run mode, the target resource @@ -72,31 +78,13 @@ void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) { var azureResource = (AzurePrivateEndpointResource)infra.AspireResource; - // Create Private DNS Zone for the target service - var dnsZoneName = azureResource.Target!.GetPrivateDnsZoneName(); - var dnsZoneIdentifier = Infrastructure.NormalizeBicepIdentifier(dnsZoneName.Replace(".", "_")); - - var privateDnsZone = new PrivateDnsZone(dnsZoneIdentifier) - { - Name = dnsZoneName, - Location = new AzureLocation("global"), - Tags = { { "aspire-resource-name", $"{azureResource.Name}-dns" } } - }; + // Get the shared DNS Zone as an existing resource + var dnsZone = azureResource.DnsZone!; + var dnsZoneIdentifier = dnsZone.GetBicepIdentifier(); + var privateDnsZone = PrivateDnsZone.FromExisting(dnsZoneIdentifier); + privateDnsZone.Name = dnsZone.NameOutput.AsProvisioningParameter(infra); infra.Add(privateDnsZone); - // Create VNet Link to connect DNS zone to the VNet - var vnetLinkIdentifier = $"{dnsZoneIdentifier}_vnetlink"; - var vnetLink = new VirtualNetworkLink(vnetLinkIdentifier) - { - Name = $"{azureResource.Name}-vnet-link", - Parent = privateDnsZone, - Location = new AzureLocation("global"), - RegistrationEnabled = false, - VirtualNetworkId = azureResource.Subnet!.Parent.Id.AsProvisioningParameter(infra), - Tags = { { "aspire-resource-name", $"{azureResource.Name}-vnetlink" } } - }; - infra.Add(vnetLink); - // Create the Private Endpoint var endpoint = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, (identifier, name) => @@ -113,14 +101,14 @@ void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) }; // Configure subnet - pe.Subnet.Id = azureResource.Subnet.Id.AsProvisioningParameter(infrastructure); + pe.Subnet.Id = azureResource.Subnet!.Id.AsProvisioningParameter(infrastructure); // Configure private link service connection pe.PrivateLinkServiceConnections.Add( new NetworkPrivateLinkServiceConnection { Name = $"{azureResource.Name}-connection", - PrivateLinkServiceId = azureResource.Target.Id.AsProvisioningParameter(infrastructure), + PrivateLinkServiceId = azureResource.Target!.Id.AsProvisioningParameter(infrastructure), GroupIds = [.. azureResource.Target.GetPrivateLinkGroupIds()] }); @@ -154,4 +142,44 @@ void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = endpoint.Name }); } } + + /// + /// Gets or creates a shared Private DNS Zone for the given zone name and VNet. + /// + private static AzurePrivateDnsZoneResource GetOrCreatePrivateDnsZone( + IDistributedApplicationBuilder builder, + string zoneName, + AzureVirtualNetworkResource vnet) + { + // Search for existing DNS Zone with matching zone name + var existingZone = builder.Resources + .OfType() + .FirstOrDefault(z => z.ZoneName == zoneName); + + AzurePrivateDnsZoneResource dnsZone; + + if (existingZone is not null) + { + dnsZone = existingZone; + } + else + { + // Create new DNS Zone resource - use hyphens for resource name + var zoneResourceName = zoneName.Replace(".", "-"); + dnsZone = new AzurePrivateDnsZoneResource(zoneResourceName, zoneName); + builder.AddResource(dnsZone); + } + + // Check if VNet Link already exists for this VNet + if (!dnsZone.VNetLinks.ContainsKey(vnet)) + { + // Create VNet Link resource + var linkName = $"{dnsZone.Name}-{vnet.Name}-link"; + var vnetLink = new AzurePrivateDnsZoneVNetLinkResource(linkName, dnsZone, vnet); + builder.AddResource(vnetLink); + dnsZone.VNetLinks[vnet] = vnetLink; + } + + return dnsZone; + } } diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs index 78f9532f7e5..03165ea1909 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs @@ -34,6 +34,11 @@ public class AzurePrivateEndpointResource(string name, Action public IAzurePrivateEndpointTarget? Target { get; set; } + /// + /// Gets or sets the Private DNS Zone for this endpoint. + /// + internal AzurePrivateDnsZoneResource? DnsZone { get; set; } + /// public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs index 42c5583e317..a1b3fb2ae83 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs @@ -143,4 +143,68 @@ public void AzureQueueStorageResource_ImplementsIAzurePrivateEndpointTarget() Assert.Equal(["queue"], target.GetPrivateLinkGroupIds()); Assert.Equal("privatelink.queue.core.windows.net", target.GetPrivateDnsZoneName()); } + + [Fact] + public async Task AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + + // Two storage accounts with blob endpoints (same DNS zone name) + var storage1 = builder.AddAzureStorage("storage1"); + var blobs1 = storage1.AddBlobs("blobs1"); + + var storage2 = builder.AddAzureStorage("storage2"); + var blobs2 = storage2.AddBlobs("blobs2"); + + // Create two private endpoints for the same DNS zone type + var pe1 = subnet.AddPrivateEndpoint(blobs1); + var pe2 = subnet.AddPrivateEndpoint(blobs2); + + // Should only have one DNS Zone resource + var dnsZones = builder.Resources.OfType().ToList(); + Assert.Single(dnsZones); + Assert.Equal("privatelink.blob.core.windows.net", dnsZones[0].ZoneName); + + // Should only have one VNet Link + var vnetLinks = builder.Resources.OfType().ToList(); + Assert.Single(vnetLinks); + + // Verify the bicep for DNS Zone, VNet Link, and both PEs + var (_, dnsZoneBicep) = await AzureManifestUtils.GetManifestWithBicep(dnsZones[0]); + var (_, pe1Bicep) = await AzureManifestUtils.GetManifestWithBicep(pe1.Resource); + var (_, pe2Bicep) = await AzureManifestUtils.GetManifestWithBicep(pe2.Resource); + + await Verify(dnsZoneBicep, extension: "bicep") + .AppendContentAsFile(pe1Bicep, "bicep", "pe1") + .AppendContentAsFile(pe2Bicep, "bicep", "pe2"); + } + + [Fact] + public void AddPrivateEndpoint_CreatesSeparateDnsZones_ForDifferentZoneNames() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + var queues = storage.AddQueues("queues"); + + // Create two private endpoints for different DNS zone types + subnet.AddPrivateEndpoint(blobs); + subnet.AddPrivateEndpoint(queues); + + // Should have two DNS Zone resources + var dnsZones = builder.Resources.OfType().ToList(); + Assert.Equal(2, dnsZones.Count); + Assert.Contains(dnsZones, z => z.ZoneName == "privatelink.blob.core.windows.net"); + Assert.Contains(dnsZones, z => z.ZoneName == "privatelink.queue.core.windows.net"); + + // Each DNS Zone should have one VNet Link (tracked on zone, not in builder.Resources) + Assert.All(dnsZones, z => Assert.Single(z.VNetLinks)); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep index 433d47da9ba..71713b626d3 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep @@ -1,33 +1,14 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param myvnet_outputs_id string +param privatelink_queue_core_windows_net_outputs_name string param myvnet_outputs_pesubnet_id string param storage_outputs_id string -resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: 'privatelink.queue.core.windows.net' - location: 'global' - tags: { - 'aspire-resource-name': 'pesubnet-queues-pe-dns' - } -} - -resource privatelink_queue_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'pesubnet-queues-pe-vnet-link' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: myvnet_outputs_id - } - } - tags: { - 'aspire-resource-name': 'pesubnet-queues-pe-vnetlink' - } - parent: privatelink_queue_core_windows_net +resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_queue_core_windows_net_outputs_name } resource pesubnet_queues_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep index c1b4109c02c..9dc6735e109 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep @@ -1,33 +1,14 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param myvnet_outputs_id string +param privatelink_blob_core_windows_net_outputs_name string param myvnet_outputs_pesubnet_id string param storage_outputs_id string -resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { - name: 'privatelink.blob.core.windows.net' - location: 'global' - tags: { - 'aspire-resource-name': 'pesubnet-blobs-pe-dns' - } -} - -resource privatelink_blob_core_windows_net_vnetlink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { - name: 'pesubnet-blobs-pe-vnet-link' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: myvnet_outputs_id - } - } - tags: { - 'aspire-resource-name': 'pesubnet-blobs-pe-vnetlink' - } - parent: privatelink_blob_core_windows_net +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_blob_core_windows_net_outputs_name } resource pesubnet_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe1.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe1.verified.bicep new file mode 100644 index 00000000000..f62af25062a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe1.verified.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_blob_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param storage1_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_blob_core_windows_net_outputs_name +} + +resource pesubnet_blobs1_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_blobs1_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage1_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'pesubnet-blobs1-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-blobs1-pe' + } +} + +resource pesubnet_blobs1_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: pesubnet_blobs1_pe +} + +output id string = pesubnet_blobs1_pe.id + +output name string = pesubnet_blobs1_pe.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe2.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe2.verified.bicep new file mode 100644 index 00000000000..276694128e2 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe2.verified.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_blob_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param storage2_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_blob_core_windows_net_outputs_name +} + +resource pesubnet_blobs2_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_blobs2_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage2_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'pesubnet-blobs2-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-blobs2-pe' + } +} + +resource pesubnet_blobs2_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: pesubnet_blobs2_pe +} + +output id string = pesubnet_blobs2_pe.id + +output name string = pesubnet_blobs2_pe.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName.verified.bicep new file mode 100644 index 00000000000..7f5ece89d1f --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName.verified.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.blob.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-blob-core-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-blob-core-windows-net-myvnet-link' + } + parent: privatelink_blob_core_windows_net +} + +output id string = privatelink_blob_core_windows_net.id + +output name string = 'privatelink.blob.core.windows.net' \ No newline at end of file From 6b423dff82dcf3d44d690a129a7ed69cec16434b Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 4 Feb 2026 18:32:20 -0600 Subject: [PATCH 19/19] Address PR feedback --- .../Program.cs | 2 +- .../AzurePrivateEndpointExtensions.cs | 25 +++++++---- .../AzurePrivateEndpointResource.cs | 16 ++++--- .../AzureSubnetResource.cs | 19 ++------ .../AzureVirtualNetworkExtensions.cs | 43 ++++++++++++------- src/Aspire.Hosting.Azure.Network/README.md | 2 +- .../IAzureDelegatedSubnetResource.cs | 2 +- ...eContainerAppEnvironmentExtensionsTests.cs | 4 +- .../AzurePrivateEndpointExtensionsTests.cs | 3 +- .../AzureVirtualNetworkExtensionsTests.cs | 22 ++++++++++ ...uresVnetConfiguration#vnet.verified.bicep} | 0 ...onfiguresVnetConfiguration.verified.bicep} | 0 12 files changed, 88 insertions(+), 50 deletions(-) rename tests/Aspire.Hosting.Azure.Tests/Snapshots/{AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep => AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep} (100%) rename tests/Aspire.Hosting.Azure.Tests/Snapshots/{AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration.verified.bicep => AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep} (100%) diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs index 0addf39ab82..1eda44bb945 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -13,7 +13,7 @@ // Configure the Container App Environment to use the VNet builder.AddAzureContainerAppEnvironment("env") - .WithSubnet(containerAppsSubnet); + .WithDelegatedSubnet(containerAppsSubnet); var storage = builder.AddAzureStorage("storage").RunAsEmulator(); diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index eb2ea091ffa..8ffe469db69 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -33,6 +33,18 @@ public static class AzurePrivateEndpointExtensions /// the network settings. /// /// + /// + /// This example creates a virtual network with a subnet and adds a private endpoint for Azure Storage blobs: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.1.0/24"); + /// + /// var storage = builder.AddAzureStorage("storage"); + /// var blobs = storage.AddBlobs("blobs"); + /// + /// peSubnet.AddPrivateEndpoint(blobs); + /// + /// public static IResourceBuilder AddPrivateEndpoint( this IResourceBuilder subnet, IResourceBuilder target) @@ -44,11 +56,7 @@ public static IResourceBuilder AddPrivateEndpoint( var name = $"{subnet.Resource.Name}-{target.Resource.Name}-pe"; var vnet = subnet.Resource.Parent; - var resource = new AzurePrivateEndpointResource(name, ConfigurePrivateEndpoint) - { - Subnet = subnet.Resource, - Target = target.Resource - }; + var resource = new AzurePrivateEndpointResource(name, subnet.Resource, target.Resource, ConfigurePrivateEndpoint); if (builder.ExecutionContext.IsRunMode) { @@ -101,14 +109,14 @@ void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) }; // Configure subnet - pe.Subnet.Id = azureResource.Subnet!.Id.AsProvisioningParameter(infrastructure); + pe.Subnet.Id = azureResource.Subnet.Id.AsProvisioningParameter(infrastructure); // Configure private link service connection pe.PrivateLinkServiceConnections.Add( new NetworkPrivateLinkServiceConnection { Name = $"{azureResource.Name}-connection", - PrivateLinkServiceId = azureResource.Target!.Id.AsProvisioningParameter(infrastructure), + PrivateLinkServiceId = azureResource.Target.Id.AsProvisioningParameter(infrastructure), GroupIds = [.. azureResource.Target.GetPrivateLinkGroupIds()] }); @@ -176,8 +184,9 @@ private static AzurePrivateDnsZoneResource GetOrCreatePrivateDnsZone( // Create VNet Link resource var linkName = $"{dnsZone.Name}-{vnet.Name}-link"; var vnetLink = new AzurePrivateDnsZoneVNetLinkResource(linkName, dnsZone, vnet); - builder.AddResource(vnetLink); dnsZone.VNetLinks[vnet] = vnetLink; + + builder.AddResource(vnetLink).ExcludeFromManifest(); } return dnsZone; diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs index 03165ea1909..914ab0409f8 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs @@ -10,8 +10,14 @@ namespace Aspire.Hosting.Azure; /// Represents an Azure Private Endpoint resource. /// /// The name of the resource. +/// The subnet where the private endpoint will be created. +/// The target Azure resource to connect via private link. /// Callback to configure the Azure Private Endpoint resource. -public class AzurePrivateEndpointResource(string name, Action configureInfrastructure) +public class AzurePrivateEndpointResource( + string name, + AzureSubnetResource subnet, + IAzurePrivateEndpointTarget target, + Action configureInfrastructure) : AzureProvisioningResource(name, configureInfrastructure) { /// @@ -25,14 +31,14 @@ public class AzurePrivateEndpointResource(string name, Action new("name", this); /// - /// Gets or sets the subnet where the private endpoint will be created. + /// Gets the subnet where the private endpoint will be created. /// - public AzureSubnetResource? Subnet { get; set; } + public AzureSubnetResource Subnet { get; } = subnet; /// - /// Gets or sets the target Azure resource to connect via private link. + /// Gets the target Azure resource to connect via private link. /// - public IAzurePrivateEndpointTarget? Target { get; set; } + public IAzurePrivateEndpointTarget Target { get; } = target; /// /// Gets or sets the Private DNS Zone for this endpoint. diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index 0594bd67908..ae962a7d3cf 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -23,26 +23,15 @@ namespace Aspire.Hosting.Azure; public class AzureSubnetResource(string name, string subnetName, string addressPrefix, AzureVirtualNetworkResource parent) : Resource(name), IResourceWithParent { - private string _subnetName = ThrowIfNullOrEmpty(subnetName); - private string _addressPrefix = ThrowIfNullOrEmpty(addressPrefix); - /// - /// The subnet name. + /// Gets the subnet name. /// - public string SubnetName - { - get => _subnetName; - set => _subnetName = ThrowIfNullOrEmpty(value); - } + public string SubnetName { get; } = ThrowIfNullOrEmpty(subnetName); /// - /// The address prefix for the subnet (e.g., "10.0.1.0/24"). + /// Gets the address prefix for the subnet (e.g., "10.0.1.0/24"). /// - public string AddressPrefix - { - get => _addressPrefix; - set => _addressPrefix = value; - } + public string AddressPrefix { get; } = ThrowIfNullOrEmpty(addressPrefix); /// /// Gets the subnet Id output reference. diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 6ac2fe9aeda..3aad3dd2485 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -19,25 +19,19 @@ public static class AzureVirtualNetworkExtensions /// /// The builder for the distributed application. /// The name of the Azure Virtual Network resource. - /// A reference to the . - public static IResourceBuilder AddAzureVirtualNetwork( - this IDistributedApplicationBuilder builder, - [ResourceName] string name) - { - return builder.AddAzureVirtualNetwork(name, null); - } - - /// - /// Adds an Azure Virtual Network resource to the application model with a specified address prefix. - /// - /// The builder for the distributed application. - /// The name of the Azure Virtual Network resource. /// The address prefix for the virtual network (e.g., "10.0.0.0/16"). If null, defaults to "10.0.0.0/16". /// A reference to the . + /// + /// This example creates a virtual network with a subnet for private endpoints: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("pe-subnet", "10.0.1.0/24"); + /// + /// public static IResourceBuilder AddAzureVirtualNetwork( this IDistributedApplicationBuilder builder, [ResourceName] string name, - string? addressPrefix) + string? addressPrefix = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); @@ -114,6 +108,13 @@ void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) /// The address prefix for the subnet (e.g., "10.0.1.0/24"). /// The subnet name in Azure. If null, the resource name is used. /// A reference to the . + /// + /// This example adds a subnet to a virtual network: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("my-subnet", "10.0.1.0/24"); + /// + /// public static IResourceBuilder AddSubnet( this IResourceBuilder builder, [ResourceName] string name, @@ -143,7 +144,7 @@ public static IResourceBuilder AddSubnet( /// /// Configures the resource to use the specified subnet with appropriate service delegation. /// - /// The type of resource that requires subnet delegation. + /// The type of resource that supports subnet delegation. /// The resource builder. /// The subnet to associate with the resource. /// A reference to the . @@ -151,7 +152,17 @@ public static IResourceBuilder AddSubnet( /// This method automatically configures the subnet with the appropriate service delegation /// for the target resource type (e.g., "Microsoft.App/environments" for Azure Container Apps). /// - public static IResourceBuilder WithSubnet( + /// + /// This example configures an Azure Container App Environment to use a subnet: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23"); + /// + /// var env = builder.AddAzureContainerAppEnvironment("env") + /// .WithDelegatedSubnet(subnet); + /// + /// + public static IResourceBuilder WithDelegatedSubnet( this IResourceBuilder builder, IResourceBuilder subnet) where T : IAzureDelegatedSubnetResource diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md index fa165aa39ce..09dbf9abf2c 100644 --- a/src/Aspire.Hosting.Azure.Network/README.md +++ b/src/Aspire.Hosting.Azure.Network/README.md @@ -73,7 +73,7 @@ var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobs("blobs"); // Add a private endpoint for the blob storage -builder.AddPrivateEndpoint(peSubnet, blobs); +peSubnet.AddPrivateEndpoint(blobs); ``` When you add a private endpoint to an Azure resource: diff --git a/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs b/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs index be556dc7e9c..64e2b97bc54 100644 --- a/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs +++ b/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Azure; /// -/// Represents an Azure resource that requires a subnet with service delegation. +/// Represents an Azure resource that supports subnet delegation. /// [Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] public interface IAzureDelegatedSubnetResource : IResource diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs index cab0e426dee..a28f4903744 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs @@ -136,7 +136,7 @@ public void ContainerRegistry_ThrowsWhenNonAzureRegistryConfigured() } [Fact] - public async Task WithSubnet_ConfiguresVnetConfiguration() + public async Task WithDelegatedSubnet_ConfiguresVnetConfiguration() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); @@ -144,7 +144,7 @@ public async Task WithSubnet_ConfiguresVnetConfiguration() var subnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23"); var containerAppEnvironment = builder.AddAzureContainerAppEnvironment("env") - .WithSubnet(subnet); + .WithDelegatedSubnet(subnet); var (_, envBicep) = await AzureManifestUtils.GetManifestWithBicep(containerAppEnvironment.Resource); var (_, vnetBicep) = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs index a1b3fb2ae83..eb1be832c40 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs @@ -169,6 +169,7 @@ public async Task AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName() Assert.Equal("privatelink.blob.core.windows.net", dnsZones[0].ZoneName); // Should only have one VNet Link + Assert.Single(dnsZones[0].VNetLinks); var vnetLinks = builder.Resources.OfType().ToList(); Assert.Single(vnetLinks); @@ -204,7 +205,7 @@ public void AddPrivateEndpoint_CreatesSeparateDnsZones_ForDifferentZoneNames() Assert.Contains(dnsZones, z => z.ZoneName == "privatelink.blob.core.windows.net"); Assert.Contains(dnsZones, z => z.ZoneName == "privatelink.queue.core.windows.net"); - // Each DNS Zone should have one VNet Link (tracked on zone, not in builder.Resources) + // Each DNS Zone should have one VNet Link Assert.All(dnsZones, z => Assert.Single(z.VNetLinks)); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs index 3283a8e5e14..e0b9a5db7c9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -104,4 +104,26 @@ public void AddAzureVirtualNetwork_InRunMode_DoesNotAddToBuilder() // In run mode, the subnet should not be added to the builder's resources Assert.DoesNotContain(subnet.Resource, builder.Resources); } + + [Fact] + public void WithDelegatedSubnet_AddsAnnotationsToSubnetAndTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.0.0/23"); + + var env = builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(subnet); + + // Verify the target has DelegatedSubnetAnnotation + var subnetAnnotation = env.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(subnetAnnotation); + Assert.Equal("{myvnet.outputs.mysubnet_Id}", subnetAnnotation.SubnetId.ValueExpression); + + // Verify the subnet has AzureSubnetServiceDelegationAnnotation + var delegationAnnotation = subnet.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(delegationAnnotation); + Assert.Equal("Microsoft.App/environments", delegationAnnotation.ServiceName); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep similarity index 100% rename from tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep rename to tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep similarity index 100% rename from tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithSubnet_ConfiguresVnetConfiguration.verified.bicep rename to tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep