diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index fe6616d4232..c90b8453a09 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -51,27 +51,74 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService) } public bool TryResolvePeerName(KeyValuePair[] attributes, [NotNullWhen(true)] out string? name) + { + return TryResolvePeerNameCore(_resourceByName, attributes, out name); + } + + internal static bool TryResolvePeerNameCore(IDictionary resources, KeyValuePair[] attributes, out string? name) { var address = OtlpHelpers.GetValue(attributes, OtlpSpan.PeerServiceAttributeKey); if (address != null) { - foreach (var (resourceName, resource) in _resourceByName) + // Match exact value. + if (TryMatchResourceAddress(address, out name)) + { + return true; + } + + // Resource addresses have the format "127.0.0.1:5000". Some libraries modify the peer.service value on the span. + // If there isn't an exact match then transform the peer.service value and try to match again. + // Change from transformers are cumulative. e.g. "localhost,5000" -> "localhost:5000" -> "127.0.0.1:5000" + var transformedAddress = address; + foreach (var transformer in s_addressTransformers) + { + transformedAddress = transformer(transformedAddress); + if (TryMatchResourceAddress(transformedAddress, out name)) + { + return true; + } + } + } + + name = null; + return false; + + bool TryMatchResourceAddress(string value, [NotNullWhen(true)] out string? name) + { + foreach (var (resourceName, resource) in resources) { foreach (var service in resource.Services) { - if (string.Equals(service.AddressAndPort, address, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(service.AddressAndPort, value, StringComparison.OrdinalIgnoreCase)) { name = resource.Name; return true; } } } - } - name = null; - return false; + name = null; + return false; + } } + private static readonly List> s_addressTransformers = [ + s => + { + // SQL Server uses comma instead of colon for port. + // https://www.connectionstrings.com/sql-server/ + if (s.AsSpan().Count(',') == 1) + { + return s.Replace(',', ':'); + } + return s; + }, + s => + { + // Some libraries use "127.0.0.1" instead of "localhost". + return s.Replace("127.0.0.1:", "localhost:"); + }]; + public IDisposable OnPeerChanges(Func callback) { lock (_lock) diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs new file mode 100644 index 00000000000..529cd73b747 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Frozen; +using System.Collections.Immutable; +using Aspire.Dashboard.Model; +using Google.Protobuf.WellKnownTypes; +using Xunit; + +namespace Aspire.Dashboard.Tests; + +public class ResourceOutgoingPeerResolverTests +{ + private static ResourceViewModel CreateResource(string name, string? serviceAddress = null, int? servicePort = null) + { + ImmutableArray resourceServices = serviceAddress is null || servicePort is null + ? ImmutableArray.Empty + : [new ResourceServiceViewModel("http", serviceAddress, servicePort)]; + + return new ResourceViewModel + { + Name = name, + ResourceType = "Container", + DisplayName = name, + Uid = Guid.NewGuid().ToString(), + CreationTimeStamp = DateTime.UtcNow, + Environment = ImmutableArray.Empty, + Endpoints = ImmutableArray.Empty, + Services = resourceServices, + ExpectedEndpointsCount = 0, + Properties = FrozenDictionary.Empty, + State = null + }; + } + + [Fact] + public void EmptyAttributes_NoMatch() + { + // Arrange + var resources = new Dictionary + { + ["test"] = CreateResource("test", "localhost", 5000) + }; + + // Act & Assert + Assert.False(ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, [], out _)); + } + + [Fact] + public void EmptyUrlAttribute_NoMatch() + { + // Arrange + var resources = new Dictionary + { + ["test"] = CreateResource("test", "localhost", 5000) + }; + + // Act & Assert + Assert.False(ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, [KeyValuePair.Create("peer.service", "")], out _)); + } + + [Fact] + public void NullUrlAttribute_NoMatch() + { + // Arrange + var resources = new Dictionary + { + ["test"] = CreateResource("test", "localhost", 5000) + }; + + // Act & Assert + Assert.False(ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, [KeyValuePair.Create("peer.service", null!)], out _)); + } + + [Fact] + public void ExactValueAttribute_Match() + { + // Arrange + var resources = new Dictionary + { + ["test"] = CreateResource("test", "localhost", 5000) + }; + + // Act & Assert + Assert.True(ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, [KeyValuePair.Create("peer.service", "localhost:5000")], out var value)); + Assert.Equal("test", value); + } + + [Fact] + public void NumberAddressValueAttribute_Match() + { + // Arrange + var resources = new Dictionary + { + ["test"] = CreateResource("test", "localhost", 5000) + }; + + // Act & Assert + Assert.True(ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, [KeyValuePair.Create("peer.service", "127.0.0.1:5000")], out var value)); + Assert.Equal("test", value); + } + + [Fact] + public void CommaAddressValueAttribute_Match() + { + // Arrange + var resources = new Dictionary + { + ["test"] = CreateResource("test", "localhost", 5000) + }; + + // Act & Assert + Assert.True(ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, [KeyValuePair.Create("peer.service", "127.0.0.1,5000")], out var value)); + Assert.Equal("test", value); + } +}