diff --git a/Aspire.slnx b/Aspire.slnx
index 316fc691811..c89c9907d9e 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -91,6 +91,7 @@
+
@@ -159,6 +160,10 @@
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5c01540e93c..36db9b75c0e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -48,8 +48,10 @@
+
+
diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep
index 354128b8f35..a6bbd75eee9 100644
--- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep
+++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep
@@ -11,6 +11,7 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {
properties: {
accessTier: 'Hot'
allowSharedKeyAccess: false
+ isHnsEnabled: false
minimumTlsVersion: 'TLS1_2'
networkAcls: {
defaultAction: 'Allow'
@@ -48,8 +49,12 @@ 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
-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/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..1eda44bb945
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+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", "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")
+ .WithDelegatedSubnet(containerAppsSubnet);
+
+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
+privateEndpointsSubnet.AddPrivateEndpoint(blobs);
+privateEndpointsSubnet.AddPrivateEndpoint(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/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep
new file mode 100644
index 00000000000..775868cc85d
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep
@@ -0,0 +1,113 @@
+@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__mycontainer'
+ value: 'Endpoint=${storage_outputs_blobendpoint};ContainerName=mycontainer'
+ }
+ {
+ name: 'MYCONTAINER_URI'
+ value: storage_outputs_blobendpoint
+ }
+ {
+ name: 'MYCONTAINER_BLOBCONTAINERNAME'
+ value: 'mycontainer'
+ }
+ {
+ name: 'ConnectionStrings__myqueue'
+ value: 'Endpoint=${storage_outputs_queueendpoint};QueueName=myqueue'
+ }
+ {
+ name: 'MYQUEUE_URI'
+ value: storage_outputs_queueendpoint
+ }
+ {
+ name: 'MYQUEUE_QUEUENAME'
+ value: 'myqueue'
+ }
+ {
+ 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/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-identity.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-identity.module.bicep
new file mode 100644
index 00000000000..e2d7908d230
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.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/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-storage.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-storage.module.bicep
new file mode 100644
index 00000000000..b3f1171c933
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.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/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..2c8a2db9647
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json
@@ -0,0 +1,139 @@
+{
+ "$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"
+ },
+ "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": {
+ "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": {
+ "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}"
+ }
+ },
+ "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/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.bicep
new file mode 100644
index 00000000000..e2bf0b77f72
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.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/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep
new file mode 100644
index 00000000000..cc7b2008ed2
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.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_container_apps_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_container_apps_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/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep
new file mode 100644
index 00000000000..18c7dfa25f5
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.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 vnet_outputs_private_endpoints_id string
+
+param storage_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 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/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep
new file mode 100644
index 00000000000..a05692409d5
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep
@@ -0,0 +1,55 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+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' existing = {
+ name: privatelink_queue_core_windows_net_outputs_name
+}
+
+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/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/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/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep
new file mode 100644
index 00000000000..10c4439e46a
--- /dev/null
+++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.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 container_apps 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = {
+ name: 'container-apps'
+ properties: {
+ addressPrefix: '10.0.0.0/23'
+ delegations: [
+ {
+ properties: {
+ serviceName: 'Microsoft.App/environments'
+ }
+ name: 'Microsoft.App/environments'
+ }
+ ]
+ }
+ parent: vnet
+}
+
+resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = {
+ name: 'private-endpoints'
+ properties: {
+ addressPrefix: '10.0.2.0/27'
+ }
+ parent: vnet
+ dependsOn: [
+ container_apps
+ ]
+}
+
+output container_apps_Id string = container_apps.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.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
new file mode 100644
index 00000000000..485c5baba74
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj
@@ -0,0 +1,23 @@
+
+
+
+ $(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
+ true
+ $(NoWarn);AZPROVISION001;ASPIREAZURE003
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 00000000000..8ffe469db69
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs
@@ -0,0 +1,194 @@
+// 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;
+using Azure.Provisioning.PrivateDns;
+
+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 subnet.
+ ///
+ /// The subnet to add the private endpoint to.
+ /// 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. 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
+ /// configured to deny public network access. To override this behavior, use
+ /// to customize
+ /// 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)
+ {
+ ArgumentNullException.ThrowIfNull(subnet);
+ ArgumentNullException.ThrowIfNull(target);
+
+ var builder = subnet.ApplicationBuilder;
+ var name = $"{subnet.Resource.Name}-{target.Resource.Name}-pe";
+ var vnet = subnet.Resource.Parent;
+
+ var resource = new AzurePrivateEndpointResource(name, subnet.Resource, target.Resource, ConfigurePrivateEndpoint);
+
+ if (builder.ExecutionContext.IsRunMode)
+ {
+ // In run mode, we don't want to add the resource to the builder.
+ 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
+ // 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)
+ {
+ var azureResource = (AzurePrivateEndpointResource)infra.AspireResource;
+
+ // 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 the Private Endpoint
+ var endpoint = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra,
+ (identifier, name) =>
+ {
+ var resource = PrivateEndpoint.FromExisting(identifier);
+ resource.Name = name;
+ return resource;
+ },
+ (infrastructure) =>
+ {
+ var pe = new PrivateEndpoint(infrastructure.AspireResource.GetBicepIdentifier())
+ {
+ Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }
+ };
+
+ // Configure subnet
+ 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),
+ GroupIds = [.. azureResource.Target.GetPrivateLinkGroupIds()]
+ });
+
+ return pe;
+ });
+
+ // Create DNS Zone Group on the Private Endpoint
+ var dnsZoneGroupIdentifier = $"{endpoint.BicepIdentifier}_dnsgroup";
+ var dnsZoneGroup = new PrivateDnsZoneGroup(dnsZoneGroupIdentifier)
+ {
+ Name = "default",
+ Parent = endpoint,
+ PrivateDnsZoneConfigs =
+ {
+ new PrivateDnsZoneConfig
+ {
+ Name = dnsZoneIdentifier,
+ PrivateDnsZoneId = privateDnsZone.Id
+ }
+ }
+ };
+ infra.Add(dnsZoneGroup);
+
+ // 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 });
+ }
+ }
+
+ ///
+ /// 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);
+ 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
new file mode 100644
index 00000000000..914ab0409f8
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.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 Azure.Provisioning.Network;
+using Azure.Provisioning.Primitives;
+
+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,
+ AzureSubnetResource subnet,
+ IAzurePrivateEndpointTarget target,
+ 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 the subnet where the private endpoint will be created.
+ ///
+ public AzureSubnetResource Subnet { get; } = subnet;
+
+ ///
+ /// Gets the target Azure resource to connect via private link.
+ ///
+ public IAzurePrivateEndpointTarget Target { get; } = target;
+
+ ///
+ /// Gets or sets the Private DNS Zone for this endpoint.
+ ///
+ internal AzurePrivateDnsZoneResource? DnsZone { 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
new file mode 100644
index 00000000000..ae962a7d3cf
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs
@@ -0,0 +1,82 @@
+// 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;
+using Azure.Provisioning.Network;
+using Azure.Provisioning.Primitives;
+
+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
+{
+ ///
+ /// Gets the subnet name.
+ ///
+ public string SubnetName { get; } = ThrowIfNullOrEmpty(subnetName);
+
+ ///
+ /// Gets the address prefix for the subnet (e.g., "10.0.1.0/24").
+ ///
+ public string AddressPrefix { get; } = ThrowIfNullOrEmpty(addressPrefix);
+
+ ///
+ /// Gets the subnet Id output reference.
+ ///
+ 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));
+
+ 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.
+ ///
+ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, ProvisionableResource? dependsOn)
+ {
+ var subnet = new SubnetResource(Infrastructure.NormalizeBicepIdentifier(Name))
+ {
+ Name = SubnetName,
+ AddressPrefix = AddressPrefix,
+ };
+
+ if (dependsOn is not null)
+ {
+ subnet.DependsOn.Add(dependsOn);
+ }
+
+ 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))
+ {
+ Value = subnet.Id
+ });
+
+ return subnet;
+ }
+}
diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs
new file mode 100644
index 00000000000..fe10e5f6d3a
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs
@@ -0,0 +1,24 @@
+// 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;
+
+///
+/// Annotation to specify a service delegation for an Azure Subnet.
+///
+/// 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.
+ ///
+ 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
new file mode 100644
index 00000000000..3aad3dd2485
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs
@@ -0,0 +1,186 @@
+// 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;
+using Azure.Provisioning.Primitives;
+
+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.
+ /// 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 = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ 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)
+ {
+ 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())
+ {
+ AddressSpace = new VirtualNetworkAddressSpace()
+ {
+ AddressPrefixes = { addressPrefix ?? "10.0.0.0/16" }
+ },
+ Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }
+ };
+
+ return vnet;
+ });
+
+ var azureResource = (AzureVirtualNetworkResource)infra.AspireResource;
+
+ // 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)
+ {
+ var cdkSubnet = subnet.ToProvisioningEntity(infra, dependsOn);
+ cdkSubnet.Parent = vnet;
+ infra.Add(cdkSubnet);
+
+ dependsOn = cdkSubnet;
+ }
+ }
+
+ // 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 });
+ }
+ }
+
+ ///
+ /// 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").
+ /// 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,
+ string addressPrefix,
+ string? subnetName = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentException.ThrowIfNullOrEmpty(addressPrefix);
+
+ subnetName ??= name;
+
+ 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();
+ }
+
+ ///
+ /// Configures the resource to use the specified subnet with appropriate service delegation.
+ ///
+ /// The type of resource that supports 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).
+ ///
+ ///
+ /// 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
+ {
+ 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.Network/AzureVirtualNetworkResource.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs
new file mode 100644
index 00000000000..203393fc420
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs
@@ -0,0 +1,57 @@
+// 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 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..09dbf9abf2c
--- /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, and Private Endpoints.
+
+## 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 Private Endpoints
+
+Create a private endpoint to securely connect to Azure resources over a private network:
+
+```csharp
+var vnet = builder.AddAzureVirtualNetwork("vnet");
+var peSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/24");
+
+var storage = builder.AddAzureStorage("storage");
+var blobs = storage.AddBlobs("blobs");
+
+// Add a private endpoint for the blob storage
+peSubnet.AddPrivateEndpoint(blobs);
+```
+
+When you add a private endpoint to an Azure resource:
+
+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
+
+To override the automatic network lockdown, use `ConfigureInfrastructure`:
+
+```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/private-link/
+
+## Feedback & contributing
+
+https://github.com/dotnet/aspire
diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs
index fb6baaa9398..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;
@@ -13,7 +15,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 +84,12 @@ void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDiction
}
}
+ BicepOutputReference IAzurePrivateEndpointTarget.Id => Parent.Id;
+
+ 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..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;
@@ -13,7 +15,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 +77,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 139b06caa03..f2513ccd5af 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;
@@ -53,26 +55,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.HasAnnotationOfType();
+
+ var storageAccount = new StorageAccount(infrastructure.AspireResource.GetBicepIdentifier())
{
- // 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 } }
+ 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)
+ {
+ storageAccount.PublicNetworkAccess = StoragePublicNetworkAccess.Disabled;
+ }
+
+ return storageAccount;
});
if (azureResource.BlobContainers.Count > 0 || azureResource.DataLakeFileSystems.Count > 0)
@@ -135,6 +153,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/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..64e2b97bc54
--- /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 supports subnet 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/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs
new file mode 100644
index 00000000000..883989c79e0
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs
@@ -0,0 +1,31 @@
+// 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 can be connected to via a private endpoint.
+///
+[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
+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();
+
+ ///
+ /// 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..2a9e630fe81
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs
@@ -0,0 +1,16 @@
+// 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;
+
+///
+/// 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
+{
+}
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/AzureContainerAppEnvironmentExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs
index a6218a43f6e..a28f4903744 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 WithDelegatedSubnet_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")
+ .WithDelegatedSubnet(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/AzurePrivateEndpointExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs
new file mode 100644
index 00000000000..eb1be832c40
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs
@@ -0,0 +1,211 @@
+// 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 AddPrivateEndpoint_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 = subnet.AddPrivateEndpoint(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 AddPrivateEndpoint_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());
+
+ subnet.AddPrivateEndpoint(blobs);
+
+ // After adding PE, annotation should be on parent storage
+ var annotation = storage.Resource.Annotations.OfType().SingleOrDefault();
+ Assert.NotNull(annotation);
+ }
+
+ [Fact]
+ public void AddPrivateEndpoint_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");
+
+ subnet.AddPrivateEndpoint(queues);
+
+ var annotation = storage.Resource.Annotations.OfType().SingleOrDefault();
+ Assert.NotNull(annotation);
+ }
+
+ [Fact]
+ public async Task AddPrivateEndpoint_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 = subnet.AddPrivateEndpoint(blobs);
+
+ var manifest = await AzureManifestUtils.GetManifestWithBicep(pe.Resource);
+
+ await Verify(manifest.BicepText, extension: "bicep");
+ }
+
+ [Fact]
+ public async Task AddPrivateEndpoint_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 = subnet.AddPrivateEndpoint(queues);
+
+ var manifest = await AzureManifestUtils.GetManifestWithBicep(pe.Resource);
+
+ await Verify(manifest.BicepText, extension: "bicep");
+ }
+
+ [Fact]
+ public void AddPrivateEndpoint_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 = subnet.AddPrivateEndpoint(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());
+ }
+
+ [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
+ Assert.Single(dnsZones[0].VNetLinks);
+ 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
+ Assert.All(dnsZones, z => Assert.Single(z.VNetLinks));
+ }
+}
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs
new file mode 100644
index 00000000000..e5b8bfcba07
--- /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");
+
+ subnet.AddPrivateEndpoint(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");
+
+ subnet.AddPrivateEndpoint(blobs);
+ subnet.AddPrivateEndpoint(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..e0b9a5db7c9
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs
@@ -0,0 +1,129 @@
+// 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 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", "10.0.1.0/24", subnetName: "custom-subnet-name");
+
+ 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", "10.0.2.0/24", subnetName: "custom-subnet-name");
+
+ 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);
+ }
+
+ [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.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep
new file mode 100644
index 00000000000..e9c6c13ec83
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_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.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep
new file mode 100644
index 00000000000..89b970e4830
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_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
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.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep
new file mode 100644
index 00000000000..71713b626d3
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep
@@ -0,0 +1,55 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+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' existing = {
+ name: privatelink_queue_core_windows_net_outputs_name
+}
+
+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.AddPrivateEndpoint_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep
new file mode 100644
index 00000000000..9dc6735e109
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.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 storage_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_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/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
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
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
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