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