From 0391a693aaeba7b9ca39ec7c649582a13318d963 Mon Sep 17 00:00:00 2001 From: Reuben Bond <203839+ReubenBond@users.noreply.github.com> Date: Thu, 29 Feb 2024 14:09:19 -0800 Subject: [PATCH] Add Aspire.Hosting.Testing to facilitate integration testing (#2310) --- Aspire.sln | 47 ++ .../Aspire.Hosting.Orleans.csproj | 1 + .../Aspire.Hosting.Testing.csproj | 21 + ...DistributedApplicationEntryPointInvoker.cs | 208 +++++++++ .../DistributedApplicationExtensions.cs | 110 +++++ .../DistributedApplicationTestingBuilder.cs | 166 +++++++ ...DistributedApplicationTestingHarnessOfT.cs | 433 ++++++++++++++++++ src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + src/Aspire.Hosting/Dcp/ApplicationExecutor.cs | 53 ++- .../DcpDistributedApplicationLifecycleHook.cs | 8 +- src/Aspire.Hosting/Dcp/DcpHostService.cs | 12 + src/Aspire.Hosting/Dcp/DcpOptions.cs | 20 + src/Aspire.Hosting/DistributedApplication.cs | 20 +- .../DistributedApplicationBuilder.cs | 91 +++- .../Aspire.Hosting.Testing.Tests.csproj | 22 + .../Directory.Build.props | 8 + .../Directory.Build.targets | 9 + .../DistributedApplicationFixtureOfT.cs | 26 ++ .../TestingBuilderTests.cs | 64 +++ .../Directory.Build.props | 8 + .../Directory.Build.targets | 9 + .../TestingAppHost1.AppHost/Program.cs | 18 + .../Properties/launchSettings.json | 16 + .../TestingAppHost1.AppHost.csproj | 17 + .../appsettings.Development.json | 8 + .../TestingAppHost1.AppHost/appsettings.json | 9 + .../TestingAppHost1.MyWebApp/Program.cs | 48 ++ .../Properties/launchSettings.json | 41 ++ .../TestingAppHost1.MyWebApp.csproj | 18 + .../TestingAppHost1.MyWebApp.http | 6 + .../appsettings.Development.json | 8 + .../TestingAppHost1.MyWebApp/appsettings.json | 9 + .../TestingAppHost1.MyWorker/Program.cs | 8 + .../TestingAppHost1.MyWorker.csproj | 18 + .../Extensions.cs | 119 +++++ .../TestingAppHost1.ServiceDefaults.csproj | 27 ++ 36 files changed, 1683 insertions(+), 24 deletions(-) create mode 100644 src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj create mode 100644 src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs create mode 100644 src/Aspire.Hosting.Testing/DistributedApplicationExtensions.cs create mode 100644 src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs create mode 100644 src/Aspire.Hosting.Testing/DistributedApplicationTestingHarnessOfT.cs create mode 100644 tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj create mode 100644 tests/Aspire.Hosting.Testing.Tests/Directory.Build.props create mode 100644 tests/Aspire.Hosting.Testing.Tests/Directory.Build.targets create mode 100644 tests/Aspire.Hosting.Testing.Tests/DistributedApplicationFixtureOfT.cs create mode 100644 tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/Directory.Build.props create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/Directory.Build.targets create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/Properties/launchSettings.json create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHost1.AppHost.csproj create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/appsettings.Development.json create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/appsettings.json create mode 100644 tests/TestingAppHost1/TestingAppHost1.MyWebApp/Program.cs create mode 100644 tests/TestingAppHost1/TestingAppHost1.MyWebApp/Properties/launchSettings.json create mode 100644 tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.csproj create mode 100644 tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.http create mode 100644 tests/TestingAppHost1/TestingAppHost1.MyWebApp/appsettings.Development.json create mode 100644 tests/TestingAppHost1/TestingAppHost1.MyWebApp/appsettings.json create mode 100644 tests/TestingAppHost1/TestingAppHost1.MyWorker/Program.cs create mode 100644 tests/TestingAppHost1/TestingAppHost1.MyWorker/TestingAppHost1.MyWorker.csproj create mode 100644 tests/TestingAppHost1/TestingAppHost1.ServiceDefaults/Extensions.cs create mode 100644 tests/TestingAppHost1/TestingAppHost1.ServiceDefaults/TestingAppHost1.ServiceDefaults.csproj diff --git a/Aspire.sln b/Aspire.sln index 46afdc660a..ad79e3c7f1 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -269,6 +269,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureStorageEndToEnd.ApiSer EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Dashboard.Components.Tests", "tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj", "{0870A667-FB0C-4758-AEAF-9E5F092AD7C1}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{A7C6452C-FEDB-4883-9AE7-29892D260AA3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Testing", "src\Aspire.Hosting.Testing\Aspire.Hosting.Testing.csproj", "{3216CF59-84B0-46FF-8572-D0AFB0155423}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Testing.Tests", "tests\Aspire.Hosting.Testing.Tests\Aspire.Hosting.Testing.Tests.csproj", "{BE46B5B3-DFD4-4565-A2CD-7D95C623B03D}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DatabaseMigration", "DatabaseMigration", "{8B1802BC-6CB0-4027-850C-2AED42A82C9E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabaseMigration.ApiService", "playground\DatabaseMigration\DatabaseMigration.ApiService\DatabaseMigration.ApiService.csproj", "{7CC9BADD-B444-49E0-B6F0-BE2CD3A4669E}" @@ -279,6 +285,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabaseMigration.Migration EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabaseMigration.ApiModel", "playground\DatabaseMigration\DatabaseMigration.ApiModel\DatabaseMigration.ApiModel.csproj", "{C15F3F13-AB63-47CF-AAFE-D319F02E7B33}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestingAppHost1", "TestingAppHost1", "{A4F7DD00-59FE-4754-B0CF-3CC8C0856E17}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestingAppHost1.AppHost", "tests\TestingAppHost1\TestingAppHost1.AppHost\TestingAppHost1.AppHost.csproj", "{041C0A17-0968-4EB8-9208-5B8E257A0B33}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestingAppHost1.ServiceDefaults", "tests\TestingAppHost1\TestingAppHost1.ServiceDefaults\TestingAppHost1.ServiceDefaults.csproj", "{3D2D2428-BCB4-464F-9D03-7E9E6739D54B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestingAppHost1.MyWebApp", "tests\TestingAppHost1\TestingAppHost1.MyWebApp\TestingAppHost1.MyWebApp.csproj", "{6723830C-7E39-4709-836B-17E5EE426751}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestingAppHost1.MyWorker", "tests\TestingAppHost1\TestingAppHost1.MyWorker\TestingAppHost1.MyWorker.csproj", "{5345A33F-B845-4F2D-A934-F9D73327B1CE}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "signalr", "signalr", "{E6985EED-47E3-4EAC-8222-074E5410CEDC}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SignalRAppHost", "playground\signalr\SignalRAppHost\SignalRAppHost.csproj", "{89904E0B-9394-4A49-BA21-E02FF543587B}" @@ -733,6 +749,14 @@ Global {0870A667-FB0C-4758-AEAF-9E5F092AD7C1}.Debug|Any CPU.Build.0 = Debug|Any CPU {0870A667-FB0C-4758-AEAF-9E5F092AD7C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {0870A667-FB0C-4758-AEAF-9E5F092AD7C1}.Release|Any CPU.Build.0 = Release|Any CPU + {3216CF59-84B0-46FF-8572-D0AFB0155423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3216CF59-84B0-46FF-8572-D0AFB0155423}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3216CF59-84B0-46FF-8572-D0AFB0155423}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3216CF59-84B0-46FF-8572-D0AFB0155423}.Release|Any CPU.Build.0 = Release|Any CPU + {BE46B5B3-DFD4-4565-A2CD-7D95C623B03D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE46B5B3-DFD4-4565-A2CD-7D95C623B03D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE46B5B3-DFD4-4565-A2CD-7D95C623B03D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE46B5B3-DFD4-4565-A2CD-7D95C623B03D}.Release|Any CPU.Build.0 = Release|Any CPU {7CC9BADD-B444-49E0-B6F0-BE2CD3A4669E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7CC9BADD-B444-49E0-B6F0-BE2CD3A4669E}.Debug|Any CPU.Build.0 = Debug|Any CPU {7CC9BADD-B444-49E0-B6F0-BE2CD3A4669E}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -769,6 +793,22 @@ Global {4231B6F1-1110-4992-A727-8F1176A47440}.Debug|Any CPU.Build.0 = Debug|Any CPU {4231B6F1-1110-4992-A727-8F1176A47440}.Release|Any CPU.ActiveCfg = Release|Any CPU {4231B6F1-1110-4992-A727-8F1176A47440}.Release|Any CPU.Build.0 = Release|Any CPU + {041C0A17-0968-4EB8-9208-5B8E257A0B33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {041C0A17-0968-4EB8-9208-5B8E257A0B33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {041C0A17-0968-4EB8-9208-5B8E257A0B33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {041C0A17-0968-4EB8-9208-5B8E257A0B33}.Release|Any CPU.Build.0 = Release|Any CPU + {3D2D2428-BCB4-464F-9D03-7E9E6739D54B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D2D2428-BCB4-464F-9D03-7E9E6739D54B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D2D2428-BCB4-464F-9D03-7E9E6739D54B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D2D2428-BCB4-464F-9D03-7E9E6739D54B}.Release|Any CPU.Build.0 = Release|Any CPU + {6723830C-7E39-4709-836B-17E5EE426751}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6723830C-7E39-4709-836B-17E5EE426751}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6723830C-7E39-4709-836B-17E5EE426751}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6723830C-7E39-4709-836B-17E5EE426751}.Release|Any CPU.Build.0 = Release|Any CPU + {5345A33F-B845-4F2D-A934-F9D73327B1CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5345A33F-B845-4F2D-A934-F9D73327B1CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5345A33F-B845-4F2D-A934-F9D73327B1CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5345A33F-B845-4F2D-A934-F9D73327B1CE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -897,6 +937,8 @@ Global {157A434E-E3CA-4080-96CF-903CC3DF66E9} = {8AA07A14-A4A7-45EC-B0F6-4690B516B16D} {921CB408-5E37-4354-B4CF-EAE517F633DC} = {8AA07A14-A4A7-45EC-B0F6-4690B516B16D} {0870A667-FB0C-4758-AEAF-9E5F092AD7C1} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} + {3216CF59-84B0-46FF-8572-D0AFB0155423} = {A7C6452C-FEDB-4883-9AE7-29892D260AA3} + {BE46B5B3-DFD4-4565-A2CD-7D95C623B03D} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {8B1802BC-6CB0-4027-850C-2AED42A82C9E} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {7CC9BADD-B444-49E0-B6F0-BE2CD3A4669E} = {8B1802BC-6CB0-4027-850C-2AED42A82C9E} {E5C93F8B-D31B-4268-89EB-830EDC5524D0} = {8B1802BC-6CB0-4027-850C-2AED42A82C9E} @@ -907,6 +949,11 @@ Global {27B6DDFB-374D-43D5-918D-91B2EE7AEF72} = {E6985EED-47E3-4EAC-8222-074E5410CEDC} {9C30FFD6-2262-45E7-B010-24B30E0433C2} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {51654CD7-2E05-4664-B2EB-95308A300609} = {9C30FFD6-2262-45E7-B010-24B30E0433C2} + {A4F7DD00-59FE-4754-B0CF-3CC8C0856E17} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} + {041C0A17-0968-4EB8-9208-5B8E257A0B33} = {A4F7DD00-59FE-4754-B0CF-3CC8C0856E17} + {3D2D2428-BCB4-464F-9D03-7E9E6739D54B} = {A4F7DD00-59FE-4754-B0CF-3CC8C0856E17} + {6723830C-7E39-4709-836B-17E5EE426751} = {A4F7DD00-59FE-4754-B0CF-3CC8C0856E17} + {5345A33F-B845-4F2D-A934-F9D73327B1CE} = {A4F7DD00-59FE-4754-B0CF-3CC8C0856E17} {0244203D-7491-4414-9C88-10BFED9C5B2D} = {9C30FFD6-2262-45E7-B010-24B30E0433C2} {867A00A7-AF8E-4396-9583-982FBB31762C} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {4231B6F1-1110-4992-A727-8F1176A47440} = {867A00A7-AF8E-4396-9583-982FBB31762C} diff --git a/src/Aspire.Hosting.Orleans/Aspire.Hosting.Orleans.csproj b/src/Aspire.Hosting.Orleans/Aspire.Hosting.Orleans.csproj index 517a8d3fd2..706ef5ec33 100644 --- a/src/Aspire.Hosting.Orleans/Aspire.Hosting.Orleans.csproj +++ b/src/Aspire.Hosting.Orleans/Aspire.Hosting.Orleans.csproj @@ -3,6 +3,7 @@ $(NetCurrent) true + aspire hosting orleans Orleans support for .NET Aspire. diff --git a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj new file mode 100644 index 0000000000..d17e6cf43b --- /dev/null +++ b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj @@ -0,0 +1,21 @@ + + + + $(NetCurrent) + Library + true + true + $(NoWarn);CS8002 + aspire testing + Testing support for the .NET Aspire application model. + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs b/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs new file mode 100644 index 0000000000..acaaa8d0c0 --- /dev/null +++ b/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs @@ -0,0 +1,208 @@ +using System.Diagnostics; +using System.Reflection; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Hosting.Testing; + +internal sealed class DistributedApplicationEntryPointInvoker +{ + // This helpers encapsulates all of the complex logic required to: + // 1. Execute the entry point of the specified assembly in a different thread. + // 2. Wait for the diagnostic source events to fire + // 3. Give the caller a chance to execute logic to mutate the IDistributedApplicationBuilder + // 4. Resolve the instance of the DistributedApplication + // 5. Allow the caller to determine if the entry point has completed + public static Func>? ResolveEntryPoint( + Assembly assembly, + Action? onConstructing = null, + Action? onConstructed = null, + Action? onBuilding = null, + Action? entryPointCompleted = null) + { + if (assembly.EntryPoint is null) + { + return null; + } + + return async (args, ct) => + { + var invoker = new EntryPointInvoker( + args, + assembly.EntryPoint, + onConstructing, + onConstructed, + onBuilding, + entryPointCompleted); + return await invoker.InvokeAsync(ct).ConfigureAwait(false); + }; + } + + private sealed class EntryPointInvoker : IObserver + { + private static readonly AsyncLocal s_currentListener = new(); + private readonly string[] _args; + private readonly MethodInfo _entryPoint; + private readonly TaskCompletionSource _appTcs = new(); + private readonly ApplicationBuilderDiagnosticListener _applicationBuilderListener; + private readonly Action? _onConstructing; + private readonly Action? _onConstructed; + private readonly Action? _onBuilding; + private readonly Action? _entryPointCompleted; + + public EntryPointInvoker( + string[] args, + MethodInfo entryPoint, + Action? onConstructing, + Action? onConstructed, + Action? onBuilding, + Action? entryPointCompleted) + { + _args = args; + _entryPoint = entryPoint; + _onConstructing = onConstructing; + _onConstructed = onConstructed; + _onBuilding = onBuilding; + _entryPointCompleted = entryPointCompleted; + _applicationBuilderListener = new(this); + } + + public async Task InvokeAsync(CancellationToken cancellationToken) + { + using var subscription = DiagnosticListener.AllListeners.Subscribe(this); + + // Kick off the entry point on a new thread so we don't block the current one + // in case we need to timeout the execution + var thread = new Thread(() => + { + Exception? exception = null; + + try + { + // Set the async local to the instance of the HostingListener so we can filter events that + // aren't scoped to this execution of the entry point. + s_currentListener.Value = this; + + var parameters = _entryPoint.GetParameters(); + object? result; + if (parameters.Length == 0) + { + result = _entryPoint.Invoke(null, []); + } + else + { + result = _entryPoint.Invoke(null, [_args]); + } + + // Try to set an exception if the entry point returns gracefully, this will force + // build to throw + _appTcs.TrySetException(new InvalidOperationException($"The entry point exited without building a {nameof(DistributedApplication)}.")); + } + catch (TargetInvocationException tie) when (tie.InnerException?.GetType().Name == "HostAbortedException") + { + // The host was stopped by our own logic + } + catch (TargetInvocationException tie) + { + exception = tie.InnerException ?? tie; + + // Another exception happened, propagate that to the caller + _appTcs.TrySetException(exception); + } + catch (Exception ex) + { + exception = ex; + + // Another exception happened, propagate that to the caller + _appTcs.TrySetException(exception); + } + finally + { + // Signal that the entry point is completed + _entryPointCompleted?.Invoke(exception); + } + }) + { + // Make sure this doesn't hang the process + IsBackground = true, + Name = $"{_entryPoint.DeclaringType?.Assembly.GetName().Name ?? "Unknown"}.EntryPoint" + }; + + // Start the thread + thread.Start(); + + return await _appTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + + } + + public void OnNext(DiagnosticListener value) + { + if (s_currentListener.Value != this) + { + // Ignore events that aren't for this listener + return; + } + + if (value.Name == "Aspire.Hosting") + { + _applicationBuilderListener.Subscribe(value); + } + } + + private sealed class ApplicationBuilderDiagnosticListener(EntryPointInvoker owner) : IObserver> + { + private IDisposable? _disposable; + + public void Subscribe(DiagnosticListener listener) + { + _disposable = listener.Subscribe(this); + } + + public void OnCompleted() + { + _disposable?.Dispose(); + } + + public void OnError(Exception error) + { + } + + public void OnNext(KeyValuePair value) + { + if (s_currentListener.Value != owner) + { + // Ignore events that aren't for this listener + return; + } + + if (value.Key == "DistributedApplicationBuilderConstructing") + { + var args = ((DistributedApplicationOptions Options, HostApplicationBuilderSettings InnerBuilderOptions))value.Value!; + owner._onConstructing?.Invoke(args.Options, args.InnerBuilderOptions); + } + + if (value.Key == "DistributedApplicationBuilderConstructed") + { + owner._onConstructed?.Invoke((DistributedApplicationBuilder)value.Value!); + } + + if (value.Key == "DistributedApplicationBuilding") + { + owner._onBuilding?.Invoke((DistributedApplicationBuilder)value.Value!); + } + + if (value.Key == "DistributedApplicationBuilt") + { + owner._appTcs.TrySetResult((DistributedApplication)value.Value!); + } + } + } + } +} diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationExtensions.cs new file mode 100644 index 0000000000..f9d7396a76 --- /dev/null +++ b/src/Aspire.Hosting.Testing/DistributedApplicationExtensions.cs @@ -0,0 +1,110 @@ +// 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 Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Testing; + +/// +/// Extensions for working with in test code. +/// +public static class DistributedApplicationExtensions +{ + /// + /// Creates an configured to communicate with the specified resource. + /// + /// The application. + /// The resourceName of the resource. + /// The resourceName of the endpoint on the resource to communicate with. + /// The . + public static HttpClient CreateHttpClient(this DistributedApplication app, string resourceName, string? endpointName = default) + { + var baseUri = GetEndpointUriStringCore(app, resourceName, endpointName); + var clientFactory = app.Services.GetRequiredService(); + var client = clientFactory.CreateClient(); + client.BaseAddress = new(baseUri); + + return client; + } + + /// + /// Gets the connection string for the specified resource. + /// + /// The application. + /// The resource name. + /// The connection string for the specified resource. + /// The resource was not found or does not expose a connection string. + public static string? GetConnectionString(this DistributedApplication app, string resourceName) + { + var resource = GetResource(app, resourceName); + if (resource is not IResourceWithConnectionString resourceWithConnectionString) + { + throw new ArgumentException($"Resource '{resourceName}' does not expose a connection string.", nameof(resourceName)); + } + + return resourceWithConnectionString.GetConnectionString(); + } + + /// + /// Gets the endpoint for the specified resource. + /// + /// The application. + /// The resource name. + /// The optional endpoint name. If none are specified, the single defined endpoint is returned. + /// A URI representation of the endpoint. + /// The resource was not found, no matching endpoint was found, or multiple endpoints were found. + /// The resource has no endpoints. + public static Uri GetEndpoint(this DistributedApplication app, string resourceName, string? endpointName = default) => new(GetEndpointUriStringCore(app, resourceName, endpointName)); + + private static IResource GetResource(DistributedApplication app, string resourceName) + { + var applicationModel = app.Services.GetRequiredService(); + + var resources = applicationModel.Resources; + var resource = resources.SingleOrDefault(r => string.Equals(r.Name, resourceName, StringComparison.OrdinalIgnoreCase)); + + if (resource is null) + { + throw new ArgumentException($"Resource '{resourceName}' not found.", nameof(resourceName)); + } + + return resource; + } + + private static string GetEndpointUriStringCore(DistributedApplication app, string resourceName, string? endpointName = default) + { + var resource = GetResource(app, resourceName); + if (!resource.TryGetAllocatedEndPoints(out var endpoints)) + { + throw new InvalidOperationException($"Resource '{resourceName}' has no allocated endpoints."); + } + + AllocatedEndpointAnnotation? endpoint; + if (!string.IsNullOrEmpty(endpointName)) + { + endpoint = GetEndpointOrDefault(endpoints, resourceName, endpointName); + } + else + { + endpoint = GetEndpointOrDefault(endpoints, resourceName, "http") ?? GetEndpointOrDefault(endpoints, resourceName, "https"); + } + + if (endpoint is null) + { + throw new ArgumentException($"Endpoint '{endpointName}' for resource '{resourceName}' not found.", nameof(endpointName)); + } + + return endpoint.UriString; + } + + static AllocatedEndpointAnnotation? GetEndpointOrDefault(IEnumerable endpoints, string resourceName, string? endpointName) + { + var filteredEndpoints = endpoints.Where(e => string.Equals(e.Name, endpointName, StringComparison.OrdinalIgnoreCase)).ToList(); + return filteredEndpoints.Count switch + { + 0 => null, + 1 => filteredEndpoints[0], + _ => throw new InvalidOperationException($"Resource '{resourceName}' has multiple endpoints named '{endpointName}'."), + }; + } +} diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs new file mode 100644 index 0000000000..6b9f99c140 --- /dev/null +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -0,0 +1,166 @@ +// 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 Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Hosting.Testing; + +/// +/// Harness for running a distributed application for testing. +/// +/// +/// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used. +/// +public sealed class DistributedApplicationTestingBuilder : IDistributedApplicationBuilder where TEntryPoint : class +{ + private readonly SuspendingDistributedApplicationFactory _factory; + private readonly DistributedApplicationBuilder _applicationBuilder; + + /// + /// Initializes a new instance of the class. + /// + public DistributedApplicationTestingBuilder() : this((_, __) => { }) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The delegate used to configure the creation of the builder. + public DistributedApplicationTestingBuilder(Action configureBuilder) + { + _factory = new(configureBuilder); + _applicationBuilder = _factory.DistributedApplicationBuilder.Result; + } + + /// + public ConfigurationManager Configuration => _applicationBuilder.Configuration; + + /// + public string AppHostDirectory => _applicationBuilder.AppHostDirectory; + + /// + public IHostEnvironment Environment => _applicationBuilder.Environment; + + /// + public IServiceCollection Services => _applicationBuilder.Services; + + /// + public DistributedApplicationExecutionContext ExecutionContext => _applicationBuilder.ExecutionContext; + + /// + public IResourceCollection Resources => _applicationBuilder.Resources; + + /// + public IResourceBuilder AddResource(T resource) where T : IResource => _applicationBuilder.AddResource(resource); + + /// + public IResourceBuilder CreateResourceBuilder(T resource) where T : IResource => _applicationBuilder.CreateResourceBuilder(resource); + + /// + public DistributedApplication Build() + { + _factory.Build(); + return new DelegatedDistributedApplication(new DelegatedHost(_factory)); + } + + private sealed class SuspendingDistributedApplicationFactory(Action configureBuilder) + : DistributedApplicationTestingHarness + { + private readonly SemaphoreSlim _continueBuilding = new(0); + private readonly TaskCompletionSource _builderTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public new DistributedApplication DistributedApplication => base.DistributedApplication; + + public Task DistributedApplicationBuilder => _builderTcs.Task; + + protected override void OnBuilderCreating(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostOptions) + { + base.OnBuilderCreating(applicationOptions, hostOptions); + configureBuilder(applicationOptions, hostOptions); + } + + protected override void OnBuilderCreated(DistributedApplicationBuilder applicationBuilder) + { + base.OnBuilderCreated(applicationBuilder); + } + + protected override void OnBuilding(DistributedApplicationBuilder applicationBuilder) + { + base.OnBuilding(applicationBuilder); + _builderTcs.TrySetResult(applicationBuilder); + + // Wait until the owner signals that building can continue by calling Build(). + _continueBuilding.Wait(); + } + + public void Build() + { + _continueBuilding.Release(); + } + + public override async ValueTask DisposeAsync() + { + _continueBuilding.Release(); + _builderTcs.TrySetCanceled(); + await base.DisposeAsync().ConfigureAwait(false); + } + + public override void Dispose() + { + _continueBuilding.Release(); + _builderTcs.TrySetCanceled(); + base.Dispose(); + } + } + + private sealed class DelegatedDistributedApplication(DelegatedHost host) : DistributedApplication(host) + { + private readonly DelegatedHost _host = host; + + public override async Task RunAsync(CancellationToken cancellationToken) + { + // Avoid calling the base here, since it will execute the pre-start hooks + // before calling the corresponding host method, which also executes the same pre-start hooks. + await _host.RunAsync(cancellationToken).ConfigureAwait(false); + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + // Avoid calling the base here, since it will execute the pre-start hooks + // before calling the corresponding host method, which also executes the same pre-start hooks. + await _host.StartAsync(cancellationToken).ConfigureAwait(false); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _host.StopAsync(cancellationToken).ConfigureAwait(false); + } + } + + private sealed class DelegatedHost(SuspendingDistributedApplicationFactory appFactory) : IHost, IAsyncDisposable + { + public IServiceProvider Services => appFactory.Services; + + public void Dispose() + { + appFactory.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await appFactory.DisposeAsync().ConfigureAwait(false); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await appFactory.InitializeAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await appFactory.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingHarnessOfT.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingHarnessOfT.cs new file mode 100644 index 0000000000..1b8198f10b --- /dev/null +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingHarnessOfT.cs @@ -0,0 +1,433 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Testing; + +/// +/// Harness for running a distributed application for testing. +/// +/// +/// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used. +/// +public class DistributedApplicationTestingHarness : IDisposable, IAsyncDisposable where TEntryPoint : class +{ + private readonly TaskCompletionSource _startedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _exitTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly object _lockObj = new(); + private Task? _appTask; + private DistributedApplication? _app; + private IHostApplicationLifetime? _hostApplicationLifetime; + + /// + /// Gets the distributed application associated with this . + /// + protected DistributedApplication DistributedApplication { get { EnsureApp(); return _app; } } + + /// + /// Initializes the application. + /// + /// A token used to signal cancellation. + /// A representing the completion of the operation. + public Task InitializeAsync(CancellationToken cancellationToken = default) + { + EnsureApp(); + return _startedTcs.Task.WaitAsync(cancellationToken); + } + + /// + /// Gets the created by the server associated with this . + /// + public virtual IServiceProvider Services + { + get + { + EnsureApp(); + return _app.Services; + } + } + + /// + /// Gets the associated with this . + /// + public DistributedApplicationModel ApplicationModel + { + get + { + EnsureApp(); + ThrowIfNotInitialized(); + return _app.Services.GetRequiredService(); + } + } + + /// + /// Creates an instance of that is configured to route requests to the specified resource and endpoint. + /// + /// The . + public HttpClient CreateHttpClient(string resourceName, string? endpointName = default) + { + EnsureApp(); + ThrowIfNotInitialized(); + + return DistributedApplication.CreateHttpClient(resourceName, endpointName); + } + + /// + /// Gets the connection string for the specified resource. + /// + /// The resource name. + /// The connection string for the specified resource. + /// The resource was not found or does not expose a connection string. + public string? GetConnectionString(string resourceName) + { + EnsureApp(); + ThrowIfNotInitialized(); + + return DistributedApplication.GetConnectionString(resourceName); + } + + /// + /// Gets the endpoint for the specified resource. + /// + /// The resource name. + /// The optional endpoint name. If none are specified, the single defined endpoint is returned. + /// A URI representation of the endpoint. + /// The resource was not found, no matching endpoint was found, or multiple endpoints were found. + /// The resource has no endpoints. + public Uri GetEndpoint(string resourceName, string? endpointName = default) + { + EnsureApp(); + ThrowIfNotInitialized(); + + return DistributedApplication.GetEndpoint(resourceName, endpointName); + } + + /// + /// Called when the application builder is being created. + /// + /// The application options. + /// The host builder options. + protected virtual void OnBuilderCreating(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostOptions) + { + } + + /// + /// Called when the application builder is created. + /// + /// The application builder. + protected virtual void OnBuilderCreated(DistributedApplicationBuilder applicationBuilder) + { + } + + /// + /// Called when the application is being built. + /// + /// The application builder. + protected virtual void OnBuilding(DistributedApplicationBuilder applicationBuilder) + { + } + + private void OnBuilderCreatingCore(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostBuilderOptions) + { + hostBuilderOptions.EnvironmentName = Environments.Development; + hostBuilderOptions.ApplicationName = typeof(TEntryPoint).Assembly.GetName().Name ?? string.Empty; + applicationOptions.AssemblyName = typeof(TEntryPoint).Assembly.GetName().Name ?? string.Empty; + applicationOptions.DisableDashboard = true; + OnBuilderCreating(applicationOptions, hostBuilderOptions); + } + + private void OnBuilderCreatedCore(DistributedApplicationBuilder applicationBuilder) + { + OnBuilderCreated(applicationBuilder); + } + + private void OnBuildingCore(DistributedApplicationBuilder applicationBuilder) + { + // Patch DcpOptions configuration + var services = applicationBuilder.Services; + services.RemoveAll>(); + services.AddSingleton, ConfigureDcpOptions>(); + services.Configure(o => + { + o.ResourceNameSuffix = $"{Random.Shared.Next():x}"; + o.DeleteResourcesOnShutdown = true; + o.RandomizePorts = true; + }); + + services.AddHttpClient(); + services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); + + InterceptHostCreation(applicationBuilder); + + OnBuilding(applicationBuilder); + } + + [MemberNotNull(nameof(_app))] + private void EnsureApp() + { + if (_app is not null) + { + return; + } + + EnsureDepsFile(); + + if (_appTask is null) + { + lock (_lockObj) + { + if (_appTask is null) + { + // This helper launches the target assembly's entry point and hooks into the lifecycle + // so we can intercept execution at key stages. + var factory = DistributedApplicationEntryPointInvoker.ResolveEntryPoint( + typeof(TEntryPoint).Assembly, + onConstructing: OnBuilderCreatingCore, + onConstructed: OnBuilderCreatedCore, + onBuilding: OnBuildingCore, + entryPointCompleted: OnEntryPointExit); + + if (factory is null) + { + throw new InvalidOperationException( + $"Could not intercept application builder instance. Ensure that {typeof(TEntryPoint)} is a type in an executable assembly, that the entrypoint creates an {typeof(DistributedApplicationBuilder)}, and that the resulting {typeof(DistributedApplication)} is being started."); + } + + _appTask = ResolveApp(factory); + } + } + } + + _app = _appTask.GetAwaiter().GetResult(); + } + + private void OnEntryPointExit(Exception? exception) + { + if (exception is not null) + { + _exitTcs.TrySetException(exception); + } + else + { + _exitTcs.TrySetResult(); + } + } + + private async Task ResolveApp(Func> factory) + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + using var cts = new CancellationTokenSource(GetConfiguredTimeout()); + var app = await factory([], cts.Token).ConfigureAwait(false); + _hostApplicationLifetime = app.Services.GetService() + ?? throw new InvalidOperationException($"Application did not register an implementation of {typeof(IHostApplicationLifetime)}."); + return app; + + static TimeSpan GetConfiguredTimeout() + { + const string TimeoutEnvironmentKey = "DOTNET_HOST_FACTORY_RESOLVER_DEFAULT_TIMEOUT_IN_SECONDS"; + if (Debugger.IsAttached) + { + return Timeout.InfiniteTimeSpan; + } + + if (uint.TryParse(Environment.GetEnvironmentVariable(TimeoutEnvironmentKey), out var timeoutInSeconds)) + { + return TimeSpan.FromSeconds((int)timeoutInSeconds); + } + + return TimeSpan.FromMinutes(5); + } + } + + private void ThrowIfNotInitialized() + { + if (!_startedTcs.Task.IsCompleted) + { + throw new InvalidOperationException("The application has not been initialized."); + } + } + + private static void EnsureDepsFile() + { + if (typeof(TEntryPoint).Assembly.EntryPoint == null) + { + throw new InvalidOperationException($"Assembly of specified type {typeof(TEntryPoint).Name} does not have an entry point."); + } + + var depsFileName = $"{typeof(TEntryPoint).Assembly.GetName().Name}.deps.json"; + var depsFile = new FileInfo(Path.Combine(AppContext.BaseDirectory, depsFileName)); + if (!depsFile.Exists) + { + throw new InvalidOperationException($"Missing deps file '{Path.GetFileName(depsFile.FullName)}'. Make sure the project has been built."); + } + } + + /// + public virtual void Dispose() + { + if (_app is null || _hostApplicationLifetime is null) + { + return; + } + + _hostApplicationLifetime?.StopApplication(); + _app?.Dispose(); + } + + /// + public virtual async ValueTask DisposeAsync() + { + if (_app is null) + { + return; + } + + if (_hostApplicationLifetime is { } hostLifetime) + { + hostLifetime.StopApplication(); + + // Wait for shutdown to complete. + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var _ = hostLifetime.ApplicationStopped.Register(s => ((TaskCompletionSource)s!).SetResult(), tcs); + await tcs.Task.ConfigureAwait(false); + } + + await _exitTcs.Task.ConfigureAwait(false); + + if (_app is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + } + + // Replaces the IHost registration with an InterceptedHost registration which delegates to the original registration. + private void InterceptHostCreation(DistributedApplicationBuilder applicationBuilder) + { + // Find the original IHost registration and remove it. + var hostDescriptor = applicationBuilder.Services.Single(s => s.ServiceType == typeof(IHost) && s.ServiceKey is null); + applicationBuilder.Services.Remove(hostDescriptor); + + // Insert the registration, modified to be a keyed service keyed on this factory instance. + var interceptedDescriptor = hostDescriptor switch + { + { ImplementationFactory: { } factory } => ServiceDescriptor.KeyedSingleton(this, (sp, _) => (IHost)factory(sp)), + { ImplementationInstance: { } instance } => ServiceDescriptor.KeyedSingleton(this, (IHost)instance), + { ImplementationType: { } type } => ServiceDescriptor.KeyedSingleton(typeof(IHost), this, type), + _ => throw new InvalidOperationException($"Registered service descriptor for {typeof(IHost)} does not conform to any known pattern.") + }; + applicationBuilder.Services.Add(interceptedDescriptor); + + // Add a non-keyed registration which resolved the keyed registration, enabling interception. + applicationBuilder.Services.AddSingleton(sp => new ObservedHost(sp.GetRequiredKeyedService(this), this)); + } + + private sealed class ObservedHost(IHost innerHost, DistributedApplicationTestingHarness appFactory) : IHost, IAsyncDisposable + { + private bool _disposing; + + public IServiceProvider Services => innerHost.Services; + + public void Dispose() + { + if (_disposing) + { + return; + } + + _disposing = true; + innerHost.Dispose(); + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposing) + { + return; + } + + _disposing = true; + if (innerHost is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + innerHost.Dispose(); + } + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + try + { + await innerHost.StartAsync(cancellationToken).ConfigureAwait(false); + appFactory._startedTcs.TrySetResult(); + } + catch (Exception exception) + { + appFactory._startedTcs.TrySetException(exception); + } + } + + public Task StopAsync(CancellationToken cancellationToken = default) => innerHost.StopAsync(cancellationToken); + } + + private sealed class ConfigureDcpOptions(IConfiguration configuration) : IConfigureOptions + { + private const string DcpCliPathMetadataKey = "DcpCliPath"; + private const string DcpExtensionsPathMetadataKey = "DcpExtensionsPath"; + private const string DcpBinPathMetadataKey = "DcpBinPath"; + + public void Configure(DcpOptions options) + { + var dcpPublisherConfiguration = configuration.GetSection("DcpPublisher"); + var publishingConfiguration = configuration.GetSection("Publishing"); + + string? publisher = publishingConfiguration[nameof(PublishingOptions.Publisher)]; + string? cliPath; + + if (publisher is not null && publisher != "dcp") + { + // If DCP is not set as the publisher, don't calculate the DCP config + return; + } + + if (!string.IsNullOrEmpty(dcpPublisherConfiguration["CliPath"])) + { + // If an explicit path to DCP was provided from configuration, don't try to resolve via assembly attributes + cliPath = dcpPublisherConfiguration["CliPath"]; + options.CliPath = cliPath; + } + else + { + var entryPointAssembly = typeof(TEntryPoint).Assembly; + var assemblyMetadata = entryPointAssembly?.GetCustomAttributes(); + cliPath = GetMetadataValue(assemblyMetadata, DcpCliPathMetadataKey); + options.CliPath = cliPath; + options.ExtensionsPath = GetMetadataValue(assemblyMetadata, DcpExtensionsPathMetadataKey); + options.BinPath = GetMetadataValue(assemblyMetadata, DcpBinPathMetadataKey); + } + + if (string.IsNullOrEmpty(cliPath)) + { + throw new InvalidOperationException($"Could not resolve the path to the Aspire application host. The application cannot be run without it."); + } + } + + private static string? GetMetadataValue(IEnumerable? assemblyMetadata, string key) + { + return assemblyMetadata?.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase))?.Value; + } + } +} diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 19d234f9b6..728afdc60f 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -55,6 +55,7 @@ + diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index 5eb1a60780..410e7262c3 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -688,7 +688,7 @@ private void PrepareContainers() throw new InvalidOperationException(); } - var ctr = Container.Create(container.Name, containerImageName); + var ctr = Container.Create(GetObjectNameForResource(container), containerImageName); ctr.Annotate(Container.ResourceNameAnnotation, container.Name); ctr.Annotate(Container.OtelServiceNameAnnotation, container.Name); @@ -896,10 +896,17 @@ private async Task CreateResourcesAsync(CancellationToken cancellationToken) } } - private static string GetObjectNameForResource(IResource resource, string suffix = "") + private string GetObjectNameForResource(IResource resource, string suffix = "") { - string maybeWithSuffix(string s) => string.IsNullOrWhiteSpace(suffix) ? s : $"{s}_{suffix}"; - return maybeWithSuffix(resource.Name); + static string maybeWithSuffix(string s, string localSuffix, string? globalSuffix) + => (string.IsNullOrWhiteSpace(localSuffix), string.IsNullOrWhiteSpace(globalSuffix)) switch + { + (true, true) => s, + (false, true) => $"{s}_{localSuffix}", + (true, false) => $"{s}_{globalSuffix}", + (false, false) => $"{s}_{localSuffix}_{globalSuffix}" + }; + return maybeWithSuffix(resource.Name, suffix, _options.Value.ResourceNameSuffix); } private static string GenerateUniqueServiceName(HashSet serviceNames, string candidateName) @@ -931,4 +938,42 @@ private static bool IsUnspecifiedHttpService(ServiceAppResource serviceAppResour EnvironmentVariable: null or { Length: 0 } }; } + + public async Task DeleteResourcesAsync(CancellationToken cancellationToken = default) + { + try + { + AspireEventSource.Instance.DcpModelCleanupStart(); + await DeleteResourcesAsync("project", cancellationToken).ConfigureAwait(false); + await DeleteResourcesAsync("project", cancellationToken).ConfigureAwait(false); + await DeleteResourcesAsync("container", cancellationToken).ConfigureAwait(false); + await DeleteResourcesAsync("service", cancellationToken).ConfigureAwait(false); + } + finally + { + AspireEventSource.Instance.DcpModelCleanupStop(); + _appResources.Clear(); + } + } + + private async Task DeleteResourcesAsync(string resourceType, CancellationToken cancellationToken) where RT : CustomResource + { + var resourcesToDelete = _appResources.Select(r => r.DcpResource).OfType(); + if (!resourcesToDelete.Any()) + { + return; + } + + foreach (var res in resourcesToDelete) + { + try + { + await kubernetesService.DeleteAsync(res.Metadata.Name, res.Metadata.NamespaceProperty, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogInformation(ex, "Could not stop {ResourceType} '{ResourceName}'.", resourceType, res.Metadata.Name); + } + } + } } diff --git a/src/Aspire.Hosting/Dcp/DcpDistributedApplicationLifecycleHook.cs b/src/Aspire.Hosting/Dcp/DcpDistributedApplicationLifecycleHook.cs index 1eca64d786..fe7d8660c9 100644 --- a/src/Aspire.Hosting/Dcp/DcpDistributedApplicationLifecycleHook.cs +++ b/src/Aspire.Hosting/Dcp/DcpDistributedApplicationLifecycleHook.cs @@ -3,11 +3,12 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.Options; using System.Net.Sockets; namespace Aspire.Hosting.Dcp; -internal sealed class DcpDistributedApplicationLifecycleHook(DistributedApplicationExecutionContext executionContext) : IDistributedApplicationLifecycleHook +internal sealed class DcpDistributedApplicationLifecycleHook(IOptions options, DistributedApplicationExecutionContext executionContext) : IDistributedApplicationLifecycleHook { public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { @@ -19,7 +20,7 @@ public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationT return Task.CompletedTask; } - private static void PrepareServices(DistributedApplicationModel model) + private void PrepareServices(DistributedApplicationModel model) { // Automatically add EndpointAnnotation to project resources based on ApplicationUrl set in the launch profile. foreach (var projectResource in model.Resources.OfType()) @@ -49,10 +50,11 @@ private static void PrepareServices(DistributedApplicationModel model) continue; } + int? port = options.Value.RandomizePorts is true ? null : uri.Port; var generatedEndpointAnnotation = new EndpointAnnotation( ProtocolType.Tcp, uriScheme: uri.Scheme, - port: uri.Port + port: port ); projectResource.Annotations.Add(generatedEndpointAnnotation); diff --git a/src/Aspire.Hosting/Dcp/DcpHostService.cs b/src/Aspire.Hosting/Dcp/DcpHostService.cs index c4fb35de2e..b28017ad92 100644 --- a/src/Aspire.Hosting/Dcp/DcpHostService.cs +++ b/src/Aspire.Hosting/Dcp/DcpHostService.cs @@ -70,6 +70,18 @@ public async Task StartAsync(CancellationToken cancellationToken = default) public async Task StopAsync(CancellationToken cancellationToken = default) { + if (_dcpOptions.DeleteResourcesOnShutdown is true) + { + try + { + await _appExecutor.DeleteResourcesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting application resources."); + } + } + _shutdownCts.Cancel(); if (_logProcessorTask is { } task) { diff --git a/src/Aspire.Hosting/Dcp/DcpOptions.cs b/src/Aspire.Hosting/Dcp/DcpOptions.cs index 6ce87f2559..0e8226c5b4 100644 --- a/src/Aspire.Hosting/Dcp/DcpOptions.cs +++ b/src/Aspire.Hosting/Dcp/DcpOptions.cs @@ -62,6 +62,21 @@ internal sealed class DcpOptions /// public int DependencyCheckTimeout { get; set; } = 25; + /// + /// The suffix to use for resource names when creating resources in DCP. + /// + public string? ResourceNameSuffix { get; set; } + + /// + /// Whether to delete resources created by this application when the application is shut down. + /// + public bool? DeleteResourcesOnShutdown { get; set; } + + /// + /// Whether to randomize ports used by resources during orchestration. + /// + public bool? RandomizePorts { get; set; } + public void ApplyApplicationConfiguration(DistributedApplicationOptions appOptions, IConfiguration dcpPublisherConfiguration, IConfiguration publishingConfiguration, IConfiguration coreConfiguration) { string? publisher = publishingConfiguration[nameof(PublishingOptions.Publisher)]; @@ -110,6 +125,11 @@ public void ApplyApplicationConfiguration(DistributedApplicationOptions appOptio DependencyCheckTimeout = coreConfiguration.GetValue("DOTNET_ASPIRE_DEPENDENCY_CHECK_TIMEOUT", DependencyCheckTimeout); } + if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(ResourceNameSuffix)])) + { + ResourceNameSuffix = dcpPublisherConfiguration[nameof(ResourceNameSuffix)]; + } + if (string.IsNullOrEmpty(CliPath)) { throw new InvalidOperationException($"Could not resolve the path to the Aspire application host. The application cannot be run without it."); diff --git a/src/Aspire.Hosting/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs index 618006c814..01d9a409ea 100644 --- a/src/Aspire.Hosting/DistributedApplication.cs +++ b/src/Aspire.Hosting/DistributedApplication.cs @@ -6,7 +6,6 @@ using Aspire.Hosting.Lifecycle; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -28,7 +27,7 @@ internal enum ContainerRuntimeHealthCheckFailures : int PrerequisiteMissing = 127 } -internal enum DcpVersionCheckFailures: int +internal enum DcpVersionCheckFailures : int { /// /// Represents the exit code indicating that the version of DCP is too low or too high. @@ -48,19 +47,14 @@ internal enum DcpVersionCheckFailures: int public class DistributedApplication : IHost, IAsyncDisposable { private readonly IHost _host; - private readonly string[] _args; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The instance. - /// The command-line arguments. - public DistributedApplication(IHost host, string[] args) + public DistributedApplication(IHost host) { _host = host; - _logger = host.Services.GetRequiredService>(); - _args = args; } /// @@ -99,7 +93,7 @@ public static IDistributedApplicationBuilder CreateBuilder(DistributedApplicatio /// /// Disposes the distributed application by disposing the . /// - public void Dispose() + public virtual void Dispose() { _host.Dispose(); } @@ -108,26 +102,26 @@ public void Dispose() /// Asynchronously disposes the distributed application by disposing the . /// /// A representing the asynchronous operation. - public ValueTask DisposeAsync() + public virtual ValueTask DisposeAsync() { return ((IAsyncDisposable)_host).DisposeAsync(); } /// - public async Task StartAsync(CancellationToken cancellationToken = default) + public virtual async Task StartAsync(CancellationToken cancellationToken = default) { await ExecuteBeforeStartHooksAsync(cancellationToken).ConfigureAwait(false); await _host.StartAsync(cancellationToken).ConfigureAwait(false); } /// - public async Task StopAsync(CancellationToken cancellationToken = default) + public virtual async Task StopAsync(CancellationToken cancellationToken = default) { await _host.StopAsync(cancellationToken).ConfigureAwait(false); } /// - public async Task RunAsync(CancellationToken cancellationToken = default) + public virtual async Task RunAsync(CancellationToken cancellationToken = default) { await ExecuteBeforeStartHooksAsync(cancellationToken).ConfigureAwait(false); await _host.RunAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 9df0e39cc4..3134a792f0 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; @@ -18,8 +20,13 @@ namespace Aspire.Hosting; /// public class DistributedApplicationBuilder : IDistributedApplicationBuilder { + private const string HostingDiagnosticListenerName = "Aspire.Hosting"; + private const string ApplicationBuildingEventName = "DistributedApplicationBuilding"; + private const string ApplicationBuiltEventName = "DistributedApplicationBuilt"; + private const string BuilderConstructingEventName = "DistributedApplicationBuilderConstructing"; + private const string BuilderConstructedEventName = "DistributedApplicationBuilderConstructed"; + private readonly HostApplicationBuilder _innerBuilder; - private readonly string[] _args; /// public IHostEnvironment Environment => _innerBuilder.Environment; @@ -39,14 +46,23 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder /// public IResourceCollection Resources { get; } = new ResourceCollection(); + /// + /// Initializes a new instance of the class with the specified options. + /// + /// The arguments provided to the builder. + public DistributedApplicationBuilder(string[] args) : this(new DistributedApplicationOptions { Args = args }) + { + } + /// /// Initializes a new instance of the class with the specified options. /// /// The options for the distributed application. public DistributedApplicationBuilder(DistributedApplicationOptions options) { - _args = options.Args ?? []; - _innerBuilder = new HostApplicationBuilder(); + var innerBuilderOptions = new HostApplicationBuilderSettings(); + LogBuilderConstructing(options, innerBuilderOptions); + _innerBuilder = new HostApplicationBuilder(innerBuilderOptions); _innerBuilder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); _innerBuilder.Logging.AddFilter("Microsoft.AspNetCore.Server.Kestrel", LogLevel.None); @@ -96,6 +112,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) }; _innerBuilder.Services.AddSingleton(ExecutionContext); + LogBuilderConstructed(this); } private void ConfigurePublishingOptions(DistributedApplicationOptions options) @@ -126,6 +143,8 @@ public DistributedApplication Build() AspireEventSource.Instance.DistributedApplicationBuildStart(); try { + LogAppBuilding(this); + // AddResource(resource) validates that a name is unique but it's possible to add resources directly to the resource collection. // Validate names for duplicates while building the application. foreach (var duplicateResourceName in Resources.GroupBy(r => r.Name, StringComparers.ResourceName) @@ -135,7 +154,8 @@ public DistributedApplication Build() throw new DistributedApplicationException($"Multiple resources with the name '{duplicateResourceName}'. Resource names are case-insensitive."); } - var application = new DistributedApplication(_innerBuilder.Build(), _args); + var application = new DistributedApplication(_innerBuilder.Build()); + LogAppBuilt(application); return application; } finally @@ -162,4 +182,67 @@ public IResourceBuilder CreateResourceBuilder(T resource) where T : IResou var builder = new DistributedApplicationResourceBuilder(this, resource); return builder; } + + private static DiagnosticListener LogBuilderConstructing(DistributedApplicationOptions appBuilderOptions, HostApplicationBuilderSettings hostBuilderOptions) + { + var diagnosticListener = new DiagnosticListener(HostingDiagnosticListenerName); + + if (diagnosticListener.IsEnabled() && diagnosticListener.IsEnabled(BuilderConstructingEventName)) + { + Write(diagnosticListener, BuilderConstructingEventName, (appBuilderOptions, hostBuilderOptions)); + } + + return diagnosticListener; + } + + private static DiagnosticListener LogBuilderConstructed(DistributedApplicationBuilder builder) + { + var diagnosticListener = new DiagnosticListener(HostingDiagnosticListenerName); + + if (diagnosticListener.IsEnabled() && diagnosticListener.IsEnabled(BuilderConstructedEventName)) + { + Write(diagnosticListener, BuilderConstructedEventName, builder); + } + + return diagnosticListener; + } + + private static DiagnosticListener LogAppBuilding(DistributedApplicationBuilder appBuilder) + { + var diagnosticListener = new DiagnosticListener(HostingDiagnosticListenerName); + + if (diagnosticListener.IsEnabled() && diagnosticListener.IsEnabled(ApplicationBuildingEventName)) + { + Write(diagnosticListener, ApplicationBuildingEventName, appBuilder); + } + + return diagnosticListener; + } + + private static DiagnosticListener LogAppBuilt(DistributedApplication app) + { + var diagnosticListener = new DiagnosticListener(HostingDiagnosticListenerName); + + if (diagnosticListener.IsEnabled() && diagnosticListener.IsEnabled(ApplicationBuiltEventName)) + { + Write(diagnosticListener, ApplicationBuiltEventName, app); + } + + return diagnosticListener; + } + + // Remove when https://github.com/dotnet/runtime/pull/78532 is merged and consumed by the used SDK. +#if NET7_0 + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", + Justification = "DiagnosticSource is used here to pass objects in-memory to code using HostFactoryResolver. This won't require creating new generic types.")] +#endif + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:UnrecognizedReflectionPattern", + Justification = "The values being passed into Write are being consumed by the application already.")] + private static void Write<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>( + DiagnosticListener diagnosticSource, + string name, + T value) + { + diagnosticSource.Write(name, value); + } } diff --git a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj new file mode 100644 index 0000000000..d547564588 --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj @@ -0,0 +1,22 @@ + + + + $(NetCurrent) + enable + enable + false + true + + + + + + + + + + + + + + diff --git a/tests/Aspire.Hosting.Testing.Tests/Directory.Build.props b/tests/Aspire.Hosting.Testing.Tests/Directory.Build.props new file mode 100644 index 0000000000..5d2b9d076c --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/Aspire.Hosting.Testing.Tests/Directory.Build.targets b/tests/Aspire.Hosting.Testing.Tests/Directory.Build.targets new file mode 100644 index 0000000000..4ea136db24 --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationFixtureOfT.cs b/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationFixtureOfT.cs new file mode 100644 index 0000000000..d9914250d9 --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationFixtureOfT.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Hosting.Testing.Tests; + +public sealed class DistributedApplicationFixture : DistributedApplicationTestingHarness, IAsyncLifetime where TEntryPoint : class +{ + protected override void OnBuilderCreating(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostOptions) + { + base.OnBuilderCreating(applicationOptions, hostOptions); + } + + protected override void OnBuilderCreated(DistributedApplicationBuilder applicationBuilder) + { + base.OnBuilderCreated(applicationBuilder); + } + + protected override void OnBuilding(DistributedApplicationBuilder applicationBuilder) + { + base.OnBuilding(applicationBuilder); + } + + public async Task InitializeAsync() => await base.InitializeAsync(); + + async Task IAsyncLifetime.DisposeAsync() => await DisposeAsync(); +} diff --git a/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs new file mode 100644 index 0000000000..5e5ba0c3f2 --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http.Json; +using Aspire.Hosting.Tests.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Testing.Tests; + +public class TestingBuilderTests +{ + [LocalOnlyFact] + public async Task HasEndPoints() + { + var appHost = new DistributedApplicationTestingBuilder(); + await using var app = appHost.Build(); + + await app.StartAsync(); + + // Get an endpoint from a resource + var workerEndpoint = app.GetEndpoint("myworker1", "myendpoint1"); + Assert.NotNull(workerEndpoint); + Assert.True(workerEndpoint.Host.Length > 0); + + // Get a connection string from a resource + var pgConnectionString = app.GetConnectionString("postgres1"); + Assert.NotNull(pgConnectionString); + Assert.True(pgConnectionString.Length > 0); + } + + [LocalOnlyFact] + public async Task CanGetResources() + { + var appHost = new DistributedApplicationTestingBuilder(); + appHost.Resources.Remove(appHost.Resources.Single(r => r.Name == "redis1")); + + await using var app = appHost.Build(); + await app.StartAsync(); + + // Ensure that the resource which we added is present in the model. + var appModel = app.Services.GetRequiredService(); + Assert.Contains(appModel.GetContainerResources(), c => c.Name == "redis1"); + Assert.Contains(appModel.GetProjectResources(), p => p.Name == "myworker1"); + } + + [LocalOnlyFact] + public async Task HttpClientGetTest() + { + var builder = new DistributedApplicationTestingBuilder(); + await using var app = builder.Build(); + await app.StartAsync(); + + var httpClient = app.CreateHttpClient("mywebapp1"); + var result1 = await httpClient.GetFromJsonAsync("/weatherforecast"); + Assert.NotNull(result1); + Assert.True(result1.Length > 0); + } + + private sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) + { + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Directory.Build.props b/tests/TestingAppHost1/TestingAppHost1.AppHost/Directory.Build.props new file mode 100644 index 0000000000..9e4b6aea86 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Directory.Build.targets b/tests/TestingAppHost1/TestingAppHost1.AppHost/Directory.Build.targets new file mode 100644 index 0000000000..112ac8875d --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs new file mode 100644 index 0000000000..4809334ef7 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); +builder.AddRedis("redis1"); +builder.AddProject("mywebapp1"); +builder.AddProject("myworker1") + .WithEndpoint(name: "myendpoint1"); +builder.AddPostgres("postgres1"); +builder.Build().Run(); + +// Require a public Program class to reference this in the integration tests. Using IVT alone is not sufficient +// in this case, because the accessibility of the `Program` type must match that of the fixture class. + +public partial class Program +{ +} + diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Properties/launchSettings.json b/tests/TestingAppHost1/TestingAppHost1.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..fd4c76146d --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15039", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16158" + } + } + } +} diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHost1.AppHost.csproj b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHost1.AppHost.csproj new file mode 100644 index 0000000000..2aac1bf70b --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHost1.AppHost.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + + + + + diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/appsettings.Development.json b/tests/TestingAppHost1/TestingAppHost1.AppHost/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/appsettings.json b/tests/TestingAppHost1/TestingAppHost1.AppHost/appsettings.json new file mode 100644 index 0000000000..31c092aa45 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Program.cs b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Program.cs new file mode 100644 index 0000000000..fd8784a30d --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Program.cs @@ -0,0 +1,48 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast") +.WithOpenApi(); + +app.Run(); + +sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Properties/launchSettings.json b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Properties/launchSettings.json new file mode 100644 index 0000000000..e4671fff2c --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:33578", + "sslPort": 44317 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5150", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7097;http://localhost:5150", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.csproj b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.csproj new file mode 100644 index 0000000000..9266eddb45 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.http b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.http new file mode 100644 index 0000000000..f92ad367c7 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/TestingAppHost1.MyWebApp.http @@ -0,0 +1,6 @@ +@TestingAppHost1.MyWebApp_HostAddress = http://localhost:5150 + +GET {{TestingAppHost1.MyWebApp_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/appsettings.Development.json b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWebApp/appsettings.json b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWebApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWorker/Program.cs b/tests/TestingAppHost1/TestingAppHost1.MyWorker/Program.cs new file mode 100644 index 0000000000..e209f5772e --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWorker/Program.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); +builder.AddServiceDefaults(); +builder.Build().Run(); diff --git a/tests/TestingAppHost1/TestingAppHost1.MyWorker/TestingAppHost1.MyWorker.csproj b/tests/TestingAppHost1/TestingAppHost1.MyWorker/TestingAppHost1.MyWorker.csproj new file mode 100644 index 0000000000..7e4f66e232 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.MyWorker/TestingAppHost1.MyWorker.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + diff --git a/tests/TestingAppHost1/TestingAppHost1.ServiceDefaults/Extensions.cs b/tests/TestingAppHost1/TestingAppHost1.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000000..4921efee9a --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.ServiceDefaults/Extensions.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.UseServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddRuntimeInstrumentation() + .AddBuiltInMeters(); + }) + .WithTracing(tracing => + { + if (builder.Environment.IsDevelopment()) + { + // We want to view all traces in development + tracing.SetSampler(new AlwaysOnSampler()); + } + + tracing.AddAspNetCoreInstrumentation() + .AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + } + + // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // builder.Services.AddOpenTelemetry() + // .WithMetrics(metrics => metrics.AddPrometheusExporter()); + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // app.MapPrometheusScrapingEndpoint(); + + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } + + private static MeterProviderBuilder AddBuiltInMeters(this MeterProviderBuilder meterProviderBuilder) => + meterProviderBuilder.AddMeter( + "Microsoft.AspNetCore.Hosting", + "Microsoft.AspNetCore.Server.Kestrel", + "System.Net.Http"); +} diff --git a/tests/TestingAppHost1/TestingAppHost1.ServiceDefaults/TestingAppHost1.ServiceDefaults.csproj b/tests/TestingAppHost1/TestingAppHost1.ServiceDefaults/TestingAppHost1.ServiceDefaults.csproj new file mode 100644 index 0000000000..c315411ff5 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.ServiceDefaults/TestingAppHost1.ServiceDefaults.csproj @@ -0,0 +1,27 @@ + + + + Library + net8.0 + enable + enable + true + + + + + + + + + + + + + + + + + + +