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;