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)], []) }
};
}