diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs
new file mode 100644
index 00000000000..67c52d6c75b
--- /dev/null
+++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs
@@ -0,0 +1,497 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Aspire.Dashboard.Model;
+
+///
+/// Provides utilities for parsing connection strings to extract host and port information.
+/// Supports various connection string formats including URIs, key-value pairs, and delimited lists.
+///
+internal static partial class ConnectionStringParser
+{
+ private static readonly Dictionary s_schemeDefaultPorts = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["http"] = 80,
+ ["https"] = 443,
+ ["ftp"] = 21,
+ ["ftps"] = 990,
+ ["ssh"] = 22,
+ ["telnet"] = 23,
+ ["smtp"] = 25,
+ ["dns"] = 53,
+ ["dhcp"] = 67,
+ ["tftp"] = 69,
+ ["pop3"] = 110,
+ ["ntp"] = 123,
+ ["imap"] = 143,
+ ["snmp"] = 161,
+ ["ldap"] = 389,
+ ["smtps"] = 465,
+ ["ldaps"] = 636,
+ ["imaps"] = 993,
+ ["pop3s"] = 995,
+ ["mssql"] = 1433,
+ ["mysql"] = 3306,
+ ["postgresql"] = 5432,
+ ["postgres"] = 5432,
+ ["redis"] = 6379,
+ ["mongodb"] = 27017,
+ ["amqp"] = 5672,
+ ["amqps"] = 5671,
+ ["kafka"] = 9092
+ };
+
+ private static readonly string[] s_hostAliases = ["host", "server", "data source", "addr", "address", "endpoint", "contact points"];
+
+ private static readonly string[] s_knownProtocols = ["tcp", "udp", "ssl", "tls", "http", "https", "ftp", "ssh"];
+
+ ///
+ /// Matches host:port or host,port patterns with optional IPv6 bracket notation.
+ /// Examples: "localhost:5432", "127.0.0.1,1433", "[::1]:6379"
+ ///
+ [GeneratedRegex(@"(\[[^\]]+\]|[^,:;\s]+)[:|,](\d{1,5})", RegexOptions.Compiled)]
+ private static partial Regex HostPortRegex();
+
+ ///
+ /// Matches JDBC URLs to extract host and optional port.
+ /// Examples: "jdbc:postgresql://localhost:5432/db", "jdbc:mysql://server/database"
+ ///
+ [GeneratedRegex(@"^jdbc:[^:]+://([^:/\s]+)(?::(\d+))?(?:/.*)?", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
+ private static partial Regex JdbcUrlRegex();
+
+ ///
+ /// Attempts to extract a host and optional port from an arbitrary connection string.
+ /// Returns true if a host could be identified; otherwise false.
+ ///
+ /// Supports the following connection string formats:
+ /// - URIs: "postgres://user:pass@host:5432/db", "redis://host:6379"
+ /// - Key-value pairs: "Host=localhost;Port=5432", "Server=tcp:host,1433"
+ /// - Delimited lists: "broker1:9092,broker2:9092" (returns first broker)
+ /// - Single hostnames: "localhost", "api.example.com"
+ ///
+ /// The connection string to parse.
+ /// When this method returns true, contains the host part with surrounding brackets removed; otherwise, an empty string.
+ /// When this method returns true, contains the explicit port, scheme-derived default, or null when unavailable; otherwise, null.
+ /// true if a host was found; otherwise, false.
+ public static bool TryDetectHostAndPort(
+ string connectionString,
+ [NotNullWhen(true)] out string? host,
+ out int? port)
+ {
+ host = null;
+ port = null;
+
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ return false;
+ }
+
+ // Strategy 1: Parse as URI (including JDBC URLs)
+ // Examples: "postgres://host:5432/db", "jdbc:mysql://host/db"
+ if (TryParseAsUri(connectionString, out host, out port))
+ {
+ return true;
+ }
+
+ // Strategy 2: Parse as key-value pairs
+ // Examples: "Host=localhost;Port=5432", "Server=tcp:host,1433"
+ if (TryParseAsKeyValuePairs(connectionString, out host, out port))
+ {
+ return true;
+ }
+
+ // Strategy 3: Use regex heuristic for host:port patterns
+ // Examples: "localhost:5432", "127.0.0.1,1433", "[::1]:6379"
+ if (TryParseWithRegexHeuristic(connectionString, out host, out port))
+ {
+ return true;
+ }
+
+ // Strategy 4: Treat as single hostname (conservative approach)
+ // Examples: "localhost", "api.example.com" (but not file paths)
+ if (TryParseAsSingleHost(connectionString, out host, out port))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Attempts to parse the connection string as a URI (including JDBC URLs).
+ ///
+ /// The string to parse as a URI. Examples: "postgres://host:5432/db", "jdbc:mysql://host/db"
+ /// The extracted host name, or null if parsing failed.
+ /// The extracted port number, or null if no port was found.
+ /// True if a host was successfully extracted; otherwise false.
+ private static bool TryParseAsUri(string connectionString, [NotNullWhen(true)] out string? host, out int? port)
+ {
+ host = null;
+ port = null;
+
+ // Handle JDBC URLs specially since they're not recognized by Uri.TryCreate
+ // Example: "jdbc:postgresql://localhost:5432/database"
+ if (connectionString.StartsWith("jdbc:", StringComparison.OrdinalIgnoreCase))
+ {
+ return TryParseJdbcUrl(connectionString, out host, out port);
+ }
+
+ // Standard URI parsing for protocols like postgres://, redis://, etc.
+ if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host))
+ {
+ host = TrimBrackets(uri.Host);
+ port = uri.Port != -1 ? uri.Port : DefaultPortFromScheme(uri.Scheme);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Attempts to parse key-value pair connection strings.
+ ///
+ /// The connection string with key-value pairs. Examples: "Host=localhost;Port=5432", "Server=tcp:host,1433"
+ /// The extracted host name, or null if parsing failed.
+ /// The extracted port number, or null if no port was found.
+ /// True if a host was successfully extracted; otherwise false.
+ private static bool TryParseAsKeyValuePairs(string connectionString, [NotNullWhen(true)] out string? host, out int? port)
+ {
+ host = null;
+ port = null;
+
+ var keyValuePairs = SplitIntoDictionary(connectionString);
+
+ foreach (var hostAlias in s_hostAliases)
+ {
+ if (keyValuePairs.TryGetValue(hostAlias, out var token))
+ {
+ // First, check if the token is a complete URL
+ // Example: "Endpoint=https://storage.azure.com"
+ if (TryParseAsUri(token, out var tokenHost, out var tokenPort))
+ {
+ host = tokenHost;
+ port = tokenPort;
+ return true;
+ }
+
+ // Handle special case of multiple contact points (should return false to be conservative)
+ // Example: "contact points=node1,node2,node3" should not be parsed
+ if (hostAlias.Equals("contact points", StringComparison.OrdinalIgnoreCase) &&
+ token.Contains(',') && token.Split(',').Length > 1)
+ {
+ return false;
+ }
+
+ // Remove protocol prefixes like "tcp:", "udp:", etc.
+ // Example: "Server=tcp:localhost,1433" becomes "localhost,1433"
+ token = RemoveProtocolPrefix(token);
+
+ if (token.Contains(',') || token.Contains(':'))
+ {
+ // Handle host:port or host,port patterns
+ // Examples: "localhost:5432", "127.0.0.1,1433", "[::1]:6379"
+ if (TryParseHostPortToken(token, keyValuePairs, out host, out port))
+ {
+ return true;
+ }
+ }
+ else if (!string.IsNullOrEmpty(token))
+ {
+ // Single hostname without port
+ // Example: "Host=localhost"
+ host = TrimBrackets(token);
+ port = PortFromKV(keyValuePairs);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Uses regex heuristics to find host:port patterns in the connection string.
+ ///
+ /// The connection string to search. Examples: "broker1:9092,broker2:9092", "localhost:5432"
+ /// The extracted host name, or null if parsing failed.
+ /// The extracted port number, or null if no port was found.
+ /// True if a host:port pattern was found; otherwise false.
+ private static bool TryParseWithRegexHeuristic(string connectionString, [NotNullWhen(true)] out string? host, out int? port)
+ {
+ host = null;
+ port = null;
+
+ var match = HostPortRegex().Match(connectionString);
+ if (match.Success)
+ {
+ var hostPart = match.Groups[1].Value;
+ var portPart = match.Groups[2].Value;
+ if (!string.IsNullOrEmpty(hostPart))
+ {
+ host = TrimBrackets(hostPart);
+ port = ParseIntSafe(portPart);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Attempts to treat the entire connection string as a single hostname (conservative approach).
+ ///
+ /// The string to evaluate as a hostname. Examples: "localhost", "api.example.com"
+ /// The hostname if it looks valid, or null if it appears to be a file path or other non-hostname.
+ /// Always null for single hostname parsing.
+ /// True if the string looks like a valid hostname; otherwise false.
+ private static bool TryParseAsSingleHost(string connectionString, [NotNullWhen(true)] out string? host, out int? port)
+ {
+ host = null;
+ port = null;
+
+ if (LooksLikeHost(connectionString))
+ {
+ host = TrimBrackets(connectionString);
+ port = null;
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Parses a host:port or host,port token, with special handling for IPv6 addresses.
+ ///
+ /// The token to parse. Examples: "localhost:5432", "[::1]:6379", "host,1433"
+ /// Additional key-value pairs that might contain a separate port value.
+ /// The extracted host name, or null if parsing failed.
+ /// The extracted port number, or null if no port was found.
+ /// True if parsing succeeded; otherwise false.
+ private static bool TryParseHostPortToken(string token, Dictionary keyValuePairs, [NotNullWhen(true)] out string? host, out int? port)
+ {
+ host = null;
+ port = null;
+
+ // Special handling for IPv6 addresses in brackets
+ // Example: "[::1]:6379" or "[::1],6379"
+ if (token.StartsWith('[') && token.Contains(']'))
+ {
+ var bracketEnd = token.IndexOf(']');
+ if (bracketEnd > 0)
+ {
+ host = TrimBrackets(token[..(bracketEnd + 1)]);
+ // Look for port after the bracket (could be colon or comma separated)
+ var afterBracket = token[(bracketEnd + 1)..];
+ if ((afterBracket.StartsWith(':') || afterBracket.StartsWith(',')) && afterBracket.Length > 1)
+ {
+ port = ParseIntSafe(afterBracket[1..]) ?? PortFromKV(keyValuePairs);
+ }
+ else
+ {
+ port = PortFromKV(keyValuePairs);
+ }
+ return true;
+ }
+ }
+
+ // Regular host:port or host,port parsing
+ var (hostPart, portPart) = SplitOnLast(token);
+ if (!string.IsNullOrEmpty(hostPart))
+ {
+ host = TrimBrackets(hostPart);
+ port = ParseIntSafe(portPart) ?? PortFromKV(keyValuePairs);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Parses JDBC URLs which have the format: jdbc:subprotocol://host:port/database
+ ///
+ /// The JDBC URL to parse. Examples: "jdbc:postgresql://localhost:5432/db", "jdbc:mysql://server/database"
+ /// The extracted host name, or null if parsing failed.
+ /// The extracted port number, or null if no port was specified.
+ /// True if the JDBC URL was successfully parsed; otherwise false.
+ private static bool TryParseJdbcUrl(string jdbcUrl, [NotNullWhen(true)] out string? host, out int? port)
+ {
+ host = null;
+ port = null;
+
+ var match = JdbcUrlRegex().Match(jdbcUrl);
+ if (match.Success)
+ {
+ host = match.Groups[1].Value;
+ if (match.Groups[2].Success && int.TryParse(match.Groups[2].Value, out var portValue))
+ {
+ port = portValue;
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Removes square brackets from the beginning and end of a string.
+ ///
+ /// The string to trim. Example: "[::1]" becomes "::1"
+ /// The string with brackets removed.
+ private static string TrimBrackets(string s) => s.Trim('[', ']');
+
+ ///
+ /// Removes known protocol prefixes from connection string values.
+ ///
+ /// The value to clean. Examples: "tcp:localhost" becomes "localhost", "ssl:host:443" becomes "host:443"
+ /// The value with protocol prefix removed, or the original value if no known prefix is found.
+ private static string RemoveProtocolPrefix(string value)
+ {
+ // Remove common protocol prefixes like "tcp:", "udp:", "ssl:", etc.
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ var colonIndex = value.IndexOf(':');
+ if (colonIndex > 0 && colonIndex < value.Length - 1)
+ {
+ var prefix = value[..colonIndex].ToLowerInvariant();
+ // Only remove known protocol prefixes, not arbitrary single letters
+ if (s_knownProtocols.Contains(prefix))
+ {
+ return value[(colonIndex + 1)..];
+ }
+ }
+
+ return value;
+ }
+
+ ///
+ /// Gets the default port number for a given URI scheme.
+ ///
+ /// The URI scheme. Examples: "postgres", "redis", "https"
+ /// The default port number for the scheme, or null if no default is known.
+ private static int? DefaultPortFromScheme(string? scheme)
+ {
+ if (string.IsNullOrEmpty(scheme))
+ {
+ return null;
+ }
+
+ return s_schemeDefaultPorts.TryGetValue(scheme, out var port) ? port : null;
+ }
+
+ ///
+ /// Extracts a port value from key-value pairs using the "port" key.
+ ///
+ /// The dictionary of key-value pairs to search.
+ /// The port number if found and valid, or null otherwise.
+ private static int? PortFromKV(Dictionary keyValuePairs)
+ {
+ return keyValuePairs.TryGetValue("port", out var portValue) ? ParseIntSafe(portValue) : null;
+ }
+
+ ///
+ /// Safely parses a string as an integer port number (0-65535).
+ ///
+ /// The string to parse. Examples: "5432", "443", "invalid"
+ /// The parsed port number if valid, or null if parsing failed or the number is out of range.
+ private static int? ParseIntSafe(string? s)
+ {
+ if (string.IsNullOrEmpty(s))
+ {
+ return null;
+ }
+
+ if (int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var value) &&
+ value >= 0 && value <= 65535)
+ {
+ return value;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Splits a connection string into key-value pairs using semicolon or whitespace delimiters.
+ ///
+ /// The connection string to split. Examples: "Host=localhost;Port=5432", "server=host port=1433"
+ /// A dictionary of key-value pairs with case-insensitive keys.
+ private static Dictionary SplitIntoDictionary(string connectionString)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Split by semicolon first, then by whitespace if no semicolons found
+ var parts = connectionString.Contains(';')
+ ? connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)
+ : connectionString.Split([' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var part in parts)
+ {
+ var trimmedPart = part.Trim();
+ var equalIndex = trimmedPart.IndexOf('=');
+ if (equalIndex > 0 && equalIndex < trimmedPart.Length - 1)
+ {
+ var key = trimmedPart[..equalIndex].Trim();
+ var value = trimmedPart[(equalIndex + 1)..].Trim();
+ if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value))
+ {
+ result[key] = value;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Splits a token on the last occurrence of ':' or ',' to separate host and port.
+ ///
+ /// The token to split. Examples: "localhost:5432", "host,1433", "host:8080:extra"
+ /// A tuple with the host part and port part. Port part may be empty if no delimiter is found.
+ private static (string host, string port) SplitOnLast(string token)
+ {
+ // Split on the last occurrence of ':' or ','
+ var lastColonIndex = token.LastIndexOf(':');
+ var lastCommaIndex = token.LastIndexOf(',');
+ var splitIndex = Math.Max(lastColonIndex, lastCommaIndex);
+
+ if (splitIndex > 0 && splitIndex < token.Length - 1)
+ {
+ return (token[..splitIndex].Trim(), token[(splitIndex + 1)..].Trim());
+ }
+
+ return (token, string.Empty);
+ }
+
+ ///
+ /// Determines if a string looks like a hostname rather than a file path or other non-hostname string.
+ /// Uses URI validation with conservative heuristics to avoid false positives.
+ ///
+ /// The string to evaluate. Examples: "localhost" (valid), "/path/to/file.db" (invalid), "api.example.com" (valid)
+ /// True if the string appears to be a hostname; otherwise false.
+ private static bool LooksLikeHost(string connectionString)
+ {
+ // Reject strings with '=' (likely key-value pairs)
+ if (connectionString.Contains('='))
+ {
+ return false;
+ }
+
+ // Reject obvious file path indicators
+ if (connectionString.StartsWith('/') || connectionString.StartsWith('\\') ||
+ connectionString.StartsWith("./") || connectionString.StartsWith("../") ||
+ (connectionString.Length > 2 && connectionString[1] == ':' && char.IsLetter(connectionString[0])))
+ {
+ return false;
+ }
+
+ // Use Uri parsing to validate hostname - create a fake URI and see if it parses
+ var fakeUri = $"scheme://{connectionString.Trim()}";
+ return Uri.TryCreate(fakeUri, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host);
+ }
+}
\ No newline at end of file
diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs
index 7a8277a31be..15b9401fef0 100644
--- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs
+++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs
@@ -42,29 +42,30 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService)
await foreach (var changes in subscription.WithCancellation(_watchContainersTokenSource.Token).ConfigureAwait(false))
{
- var hasUrlChanges = false;
+ var hasPeerRelevantChanges = false;
foreach (var (changeType, resource) in changes)
{
if (changeType == ResourceViewModelChangeType.Upsert)
{
- if (!_resourceByName.TryGetValue(resource.Name, out var existingResource) || !AreEquivalent(resource.Urls, existingResource.Urls))
+ if (!_resourceByName.TryGetValue(resource.Name, out var existingResource) ||
+ !ArePeerRelevantPropertiesEquivalent(resource, existingResource))
{
- hasUrlChanges = true;
+ hasPeerRelevantChanges = true;
}
_resourceByName[resource.Name] = resource;
}
else if (changeType == ResourceViewModelChangeType.Delete)
{
- hasUrlChanges = true;
+ hasPeerRelevantChanges = true;
var removed = _resourceByName.TryRemove(resource.Name, out _);
Debug.Assert(removed, "Cannot remove unknown resource.");
}
}
- if (hasUrlChanges)
+ if (hasPeerRelevantChanges)
{
await RaisePeerChangesAsync().ConfigureAwait(false);
}
@@ -72,7 +73,30 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService)
});
}
- private static bool AreEquivalent(ImmutableArray urls1, ImmutableArray urls2)
+ private static bool ArePeerRelevantPropertiesEquivalent(ResourceViewModel resource1, ResourceViewModel resource2)
+ {
+ // Check if URLs are equivalent
+ if (!AreUrlsEquivalent(resource1.Urls, resource2.Urls))
+ {
+ return false;
+ }
+
+ // Check if connection string properties are equivalent
+ if (!ArePropertyValuesEquivalent(resource1, resource2, KnownProperties.Resource.ConnectionString))
+ {
+ return false;
+ }
+
+ // Check if parameter value properties are equivalent
+ if (!ArePropertyValuesEquivalent(resource1, resource2, KnownProperties.Parameter.Value))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool AreUrlsEquivalent(ImmutableArray urls1, ImmutableArray urls2)
{
// Compare if the two sets of URLs are equivalent.
if (urls1.Length != urls2.Length)
@@ -94,30 +118,79 @@ private static bool AreEquivalent(ImmutableArray urls1, ImmutableA
return true;
}
- public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResource)
+ private static bool ArePropertyValuesEquivalent(ResourceViewModel resource1, ResourceViewModel resource2, string propertyName)
{
- return TryResolvePeerNameCore(_resourceByName, attributes, out name, out matchedResource);
+ var hasProperty1 = resource1.Properties.TryGetValue(propertyName, out var property1);
+ var hasProperty2 = resource2.Properties.TryGetValue(propertyName, out var property2);
+
+ // If both don't have the property, they're equivalent
+ if (!hasProperty1 && !hasProperty2)
+ {
+ return true;
+ }
+
+ // If only one has the property, they're not equivalent
+ if (hasProperty1 != hasProperty2)
+ {
+ return false;
+ }
+
+ // Both have the property, compare values
+ var value1 = property1!.Value.TryConvertToString(out var str1) ? str1 : string.Empty;
+ var value2 = property2!.Value.TryConvertToString(out var str2) ? str2 : string.Empty;
+
+ return string.Equals(value1, value2, StringComparison.Ordinal);
}
- internal static bool TryResolvePeerNameCore(IDictionary resources, KeyValuePair[] attributes, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch)
+ public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResource)
{
var address = OtlpHelpers.GetPeerAddress(attributes);
if (address != null)
{
- // Match exact value.
- if (TryMatchResourceAddress(address, out name, out resourceMatch))
+ // 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;
+ }
- // 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"
+ internal static bool TryResolvePeerNameCore(IDictionary resources, KeyValuePair[] attributes, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch)
+ {
+ 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, resources, out name, out resourceMatch))
+ {
+ return true;
+ }
+
+ // Then apply each transformer cumulatively and check
foreach (var transformer in s_addressTransformers)
{
transformedAddress = transformer(transformedAddress);
- if (TryMatchResourceAddress(transformedAddress, out name, out resourceMatch))
+ if (TryMatchAgainstResources(transformedAddress, resources, out name, out resourceMatch))
{
return true;
}
@@ -127,28 +200,51 @@ 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.
+ ///
+ private static bool TryMatchAgainstResources(string peerAddress, IDictionary resources, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch)
+ {
+ foreach (var (_, resource) in resources)
{
- foreach (var (resourceName, resource) in resources)
+ foreach (var resourceAddress in resource.CachedAddresses)
{
- foreach (var service in resource.Urls)
+ if (DoesAddressMatch(resourceAddress, peerAddress))
{
- var hostAndPort = service.Url.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped);
-
- if (string.Equals(hostAndPort, value, StringComparison.OrdinalIgnoreCase))
- {
- name = ResourceViewModel.GetResourceName(resource, resources);
- resourceMatch = resource;
- return true;
- }
+ name = ResourceViewModel.GetResourceName(resource, resources);
+ resourceMatch = resource;
+ return true;
}
}
+ }
- name = null;
- resourceMatch = null;
- return false;
+ name = null;
+ resourceMatch = null;
+ return false;
+ }
+
+ private static bool DoesAddressMatch(string endpoint, string value)
+ {
+ if (string.Equals(endpoint, value, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
}
+
+ // Apply the same transformations that are applied to the peer service value
+ var transformedEndpoint = endpoint;
+ foreach (var transformer in s_addressTransformers)
+ {
+ transformedEndpoint = transformer(transformedEndpoint);
+ if (string.Equals(transformedEndpoint, value, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
}
private static readonly List> s_addressTransformers = [
diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs
index 68d7705aa96..4fc40862b20 100644
--- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs
+++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs
@@ -22,6 +22,7 @@ public sealed class ResourceViewModel
{
private readonly ImmutableArray _healthReports = [];
private readonly KnownResourceState? _knownState;
+ private Lazy>? _cachedAddresses;
public required string Name { get; init; }
public required string ResourceType { get; init; }
@@ -43,6 +44,44 @@ public sealed class ResourceViewModel
public bool IsHidden { private get; init; }
public bool SupportsDetailedTelemetry { get; init; }
+ ///
+ /// Gets the cached addresses for this resource that can be used for peer matching.
+ /// This includes addresses extracted from URLs, connection strings, and parameter values.
+ ///
+ public ImmutableArray CachedAddresses => (_cachedAddresses ??= new Lazy>(ExtractResourceAddresses)).Value;
+
+ private ImmutableArray ExtractResourceAddresses()
+ {
+ var addresses = new List();
+
+ // Extract addresses from URL endpoints
+ foreach (var service in Urls)
+ {
+ var hostAndPort = service.Url.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped);
+ addresses.Add(hostAndPort);
+ }
+
+ // Extract addresses from connection strings using comprehensive parsing
+ if (Properties.TryGetValue(KnownProperties.Resource.ConnectionString, out var connectionStringProperty) &&
+ connectionStringProperty.Value.TryConvertToString(out var connectionString) &&
+ ConnectionStringParser.TryDetectHostAndPort(connectionString, out var host, out var port))
+ {
+ var endpoint = port.HasValue ? $"{host}:{port.Value}" : host;
+ addresses.Add(endpoint);
+ }
+
+ // Extract addresses from parameter values (for Parameter resources that contain URLs or host:port values)
+ if (Properties.TryGetValue(KnownProperties.Parameter.Value, out var parameterValueProperty) &&
+ parameterValueProperty.Value.TryConvertToString(out var parameterValue) &&
+ ConnectionStringParser.TryDetectHostAndPort(parameterValue, out var parameterHost, out var parameterPort))
+ {
+ var parameterEndpoint = parameterPort.HasValue ? $"{parameterHost}:{parameterPort.Value}" : parameterHost;
+ addresses.Add(parameterEndpoint);
+ }
+
+ return addresses.ToImmutableArray();
+ }
+
public required ImmutableArray HealthReports
{
get => _healthReports;
diff --git a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs
index cabfd188121..27e5f2af9c8 100644
--- a/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs
+++ b/src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs
@@ -34,11 +34,29 @@ public static IResourceBuilder AddGitHubModel(this IDistrib
{
ResourceType = "GitHubModel",
CreationTimeStamp = DateTime.UtcNow,
- State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success),
+ State = KnownResourceStates.Waiting,
Properties =
- [
- new(CustomResourceKnownProperties.Source, "GitHub Models")
- ]
+ [
+ new(CustomResourceKnownProperties.Source, "GitHub Models")
+ ]
+ })
+ .OnInitializeResource(async (r, evt, ct) =>
+ {
+ // Connection string resolution is dependent on parameters being resolved
+ // We use this to wait for the parameters to be resolved before we can compute the connection string.
+ var cs = await r.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+
+ // Publish the update with the connection string value and the state as running.
+ // This will allow health checks to start running.
+ await evt.Notifications.PublishUpdateAsync(r, s => s with
+ {
+ State = KnownResourceStates.Running,
+ Properties = [.. s.Properties, new(CustomResourceKnownProperties.ConnectionString, cs) { IsSensitive = true }]
+ }).ConfigureAwait(false);
+
+ // Publish the connection string available event for other resources that may depend on this resource.
+ await evt.Eventing.PublishAsync(new ConnectionStringAvailableEvent(r, evt.Services), ct)
+ .ConfigureAwait(false);
});
}
@@ -94,7 +112,7 @@ public static IResourceBuilder WithHealthCheck(this IResour
{
// Cache the health check instance so we can reuse its result in order to avoid multiple API calls
// that would exhaust the rate limit.
-
+
if (healthCheck is not null)
{
return healthCheck;
diff --git a/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs b/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs
new file mode 100644
index 00000000000..e5a897cc401
--- /dev/null
+++ b/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs
@@ -0,0 +1,155 @@
+// 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.Model;
+using Xunit;
+
+namespace Aspire.Dashboard.Tests;
+
+public class ConnectionStringParserTests
+{
+ [Theory]
+ [InlineData("redis://[fe80::1]:6380", true, "fe80::1", 6380)]
+ [InlineData("postgres://h/db", true, "h", 5432)]
+ [InlineData("Endpoint=h:6379;password=pw", true, "h", 6379)]
+ [InlineData("host=h;user=foo", true, "h", null)]
+ [InlineData("broker1:9092,broker2:9092", true, "broker1", 9092)]
+ [InlineData("/var/sqlite/file.db", false, "", null)]
+ [InlineData("foo bar baz", false, "", null)]
+ [InlineData("https://models.github.ai/inference", true, "models.github.ai", 443)]
+ [InlineData("Server=tcp:localhost,1433;Database=test", true, "localhost", 1433)]
+ [InlineData("Server=localhost;port=5432", true, "localhost", 5432)]
+ // SQL Server patterns
+ [InlineData("Server=myServerAddress;Database=myDataBase;User Id=myUsername;Password=myPassword;", true, "myServerAddress", null)]
+ [InlineData("Server=myServerAddress,1433;Database=myDataBase;Trusted_Connection=True;", true, "myServerAddress", 1433)]
+ [InlineData("Data Source=tcp:localhost,1433;Initial Catalog=TestDB;", true, "localhost", 1433)]
+ [InlineData("Data Source=.\\SQLEXPRESS;AttachDbFilename=|DataDirectory|mydbfile.mdf;Integrated Security=true;User Instance=true;", true, ".\\SQLEXPRESS", null)]
+ [InlineData("Server=(localdb)\\MSSQLLocalDB;Database=AspNetCore.StarterSite;Trusted_Connection=true;MultipleActiveResultSets=true", true, "(localdb)\\MSSQLLocalDB", null)]
+ // PostgreSQL patterns
+ [InlineData("Host=localhost;Database=mydb;Username=myuser;Password=mypass", true, "localhost", null)]
+ [InlineData("Host=localhost;Port=5432;Database=mydb;Username=myuser;Password=mypass", true, "localhost", 5432)]
+ [InlineData("postgresql://user:password@localhost:5432/dbname", true, "localhost", 5432)]
+ [InlineData("postgres://user:password@localhost/dbname", true, "localhost", 5432)]
+ // MySQL patterns
+ [InlineData("Server=localhost;Database=myDataBase;Uid=myUsername;Pwd=myPassword;", true, "localhost", null)]
+ [InlineData("Server=localhost;Port=3306;Database=myDataBase;Uid=myUsername;Pwd=myPassword;", true, "localhost", 3306)]
+ [InlineData("mysql://user:password@localhost:3306/database", true, "localhost", 3306)]
+ // MongoDB patterns
+ [InlineData("mongodb://localhost:27017", true, "localhost", 27017)]
+ [InlineData("mongodb://user:password@localhost:27017/database", true, "localhost", 27017)]
+ [InlineData("mongodb://localhost", true, "localhost", 27017)]
+ [InlineData("mongodb+srv://cluster0.example.mongodb.net/database", true, "cluster0.example.mongodb.net", null)]
+ // Redis patterns
+ [InlineData("localhost:6379", true, "localhost", 6379)]
+ [InlineData("redis://localhost:6379", true, "localhost", 6379)]
+ [InlineData("rediss://localhost:6380", true, "localhost", 6380)]
+ [InlineData("redis://user:password@localhost:6379/0", true, "localhost", 6379)]
+ [InlineData("Endpoint=localhost:6379;Password=mypassword", true, "localhost", 6379)]
+ // Oracle patterns
+ [InlineData("Data Source=localhost:1521/XE;User Id=hr;Password=password;", true, "localhost", null)] // Won't parse port from path syntax
+ // JDBC patterns (basic ones that should work - but many JDBC URLs are complex)
+ [InlineData("jdbc:postgresql://localhost:5432/database", true, "localhost", 5432)]
+ [InlineData("jdbc:mysql://localhost:3306/database", true, "localhost", 3306)]
+ [InlineData("jdbc:sqlserver://localhost:1433;databaseName=TestDB", true, "localhost", 1433)]
+ // Cloud provider patterns
+ [InlineData("https://myaccount.blob.core.windows.net/", true, "myaccount.blob.core.windows.net", 443)]
+ [InlineData("https://myvault.vault.azure.net:8080/", true, "myvault.vault.azure.net", 8080)]
+ [InlineData("Server=tcp:myserver.database.windows.net,1433;Database=mydatabase;", true, "myserver.database.windows.net", 1433)]
+ // Kafka patterns
+ [InlineData("localhost:9092,localhost:9093,localhost:9094", true, "localhost", 9092)]
+ [InlineData("broker-1:9092,broker-2:9092", true, "broker-1", 9092)]
+ // RabbitMQ patterns
+ [InlineData("amqp://localhost", true, "localhost", 5672)]
+ [InlineData("amqp://user:pass@localhost:5672/vhost", true, "localhost", 5672)]
+ [InlineData("amqps://localhost:5671", true, "localhost", 5671)]
+ [InlineData("Host=localhost;Port=5672;VirtualHost=/;Username=guest;Password=guest", true, "localhost", 5672)]
+ // Elasticsearch patterns
+ [InlineData("http://localhost:9200", true, "localhost", 9200)]
+ [InlineData("https://elastic:password@localhost:9200", true, "localhost", 9200)]
+ // InfluxDB patterns
+ [InlineData("http://localhost:8086", true, "localhost", 8086)]
+ [InlineData("https://localhost:8086", true, "localhost", 8086)]
+ // Cassandra patterns
+ [InlineData("Contact Points=localhost;Port=9042", true, "localhost", 9042)]
+ [InlineData("Contact Points=node1,node2,node3;Port=9042", false, "", null)] // Multiple contact points - too complex
+ // Neo4j patterns
+ [InlineData("bolt://localhost:7687", true, "localhost", 7687)]
+ [InlineData("neo4j://localhost:7687", true, "localhost", 7687)]
+ // Docker/container patterns
+ [InlineData("server.local", true, "server.local", null)]
+ [InlineData("my-service:5432", true, "my-service", 5432)]
+ [InlineData("my-namespace.my-service.svc.cluster.local:5432", true, "my-namespace.my-service.svc.cluster.local", 5432)]
+ // IPv6 patterns
+ [InlineData("Server=[::1],1433", true, "::1", 1433)]
+ [InlineData("Host=[2001:db8::1];Port=5432", true, "2001:db8::1", 5432)]
+ [InlineData("http://[2001:db8::1]:8080", true, "2001:db8::1", 8080)]
+ // Edge cases and invalid patterns
+ [InlineData("", false, "", null)]
+ [InlineData(" ", false, "", null)]
+ [InlineData("=", false, "", null)]
+ [InlineData("key=", false, "", null)]
+ [InlineData("=value", false, "", null)]
+ [InlineData("C:\\path\\to\\file.db", false, "", null)]
+ [InlineData("./relative/path/file.db", false, "", null)]
+ [InlineData("/absolute/path/file.db", false, "", null)]
+ [InlineData("just some random text", false, "", null)]
+ [InlineData("host=;port=5432", false, "", null)] // Empty host
+ [InlineData("server=localhost;port=abc", true, "localhost", null)] // Invalid port
+ [InlineData("server=localhost;port=99999", true, "localhost", null)] // Port out of range
+ public void TryDetectHostAndPort_VariousFormats_ReturnsExpectedResults(
+ string connectionString,
+ bool expectedResult,
+ string expectedHost,
+ int? expectedPort)
+ {
+ // Act
+ var result = ConnectionStringParser.TryDetectHostAndPort(connectionString, out var host, out var port);
+
+ // Assert
+ Assert.Equal(expectedResult, result);
+ if (expectedResult)
+ {
+ Assert.Equal(expectedHost, host);
+ Assert.Equal(expectedPort, port);
+ }
+ else
+ {
+ Assert.Null(host);
+ Assert.Null(port);
+ }
+ }
+
+ [Fact]
+ public void TryDetectHostAndPort_IPv6URI_ReturnsCorrectHost()
+ {
+ // Test case specifically for IPv6 addresses with brackets
+ var connectionString = "redis://[fe80::1]:6380";
+ var result = ConnectionStringParser.TryDetectHostAndPort(connectionString, out var host, out var port);
+
+ Assert.True(result);
+ Assert.Equal("fe80::1", host); // Brackets should be trimmed
+ Assert.Equal(6380, port);
+ }
+
+ [Fact]
+ public void TryDetectHostAndPort_KeyValuePairsWithSemicolon_ParsesCorrectly()
+ {
+ var connectionString = "Endpoint=h:6379;password=pw;database=0";
+ var result = ConnectionStringParser.TryDetectHostAndPort(connectionString, out var host, out var port);
+
+ Assert.True(result);
+ Assert.Equal("h", host);
+ Assert.Equal(6379, port);
+ }
+
+ [Fact]
+ public void TryDetectHostAndPort_DelimitedList_TakesFirstEntry()
+ {
+ var connectionString = "broker1:9092,broker2:9093,broker3:9094";
+ var result = ConnectionStringParser.TryDetectHostAndPort(connectionString, out var host, out var port);
+
+ Assert.True(result);
+ Assert.Equal("broker1", host);
+ Assert.Equal(9092, port);
+ }
+}
\ No newline at end of file
diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs
index 4914d27ec0c..32fe3c48a5a 100644
--- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs
+++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs
@@ -8,6 +8,7 @@
using Aspire.Tests.Shared.DashboardModel;
using Microsoft.AspNetCore.InternalTesting;
using Xunit;
+using Value = Google.Protobuf.WellKnownTypes.Value;
namespace Aspire.Dashboard.Tests;
@@ -219,6 +220,143 @@ private static bool TryResolvePeerName(IDictionary re
return ResourceOutgoingPeerResolver.TryResolvePeerNameCore(resources, attributes, out peerName, out _);
}
+ [Fact]
+ public void ConnectionStringWithEndpoint_Match()
+ {
+ // Arrange - GitHub Models resource with connection string containing endpoint
+ var connectionString = "Endpoint=https://models.github.ai/inference;Key=test-key;Model=openai/gpt-4o-mini;DeploymentId=openai/gpt-4o-mini";
+ var resources = new Dictionary
+ {
+ ["github-model"] = CreateResourceWithConnectionString("github-model", connectionString)
+ };
+
+ // Act & Assert
+ Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "models.github.ai:443")], out var value));
+ Assert.Equal("github-model", value);
+ }
+
+ [Fact]
+ public void ConnectionStringWithEndpointOrganization_Match()
+ {
+ // Arrange - GitHub Models resource with organization endpoint
+ var connectionString = "Endpoint=https://models.github.ai/orgs/myorg/inference;Key=test-key;Model=openai/gpt-4o-mini;DeploymentId=openai/gpt-4o-mini";
+ var resources = new Dictionary
+ {
+ ["github-model"] = CreateResourceWithConnectionString("github-model", connectionString)
+ };
+
+ // Act & Assert
+ Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "models.github.ai:443")], out var value));
+ Assert.Equal("github-model", value);
+ }
+
+ [Fact]
+ public void ParameterWithUrlValue_Match()
+ {
+ // Arrange - Parameter resource with URL value
+ var resources = new Dictionary
+ {
+ ["api-url-param"] = CreateResourceWithParameterValue("api-url-param", "https://api.example.com:8080/endpoint")
+ };
+
+ // Act & Assert
+ Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "api.example.com:8080")], out var value));
+ Assert.Equal("api-url-param", value);
+ }
+
+ [Fact]
+ public void ConnectionStringWithoutEndpoint_NoMatch()
+ {
+ // Arrange - Connection string without Endpoint property
+ var connectionString = "Server=localhost;Database=test;User=admin;Password=secret";
+ var resources = new Dictionary
+ {
+ ["sql-connection"] = CreateResourceWithConnectionString("sql-connection", connectionString)
+ };
+
+ // Act & Assert
+ Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:1433")], out _));
+ }
+
+ [Fact]
+ public void ParameterWithNonUrlValue_NoMatch()
+ {
+ // Arrange - Parameter resource with non-URL value
+ var resources = new Dictionary
+ {
+ ["config-param"] = CreateResourceWithParameterValue("config-param", "simple-config-value")
+ };
+
+ // Act & Assert
+ Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:5000")], out _));
+ }
+
+ [Fact]
+ public void ConnectionStringAsDirectUrl_Match()
+ {
+ // Arrange - Connection string that is itself a URL (e.g., blob storage)
+ var connectionString = "https://mystorageaccount.blob.core.windows.net/";
+ var resources = new Dictionary
+ {
+ ["blob-storage"] = CreateResourceWithConnectionString("blob-storage", connectionString)
+ };
+
+ // Act & Assert
+ Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "mystorageaccount.blob.core.windows.net:443")], out var value));
+ Assert.Equal("blob-storage", value);
+ }
+
+ [Fact]
+ public void ConnectionStringAsDirectUrlWithCustomPort_Match()
+ {
+ // Arrange - Connection string that is itself a URL with custom port
+ var connectionString = "https://myvault.vault.azure.net:8080/";
+ var resources = new Dictionary
+ {
+ ["key-vault"] = CreateResourceWithConnectionString("key-vault", connectionString)
+ };
+
+ // Act & Assert
+ Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "myvault.vault.azure.net:8080")], out var value));
+ Assert.Equal("key-vault", value);
+ }
+
+ private static ResourceViewModel CreateResourceWithConnectionString(string name, string connectionString)
+ {
+ var properties = new Dictionary
+ {
+ [KnownProperties.Resource.ConnectionString] = new(
+ name: KnownProperties.Resource.ConnectionString,
+ value: Value.ForString(connectionString),
+ isValueSensitive: false,
+ knownProperty: null,
+ priority: 0)
+ };
+
+ return ModelTestHelpers.CreateResource(
+ appName: name,
+ resourceType: KnownResourceTypes.ConnectionString,
+ properties: properties);
+ }
+
+ private static ResourceViewModel CreateResourceWithParameterValue(string name, string value)
+ {
+ var properties = new Dictionary
+ {
+ [KnownProperties.Parameter.Value] = new(
+ name: KnownProperties.Parameter.Value,
+ value: Value.ForString(value),
+ isValueSensitive: false,
+ knownProperty: null,
+ priority: 0)
+ };
+
+ return ModelTestHelpers.CreateResource(
+ appName: name,
+ resourceType: KnownResourceTypes.Parameter,
+ properties: properties);
+ }
+
private sealed class MockDashboardClient(Task subscribeResult) : IDashboardClient
{
public bool IsEnabled => true;