diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index ae962a7d3cf..7717c6d3674 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.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; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; @@ -13,35 +14,68 @@ namespace Aspire.Hosting.Azure; /// /// Represents an Azure Subnet resource. /// -/// 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, string addressPrefix, AzureVirtualNetworkResource parent) - : Resource(name), IResourceWithParent +public class AzureSubnetResource : Resource, IResourceWithParent { + // Backing field holds either string or ParameterResource + private readonly object _addressPrefix; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The subnet name. + /// The address prefix for the subnet. + /// The parent Virtual Network resource. + public AzureSubnetResource(string name, string subnetName, string addressPrefix, AzureVirtualNetworkResource parent) + : base(name) + { + SubnetName = ThrowIfNullOrEmpty(subnetName); + _addressPrefix = ThrowIfNullOrEmpty(addressPrefix); + Parent = parent ?? throw new ArgumentNullException(nameof(parent)); + } + + /// + /// Initializes a new instance of the class with a parameterized address prefix. + /// + /// The name of the resource. + /// The subnet name. + /// The parameter resource containing the address prefix for the subnet. + /// The parent Virtual Network resource. + public AzureSubnetResource(string name, string subnetName, ParameterResource addressPrefix, AzureVirtualNetworkResource parent) + : base(name) + { + SubnetName = ThrowIfNullOrEmpty(subnetName); + _addressPrefix = addressPrefix ?? throw new ArgumentNullException(nameof(addressPrefix)); + Parent = parent ?? throw new ArgumentNullException(nameof(parent)); + } + /// /// Gets the subnet name. /// - public string SubnetName { get; } = ThrowIfNullOrEmpty(subnetName); + public string SubnetName { get; } /// - /// Gets 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"), or null if the address prefix is provided via a . /// - public string AddressPrefix { get; } = ThrowIfNullOrEmpty(addressPrefix); + public string? AddressPrefix => _addressPrefix as string; + + /// + /// Gets the parameter resource containing the address prefix for the subnet, or null if the address prefix is provided as a literal string. + /// + public ParameterResource? AddressPrefixParameter => _addressPrefix as ParameterResource; /// /// Gets the subnet Id output reference. /// - public BicepOutputReference Id => new($"{Infrastructure.NormalizeBicepIdentifier(Name)}_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)); + public AzureVirtualNetworkResource Parent { get; } private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) => !string.IsNullOrEmpty(argument) ? argument : throw new ArgumentNullException(paramName); @@ -54,9 +88,22 @@ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, var subnet = new SubnetResource(Infrastructure.NormalizeBicepIdentifier(Name)) { Name = SubnetName, - AddressPrefix = AddressPrefix, }; + // Set the address prefix from either the literal string or the parameter + if (_addressPrefix is string addressPrefix) + { + subnet.AddressPrefix = addressPrefix; + } + else if (_addressPrefix is ParameterResource addressPrefixParameter) + { + subnet.AddressPrefix = addressPrefixParameter.AsProvisioningParameter(infra); + } + else + { + throw new UnreachableException("AddressPrefix must be set either as a string or a ParameterResource."); + } + if (dependsOn is not null) { subnet.DependsOn.Add(dependsOn); diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 3aad3dd2485..9f464e95b8d 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -38,8 +38,46 @@ public static IResourceBuilder AddAzureVirtualNetwo builder.AddAzureProvisioning(); - AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork); + AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork, addressPrefix); + return AddAzureVirtualNetworkCore(builder, resource); + } + + /// + /// Adds an Azure Virtual Network resource to the application model with a parameterized address prefix. + /// + /// The builder for the distributed application. + /// The name of the Azure Virtual Network resource. + /// The parameter resource containing the address prefix for the virtual network (e.g., "10.0.0.0/16"). + /// A reference to the . + /// + /// This example creates a virtual network with a parameterized address prefix: + /// + /// var vnetPrefix = builder.AddParameter("vnetPrefix"); + /// var vnet = builder.AddAzureVirtualNetwork("vnet", vnetPrefix); + /// var subnet = vnet.AddSubnet("pe-subnet", "10.0.1.0/24"); + /// + /// + public static IResourceBuilder AddAzureVirtualNetwork( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder addressPrefix) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(addressPrefix); + + builder.AddAzureProvisioning(); + + AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork, addressPrefix.Resource); + + return AddAzureVirtualNetworkCore(builder, resource); + } + + private static IResourceBuilder AddAzureVirtualNetworkCore( + IDistributedApplicationBuilder builder, + AzureVirtualNetworkResource resource) + { if (builder.ExecutionContext.IsRunMode) { // In run mode, we don't want to add the resource to the builder. @@ -47,57 +85,69 @@ public static IResourceBuilder AddAzureVirtualNetwo } return builder.AddResource(resource); + } - void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) - { - var vnet = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, - (identifier, name) => + private static void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) + { + var azureResource = (AzureVirtualNetworkResource)infra.AspireResource; + + var vnet = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + (identifier, name) => + { + var resource = VirtualNetwork.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var vnet = new VirtualNetwork(infrastructure.AspireResource.GetBicepIdentifier()) { - var resource = VirtualNetwork.FromExisting(identifier); - resource.Name = name; - return resource; - }, - (infrastructure) => + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // Set the address prefix from either the literal string or the parameter + if (azureResource.AddressPrefix is { } addressPrefix) { - var vnet = new VirtualNetwork(infrastructure.AspireResource.GetBicepIdentifier()) + vnet.AddressSpace = new VirtualNetworkAddressSpace() { - AddressSpace = new VirtualNetworkAddressSpace() - { - AddressPrefixes = { addressPrefix ?? "10.0.0.0/16" } - }, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + AddressPrefixes = { addressPrefix } }; + } + else if (azureResource.AddressPrefixParameter is { } addressPrefixParameter) + { + vnet.AddressSpace = new VirtualNetworkAddressSpace() + { + AddressPrefixes = { addressPrefixParameter.AsProvisioningParameter(infrastructure) } + }; + } - return vnet; - }); - - var azureResource = (AzureVirtualNetworkResource)infra.AspireResource; + return vnet; + }); - // Add subnets - if (azureResource.Subnets.Count > 0) + // 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. + ProvisionableResource? dependsOn = null; + foreach (var subnet in azureResource.Subnets) { - // Chain subnet provisioning to ensure deployment doesn't fail - // due to parallel creation of subnets within the VNet. - ProvisionableResource? dependsOn = null; - foreach (var subnet in azureResource.Subnets) - { - var cdkSubnet = subnet.ToProvisioningEntity(infra, dependsOn); - cdkSubnet.Parent = vnet; - infra.Add(cdkSubnet); + var cdkSubnet = subnet.ToProvisioningEntity(infra, dependsOn); + cdkSubnet.Parent = vnet; + infra.Add(cdkSubnet); - dependsOn = cdkSubnet; - } + dependsOn = cdkSubnet; } + } - // Output the VNet ID for references - infra.Add(new ProvisioningOutput("id", typeof(string)) - { - Value = vnet.Id - }); + // Output the VNet ID for references + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = vnet.Id + }); - // We need to output name so it can be referenced by others. - infra.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 }); } /// @@ -129,6 +179,46 @@ public static IResourceBuilder AddSubnet( var subnet = new AzureSubnetResource(name, subnetName, addressPrefix, builder.Resource); + return AddSubnetCore(builder, subnet); + } + + /// + /// Adds an Azure Subnet to the Virtual Network with a parameterized address prefix. + /// + /// The Virtual Network resource builder. + /// The name of the subnet resource. + /// The parameter resource containing 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 with a parameterized address prefix: + /// + /// var subnetPrefix = builder.AddParameter("subnetPrefix"); + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("my-subnet", subnetPrefix); + /// + /// + public static IResourceBuilder AddSubnet( + this IResourceBuilder builder, + [ResourceName] string name, + IResourceBuilder addressPrefix, + string? subnetName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(addressPrefix); + + subnetName ??= name; + + var subnet = new AzureSubnetResource(name, subnetName, addressPrefix.Resource, builder.Resource); + + return AddSubnetCore(builder, subnet); + } + + private static IResourceBuilder AddSubnetCore( + IResourceBuilder builder, + AzureSubnetResource subnet) + { builder.Resource.Subnets.Add(subnet); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs index 203393fc420..e253c7c32a5 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.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 Aspire.Hosting.ApplicationModel; using Azure.Provisioning.Network; using Azure.Provisioning.Primitives; @@ -14,8 +15,23 @@ namespace Aspire.Hosting.Azure; public class AzureVirtualNetworkResource(string name, Action configureInfrastructure) : AzureProvisioningResource(name, configureInfrastructure) { + private const string DefaultAddressPrefix = "10.0.0.0/16"; + + // Backing field holds either string or ParameterResource + private readonly object _addressPrefix = DefaultAddressPrefix; + internal List Subnets { get; } = []; + /// + /// Gets the address prefix for the virtual network (e.g., "10.0.0.0/16"), or null if the address prefix is provided via a . + /// + public string? AddressPrefix => _addressPrefix as string; + + /// + /// Gets the parameter resource containing the address prefix for the virtual network, or null if the address prefix is provided as a literal string. + /// + public ParameterResource? AddressPrefixParameter => _addressPrefix as ParameterResource; + /// /// Gets the "id" output reference from the Azure Virtual Network resource. /// @@ -26,6 +42,31 @@ public class AzureVirtualNetworkResource(string name, Action public BicepOutputReference NameOutput => new("name", this); + /// + /// Initializes a new instance of the class with a string address prefix. + /// + /// The name of the resource. + /// Callback to configure the Azure Virtual Network resource. + /// The address prefix for the virtual network (e.g., "10.0.0.0/16"). + public AzureVirtualNetworkResource(string name, Action configureInfrastructure, string? addressPrefix) + : this(name, configureInfrastructure) + { + _addressPrefix = addressPrefix ?? DefaultAddressPrefix; + } + + /// + /// Initializes a new instance of the class with a parameterized address prefix. + /// + /// The name of the resource. + /// Callback to configure the Azure Virtual Network resource. + /// The parameter resource containing the address prefix for the virtual network. + public AzureVirtualNetworkResource(string name, Action configureInfrastructure, ParameterResource addressPrefix) + : this(name, configureInfrastructure) + { + ArgumentNullException.ThrowIfNull(addressPrefix); + _addressPrefix = addressPrefix; + } + /// public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs index e0b9a5db7c9..470e252ad89 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -30,6 +30,36 @@ public void AddAzureVirtualNetwork_WithCustomAddressPrefix() Assert.NotNull(vnet); Assert.Equal("myvnet", vnet.Resource.Name); + Assert.Equal("10.1.0.0/16", vnet.Resource.AddressPrefix); + Assert.Null(vnet.Resource.AddressPrefixParameter); + } + + [Fact] + public void AddAzureVirtualNetwork_WithParameterResource_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnetPrefixParam = builder.AddParameter("vnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet", vnetPrefixParam); + + Assert.NotNull(vnet); + Assert.Equal("myvnet", vnet.Resource.Name); + Assert.Null(vnet.Resource.AddressPrefix); + Assert.Same(vnetPrefixParam.Resource, vnet.Resource.AddressPrefixParameter); + } + + [Fact] + public async Task AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnetPrefixParam = builder.AddParameter("vnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet", vnetPrefixParam); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep") + .UseMethodName("AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep"); } [Fact] @@ -126,4 +156,50 @@ public void WithDelegatedSubnet_AddsAnnotationsToSubnetAndTarget() Assert.NotNull(delegationAnnotation); Assert.Equal("Microsoft.App/environments", delegationAnnotation.ServiceName); } + + [Fact] + public void AddSubnet_WithParameterResource_CreatesSubnetResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var addressPrefixParam = builder.AddParameter("subnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", addressPrefixParam); + + Assert.NotNull(subnet); + Assert.Equal("mysubnet", subnet.Resource.Name); + Assert.Equal("mysubnet", subnet.Resource.SubnetName); + Assert.Null(subnet.Resource.AddressPrefix); + Assert.Same(addressPrefixParam.Resource, subnet.Resource.AddressPrefixParameter); + Assert.Same(vnet.Resource, subnet.Resource.Parent); + } + + [Fact] + public void AddSubnet_WithParameterResource_AndCustomSubnetName() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var addressPrefixParam = builder.AddParameter("subnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", addressPrefixParam, subnetName: "custom-subnet-name"); + + Assert.Equal("mysubnet", subnet.Resource.Name); + Assert.Equal("custom-subnet-name", subnet.Resource.SubnetName); + Assert.Null(subnet.Resource.AddressPrefix); + Assert.Same(addressPrefixParam.Resource, subnet.Resource.AddressPrefixParameter); + } + + [Fact] + public async Task AddSubnet_WithParameterResource_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var addressPrefixParam = builder.AddParameter("subnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + vnet.AddSubnet("mysubnet", addressPrefixParam); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..e92965f0a9a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep.verified.bicep @@ -0,0 +1,23 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnetPrefix string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + vnetPrefix + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +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/AzureVirtualNetworkExtensionsTests.AddSubnet_WithParameterResource_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithParameterResource_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..94e76663c5d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithParameterResource_GeneratesBicep.verified.bicep @@ -0,0 +1,33 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param subnetPrefix string + +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 mysubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'mysubnet' + properties: { + addressPrefix: subnetPrefix + } + parent: myvnet +} + +output mysubnet_Id string = mysubnet.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file