From f679c2228f6817bb9e9e019fc6cbdd1cea71cd08 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 25 Nov 2025 14:08:27 -0800 Subject: [PATCH 1/7] Add new event for finalizing resource annotations --- .../JavaScriptHostingExtensions.cs | 25 +++++++++++- .../PythonAppResourceBuilderExtensions.cs | 39 +++++++++++++++++-- ...ertificateAuthorityCollectionAnnotation.cs | 28 +++++++++++++ .../FinalizeResourceAnnotationsEvent.cs | 27 +++++++++++++ src/Aspire.Hosting/DistributedApplication.cs | 6 +++ ...istributedApplicationEventingExtensions.cs | 11 ++++++ .../DistributedApplicationEventing.cs | 23 ++++++++++- .../Eventing/EventDispatchBehavior.cs | 2 +- .../IDistributedApplicationEventing.cs | 25 +++++++++++- .../AddPythonAppTests.cs | 15 ++++--- 10 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/FinalizeResourceAnnotationsEvent.cs diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 73caa78d9a2..577906d10f7 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -677,7 +677,7 @@ private static void AddInstaller(IResourceBuilder resource .WithParentRelationship(resource.Resource) .ExcludeFromManifest(); - resource.ApplicationBuilder.Eventing.Subscribe((_, _) => + resource.OnFinalizeResourceAnnotations((_, _, cancellationToken) => { // set the installer's working directory to match the resource's working directory // and set the install command and args based on the resource's annotations @@ -692,6 +692,29 @@ private static void AddInstaller(IResourceBuilder resource .WithWorkingDirectory(resource.Resource.WorkingDirectory) .WithArgs(installCommand.Args); + if (resource.Resource.TryGetAnnotationsOfType(out var trustConfigAnnotations)) + { + // Use the same trust configuration as the parent resource + foreach (var trustConfigAnnotation in trustConfigAnnotations) + { + installerBuilder.WithAnnotation(trustConfigAnnotation, ResourceAnnotationMutationBehavior.Append); + } + } + + if (resource.Resource.TryGetLastAnnotation(out var trustAnnotation)) + { + if (installerBuilder.Resource.TryGetLastAnnotation(out var existingTrustAnnotation)) + { + // Merge existing trust with parent's trust configuration + installerBuilder.WithAnnotation(CertificateAuthorityCollectionAnnotation.From(existingTrustAnnotation, trustAnnotation), ResourceAnnotationMutationBehavior.Replace); + } + else + { + // No existing trust, just copy from parent + installerBuilder.WithAnnotation(CertificateAuthorityCollectionAnnotation.From(trustAnnotation), ResourceAnnotationMutationBehavior.Replace); + } + } + return Task.CompletedTask; }); diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 66900ffafd5..e808d8e369a 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -1363,16 +1363,14 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w // For other package managers (pip, etc.), Python validation happens via PythonVenvCreatorResource }); - builder.ApplicationBuilder.Eventing.Subscribe((_, _) => + installerBuilder.OnFinalizeResourceAnnotations((_, _, ct) => { // Set the installer's working directory to match the resource's working directory // and set the install command and args based on the resource's annotations if (!builder.Resource.TryGetLastAnnotation(out var packageManager) || !builder.Resource.TryGetLastAnnotation(out var installCommand)) { - // No package manager configured - don't fail, just don't run the installer - // This allows venv to be created without requiring a package manager - return Task.CompletedTask; + throw new InvalidOperationException("PythonPackageManagerAnnotation and PythonInstallCommandAnnotation are required when installing packages."); } installerBuilder @@ -1380,6 +1378,39 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w .WithWorkingDirectory(builder.Resource.WorkingDirectory) .WithArgs(installCommand.Args); + if (builder.Resource.TryGetAnnotationsOfType(out var trustConfigAnnotations)) + { + // Use the same trust configuration as the parent resource + foreach (var trustConfigAnnotation in trustConfigAnnotations) + { + installerBuilder.WithAnnotation(trustConfigAnnotation, ResourceAnnotationMutationBehavior.Append); + } + } + + installerBuilder.WithCertificateTrustConfiguration(ctx => + { + if (ctx.Scope != CertificateTrustScope.None) + { + ctx.EnvironmentVariables["UV_NATIVE_TLS"] = "true"; + } + + return Task.CompletedTask; + }); + + if (builder.Resource.TryGetLastAnnotation(out var trustAnnotation)) + { + if (installerBuilder.Resource.TryGetLastAnnotation(out var existingTrustAnnotation)) + { + // Merge existing trust with parent's trust configuration + installerBuilder.WithAnnotation(CertificateAuthorityCollectionAnnotation.From(existingTrustAnnotation, trustAnnotation), ResourceAnnotationMutationBehavior.Replace); + } + else + { + // No existing trust, just copy from parent + installerBuilder.WithAnnotation(CertificateAuthorityCollectionAnnotation.From(trustAnnotation), ResourceAnnotationMutationBehavior.Replace); + } + } + return Task.CompletedTask; }); diff --git a/src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs index 1a0ad553f23..da68c8805fe 100644 --- a/src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs @@ -40,6 +40,34 @@ public enum CertificateTrustScope /// public sealed class CertificateAuthorityCollectionAnnotation : IResourceAnnotation { + /// + /// Creates a new instance from one or more merged instances. + /// Certificate authority collections from all provided instances will be combined into the new instance, while the last vlaues for + /// and will be used, with null values being ignored (previous value if any will be retained). + /// + /// The other s that will be merged to create the new instance. + /// A merged copy of the provided instances + public static CertificateAuthorityCollectionAnnotation From(params CertificateAuthorityCollectionAnnotation[] other) + { + ArgumentNullException.ThrowIfNull(other); + + var annotation = new CertificateAuthorityCollectionAnnotation(); + foreach (var item in other) + { + annotation.CertificateAuthorityCollections.AddRange(item.CertificateAuthorityCollections); + if (item.TrustDeveloperCertificates.HasValue) + { + annotation.TrustDeveloperCertificates = item.TrustDeveloperCertificates; + } + if (item.Scope.HasValue) + { + annotation.Scope = item.Scope; + } + } + + return annotation; + } + /// /// Gets the that is being referenced. /// diff --git a/src/Aspire.Hosting/ApplicationModel/FinalizeResourceAnnotationsEvent.cs b/src/Aspire.Hosting/ApplicationModel/FinalizeResourceAnnotationsEvent.cs new file mode 100644 index 00000000000..994e7440bb4 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/FinalizeResourceAnnotationsEvent.cs @@ -0,0 +1,27 @@ +// 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.Eventing; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// This event is raised by the app model after the BeforeStartEvent has been finalized, but before +/// any resource processing begins. This is the last safe event during which resource annotations can be +/// modified without side-effects. +/// +/// The resource the event is firing for. +/// The for the app host. +/// +/// Resources that are created by orchestrators may not yet be ready to handle requests. +/// +public class FinalizeResourceAnnotationsEvent(IResource resource, IServiceProvider services) : IDistributedApplicationResourceEvent +{ + /// + public IResource Resource { get; } = resource; + + /// + /// The for the app host. + /// + public IServiceProvider Services { get; } = services; +} diff --git a/src/Aspire.Hosting/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs index f814019a0ba..e6d81741f42 100644 --- a/src/Aspire.Hosting/DistributedApplication.cs +++ b/src/Aspire.Hosting/DistributedApplication.cs @@ -498,6 +498,12 @@ internal async Task ExecuteBeforeStartHooksAsync(CancellationToken cancellationT { await lifecycleHook.BeforeStartAsync(appModel, cancellationToken).ConfigureAwait(false); } + + foreach (var resource in appModel.Resources) + { + // Publish finalizer events in reverse order to ensure that any event + await eventing.PublishAsync(new FinalizeResourceAnnotationsEvent(resource, _host.Services), reverseOrder: true, cancellationToken: cancellationToken).ConfigureAwait(false); + } } finally { diff --git a/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs b/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs index ab29b72142c..1515a8faa36 100644 --- a/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs +++ b/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs @@ -11,6 +11,17 @@ namespace Aspire.Hosting; /// public static class DistributedApplicationEventingExtensions { + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The resource type. + /// The resource builder. + /// A callback to handle the event. + /// The . + public static IResourceBuilder OnFinalizeResourceAnnotations(this IResourceBuilder builder, Func callback) + where T : IResource + => builder.OnEvent(callback); + /// /// Subscribes a callback to the event within the AppHost. /// diff --git a/src/Aspire.Hosting/Eventing/DistributedApplicationEventing.cs b/src/Aspire.Hosting/Eventing/DistributedApplicationEventing.cs index 5333c276cc9..b22868f907b 100644 --- a/src/Aspire.Hosting/Eventing/DistributedApplicationEventing.cs +++ b/src/Aspire.Hosting/Eventing/DistributedApplicationEventing.cs @@ -16,15 +16,34 @@ public class DistributedApplicationEventing : IDistributedApplicationEventing [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] public Task PublishAsync(T @event, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent { - return PublishAsync(@event, EventDispatchBehavior.BlockingSequential, cancellationToken); + return PublishAsync(@event, reverseOrder: false, dispatchBehavior: EventDispatchBehavior.BlockingSequential, cancellationToken); } /// [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] - public async Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent + public Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent + { + return PublishAsync(@event, reverseOrder: false, dispatchBehavior, cancellationToken); + } + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] + public Task PublishAsync(T @event, bool reverseOrder, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent + { + return PublishAsync(@event, reverseOrder, EventDispatchBehavior.BlockingSequential, cancellationToken); + } + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] + public async Task PublishAsync(T @event, bool reverseOrder, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent { if (_eventSubscriptionListLookup.TryGetValue(typeof(T), out var subscriptions)) { + if (reverseOrder) + { + subscriptions.Reverse(); + } + if (dispatchBehavior == EventDispatchBehavior.BlockingConcurrent || dispatchBehavior == EventDispatchBehavior.NonBlockingConcurrent) { var pendingSubscriptionCallbacks = new List(subscriptions.Count); diff --git a/src/Aspire.Hosting/Eventing/EventDispatchBehavior.cs b/src/Aspire.Hosting/Eventing/EventDispatchBehavior.cs index 69365f1546f..ada55aaa558 100644 --- a/src/Aspire.Hosting/Eventing/EventDispatchBehavior.cs +++ b/src/Aspire.Hosting/Eventing/EventDispatchBehavior.cs @@ -26,5 +26,5 @@ public enum EventDispatchBehavior /// /// Fires events concurrently but does not block. /// - NonBlockingConcurrent + NonBlockingConcurrent, } diff --git a/src/Aspire.Hosting/Eventing/IDistributedApplicationEventing.cs b/src/Aspire.Hosting/Eventing/IDistributedApplicationEventing.cs index 4d6a4e978d5..fe7e1a3106f 100644 --- a/src/Aspire.Hosting/Eventing/IDistributedApplicationEventing.cs +++ b/src/Aspire.Hosting/Eventing/IDistributedApplicationEventing.cs @@ -19,7 +19,7 @@ public interface IDistributedApplicationEventing DistributedApplicationEventSubscription Subscribe(Func callback) where T : IDistributedApplicationEvent; /// - /// Subscribes a callback to a specific event type + /// Subscribes a callback to a specific event type /// /// The type of the event. /// The resource instance associated with the event. @@ -53,4 +53,27 @@ public interface IDistributedApplicationEventing /// A task that can be awaited. [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent; + + /// + /// Publishes an event to all subscribes of the specific event type in reverse subscription order. + /// + /// The type of the event + /// Whether to publish the events in reverse subscription order. + /// The event. + /// A cancellation token. + /// A task that can be awaited. + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] + Task PublishAsync(T @event, bool reverseOrder, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent; + + /// + /// Publishes an event to all subscribes of the specific event type in reverse subscription order. + /// + /// The type of the event + /// The event. + /// Whether to publish the events in reverse subscription order. + /// The dispatch behavior for the event. + /// A cancellation token. + /// A task that can be awaited. + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] + Task PublishAsync(T @event, bool reverseOrder, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent; } diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 8a1fea29671..97c92e545ef 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -367,7 +367,7 @@ private static void AssertPythonCommandPath(string expectedVenvPath, string actu var expectedCommand = OperatingSystem.IsWindows() ? Path.Join(expectedVenvPath, "Scripts", "python.exe") : Path.Join(expectedVenvPath, "bin", "python"); - + Assert.Equal(expectedCommand, actualCommand); } @@ -552,7 +552,7 @@ public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempAppDir = new TempDirectory(); - + // Create app directory as a subdirectory of AppHost (realistic scenario) var appDirName = "python-app"; var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName); @@ -594,7 +594,7 @@ public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere() public void WithVirtualEnvironment_PrefersAppDirectoryWhenVenvExistsInBoth() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); - + // Create app directory as a subdirectory of AppHost (realistic scenario) var appDirName = "python-app"; var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName); @@ -663,7 +663,7 @@ public void WithVirtualEnvironment_DefaultsToAppDirectoryWhenVenvExistsInNeither public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); - + // Create app directory as a subdirectory of AppHost var appDirName = "python-app"; var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName); @@ -680,7 +680,7 @@ public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim() try { var scriptName = "main.py"; - + // Explicitly specify a custom venv path - should use it verbatim, not fall back to AppHost .venv var resourceBuilder = builder.AddPythonApp("pythonProject", appDirName, scriptName) .WithVirtualEnvironment("custom-venv"); @@ -2297,6 +2297,11 @@ private static async Task PublishBeforeStartEventAsync(DistributedApplication ap var appModel = app.Services.GetRequiredService(); var eventing = app.Services.GetRequiredService(); await eventing.PublishAsync(new BeforeStartEvent(app.Services, appModel), CancellationToken.None); + + foreach (var resource in appModel.Resources) + { + await eventing.PublishAsync(new FinalizeResourceAnnotationsEvent(resource, app.Services), reverseOrder: true).ConfigureAwait(false); + } } } From c6805520cfda0bf276d3dedc6566bdbdcd391811 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 25 Nov 2025 16:26:01 -0800 Subject: [PATCH 2/7] Update suppression file for new eventing method --- src/Aspire.Hosting/CompatibilitySuppressions.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index a2457b76188..5513632e7c1 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -8,4 +8,11 @@ lib/net8.0/Aspire.Hosting.dll true + + CP0006 + P:Aspire.Hosting.Eventing.IDistributedApplicationEventing.PublishAsync + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + \ No newline at end of file From b939863ca952a72237bd987b4df91a4062713ea6 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 25 Nov 2025 17:06:05 -0800 Subject: [PATCH 3/7] Properly update the suppression file --- src/Aspire.Hosting/CompatibilitySuppressions.xml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index 5513632e7c1..a218e799b5c 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -3,14 +3,21 @@ CP0006 - P:Aspire.Hosting.IDeveloperCertificateService.UseForServerAuthentication + M:Aspire.Hosting.Eventing.IDistributedApplicationEventing.PublishAsync``1(``0,System.Boolean,Aspire.Hosting.Eventing.EventDispatchBehavior,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0006 + M:Aspire.Hosting.Eventing.IDistributedApplicationEventing.PublishAsync``1(``0,System.Boolean,System.Threading.CancellationToken) lib/net8.0/Aspire.Hosting.dll lib/net8.0/Aspire.Hosting.dll true CP0006 - P:Aspire.Hosting.Eventing.IDistributedApplicationEventing.PublishAsync + P:Aspire.Hosting.IDeveloperCertificateService.UseForServerAuthentication lib/net8.0/Aspire.Hosting.dll lib/net8.0/Aspire.Hosting.dll true From a6592022c13c4b373ae88d1f86452524eea5ae48 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Dec 2025 16:21:28 -0800 Subject: [PATCH 4/7] Switch to using annotation instead of event --- .../JavaScriptHostingExtensions.cs | 2 +- .../PythonAppResourceBuilderExtensions.cs | 2 +- .../FinalizeResourceAnnotationsEvent.cs | 27 ------- ...ResourceConfigurationCallbackAnnotation.cs | 37 ++++++++++ .../CompatibilitySuppressions.xml | 74 ++++++++++++++++++- src/Aspire.Hosting/DistributedApplication.cs | 18 ++++- ...istributedApplicationEventingExtensions.cs | 11 --- .../DistributedApplicationEventing.cs | 23 +----- .../Eventing/EventDispatchBehavior.cs | 2 +- .../IDistributedApplicationEventing.cs | 25 +------ .../ResourceBuilderExtensions.cs | 19 +++++ .../AddPythonAppTests.cs | 17 ++++- 12 files changed, 166 insertions(+), 91 deletions(-) delete mode 100644 src/Aspire.Hosting/ApplicationModel/FinalizeResourceAnnotationsEvent.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/FinalizeResourceConfigurationCallbackAnnotation.cs diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 577906d10f7..3f9e70311f9 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -677,7 +677,7 @@ private static void AddInstaller(IResourceBuilder resource .WithParentRelationship(resource.Resource) .ExcludeFromManifest(); - resource.OnFinalizeResourceAnnotations((_, _, cancellationToken) => + resource.WithConfigurationFinalizer(_ => { // set the installer's working directory to match the resource's working directory // and set the install command and args based on the resource's annotations diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index e808d8e369a..9b75505227f 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -1363,7 +1363,7 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w // For other package managers (pip, etc.), Python validation happens via PythonVenvCreatorResource }); - installerBuilder.OnFinalizeResourceAnnotations((_, _, ct) => + installerBuilder.WithConfigurationFinalizer(_ => { // Set the installer's working directory to match the resource's working directory // and set the install command and args based on the resource's annotations diff --git a/src/Aspire.Hosting/ApplicationModel/FinalizeResourceAnnotationsEvent.cs b/src/Aspire.Hosting/ApplicationModel/FinalizeResourceAnnotationsEvent.cs deleted file mode 100644 index 994e7440bb4..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/FinalizeResourceAnnotationsEvent.cs +++ /dev/null @@ -1,27 +0,0 @@ -// 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.Eventing; - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// This event is raised by the app model after the BeforeStartEvent has been finalized, but before -/// any resource processing begins. This is the last safe event during which resource annotations can be -/// modified without side-effects. -/// -/// The resource the event is firing for. -/// The for the app host. -/// -/// Resources that are created by orchestrators may not yet be ready to handle requests. -/// -public class FinalizeResourceAnnotationsEvent(IResource resource, IServiceProvider services) : IDistributedApplicationResourceEvent -{ - /// - public IResource Resource { get; } = resource; - - /// - /// The for the app host. - /// - public IServiceProvider Services { get; } = services; -} diff --git a/src/Aspire.Hosting/ApplicationModel/FinalizeResourceConfigurationCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/FinalizeResourceConfigurationCallbackAnnotation.cs new file mode 100644 index 00000000000..76cb8382254 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/FinalizeResourceConfigurationCallbackAnnotation.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Annotation to register a callback to be invoked during resource finalization. Callbacks are executed in reverse order +/// of their registration immediately after the BeforeStartEvent is complete. +/// +public sealed class FinalizeResourceConfigurationCallbackAnnotation : IResourceAnnotation +{ + /// + /// The callback to be invoked during resource finalization. + /// + public required Func Callback { get; init; } +} + +/// +/// Context for a finalize resource configuration callback annotation. +/// +public sealed class FinalizeResourceConfigurationCallbackAnnotationContext +{ + /// + /// The resource associated with the callback. + /// + public required IResource Resource { get; init; } + + /// + /// The execution context for the callback. + /// + public required DistributedApplicationExecutionContext ExecutionContext { get; init; } + + /// + /// The cancellation token for the callback. + /// + public required CancellationToken CancellationToken { get; init; } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index a218e799b5c..91c7447fafb 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -1,16 +1,86 @@  + + CP0001 + T:Aspire.Hosting.ApplicationModel.DeploymentImageTagCallbackAnnotation + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0001 + T:Aspire.Hosting.ApplicationModel.DeploymentImageTagCallbackAnnotationContext + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0001 + T:Aspire.Hosting.Publishing.ContainerBuildOptions + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0002 + M:Aspire.Hosting.ApplicationModel.ResourceExtensions.WithDeploymentImageTag``1(Aspire.Hosting.ApplicationModel.IResourceBuilder{``0},System.Func{Aspire.Hosting.ApplicationModel.DeploymentImageTagCallbackAnnotationContext,System.String}) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0002 + M:Aspire.Hosting.ApplicationModel.ResourceExtensions.WithDeploymentImageTag``1(Aspire.Hosting.ApplicationModel.IResourceBuilder{``0},System.Func{Aspire.Hosting.ApplicationModel.DeploymentImageTagCallbackAnnotationContext,System.Threading.Tasks.Task{System.String}}) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0002 + M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImageAsync(Aspire.Hosting.ApplicationModel.IResource,Aspire.Hosting.Publishing.ContainerBuildOptions,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0002 + M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImagesAsync(System.Collections.Generic.IEnumerable{Aspire.Hosting.ApplicationModel.IResource},Aspire.Hosting.Publishing.ContainerBuildOptions,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0002 + M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.PushImageAsync(System.String,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0002 + M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.TagImageAsync(System.String,System.String,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0006 + M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImageAsync(Aspire.Hosting.ApplicationModel.IResource,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + CP0006 - M:Aspire.Hosting.Eventing.IDistributedApplicationEventing.PublishAsync``1(``0,System.Boolean,Aspire.Hosting.Eventing.EventDispatchBehavior,System.Threading.CancellationToken) + M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImagesAsync(System.Collections.Generic.IEnumerable{Aspire.Hosting.ApplicationModel.IResource},System.Threading.CancellationToken) lib/net8.0/Aspire.Hosting.dll lib/net8.0/Aspire.Hosting.dll true CP0006 - M:Aspire.Hosting.Eventing.IDistributedApplicationEventing.PublishAsync``1(``0,System.Boolean,System.Threading.CancellationToken) + M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.PushImageAsync(Aspire.Hosting.ApplicationModel.IResource,System.Threading.CancellationToken) lib/net8.0/Aspire.Hosting.dll lib/net8.0/Aspire.Hosting.dll true diff --git a/src/Aspire.Hosting/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs index e6d81741f42..ea5c57ffca4 100644 --- a/src/Aspire.Hosting/DistributedApplication.cs +++ b/src/Aspire.Hosting/DistributedApplication.cs @@ -501,8 +501,22 @@ internal async Task ExecuteBeforeStartHooksAsync(CancellationToken cancellationT foreach (var resource in appModel.Resources) { - // Publish finalizer events in reverse order to ensure that any event - await eventing.PublishAsync(new FinalizeResourceAnnotationsEvent(resource, _host.Services), reverseOrder: true, cancellationToken: cancellationToken).ConfigureAwait(false); + if (resource.TryGetAnnotationsOfType(out var finalizeAnnotations)) + { + var context = new FinalizeResourceConfigurationCallbackAnnotationContext + { + Resource = resource, + ExecutionContext = execContext, + CancellationToken = cancellationToken, + }; + + // Execute in reverse order; take as a list to avoid mutating the collection during enumeration + var callbacks = finalizeAnnotations.Reverse().ToList(); + foreach (var callback in callbacks) + { + await callback.Callback(context).ConfigureAwait(false); + } + } } } finally diff --git a/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs b/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs index 1515a8faa36..ab29b72142c 100644 --- a/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs +++ b/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs @@ -11,17 +11,6 @@ namespace Aspire.Hosting; /// public static class DistributedApplicationEventingExtensions { - /// - /// Subscribes a callback to the event within the AppHost. - /// - /// The resource type. - /// The resource builder. - /// A callback to handle the event. - /// The . - public static IResourceBuilder OnFinalizeResourceAnnotations(this IResourceBuilder builder, Func callback) - where T : IResource - => builder.OnEvent(callback); - /// /// Subscribes a callback to the event within the AppHost. /// diff --git a/src/Aspire.Hosting/Eventing/DistributedApplicationEventing.cs b/src/Aspire.Hosting/Eventing/DistributedApplicationEventing.cs index b22868f907b..5333c276cc9 100644 --- a/src/Aspire.Hosting/Eventing/DistributedApplicationEventing.cs +++ b/src/Aspire.Hosting/Eventing/DistributedApplicationEventing.cs @@ -16,34 +16,15 @@ public class DistributedApplicationEventing : IDistributedApplicationEventing [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] public Task PublishAsync(T @event, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent { - return PublishAsync(@event, reverseOrder: false, dispatchBehavior: EventDispatchBehavior.BlockingSequential, cancellationToken); + return PublishAsync(@event, EventDispatchBehavior.BlockingSequential, cancellationToken); } /// [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] - public Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent - { - return PublishAsync(@event, reverseOrder: false, dispatchBehavior, cancellationToken); - } - - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] - public Task PublishAsync(T @event, bool reverseOrder, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent - { - return PublishAsync(@event, reverseOrder, EventDispatchBehavior.BlockingSequential, cancellationToken); - } - - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] - public async Task PublishAsync(T @event, bool reverseOrder, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent + public async Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent { if (_eventSubscriptionListLookup.TryGetValue(typeof(T), out var subscriptions)) { - if (reverseOrder) - { - subscriptions.Reverse(); - } - if (dispatchBehavior == EventDispatchBehavior.BlockingConcurrent || dispatchBehavior == EventDispatchBehavior.NonBlockingConcurrent) { var pendingSubscriptionCallbacks = new List(subscriptions.Count); diff --git a/src/Aspire.Hosting/Eventing/EventDispatchBehavior.cs b/src/Aspire.Hosting/Eventing/EventDispatchBehavior.cs index ada55aaa558..69365f1546f 100644 --- a/src/Aspire.Hosting/Eventing/EventDispatchBehavior.cs +++ b/src/Aspire.Hosting/Eventing/EventDispatchBehavior.cs @@ -26,5 +26,5 @@ public enum EventDispatchBehavior /// /// Fires events concurrently but does not block. /// - NonBlockingConcurrent, + NonBlockingConcurrent } diff --git a/src/Aspire.Hosting/Eventing/IDistributedApplicationEventing.cs b/src/Aspire.Hosting/Eventing/IDistributedApplicationEventing.cs index fe7e1a3106f..4d6a4e978d5 100644 --- a/src/Aspire.Hosting/Eventing/IDistributedApplicationEventing.cs +++ b/src/Aspire.Hosting/Eventing/IDistributedApplicationEventing.cs @@ -19,7 +19,7 @@ public interface IDistributedApplicationEventing DistributedApplicationEventSubscription Subscribe(Func callback) where T : IDistributedApplicationEvent; /// - /// Subscribes a callback to a specific event type + /// Subscribes a callback to a specific event type /// /// The type of the event. /// The resource instance associated with the event. @@ -53,27 +53,4 @@ public interface IDistributedApplicationEventing /// A task that can be awaited. [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent; - - /// - /// Publishes an event to all subscribes of the specific event type in reverse subscription order. - /// - /// The type of the event - /// Whether to publish the events in reverse subscription order. - /// The event. - /// A cancellation token. - /// A task that can be awaited. - [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] - Task PublishAsync(T @event, bool reverseOrder, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent; - - /// - /// Publishes an event to all subscribes of the specific event type in reverse subscription order. - /// - /// The type of the event - /// The event. - /// Whether to publish the events in reverse subscription order. - /// The dispatch behavior for the event. - /// A cancellation token. - /// A task that can be awaited. - [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Cancellation token")] - Task PublishAsync(T @event, bool reverseOrder, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) where T : IDistributedApplicationEvent; } diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index e07cf711524..a58fc18658e 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3026,4 +3026,23 @@ public static IResourceBuilder ExcludeFromMcp(this IResourceBuilder bui return builder.WithAnnotation(new ExcludeFromMcpAnnotation()); } + + /// + /// Adds a resource configuration finalizer callback annotation to the resource. + /// + /// The resource type. + /// The resource builder. + /// The callback to be invoked during resource finalization. All resource configuration finalizers will be invoked in reverse order of their registration immediately after the BeforeStartEvent is complete. + /// The . + public static IResourceBuilder WithConfigurationFinalizer(this IResourceBuilder builder, Func callback) + where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(callback); + + return builder.WithAnnotation(new FinalizeResourceConfigurationCallbackAnnotation + { + Callback = callback, + }, ResourceAnnotationMutationBehavior.Append); + } } diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 97c92e545ef..779bb9271ec 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -2300,7 +2300,22 @@ private static async Task PublishBeforeStartEventAsync(DistributedApplication ap foreach (var resource in appModel.Resources) { - await eventing.PublishAsync(new FinalizeResourceAnnotationsEvent(resource, app.Services), reverseOrder: true).ConfigureAwait(false); + if (resource.TryGetAnnotationsOfType(out var finalizeAnnotations)) + { + var context = new FinalizeResourceConfigurationCallbackAnnotationContext + { + Resource = resource, + ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + CancellationToken = CancellationToken.None, + }; + + // Execute in reverse order; take as a list to avoid mutating the collection during enumeration + var callbacks = finalizeAnnotations.Reverse().ToList(); + foreach (var callback in callbacks) + { + await callback.Callback(context).ConfigureAwait(false); + } + } } } } From 8f8ea2dbf3794e464214c1a3d056b51b58ae4962 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Dec 2025 16:29:04 -0800 Subject: [PATCH 5/7] Update comments and indenting --- src/Aspire.Hosting/ResourceBuilderExtensions.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index d8960ffa4d8..8448c320d69 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3028,14 +3028,16 @@ public static IResourceBuilder ExcludeFromMcp(this IResourceBuilder bui } /// - /// Adds a resource configuration finalizer callback annotation to the resource. + /// Adds a resource configuration finalizer callback annotation to the resource. + /// This is the last safe opportunity to modify resource annotations before configuration processing begins and provides an + /// opportunity to apply default behaviors based on the final resource configuration. /// /// The resource type. /// The resource builder. /// The callback to be invoked during resource finalization. All resource configuration finalizers will be invoked in reverse order of their registration immediately after the BeforeStartEvent is complete. /// The . public static IResourceBuilder WithConfigurationFinalizer(this IResourceBuilder builder, Func callback) - where T : IResource + where T : IResource { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(callback); From b15acfcdaf8d942cc0edbb9f31b4aef51ef2dfb6 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Dec 2025 17:08:52 -0800 Subject: [PATCH 6/7] Add test coverage --- .../DistributedApplicationTests.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 081379c40b0..ac7d566e2b8 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -1681,6 +1681,118 @@ public async Task AfterResourcesCreatedLifecycleHookWorks() await kubernetesLifecycle.HooksCompleted.DefaultTimeout(TestConstants.DefaultOrchestratorTestTimeout); } + [Fact] + public async Task FinalizeResourceConfigurationCallbacksAreCalledAfterBeforeStartEvent() + { + const string testName = "finalize-callbacks-after-before-start"; + using var testProgram = CreateTestProgram(testName); + + var executionOrder = new List(); + + // Subscribe to BeforeStartEvent + testProgram.AppBuilder.Eventing.Subscribe(async (@event, cancellationToken) => + { + executionOrder.Add("BeforeStartEvent"); + await Task.CompletedTask; + }); + + testProgram.ServiceABuilder + .WithConfigurationFinalizer(async ctx => + { + executionOrder.Add("FinalizeCallback1"); + await Task.CompletedTask; + }) + .WithConfigurationFinalizer(async ctx => + { + executionOrder.Add("FinalizeCallback2"); + await Task.CompletedTask; + }); + + await using var app = testProgram.Build(); + + await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + // BeforeStartEvent should execute first, then callbacks in reverse order + Assert.Equal(["BeforeStartEvent", "FinalizeCallback2", "FinalizeCallback1"], executionOrder); + + await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + } + + [Fact] + public async Task FinalizeResourceConfigurationCallbacksReceiveCorrectContext() + { + const string testName = "finalize-callbacks-context"; + using var testProgram = CreateTestProgram(testName); + + IResource? capturedResource = null; + DistributedApplicationExecutionContext? capturedExecutionContext = null; + CancellationToken capturedCancellationToken = default; + + testProgram.ServiceABuilder + .WithConfigurationFinalizer(async ctx => + { + capturedResource = ctx.Resource; + capturedExecutionContext = ctx.ExecutionContext; + capturedCancellationToken = ctx.CancellationToken; + await Task.CompletedTask; + }); + + await using var app = testProgram.Build(); + + await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + // Verify context properties were populated + Assert.NotNull(capturedResource); + Assert.Equal(testProgram.ServiceABuilder.Resource, capturedResource); + Assert.NotNull(capturedExecutionContext); + Assert.False(capturedCancellationToken.IsCancellationRequested); + + await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + } + + [Fact] + public async Task FinalizeResourceConfigurationCallbacksAreCalledForMultipleResources() + { + const string testName = "finalize-callbacks-multiple-resources"; + using var testProgram = CreateTestProgram(testName); + + var serviceACallbackExecuted = false; + var serviceBCallbackExecuted = false; + var serviceCCallbackExecuted = false; + + testProgram.ServiceABuilder + .WithConfigurationFinalizer(async ctx => + { + serviceACallbackExecuted = true; + await Task.CompletedTask; + }); + + testProgram.ServiceBBuilder + .WithConfigurationFinalizer(async ctx => + { + serviceBCallbackExecuted = true; + await Task.CompletedTask; + }); + + testProgram.ServiceCBuilder + .WithConfigurationFinalizer(async ctx => + { + serviceCCallbackExecuted = true; + await Task.CompletedTask; + }); + + await using var app = testProgram.Build(); + + await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + // All callbacks should have executed + Assert.True(serviceACallbackExecuted); + Assert.True(serviceBCallbackExecuted); + Assert.True(serviceCCallbackExecuted); + + await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + } + private static IResourceBuilder AddRedisContainer(IDistributedApplicationBuilder builder, string containerName) { return builder.AddContainer(containerName, RedisContainerImageTags.Image, RedisContainerImageTags.Tag) From 63c4f9db2e9bfa673fc5713963711d740f1b8a60 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:50:30 -0800 Subject: [PATCH 7/7] Update src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../CertificateAuthorityCollectionAnnotation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs index da68c8805fe..9abf71a6472 100644 --- a/src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/CertificateAuthorityCollectionAnnotation.cs @@ -42,7 +42,7 @@ public sealed class CertificateAuthorityCollectionAnnotation : IResourceAnnotati { /// /// Creates a new instance from one or more merged instances. - /// Certificate authority collections from all provided instances will be combined into the new instance, while the last vlaues for + /// Certificate authority collections from all provided instances will be combined into the new instance, while the last values for /// and will be used, with null values being ignored (previous value if any will be retained). /// /// The other s that will be merged to create the new instance.