diff --git a/Aspire.sln b/Aspire.sln index 4673be1ffb2..b6abcb7249a 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -665,6 +665,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.ContainerRegistry", "src\Aspire.Hosting.Azure.ContainerRegistry\Aspire.Hosting.Azure.ContainerRegistry.csproj", "{6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.AppService", "src\Aspire.Hosting.Azure.AppService\Aspire.Hosting.Azure.AppService.csproj", "{5DDF8E89-FBBD-4A6F-BF32-7D2140724941}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureAppService", "AzureAppService", "{2D9974C2-3AB2-FBFD-5156-080508BB7449}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureAppService.ApiService", "playground\AzureAppService\AzureAppService.ApiService\AzureAppService.ApiService.csproj", "{A617DC84-65DA-41B5-B378-6C2F569CEE48}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureAppService.AppHost", "playground\AzureAppService\AzureAppService.AppHost\AzureAppService.AppHost.csproj", "{2C879943-DF34-44FA-B2C3-29D97F24DD76}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -3891,6 +3899,42 @@ Global {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x64.Build.0 = Release|Any CPU {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x86.ActiveCfg = Release|Any CPU {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x86.Build.0 = Release|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|x64.ActiveCfg = Debug|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|x64.Build.0 = Debug|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Debug|x86.Build.0 = Debug|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Release|Any CPU.Build.0 = Release|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Release|x64.ActiveCfg = Release|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Release|x64.Build.0 = Release|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Release|x86.ActiveCfg = Release|Any CPU + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941}.Release|x86.Build.0 = Release|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Debug|x64.ActiveCfg = Debug|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Debug|x64.Build.0 = Debug|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Debug|x86.ActiveCfg = Debug|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Debug|x86.Build.0 = Debug|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Release|Any CPU.Build.0 = Release|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Release|x64.ActiveCfg = Release|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Release|x64.Build.0 = Release|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Release|x86.ActiveCfg = Release|Any CPU + {A617DC84-65DA-41B5-B378-6C2F569CEE48}.Release|x86.Build.0 = Release|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Debug|x64.Build.0 = Debug|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Debug|x86.Build.0 = Debug|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|Any CPU.Build.0 = Release|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|x64.ActiveCfg = Release|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|x64.Build.0 = Release|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|x86.ActiveCfg = Release|Any CPU + {2C879943-DF34-44FA-B2C3-29D97F24DD76}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -4208,7 +4252,11 @@ Global {192747A2-9338-DECF-5C8C-28EB8E13829B} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {8FCA0CFA-7823-6A2F-342A-107A994915B0} = {C424395C-1235-41A4-BF55-07880A04368C} {30950CEB-2232-F9FC-04FF-ADDCB8AC30A7} = {C424395C-1235-41A4-BF55-07880A04368C} - {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3} = {77CFE74A-32EE-400C-8930-5025E8555256} + {5DDF8E89-FBBD-4A6F-BF32-7D2140724941} = {77CFE74A-32EE-400C-8930-5025E8555256} + {2D9974C2-3AB2-FBFD-5156-080508BB7449} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {A617DC84-65DA-41B5-B378-6C2F569CEE48} = {2D9974C2-3AB2-FBFD-5156-080508BB7449} + {2C879943-DF34-44FA-B2C3-29D97F24DD76} = {2D9974C2-3AB2-FBFD-5156-080508BB7449} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4} diff --git a/Directory.Packages.props b/Directory.Packages.props index b3825726499..ddb878f9ba9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,7 @@ + diff --git a/playground/AzureAppService/.aspire/settings.json b/playground/AzureAppService/.aspire/settings.json new file mode 100644 index 00000000000..21b430939bb --- /dev/null +++ b/playground/AzureAppService/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../AzureAppService.AppHost/AzureAppService.AppHost.csproj" +} \ No newline at end of file diff --git a/playground/AzureAppService/AzureAppService.ApiService/AzureAppService.ApiService.csproj b/playground/AzureAppService/AzureAppService.ApiService/AzureAppService.ApiService.csproj new file mode 100644 index 00000000000..84c50db2a4a --- /dev/null +++ b/playground/AzureAppService/AzureAppService.ApiService/AzureAppService.ApiService.csproj @@ -0,0 +1,15 @@ + + + + $(DefaultTargetFramework) + enable + enable + + + + + + + + + diff --git a/playground/AzureAppService/AzureAppService.ApiService/Program.cs b/playground/AzureAppService/AzureAppService.ApiService/Program.cs new file mode 100644 index 00000000000..b0cdbaa85d5 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.ApiService/Program.cs @@ -0,0 +1,81 @@ +// 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 Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddCosmosDbContext("account", "db"); +builder.AddAzureBlobClient("blobs"); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.MapGet("/", () => +{ + return Results.Content(""" + + + + + + """, + "text/html"); +}); + +app.MapGet("/blobs", async (BlobServiceClient bsc) => +{ + var container = bsc.GetBlobContainerClient("mycontainer"); + await container.CreateIfNotExistsAsync(); + + var blobNameAndContent = Guid.NewGuid().ToString(); + await container.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + + var blobs = container.GetBlobsAsync(); + + var blobNames = new List(); + + await foreach (var blob in blobs) + { + blobNames.Add(blob.Name); + } + + return blobNames; +}); + +app.MapGet("/cosmos", async (TestCosmosContext context) => +{ + await context.Database.EnsureCreatedAsync(); + + context.Entries.Add(new EntityFrameworkEntry()); + await context.SaveChangesAsync(); + + return await context.Entries.ToListAsync(); +}); + +app.Run(); + +public class Entry +{ + [JsonProperty("id")] + public string? Id { get; set; } +} + +public class TestCosmosContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Entries { get; set; } +} + +public class EntityFrameworkEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); +} + diff --git a/playground/AzureAppService/AzureAppService.ApiService/Properties/launchSettings.json b/playground/AzureAppService/AzureAppService.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000000..de23e4696cf --- /dev/null +++ b/playground/AzureAppService/AzureAppService.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/AzureAppService/AzureAppService.ApiService/appsettings.Development.json b/playground/AzureAppService/AzureAppService.ApiService/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/AzureAppService/AzureAppService.ApiService/appsettings.json b/playground/AzureAppService/AzureAppService.ApiService/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/AzureAppService/AzureAppService.AppHost/AzureAppService.AppHost.csproj b/playground/AzureAppService/AzureAppService.AppHost/AzureAppService.AppHost.csproj new file mode 100644 index 00000000000..77364f5bb2a --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/AzureAppService.AppHost.csproj @@ -0,0 +1,25 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + 9dc69458-f2b4-4306-9dc5-f7b8e398a3a9 + + + + + + + + + + + + + + + + diff --git a/playground/AzureAppService/AzureAppService.AppHost/Program.cs b/playground/AzureAppService/AzureAppService.AppHost/Program.cs new file mode 100644 index 00000000000..7939e9fb629 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/Program.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Azure; +using Azure.Provisioning.Storage; + +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddAppServiceEnvironment("infra"); + +// Testing secret parameters +var param = builder.AddParameter("secretparam", "fakeSecret", secret: true); + +// Testing kv secret refs +var cosmosDb = builder.AddAzureCosmosDB("account") + .RunAsEmulator(c => c.WithLifetime(ContainerLifetime.Persistent)); + +cosmosDb.AddCosmosDatabase("db"); + +// Testing managed identity +var storage = builder.AddAzureStorage("storage") + .ConfigureInfrastructure(infra => + { + var storage = infra.GetProvisionableResources().OfType().Single(); + storage.AllowBlobPublicAccess = false; + }) + .RunAsEmulator(c => c.WithLifetime(ContainerLifetime.Persistent)); +var blobs = storage.AddBlobs("blobs"); + +// Testing projects +builder.AddProject("api") + .WithExternalHttpEndpoints() + .WithReference(blobs) + .WithRoleAssignments(storage, StorageBuiltInRole.StorageBlobDataContributor) + .WithReference(cosmosDb) + .WithEnvironment("VALUE", param) + .WithEnvironment(context => + { + if (context.Resource.TryGetLastAnnotation(out var identity)) + { + context.EnvironmentVariables["AZURE_PRINCIPAL_NAME"] = identity.IdentityResource.PrincipalName; + } + }); + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); diff --git a/playground/AzureAppService/AzureAppService.AppHost/Properties/launchSettings.json b/playground/AzureAppService/AzureAppService.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..6f14f67dec0 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/Properties/launchSettings.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:15687;http://localhost:15688", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16167", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17317", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15688", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16167", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17318", + "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:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16157" + } + } + } +} diff --git a/playground/AzureAppService/AzureAppService.AppHost/account.module.bicep b/playground/AzureAppService/AzureAppService.AppHost/account.module.bicep new file mode 100644 index 00000000000..2aa3031838c --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/account.module.bicep @@ -0,0 +1,39 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource account 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { + name: take('account-${uniqueString(resourceGroup().id)}', 44) + location: location + properties: { + locations: [ + { + locationName: location + failoverPriority: 0 + } + ] + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + databaseAccountOfferType: 'Standard' + disableLocalAuth: true + } + kind: 'GlobalDocumentDB' + tags: { + 'aspire-resource-name': 'account' + } +} + +resource db 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-08-15' = { + name: 'db' + location: location + properties: { + resource: { + id: 'db' + } + } + parent: account +} + +output connectionString string = account.properties.documentEndpoint + +output name string = account.name \ No newline at end of file diff --git a/playground/AzureAppService/AzureAppService.AppHost/api-identity.module.bicep b/playground/AzureAppService/AzureAppService.AppHost/api-identity.module.bicep new file mode 100644 index 00000000000..0ff5902d5b0 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/api-identity.module.bicep @@ -0,0 +1,15 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + 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 \ No newline at end of file diff --git a/playground/AzureAppService/AzureAppService.AppHost/api-roles-account.module.bicep b/playground/AzureAppService/AzureAppService.AppHost/api-roles-account.module.bicep new file mode 100644 index 00000000000..94e2505986b --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/api-roles-account.module.bicep @@ -0,0 +1,25 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param account_outputs_name string + +param principalId string + +resource account 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' existing = { + name: account_outputs_name +} + +resource account_roleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2024-08-15' existing = { + name: '00000000-0000-0000-0000-000000000002' + parent: account +} + +resource account_roleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2024-08-15' = { + name: guid(principalId, account_roleDefinition.id, account.id) + properties: { + principalId: principalId + roleDefinitionId: account_roleDefinition.id + scope: account.id + } + parent: account +} \ No newline at end of file diff --git a/playground/AzureAppService/AzureAppService.AppHost/api-roles-storage.module.bicep b/playground/AzureAppService/AzureAppService.AppHost/api-roles-storage.module.bicep new file mode 100644 index 00000000000..bab6fc34eb1 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/api-roles-storage.module.bicep @@ -0,0 +1,20 @@ +@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 +} \ No newline at end of file diff --git a/playground/AzureAppService/AzureAppService.AppHost/api.module.bicep b/playground/AzureAppService/AzureAppService.AppHost/api.module.bicep new file mode 100644 index 00000000000..c5b1d87d429 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/api.module.bicep @@ -0,0 +1,90 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param infra_outputs_azure_container_registry_endpoint string + +param infra_outputs_planid string + +param infra_outputs_azure_container_registry_managed_identity_id string + +param infra_outputs_azure_container_registry_managed_identity_client_id string + +param api_containerimage string + +param api_containerport string + +param storage_outputs_blobendpoint string + +param account_outputs_connectionstring string + +@secure() +param secretparam_value string + +param api_identity_outputs_principalname string + +param api_identity_outputs_id string + +param api_identity_outputs_clientid string + +resource webapp 'Microsoft.Web/sites@2024-04-01' = { + name: take('${toLower('api')}-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + serverFarmId: infra_outputs_planid + keyVaultReferenceIdentity: api_identity_outputs_id + siteConfig: { + linuxFxVersion: 'DOCKER|${api_containerimage}' + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: infra_outputs_azure_container_registry_managed_identity_client_id + appSettings: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: api_containerport + } + { + name: 'ConnectionStrings__blobs' + value: storage_outputs_blobendpoint + } + { + name: 'ConnectionStrings__account' + value: account_outputs_connectionstring + } + { + name: 'VALUE' + value: secretparam_value + } + { + name: 'AZURE_PRINCIPAL_NAME' + value: api_identity_outputs_principalname + } + { + name: 'AZURE_CLIENT_ID' + value: api_identity_outputs_clientid + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${infra_outputs_azure_container_registry_managed_identity_id}': { } + '${api_identity_outputs_id}': { } + } + } +} \ No newline at end of file diff --git a/playground/AzureAppService/AzureAppService.AppHost/appsettings.Development.json b/playground/AzureAppService/AzureAppService.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/AzureAppService/AzureAppService.AppHost/appsettings.json b/playground/AzureAppService/AzureAppService.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/AzureAppService/AzureAppService.AppHost/aspire-manifest.json b/playground/AzureAppService/AzureAppService.AppHost/aspire-manifest.json new file mode 100644 index 00000000000..2e8a59d0c58 --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/aspire-manifest.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json.schemastore.org/aspire-8.0.json", + "resources": { + "infra": { + "type": "azure.bicep.v0", + "path": "infra.module.bicep", + "params": { + "userPrincipalId": "" + } + }, + "secretparam": { + "type": "parameter.v0", + "value": "{secretparam.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true + } + } + }, + "account": { + "type": "azure.bicep.v0", + "connectionString": "{account.outputs.connectionString}", + "path": "account.module.bicep" + }, + "db": { + "type": "value.v0", + "connectionString": "AccountEndpoint={account.outputs.connectionString};Database=db" + }, + "storage": { + "type": "azure.bicep.v0", + "path": "storage.module.bicep" + }, + "blobs": { + "type": "value.v0", + "connectionString": "{storage.outputs.blobEndpoint}" + }, + "api": { + "type": "project.v1", + "path": "../AzureAppService.ApiService/AzureAppService.ApiService.csproj", + "deployment": { + "type": "azure.bicep.v0", + "path": "api.module.bicep", + "params": { + "infra_outputs_azure_container_registry_endpoint": "{infra.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "infra_outputs_planid": "{infra.outputs.planId}", + "infra_outputs_azure_container_registry_managed_identity_id": "{infra.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "infra_outputs_azure_container_registry_managed_identity_client_id": "{infra.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID}", + "api_containerimage": "{api.containerImage}", + "api_containerport": "{api.containerPort}", + "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", + "account_outputs_connectionstring": "{account.outputs.connectionString}", + "secretparam_value": "{secretparam.value}", + "api_identity_outputs_principalname": "{api-identity.outputs.principalName}", + "api_identity_outputs_id": "{api-identity.outputs.id}", + "api_identity_outputs_clientid": "{api-identity.outputs.clientId}" + } + }, + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "HTTP_PORTS": "{api.bindings.http.targetPort}", + "ConnectionStrings__blobs": "{blobs.connectionString}", + "ConnectionStrings__account": "{account.connectionString}", + "VALUE": "{secretparam.value}", + "AZURE_PRINCIPAL_NAME": "{api-identity.outputs.principalName}" + }, + "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}" + } + }, + "api-roles-account": { + "type": "azure.bicep.v0", + "path": "api-roles-account.module.bicep", + "params": { + "account_outputs_name": "{account.outputs.name}", + "principalId": "{api-identity.outputs.principalId}" + } + } + } +} \ No newline at end of file diff --git a/playground/AzureAppService/AzureAppService.AppHost/infra.module.bicep b/playground/AzureAppService/AzureAppService.AppHost/infra.module.bicep new file mode 100644 index 00000000000..4cd6db10e5e --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/infra.module.bicep @@ -0,0 +1,54 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string + +param tags object = { } + +resource infra_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: take('infra_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource infra_acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: take('infraacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: tags +} + +resource infra_acr_infra_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(infra_acr.id, infra_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: infra_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: infra_acr +} + +resource infra_asplan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: take('infraasplan-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + reserved: true + } + kind: 'Linux' + sku: { + name: 'B1' + tier: 'Basic' + } +} + +output planId string = infra_asplan.id + +output AZURE_CONTAINER_REGISTRY_NAME string = infra_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = infra_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = infra_mi.id + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = infra_mi.properties.clientId \ No newline at end of file diff --git a/playground/AzureAppService/AzureAppService.AppHost/storage.module.bicep b/playground/AzureAppService/AzureAppService.AppHost/storage.module.bicep new file mode 100644 index 00000000000..9588f49e52a --- /dev/null +++ b/playground/AzureAppService/AzureAppService.AppHost/storage.module.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' + allowBlobPublicAccess: false + allowSharedKeyAccess: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Allow' + } + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { + name: 'default' + parent: storage +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +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 diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs index 06a0abff8d5..a97a1bfb28e 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs @@ -628,26 +628,7 @@ private BicepValue AllocateKeyVaultSecretUriReference(BicepSecretOutputR private BicepValue AllocateKeyVaultSecretUriReference(IAzureKeyVaultSecretReference secretOutputReference) { - if (!KeyVaultRefs.TryGetValue(secretOutputReference.Resource.Name, out var kv)) - { - // We resolve the keyvault that represents the storage for secret outputs - var parameter = AllocateParameter(secretOutputReference.Resource.NameOutputReference); - kv = KeyVaultService.FromExisting($"{parameter.BicepIdentifier}_kv"); - kv.Name = parameter; - - KeyVaultRefs[secretOutputReference.Resource.Name] = kv; - } - - if (!KeyVaultSecretRefs.TryGetValue(secretOutputReference.ValueExpression, out var secret)) - { - // Now we resolve the secret - var secretBicepIdentifier = Infrastructure.NormalizeBicepIdentifier($"{kv.BicepIdentifier}_{secretOutputReference.SecretName}"); - secret = KeyVaultSecret.FromExisting(secretBicepIdentifier); - secret.Name = secretOutputReference.SecretName; - secret.Parent = kv; - - KeyVaultSecretRefs[secretOutputReference.ValueExpression] = secret; - } + var secret = secretOutputReference.AsKeyVaultSecret(Infra); return secret.Properties.SecretUri; } diff --git a/src/Aspire.Hosting.Azure.AppContainers/IContainerRegistry.cs b/src/Aspire.Hosting.Azure.AppContainers/IContainerRegistry.cs deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj b/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj new file mode 100644 index 00000000000..b004368b6a6 --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj @@ -0,0 +1,23 @@ + + + + $(DefaultTargetFramework) + true + aspire appservice integration hosting azure + Azure app service resource types for .NET Aspire. + $(SharedDir)Azure_256x.png + true + + + + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceComputeResourceExtensions.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceComputeResourceExtensions.cs new file mode 100644 index 00000000000..3e5c83eadc2 --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceComputeResourceExtensions.cs @@ -0,0 +1,47 @@ +// 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.AppService; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for publishing compute resources as Azure App Service websites. +/// +public static class AzureAppServiceComputeResourceExtensions +{ + /// + /// Publishes the specified compute resource as an Azure App Service. + /// + /// The type of the compute resource. + /// The compute resource builder. + /// The configuration action for the App Service WebSite resource. + /// The updated compute resource builder. + /// + /// + /// + /// builder.AddProject<Projects.Api>("name").PublishAsAzureAppServiceWebsite((infrastructure, app) => + /// { + /// // Configure the App Service WebSite resource here + /// }); + /// + /// + /// + public static IResourceBuilder PublishAsAzureAppServiceWebsite(this IResourceBuilder builder, Action configure) +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + where T : IComputeResource +#pragma warning restore ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + return builder.WithAnnotation(new AzureAppServiceWebsiteCustomizationAnnotation(configure)); + } +} diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentContext.cs new file mode 100644 index 00000000000..efff206e3d8 --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentContext.cs @@ -0,0 +1,48 @@ +// 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.AppService; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Azure; + +internal sealed class AzureAppServiceEnvironmentContext( + ILogger logger, + DistributedApplicationExecutionContext executionContext, + AzureAppServiceEnvironmentResource environment) +{ + public ILogger Logger => logger; + + public DistributedApplicationExecutionContext ExecutionContext => executionContext; + + public AzureAppServiceEnvironmentResource Environment => environment; + + private readonly Dictionary _appServices = []; + + public AzureAppServiceWebsiteContext GetAppServiceContext(IResource resource) + { + if (!_appServices.TryGetValue(resource, out var context)) + { + throw new InvalidOperationException($"App Service context not found for resource {resource.Name}."); + } + + return context; + } + + public async Task CreateAppServiceAsync(IResource resource, AzureProvisioningOptions provisioningOptions, CancellationToken cancellationToken) + { + if (!_appServices.TryGetValue(resource, out var context)) + { + _appServices[resource] = context = new AzureAppServiceWebsiteContext(resource, this); + await context.ProcessAsync(cancellationToken).ConfigureAwait(false); + } + + var provisioningResource = new AzureProvisioningResource(resource.Name, context.BuildWebSite) + { + ProvisioningBuildOptions = provisioningOptions.ProvisioningBuildOptions + }; + + return provisioningResource; + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs new file mode 100644 index 00000000000..39f4eff4c81 --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs @@ -0,0 +1,130 @@ +// 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 Aspire.Hosting.Azure.AppService; +using Aspire.Hosting.Lifecycle; +using Azure.Provisioning; +using Azure.Provisioning.AppService; +using Azure.Provisioning.ContainerRegistry; +using Azure.Provisioning.Expressions; +using Azure.Provisioning.Roles; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting; + +/// +/// Extensions for adding Azure App Service Environment resources to a distributed application builder. +/// +public static partial class AzureAppServiceEnvironmentExtensions +{ + /// + /// Adds a azure app service environment resource to the distributed application builder. + /// + /// The distributed application builder. + /// The name of the resource. + /// + public static IResourceBuilder AddAppServiceEnvironment(this IDistributedApplicationBuilder builder, string name) + { + builder.AddAzureProvisioning(); + builder.Services.Configure(options => options.SupportsTargetedRoleAssignments = true); + + if (builder.ExecutionContext.IsPublishMode) + { + builder.Services.TryAddLifecycleHook(); + } + + var resource = new AzureAppServiceEnvironmentResource(name, static infra => + { + var prefix = infra.AspireResource.Name; + var resource = infra.AspireResource; + + // This tells azd to avoid creating infrastructure + var userPrincipalId = new ProvisioningParameter(AzureBicepResource.KnownParameters.UserPrincipalId, typeof(string)); + infra.Add(userPrincipalId); + + var tags = new ProvisioningParameter("tags", typeof(object)) + { + Value = new BicepDictionary() + }; + + infra.Add(tags); + + var identity = new UserAssignedIdentity(Infrastructure.NormalizeBicepIdentifier($"{prefix}-mi")) + { + Tags = tags + }; + + infra.Add(identity); + + ContainerRegistryService? containerRegistry = null; + if (resource.TryGetLastAnnotation(out var registryReferenceAnnotation) && registryReferenceAnnotation.Registry is AzureProvisioningResource registry) + { + containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); + } + else + { + containerRegistry = new ContainerRegistryService(Infrastructure.NormalizeBicepIdentifier($"{prefix}_acr")) + { + Sku = new() { Name = ContainerRegistrySkuName.Basic }, + Tags = tags + }; + } + + infra.Add(containerRegistry); + + var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, identity); + + // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265 + pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, identity.Id, pullRa.RoleDefinitionId); + infra.Add(pullRa); + + var plan = new AppServicePlan(Infrastructure.NormalizeBicepIdentifier($"{prefix}-asplan")) + { + Sku = new AppServiceSkuDescription + { + Name = "B1", + Tier = "Basic" + }, + Kind = "Linux", + IsReserved = true + }; + + infra.Add(plan); + + infra.Add(new ProvisioningOutput("planId", typeof(string)) + { + Value = plan.Id + }); + + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_NAME", typeof(string)) + { + Value = containerRegistry.Name + }); + + // AZD looks for this output to find the container registry endpoint + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_ENDPOINT", typeof(string)) + { + Value = containerRegistry.LoginServer + }); + + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", typeof(string)) + { + Value = identity.Id + }); + + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID", typeof(string)) + { + Value = identity.ClientId + }); + }); + + if (!builder.ExecutionContext.IsPublishMode) + { + return builder.CreateResourceBuilder(resource); + } + + return builder.AddResource(resource); + } +} diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs new file mode 100644 index 00000000000..e3984cd743c --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs @@ -0,0 +1,36 @@ +// 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.AppService; + +/// +/// Represents an Azure App Service Environment resource. +/// +/// The name of the Azure App Service Environment. +/// The callback to configure the Azure infrastructure for this resource. +public class AzureAppServiceEnvironmentResource(string name, Action configureInfrastructure) : + AzureProvisioningResource(name, configureInfrastructure), +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + IComputeEnvironmentResource, + IAzureContainerRegistry +#pragma warning restore ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +{ + // We don't want these to be public if we end up with an app service + // per compute resource. + internal BicepOutputReference PlanIdOutputReference => new("planId", this); + internal BicepOutputReference ContainerRegistryUrl => new("AZURE_CONTAINER_REGISTRY_ENDPOINT", this); + internal BicepOutputReference ContainerRegistryName => new("AZURE_CONTAINER_REGISTRY_NAME", this); + internal BicepOutputReference ContainerRegistryManagedIdentityId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", this); + internal BicepOutputReference ContainerRegistryClientId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID", this); + + ReferenceExpression IAzureContainerRegistry.ManagedIdentityId => + ReferenceExpression.Create($"{ContainerRegistryManagedIdentityId}"); + + ReferenceExpression IContainerRegistry.Name => + ReferenceExpression.Create($"{ContainerRegistryName}"); + + ReferenceExpression IContainerRegistry.Endpoint => + ReferenceExpression.Create($"{ContainerRegistryUrl}"); +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceInfrastructure.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceInfrastructure.cs new file mode 100644 index 00000000000..db5fa1e203f --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceInfrastructure.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Azure.AppService; + +internal sealed class AzureAppServiceInfrastructure( + ILogger logger, + IOptions provisioningOptions, + DistributedApplicationExecutionContext executionContext) : + IDistributedApplicationLifecycleHook +{ + public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + if (!executionContext.IsPublishMode) + { + return; + } + + var appServiceEnvironment = appModel.Resources.OfType().FirstOrDefault() + ?? throw new InvalidOperationException("AppServiceEnvironmentResource not found."); + + var appServiceEnvironmentContext = new AzureAppServiceEnvironmentContext( + logger, + executionContext, + appServiceEnvironment); + + foreach (var resource in appModel.Resources) + { + if (resource.TryGetLastAnnotation(out var lastAnnotation) && lastAnnotation == ManifestPublishingCallbackAnnotation.Ignore) + { + continue; + } + + // We only support project resources for now. + if (resource is not ProjectResource) + { + continue; + } + + var website = await appServiceEnvironmentContext.CreateAppServiceAsync(resource, provisioningOptions.Value, cancellationToken).ConfigureAwait(false); + +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + resource.Annotations.Add(new DeploymentTargetAnnotation(website) + { + ContainerRegistry = appServiceEnvironment, + ComputeEnvironment = appServiceEnvironment + }); +#pragma warning restore ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + } + } +} diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs new file mode 100644 index 00000000000..bb8ed7f68c9 --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs @@ -0,0 +1,346 @@ +// 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 ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +using System.Globalization; +using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning; +using Azure.Provisioning.AppService; +using Azure.Provisioning.Expressions; +using Azure.Provisioning.Resources; + +namespace Aspire.Hosting.Azure.AppService; + +internal sealed class AzureAppServiceWebsiteContext( + IResource resource, + AzureAppServiceEnvironmentContext environmentContext) +{ + public IResource Resource => resource; + + record struct EndpointMapping(string Scheme, BicepValue Host, int Port, int? TargetPort, bool IsHttpIngress, bool External); + + private readonly Dictionary _endpointMapping = []; + + // Resolved environment variables and command line args + // These contain the values that need to be further transformed into + // bicep compatible values + public Dictionary EnvironmentVariables { get; } = []; + public List Args { get; } = []; + + private AzureResourceInfrastructure? _infrastructure; + public AzureResourceInfrastructure Infra => _infrastructure ?? throw new InvalidOperationException("Infra is not set"); + + // Naming the app service is globally unique (doman names), so we use the resource group ID to create a unique name + // within the naming spec for the app service. + public BicepValue HostName => BicepFunction.Take( + BicepFunction.Interpolate($"{BicepFunction.ToLower(resource.Name)}-{BicepFunction.GetUniqueString(BicepFunction.GetResourceGroup().Id)}"), 60); + + public async Task ProcessAsync(CancellationToken cancellationToken) + { + ProcessEndpoints(); + + await ProcessEnvironmentAsync(cancellationToken).ConfigureAwait(true); + await ProcessArgumentsAsync(cancellationToken).ConfigureAwait(true); + } + + private async Task ProcessEnvironmentAsync(CancellationToken cancellationToken) + { + if (resource.TryGetAnnotationsOfType(out var environmentCallbacks)) + { + var context = new EnvironmentCallbackContext( + environmentContext.ExecutionContext, resource, EnvironmentVariables, cancellationToken); + + foreach (var c in environmentCallbacks) + { + await c.Callback(context).ConfigureAwait(true); + } + } + } + + private async Task ProcessArgumentsAsync(CancellationToken cancellationToken) + { + if (resource.TryGetAnnotationsOfType(out var commandLineArgsCallbackAnnotations)) + { + var context = new CommandLineArgsCallbackContext(Args, cancellationToken) + { + ExecutionContext = environmentContext.ExecutionContext + }; + + foreach (var c in commandLineArgsCallbackAnnotations) + { + await c.Callback(context).ConfigureAwait(true); + } + } + } + + private void ProcessEndpoints() + { + if (!resource.TryGetEndpoints(out var endpoints) || !endpoints.Any()) + { + return; + } + + // Only http/https are supported in App Service + var unsupportedEndpoints = endpoints.Where(e => e.UriScheme is not ("http" or "https")).ToArray(); + if (unsupportedEndpoints.Length > 0) + { + throw new NotSupportedException($"The endpoint(s) {string.Join(", ", unsupportedEndpoints.Select(e => $"'{e.Name}'"))} on resource '{resource.Name}' specifies an unsupported scheme. Only http and https are supported in App Service."); + } + + foreach (var endpoint in endpoints) + { + if (!endpoint.IsExternal) + { + throw new NotSupportedException($"The endpoint '{endpoint.Name}' on resource '{resource.Name}' is not external. App Service only supports external endpoints."); + } + + // For App Service, we ignore port mappings since ports are handled by the platform + _endpointMapping[endpoint.Name] = new( + Scheme: endpoint.UriScheme, + Host: HostName, + Port: endpoint.UriScheme == "https" ? 443 : 80, + TargetPort: null, // App Service manages internal port mapping + IsHttpIngress: true, + External: true); // All App Service endpoints are external + } + } + + private (object, SecretType) ProcessValue(object value, SecretType secretType = SecretType.None, object? parent = null) + { + if (value is string s) + { + return (s, secretType); + } + + if (value is EndpointReference ep) + { + var context = environmentContext.GetAppServiceContext(ep.Resource); + return (GetValue(context._endpointMapping[ep.EndpointName], EndpointProperty.Url), secretType); + } + + if (value is ParameterResource param) + { + var st = param.Secret ? SecretType.Normal : secretType; + return (AllocateParameter(param, secretType: st), st); + } + + if (value is ConnectionStringReference cs) + { + return ProcessValue(cs.Resource.ConnectionStringExpression, secretType, parent); + } + + if (value is IResourceWithConnectionString csrs) + { + return ProcessValue(csrs.ConnectionStringExpression, secretType, parent); + } + + if (value is BicepOutputReference output) + { + return (AllocateParameter(output, secretType: secretType), secretType); + } + + if (value is IAzureKeyVaultSecretReference vaultSecretReference) + { + if (parent is null) + { + return (AllocateKeyVaultSecretUriReference(vaultSecretReference), SecretType.KeyVault); + } + + return (AllocateParameter(vaultSecretReference, secretType: SecretType.KeyVault), SecretType.KeyVault); + } + + if (value is EndpointReferenceExpression epExpr) + { + var context = environmentContext.GetAppServiceContext(epExpr.Endpoint.Resource); + var mapping = context._endpointMapping[epExpr.Endpoint.EndpointName]; + var val = GetValue(mapping, epExpr.Property); + return (val, secretType); + } + + if (value is ReferenceExpression expr) + { + if (expr.Format == "{0}" && expr.ValueProviders.Count == 1) + { + return ProcessValue(expr.ValueProviders[0], secretType, parent); + } + + var args = new object[expr.ValueProviders.Count]; + var index = 0; + var finalSecretType = SecretType.None; + + foreach (var vp in expr.ValueProviders) + { + var (val, secret) = ProcessValue(vp, secretType, expr); + if (secret != SecretType.None) + { + finalSecretType = SecretType.Normal; + } + args[index++] = val; + } + + return (new BicepFormatString(expr.Format, args), finalSecretType); + } + + throw new NotSupportedException($"Unsupported value type {value.GetType()}"); + } + + private static BicepValue ResolveValue(object val) + { + return val switch + { + BicepValue s => s, + string s => s, + ProvisioningParameter p => p, + BicepFormatString fs => BicepFunction2.Interpolate(fs), + _ => throw new NotSupportedException($"Unsupported value type {val.GetType()}") + }; + } + + public void BuildWebSite(AzureResourceInfrastructure infra) + { + _infrastructure = infra; + + // We need to reference the container registry URL so that it exists in the manifest + var containerRegistryUrl = environmentContext.Environment.ContainerRegistryUrl.AsProvisioningParameter(infra); + var appServicePlanParameter = environmentContext.Environment.PlanIdOutputReference.AsProvisioningParameter(infra); + var acrMidParameter = environmentContext.Environment.ContainerRegistryManagedIdentityId.AsProvisioningParameter(infra); + var acrClientIdParameter = environmentContext.Environment.ContainerRegistryClientId.AsProvisioningParameter(infra); + var containerImage = AllocateParameter(ResourceExpression.GetContainerImageExpression(Resource)); + + var webSite = new WebSite("webapp") + { + // Use the host name as the name of the web app + Name = HostName, + AppServicePlanId = appServicePlanParameter, + SiteConfig = new SiteConfigProperties() + { + LinuxFxVersion = BicepFunction.Interpolate($"DOCKER|{containerImage}"), + AcrUserManagedIdentityId = acrClientIdParameter, + UseManagedIdentityCreds = true, + AppSettings = [] + }, + Identity = new ManagedServiceIdentity() + { + ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned, + UserAssignedIdentities = [] + }, + }; + + foreach (var kv in EnvironmentVariables) + { + var (val, secretType) = ProcessValue(kv.Value); + var value = ResolveValue(val); + + if (secretType == SecretType.KeyVault) + { + // https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references?tabs=azure-cli#-understand-source-app-settings-from-key-vault + // @Microsoft.KeyVault({referenceString}) + value = BicepFunction.Interpolate($"@Microsoft.KeyVault(SecretUri={val})"); + } + + webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair { Name = kv.Key, Value = value }); + } + + if (Args.Count > 0) + { + var args = new List>(); + + foreach (var arg in Args) + { + var (val, secretType) = ProcessValue(arg); + var value = ResolveValue(val); + + args.Add(value); + } + + // App Service does not support array arguments, so we need to join them into a single string + static FunctionCallExpression Join(BicepExpression args, string delimeter) => + new(new IdentifierExpression("join"), args, new StringLiteralExpression(delimeter)); + + var arrayExpression = new ArrayExpression([.. args.Select(a => a.Compile())]); + + webSite.SiteConfig.AppCommandLine = Join(arrayExpression, " "); + } + + var id = BicepFunction.Interpolate($"{acrMidParameter}").Compile().ToString(); + webSite.Identity.UserAssignedIdentities[id] = new UserAssignedIdentityDetails(); + + // This is the user assigned identity associated with the web app, not the container registry + if (resource.TryGetLastAnnotation(out var appIdentityAnnotation)) + { + var appIdentityResource = appIdentityAnnotation.IdentityResource; + + var computeIdentity = appIdentityResource.Id.AsProvisioningParameter(infra); + + var cid = BicepFunction.Interpolate($"{computeIdentity}").Compile().ToString(); + + webSite.KeyVaultReferenceIdentity = computeIdentity; + + webSite.Identity.ManagedServiceIdentityType = ManagedServiceIdentityType.UserAssigned; + webSite.Identity.UserAssignedIdentities[cid] = new UserAssignedIdentityDetails(); + + webSite.SiteConfig.AppSettings.Add(new AppServiceNameValuePair + { + Name = "AZURE_CLIENT_ID", + Value = appIdentityResource.ClientId.AsProvisioningParameter(infra) + }); + } + + infra.Add(webSite); + + // Allow users to customize the web app here + if (resource.TryGetAnnotationsOfType(out var customizeWebSiteAnnotations)) + { + foreach (var customizeWebSiteAnnotation in customizeWebSiteAnnotations) + { + customizeWebSiteAnnotation.Configure(infra, webSite); + } + } + } + + private BicepValue GetValue(EndpointMapping mapping, EndpointProperty property) + { + return property switch + { + EndpointProperty.Url => BicepFunction.Interpolate($"{mapping.Scheme}://{mapping.Host}.azurewebsites.net"), + EndpointProperty.Host => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"), + EndpointProperty.Port => mapping.Port.ToString(CultureInfo.InvariantCulture), + EndpointProperty.TargetPort => mapping.TargetPort?.ToString(CultureInfo.InvariantCulture) ?? (BicepValue)AllocateParameter(ResourceExpression.GetContainerPortExpression(Resource)), + EndpointProperty.Scheme => mapping.Scheme, + EndpointProperty.HostAndPort => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"), + EndpointProperty.IPV4Host => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"), + _ => throw new NotSupportedException($"Unsupported endpoint property {property}") + }; + } + + private BicepValue AllocateKeyVaultSecretUriReference(IAzureKeyVaultSecretReference secretReference) + { + var secret = secretReference.AsKeyVaultSecret(Infra); + + // https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references?tabs=azure-cli#-understand-source-app-settings-from-key-vault + return secret.Properties.SecretUri; + } + + private ProvisioningParameter AllocateParameter(IManifestExpressionProvider parameter, SecretType secretType = SecretType.None) + { + return parameter.AsProvisioningParameter(Infra, isSecure: secretType == SecretType.Normal); + } + + private sealed class ResourceExpression(IResource resource, string propertyExpression) : IManifestExpressionProvider + { + public string ValueExpression => $"{{{resource.Name}.{propertyExpression}}}"; + + public static IManifestExpressionProvider GetContainerImageExpression(IResource p) => + new ResourceExpression(p, "containerImage"); + + public static IManifestExpressionProvider GetContainerPortExpression(IResource p) => + new ResourceExpression(p, "containerPort"); + } + + enum SecretType + { + None, + Normal, + KeyVault + } +} diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteCustomizationAnnotation.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteCustomizationAnnotation.cs new file mode 100644 index 00000000000..d1b918e4aac --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteCustomizationAnnotation.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 Aspire.Hosting.ApplicationModel; +using Azure.Provisioning.AppService; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an annotation for customizing an Azure Web App. +/// +public sealed class AzureAppServiceWebsiteCustomizationAnnotation(Action configure) + : IResourceAnnotation +{ + /// + /// Gets the configuration action for customizing the Azure Web App. + /// + public Action Configure { get; } = configure ?? throw new ArgumentNullException(nameof(configure)); +} diff --git a/src/Aspire.Hosting.Azure/AzureProvisioningResourceExtensions.cs b/src/Aspire.Hosting.Azure/AzureProvisioningResourceExtensions.cs index 8f79b066257..9c64e0a9985 100644 --- a/src/Aspire.Hosting.Azure/AzureProvisioningResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure/AzureProvisioningResourceExtensions.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; +using Azure.Provisioning.KeyVault; namespace Aspire.Hosting; @@ -45,6 +46,52 @@ public static IResourceBuilder ConfigureInfrastructure(this IResourceBuild return builder; } + /// + /// Gets or creates a resource in the specified + /// for the given . + /// + /// If the referenced Key Vault or secret does not already exist in the infrastructure, they will be created and added. + /// This allows referencing secrets that are provisioned outside of the current deployment. + /// + /// + /// The representing the Key Vault secret to reference. + /// The in which to locate or add the . + /// + /// The instance corresponding to the given secret reference. + /// + public static KeyVaultSecret AsKeyVaultSecret(this IAzureKeyVaultSecretReference secretReference, AzureResourceInfrastructure infrastructure) + { + ArgumentNullException.ThrowIfNull(secretReference); + ArgumentNullException.ThrowIfNull(infrastructure); + + var resources = infrastructure.GetProvisionableResources(); + + var parameter = secretReference.Resource.NameOutputReference.AsProvisioningParameter(infrastructure); + var kvName = Infrastructure.NormalizeBicepIdentifier($"{parameter.BicepIdentifier}_kv"); + + var kv = resources.OfType().SingleOrDefault(kv => kv.BicepIdentifier == kvName); + + if (kv is null) + { + kv = KeyVaultService.FromExisting(kvName); + kv.Name = parameter; + infrastructure.Add(kv); + } + + var kvsName = Infrastructure.NormalizeBicepIdentifier($"{kv.BicepIdentifier}_{secretReference.SecretName}"); + var kvs = resources.OfType().SingleOrDefault(kvSecret => kvSecret.BicepIdentifier == kvsName); + + if (kvs is null) + { + kvs = KeyVaultSecret.FromExisting(kvsName); + kvs.Name = secretReference.SecretName; + kvs.Parent = kv; + infrastructure.Add(kvs); + } + + return kvs; + } + /// /// Creates a new in , or reuses an existing bicep parameter if one with /// the same name already exists, that corresponds to . @@ -211,7 +258,8 @@ private static ProvisioningParameter GetOrAddParameter(AzureResourceInfrastructu if (isSecure.HasValue) { parameter.IsSecure = isSecure.Value; - }; + } + infrastructure.Add(parameter); } 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 e3b52ef4a38..c23db8ae409 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs new file mode 100644 index 00000000000..2af3302b36c --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs @@ -0,0 +1,481 @@ +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.AppService; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using static Aspire.Hosting.Utils.AzureManifestUtils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureAppServiceTests(ITestOutputHelper output) +{ + + [Fact] + public async Task AddContainerAppEnvironmentAddsDeploymentTargetWithContainerAppToProjectResources() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var env = builder.AddAppServiceEnvironment("env"); + + builder.AddProject("api", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .PublishAsAzureAppServiceWebsite((infrastructure, site) => + { + site.SiteConfig.IsWebSocketsEnabled = true; + }); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var container = Assert.IsType(Assert.Single(model.GetProjectResources()), exactMatch: false); + + var target = container.GetDeploymentTargetAnnotation(); + + Assert.NotNull(target); + Assert.Same(env.Resource, target.ComputeEnvironment); + + var resource = target?.DeploymentTarget as AzureProvisioningResource; + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + var m = manifest.ToString(); + + var expectedManifest = + """ + { + "type": "azure.bicep.v0", + "path": "api.module.bicep", + "params": { + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_planid": "{env.outputs.planId}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "env_outputs_azure_container_registry_managed_identity_client_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID}", + "api_containerimage": "{api.containerImage}", + "api_containerport": "{api.containerPort}" + } + } + """; + + Assert.Equal(expectedManifest, m); + + var expectedBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param env_outputs_azure_container_registry_endpoint string + + param env_outputs_planid string + + param env_outputs_azure_container_registry_managed_identity_id string + + param env_outputs_azure_container_registry_managed_identity_client_id string + + param api_containerimage string + + param api_containerport string + + resource webapp 'Microsoft.Web/sites@2024-04-01' = { + name: take('${toLower('api')}-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + serverFarmId: env_outputs_planid + siteConfig: { + linuxFxVersion: 'DOCKER|${api_containerimage}' + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: env_outputs_azure_container_registry_managed_identity_client_id + appSettings: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: api_containerport + } + ] + webSocketsEnabled: true + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } + } + """; + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } + + [Fact] + public async Task AddContainerAppEnvironmentAddsEnvironmentResource() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAppServiceEnvironment("env"); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var environment = Assert.Single(model.Resources.OfType()); + + var (manifest, bicep) = await GetManifestWithBicep(environment); + + var m = manifest.ToString(); + + var expectedManifest = + """ + { + "type": "azure.bicep.v0", + "path": "env.module.bicep", + "params": { + "userPrincipalId": "" + } + } + """; + + Assert.Equal(expectedManifest, m); + + string expectedBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param userPrincipalId string + + param tags object = { } + + resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags + } + + resource env_acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: tags + } + + resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr + } + + resource env_asplan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + reserved: true + } + kind: 'Linux' + sku: { + name: 'B1' + tier: 'Basic' + } + } + + output planId string = env_asplan.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_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId + """; + + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } + + [Fact] + public async Task KeyvaultReferenceHandling() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAppServiceEnvironment("env"); + + var db = builder.AddAzureCosmosDB("mydb").WithAccessKeyAuthentication(); + db.AddCosmosDatabase("db"); + + builder.AddProject("api", launchProfileName: null) + .WithReference(db); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var container = Assert.Single(model.GetProjectResources()); + + container.TryGetLastAnnotation(out var target); + + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + var m = manifest.ToString(); + + var expectedManifest = + """ + { + "type": "azure.bicep.v0", + "path": "api.module.bicep", + "params": { + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_planid": "{env.outputs.planId}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "env_outputs_azure_container_registry_managed_identity_client_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID}", + "api_containerimage": "{api.containerImage}", + "mydb_kv_outputs_name": "{mydb-kv.outputs.name}", + "api_identity_outputs_id": "{api-identity.outputs.id}", + "api_identity_outputs_clientid": "{api-identity.outputs.clientId}" + } + } + """; + + Assert.Equal(expectedManifest, m); + + var expectedBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param env_outputs_azure_container_registry_endpoint string + + param env_outputs_planid string + + param env_outputs_azure_container_registry_managed_identity_id string + + param env_outputs_azure_container_registry_managed_identity_client_id string + + param api_containerimage string + + param mydb_kv_outputs_name string + + param api_identity_outputs_id string + + param api_identity_outputs_clientid string + + resource mydb_kv_outputs_name_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: mydb_kv_outputs_name + } + + resource mydb_kv_outputs_name_kv_connectionstrings__mydb 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { + name: 'connectionstrings--mydb' + parent: mydb_kv_outputs_name_kv + } + + resource webapp 'Microsoft.Web/sites@2024-04-01' = { + name: take('${toLower('api')}-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + serverFarmId: env_outputs_planid + keyVaultReferenceIdentity: api_identity_outputs_id + siteConfig: { + linuxFxVersion: 'DOCKER|${api_containerimage}' + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: env_outputs_azure_container_registry_managed_identity_client_id + appSettings: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ConnectionStrings__mydb' + value: '@Microsoft.KeyVault(SecretUri=${mydb_kv_outputs_name_kv_connectionstrings__mydb.properties.secretUri})' + } + { + name: 'AZURE_CLIENT_ID' + value: api_identity_outputs_clientid + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + '${api_identity_outputs_id}': { } + } + } + } + """; + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } + + [Fact] + public async Task EndpointReferencesAreResolvedAcrossProjects() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAppServiceEnvironment("env"); + + // Add 2 projects with endpoints + var project1 = builder.AddProject("project1", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints(); + + var project2 = builder.AddProject("project2", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithReference(project1); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + project2.Resource.TryGetLastAnnotation(out var target); + + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + var m = manifest.ToString(); + + var expectedManifest = + """ + { + "type": "azure.bicep.v0", + "path": "project2.module.bicep", + "params": { + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_planid": "{env.outputs.planId}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "env_outputs_azure_container_registry_managed_identity_client_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID}", + "project2_containerimage": "{project2.containerImage}", + "project2_containerport": "{project2.containerPort}" + } + } + """; + + Assert.Equal(expectedManifest, m); + + var expectedBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param env_outputs_azure_container_registry_endpoint string + + param env_outputs_planid string + + param env_outputs_azure_container_registry_managed_identity_id string + + param env_outputs_azure_container_registry_managed_identity_client_id string + + param project2_containerimage string + + param project2_containerport string + + resource webapp 'Microsoft.Web/sites@2024-04-01' = { + name: take('${toLower('project2')}-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + serverFarmId: env_outputs_planid + siteConfig: { + linuxFxVersion: 'DOCKER|${project2_containerimage}' + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: env_outputs_azure_container_registry_managed_identity_client_id + appSettings: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: project2_containerport + } + { + name: 'services__project1__http__0' + value: 'http://${take('${toLower('project1')}-${uniqueString(resourceGroup().id)}', 60)}.azurewebsites.net' + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } + } + """; + + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } + + private static Task<(JsonNode ManifestNode, string BicepText)> GetManifestWithBicep(IResource resource) => + AzureManifestUtils.GetManifestWithBicep(resource, skipPreparer: true); + + private sealed class Project : IProjectMetadata + { + public string ProjectPath => "project"; + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 5fe5fda74e5..8c413216459 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1816,15 +1816,15 @@ param api_identity_outputs_clientid string name: mydb_kv_outputs_name } - resource mydb_secretoutputs_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: mydb_secretoutputs - } - resource mydb_kv_outputs_name_kv_connectionstrings__mydb 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { name: 'connectionstrings--mydb' parent: mydb_kv_outputs_name_kv } + resource mydb_secretoutputs_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: mydb_secretoutputs + } + resource mydb_secretoutputs_kv_connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { name: 'connectionString' parent: mydb_secretoutputs_kv diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs index 986010f9bad..00f38797639 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs @@ -3,6 +3,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; +using Azure.Provisioning; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -166,4 +167,78 @@ public async Task WithEnvironment_AddsKeyVaultSecretReference() Assert.Equal("MY_SECRET", pubishKvp.Key); Assert.Equal("{myKeyVault.secrets.mySecret}", pubishKvp.Value); } + + [Fact] + public async Task ConsumingAKeyVaultSecretInAnotherBicepModule() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var kv = builder.AddAzureKeyVault("myKeyVault"); + + var secretReference = kv.Resource.GetSecret("mySecret"); + var secretReference2 = kv.Resource.GetSecret("mySecret2"); + + var module = builder.AddAzureInfrastructure("mymodule", infra => + { + var secret = secretReference.AsKeyVaultSecret(infra); + var secret2 = secretReference2.AsKeyVaultSecret(infra); + + // Should be idempotent + _ = secretReference.AsKeyVaultSecret(infra); + + infra.Add(new ProvisioningOutput("secretUri1", typeof(string)) + { + Value = secret.Properties.SecretUri + }); + + infra.Add(new ProvisioningOutput("secretUri2", typeof(string)) + { + Value = secret2.Properties.SecretUri + }); + }); + + var (manifest, bicep) = await AzureManifestUtils.GetManifestWithBicep(module.Resource, skipPreparer: true); + + var expectedManifest = + """ + { + "type": "azure.bicep.v0", + "path": "mymodule.module.bicep", + "params": { + "mykeyvault_outputs_name": "{myKeyVault.outputs.name}" + } + } + """; + + var m = manifest.ToString(); + Assert.Equal(expectedManifest, m); + + var expectedBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param mykeyvault_outputs_name string + + resource mykeyvault_outputs_name_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: mykeyvault_outputs_name + } + + resource mykeyvault_outputs_name_kv_mySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { + name: 'mySecret' + parent: mykeyvault_outputs_name_kv + } + + resource mykeyvault_outputs_name_kv_mySecret2 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { + name: 'mySecret2' + parent: mykeyvault_outputs_name_kv + } + + output secretUri1 string = mykeyvault_outputs_name_kv_mySecret.properties.secretUri + + output secretUri2 string = mykeyvault_outputs_name_kv_mySecret2.properties.secretUri + """; + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } } \ No newline at end of file