diff --git a/src/Aspire.Dashboard/Components/Controls/GridValue.razor b/src/Aspire.Dashboard/Components/Controls/GridValue.razor index 8d3266d1c02..ec153c3d6e5 100644 --- a/src/Aspire.Dashboard/Components/Controls/GridValue.razor +++ b/src/Aspire.Dashboard/Components/Controls/GridValue.razor @@ -10,7 +10,7 @@ @if (EnableMasking && IsMasked) { - @DashboardUIHelpers.GetMaskingText(length: 8) + @DashboardUIHelpers.GetMaskingText(length: 8).MarkupString } else @@ -32,7 +32,7 @@ @((MarkupString)_formattedValue) } @ContentAfterValue - } + } } diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor index de5f66a1637..73f47952bb0 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/SourceColumnDisplay.razor @@ -24,7 +24,7 @@ } else { -  @DashboardUIHelpers.GetMaskingText(length: 6) +  @DashboardUIHelpers.GetMaskingText(length: 6).MarkupString } } } diff --git a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs index 83251977e0b..788c8e56e7d 100644 --- a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Utils; + namespace Aspire.Dashboard.Model; public class ResourceSourceViewModel(string value, List? contentAfterValue, string valueToVisualize, string tooltip) @@ -12,65 +14,22 @@ public class ResourceSourceViewModel(string value, List? content internal static ResourceSourceViewModel? GetSourceViewModel(ResourceViewModel resource) { - (List? Arguments, string? ArgumentsString) commandLineInfo; - - // If the resource contains launch arguments, these project arguments should be shown in place of all executable arguments, - // which include args added by the app host - if (resource.TryGetAppArgs(out var launchArguments)) - { - if (launchArguments.IsDefaultOrEmpty) - { - commandLineInfo = (null, null); - } - else - { - var argumentsString = string.Join(" ", launchArguments); - if (resource.TryGetAppArgsSensitivity(out var areArgumentsSensitive)) - { - var arguments = launchArguments - .Select((arg, i) => new LaunchArgument(arg, IsShown: !areArgumentsSensitive[i])) - .ToList(); - - commandLineInfo = (Arguments: arguments, argumentsString); - } - else - { - commandLineInfo = (Arguments: launchArguments.Select(arg => new LaunchArgument(arg, true)).ToList(), argumentsString); - } - } - } - else if (resource.TryGetExecutableArguments(out var executableArguments) && !resource.IsProject()) - { - var arguments = executableArguments.IsDefaultOrEmpty ? null : executableArguments.Select(arg => new LaunchArgument(arg, true)).ToList(); - commandLineInfo = (Arguments: arguments, string.Join(' ', executableArguments)); - } - else - { - commandLineInfo = (Arguments: null, null); - } + var commandLineInfo = GetCommandLineInfo(resource); // NOTE projects are also executables, so we have to check for projects first if (resource.IsProject() && resource.TryGetProjectPath(out var projectPath)) { - if (commandLineInfo is { Arguments: { } arguments, ArgumentsString: { } fullCommandLine }) - { - return new ResourceSourceViewModel(value: Path.GetFileName(projectPath), contentAfterValue: arguments, valueToVisualize: $"{projectPath} {fullCommandLine}", tooltip: $"{projectPath} {fullCommandLine}"); - } - - // default to project path if there is no executable path or executable arguments - return new ResourceSourceViewModel(value: Path.GetFileName(projectPath), contentAfterValue: commandLineInfo.Arguments, valueToVisualize: projectPath, tooltip: projectPath); + return CreateResourceSourceViewModel(Path.GetFileName(projectPath), projectPath, commandLineInfo); } if (resource.TryGetExecutablePath(out var executablePath)) { - var fullSource = commandLineInfo.ArgumentsString is not null ? $"{executablePath} {commandLineInfo.ArgumentsString}" : executablePath; - return new ResourceSourceViewModel(value: Path.GetFileName(executablePath), contentAfterValue: commandLineInfo.Arguments, valueToVisualize: fullSource, tooltip: fullSource); + return CreateResourceSourceViewModel(Path.GetFileName(executablePath), executablePath, commandLineInfo); } if (resource.TryGetContainerImage(out var containerImage)) { - var fullSource = commandLineInfo.ArgumentsString is null ? containerImage : $"{containerImage} {commandLineInfo.ArgumentsString}"; - return new ResourceSourceViewModel(value: containerImage, contentAfterValue: commandLineInfo.Arguments, valueToVisualize: fullSource, tooltip: fullSource); + return CreateResourceSourceViewModel(containerImage, containerImage, commandLineInfo); } if (resource.Properties.TryGetValue(KnownProperties.Resource.Source, out var property) && property.Value is { HasStringValue: true, StringValue: var value }) @@ -79,7 +38,56 @@ public class ResourceSourceViewModel(string value, List? content } return null; + + static CommandLineInfo? GetCommandLineInfo(ResourceViewModel resourceViewModel) + { + // If the resource contains launch arguments, these project arguments should be shown in place of all executable arguments, + // which include args added by the app host + if (resourceViewModel.TryGetAppArgs(out var launchArguments)) + { + if (launchArguments.IsDefaultOrEmpty) + { + return null; + } + + var argumentsString = string.Join(" ", launchArguments); + if (resourceViewModel.TryGetAppArgsSensitivity(out var areArgumentsSensitive)) + { + var arguments = launchArguments + .Select((arg, i) => new LaunchArgument(arg, IsShown: !areArgumentsSensitive[i])) + .ToList(); + + return new CommandLineInfo( + Arguments: arguments, + ArgumentsString: argumentsString, + TooltipString: string.Join(" ", arguments.Select(arg => arg.IsShown + ? arg.Value + : DashboardUIHelpers.GetMaskingText(6).Text))); + } + + return new CommandLineInfo(Arguments: launchArguments.Select(arg => new LaunchArgument(arg, true)).ToList(), ArgumentsString: argumentsString, TooltipString: argumentsString); + } + + if (resourceViewModel.TryGetExecutableArguments(out var executableArguments) && !resourceViewModel.IsProject()) + { + var arguments = executableArguments.IsDefaultOrEmpty ? [] : executableArguments.Select(arg => new LaunchArgument(arg, true)).ToList(); + var argumentsString = string.Join(" ", executableArguments); + + return new CommandLineInfo(Arguments: arguments, ArgumentsString: argumentsString, TooltipString: argumentsString); + } + + return null; + } + + static ResourceSourceViewModel CreateResourceSourceViewModel(string value, string path, CommandLineInfo? commandLineInfo) + { + return commandLineInfo is not null + ? new ResourceSourceViewModel(value: value, contentAfterValue: commandLineInfo.Arguments, valueToVisualize: $"{path} {commandLineInfo.ArgumentsString}", tooltip: $"{path} {commandLineInfo.TooltipString}") + : new ResourceSourceViewModel(value: value, contentAfterValue: null, valueToVisualize: path, tooltip: path); + } } + + private record CommandLineInfo(List Arguments, string ArgumentsString, string TooltipString); } public record LaunchArgument(string Value, bool IsShown); diff --git a/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs b/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs index 6512cf5cfee..800b5e5535a 100644 --- a/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs +++ b/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs @@ -43,16 +43,25 @@ public static (ColumnResizeLabels resizeLabels, ColumnSortLabels sortLabels) Cre return (resizeLabels, sortLabels); } - private static readonly ConcurrentDictionary s_cachedMasking = new(); + private static readonly ConcurrentDictionary s_cachedMasking = new(); - public static MarkupString GetMaskingText(int length) + public static TextMask GetMaskingText(int length) { - return new MarkupString(s_cachedMasking.GetOrAdd(length, static i => + return s_cachedMasking.GetOrAdd(length, static i => { - const string maskingChar = "●"; - return new StringBuilder(maskingChar.Length * i) - .Insert(0, maskingChar, i) - .ToString(); - })); + const string markupMaskingChar = "●"; + const string textMaskingChar = "●"; + + return new TextMask( + new MarkupString(Repeat(markupMaskingChar, i)), + Repeat(textMaskingChar, i) + ); + + static string Repeat(string s, int n) => new StringBuilder(s.Length * n) + .Insert(0, s, n) + .ToString(); + }); } } + +internal record TextMask(MarkupString MarkupString, string Text); diff --git a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs index ed165bd2c4c..37812d4c323 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs @@ -94,7 +94,10 @@ async Task EvalExpressionAsync(ReferenceExpression expr) } } - return new(string.Format(CultureInfo.InvariantCulture, expr.Format, args), isSensitive); + // Identically to ReferenceExpression.GetValueAsync, we return null if the format is empty + var value = expr.Format.Length == 0 ? null : string.Format(CultureInfo.InvariantCulture, expr.Format, args); + + return new ResolvedValue(value, isSensitive); } async Task EvalValueProvider(IValueProvider vp) @@ -148,22 +151,29 @@ async Task EvalValueProvider(IValueProvider vp) return new ResolvedValue(value, false); } + async Task ResolveConnectionStringReferenceAsync(ConnectionStringReference cs) + { + // We are substituting our own logic for ConnectionStringReference's GetValueAsync. + // However, ConnectionStringReference#GetValueAsync will throw if the connection string is not optional but is not present. + // To avoid duplicating that logic, we can defer to ConnectionStringReference#GetValueAsync, which will throw if needed. + await ((IValueProvider)cs).GetValueAsync(cancellationToken).ConfigureAwait(false); + + return await ResolveInternalAsync(cs.Resource.ConnectionStringExpression).ConfigureAwait(false); + } + /// - /// Resolve an expression when it is being used from inside a container. - /// So it's either a container-to-container or container-to-exe communication. + /// Resolve an expression. When it is being used from inside a container, endpoints may be evaluated (either in a container-to-container or container-to-exe communication). /// async ValueTask ResolveInternalAsync(object? value) { - return (value, sourceIsContainer) switch + return value switch { - (ConnectionStringReference cs, true) => await ResolveInternalAsync(cs.Resource.ConnectionStringExpression).ConfigureAwait(false), - (IResourceWithConnectionString cs, true) => await ResolveInternalAsync(cs.ConnectionStringExpression).ConfigureAwait(false), - (ReferenceExpression ex, false) => await EvalExpressionAsync(ex).ConfigureAwait(false), - (ReferenceExpression ex, true) => await EvalExpressionAsync(ex).ConfigureAwait(false), - (EndpointReference endpointReference, true) => new(await EvalEndpointAsync(endpointReference, EndpointProperty.Url).ConfigureAwait(false), false), - (EndpointReferenceExpression ep, true) => new(await EvalEndpointAsync(ep.Endpoint, ep.Property).ConfigureAwait(false), false), - (IValueProvider vp, false) => await EvalValueProvider(vp).ConfigureAwait(false), - (IValueProvider vp, true) => await EvalValueProvider(vp).ConfigureAwait(false), + ConnectionStringReference cs => await ResolveConnectionStringReferenceAsync(cs).ConfigureAwait(false), + IResourceWithConnectionString cs and not ResourceWithConnectionStringSurrogate => await ResolveInternalAsync(cs.ConnectionStringExpression).ConfigureAwait(false), + ReferenceExpression ex => await EvalExpressionAsync(ex).ConfigureAwait(false), + EndpointReference endpointReference when sourceIsContainer => new ResolvedValue(await EvalEndpointAsync(endpointReference, EndpointProperty.Url).ConfigureAwait(false), false), + EndpointReferenceExpression ep when sourceIsContainer => new ResolvedValue(await EvalEndpointAsync(ep.Endpoint, ep.Property).ConfigureAwait(false), false), + IValueProvider vp => await EvalValueProvider(vp).ConfigureAwait(false), _ => throw new NotImplementedException() }; } diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index c9ffc41ba63..5d6d98cd43e 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -55,6 +55,7 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri /// public async ValueTask GetValueAsync(CancellationToken cancellationToken) { + // NOTE: any logical changes to this method should also be made to ExpressionResolver.EvalExpressionAsync if (Format.Length == 0) { return null; diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs index e6d89958c42..a3ecec34f29 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Model; +using Aspire.Dashboard.Utils; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging.Abstractions; @@ -80,21 +81,22 @@ void AddStringProperty(string propertyName, string? propertyValue) valueToVisualize: "path/to/project arg2", tooltip: "path/to/project arg2")); + var maskingText = DashboardUIHelpers.GetMaskingText(6).Text; // Project with app arguments, as well as a secret (format argument) data.Add(new TestData( ResourceType: "Project", ExecutablePath: "path/to/executable", ExecutableArguments: ["arg1", "arg2"], - AppArgs: ["arg2", "--key", "secret"], - AppArgsSensitivity: [false, false, true], + AppArgs: ["arg2", "--key", "secret", "secret2", "notsecret"], + AppArgsSensitivity: [false, false, true, true, false], ProjectPath: "path/to/project", ContainerImage: null, SourceProperty: null), new ResourceSourceViewModel( value: "project", - contentAfterValue: [new LaunchArgument("arg2", true), new LaunchArgument("--key", true), new LaunchArgument("secret", false)], - valueToVisualize: "path/to/project arg2 --key secret", - tooltip: "path/to/project arg2 --key secret")); + contentAfterValue: [new LaunchArgument("arg2", true), new LaunchArgument("--key", true), new LaunchArgument("secret", false), new LaunchArgument("secret2", false), new LaunchArgument("notsecret", true)], + valueToVisualize: "path/to/project arg2 --key secret secret2 notsecret", + tooltip: $"path/to/project arg2 --key {maskingText} {maskingText} notsecret")); // Project without executable arguments data.Add(new TestData( diff --git a/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs b/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs index 50260cc8d14..669be071cfa 100644 --- a/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs +++ b/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs @@ -9,6 +9,49 @@ namespace Aspire.Hosting.Tests; public class ExpressionResolverTests { + [Theory] + [MemberData(nameof(ResolveInternalAsync_ResolvesCorrectly_MemberData))] + public async Task ResolveInternalAsync_ResolvesCorrectly(ExpressionResolverTestData testData, Type? exceptionType, (string Value, bool IsSensitive)? expectedValue) + { + if (exceptionType is not null) + { + await Assert.ThrowsAsync(exceptionType, ResolveAsync); + } + else + { + var resolvedValue = await ExpressionResolver.ResolveAsync(testData.SourceIsContainer, testData.ValueProvider, string.Empty, CancellationToken.None); + + Assert.Equal(expectedValue?.Value, resolvedValue.Value); + Assert.Equal(expectedValue?.IsSensitive, resolvedValue.IsSensitive); + } + + async Task ResolveAsync() => await ExpressionResolver.ResolveAsync(testData.SourceIsContainer, testData.ValueProvider, string.Empty, CancellationToken.None); + } + + public static TheoryData ResolveInternalAsync_ResolvesCorrectly_MemberData() + { + var data = new TheoryData(); + + // doesn't differ by sourceIsContainer + data.Add(new ExpressionResolverTestData(false, new ConnectionStringReference(new TestExpressionResolverResource("Empty"), false)), typeof(DistributedApplicationException), null); + data.Add(new ExpressionResolverTestData(false, new ConnectionStringReference(new TestExpressionResolverResource("Empty"), true)), null, (null, false)); + data.Add(new ExpressionResolverTestData(true, new ConnectionStringReference(new TestExpressionResolverResource("String"), true)), null, ("String", false)); + data.Add(new ExpressionResolverTestData(true, new ConnectionStringReference(new TestExpressionResolverResource("SecretParameter"), false)), null, ("SecretParameter", true)); + + // IResourceWithConnectionString resolves differently for ResourceWithConnectionStringSurrogate (as a secret parameter) + data.Add(new ExpressionResolverTestData(false, new ResourceWithConnectionStringSurrogate("SurrogateResource", _ => "SurrogateResource", null)), null, ("SurrogateResource", true)); + data.Add(new ExpressionResolverTestData(false, new TestExpressionResolverResource("String")), null, ("String", false)); + + data.Add(new ExpressionResolverTestData(false, new ParameterResource("SecretParameter", _ => "SecretParameter", secret: true)), null, ("SecretParameter", true)); + data.Add(new ExpressionResolverTestData(false, new ParameterResource("NonSecretParameter", _ => "NonSecretParameter", secret: false)), null, ("NonSecretParameter", false)); + + // ExpressionResolverGeneratesCorrectEndpointStrings separately tests EndpointReference and EndpointReferenceExpression + + return data; + } + + public record ExpressionResolverTestData(bool SourceIsContainer, IValueProvider ValueProvider); + [Theory] [InlineData("TwoFullEndpoints", false, false, "Test1=http://127.0.0.1:12345/;Test2=https://localhost:12346/;")] [InlineData("TwoFullEndpoints", false, true, "Test1=http://127.0.0.1:12345/;Test2=https://localhost:12346/;")] @@ -28,7 +71,7 @@ public class ExpressionResolverTests [InlineData("PortBeforeHost", true, true, "Port=10000;Host=testresource;")] [InlineData("FullAndPartial", true, false, "Test1=http://ContainerHostName:12345/;Test2=https://localhost:12346/;")] [InlineData("FullAndPartial", true, true, "Test1=http://testresource:10000/;Test2=https://localhost:12346/;")] // Second port not replaced since host is hard coded - public async Task ExpressionResolverGeneratesCorrectStrings(string exprName, bool sourceIsContainer, bool targetIsContainer, string expectedConnectionString) + public async Task ExpressionResolverGeneratesCorrectEndpointStrings(string exprName, bool sourceIsContainer, bool targetIsContainer, string expectedConnectionString) { var builder = DistributedApplication.CreateBuilder(); @@ -118,6 +161,14 @@ public async Task HostUrlPropertyGetsResolvedInOtlpExporterEndpoint(bool contain } } +sealed class TestValueProviderResource(string name) : Resource(name), IValueProvider +{ + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(base.Name); + } +} + sealed class TestExpressionResolverResource : ContainerResource, IResourceWithEndpoints, IResourceWithConnectionString { readonly string _exprName; @@ -136,7 +187,11 @@ public TestExpressionResolverResource(string exprName) : base("testresource") { "OnlyHost", ReferenceExpression.Create($"Host={Endpoint1.Property(EndpointProperty.Host)};") }, { "OnlyPort", ReferenceExpression.Create($"Port={Endpoint1.Property(EndpointProperty.Port)};") }, { "PortBeforeHost", ReferenceExpression.Create($"Port={Endpoint1.Property(EndpointProperty.Port)};Host={Endpoint1.Property(EndpointProperty.Host)};") }, - { "FullAndPartial", ReferenceExpression.Create($"Test1={Endpoint1.Property(EndpointProperty.Scheme)}://{Endpoint1.Property(EndpointProperty.IPV4Host)}:{Endpoint1.Property(EndpointProperty.Port)}/;Test2={Endpoint2.Property(EndpointProperty.Scheme)}://localhost:{Endpoint2.Property(EndpointProperty.Port)}/;") } + { "FullAndPartial", ReferenceExpression.Create($"Test1={Endpoint1.Property(EndpointProperty.Scheme)}://{Endpoint1.Property(EndpointProperty.IPV4Host)}:{Endpoint1.Property(EndpointProperty.Port)}/;Test2={Endpoint2.Property(EndpointProperty.Scheme)}://localhost:{Endpoint2.Property(EndpointProperty.Port)}/;") }, + { "Empty", ReferenceExpression.Create($"") }, + { "String", ReferenceExpression.Create($"String") }, + { "SecretParameter", ReferenceExpression.Create("SecretParameter", [new ParameterResource("SecretParameter", _ => "SecretParameter", secret: true)], []) }, + { "NonSecretParameter", ReferenceExpression.Create("NonSecretParameter", [new ParameterResource("NonSecretParameter", _ => "NonSecretParameter", secret: false)], []) } }; }