diff --git a/.gitignore b/.gitignore index f452f0fdcc7..79d0b07c380 100644 --- a/.gitignore +++ b/.gitignore @@ -147,4 +147,4 @@ node_modules/ *.svclog # Python virtual environments -.venv \ No newline at end of file +.venv diff --git a/Aspire.sln b/Aspire.sln index 77e05ffe136..29e219afabc 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -583,6 +584,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Dapr.Tests", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.AWS.Tests", "tests\Aspire.Hosting.AWS.Tests\Aspire.Hosting.AWS.Tests.csproj", "{6F71BC73-B703-4E64-98E0-801781302E7A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureFunctionsEndToEnd", "AzureFunctionsEndToEnd", "{305D5B56-8782-493C-BD44-9860F8616D92}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureFunctionsEndToEnd.ApiService", "playground\AzureFunctionsEndToEnd\AzureFunctionsEndToEnd.ApiService\AzureFunctionsEndToEnd.ApiService.csproj", "{659AF918-57A4-4616-B3D0-24FCE38DF12A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureFunctionsEndToEnd.AppHost", "playground\AzureFunctionsEndToEnd\AzureFunctionsEndToEnd.AppHost\AzureFunctionsEndToEnd.AppHost.csproj", "{13025E2D-2E2B-4319-8754-0B12F324283E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureFunctionsEndToEnd.Functions", "playground\AzureFunctionsEndToEnd\AzureFunctionsEndToEnd.Functions\AzureFunctionsEndToEnd.Functions.csproj", "{79D2E40E-95EC-4BAF-8382-E5669B8E6E42}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.Functions", "src\Aspire.Hosting.Azure.Functions\Aspire.Hosting.Azure.Functions.csproj", "{A8FFEB1F-B128-48D0-A114-5C94FF770551}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "waitfor", "waitfor", "{3FF3F00C-95C0-46FC-B2BE-A3920C71E393}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WaitForSandbox.AppHost", "playground\waitfor\WaitForSandbox.AppHost\WaitForSandbox.AppHost.csproj", "{415E011A-1C56-41A1-BAEB-CA5D5CED1A57}" @@ -1547,6 +1558,22 @@ Global {6F71BC73-B703-4E64-98E0-801781302E7A}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F71BC73-B703-4E64-98E0-801781302E7A}.Release|Any CPU.ActiveCfg = Release|Any CPU {6F71BC73-B703-4E64-98E0-801781302E7A}.Release|Any CPU.Build.0 = Release|Any CPU + {659AF918-57A4-4616-B3D0-24FCE38DF12A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {659AF918-57A4-4616-B3D0-24FCE38DF12A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {659AF918-57A4-4616-B3D0-24FCE38DF12A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {659AF918-57A4-4616-B3D0-24FCE38DF12A}.Release|Any CPU.Build.0 = Release|Any CPU + {13025E2D-2E2B-4319-8754-0B12F324283E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13025E2D-2E2B-4319-8754-0B12F324283E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13025E2D-2E2B-4319-8754-0B12F324283E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13025E2D-2E2B-4319-8754-0B12F324283E}.Release|Any CPU.Build.0 = Release|Any CPU + {79D2E40E-95EC-4BAF-8382-E5669B8E6E42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79D2E40E-95EC-4BAF-8382-E5669B8E6E42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79D2E40E-95EC-4BAF-8382-E5669B8E6E42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79D2E40E-95EC-4BAF-8382-E5669B8E6E42}.Release|Any CPU.Build.0 = Release|Any CPU + {A8FFEB1F-B128-48D0-A114-5C94FF770551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8FFEB1F-B128-48D0-A114-5C94FF770551}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8FFEB1F-B128-48D0-A114-5C94FF770551}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8FFEB1F-B128-48D0-A114-5C94FF770551}.Release|Any CPU.Build.0 = Release|Any CPU {415E011A-1C56-41A1-BAEB-CA5D5CED1A57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {415E011A-1C56-41A1-BAEB-CA5D5CED1A57}.Debug|Any CPU.Build.0 = Debug|Any CPU {415E011A-1C56-41A1-BAEB-CA5D5CED1A57}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -1844,6 +1871,11 @@ Global {091EA540-355B-4763-9980-5F83F0BB6F11} = {15966C27-17FA-4A46-A172-55985411540A} {C60C5CFA-5B6D-4432-BFCD-54D1BEEC7DBE} = {830A89EC-4029-4753-B25A-068BAE37DEC7} {6F71BC73-B703-4E64-98E0-801781302E7A} = {830A89EC-4029-4753-B25A-068BAE37DEC7} + {305D5B56-8782-493C-BD44-9860F8616D92} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {659AF918-57A4-4616-B3D0-24FCE38DF12A} = {305D5B56-8782-493C-BD44-9860F8616D92} + {13025E2D-2E2B-4319-8754-0B12F324283E} = {305D5B56-8782-493C-BD44-9860F8616D92} + {79D2E40E-95EC-4BAF-8382-E5669B8E6E42} = {305D5B56-8782-493C-BD44-9860F8616D92} + {A8FFEB1F-B128-48D0-A114-5C94FF770551} = {77CFE74A-32EE-400C-8930-5025E8555256} {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {415E011A-1C56-41A1-BAEB-CA5D5CED1A57} = {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} {C554C480-3DA7-4D62-A09A-3F3F743D7A66} = {3FF3F00C-95C0-46FC-B2BE-A3920C71E393} diff --git a/Directory.Build.targets b/Directory.Build.targets index 97c52f795e2..0b0b9392ba0 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -24,4 +24,21 @@ + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 1dabf4af9c5..c3958449ef6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -167,9 +167,18 @@ + + + + + + + + + - \ No newline at end of file + diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/AzureFunctionsEndToEnd.ApiService.csproj b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/AzureFunctionsEndToEnd.ApiService.csproj new file mode 100644 index 00000000000..9f7f87bb363 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/AzureFunctionsEndToEnd.ApiService.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Program.cs new file mode 100644 index 00000000000..38d5b8317f7 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Program.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Text; +#if !SKIP_EVENTHUBS_EMULATION +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Producer; +#endif +using Azure.Storage.Blobs; +using Azure.Storage.Queues; + +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire components. +builder.AddServiceDefaults(); +builder.AddAzureQueueClient("queue"); +builder.AddAzureBlobClient("blob"); +#if !SKIP_EVENTHUBS_EMULATION +builder.AddAzureEventHubProducerClient("eventhubs", static settings => settings.EventHubName = "myhub"); +#endif + +var app = builder.Build(); + +app.MapGet("/publish/asq", async (QueueServiceClient client, CancellationToken cancellationToken) => +{ + var queue = client.GetQueueClient("queue"); + await queue.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + var data = Convert.ToBase64String(Encoding.UTF8.GetBytes("Hello, World!")); + await queue.SendMessageAsync(data, cancellationToken: cancellationToken); + return Results.Ok("Message sent to Azure Storage Queue."); +}); + +static string RandomString(int length) +{ + const string chars = "abcdefghijklmnopqrstuvwxyz"; + return RandomNumberGenerator.GetString(chars, length); +} + +app.MapGet("/publish/blob", async (BlobServiceClient client, CancellationToken cancellationToken, int length = 20) => +{ + var container = client.GetBlobContainerClient("blobs"); + await container.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + + var entry = new { Id = Guid.NewGuid(), Text = RandomString(length) }; + var blob = container.GetBlobClient(entry.Id.ToString()); + + await blob.UploadAsync(new BinaryData(entry)); + + return Results.Ok("String uploaded to Azure Storage Blobs."); +}); + +#if !SKIP_EVENTHUBS_EMULATION +app.MapGet("/publish/eventhubs", async (EventHubProducerClient client, CancellationToken cancellationToken, int length = 20) => +{ + var data = new BinaryData(Encoding.UTF8.GetBytes(RandomString(length))); + await client.SendAsync([new EventData(data)]); + return Results.Ok("Message sent to Azure EventHubs."); +}); +#endif + +app.MapGet("/", async (HttpClient client) => +{ + var stream = await client.GetStreamAsync("http://funcapp/api/weatherforecast"); + return Results.Stream(stream, "application/json"); +}); + +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Properties/launchSettings.json b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000000..96710726e07 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "publish/asq", + "applicationUrl": "http://localhost:5313", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "publish/asq", + "applicationUrl": "https://localhost:7314;http://localhost:5313", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/appsettings.Development.json b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/appsettings.json b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/AzureFunctionsEndToEnd.AppHost.csproj b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/AzureFunctionsEndToEnd.AppHost.csproj new file mode 100644 index 00000000000..f33cab6be79 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/AzureFunctionsEndToEnd.AppHost.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + enable + enable + true + d824db17-effc-4f8c-aa80-f0ae6aba93eb + + + + + + + + + + + + + + + diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs new file mode 100644 index 00000000000..be5ba8e4b4c --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs @@ -0,0 +1,29 @@ +using Aspire.Hosting.Azure; + +var builder = DistributedApplication.CreateBuilder(args); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); +var queue = storage.AddQueues("queue"); +var blob = storage.AddBlobs("blob"); + +#if !SKIP_EVENTHUBS_EMULATION +var eventHubs = builder.AddAzureEventHubs("eventhubs").RunAsEmulator().AddEventHub("myhub"); +#endif + +var funcApp = builder.AddAzureFunctionsProject("funcapp") + .WithExternalHttpEndpoints() +#if !SKIP_EVENTHUBS_EMULATION + .WithReference(eventHubs) +#endif + .WithReference(blob) + .WithReference(queue); + +builder.AddProject("apiservice") +#if !SKIP_EVENTHUBS_EMULATION + .WithReference(eventHubs) +#endif + .WithReference(queue) + .WithReference(blob) + .WithReference(funcApp); + +builder.Build().Run(); diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Properties/launchSettings.json b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..d06eb6fd9fc --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17244;http://localhost:15054", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21003", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22110" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15054", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19010", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20125" + } + } + } +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/appsettings.Development.json b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/appsettings.json b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/.dockerignore b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/.dockerignore new file mode 100644 index 00000000000..1927772bc2e --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/.dockerignore @@ -0,0 +1 @@ +local.settings.json \ No newline at end of file diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/AzureFunctionsEndToEnd.Functions.csproj b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/AzureFunctionsEndToEnd.Functions.csproj new file mode 100644 index 00000000000..850bd534c5a --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/AzureFunctionsEndToEnd.Functions.csproj @@ -0,0 +1,42 @@ + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + + + + + + func + start --csharp --verbose + + diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Dockerfile b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Dockerfile new file mode 100644 index 00000000000..f5bedbc00ee --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS installer-env + +COPY . /src/dotnet-function-app +RUN cd /src/dotnet-function-app/AzureFunctionsEndToEnd.Functions && \ +mkdir -p /home/site/wwwroot && \ +dotnet publish *.csproj --output /home/site/wwwroot + +# To enable ssh & remote debugging on app service change the base image to the one below +# FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0-appservice +FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0 +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"] diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/HostBuilderExtensions.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/HostBuilderExtensions.cs new file mode 100644 index 00000000000..0f30915d12c --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/HostBuilderExtensions.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class HostBuilderExtensions +{ + /// + /// The `AddServiceDefaults` method that Aspire provides out of the box is designed + /// to target IHostApplicationBuilder. Function Apps still use HostBuilder so we implement + /// a dupe of the `AddServiceDefaults` implementation here to get things working properly. + /// Long-term, Functions apps should use `IHostApplicationBuilder`. + /// + public static HostBuilder AddServiceDefaults(this HostBuilder builder) + { + builder.ConfigureLogging(x => + { + x.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + }); + + builder.ConfigureServices((context, services) => + { + services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + }); + + builder.ConfigureServices(services => + { + services.AddServiceDiscovery(); + + services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + }); + + return builder; + } +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs new file mode 100644 index 00000000000..f8a2a5ef5d0 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs @@ -0,0 +1,16 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace AzureFunctionsEndToEnd.Functions; + +public class MyAzureBlobTrigger(ILogger logger) +{ + [Function(nameof(MyAzureBlobTrigger))] + [BlobOutput("test-files/{name}.txt", Connection = "blob")] + public string Run([BlobTrigger("blobs/{name}", Connection = "blob")] string triggerString) + { + logger.LogInformation("C# blob trigger function invoked with {message}...", triggerString); + return triggerString.ToUpper(); + } +} + diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureQueueTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureQueueTrigger.cs new file mode 100644 index 00000000000..0ea035d98c5 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureQueueTrigger.cs @@ -0,0 +1,15 @@ +using Azure.Storage.Queues.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace AzureFunctionsEndToEnd.Functions; + +public class MyAzureQueueTrigger(ILogger logger) +{ + [Function(nameof(MyAzureQueueTrigger))] + public void Run([QueueTrigger("queue", Connection = "queue")] QueueMessage message) + { + logger.LogInformation("C# Queue trigger function processed: {Text}", message.MessageText); + } +} + diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyEventHubTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyEventHubTrigger.cs new file mode 100644 index 00000000000..8cab0867ef9 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyEventHubTrigger.cs @@ -0,0 +1,15 @@ +#if !SKIP_EVENTHUBS_EMULATION +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace AspirePlusFunctions.Functions; + +public class MyEventHubTrigger(ILogger logger) +{ + [Function(nameof(MyEventHubTrigger))] + public void Run([EventHubTrigger("myhub", Connection = "eventhubs")] string[] input) + { + logger.LogInformation("C# EventHub trigger function processed: {Count} messages", input.Length); + } +} +#endif diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs new file mode 100644 index 00000000000..ee0140cd12a --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace AzureFunctionsEndToEnd.Functions; + +public class MyHttpTrigger(ILogger logger) +{ + [Function("weatherforecast")] + public IResult Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequest req) + { + logger.LogInformation("C# HTTP trigger function processed a request."); + + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + "Warm and sunny in Azure Functions" + )) + .ToArray(); + + return Results.Ok(forecast); + } +} + +public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs new file mode 100644 index 00000000000..e509752d83d --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs @@ -0,0 +1,17 @@ +using Microsoft.Azure.Functions.Worker.OpenTelemetry; +using Microsoft.Extensions.Hosting; +using OpenTelemetry; +using Microsoft.Extensions.DependencyInjection; + +var host = new HostBuilder() + .AddServiceDefaults() + .ConfigureFunctionsWebApplication() + .ConfigureServices(services => + { + services.AddOpenTelemetry() + .UseFunctionsWorkerDefaults() + .UseOtlpExporter(); + }) + .Build(); + +host.Run(); diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Properties/launchSettings.json b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Properties/launchSettings.json new file mode 100644 index 00000000000..696ecb572c1 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "AzureFunctionsEndToEnd_Functions": { + "commandName": "Project", + "launchBrowser": false + } + } +} diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/host.json b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/host.json new file mode 100644 index 00000000000..aa5fcc18c65 --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/host.json @@ -0,0 +1,13 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + }, + "telemetryMode": "openTelemetry" +} \ No newline at end of file diff --git a/playground/Directory.Build.targets b/playground/Directory.Build.targets index 4ffec86c99f..66767645cf9 100644 --- a/playground/Directory.Build.targets +++ b/playground/Directory.Build.targets @@ -3,8 +3,11 @@ + building on CI --> true + + true @@ -14,7 +17,8 @@ - - SKIP_DASHBOARD_REFERENCE;$(DefineConstants) + + SKIP_DASHBOARD_REFERENCE;$(DefineConstants) + SKIP_EVENTHUBS_EMULATION;$(DefineConstants) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs index ec2a63bb600..7ff43dc6ae3 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs @@ -16,7 +16,8 @@ namespace Aspire.Hosting.Azure; public class AzureEventHubsResource(string name, Action configureConstruct) : AzureConstructResource(name, configureConstruct), IResourceWithConnectionString, - IResourceWithEndpoints + IResourceWithEndpoints, + IResourceWithAzureFunctionsConfig { internal List<(string Name, Action, ResourceModuleConstruct, EventHub>? Configure)> Hubs { get; } = []; @@ -39,4 +40,16 @@ public class AzureEventHubsResource(string name, Action IsEmulator ? ReferenceExpression.Create($"Endpoint=sb://{EmulatorEndpoint.Property(EndpointProperty.Host)}:{EmulatorEndpoint.Property(EndpointProperty.Port)};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;") : ReferenceExpression.Create($"{EventHubsEndpoint}"); + + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + { + if (IsEmulator) + { + target[connectionName] = ConnectionStringExpression; + } + else + { + target[$"{connectionName}__fullyQualifiedNamespace"] = EventHubsEndpoint; + } + } } diff --git a/src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj b/src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj new file mode 100644 index 00000000000..34262d21de0 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj @@ -0,0 +1,19 @@ + + + + $(NetCurrent) + true + aspire hosting azure functions + Azure Functions resource types for .NET Aspire. + $(SharedDir)Azure_256x.png + + + + 0 + + + + + + + diff --git a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs new file mode 100644 index 00000000000..61a18d80be2 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs @@ -0,0 +1,145 @@ +// 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; + +/// +/// Extension methods for . +/// +public static class AzureFunctionsProjectResourceExtensions +{ + /// + /// Adds an Azure Functions project to the distributed application. + /// + /// The type of the project metadata, which must implement and have a parameterless constructor. + /// The to which the Azure Functions project will be added. + /// The name to be associated with the Azure Functions project. This name will be used for service discovery when referenced in a dependency. + /// An for the added Azure Functions project resource. + public static IResourceBuilder AddAzureFunctionsProject(this IDistributedApplicationBuilder builder, string name) where TProject : IProjectMetadata, new() + { + var resource = new AzureFunctionsProjectResource(name); + + // Add the default storage resource if it doesn't already exist. + var storage = builder.Resources.OfType().FirstOrDefault(r => r.Name == "azure-functions-default-storage"); + + if (storage is null) + { + storage = builder.AddAzureStorage("azure-functions-default-storage").RunAsEmulator().Resource; + + builder.Eventing.Subscribe((data, token) => + { + var removeStorage = true; + // Look at all of the resources and if none of them use the default storage, then we can remove it. + // This is because we're unable to cleanly add a resource to the builder from within a callback. + foreach (var item in data.Model.Resources.OfType()) + { + if (item.HostStorage == storage) + { + removeStorage = false; + } + + // Before the resource starts, we want to apply the azure functions specific environment variables. + // we look at all of the environment variables and apply the configuration for any resources that implement IResourceWithAzureFunctionsConfig. + item.Annotations.Add(new EnvironmentCallbackAnnotation(static context => + { + var functionsConfigMapping = new Dictionary(); + var valuesToRemove = new List(); + + foreach (var (envName, val) in context.EnvironmentVariables) + { + var (name, config) = val switch + { + IResourceWithAzureFunctionsConfig c => (c.Name, c), + ConnectionStringReference conn when conn.Resource is IResourceWithAzureFunctionsConfig c => (conn.ConnectionName ?? c.Name, c), + _ => ("", null) + }; + + if (config is not null) + { + valuesToRemove.Add(envName); + functionsConfigMapping[name] = config; + } + } + + // REVIEW: We need to remove the existing values before adding the new ones as there's a conflict with the connection strings. + // we don't want to do this because it'll stop the aspire components from working in functions projects. + foreach (var envName in valuesToRemove) + { + context.EnvironmentVariables.Remove(envName); + } + + foreach (var (name, config) in functionsConfigMapping) + { + config.ApplyAzureFunctionsConfiguration(context.EnvironmentVariables, name); + } + + return Task.CompletedTask; + })); + } + + if (removeStorage) + { + data.Model.Resources.Remove(storage); + } + + return Task.CompletedTask; + }); + } + + resource.HostStorage = storage; + + return builder.AddResource(resource) + .WithAnnotation(new TProject()) + .WithEnvironment(context => + { + context.EnvironmentVariables["OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES"] = "true"; + context.EnvironmentVariables["OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES"] = "true"; + context.EnvironmentVariables["OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY"] = "in_memory"; + context.EnvironmentVariables["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true"; + context.EnvironmentVariables["FUNCTIONS_WORKER_RUNTIME"] = "dotnet-isolated"; + + // Set the storage connection string. + ((IResourceWithAzureFunctionsConfig)resource.HostStorage).ApplyAzureFunctionsConfiguration(context.EnvironmentVariables, "Storage"); + }) + .WithArgs(context => + { + var http = resource.GetEndpoint("http"); + context.Args.Add("--port"); + context.Args.Add(http.Property(EndpointProperty.TargetPort)); + }) + .WithOtlpExporter() + .WithHttpEndpoint() + .WithManifestPublishingCallback(async (context) => + { + context.Writer.WriteString("type", "function.v0"); + context.Writer.WriteString("path", context.GetManifestRelativePath(new TProject().ProjectPath)); + await context.WriteEnvironmentVariablesAsync(resource).ConfigureAwait(false); + context.Writer.WriteStartObject("bindings"); + foreach (var s in new string[] { "http", "https" }) + { + context.Writer.WriteStartObject(s); + context.Writer.WriteString("scheme", s); + context.Writer.WriteString("protocol", "tcp"); + context.Writer.WriteString("transport", "http"); + context.Writer.WriteBoolean("external", true); + context.Writer.WriteEndObject(); + } + + context.Writer.WriteEndObject(); + }); + } + + /// + /// Configures the Azure Functions project resource to use the specified Azure Storage resource as its host storage. + /// + /// The resource builder for the Azure Functions project resource. + /// The resource builder for the Azure Storage resource to be used as host storage. + /// The resource builder for the Azure Functions project resource, configured with the specified host storage. + public static IResourceBuilder WithHostStorage(this IResourceBuilder builder, IResourceBuilder storage) + { + builder.Resource.HostStorage = storage.Resource; + return builder; + } +} diff --git a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsResource.cs b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsResource.cs new file mode 100644 index 00000000000..1c57a53d904 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsResource.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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Functions project resource within the Aspire hosting environment. +/// +/// +/// This class is used to define and manage the configuration of an Azure Functions project, +/// including its associated host storage. We create a strongly-typed resource for the Azure Functions +/// to support Functions-specific customizations, like the mapping of connection strings and configurations +/// for host storage. +/// /// +public class AzureFunctionsProjectResource(string name) : ProjectResource(name) +{ + internal AzureStorageResource? HostStorage { get; set; } +} diff --git a/src/Aspire.Hosting.Azure.Functions/PublicAPI.Shipped.txt b/src/Aspire.Hosting.Azure.Functions/PublicAPI.Shipped.txt new file mode 100644 index 00000000000..7dc5c58110b --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Aspire.Hosting.Azure.Functions/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Azure.Functions/PublicAPI.Unshipped.txt new file mode 100644 index 00000000000..4a6ca950364 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +#nullable enable +Aspire.Hosting.Azure.AzureFunctionsProjectResource +Aspire.Hosting.Azure.AzureFunctionsProjectResource.AzureFunctionsProjectResource(string! name) -> void +Aspire.Hosting.Azure.AzureFunctionsProjectResourceExtensions +static Aspire.Hosting.Azure.AzureFunctionsProjectResourceExtensions.AddAzureFunctionsProject(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.Azure.AzureFunctionsProjectResourceExtensions.WithHostStorage(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! storage) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Aspire.Hosting.Azure.Functions/README.md b/src/Aspire.Hosting.Azure.Functions/README.md new file mode 100644 index 00000000000..206256dfdaf --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/README.md @@ -0,0 +1,107 @@ +# Aspire.Hosting.Azure.Functions library + +Provides methods to the .NET Aspire hosting model for Azure functions. + +## Getting started + +### Prerequisites + +* A .NET Aspire project based on the starter template. +* A .NET-based Azure Functions worker project. + +### Install the package + +In your AppHost project, install the .NET Aspire Azure Functions Hosting library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Azure.Functions +``` + +## Usage example + +Add the following `PropertyGroup` in your .NET-based Azure Functions project. + +```xml + + func + start --csharp + +``` + +The Aspire Azure Functions integration does not currently support ports configured in the launch profile of the Functions application. +Remove the `commandLineArgs` property in the default `launchSettings.json` file: + +```diff +{ + "profiles": { + "Company.FunctionApp": { + "commandName": "Project", +- "commandLineArgs": "--port 7071", + "launchBrowser": false + } + } +} +``` + +Add a reference to the .NET-based Azure Functions project in your `AppHost` project. + +```dotnetcli +dotnet add reference ..\Company.FunctionApp\Company.FunctionApp.csproj +``` + +In the _Program.cs_ file of `AppHost`, use the `AddAzureFunctionsProject` to configure the Functions project resource. + +```csharp +using Aspire.Hosting; +using Aspire.Hosting.Azure; +using Aspire.Hosting.Azure.Functions; + +var builder = new DistributedApplicationBuilder(); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); +var queue = storage.AddQueues("queue"); +var blob = storage.AddBlobs("blob"); + +builder.AddAzureFunctionsProject("my-functions-project") + .WithReference(queue) + .WithReference(blob); + +var app = builder.Build(); + +app.Run(); +``` + +## Current Limitations + +The Azure Functions integration currently only support Azure Storage Queues, Azure Storage Blobs, and Azure Event Hubs as resource references. + +The Azure Functions integration does not currently support OpenTelemetry from the locally running Azure Functions host. + +Due to a [current bug in the Azure Functions Core Tools](https://github.com/Azure/azure-functions-core-tools/issues/3594), the Functions host may fail +to find the target project to build: + +> Can't determine Project to build. Expected 1 .csproj or .fsproj but found 2 + +To work around this issue, run the following commands in the Functions project directory: + +```dotnetcli +cd Company.FunctionApp +rm bin/ obj/ +func start --csharp +``` + +Then, update the `RunArguments` in the project file as follows: + +```diff + + func +- start --csharp ++ start --no-build --csharp + +``` + +Stop the local Functions host running in `Company.FunctionApp` and re-run the Aspire AppHost. + +## 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 c8edef404e2..9c7591d3af5 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs @@ -12,7 +12,8 @@ namespace Aspire.Hosting.Azure; /// The that the resource is stored in. public class AzureBlobStorageResource(string name, AzureStorageResource storage) : Resource(name), IResourceWithConnectionString, - IResourceWithParent + IResourceWithParent, + IResourceWithAzureFunctionsConfig { /// /// Gets the parent AzureStorageResource of this AzureBlobStorageResource. @@ -24,4 +25,18 @@ public class AzureBlobStorageResource(string name, AzureStorageResource storage) /// public ReferenceExpression ConnectionStringExpression => Parent.GetBlobConnectionString(); + + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + { + if (Parent.IsEmulator) + { + target[connectionName] = Parent.GetEmulatorConnectionString(); + } + else + { + // Blob and Queue services are required to make blob triggers work. + target[$"{connectionName}__blobServiceUri"] = Parent.BlobEndpoint; + target[$"{connectionName}__queueServiceUri"] = Parent.QueueEndpoint; + } + } } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs index 549dac5725a..1b6adfe65fc 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs @@ -12,7 +12,8 @@ namespace Aspire.Hosting.Azure; /// The that the resource is stored in. public class AzureQueueStorageResource(string name, AzureStorageResource storage) : Resource(name), IResourceWithConnectionString, - IResourceWithParent + IResourceWithParent, + IResourceWithAzureFunctionsConfig { /// /// Gets the parent AzureStorageResource of this AzureQueueStorageResource. @@ -24,4 +25,16 @@ public class AzureQueueStorageResource(string name, AzureStorageResource storage /// public ReferenceExpression ConnectionStringExpression => Parent.GetQueueConnectionString(); + + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + { + if (Parent.IsEmulator) + { + target[connectionName] = Parent.GetEmulatorConnectionString(); + } + else + { + target[$"{connectionName}__queueServiceUri"] = Parent.QueueEndpoint; + } + } } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs index 09fe2d345df..c809b8ac1af 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs @@ -12,7 +12,8 @@ namespace Aspire.Hosting.Azure; /// Callback to populate the construct with Azure resources. public class AzureStorageResource(string name, Action configureConstruct) : AzureConstructResource(name, configureConstruct), - IResourceWithEndpoints + IResourceWithEndpoints, + IResourceWithAzureFunctionsConfig { private EndpointReference EmulatorBlobEndpoint => new(this, "blob"); private EndpointReference EmulatorQueueEndpoint => new(this, "queue"); @@ -38,6 +39,14 @@ public class AzureStorageResource(string name, Action c /// public bool IsEmulator => this.IsContainer(); + /// + /// Gets the connection string for the Azure Storage emulator. + /// + /// + internal ReferenceExpression GetEmulatorConnectionString() => IsEmulator + ? ReferenceExpression.Create($"{AzureStorageEmulatorConnectionString.Create(blobPort: EmulatorBlobEndpoint.Port, queuePort: EmulatorQueueEndpoint.Port, tablePort: EmulatorTableEndpoint.Port)}") + : throw new InvalidOperationException("The Azure Storage resource is not running in the local emulator."); + internal ReferenceExpression GetTableConnectionString() => IsEmulator ? ReferenceExpression.Create($"{AzureStorageEmulatorConnectionString.Create(tablePort: EmulatorTableEndpoint.Port)}") : ReferenceExpression.Create($"{TableEndpoint}"); @@ -49,4 +58,17 @@ internal ReferenceExpression GetQueueConnectionString() => IsEmulator internal ReferenceExpression GetBlobConnectionString() => IsEmulator ? ReferenceExpression.Create($"{AzureStorageEmulatorConnectionString.Create(blobPort: EmulatorBlobEndpoint.Port)}") : ReferenceExpression.Create($"{BlobEndpoint}"); + + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + { + if (IsEmulator) + { + target[connectionName] = GetEmulatorConnectionString(); + } + else + { + target[$"{connectionName}__blobServiceUri"] = BlobEndpoint; + target[$"{connectionName}__queueServiceUri"] = QueueEndpoint; + } + } } diff --git a/src/Aspire.Hosting.Azure/IResourceWithAzureFunctionsConfig.cs b/src/Aspire.Hosting.Azure/IResourceWithAzureFunctionsConfig.cs new file mode 100644 index 00000000000..ec70998e8e7 --- /dev/null +++ b/src/Aspire.Hosting.Azure/IResourceWithAzureFunctionsConfig.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; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an resource that can provide configuration for Azure Functions. +/// +public interface IResourceWithAzureFunctionsConfig : IResource +{ + /// + /// Applies the Azure Functions configuration to the target dictionary. + /// + /// The dictionary to which the Azure Functions configuration will be applied. + /// The name of the connection key to be used for the given configuration. + void ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName); +} diff --git a/src/Aspire.Hosting.Azure/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Azure/PublicAPI.Unshipped.txt index 245ea64008f..b9b544922b3 100644 --- a/src/Aspire.Hosting.Azure/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.Azure/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable static Aspire.Hosting.AzureBicepResourceExtensions.WithParameter(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Aspire.Hosting.ApplicationModel.EndpointReference! value) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureBicepResourceExtensions.WithParameter(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Aspire.Hosting.ApplicationModel.ReferenceExpression! value) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +Aspire.Hosting.Azure.IResourceWithAzureFunctionsConfig +Aspire.Hosting.Azure.IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(System.Collections.Generic.IDictionary! target, string! connectionName) -> void static Aspire.Hosting.AzureConstructResourceExtensions.ConfigureConstruct(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Action! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Aspire.Hosting/ApplicationModel/ConnectionStringReference.cs b/src/Aspire.Hosting/ApplicationModel/ConnectionStringReference.cs index e1f1ba6d39e..7bd9472fa85 100644 --- a/src/Aspire.Hosting/ApplicationModel/ConnectionStringReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/ConnectionStringReference.cs @@ -17,6 +17,11 @@ public class ConnectionStringReference(IResourceWithConnectionString resource, b /// public bool Optional { get; } = optional; + /// + /// The name of the connection key. + /// + public string? ConnectionName { get; set; } + string IManifestExpressionProvider.ValueExpression => Resource.ValueExpression; IEnumerable IValueWithReferences.References => [Resource]; diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index 2bcb2f4f2b5..6a981d6bc22 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -26,6 +26,8 @@ Aspire.Hosting.ApplicationModel.ContainerLifetimeAnnotation.LifetimeType.set -> Aspire.Hosting.ApplicationModel.ContainerLifetimeType Aspire.Hosting.ApplicationModel.ContainerLifetimeType.Default = 0 -> Aspire.Hosting.ApplicationModel.ContainerLifetimeType Aspire.Hosting.ApplicationModel.ContainerLifetimeType.Persistent = 1 -> Aspire.Hosting.ApplicationModel.ContainerLifetimeType +Aspire.Hosting.ApplicationModel.ConnectionStringReference.ConnectionName.get -> string? +Aspire.Hosting.ApplicationModel.ConnectionStringReference.ConnectionName.set -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.get -> Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.init -> void Aspire.Hosting.ApplicationModel.HealthCheckAnnotation diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index c0a0bcc389e..b8fedc23476 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -284,7 +284,10 @@ public static IResourceBuilder WithReference(this IR { var connectionStringName = resource.ConnectionStringEnvironmentVariable ?? $"{ConnectionStringEnvironmentName}{connectionName}"; - context.EnvironmentVariables[connectionStringName] = new ConnectionStringReference(resource, optional); + context.EnvironmentVariables[connectionStringName] = new ConnectionStringReference(resource, optional) + { + ConnectionName = connectionName + }; }); } diff --git a/tests/Aspire.Playground.Tests/AppHostTests.cs b/tests/Aspire.Playground.Tests/AppHostTests.cs index c612c6c6288..c3f3e557927 100644 --- a/tests/Aspire.Playground.Tests/AppHostTests.cs +++ b/tests/Aspire.Playground.Tests/AppHostTests.cs @@ -279,7 +279,7 @@ public static IList GetAllTestEndpoints() new ("catalogdbapp", "Application started"), new ("basketservice", "Application started"), new ("postgres", "database system is ready to accept connections"), - ]) + ]), ]; return candidates; diff --git a/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj b/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj index 90de102a099..ca70f81f409 100644 --- a/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj +++ b/tests/Aspire.Playground.Tests/Aspire.Playground.Tests.csproj @@ -17,6 +17,13 @@ $(MSBuildThisFileDirectory)..\..\playground\ $(MSBuildThisFileDirectory)..\Shared\ $(MSBuildThisFileDirectory).runsettings + + true + + + + SKIP_EVENTHUBS_EMULATION;$(DefineConstants) @@ -24,6 +31,9 @@ picked up by msbuild by default --> + + + @@ -35,6 +45,7 @@ + @@ -60,6 +71,9 @@ + + + diff --git a/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs b/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs index 7de2d3f5a10..49a917fd0f2 100644 --- a/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs +++ b/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs @@ -3,6 +3,7 @@ using Aspire.Hosting; using Aspire.Hosting.Tests.Utils; +using Aspire.Components.Common.Tests; using SamplesIntegrationTests; using SamplesIntegrationTests.Infrastructure; using Xunit; @@ -54,6 +55,74 @@ await WaitForAllTextAsync(app, await app.StopAsync(); } + [Fact] + [ActiveIssue("https://github.com/dotnet/aspire/issues/5564", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))] + [RequiresDocker] + [RequiresTools(["func"])] + public async Task AzureFunctionsTest() + { + var appHostPath = Directory.GetFiles(AppContext.BaseDirectory, "AzureFunctionsEndToEnd.AppHost.dll").Single(); + var appHost = await DistributedApplicationTestFactory.CreateAsync(appHostPath, _testOutput); + await using var app = await appHost.BuildAsync(); + + await app.StartAsync(); + await app.WaitForResources().WaitAsync(TimeSpan.FromMinutes(2)); + + // Wait for the blobTrigger to be discovered as an indication that the host and worker + // has been successfully launched + await WaitForAllTextAsync(app, + [ + "MyAzureBlobTrigger: blobTrigger" + ], + resourceName: "funcapp", + timeoutSecs: 160); + + // Assert that HTTP triggers work correctly + await app.CreateHttpClient("funcapp").GetAsync("/api/weatherforecast"); + await WaitForAllTextAsync(app, + [ + "Executing HTTP request:", + "api/weatherforecast" + ], + resourceName: "funcapp", + timeoutSecs: 160); + + // Assert that Azure Storage Queue triggers work correctly + await app.CreateHttpClient("apiservice").GetAsync("/publish/asq"); + await WaitForAllTextAsync(app, + [ + "Executed 'Functions.MyAzureQueueTrigger'" + ], + resourceName: "funcapp", + timeoutSecs: 160); + + // Assert that Azure Storage Blob triggers work correctly + await app.CreateHttpClient("apiservice").GetAsync("/publish/blob"); + await WaitForAllTextAsync(app, + [ + "Executed 'Functions.MyAzureBlobTrigger'" + ], + resourceName: "funcapp", + timeoutSecs: 160); + + // Assert that EventHubs triggers work correctly +#if !SKIP_EVENTHUBS_EMULATION + await app.CreateHttpClient("apiservice").GetAsync("/publish/eventhubs"); + await WaitForAllTextAsync(app, + [ + "Executed 'Functions.MyEventHubTrigger'" + ], + resourceName: "funcapp", + timeoutSecs: 160); +#endif + + // TODO: The following line is commented out because the test fails due to an erroneous log in the Functions App + // resource that happens after the Functions host has been built. The error log shows up after the Functions + // worker extension has been built and before the host has launched. + // app.EnsureNoErrorsLogged(); + await app.StopAsync(); + } + internal static Task WaitForAllTextAsync(DistributedApplication app, IEnumerable logTexts, string? resourceName = null, int timeoutSecs = -1) { CancellationTokenSource cts = new(); diff --git a/tests/Shared/RepoTesting/Directory.Packages.Helix.props b/tests/Shared/RepoTesting/Directory.Packages.Helix.props index e0d771d335e..173a1ce6788 100644 --- a/tests/Shared/RepoTesting/Directory.Packages.Helix.props +++ b/tests/Shared/RepoTesting/Directory.Packages.Helix.props @@ -23,6 +23,7 @@ + diff --git a/tests/helix/send-to-helix-buildonhelixtests.targets b/tests/helix/send-to-helix-buildonhelixtests.targets index 9029c46e586..751bb8ed0d9 100644 --- a/tests/helix/send-to-helix-buildonhelixtests.targets +++ b/tests/helix/send-to-helix-buildonhelixtests.targets @@ -4,6 +4,7 @@ $(BuildHelixWorkItemsDependsOn);BuildHelixWorkItemsForBuildOnHelixTests true true + true $(TestArchiveTestsDirForBuildOnHelixTests)**/*.zip diff --git a/tests/helix/send-to-helix-inner.proj b/tests/helix/send-to-helix-inner.proj index ebcc638a185..781f6615663 100644 --- a/tests/helix/send-to-helix-inner.proj +++ b/tests/helix/send-to-helix-inner.proj @@ -26,6 +26,9 @@ <_CreateDotNetDevCertsDirectory>$(ArtifactsObjDir)create-dotnet-devcert <_SupportDataStagingDir>$([MSBuild]::NormalizeDirectory($(ArtifactsDir), 'helix', 'support-data')) + <_AzureFunctionsCliUrl Condition="'$(OS)' == 'Windows_NT'">https://github.com/Azure/azure-functions-core-tools/releases/download/4.0.5907/Azure.Functions.Cli.min.win-x64_net8.4.0.5907.zip + <_AzureFunctionsCliUrl Condition="'$(OS)' != 'Windows_NT'">https://github.com/Azure/azure-functions-core-tools/releases/download/4.0.5907/Azure.Functions.Cli.linux-x64_net8.4.0.5907.zip + _StageDotNetCoverageTool;_StageCreateDotNetDevCertScripts @@ -108,6 +111,15 @@ + + + + + + + + +