diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 73caa78d9a2..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.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 @@ -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..9b75505227f 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.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 @@ -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..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..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/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs index f814019a0ba..ea5c57ffca4 100644 --- a/src/Aspire.Hosting/DistributedApplication.cs +++ b/src/Aspire.Hosting/DistributedApplication.cs @@ -498,6 +498,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 4c50ca63410..8448c320d69 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3027,6 +3027,27 @@ 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 . + 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 8a1fea29671..779bb9271ec 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,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 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)