diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs index c16701620c9..9c42444d2ce 100644 --- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs +++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs @@ -85,7 +85,7 @@ // Some issues only show up when installing for first time, rather than using existing downloaded versions // Use a specific NUGET_PACKAGES path for these playground tools, so we can easily reset them -builder.Eventing.Subscribe(async (evt, _) => +builder.OnBeforeStart(async (evt, _) => { var nugetPackagesPath = Path.Join(evt.Services.GetRequiredService().BasePath, "nuget"); diff --git a/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryExtensions.cs b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryExtensions.cs index 8f908c0edd4..a81c7cb7fb1 100644 --- a/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryExtensions.cs +++ b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryExtensions.cs @@ -81,7 +81,7 @@ public static IResourceBuilder AddAzureContainer /// private static void SubscribeToAddRegistryTargetAnnotations(IDistributedApplicationBuilder builder, AzureContainerRegistryResource registry) { - builder.Eventing.Subscribe((beforeStartEvent, cancellationToken) => + builder.OnBeforeStart((beforeStartEvent, cancellationToken) => { foreach (var resource in beforeStartEvent.Model.Resources) { diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs index 1a622f91dfc..69532183455 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs @@ -485,7 +485,7 @@ public static IResourceBuilder WithAccessKeyAuthenticatio // need to do this later in case builder becomes an emulator after this method is called. if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - builder.ApplicationBuilder.Eventing.Subscribe((data, _) => + builder.ApplicationBuilder.OnBeforeStart((data, _) => { if (builder.Resource.IsEmulator) { diff --git a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs index e9422107ae3..33cbb3d1abe 100644 --- a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs @@ -154,7 +154,7 @@ private static IResourceBuilder AddAzureFunctions .Resource; } - builder.Eventing.Subscribe((data, token) => + builder.OnBeforeStart((data, token) => { var removeStorage = true; // Look at all of the resources and if none of them use the default storage, then we can remove it. diff --git a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs index 0fbb02428e4..8ebd00eed83 100644 --- a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs +++ b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs @@ -306,7 +306,7 @@ public static IResourceBuilder WithPassword // need to do this later in case builder becomes an emulator after this method is called. if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - builder.ApplicationBuilder.Eventing.Subscribe((data, token) => + builder.ApplicationBuilder.OnBeforeStart((data, token) => { if (builder.Resource.IsContainer()) { diff --git a/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisExtensions.cs b/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisExtensions.cs index 5eb27695973..4af062dd858 100644 --- a/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisExtensions.cs +++ b/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisExtensions.cs @@ -138,7 +138,7 @@ public static IResourceBuilder WithAccessKeyAuthentic // need to do this later in case builder becomes an emulator after this method is called. if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - builder.ApplicationBuilder.Eventing.Subscribe((data, token) => + builder.ApplicationBuilder.OnBeforeStart((data, token) => { if (builder.Resource.IsContainer()) { diff --git a/src/Aspire.Hosting.Azure.Redis/AzureRedisExtensions.cs b/src/Aspire.Hosting.Azure.Redis/AzureRedisExtensions.cs index 67ffdd24aa0..2f8492d3dcb 100644 --- a/src/Aspire.Hosting.Azure.Redis/AzureRedisExtensions.cs +++ b/src/Aspire.Hosting.Azure.Redis/AzureRedisExtensions.cs @@ -206,7 +206,7 @@ public static IResourceBuilder WithAccessKeyAuthenticat // need to do this later in case builder becomes an emulator after this method is called. if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - builder.ApplicationBuilder.Eventing.Subscribe((data, token) => + builder.ApplicationBuilder.OnBeforeStart((data, token) => { if (builder.Resource.IsContainer()) { diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index b8e3cc289a3..1f44f998629 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -266,7 +266,7 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl if (builder.ExecutionContext.IsRunMode) { - builder.Eventing.Subscribe((_, _) => + builder.OnBeforeStart((_, _) => { // set the command to the package manager executable if the JavaScriptRunScriptAnnotation is present if (resourceBuilder.Resource.TryGetLastAnnotation(out _) && @@ -471,7 +471,7 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder((_, _) => + builder.OnBeforeStart((_, _) => { if (resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager)) { @@ -1139,7 +1139,7 @@ private static void AddInstaller(IResourceBuilder resource .ExcludeFromManifest() .WithCertificateTrustScope(CertificateTrustScope.None); - resource.ApplicationBuilder.Eventing.Subscribe((_, _) => + resource.ApplicationBuilder.OnBeforeStart((_, _) => { // 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.Maui/MauiOtlpExtensions.cs b/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs index 50163302608..b5b999379c7 100644 --- a/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs +++ b/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs @@ -116,7 +116,7 @@ private static OtlpDevTunnelConfigurationAnnotation CreateOtlpDevTunnelInfrastru // Manually allocate the stub endpoint so dev tunnel can start // Dev tunnels wait for ResourceEndpointsAllocatedEvent before starting - appBuilder.Eventing.Subscribe((evt, ct) => + appBuilder.OnBeforeStart((evt, ct) => { var endpoint = stubResource.Annotations.OfType().FirstOrDefault(); if (endpoint is not null && endpoint.AllocatedEndpoint is null) diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 400a735ac2a..ccfd298b569 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -491,7 +491,7 @@ private static IResourceBuilder AddPythonAppCore( // and the dependencies will be established based on which resources actually exist // Only do this in run mode since the installer and venv creator only run in run mode var resourceToSetup = resourceBuilder.Resource; - builder.Eventing.Subscribe((evt, ct) => + builder.OnBeforeStart((evt, ct) => { // Wire up wait dependencies for this resource based on which child resources exist SetupDependencies(builder, resourceToSetup); @@ -1332,7 +1332,7 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w installerBuilder.WithExplicitStart(); } - builder.ApplicationBuilder.Eventing.Subscribe((_, _) => + builder.ApplicationBuilder.OnBeforeStart((_, _) => { // 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/ContainerRegistryResourceBuilderExtensions.cs b/src/Aspire.Hosting/ContainerRegistryResourceBuilderExtensions.cs index 595c58005cc..360a4a147ee 100644 --- a/src/Aspire.Hosting/ContainerRegistryResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ContainerRegistryResourceBuilderExtensions.cs @@ -120,7 +120,7 @@ public static IResourceBuilder AddContainerRegistry( /// private static void SubscribeToAddRegistryTargetAnnotations(IDistributedApplicationBuilder builder, ContainerRegistryResource registry) { - builder.Eventing.Subscribe((beforeStartEvent, cancellationToken) => + builder.OnBeforeStart((beforeStartEvent, cancellationToken) => { foreach (var resource in beforeStartEvent.Model.Resources) { diff --git a/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs b/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs index 2885a776b95..f2f1235f713 100644 --- a/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs +++ b/src/Aspire.Hosting/DistributedApplicationEventingExtensions.cs @@ -3,14 +3,51 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Eventing; +using Aspire.Hosting.Publishing; namespace Aspire.Hosting; /// -/// Provides extension methods for subscribing to events on resources. +/// Provides extension methods for subscribing to and events. /// public static class DistributedApplicationEventingExtensions { + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The distributed application builder. + /// A callback to handle the event. + /// The for chaining. + /// If you need to ensure you only subscribe to the event once, see . + [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] + public static T OnBeforeStart(this T builder, Func callback) + where T : IDistributedApplicationBuilder + => builder.OnApplicationEvent(callback); + + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The distributed application builder. + /// A callback to handle the event. + /// The for chaining. + /// If you need to ensure you only subscribe to the event once, see . + [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] + public static T OnBeforePublish(this T builder, Func callback) + where T : IDistributedApplicationBuilder + => builder.OnApplicationEvent(callback); + + /// + /// Subscribes a callback to the event within the AppHost. + /// + /// The distributed application builder. + /// A callback to handle the event. + /// The for chaining. + /// If you need to ensure you only subscribe to the event once, see . + [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] + public static T OnAfterPublish(this T builder, Func callback) + where T : IDistributedApplicationBuilder + => builder.OnApplicationEvent(callback); + /// /// Subscribes a callback to the event within the AppHost. /// @@ -22,10 +59,10 @@ public static class DistributedApplicationEventingExtensions [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] public static IResourceBuilder OnBeforeResourceStarted(this IResourceBuilder builder, Func callback) where T : IResource - => builder.OnEvent(callback); + => builder.OnResourceEvent(callback); /// - /// Subscribes a callback to the event within the AppHost. + /// Subscribes a callback to the event for . /// /// The resource type. /// The resource builder. @@ -35,10 +72,10 @@ public static IResourceBuilder OnBeforeResourceStarted(this IResourceBuild [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] public static IResourceBuilder OnResourceStopped(this IResourceBuilder builder, Func callback) where T : IResource - => builder.OnEvent(callback); + => builder.OnResourceEvent(callback); /// - /// Subscribes a callback to the event within the AppHost. + /// Subscribes a callback to the event for . /// /// The resource type. /// The resource builder. @@ -48,10 +85,10 @@ public static IResourceBuilder OnResourceStopped(this IResourceBuilder [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] public static IResourceBuilder OnConnectionStringAvailable(this IResourceBuilder builder, Func callback) where T : IResourceWithConnectionString - => builder.OnEvent(callback); + => builder.OnResourceEvent(callback); /// - /// Subscribes a callback to the event within the AppHost. + /// Subscribes a callback to the event for . /// /// The resource type. /// The resource builder. @@ -61,10 +98,10 @@ public static IResourceBuilder OnConnectionStringAvailable(this IResourceB [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] public static IResourceBuilder OnInitializeResource(this IResourceBuilder builder, Func callback) where T : IResource - => builder.OnEvent(callback); + => builder.OnResourceEvent(callback); /// - /// Subscribes a callback to the event within the AppHost. + /// Subscribes a callback to the event for . /// /// The resource type. /// The resource builder. @@ -74,10 +111,10 @@ public static IResourceBuilder OnInitializeResource(this IResourceBuilder< [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] public static IResourceBuilder OnResourceEndpointsAllocated(this IResourceBuilder builder, Func callback) where T : IResourceWithEndpoints - => builder.OnEvent(callback); + => builder.OnResourceEvent(callback); /// - /// Subscribes a callback to the event within the AppHost. + /// Subscribes a callback to the event for . /// /// The resource type. /// The resource builder. @@ -87,9 +124,17 @@ public static IResourceBuilder OnResourceEndpointsAllocated(this IResource [AspireExportIgnore(Reason = "Complex generic delegates with event/CancellationToken types — not ATS-compatible.")] public static IResourceBuilder OnResourceReady(this IResourceBuilder builder, Func callback) where T : IResource - => builder.OnEvent(callback); + => builder.OnResourceEvent(callback); + + private static T OnApplicationEvent(this T builder, Func callback) + where T : IDistributedApplicationBuilder + where TEvent : IDistributedApplicationEvent + { + builder.Eventing.Subscribe(callback); + return builder; + } - private static IResourceBuilder OnEvent(this IResourceBuilder builder, Func callback) + private static IResourceBuilder OnResourceEvent(this IResourceBuilder builder, Func callback) where TResource : IResource where TEvent : IDistributedApplicationResourceEvent { diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 3b5f0410a4a..ad3b665dd54 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -2892,7 +2892,7 @@ public static IResourceBuilder SubscribeHttpsEndpointsUpdate((@event, cancellationToken) => + builder.ApplicationBuilder.OnBeforeStart((@event, cancellationToken) => { var developerCertificateService = @event.Services.GetRequiredService(); diff --git a/tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs b/tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs index de18d7f6b5b..acbba2a8808 100644 --- a/tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs +++ b/tests/Aspire.Hosting.Tests/Eventing/DistributedApplicationBuilderEventingTests.cs @@ -3,6 +3,7 @@ using Aspire.TestUtilities; using Aspire.Hosting.Eventing; +using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; @@ -336,6 +337,125 @@ public async Task ResourceStoppedEventFiresWhenResourceStops() await resourceStoppedTcs.Task.DefaultTimeout(); } + [Fact] + public async Task OnBeforeStartSubscribesToBeforeStartEvent() + { + var eventFired = new ManualResetEventSlim(); + + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.OnBeforeStart((e, ct) => + { + Assert.NotNull(e.Services); + Assert.NotNull(e.Model); + eventFired.Set(); + return Task.CompletedTask; + }); + + using var app = builder.Build(); + await app.StartAsync(); + + var fired = eventFired.Wait(TimeSpan.FromSeconds(10)); + Assert.True(fired); + + await app.StopAsync(); + } + + [Fact] + public async Task OnAfterResourcesCreatedSubscribesToAfterResourcesCreatedEvent() + { + var eventFired = new ManualResetEventSlim(); + + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.Eventing.Subscribe((e, ct) => + { + Assert.NotNull(e.Services); + Assert.NotNull(e.Model); + eventFired.Set(); + return Task.CompletedTask; + }); + + using var app = builder.Build(); + await app.StartAsync(); + + var fired = eventFired.Wait(TimeSpan.FromSeconds(10)); + Assert.True(fired); + + await app.StopAsync(); + } + + [Fact] + public async Task OnBeforePublishSubscribesToBeforePublishEvent() + { + var eventFired = false; + + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.OnBeforePublish((e, ct) => + { + Assert.NotNull(e.Model); + eventFired = true; + return Task.CompletedTask; + }); + + using var app = builder.Build(); + var eventing = app.Services.GetRequiredService(); + + // Manually publish the event to verify subscription + var testEvent = new BeforePublishEvent(app.Services, new([])); + await eventing.PublishAsync(testEvent, CancellationToken.None); + + Assert.True(eventFired); + } + + [Fact] + public async Task OnAfterPublishSubscribesToAfterPublishEvent() + { + var eventFired = false; + + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.OnAfterPublish((e, ct) => + { + Assert.NotNull(e.Model); + eventFired = true; + return Task.CompletedTask; + }); + + using var app = builder.Build(); + var eventing = app.Services.GetRequiredService(); + + // Manually publish the event to verify subscription + var testEvent = new AfterPublishEvent(app.Services, new([])); + await eventing.PublishAsync(testEvent, CancellationToken.None); + + Assert.True(eventFired); + } + + [Fact] + public void OnBeforeStartReturnsBuilderForChaining() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var result = builder.OnBeforeStart((e, ct) => Task.CompletedTask); + + Assert.Same(builder, result); + } + + [Fact] + public void OnBeforePublishReturnsBuilderForChaining() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var result = builder.OnBeforePublish((e, ct) => Task.CompletedTask); + + Assert.Same(builder, result); + } + + [Fact] + public void OnAfterPublishReturnsBuilderForChaining() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var result = builder.OnAfterPublish((e, ct) => Task.CompletedTask); + + Assert.Same(builder, result); + } + public class DummyEvent : IDistributedApplicationEvent { }