diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index 15b9401fef0..500036fc27c 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -144,35 +144,10 @@ private static bool ArePropertyValuesEquivalent(ResourceViewModel resource1, Res public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResource) { - var address = OtlpHelpers.GetPeerAddress(attributes); - if (address != null) - { - // Apply transformers to the peer address cumulatively - var transformedAddress = address; - - // First check exact match - if (TryMatchAgainstResources(transformedAddress, _resourceByName, out name, out matchedResource)) - { - return true; - } - - // Then apply each transformer cumulatively and check - foreach (var transformer in s_addressTransformers) - { - transformedAddress = transformer(transformedAddress); - if (TryMatchAgainstResources(transformedAddress, _resourceByName, out name, out matchedResource)) - { - return true; - } - } - } - - name = null; - matchedResource = null; - return false; + return TryResolvePeerCore(_resourceByName, attributes, out name, out matchedResource); } - internal static bool TryResolvePeerNameCore(IDictionary resources, KeyValuePair[] attributes, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch) + internal static bool TryResolvePeerCore(IDictionary resources, KeyValuePair[] attributes, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch) { var address = OtlpHelpers.GetPeerAddress(attributes); if (address != null) @@ -205,22 +180,43 @@ internal static bool TryResolvePeerNameCore(IDictionary /// Checks if a transformed peer address matches any of the resource addresses using their cached addresses. /// Applies the same transformations to resource addresses for consistent matching. + /// Returns true only if exactly one resource matches; false if no matches or multiple matches are found. /// private static bool TryMatchAgainstResources(string peerAddress, IDictionary resources, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch) { + ResourceViewModel? foundResource = null; + foreach (var (_, resource) in resources) { foreach (var resourceAddress in resource.CachedAddresses) { if (DoesAddressMatch(resourceAddress, peerAddress)) { - name = ResourceViewModel.GetResourceName(resource, resources); - resourceMatch = resource; - return true; + if (foundResource is null) + { + foundResource = resource; + } + else if (!string.Equals(foundResource.Name, resource.Name, StringComparisons.ResourceName)) + { + // Multiple different resources match - return false immediately + name = null; + resourceMatch = null; + return false; + } + break; // No need to check other addresses for this resource once we found a match } } } + // Return true only if exactly one resource matched + if (foundResource is not null) + { + name = ResourceViewModel.GetResourceName(foundResource, resources); + resourceMatch = foundResource; + return true; + } + + // Return false if no matches found name = null; resourceMatch = null; return false; diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index 32fe3c48a5a..fc6f851e93f 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -217,7 +217,7 @@ public void NameAndDisplayNameDifferent_MultipleInstances_ReturnName() private static bool TryResolvePeerName(IDictionary resources, KeyValuePair[] attributes, out string? peerName) { - return ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, attributes, out peerName, out _); + return ResourceOutgoingPeerResolver.TryResolvePeerCore(resources, attributes, out peerName, out _); } [Fact] @@ -357,6 +357,117 @@ private static ResourceViewModel CreateResourceWithParameterValue(string name, s properties: properties); } + [Fact] + public void MultipleResourcesMatch_SqlServerAddresses_ReturnsFalse() + { + // Arrange - Multiple SQL Server resources with same address + var resources = new Dictionary + { + ["sqlserver1"] = CreateResource("sqlserver1", "localhost", 1433), + ["sqlserver2"] = CreateResource("sqlserver2", "localhost", 1433) + }; + + // Act & Assert - Both resources would match "localhost:1433" + // so this should return false (ambiguous match) + Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:1433")], out var name)); + Assert.Null(name); + } + + [Fact] + public void MultipleResourcesMatch_RedisAddresses_ReturnsFalse() + { + // Arrange - Multiple Redis resources with equivalent addresses + var resources = new Dictionary + { + ["redis-cache"] = CreateResource("redis-cache", "localhost", 6379), + ["redis-session"] = CreateResource("redis-session", "localhost", 6379) + }; + + // Act & Assert - Both resources would match "localhost:6379" + // so this should return false (ambiguous match) + Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:6379")], out var name)); + Assert.Null(name); + } + + [Fact] + public void MultipleResourcesMatch_SqlServerCommaFormat_ReturnsFalse() + { + // Arrange - Multiple SQL Server resources where comma format would match both + var resources = new Dictionary + { + ["sqldb1"] = CreateResource("sqldb1", "localhost", 1433), + ["sqldb2"] = CreateResource("sqldb2", "localhost", 1433) + }; + + // Act & Assert - SQL Server comma format "localhost,1433" should match both resources + // so this should return false (ambiguous match) + Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost,1433")], out var name)); + Assert.Null(name); + } + + [Fact] + public void MultipleResourcesMatch_MixedPortFormats_ReturnsFalse() + { + // Arrange - Resources with same logical address but different port formats + var resources = new Dictionary + { + ["db-primary"] = CreateResource("db-primary", "dbserver", 5432), + ["db-replica"] = CreateResource("db-replica", "dbserver", 5432) + }; + + // Act & Assert - Should be ambiguous since both resources have same address + Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("server.address", "dbserver"), KeyValuePair.Create("server.port", "5432")], out var name)); + Assert.Null(name); + } + + [Fact] + public void MultipleResourcesMatch_AddressTransformation_ReturnsFalse() + { + // Arrange - Multiple resources with exact same address (not just after transformation) + var resources = new Dictionary + { + ["web-frontend"] = CreateResource("web-frontend", "localhost", 8080), + ["web-backend"] = CreateResource("web-backend", "localhost", 8080) + }; + + // Act & Assert - Both resources have identical cached address "localhost:8080" + // so this should return false (ambiguous match) + Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:8080")], out var name)); + Assert.Null(name); + } + + [Fact] + public void MultipleResourcesMatch_ViaTransformation_ReturnsFirstMatch() + { + // Arrange - Resources that become ambiguous after address transformation + // Note: This test documents current behavior where transformation order matters + var resources = new Dictionary + { + ["sql-primary"] = CreateResource("sql-primary", "localhost", 1433), + ["sql-replica"] = CreateResource("sql-replica", "127.0.0.1", 1433) + }; + + // Act & Assert - Due to transformation order, this currently finds sql-replica first + // before the transformation that would make sql-primary match as well + Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "127.0.0.1:1433")], out var name)); + Assert.Equal("sql-replica", name); + } + + [Fact] + public void SingleResourceAfterTransformation_ReturnsTrue() + { + // Arrange - Only one resource that matches after address transformation + var resources = new Dictionary + { + ["unique-service"] = CreateResource("unique-service", "localhost", 8080), + ["other-service"] = CreateResource("other-service", "remotehost", 9090) + }; + + // Act & Assert - Only the first resource should match "127.0.0.1:8080" after transformation + Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "127.0.0.1:8080")], out var name)); + Assert.Equal("unique-service", name); + } + private sealed class MockDashboardClient(Task subscribeResult) : IDashboardClient { public bool IsEnabled => true;