diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 5a76c0441de..9a6e2903945 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -966,31 +966,31 @@ private async Task CreateExecutableAsync(AppResource er, ILogger resourceLogger, } var spec = exe.Spec; + // Don't create an args collection unless needed. A null args collection means a project run by the will use args provided by the launch profile. + // https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#launch-profile-processing-project-launch-configuration + spec.Args = null; + // An executable can be restarted so args must be reset to an empty state. // After resetting, first apply any dotnet project related args, e.g. configuration, and then add args from the model resource. - spec.Args = []; - if (er.DcpResource.TryGetAnnotationAsObjectList(CustomResource.ResourceProjectArgsAnnotation, out var projectArgs)) + if (er.DcpResource.TryGetAnnotationAsObjectList(CustomResource.ResourceProjectArgsAnnotation, out var projectArgs) && projectArgs.Count > 0) { + spec.Args ??= []; spec.Args.AddRange(projectArgs); } - var launchArgs = new List<(string Value, bool IsSensitive, bool AnnotationOnly)>(); + // Get args from app host model resource. + (var appHostArgs, var failedToApplyArgs) = await BuildArgsAsync(resourceLogger, er.ModelResource, cancellationToken).ConfigureAwait(false); - // If the executable is a project then include any command line args from the launch profile. - if (er.ModelResource is ProjectResource project) - { - // When the .NET project is launched from an IDE the launch profile args are automatically added. - // We still want to display the args in the dashboard so only add them to the custom arg annotations. - var annotationOnly = spec.ExecutionType == ExecutionType.IDE; + var launchArgs = BuildLaunchArgs(er, spec, appHostArgs); - var launchProfileArgs = GetLaunchProfileArgs(project.GetEffectiveLaunchProfile()?.LaunchProfile); - launchArgs.AddRange(launchProfileArgs.Select(a => (a, isSensitive: false, annotationOnly))); + var executableArgs = launchArgs.Where(a => !a.AnnotationOnly).Select(a => a.Value).ToList(); + if (executableArgs.Count > 0) + { + spec.Args ??= []; + spec.Args.AddRange(executableArgs); } - (var args, var failedToApplyArgs) = await BuildArgsAsync(resourceLogger, er.ModelResource, cancellationToken).ConfigureAwait(false); - launchArgs.AddRange(args.Select(a => (a.Value, a.IsSensitive, annotationOnly: false))); - - spec.Args.AddRange(launchArgs.Where(a => !a.AnnotationOnly).Select(a => a.Value)); + // Arg annotations are what is displayed in the dashboard. er.DcpResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, launchArgs.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); (spec.Env, var failedToApplyConfiguration) = await BuildEnvVarsAsync(resourceLogger, er.ModelResource, cancellationToken).ConfigureAwait(false); @@ -1003,6 +1003,37 @@ private async Task CreateExecutableAsync(AppResource er, ILogger resourceLogger, await _kubernetesService.CreateAsync(exe, cancellationToken).ConfigureAwait(false); } + private static List<(string Value, bool IsSensitive, bool AnnotationOnly)> BuildLaunchArgs(AppResource er, ExecutableSpec spec, List<(string Value, bool IsSensitive)> appHostArgs) + { + // Launch args is the final list of args that are displayed in the UI and possibly added to the executable spec. + // They're built from app host resource model args and any args in the effective launch profile. + // Follows behavior in the IDE execution spec when in IDE execution mode: + // https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#launch-profile-processing-project-launch-configuration + var launchArgs = new List<(string Value, bool IsSensitive, bool AnnotationOnly)>(); + + // If the executable is a project then include any command line args from the launch profile. + if (er.ModelResource is ProjectResource project) + { + // Args in the launch profile is used when: + // 1. The project is run as an executable. Launch profile args are combined with app host supplied args. + // 2. The project is run by the IDE and no app host args are specified. + if (spec.ExecutionType == ExecutionType.Process || (spec.ExecutionType == ExecutionType.IDE && appHostArgs.Count == 0)) + { + // When the .NET project is launched from an IDE the launch profile args are automatically added. + // We still want to display the args in the dashboard so only add them to the custom arg annotations. + var annotationOnly = spec.ExecutionType == ExecutionType.IDE; + + var launchProfileArgs = GetLaunchProfileArgs(project.GetEffectiveLaunchProfile()?.LaunchProfile); + launchArgs.AddRange(launchProfileArgs.Select(a => (a, isSensitive: false, annotationOnly))); + } + } + + // In the situation where args are combined (process execution) the app host args are added after the launch profile args. + launchArgs.AddRange(appHostArgs.Select(a => (a.Value, a.IsSensitive, annotationOnly: false))); + + return launchArgs; + } + private static List GetLaunchProfileArgs(LaunchProfile? launchProfile) { var args = new List(); diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index ff0327f96e5..91a9306bd16 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -106,9 +106,11 @@ public async Task ResourceStarted_ProjectHasReplicas_EventRaisedOnce() } [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAdded(bool isIDE) + [InlineData(ExecutionType.IDE, false, null, new string[] { "--", "--test1", "--test2" })] + [InlineData(ExecutionType.IDE, true, new string[] { "--withargs-test" }, new string[] { "--withargs-test" })] + [InlineData(ExecutionType.Process, false, new string[] { "--", "--test1", "--test2" }, new string[] { "--", "--test1", "--test2" })] + [InlineData(ExecutionType.Process, true, new string[] { "--", "--test1", "--test2", "--withargs-test" }, new string[] { "--", "--test1", "--test2", "--withargs-test" })] + public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAdded(string executionType, bool addAppHostArgs, string[]? expectedArgs, string[]? expectedAnnotations) { var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions { @@ -116,7 +118,7 @@ public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAd }); IConfiguration? configuration = null; - if (isIDE) + if (executionType == ExecutionType.IDE) { var configurationBuilder = new ConfigurationBuilder(); configurationBuilder.AddInMemoryCollection(new Dictionary @@ -127,11 +129,15 @@ public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAd configuration = configurationBuilder.Build(); } - var resource = builder.AddProject("ServiceA") - .WithArgs(c => - { - c.Args.Add("--withargs-test"); - }).Resource; + var resourceBuilder = builder.AddProject("ServiceA"); + if (addAppHostArgs) + { + resourceBuilder + .WithArgs(c => + { + c.Args.Add("--withargs-test"); + }); + } var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -148,30 +154,12 @@ public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAd var exe = Assert.Single(executables); - if (isIDE) - { - var callArg = Assert.Single(exe.Spec.Args!); - Assert.Equal("--withargs-test", callArg); - } - else - { - // ignore dotnet specific args for .NET project - var callArgs = exe.Spec.Args![^4..]; - - Assert.Collection(callArgs, - a => Assert.Equal("--", a), - a => Assert.Equal("--test1", a), - a => Assert.Equal("--test2", a), - a => Assert.Equal("--withargs-test", a)); - } + // Ignore dotnet specific args for .NET project in process execution. + var callArgs = executionType == ExecutionType.IDE ? exe.Spec.Args : exe.Spec.Args![^(expectedArgs?.Length ?? 0)..]; + Assert.Equal(expectedArgs, callArgs); Assert.True(exe.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations)); - - Assert.Collection(argAnnotations, - a => Assert.Equal("--", a.Argument), - a => Assert.Equal("--test1", a.Argument), - a => Assert.Equal("--test2", a.Argument), - a => Assert.Equal("--withargs-test", a.Argument)); + Assert.Equal(expectedAnnotations, argAnnotations.Select(a => a.Argument)); } [Fact]