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