diff --git a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs index 5e1d6f9d6d7..b8510bd00aa 100644 --- a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs @@ -107,23 +107,77 @@ public static bool HasLaunchToolArgsOwnedBy(this IResource resource, SupportsDeb /// /// Launch configuration is created by invoking the producer callback passed to /// - /// (or its asynchronous overload), - /// which owns the complete configuration; Aspire serializes the result as-is. - /// The configuration is produced fresh on each call; it is not a singleton. - /// Aspire may call the producer several times for the same resource. + /// (or one of its asynchronous overloads), which owns the complete configuration; Aspire serializes the result as-is. + /// The configuration is produced fresh on each call. /// /// - /// This describes the launch configuration itself, not whether one is going to be used. - /// Depending on how the application is started, or how a resource is configured, - /// Aspire may or may not run the resource under a debugger. Use to test for that. + /// This inspection API does not resolve the resource's environment variables. A producer that accepts a + /// receives an empty + /// collection. Aspire invokes that producer + /// separately with resolved values when it creates the executable. + /// + /// + /// This describes the launch configuration itself, not whether one is going to be used. Depending on how the + /// application is started or how a resource is configured, Aspire may or may not run the resource under a debugger. + /// Use to test for that. /// /// [AspireExportIgnore(Reason = "Debug support inspection is a local .NET helper and is not part of the ATS surface.")] - public static Task CreateLaunchConfigurationAsync(this IResource resource, string mode, CancellationToken cancellationToken = default) + public static Task CreateLaunchConfigurationAsync( + this IResource resource, + string mode, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(resource); ArgumentNullException.ThrowIfNull(mode); + var context = new LaunchConfigurationCallbackContext( + mode, + resource, + new Dictionary(), + cancellationToken); + + return resource.CreateLaunchConfigurationAsync(context); + } + + /// + /// Creates the launch configuration that this resource sends to the IDE using a callback context. + /// + /// The resource to inspect. It must carry a . + /// The callback context containing the resolved environment and launch data. + /// The launch configuration, typically an . + /// belongs to a different resource. + /// The resource does not declare debug launch support. + /// + /// + /// Launch configuration is created by invoking the producer callback passed to + /// , + /// which owns the complete configuration; Aspire serializes the result as-is. + /// + /// + /// This method never resolves environment variables. Aspire creates + /// when the active debug-support annotation is producing a launch configuration for an executable creation. + /// + /// + /// This overload is internal because only Aspire constructs callback contexts containing resolved environment + /// variables. Use the public overload when inspecting a launch configuration outside executable creation. + /// + /// + internal static Task CreateLaunchConfigurationAsync( + this IResource resource, + LaunchConfigurationCallbackContext context) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(context); + + if (!ReferenceEquals(resource, context.Resource)) + { + throw new ArgumentException( + $"The launch configuration callback context belongs to resource '{context.Resource.Name}', " + + $"but launch configuration was requested for resource '{resource.Name}'.", + nameof(context)); + } + if (!resource.TryGetLastAnnotation(out var supportsDebuggingAnnotation)) { throw new InvalidOperationException( @@ -132,7 +186,7 @@ public static Task CreateLaunchConfigurationAsync(this IResource resourc $"Note that it only adds the annotation in run mode."); } - return supportsDebuggingAnnotation.LaunchConfigurationProducer(mode, cancellationToken); + return supportsDebuggingAnnotation.LaunchConfigurationProducer(context); } private static string[]? GetSupportedLaunchConfigurations(IConfiguration configuration) diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs index b1cec8dba26..4d8dcd62915 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs @@ -60,8 +60,7 @@ public static class KnownLaunchConfigurationTypes /// /// /// Integrations create a derived type and supply it through -/// -/// or its asynchronous overload. +/// one of the WithDebugSupport overloads on . /// /// /// The launch configuration type identifier, for example . @@ -90,8 +89,8 @@ public class ExecutableLaunchConfiguration(string type) /// /// Defaults to when a debugger is attached to the app host /// and otherwise. The mode requested by the IDE for the - /// current debug session is passed to the producer callback of - /// . + /// current debug session is passed directly to mode-based producers and is available to context-based + /// producers through . /// [JsonPropertyName("mode")] public string Mode { get; set; } = System.Diagnostics.Debugger.IsAttached ? ExecutableLaunchMode.Debug : ExecutableLaunchMode.NoDebug; diff --git a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs new file mode 100644 index 00000000000..ad938176572 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs @@ -0,0 +1,58 @@ +// 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; + +/// +/// Provides the runtime data used to create a launch configuration for a resource. +/// +/// +/// Aspire creates this context after resolving the execution configuration for a specific executable +/// creation. Environment variable values may contain secrets; only copy values into the launch +/// configuration when the IDE requires them. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class LaunchConfigurationCallbackContext +{ + internal LaunchConfigurationCallbackContext( + string mode, + IResource resource, + IReadOnlyDictionary environmentVariables, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(mode); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(environmentVariables); + + Mode = mode; + Resource = resource; + EnvironmentVariables = environmentVariables; + CancellationToken = cancellationToken; + } + + /// + /// Gets the requested launch mode, one of the values on . + /// + public string Mode { get; } + + /// + /// Gets the resource being launched. + /// + public IResource Resource { get; } + + /// + /// Gets the resolved environment variables used for this executable creation. + /// + /// + /// Values can contain secrets. Aspire serializes only the launch configuration returned by the + /// producer; integrations should copy only values required by the IDE. + /// + public IReadOnlyDictionary EnvironmentVariables { get; } + + /// + /// Gets the cancellation token for this executable creation. + /// + public CancellationToken CancellationToken { get; } +} diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index c18f7d675e0..767cfc92fe3 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -178,7 +178,7 @@ public async Task RunApplicationAsync(CancellationToken ct = default) { containers = _containerCreator.PrepareObjects().ToArray(); _containerCreator.PrepareContainerExecutables(); - executables = (await _executableCreator.PrepareObjectsAsync(ct).ConfigureAwait(false)).ToArray(); + executables = _executableCreator.PrepareObjects(ct).ToArray(); prepareResourcesActivity.SetDcpPreparedResourceCounts(containers.Length, executables.Length); } diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 2b02caacb3c..62a0f674aec 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -54,10 +54,11 @@ public ExecutableCreator( _appResources = appResources; } - public async Task>> PrepareObjectsAsync(CancellationToken cancellationToken) + public IEnumerable> PrepareObjects(CancellationToken cancellationToken) { - await PrepareProjectExecutablesAsync(cancellationToken).ConfigureAwait(false); + PrepareProjectExecutables(cancellationToken); PreparePlainExecutables(); + return _appResources.Get().OfType>(); } @@ -105,7 +106,13 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC var launchToolArgumentsData = configuration.AdditionalConfigurationData.OfType().FirstOrDefault(); var resolvedLaunchToolArgumentCount = launchToolArgumentsData?.Count ?? 0; var hasPreparedProjectArguments = spec.Args is { Count: > 0 }; - await ApplyLaunchConfigurationAsync(er, exe, resolvedLaunchToolArgumentCount, hasPreparedProjectArguments, cancellationToken).ConfigureAwait(false); + await ApplyLaunchConfigurationAsync( + er, + exe, + configuration.EnvironmentVariables, + resolvedLaunchToolArgumentCount, + hasPreparedProjectArguments, + cancellationToken).ConfigureAwait(false); ApplyResolvedProjectArguments(er, exe, resolvedLaunchToolArgumentCount); var omittedLaunchToolArgumentCount = OmitLaunchToolArguments(er, spec) @@ -188,62 +195,70 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC } /// - /// Applies the resource's debug launch configuration, now that endpoints are allocated and the launch - /// configuration can reference endpoint URLs that were not available during . + /// Applies the resource's debug launch configuration after its execution configuration has been resolved. /// /// - /// "project" launch types on configure their launch configurations in - /// PrepareProjectExecutables() directly. Plain executables that carry and a - /// "project" (e.g. DotnetProjectResource) are prepared as plain - /// executables, so their "project" launch configuration is applied here for IDE/F5 parity with AddProject. - /// All other types (plain executables and project subtypes like azure-functions) are also handled here. + /// Delaying the producer until creation lets it reuse the exact resolved environment without evaluating + /// resource callbacks again. It also ensures endpoint-backed environment values are available. /// private async Task ApplyLaunchConfigurationAsync( RenderedModelResource er, Executable exe, + IEnumerable> environmentVariables, int resolvedLaunchToolArgumentCount, bool hasPreparedProjectArguments, CancellationToken cancellationToken) { if (er.ModelResource.HasAnnotationOfType() - || HasProjectLaunchArgsOverride(er.ModelResource) || !er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) { return; } - if (supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project) - { - // ProjectResources already applied the "project" launch config in PrepareProjectExecutables(). - // Only plain executables carrying project metadata need it applied here. - if (er.ModelResource is not ProjectResource) - { - if (er.ModelResource.TryGetProjectMetadata(out var plainProjectMetadata)) - { - // Clear and re-apply the launch configuration to ensure proper restart behavior. - await ApplyProjectLaunchConfigurationAsync(exe, er.ModelResource, plainProjectMetadata, supportsDebuggingAnnotation, cancellationToken).ConfigureAwait(false); - } - else - { - throw new FailedToApplyEnvironmentException( - $"Resource '{er.ModelResource.Name}' declares \"project\" debug launch support (WithDebugSupport) but has no project metadata. " + - $"The \"project\" launch configuration type is reserved for .NET project resources; use a resource that carries {nameof(IProjectMetadata)} or a different launch configuration type."); - } - } + var isProjectLaunchConfiguration = + supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project; + var hasProjectLaunchArgsOverride = HasProjectLaunchArgsOverride(er.ModelResource); + // A project launch override already supplies the process invocation. A "project" producer would describe + // a launch mode that cannot be used, while custom producers can still contribute process-mode metadata. + if (hasProjectLaunchArgsOverride && isProjectLaunchConfiguration) + { return; } - // We have non-project Executable that supports debugging; need to annotate it properly. - // A previous launch-configuration failure can leave the reusable spec in Process mode. Restore the prepared - // IDE mode before each attempt so a successful restart does not inherit that transient fallback. - exe.Spec.ExecutionType = ExecutionType.IDE; - var mode = _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; + if (isProjectLaunchConfiguration && !er.ModelResource.TryGetProjectMetadata(out _)) + { + throw new FailedToApplyEnvironmentException( + $"Resource '{er.ModelResource.Name}' declares \"project\" debug launch support (WithDebugSupport) but has no project metadata. " + + $"The \"project\" launch configuration type is reserved for .NET project resources; use a resource that carries {nameof(IProjectMetadata)} or a different launch configuration type."); + } + + // A previous producer failure can leave the reusable spec in Process mode. Restore IDE execution on + // restart unless a project launch override intentionally keeps this resource in Process mode. + if (!hasProjectLaunchArgsOverride) + { + exe.Spec.ExecutionType = ExecutionType.IDE; + } + + var mode = isProjectLaunchConfiguration + ? GetProjectLaunchConfigurationMode() + : _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; + var callbackContext = new LaunchConfigurationCallbackContext( + mode, + er.ModelResource, + environmentVariables.ToDictionary( + static variable => variable.Key, + static variable => variable.Value, + StringComparer.Ordinal), + cancellationToken); + try { - // Clear any existing launch configurations (needed for restart scenarios). + // Executable objects are reused for restarts, so replace the prior producer result. exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty); - await supportsDebuggingAnnotation.LaunchConfigurationAnnotator(exe, mode, cancellationToken).ConfigureAwait(false); + await supportsDebuggingAnnotation + .LaunchConfigurationAnnotator(exe, callbackContext) + .ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -251,6 +266,11 @@ private async Task ApplyLaunchConfigurationAsync( } catch (Exception ex) { + if (isProjectLaunchConfiguration) + { + throw; + } + if (HasIncompleteProcessCommand(er.ModelResource, supportsDebuggingAnnotation, resolvedLaunchToolArgumentCount, hasPreparedProjectArguments)) { // This project-backed executable suppressed its process scaffold because the custom launch @@ -333,8 +353,9 @@ private static bool HasIncompleteProcessCommand( && modelResource.HasLaunchToolArgsOwnedBy(annotation); } - private async Task PrepareProjectExecutablesAsync(CancellationToken cancellationToken) + private void PrepareProjectExecutables(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var modelProjectResources = _model.GetProjectResources(); foreach (var project in modelProjectResources) @@ -404,14 +425,8 @@ private async Task PrepareProjectExecutablesAsync(CancellationToken cancellation { exe.Spec.ExecutionType = ExecutionType.IDE; - if (supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project) - { - // We want this annotation even if we are not using IDE execution; see ToSnapshot() for details. - await ApplyProjectLaunchConfigurationAsync(exe, project, projectMetadata, supportsDebuggingAnnotation, cancellationToken).ConfigureAwait(false); - } - // Non-project launch types (e.g. azure-functions) have their launch configuration - // applied later in CreateExecutableAsync() after endpoints are allocated, - // unless the IDE didn't send DEBUG_SESSION_INFO (handled by the fallback branch below). + // The active launch configuration producer runs later in CreateObjectAsync, after the + // resource's arguments and environment variables have been resolved. // Keep a candidate Process command so custom IDE launch configurations whose launch-tool // callback resolves empty have a runnable fallback. This also preserves the existing fallback @@ -439,7 +454,7 @@ private async Task PrepareProjectExecutablesAsync(CancellationToken cancellation // support for their custom launch type. exe.Spec.ExecutionType = ExecutionType.IDE; - await ApplyProjectLaunchConfigurationAsync(exe, project, projectMetadata, supportsDebuggingAnnotation: null, cancellationToken).ConfigureAwait(false); + exe.SetProjectLaunchConfiguration(CreateProjectLaunchConfiguration(project, projectMetadata)); if (executableAnnotation is null && projectMetadata.IsFileBasedApp) { @@ -987,20 +1002,6 @@ private bool ShouldFallBackToIdeExecution(bool isInDebugSession, SupportsDebuggi return true; } - private async Task ApplyProjectLaunchConfigurationAsync(Executable exe, IResource project, IProjectMetadata projectMetadata, SupportsDebuggingAnnotation? supportsDebuggingAnnotation, CancellationToken cancellationToken) - { - if (supportsDebuggingAnnotation?.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project) - { - // The producer builds the complete configuration, so it is annotated as-is. Clearing first is - // what makes restarts (where the Executable object is reused) end up with a single entry. - exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty); - await supportsDebuggingAnnotation.LaunchConfigurationAnnotator(exe, GetProjectLaunchConfigurationMode(), cancellationToken).ConfigureAwait(false); - return; - } - - exe.SetProjectLaunchConfiguration(CreateProjectLaunchConfiguration(project, projectMetadata)); - } - private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(IResource project, IProjectMetadata projectMetadata) { return ProjectLaunchConfigurationFactory.Create(project, projectMetadata, GetProjectLaunchConfigurationMode()); diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index c139ad13536..4d50eb30aef 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4833,29 +4833,31 @@ public static IResourceBuilder WithLaunchToolArgs( } /// - /// Adds support for debugging the resource in VS Code when running in an extension host. + /// Adds support for debugging the resource in an IDE or extension host. /// /// The resource type. /// The launch configuration type produced for the resource, typically derived from . /// The resource builder. - /// Launch configuration producer for the resource. It is passed the launch mode (one of the values on ) and produces the configuration that is handed to the IDE. - /// The type tag of the launch configuration (as sent to the IDE). + /// A callback that receives the launch mode and produces the complete launch configuration handed to the IDE. + /// The type tag of the launch configuration sent to the IDE. /// The . /// - /// is a or , which means an - /// asynchronous producer was written without the parameter and bound to this - /// overload. Use instead. + /// is a or . Use an + /// asynchronous overload instead so the task result, rather than the task itself, becomes the launch configuration. /// /// - /// Aspire invokes the launch configuration producer while preparing and creating the underlying orchestrator objects, and may invoke it - /// several times for the same resource. Use - /// - /// when the configuration has to be resolved from work that is itself asynchronous, for example in the presence of - /// build-argument callbacks contributed by other annotations. + /// Registering debug support is synchronous. Aspire invokes + /// later only for executable creations where this debug-support annotation is active, including restarts and replicas. + /// The callback does not run for unsupported debug sessions, publish mode, or inactive annotations superseded by + /// a later . /// + [OverloadResolutionPriority(-1)] [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")] - public static IResourceBuilder WithDebugSupport(this IResourceBuilder builder, Func launchConfigurationProducer, string launchConfigurationType) + public static IResourceBuilder WithDebugSupport( + this IResourceBuilder builder, + Func launchConfigurationProducer, + string launchConfigurationType) where T : IResource { ArgumentNullException.ThrowIfNull(builder); @@ -4864,38 +4866,95 @@ public static IResourceBuilder WithDebugSupport(this if (typeof(Task).IsAssignableFrom(typeof(TLaunchConfiguration)) || IsValueTask(typeof(TLaunchConfiguration))) { throw new ArgumentException( - $"The launch configuration producer returns '{typeof(TLaunchConfiguration)}'. An asynchronous producer must take a {nameof(CancellationToken)} " + - $"parameter so that it binds to the asynchronous {nameof(WithDebugSupport)} overload; otherwise the task itself is used as the launch configuration.", + $"The launch configuration producer returns '{typeof(TLaunchConfiguration)}'. An asynchronous producer must bind to an asynchronous {nameof(WithDebugSupport)} overload " + + $"either by accepting the launch mode and a {nameof(CancellationToken)} or by accepting a {nameof(LaunchConfigurationCallbackContext)}; otherwise the task itself is used as the launch configuration.", nameof(launchConfigurationProducer)); } - return builder.WithDebugSupport((mode, _) => Task.FromResult(launchConfigurationProducer(mode)), launchConfigurationType); + return builder.WithDebugSupport( + (mode, _) => Task.FromResult(launchConfigurationProducer(mode)), + launchConfigurationType); static bool IsValueTask(Type type) => type == typeof(ValueTask) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)); } /// - /// Adds support for debugging the resource in VS Code when running in an extension host, - /// using a launch configuration that is produced asynchronously. + /// Adds support for asynchronously producing an IDE launch configuration for the resource. /// /// The resource type. /// The launch configuration type produced for the resource, typically derived from . /// The resource builder. - /// Launch configuration producer for the resource. It is passed the launch mode (one of the values on ) and produces the configuration that is handed to the IDE. - /// The type of the resource. + /// A callback that receives the launch mode and cancellation token. + /// The type tag of the launch configuration sent to the IDE. /// The . /// - /// Use this overload when the launch configuration has to be resolved from work that is itself asynchronous, for - /// example in the presence of build-argument callbacks contributed by other annotations. Aspire invokes the producer while preparing - /// and creating the underlying orchestrator objects, and may invoke it several times for the same resource. - /// A producer that computes everything synchronously should use - /// - /// instead. + /// Use this overload when producing the launch configuration requires asynchronous work. Aspire invokes the + /// producer after resolving the execution configuration and may invoke it several times for the same resource. /// [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")] - public static IResourceBuilder WithDebugSupport(this IResourceBuilder builder, Func> launchConfigurationProducer, string launchConfigurationType) + public static IResourceBuilder WithDebugSupport( + this IResourceBuilder builder, + Func> launchConfigurationProducer, + string launchConfigurationType) + where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(launchConfigurationProducer); + + return builder.WithDebugSupport( + context => launchConfigurationProducer(context.Mode, context.CancellationToken), + launchConfigurationType); + } + + /// + /// Adds support for producing an IDE launch configuration from the resolved executable environment. + /// + /// The resource type. + /// The launch configuration type produced for the resource, typically derived from . + /// The resource builder. + /// + /// A callback that receives the resolved environment variables and asynchronously produces the complete + /// launch configuration handed to the IDE. + /// + /// The type tag of the launch configuration sent to the IDE. + /// The . + /// + /// Aspire invokes after resolving the execution configuration for + /// each executable creation. A producer that completes synchronously should return its result with + /// . Process execution does not generally require a producer, but + /// Aspire can still invoke a supported non-project producer for a process executable so it can contribute + /// launch metadata. + /// + /// + /// Produce a launch configuration using a resolved environment variable: + /// + /// internal sealed class MyToolLaunchConfiguration() : ExecutableLaunchConfiguration("mytool") + /// { + /// public string? TargetDirectory { get; set; } + /// } + /// + /// builder.AddExecutable("tool", "mytool", ".") + /// .WithDebugSupport( + /// context => + /// { + /// context.EnvironmentVariables.TryGetValue("MYTOOL_TARGET_DIR", out var targetDirectory); + /// return Task.FromResult(new MyToolLaunchConfiguration + /// { + /// Mode = context.Mode, + /// TargetDirectory = targetDirectory + /// }); + /// }, + /// launchConfigurationType: "mytool"); + /// + /// + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")] + public static IResourceBuilder WithDebugSupport( + this IResourceBuilder builder, + Func> launchConfigurationProducer, + string launchConfigurationType) where T : IResource { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index 0363b909898..b3a01ad6bd3 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -12,12 +12,9 @@ namespace Aspire.Hosting.ApplicationModel; /// instead of being started as a plain process by Aspire. /// /// -/// Added by -/// (or its asynchronous overload). The -/// annotation is only honored while a debug session is active; use -/// to test for that, and -/// to inspect the launch configuration -/// the resource will send. +/// Added by a WithDebugSupport overload on . +/// The annotation is only honored while a debug session is active; use +/// to test for that. /// [DebuggerDisplay("Type = {GetType().Name,nq}, RequiredExtensionId = {LaunchConfigurationType,nq}")] [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] @@ -25,8 +22,8 @@ public sealed class SupportsDebuggingAnnotation : IResourceAnnotation { private SupportsDebuggingAnnotation( string launchConfigurationType, - Func launchConfigurationAnnotator, - Func> launchConfigurationProducer) + Func launchConfigurationAnnotator, + Func> launchConfigurationProducer) { LaunchConfigurationType = launchConfigurationType; LaunchConfigurationAnnotator = launchConfigurationAnnotator; @@ -38,7 +35,7 @@ private SupportsDebuggingAnnotation( /// /// /// The IDE advertises the launch configuration types it can handle; a resource whose type is not - /// advertised is started as a plain process instead. + /// advertised is started as a plain process instead. /// /// Exception: when the active debug session does not /// advertise any launch configuration types at all (for example Visual Studio, which does not send a @@ -49,27 +46,32 @@ private SupportsDebuggingAnnotation( public string LaunchConfigurationType { get; } // Takes the internal DCP Executable object, so it stays internal even though the annotation is public. - internal Func LaunchConfigurationAnnotator { get; } + internal Func LaunchConfigurationAnnotator { get; } - // The producer callback passed to WithDebugSupport, with the launch configuration boxed as object. - // Internal because it hands out an untyped object; DebugSupportExtensions.CreateLaunchConfigurationAsync is - // the supported way to reach it. - internal Func> LaunchConfigurationProducer { get; } + // The producer callback supplied to WithDebugSupport, with the launch configuration boxed as object. + // Internal because only Aspire constructs LaunchConfigurationCallbackContext values and because the + // untyped object is consumed by internal launch-configuration plumbing. + internal Func> LaunchConfigurationProducer { get; } - internal static SupportsDebuggingAnnotation Create(string resourceName, string launchConfigurationType, Func> launchProfileProducer) + internal static SupportsDebuggingAnnotation Create( + string resourceName, + string launchConfigurationType, + Func> launchConfigurationProducer) { // The annotator stays generic over T so the DCP annotation is serialized against the concrete // launch configuration type rather than a boxed object, which would change the emitted JSON. return new SupportsDebuggingAnnotation( launchConfigurationType, - async (exe, mode, ct) => exe.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, await ProduceAsync(mode, ct).ConfigureAwait(false)), + async (exe, context) => exe.AnnotateAsObjectList( + Executable.LaunchConfigurationsAnnotation, + await ProduceAsync(context).ConfigureAwait(false)), // The suppression is safe because ProduceAsync throws rather than returning null; the // compiler cannot see that because T is unconstrained and so may be a nullable type. - async (mode, ct) => (await ProduceAsync(mode, ct).ConfigureAwait(false))!); + async context => (await ProduceAsync(context).ConfigureAwait(false))!); - async Task ProduceAsync(string mode, CancellationToken cancellationToken) + async Task ProduceAsync(LaunchConfigurationCallbackContext context) { - var launchConfiguration = await launchProfileProducer(mode, cancellationToken).ConfigureAwait(false); + var launchConfiguration = await launchConfigurationProducer(context).ConfigureAwait(false); if (launchConfiguration is null) { throw new InvalidOperationException( diff --git a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs index 82500f1fde8..444b025f3ca 100644 --- a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs @@ -209,7 +209,11 @@ public async Task AddDotnetProject_DebugAnnotator_ProducesProjectLaunchConfigura Assert.True(app.Resource.TryGetLastAnnotation(out var supportsDebugging)); Assert.Equal(KnownLaunchConfigurationTypes.Project, supportsDebugging.LaunchConfigurationType); - var launchConfig = Assert.IsType(await app.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + app.Resource, + ExecutableLaunchMode.Debug); + var launchConfig = Assert.IsType( + await app.Resource.CreateLaunchConfigurationAsync(callbackContext)); Assert.Equal(KnownLaunchConfigurationTypes.Project, launchConfig.Type); Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode); Assert.Equal(projectPath, launchConfig.ProjectPath); @@ -240,7 +244,11 @@ await File.WriteAllTextAsync(Path.Combine(propertiesDir.FullName, "launchSetting using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var app = builder.AddDotnetProject("svc", projectPath); - var launchConfig = Assert.IsType(await app.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + app.Resource, + ExecutableLaunchMode.Debug); + var launchConfig = Assert.IsType( + await app.Resource.CreateLaunchConfigurationAsync(callbackContext)); Assert.False(launchConfig.DisableLaunchProfile); Assert.Equal("http", launchConfig.LaunchProfile); diff --git a/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs b/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs index ce352fbd1fe..ef497889ca1 100644 --- a/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs +++ b/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs @@ -1188,7 +1188,8 @@ private static async Task InvokeLaunchConfigurationAnnota Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); var exe = Executable.Create("test", "go"); - await supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug, CancellationToken.None); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); + await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); Assert.True(exe.TryGetAnnotationAsObjectList( Executable.LaunchConfigurationsAnnotation, diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs index 872a01fea15..d5224a99fa3 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs @@ -399,7 +399,8 @@ private static async Task InvokeLaunchConfigurati Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); var exe = Executable.Create("test", "bun"); - await supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug, CancellationToken.None); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); + await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); Assert.True(exe.TryGetAnnotationAsObjectList( Executable.LaunchConfigurationsAnnotation, diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs index aafe417b7d1..06e3533cefa 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs @@ -631,7 +631,8 @@ private static async Task InvokeLaunchConfigurati Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); var exe = Executable.Create("test", "node"); - await supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug, CancellationToken.None); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); + await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); Assert.True(exe.TryGetAnnotationAsObjectList( Executable.LaunchConfigurationsAnnotation, diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs index 1c5eb7708c6..d7e2325fbf0 100644 --- a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs +++ b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs @@ -890,7 +890,11 @@ private static Task GetSingleMauiLaunchConfig /// private static async Task DeserializeLaunchConfigurationAsync(IResource resource) { - var json = JsonSerializer.Serialize(await resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + resource, + ExecutableLaunchMode.Debug); + var json = JsonSerializer.Serialize( + await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(resource, callbackContext)); var launchConfiguration = JsonSerializer.Deserialize(json); Assert.NotNull(launchConfiguration); diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index c7a5dd7b1e0..4f375a73e4f 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -1633,7 +1633,8 @@ private static async Task InvokeLaunchConfigurationAn Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); var exe = Executable.Create("test", "python"); - await supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug, CancellationToken.None); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); + await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); Assert.True(exe.TryGetAnnotationAsObjectList( Executable.LaunchConfigurationsAnnotation, diff --git a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs new file mode 100644 index 00000000000..b29779b2cb3 --- /dev/null +++ b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs @@ -0,0 +1,34 @@ +// 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 ASPIREEXTENSION001 + +namespace Aspire.Hosting.Tests.Utils; + +public static class LaunchConfigurationTestHelpers +{ + public static LaunchConfigurationCallbackContext CreateCallbackContext( + IResource resource, + string mode = ExecutableLaunchMode.Debug, + IReadOnlyDictionary? environmentVariables = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return new LaunchConfigurationCallbackContext( + mode, + resource, + environmentVariables ?? new Dictionary(), + cancellationToken); + } + + public static Task InvokeLaunchConfigurationProducerAsync( + IResource resource, + LaunchConfigurationCallbackContext callbackContext) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(callbackContext); + + return resource.CreateLaunchConfigurationAsync(callbackContext); + } +} diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index a86cf6c84b8..0d627b885d8 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -257,7 +257,7 @@ public async Task CreateExecutable_ToolHasCommandLineArgs_AnnotationsAdded(param var exe = Assert.Single(executables); string[] dotnetToolExecArgs = ["tool", "exec", "package", "--yes", "--"]; - string[] callArgs = [..dotnetToolExecArgs, ..toolArgs]; + string[] callArgs = [.. dotnetToolExecArgs, .. toolArgs]; Assert.Equal(callArgs, exe.Spec.Args); @@ -4499,6 +4499,73 @@ public async Task ProjectLaunchConfiguration_FallbackToFirstProfileInsertionOrde Assert.Equal("Zed", plc.LaunchProfile); // first inserted wins } + [Fact] + public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedEnvironmentVariables() + { + var builder = DistributedApplication.CreateBuilder(); + LaunchConfigurationCallbackContext? launchContext = null; + var environmentCallbackInvocationCount = 0; + var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug; + + var resource = new TestExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithArgs("app-arg") + .WithEnvironment(context => + { + var currentInvocation = Interlocked.Increment(ref environmentCallbackInvocationCount); + context.EnvironmentVariables["DEBUG_VALUE"] = $"resolved-{currentInvocation}"; + }) + .WithDebugSupport( + context => + { + launchContext = context; + return Task.FromResult(new ExecutableLaunchConfiguration("test") + { + Mode = context.Mode + }); + }, + "test"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfo, + [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor( + distributedAppModel, + kubernetesService: kubernetesService, + configuration: configuration); + using var cts = new CancellationTokenSource(); + + await appExecutor.RunApplicationAsync(cts.Token); + + Assert.NotNull(launchContext); + Assert.Equal(ExecutableLaunchMode.Debug, launchContext.Mode); + Assert.Same(resource, launchContext.Resource); + Assert.Equal(cts.Token, launchContext.CancellationToken); + + var executable = GetCreatedExecutableForResource(kubernetesService, resource.Name); + var debugValue = Assert.Single(executable.Spec.Env!, variable => variable.Name == "DEBUG_VALUE").Value; + Assert.Equal(1, Volatile.Read(ref environmentCallbackInvocationCount)); + Assert.Equal("resolved-1", debugValue); + Assert.Equal(debugValue, launchContext.EnvironmentVariables["DEBUG_VALUE"]); + Assert.Equal(["app-arg"], executable.Spec.Args); + } + [Fact] public async Task PlainExecutable_ExtensionMode_SupportedDebugMode_RunsInIde() { @@ -6067,6 +6134,223 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfiguration_StillAppliesMauiLaunchConfiguration(bool useContextOverload) + { + var builder = DistributedApplication.CreateBuilder(); + var projectBuilder = builder.AddProject("proj", launchProfileName: null); + var projectResource = projectBuilder.Resource; + var defaultDebugSupport = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); + if (defaultDebugSupport is not null) + { + projectBuilder.Resource.Annotations.Remove(defaultDebugSupport); + } + +#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + projectBuilder.Resource.Annotations.Add( + new ProjectLaunchArgsOverrideAnnotation( + ["build", "--no-restore", "/t:Run", "-p:NoBuild=true"], + leadingResourceArgumentToRemove: "run")); +#pragma warning restore ASPIREPROJECTS001 + + var producerInvocationCount = 0; + LaunchConfigurationCallbackContext? launchContext = null; + + if (useContextOverload) + { + projectBuilder.WithDebugSupport( + context => + { + Interlocked.Increment(ref producerInvocationCount); + launchContext = context; + return Task.FromResult(CreateMauiLaunchConfiguration(context.Mode)); + }, + "maui"); + } + else + { + projectBuilder.WithDebugSupport( + mode => + { + Interlocked.Increment(ref producerInvocationCount); + return CreateMauiLaunchConfiguration(mode); + }, + "maui"); + } + + projectBuilder.WithArgs("run", "-f", "net10.0-android"); + + var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["coreclr"], + SupportedLaunchConfigurations = ["maui"] + }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfo, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName }; + var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof(DcpExecutorTests).Assembly)?.Configuration; + var appExecutor = CreateAppExecutor( + app.Services.GetRequiredService(), + kubernetesService: kubernetesService, + configuration: configuration, + distributedApplicationOptions: distributedApplicationOptions); + + await appExecutor.RunApplicationAsync(); + + var executable = GetCreatedExecutableForResource(kubernetesService, "proj"); + Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType); + Assert.Equal(1, Volatile.Read(ref producerInvocationCount)); + var expectedArgs = new List + { + "build", + "--no-restore", + "/t:Run", + "-p:NoBuild=true", + "TestProject" + }; + if (!string.IsNullOrEmpty(expectedConfiguration)) + { + expectedArgs.AddRange(["--configuration", expectedConfiguration]); + } + expectedArgs.AddRange(["-f", "net10.0-android"]); + Assert.Equal(expectedArgs, executable.Spec.Args); + Assert.True(executable.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigurations)); + var launchConfiguration = Assert.Single(launchConfigurations); + Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); + Assert.Equal("/mauiapp/MauiApp.csproj", launchConfiguration.ProjectPath); + + if (useContextOverload) + { + Assert.NotNull(launchContext); + Assert.Equal(ExecutableLaunchMode.Debug, launchContext.Mode); + Assert.Same(projectResource, launchContext.Resource); + } + + static TestMauiLaunchConfiguration CreateMauiLaunchConfiguration(string mode) => new() + { + Mode = mode, + ProjectPath = "/mauiapp/MauiApp.csproj", + TargetFramework = "net10.0-android", + Platform = "android", + TargetKind = "emulator" + }; + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MauiProjectWithLaunchArgsOverride_LaunchConfigurationProducerThrows_RemainsInProcessExecution(bool useContextOverload) + { + // The launch override already provides a runnable Process command. A custom launch producer can add + // metadata in that mode, but a producer fault must not discard the process invocation. + var builder = DistributedApplication.CreateBuilder(); + var projectBuilder = builder.AddProject("proj", launchProfileName: null); + var defaultDebugSupport = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); + if (defaultDebugSupport is not null) + { + projectBuilder.Resource.Annotations.Remove(defaultDebugSupport); + } + +#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + projectBuilder.Resource.Annotations.Add( + new ProjectLaunchArgsOverrideAnnotation( + ["build", "--no-restore", "/t:Run", "-p:NoBuild=true"], + leadingResourceArgumentToRemove: "run")); +#pragma warning restore ASPIREPROJECTS001 + + var producerInvocationCount = 0; + if (useContextOverload) + { + projectBuilder.WithDebugSupport( + async Task (context) => + { + Interlocked.Increment(ref producerInvocationCount); + await Task.Yield(); + throw new InvalidOperationException("Test exception from async launch configuration producer"); + }, + "maui"); + } + else + { + projectBuilder.WithDebugSupport( + TestMauiLaunchConfiguration (mode) => + { + Interlocked.Increment(ref producerInvocationCount); + throw new InvalidOperationException("Test exception from launch configuration producer"); + }, + "maui"); + } + + projectBuilder.WithArgs("run", "-f", "net10.0-android"); + + var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["coreclr"], + SupportedLaunchConfigurations = ["maui"] + }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfo, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName }; + var appExecutor = CreateAppExecutor( + app.Services.GetRequiredService(), + kubernetesService: kubernetesService, + configuration: configuration, + distributedApplicationOptions: distributedApplicationOptions); + + await appExecutor.RunApplicationAsync(); + + var executable = GetCreatedExecutableForResource(kubernetesService, "proj"); + Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType); + Assert.Equal(1, Volatile.Read(ref producerInvocationCount)); + + var expectedArgs = new List + { + "build", + "--no-restore", + "/t:Run", + "-p:NoBuild=true", + "TestProject" + }; + if (GetTestAssemblyConfiguration() is { } configurationName) + { + expectedArgs.AddRange(["--configuration", configurationName]); + } + expectedArgs.AddRange(["-f", "net10.0-android"]); + Assert.Equal(expectedArgs, executable.Spec.Args); + } + [Fact] public async Task ProjectResource_CustomIdeLaunch_OwnedLaunchToolArgsPreserveLaunchProfileArgs() { @@ -6535,13 +6819,10 @@ public async Task ProjectExecutable_NoSupportsDebuggingAnnotation_InDebugSession } [Fact] - public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringPrepare() + public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate() { - // Regression guard for the async launch configuration producer. A ProjectResource has its "project" - // launch configuration applied while DCP objects are *prepared* (PrepareProjectExecutablesAsync), not - // when they are created, so this is the path that previously forced producers to be synchronous. - // A producer that genuinely suspends must still be awaited to completion before the Executable is - // handed to DCP; otherwise the annotation would be missing or hold an unresolved Task. + // Project launch configuration producers run after the execution configuration has been resolved. + // A producer that genuinely suspends must still complete before the executable is handed to DCP. var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions { AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName @@ -6549,7 +6830,7 @@ public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDu var projectBuilder = builder.AddProject("ServiceA", launchProfileName: null); projectBuilder.WithDebugSupport( - async (mode, ct) => + async (mode, _) => { // Yield so the producer completes asynchronously rather than returning an already-completed task. await Task.Yield(); @@ -6591,7 +6872,7 @@ public async Task PlainExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuri var debuggableExecutable = new TestExecutableResource("test-working-directory"); builder.AddResource(debuggableExecutable).WithDebugSupport( - async (mode, ct) => + async (mode, _) => { await Task.Yield(); return new ExecutableLaunchConfiguration("test") { Mode = mode }; @@ -6631,7 +6912,7 @@ public async Task PlainExecutable_AsyncLaunchConfigurationProducerFaults_FallsBa var debuggableExecutable = new TestExecutableResource("test-working-directory"); builder.AddResource(debuggableExecutable).WithDebugSupport( - async (mode, ct) => + async (_, _) => { await Task.Yield(); throw new InvalidOperationException("Test exception from async launch configuration producer"); @@ -8728,7 +9009,9 @@ public async Task PlainExecutable_LaunchConfigurationProducerThrows_FallsBackToP var builder = DistributedApplication.CreateBuilder(); var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport((_, _) => throw new InvalidOperationException("Test exception from launch configuration producer"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + _ => throw new InvalidOperationException("Test exception from launch configuration producer"), + "test"); var runSessionInfo = new RunSessionInfo { @@ -8826,7 +9109,7 @@ public async Task Project_NonProjectLaunchConfig_AnnotatorThrows_FallsBackToProc projectBuilder.Resource.Annotations.Remove(annotationToRemove); } projectBuilder.WithDebugSupport( - (_, _) => throw new InvalidOperationException("Test exception from launch configuration producer"), + _ => throw new InvalidOperationException("Test exception from launch configuration producer"), "azure-functions"); var configDict = new Dictionary @@ -9165,13 +9448,13 @@ private static DcpExecutor CreateAppExecutor( var nameGenerator = new DcpNameGenerator(configuration, Options.Create(dcpOptions)); var executionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) - { - Services = new TestServiceProvider(configuration) - .AddService(developerCertificateService) - .AddService(distributedAppModel) - .AddService(Options.Create(dcpOptions)) - .AddService(resourceLoggerService) - }); + { + Services = new TestServiceProvider(configuration) + .AddService(developerCertificateService) + .AddService(distributedAppModel) + .AddService(Options.Create(dcpOptions)) + .AddService(resourceLoggerService) + }); var ks = kubernetesService ?? new TestKubernetesService(); var dcpEvts = events ?? new DcpExecutorEvents(); var fileSystemService = new FileSystemService(configuration); diff --git a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs index 33fc53c8c5c..b567d0a1a94 100644 --- a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs @@ -4,9 +4,11 @@ #pragma warning disable ASPIREEXTENSION001 // Debug support APIs are experimental. #pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental. +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using Aspire.Hosting.Dcp; +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; @@ -15,13 +17,62 @@ namespace Aspire.Hosting.Tests; [Trait("Partition", "2")] public class DebugSupportExtensionsTests { + [Fact] + public void LaunchConfigurationCallbackContextExposesOnlyLaunchProducerInputs() + { + var contextType = typeof(LaunchConfigurationCallbackContext); + + Assert.Empty(contextType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)); + Assert.Equal( + [ + nameof(LaunchConfigurationCallbackContext.CancellationToken), + nameof(LaunchConfigurationCallbackContext.EnvironmentVariables), + nameof(LaunchConfigurationCallbackContext.Mode), + nameof(LaunchConfigurationCallbackContext.Resource) + ], + contextType.GetProperties().Select(property => property.Name).Order()); + Assert.All(contextType.GetProperties(), property => Assert.Null(property.SetMethod)); + } + + [Fact] + public async Task CreateLaunchConfigurationInspectionOverloadCreatesResourceBoundContext() + { + var inspectionOverload = typeof(DebugSupportExtensions).GetMethod( + nameof(DebugSupportExtensions.CreateLaunchConfigurationAsync), + BindingFlags.Public | BindingFlags.Static, + [typeof(IResource), typeof(string), typeof(CancellationToken)]); + + Assert.NotNull(inspectionOverload); + + using var builder = TestDistributedApplicationBuilder.Create(); + using var cts = new CancellationTokenSource(); + LaunchConfigurationCallbackContext? observedContext = null; + + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport(context => + { + observedContext = context; + return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }); + }, "go"); + + var launchConfiguration = Assert.IsType( + await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.NoDebug, cts.Token)); + + Assert.NotNull(observedContext); + Assert.Same(executable.Resource, observedContext.Resource); + Assert.Equal(ExecutableLaunchMode.NoDebug, observedContext.Mode); + Assert.Empty(observedContext.EnvironmentVariables); + Assert.Equal(cts.Token, observedContext.CancellationToken); + Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode); + } + [Fact] public async Task CreateLaunchConfigurationResolvesTheLaunchProfileForProjectResources() { using var builder = TestDistributedApplicationBuilder.Create(); var project = builder.AddProject("proj", launchProfileName: "http"); - var launchConfiguration = Assert.IsType(await project.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.Debug)); Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); Assert.Equal(GetProjectPath(project.Resource), launchConfiguration.ProjectPath); @@ -38,7 +89,7 @@ public async Task CreateLaunchConfigurationDisablesTheLaunchProfileWhenTheResour using var builder = TestDistributedApplicationBuilder.Create(); var project = builder.AddProject("proj", launchProfileName: null); - var launchConfiguration = Assert.IsType(await project.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.Debug)); Assert.True(launchConfiguration.DisableLaunchProfile); Assert.Equal(string.Empty, launchConfiguration.LaunchProfile); @@ -69,7 +120,7 @@ public async Task CreateLaunchConfigurationReturnsTheProducerOutputForACustomPro LaunchProfile = "https" }, KnownLaunchConfigurationTypes.Project); - var launchConfiguration = Assert.IsType(await project.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.NoDebug)); + var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.NoDebug)); Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode); Assert.Equal("custom-path", launchConfiguration.ProjectPath); @@ -83,7 +134,7 @@ public async Task CreateLaunchConfigurationReturnsTheProducerOutputForNonProject var executable = builder.AddExecutable("app", "go", ".") .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" }, "go"); - var launchConfiguration = Assert.IsType(await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.NoDebug)); + var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.NoDebug)); Assert.Equal("go", launchConfiguration.Type); Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode); @@ -97,13 +148,13 @@ public async Task CreateLaunchConfigurationAwaitsAnAsynchronousProducer() // themselves asynchronous (for example build-argument callbacks contributed by other annotations). using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport(async (mode, ct) => + .WithDebugSupport(async (mode, _) => { await Task.Yield(); return new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" }; }, "go"); - var launchConfiguration = Assert.IsType(await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); Assert.Equal("./cmd/api", launchConfiguration.Package); @@ -117,13 +168,13 @@ public async Task CreateLaunchConfigurationPropagatesTheCancellationTokenToThePr CancellationToken observedToken = default; var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport((mode, ct) => + .WithDebugSupport((mode, cancellationToken) => { - observedToken = ct; + observedToken = cancellationToken; return Task.FromResult(new TestGoLaunchConfiguration { Mode = mode }); }, "go"); - await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug, cts.Token); + await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug, cts.Token); Assert.Equal(cts.Token, observedToken); } @@ -134,7 +185,7 @@ public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoDebugSuppor using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", "."); - var exception = await Assert.ThrowsAsync(() => executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); Assert.Contains("does not declare debug launch support", exception.Message); } @@ -146,9 +197,11 @@ public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoProjectMeta // support without carrying metadata fails with a clear message rather than a sequence error. using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "dotnet", "."); - executable.WithDebugSupport(mode => ProjectLaunchConfigurationFactory.Create(executable.Resource, mode), KnownLaunchConfigurationTypes.Project); + executable.WithDebugSupport( + mode => ProjectLaunchConfigurationFactory.Create(executable.Resource, mode), + KnownLaunchConfigurationTypes.Project); - var exception = await Assert.ThrowsAsync(() => executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); Assert.Contains("has no project metadata", exception.Message); } @@ -156,14 +209,11 @@ public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoProjectMeta [Fact] public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsNull() { - // TLaunchConfiguration is unconstrained, so a producer for a reference type can legitimately - // return null. That must fail with a message that names the resource rather than flowing into - // the non-nullable Task result or writing a null entry into the DCP annotation. using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") .WithDebugSupport(_ => (TestGoLaunchConfiguration)null!, "go"); - var exception = await Assert.ThrowsAsync(() => executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); Assert.Contains("returned null", exception.Message); Assert.Contains("app", exception.Message); @@ -305,6 +355,19 @@ private static string CreateDebugSessionInfo(string[] supportedLaunchConfigurati private static string GetProjectPath(IResource resource) => resource.Annotations.OfType().Last().ProjectPath; + private static Task CreateLaunchConfigurationForTestAsync( + IResource resource, + string mode = ExecutableLaunchMode.Debug, + CancellationToken cancellationToken = default) + { + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + resource, + mode, + cancellationToken: cancellationToken); + + return resource.CreateLaunchConfigurationAsync(callbackContext); + } + private sealed class TestGoLaunchConfiguration() : ExecutableLaunchConfiguration("go") { [JsonPropertyName("package")] diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 3e964e463b5..61883eef31e 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -98,7 +98,11 @@ public async Task WithDebugSupportAddsAnnotationInRunMode() var annotation = executable.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); var exe = new Executable(new ExecutableSpec()); - await annotation.LaunchConfigurationAnnotator(exe, "NoDebug", CancellationToken.None); + await annotation.LaunchConfigurationAnnotator( + exe, + LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + ExecutableLaunchMode.NoDebug)); Assert.Equal("ms-python.python", annotation.LaunchConfigurationType); Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var annotations)); @@ -124,14 +128,20 @@ public async Task WithDebugSupportAsynchronousProducerProducesTheSameAnnotationA var syncExecutable = builder.AddExecutable("sync", "command", "workingdirectory") .WithDebugSupport(mode => new ExecutableLaunchConfiguration("go") { Mode = mode }, "go"); var asyncExecutable = builder.AddExecutable("async", "command", "workingdirectory") - .WithDebugSupport(async (mode, ct) => + .WithDebugSupport(async (mode, _) => { await Task.Yield(); return new ExecutableLaunchConfiguration("go") { Mode = mode }; }, "go"); - var syncConfiguration = Assert.IsType(await syncExecutable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); - var asyncConfiguration = Assert.IsType(await asyncExecutable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug)); + var syncConfiguration = Assert.IsType( + await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync( + syncExecutable.Resource, + LaunchConfigurationTestHelpers.CreateCallbackContext(syncExecutable.Resource))); + var asyncConfiguration = Assert.IsType( + await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync( + asyncExecutable.Resource, + LaunchConfigurationTestHelpers.CreateCallbackContext(asyncExecutable.Resource))); Assert.Equal(asyncConfiguration.Type, syncConfiguration.Type); Assert.Equal(asyncConfiguration.Mode, syncConfiguration.Mode); @@ -150,7 +160,7 @@ public void WithDebugSupportRejectsATaskReturningSynchronousProducer() () => executable.WithDebugSupport(mode => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go")); Assert.Equal("launchConfigurationProducer", exception.ParamName); - Assert.Contains(nameof(CancellationToken), exception.Message); + Assert.Equal(CreateAsyncProducerGuardMessage(typeof(Task), "launchConfigurationProducer"), exception.Message); } [Fact] @@ -163,6 +173,7 @@ public void WithDebugSupportRejectsAValueTaskReturningSynchronousProducer() () => executable.WithDebugSupport(mode => ValueTask.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go")); Assert.Equal("launchConfigurationProducer", exception.ParamName); + Assert.Equal(CreateAsyncProducerGuardMessage(typeof(ValueTask), "launchConfigurationProducer"), exception.Message); } [Fact] @@ -317,6 +328,13 @@ public void WithLaunchToolArgsAreOwnedByMatchingLaunchConfigurationType() annotation => Assert.False(executable.Resource.HasLaunchToolArgsOwnedBy(annotation))); } + private static string CreateAsyncProducerGuardMessage(Type producerReturnType, string parameterName) + { + var guidance = $"The launch configuration producer returns '{producerReturnType}'. An asynchronous producer must bind to an asynchronous {nameof(ResourceBuilderExtensions.WithDebugSupport)} overload either by accepting the launch mode and a {nameof(CancellationToken)} or by accepting a {nameof(LaunchConfigurationCallbackContext)}; otherwise the task itself is used as the launch configuration."; + + return new ArgumentException(guidance, parameterName).Message; + } + [Fact] public void WithDebugSupportDoesNotOwnLaunchToolArgsWithoutWithLaunchToolArgs() {