diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 5cbba306cfb..c65213278a1 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIREDOCKERFILEBUILDER001 #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIRECERTIFICATES001 +#pragma warning disable ASPIRELIFECYCLE001 using System.Globalization; using System.Text.Json; @@ -893,7 +894,7 @@ private static void AddInstaller(IResourceBuilder resource .WithParentRelationship(resource.Resource) .ExcludeFromManifest(); - resource.ApplicationBuilder.Eventing.Subscribe((_, _) => + 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 @@ -908,6 +909,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 3da22a4b6c8..0108b51bf66 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -16,6 +16,7 @@ #pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIRECERTIFICATES001 +#pragma warning disable ASPIRELIFECYCLE001 namespace Aspire.Hosting; @@ -1365,16 +1366,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.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 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 @@ -1382,6 +1381,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..9abf71a6472 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 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. + /// 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/FinalizeResourceConfigurationCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/FinalizeResourceConfigurationCallbackAnnotation.cs new file mode 100644 index 00000000000..096458bd281 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/FinalizeResourceConfigurationCallbackAnnotation.cs @@ -0,0 +1,50 @@ +// 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.CodeAnalysis; + +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. +/// +/// +/// This annotation is used to register a callback that will be invoked immediately after the BeforeStartEvent is complete. +/// Callbacks are executed in reverse order of their registration and it is safe to modify resource annotation state during +/// this callback, but any additional annotations added in the +/// callback will be ignored. +/// +[Experimental("ASPIRELIFECYCLE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +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. +/// +/// +/// This context provides access to the resource and its execution context, as well as a cancellation token. +/// +[Experimental("ASPIRELIFECYCLE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +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/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs index f814019a0ba..a14cfc908b2 100644 --- a/src/Aspire.Hosting/DistributedApplication.cs +++ b/src/Aspire.Hosting/DistributedApplication.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIRELIFECYCLE001 + using System.Diagnostics; using System.Globalization; using Aspire.Hosting.ApplicationModel; @@ -498,6 +500,26 @@ internal async Task ExecuteBeforeStartHooksAsync(CancellationToken cancellationT { await lifecycleHook.BeforeStartAsync(appModel, cancellationToken).ConfigureAwait(false); } + + foreach (var resource in appModel.Resources) + { + 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/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 22601367d92..8d495cdb051 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3074,6 +3074,44 @@ public static IResourceBuilder ExcludeFromMcp(this IResourceBuilder bui return builder.WithAnnotation(new ExcludeFromMcpAnnotation()); } + /// + /// 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 . + /// + /// + /// Add a configuration finalizer to set default values: + /// + /// var resource = builder.AddResource("myresource"); + /// resource.WithConfigurationFinalizer(async ctx => + /// { + /// // Apply defaults based on final configuration + /// if (ctx.Resource.TryGetLastAnnotation<MyAnnotation>(out var annotation)) + /// { + /// resource.WithHttpsEndpoint(port: annotation.Port); + /// } + /// }); + /// + /// + /// + [Experimental("ASPIRELIFECYCLE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + 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); + } + /// /// Adds a callback to configure container image push options for the resource. /// diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 989494ad666..58586ee9919 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -4,6 +4,7 @@ #pragma warning disable CS0612 #pragma warning disable CS0618 // Type or member is obsolete #pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only +#pragma warning disable ASPIRELIFECYCLE001 using Microsoft.Extensions.DependencyInjection; using Aspire.Hosting.Utils; @@ -367,7 +368,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 +553,7 @@ public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempAppDir = new TestTempDirectory(); - + // Create app directory as a subdirectory of AppHost (realistic scenario) var appDirName = "python-app"; var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName); @@ -594,7 +595,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 +664,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 +681,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"); @@ -2318,7 +2319,7 @@ public async Task WithPip_InstallFalse_CreatesInstallerWithExplicitStart() // Verify the app does NOT wait for the installer var pythonAppResource = appModel.Resources.OfType().Single(); var waitAnnotations = pythonAppResource.Annotations.OfType().ToList(); - + // Should not wait for installer, only for venv creator Assert.All(waitAnnotations, wait => Assert.NotEqual(installerResource, wait.Resource)); } @@ -2351,7 +2352,7 @@ public async Task WithUv_InstallFalse_CreatesInstallerWithExplicitStart() // Verify the app does NOT wait for the installer var pythonAppResource = appModel.Resources.OfType().Single(); var waitAnnotations = pythonAppResource.Annotations.OfType().ToList(); - + // Should not have any wait annotations since uv doesn't create venv Assert.Empty(waitAnnotations); } @@ -2366,6 +2367,26 @@ 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) + { + 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); + } + } + } } } diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 4347d49f00e..52415bf7dc8 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIRECERTIFICATES001 +#pragma warning disable ASPIRELIFECYCLE001 using System.Globalization; using System.Text.RegularExpressions; @@ -1681,6 +1682,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)