From 040600a985c01d0b9c5c9404517de9eb89b3a7ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:28:49 +0000 Subject: [PATCH 01/17] Initial plan From caabfcfdd6cb4b922bb987b94cdcd2e196b71cb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:40:29 +0000 Subject: [PATCH 02/17] Implement uninstrumented peer visualization for parameters, connection strings, and GitHub models Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ResourceOutgoingPeerResolver.cs | 91 +++++++++++++++ .../ResourceOutgoingPeerResolverTests.cs | 108 ++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index 7a8277a31be..a2208a47d4f 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -130,6 +130,7 @@ internal static bool TryResolvePeerNameCore(IDictionary + { + ["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 _)); + } + + 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; From 865eb7e7d65b2b8dcb870e9d6a6f698fe32bf889 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 19:06:42 +0000 Subject: [PATCH 03/17] Support direct URL connection strings in peer resolution Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ResourceOutgoingPeerResolver.cs | 15 +++++++--- .../ResourceOutgoingPeerResolverTests.cs | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index a2208a47d4f..eda4eb1be8b 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -130,14 +130,14 @@ internal static bool TryResolvePeerNameCore(IDictionary + { + ["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 From 59a9a597aac5bbd12243aa1545b9fe45c8f3ea3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 20:30:50 +0000 Subject: [PATCH 04/17] Initial implementation of comprehensive connection string parser Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ConnectionStringParser.cs | 226 ++++++++++++++++++ .../Model/ResourceOutgoingPeerResolver.cs | 50 +--- .../ConnectionStringParserTests.cs | 78 ++++++ 3 files changed, 313 insertions(+), 41 deletions(-) create mode 100644 src/Aspire.Dashboard/Model/ConnectionStringParser.cs create mode 100644 tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs new file mode 100644 index 00000000000..44bfccab885 --- /dev/null +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -0,0 +1,226 @@ +// 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. +/// +public static 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"]; + + private static readonly Regex s_hostPortRegex = new(@"(\[[^\]]+\]|[^,:;\s]+)[:|,](\d{1,5})", RegexOptions.Compiled); + + /// + /// Attempts to extract a host and optional port from an arbitrary connection string. + /// Returns true if a host could be identified; otherwise false. + /// + /// 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; + } + + // 1. URI parse + 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; + } + + // 2. Key-value scan + var keyValuePairs = SplitIntoDictionary(connectionString); + foreach (var hostAlias in s_hostAliases) + { + if (keyValuePairs.TryGetValue(hostAlias, out var token)) + { + if (token.Contains(',') || token.Contains(':')) + { + var (hostPart, portPart) = SplitOnLast(token); + if (!string.IsNullOrEmpty(hostPart)) + { + host = TrimBrackets(hostPart); + port = ParseIntSafe(portPart) ?? PortFromKV(keyValuePairs); + return true; + } + } + else if (!string.IsNullOrEmpty(token)) + { + host = TrimBrackets(token); + port = PortFromKV(keyValuePairs); + return true; + } + } + } + + // 3. Regex heuristic for host:port or host,port patterns + var match = s_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; + } + } + + // 4. Looks like single host token (no '=' etc.) + if (LooksLikeHost(connectionString)) + { + host = TrimBrackets(connectionString); + port = null; + return true; + } + + return false; + } + + private static string TrimBrackets(string s) => s.Trim('[', ']'); + + private static int? DefaultPortFromScheme(string? scheme) + { + if (string.IsNullOrEmpty(scheme)) + { + return null; + } + + return s_schemeDefaultPorts.TryGetValue(scheme, out var port) ? port : null; + } + + private static int? PortFromKV(Dictionary keyValuePairs) + { + return keyValuePairs.TryGetValue("port", out var portValue) ? ParseIntSafe(portValue) : null; + } + + 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; + } + + 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; + } + + 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); + } + + private static bool LooksLikeHost(string connectionString) + { + // Simple heuristic: if it doesn't contain '=' and looks like a hostname or IP + if (connectionString.Contains('=')) + { + return false; + } + + // Remove common file path indicators + if (connectionString.StartsWith('/') || connectionString.StartsWith('\\') || + (connectionString.Length > 2 && connectionString[1] == ':' && char.IsLetter(connectionString[0]))) + { + return false; + } + + // Should contain dots (for domains) or be a simple name, and not contain spaces + var trimmed = connectionString.Trim(); + return !string.IsNullOrEmpty(trimmed) && + !trimmed.Contains(' ') && + (trimmed.Contains('.') || !trimmed.Contains('/')); + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index eda4eb1be8b..e34f8d4877c 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -145,15 +145,18 @@ bool TryMatchResourceAddress(string value, [NotNullWhen(true)] out string? name, } } - // Try to match against connection strings (both direct URLs and Endpoint= patterns) + // Try to match against connection strings using comprehensive parsing if (resource.Properties.TryGetValue(KnownProperties.Resource.ConnectionString, out var connectionStringProperty) && connectionStringProperty.Value.TryConvertToString(out var connectionString) && - TryExtractEndpointFromConnectionString(connectionString, out var endpoint) && - DoesAddressMatch(endpoint, value)) + ConnectionStringParser.TryDetectHostAndPort(connectionString, out var host, out var port)) { - name = ResourceViewModel.GetResourceName(resource, resources); - resourceMatch = resource; - return true; + var endpoint = port.HasValue ? $"{host}:{port.Value}" : host; + if (DoesAddressMatch(endpoint, value)) + { + name = ResourceViewModel.GetResourceName(resource, resources); + resourceMatch = resource; + return true; + } } // Try to match against parameter values (for Parameter resources that contain URLs) @@ -174,41 +177,6 @@ bool TryMatchResourceAddress(string value, [NotNullWhen(true)] out string? name, } } - private static bool TryExtractEndpointFromConnectionString(string connectionString, [NotNullWhen(true)] out string? endpoint) - { - endpoint = null; - - if (string.IsNullOrEmpty(connectionString)) - { - return false; - } - - // First, check if the entire connection string is a URL (e.g., blob storage, key vault) - if (Uri.TryCreate(connectionString, UriKind.Absolute, out var directUri)) - { - endpoint = directUri.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); - return true; - } - - // Parse connection string for Endpoint= pattern (used by GitHub Models and other resources) - var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries); - foreach (var part in parts) - { - var trimmedPart = part.Trim(); - if (trimmedPart.StartsWith("Endpoint=", StringComparison.OrdinalIgnoreCase)) - { - var endpointValue = trimmedPart[9..]; // Remove "Endpoint=" - if (Uri.TryCreate(endpointValue, UriKind.Absolute, out var uri)) - { - endpoint = uri.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); - return true; - } - } - } - - return false; - } - private static bool TryParseUrlHostAndPort(string value, [NotNullWhen(true)] out string? hostAndPort) { hostAndPort = null; diff --git a/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs b/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs new file mode 100644 index 00000000000..e12c08aad0b --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs @@ -0,0 +1,78 @@ +// 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)] + 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 From 46880446ade9964a7541707f659a709faaa07435 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 21:08:55 +0000 Subject: [PATCH 05/17] Add comprehensive connection string parser with extensive test coverage Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ConnectionStringParser.cs | 37 ++++++++- .../ConnectionStringParserTests.cs | 77 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs index 44bfccab885..e134f807ac7 100644 --- a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Linq; using System.Text.RegularExpressions; namespace Aspire.Dashboard.Model; @@ -44,7 +45,7 @@ public static class ConnectionStringParser ["kafka"] = 9092 }; - private static readonly string[] s_hostAliases = ["host", "server", "data source", "addr", "address", "endpoint"]; + private static readonly string[] s_hostAliases = ["host", "server", "data source", "addr", "address", "endpoint", "contact points"]; private static readonly Regex s_hostPortRegex = new(@"(\[[^\]]+\]|[^,:;\s]+)[:|,](\d{1,5})", RegexOptions.Compiled); @@ -83,6 +84,17 @@ public static bool TryDetectHostAndPort( { if (keyValuePairs.TryGetValue(hostAlias, out var token)) { + // First, check if the token is a complete URL + if (Uri.TryCreate(token, UriKind.Absolute, out var tokenUri) && !string.IsNullOrEmpty(tokenUri.Host)) + { + host = TrimBrackets(tokenUri.Host); + port = tokenUri.Port != -1 ? tokenUri.Port : DefaultPortFromScheme(tokenUri.Scheme); + return true; + } + + // Remove protocol prefixes like "tcp:", "udp:", etc. (but not from complete URLs) + token = RemoveProtocolPrefix(token); + if (token.Contains(',') || token.Contains(':')) { var (hostPart, portPart) = SplitOnLast(token); @@ -129,6 +141,29 @@ public static bool TryDetectHostAndPort( private static string TrimBrackets(string s) => s.Trim('[', ']'); + 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 + var knownProtocols = new[] { "tcp", "udp", "ssl", "tls", "http", "https", "ftp", "ssh" }; + if (knownProtocols.Contains(prefix)) + { + return value[(colonIndex + 1)..]; + } + } + + return value; + } + private static int? DefaultPortFromScheme(string? scheme) { if (string.IsNullOrEmpty(scheme)) diff --git a/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs b/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs index e12c08aad0b..e5a897cc401 100644 --- a/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConnectionStringParserTests.cs @@ -19,6 +19,83 @@ public class ConnectionStringParserTests [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, From 45deebfd043999e2be97ba7e7b793a6bb6e701cf Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 11 Jul 2025 20:31:20 -0700 Subject: [PATCH 06/17] Update src/Aspire.Dashboard/Model/ConnectionStringParser.cs --- src/Aspire.Dashboard/Model/ConnectionStringParser.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs index e134f807ac7..7e3fdc1069c 100644 --- a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Linq; using System.Text.RegularExpressions; namespace Aspire.Dashboard.Model; From 4f93406787804806c99241cd67339a5191af2351 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 07:26:59 +0000 Subject: [PATCH 07/17] Fix failing ConnectionStringParser tests for comprehensive connection string parsing Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ConnectionStringParser.cs | 85 +++++++++++++++++-- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs index 7e3fdc1069c..460d037e963 100644 --- a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -69,11 +69,11 @@ public static bool TryDetectHostAndPort( return false; } - // 1. URI parse - if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host)) + // 1. URI parse (including special handling for JDBC URLs) + if (TryParseAsUri(connectionString, out var uriHost, out var uriPort)) { - host = TrimBrackets(uri.Host); - port = uri.Port != -1 ? uri.Port : DefaultPortFromScheme(uri.Scheme); + host = uriHost; + port = uriPort; return true; } @@ -84,13 +84,20 @@ public static bool TryDetectHostAndPort( if (keyValuePairs.TryGetValue(hostAlias, out var token)) { // First, check if the token is a complete URL - if (Uri.TryCreate(token, UriKind.Absolute, out var tokenUri) && !string.IsNullOrEmpty(tokenUri.Host)) + if (TryParseAsUri(token, out var tokenHost, out var tokenPort)) { - host = TrimBrackets(tokenUri.Host); - port = tokenUri.Port != -1 ? tokenUri.Port : DefaultPortFromScheme(tokenUri.Scheme); + host = tokenHost; + port = tokenPort; return true; } + // Handle special case of multiple contact points (should return false) + if (hostAlias.Equals("contact points", StringComparison.OrdinalIgnoreCase) && + token.Contains(',') && token.Split(',').Length > 1) + { + return false; + } + // Remove protocol prefixes like "tcp:", "udp:", etc. (but not from complete URLs) token = RemoveProtocolPrefix(token); @@ -99,6 +106,27 @@ public static bool TryDetectHostAndPort( var (hostPart, portPart) = SplitOnLast(token); if (!string.IsNullOrEmpty(hostPart)) { + // Special handling for IPv6 addresses in brackets - don't split if already properly formatted + 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; + } + } + host = TrimBrackets(hostPart); port = ParseIntSafe(portPart) ?? PortFromKV(keyValuePairs); return true; @@ -138,6 +166,48 @@ public static bool TryDetectHostAndPort( return 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 + if (connectionString.StartsWith("jdbc:", StringComparison.OrdinalIgnoreCase)) + { + return TryParseJdbcUrl(connectionString, out host, out port); + } + + // Standard URI parsing + 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; + } + + private static bool TryParseJdbcUrl(string jdbcUrl, [NotNullWhen(true)] out string? host, out int? port) + { + host = null; + port = null; + + // JDBC URL pattern: jdbc:subprotocol://host:port/database + var match = Regex.Match(jdbcUrl, @"^jdbc:[^:]+://([^:/\s]+)(?::(\d+))?(?:/.*)?", RegexOptions.IgnoreCase); + 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; + } + private static string TrimBrackets(string s) => s.Trim('[', ']'); private static string RemoveProtocolPrefix(string value) @@ -246,6 +316,7 @@ private static bool LooksLikeHost(string connectionString) // Remove common file path indicators if (connectionString.StartsWith('/') || connectionString.StartsWith('\\') || + connectionString.StartsWith("./") || connectionString.StartsWith("../") || (connectionString.Length > 2 && connectionString[1] == ':' && char.IsLetter(connectionString[0]))) { return false; From dd1439daa47cd46b9fe4558ac9f32d1924ccf5b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 07:48:52 +0000 Subject: [PATCH 08/17] Refactor ConnectionStringParser with source-generated regexes and improved documentation Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ConnectionStringParser.cs | 263 ++++++++++++++---- 1 file changed, 215 insertions(+), 48 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs index 460d037e963..69578d4399d 100644 --- a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -9,8 +9,9 @@ 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. /// -public static class ConnectionStringParser +public static partial class ConnectionStringParser { private static readonly Dictionary s_schemeDefaultPorts = new(StringComparer.OrdinalIgnoreCase) { @@ -46,11 +47,29 @@ public static class ConnectionStringParser private static readonly string[] s_hostAliases = ["host", "server", "data source", "addr", "address", "endpoint", "contact points"]; - private static readonly Regex s_hostPortRegex = new(@"(\[[^\]]+\]|[^,:;\s]+)[:|,](\d{1,5})", RegexOptions.Compiled); + /// + /// 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. @@ -69,21 +88,87 @@ public static bool TryDetectHostAndPort( return false; } - // 1. URI parse (including special handling for JDBC URLs) - if (TryParseAsUri(connectionString, out var uriHost, out var uriPort)) + // 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)) { - host = uriHost; - port = uriPort; return true; } - // 2. Key-value scan + // 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; @@ -91,49 +176,31 @@ public static bool TryDetectHostAndPort( return true; } - // Handle special case of multiple contact points (should return false) + // 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. (but not from complete URLs) + // Remove protocol prefixes like "tcp:", "udp:", etc. + // Example: "Server=tcp:localhost,1433" becomes "localhost,1433" token = RemoveProtocolPrefix(token); if (token.Contains(',') || token.Contains(':')) { - var (hostPart, portPart) = SplitOnLast(token); - if (!string.IsNullOrEmpty(hostPart)) + // 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)) { - // Special handling for IPv6 addresses in brackets - don't split if already properly formatted - 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; - } - } - - host = TrimBrackets(hostPart); - port = ParseIntSafe(portPart) ?? PortFromKV(keyValuePairs); return true; } } else if (!string.IsNullOrEmpty(token)) { + // Single hostname without port + // Example: "Host=localhost" host = TrimBrackets(token); port = PortFromKV(keyValuePairs); return true; @@ -141,8 +208,22 @@ public static bool TryDetectHostAndPort( } } - // 3. Regex heuristic for host:port or host,port patterns - var match = s_hostPortRegex.Match(connectionString); + 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; @@ -155,7 +236,21 @@ public static bool TryDetectHostAndPort( } } - // 4. Looks like single host token (no '=' etc.) + 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); @@ -166,35 +261,66 @@ public static bool TryDetectHostAndPort( return false; } - private static bool TryParseAsUri(string connectionString, [NotNullWhen(true)] out string? host, out int? port) + /// + /// 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; - // Handle JDBC URLs specially since they're not recognized by Uri.TryCreate - if (connectionString.StartsWith("jdbc:", StringComparison.OrdinalIgnoreCase)) + // Special handling for IPv6 addresses in brackets + // Example: "[::1]:6379" or "[::1],6379" + if (token.StartsWith('[') && token.Contains(']')) { - return TryParseJdbcUrl(connectionString, out host, out port); + 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; + } } - // Standard URI parsing - if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host)) + // Regular host:port or host,port parsing + var (hostPart, portPart) = SplitOnLast(token); + if (!string.IsNullOrEmpty(hostPart)) { - host = TrimBrackets(uri.Host); - port = uri.Port != -1 ? uri.Port : DefaultPortFromScheme(uri.Scheme); + 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; - // JDBC URL pattern: jdbc:subprotocol://host:port/database - var match = Regex.Match(jdbcUrl, @"^jdbc:[^:]+://([^:/\s]+)(?::(\d+))?(?:/.*)?", RegexOptions.IgnoreCase); + var match = JdbcUrlRegex().Match(jdbcUrl); if (match.Success) { host = match.Groups[1].Value; @@ -208,8 +334,18 @@ private static bool TryParseJdbcUrl(string jdbcUrl, [NotNullWhen(true)] out stri 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. @@ -233,6 +369,11 @@ private static string RemoveProtocolPrefix(string value) 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)) @@ -243,11 +384,21 @@ private static string RemoveProtocolPrefix(string value) 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)) @@ -264,6 +415,11 @@ private static string RemoveProtocolPrefix(string 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); @@ -291,6 +447,11 @@ private static Dictionary SplitIntoDictionary(string connectionS 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 ',' @@ -306,6 +467,12 @@ private static (string host, string port) SplitOnLast(string token) return (token, string.Empty); } + /// + /// Determines if a string looks like a hostname rather than a file path or other non-hostname string. + /// Uses 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) { // Simple heuristic: if it doesn't contain '=' and looks like a hostname or IP @@ -314,7 +481,7 @@ private static bool LooksLikeHost(string connectionString) return false; } - // Remove common file path indicators + // Remove common file path indicators (be conservative to avoid false positives) if (connectionString.StartsWith('/') || connectionString.StartsWith('\\') || connectionString.StartsWith("./") || connectionString.StartsWith("../") || (connectionString.Length > 2 && connectionString[1] == ':' && char.IsLetter(connectionString[0]))) From 4e6f99e1ab717836c89198f34cb30488f70813e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 09:58:54 +0000 Subject: [PATCH 09/17] Use ConnectionStringParser for Parameter resources and remove TryParseUrlHostAndPort Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ResourceOutgoingPeerResolver.cs | 34 +++++-------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index e34f8d4877c..0219b766209 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -159,15 +159,18 @@ bool TryMatchResourceAddress(string value, [NotNullWhen(true)] out string? name, } } - // Try to match against parameter values (for Parameter resources that contain URLs) + // Try to match against parameter values (for Parameter resources that contain URLs or host:port values) if (resource.Properties.TryGetValue(KnownProperties.Parameter.Value, out var parameterValueProperty) && parameterValueProperty.Value.TryConvertToString(out var parameterValue) && - TryParseUrlHostAndPort(parameterValue, out var parameterHostAndPort) && - DoesAddressMatch(parameterHostAndPort, value)) + ConnectionStringParser.TryDetectHostAndPort(parameterValue, out var parameterHost, out var parameterPort)) { - name = ResourceViewModel.GetResourceName(resource, resources); - resourceMatch = resource; - return true; + var parameterEndpoint = parameterPort.HasValue ? $"{parameterHost}:{parameterPort.Value}" : parameterHost; + if (DoesAddressMatch(parameterEndpoint, value)) + { + name = ResourceViewModel.GetResourceName(resource, resources); + resourceMatch = resource; + return true; + } } } @@ -177,25 +180,6 @@ bool TryMatchResourceAddress(string value, [NotNullWhen(true)] out string? name, } } - private static bool TryParseUrlHostAndPort(string value, [NotNullWhen(true)] out string? hostAndPort) - { - hostAndPort = null; - - if (string.IsNullOrEmpty(value)) - { - return false; - } - - // Try to parse as a URL - if (Uri.TryCreate(value, UriKind.Absolute, out var uri)) - { - hostAndPort = uri.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); - return true; - } - - return false; - } - private static bool DoesAddressMatch(string endpoint, string value) { if (string.Equals(endpoint, value, StringComparison.OrdinalIgnoreCase)) From 2de675133097a5967f3d4903e4da61f1abc3fe82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 10:13:47 +0000 Subject: [PATCH 10/17] Implement robust hostname validation using RFC-compliant logic Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ConnectionStringParser.cs | 100 ++++++++++++++++-- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs index 69578d4399d..ea29b780a2a 100644 --- a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -469,19 +469,19 @@ private static (string host, string port) SplitOnLast(string token) /// /// Determines if a string looks like a hostname rather than a file path or other non-hostname string. - /// Uses conservative heuristics to avoid false positives. + /// Uses RFC-compliant 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) { - // Simple heuristic: if it doesn't contain '=' and looks like a hostname or IP + // Reject strings with '=' (likely key-value pairs) if (connectionString.Contains('=')) { return false; } - // Remove common file path indicators (be conservative to avoid false positives) + // Reject obvious file path indicators if (connectionString.StartsWith('/') || connectionString.StartsWith('\\') || connectionString.StartsWith("./") || connectionString.StartsWith("../") || (connectionString.Length > 2 && connectionString[1] == ':' && char.IsLetter(connectionString[0]))) @@ -489,10 +489,96 @@ private static bool LooksLikeHost(string connectionString) return false; } - // Should contain dots (for domains) or be a simple name, and not contain spaces var trimmed = connectionString.Trim(); - return !string.IsNullOrEmpty(trimmed) && - !trimmed.Contains(' ') && - (trimmed.Contains('.') || !trimmed.Contains('/')); + + // Basic sanity checks + if (string.IsNullOrEmpty(trimmed) || trimmed.Contains(' ') || trimmed.Contains('\t')) + { + return false; + } + + // Try to validate as hostname using more robust logic + return IsValidHostname(trimmed) || IsValidIPAddress(trimmed); + } + + /// + /// Validates if a string conforms to hostname rules based on RFC 1123. + /// + /// The hostname to validate + /// True if the hostname is valid; otherwise false. + private static bool IsValidHostname(string hostname) + { + // RFC 1123 hostname rules: + // - Can contain letters, digits, dots, and hyphens + // - Cannot start or end with hyphen + // - Each label (between dots) must be 1-63 characters + // - Total length must be ≤ 253 characters + + if (hostname.Length == 0 || hostname.Length > 253) + { + return false; + } + + // Cannot start or end with dot or hyphen + if (hostname.StartsWith('.') || hostname.EndsWith('.') || + hostname.StartsWith('-') || hostname.EndsWith('-')) + { + return false; + } + + // Split into labels and validate each + var labels = hostname.Split('.'); + + foreach (var label in labels) + { + if (!IsValidHostnameLabel(label)) + { + return false; + } + } + + return true; + } + + /// + /// Validates if a hostname label (part between dots) is valid. + /// + /// The label to validate + /// True if the label is valid; otherwise false. + private static bool IsValidHostnameLabel(string label) + { + // Label must be 1-63 characters + if (label.Length == 0 || label.Length > 63) + { + return false; + } + + // Cannot start or end with hyphen + if (label.StartsWith('-') || label.EndsWith('-')) + { + return false; + } + + // Can only contain letters, digits, and hyphens + foreach (var c in label) + { + if (!char.IsLetterOrDigit(c) && c != '-') + { + return false; + } + } + + return true; + } + + /// + /// Checks if a string is a valid IP address (IPv4 or IPv6). + /// + /// The address to validate + /// True if the address is a valid IP; otherwise false. + private static bool IsValidIPAddress(string address) + { + // Use .NET's IPAddress parsing for robust IP validation + return System.Net.IPAddress.TryParse(address, out _); } } \ No newline at end of file From f7add632acf97b1e2adf8606971c4fcb520c8f15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 10:30:35 +0000 Subject: [PATCH 11/17] Simplify hostname validation using URI parsing as suggested Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ConnectionStringParser.cs | 88 +------------------ 1 file changed, 4 insertions(+), 84 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs index ea29b780a2a..6840e6f104f 100644 --- a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -469,7 +469,7 @@ private static (string host, string port) SplitOnLast(string token) /// /// Determines if a string looks like a hostname rather than a file path or other non-hostname string. - /// Uses RFC-compliant validation with conservative heuristics to avoid false positives. + /// 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. @@ -497,88 +497,8 @@ private static bool LooksLikeHost(string connectionString) return false; } - // Try to validate as hostname using more robust logic - return IsValidHostname(trimmed) || IsValidIPAddress(trimmed); - } - - /// - /// Validates if a string conforms to hostname rules based on RFC 1123. - /// - /// The hostname to validate - /// True if the hostname is valid; otherwise false. - private static bool IsValidHostname(string hostname) - { - // RFC 1123 hostname rules: - // - Can contain letters, digits, dots, and hyphens - // - Cannot start or end with hyphen - // - Each label (between dots) must be 1-63 characters - // - Total length must be ≤ 253 characters - - if (hostname.Length == 0 || hostname.Length > 253) - { - return false; - } - - // Cannot start or end with dot or hyphen - if (hostname.StartsWith('.') || hostname.EndsWith('.') || - hostname.StartsWith('-') || hostname.EndsWith('-')) - { - return false; - } - - // Split into labels and validate each - var labels = hostname.Split('.'); - - foreach (var label in labels) - { - if (!IsValidHostnameLabel(label)) - { - return false; - } - } - - return true; - } - - /// - /// Validates if a hostname label (part between dots) is valid. - /// - /// The label to validate - /// True if the label is valid; otherwise false. - private static bool IsValidHostnameLabel(string label) - { - // Label must be 1-63 characters - if (label.Length == 0 || label.Length > 63) - { - return false; - } - - // Cannot start or end with hyphen - if (label.StartsWith('-') || label.EndsWith('-')) - { - return false; - } - - // Can only contain letters, digits, and hyphens - foreach (var c in label) - { - if (!char.IsLetterOrDigit(c) && c != '-') - { - return false; - } - } - - return true; - } - - /// - /// Checks if a string is a valid IP address (IPv4 or IPv6). - /// - /// The address to validate - /// True if the address is a valid IP; otherwise false. - private static bool IsValidIPAddress(string address) - { - // Use .NET's IPAddress parsing for robust IP validation - return System.Net.IPAddress.TryParse(address, out _); + // Use Uri parsing to validate hostname - create a fake URI and see if it parses + var fakeUri = $"scheme://{trimmed}"; + return Uri.TryCreate(fakeUri, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host); } } \ No newline at end of file From b2c38bea2287f854269024464e65653db339ad34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 14:24:09 +0000 Subject: [PATCH 12/17] Optimize ConnectionStringParser by using static readonly arrays and simplifying hostname validation Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ConnectionStringParser.cs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs index 6840e6f104f..7ca3e1fcd0c 100644 --- a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -47,6 +47,8 @@ public static partial class ConnectionStringParser 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" @@ -359,8 +361,7 @@ private static string RemoveProtocolPrefix(string value) { var prefix = value[..colonIndex].ToLowerInvariant(); // Only remove known protocol prefixes, not arbitrary single letters - var knownProtocols = new[] { "tcp", "udp", "ssl", "tls", "http", "https", "ftp", "ssh" }; - if (knownProtocols.Contains(prefix)) + if (s_knownProtocols.Contains(prefix)) { return value[(colonIndex + 1)..]; } @@ -489,16 +490,8 @@ private static bool LooksLikeHost(string connectionString) return false; } - var trimmed = connectionString.Trim(); - - // Basic sanity checks - if (string.IsNullOrEmpty(trimmed) || trimmed.Contains(' ') || trimmed.Contains('\t')) - { - return false; - } - // Use Uri parsing to validate hostname - create a fake URI and see if it parses - var fakeUri = $"scheme://{trimmed}"; + var fakeUri = $"scheme://{connectionString.Trim()}"; return Uri.TryCreate(fakeUri, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host); } } \ No newline at end of file From e172cc4622b576e8bfe52ba0e9afd5b4e2de0957 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 12 Jul 2025 16:04:38 +0000 Subject: [PATCH 13/17] Enhance GitHubModel resource initialization with connection string resolution and state management --- .../GitHubModelsExtensions.cs | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) 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; From ba9ed8143d0dd21c5b886e50fac0b05180a57eb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Jul 2025 23:42:43 +0000 Subject: [PATCH 14/17] Change ConnectionStringParser class from public to internal Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Dashboard/Model/ConnectionStringParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs index 7ca3e1fcd0c..67c52d6c75b 100644 --- a/src/Aspire.Dashboard/Model/ConnectionStringParser.cs +++ b/src/Aspire.Dashboard/Model/ConnectionStringParser.cs @@ -11,7 +11,7 @@ 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. /// -public static partial class ConnectionStringParser +internal static partial class ConnectionStringParser { private static readonly Dictionary s_schemeDefaultPorts = new(StringComparer.OrdinalIgnoreCase) { From 19c344f7a4c0a948adcbc3adaf859fb87b848b4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Jul 2025 04:17:14 +0000 Subject: [PATCH 15/17] Refactor to eliminate nested transformer loops and extend change detection Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> --- .../Model/ResourceOutgoingPeerResolver.cs | 180 ++++++++++++------ 1 file changed, 120 insertions(+), 60 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index 0219b766209..b5b58c0b708 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,6 +118,30 @@ private static bool AreEquivalent(ImmutableArray urls1, ImmutableA return true; } + private static bool ArePropertyValuesEquivalent(ResourceViewModel resource1, ResourceViewModel resource2, string propertyName) + { + 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); + } + public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResource) { return TryResolvePeerNameCore(_resourceByName, attributes, out name, out matchedResource); @@ -104,20 +152,23 @@ internal static bool TryResolvePeerNameCore(IDictionary "localhost:5000" -> "127.0.0.1:5000" - var transformedAddress = address; + + // Then apply each transformer cumulatively and check foreach (var transformer in s_addressTransformers) { transformedAddress = transformer(transformedAddress); - if (TryMatchResourceAddress(transformedAddress, out name, out resourceMatch)) + if (TryMatchAgainstResourceAddresses(transformedAddress, resourceAddresses, resources, out name, out resourceMatch)) { return true; } @@ -127,57 +178,27 @@ internal static bool TryResolvePeerNameCore(IDictionary + /// Checks if a transformed peer address matches any of the resource addresses. + /// Applies the same transformations to resource addresses for consistent matching. + /// + private static bool TryMatchAgainstResourceAddresses(string peerAddress, List<(string Address, ResourceViewModel Resource)> resourceAddresses, IDictionary resources, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch) + { + foreach (var (resourceAddress, resource) in resourceAddresses) { - foreach (var (resourceName, resource) in resources) + if (DoesAddressMatch(resourceAddress, peerAddress)) { - // Try to match against URL endpoints - foreach (var service in resource.Urls) - { - var hostAndPort = service.Url.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); - - if (DoesAddressMatch(hostAndPort, value)) - { - name = ResourceViewModel.GetResourceName(resource, resources); - resourceMatch = resource; - return true; - } - } - - // Try to match against connection strings using comprehensive parsing - if (resource.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; - if (DoesAddressMatch(endpoint, value)) - { - name = ResourceViewModel.GetResourceName(resource, resources); - resourceMatch = resource; - return true; - } - } - - // Try to match against parameter values (for Parameter resources that contain URLs or host:port values) - if (resource.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; - if (DoesAddressMatch(parameterEndpoint, value)) - { - 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) @@ -201,6 +222,45 @@ private static bool DoesAddressMatch(string endpoint, string value) return false; } + /// + /// Extracts all possible addresses from resources that can be used for peer matching. + /// Returns a list of (address, resource) pairs for efficient lookup. + /// + private static List<(string Address, ResourceViewModel Resource)> ExtractResourceAddresses(IDictionary resources) + { + var addresses = new List<(string, ResourceViewModel)>(); + + foreach (var (_, resource) in resources) + { + // Extract addresses from URL endpoints + foreach (var service in resource.Urls) + { + var hostAndPort = service.Url.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); + addresses.Add((hostAndPort, resource)); + } + + // Extract addresses from connection strings using comprehensive parsing + if (resource.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, resource)); + } + + // Extract addresses from parameter values (for Parameter resources that contain URLs or host:port values) + if (resource.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, resource)); + } + } + + return addresses; + } + private static readonly List> s_addressTransformers = [ s => { From c2a3f37ad2497761b71cf62902beb20cbf8e4ae2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Jul 2025 05:16:53 +0000 Subject: [PATCH 16/17] Cache resource addresses on ResourceOutgoingPeerResolver to avoid recomputation on each peer resolution Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ResourceOutgoingPeerResolver.cs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index b5b58c0b708..ef4c95aca76 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -17,6 +17,9 @@ public sealed class ResourceOutgoingPeerResolver : IOutgoingPeerResolver, IAsync private readonly List _subscriptions = []; private readonly object _lock = new(); private readonly Task? _watchTask; + + // Cache of extracted resource addresses to avoid recomputation on each peer resolution + private volatile List<(string Address, ResourceViewModel Resource)> _cachedResourceAddresses = []; public ResourceOutgoingPeerResolver(IDashboardClient resourceService) { @@ -37,6 +40,8 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService) Debug.Assert(added, "Should not receive duplicate resources in initial snapshot data."); } + // Initialize cached resource addresses after loading initial snapshot + _cachedResourceAddresses = ExtractResourceAddresses(_resourceByName); await RaisePeerChangesAsync().ConfigureAwait(false); } @@ -67,6 +72,8 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService) if (hasPeerRelevantChanges) { + // Recompute cached resource addresses when peer-relevant changes are detected + _cachedResourceAddresses = ExtractResourceAddresses(_resourceByName); await RaisePeerChangesAsync().ConfigureAwait(false); } } @@ -144,7 +151,35 @@ private static bool ArePropertyValuesEquivalent(ResourceViewModel resource1, Res public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResource) { - return TryResolvePeerNameCore(_resourceByName, attributes, out name, out matchedResource); + var address = OtlpHelpers.GetPeerAddress(attributes); + if (address != null) + { + // Use cached resource addresses for efficient lookup + var cachedAddresses = _cachedResourceAddresses; // Get snapshot to avoid race conditions + + // Apply transformers to the peer address cumulatively + var transformedAddress = address; + + // First check exact match + if (TryMatchAgainstResourceAddresses(transformedAddress, cachedAddresses, _resourceByName, out name, out matchedResource)) + { + return true; + } + + // Then apply each transformer cumulatively and check + foreach (var transformer in s_addressTransformers) + { + transformedAddress = transformer(transformedAddress); + if (TryMatchAgainstResourceAddresses(transformedAddress, cachedAddresses, _resourceByName, out name, out matchedResource)) + { + return true; + } + } + } + + name = null; + matchedResource = null; + return false; } internal static bool TryResolvePeerNameCore(IDictionary resources, KeyValuePair[] attributes, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch) From 3b2a87781a0fa2430b7e0fda5fa4b69745a5068a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Jul 2025 05:36:07 +0000 Subject: [PATCH 17/17] Move cache from ResourceOutgoingPeerResolver to ResourceViewModel Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Model/ResourceOutgoingPeerResolver.cs | 77 ++++--------------- .../Model/ResourceViewModel.cs | 39 ++++++++++ 2 files changed, 53 insertions(+), 63 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index ef4c95aca76..15b9401fef0 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -17,9 +17,6 @@ public sealed class ResourceOutgoingPeerResolver : IOutgoingPeerResolver, IAsync private readonly List _subscriptions = []; private readonly object _lock = new(); private readonly Task? _watchTask; - - // Cache of extracted resource addresses to avoid recomputation on each peer resolution - private volatile List<(string Address, ResourceViewModel Resource)> _cachedResourceAddresses = []; public ResourceOutgoingPeerResolver(IDashboardClient resourceService) { @@ -40,8 +37,6 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService) Debug.Assert(added, "Should not receive duplicate resources in initial snapshot data."); } - // Initialize cached resource addresses after loading initial snapshot - _cachedResourceAddresses = ExtractResourceAddresses(_resourceByName); await RaisePeerChangesAsync().ConfigureAwait(false); } @@ -72,8 +67,6 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService) if (hasPeerRelevantChanges) { - // Recompute cached resource addresses when peer-relevant changes are detected - _cachedResourceAddresses = ExtractResourceAddresses(_resourceByName); await RaisePeerChangesAsync().ConfigureAwait(false); } } @@ -154,14 +147,11 @@ public bool TryResolvePeer(KeyValuePair[] attributes, out string var address = OtlpHelpers.GetPeerAddress(attributes); if (address != null) { - // Use cached resource addresses for efficient lookup - var cachedAddresses = _cachedResourceAddresses; // Get snapshot to avoid race conditions - // Apply transformers to the peer address cumulatively var transformedAddress = address; // First check exact match - if (TryMatchAgainstResourceAddresses(transformedAddress, cachedAddresses, _resourceByName, out name, out matchedResource)) + if (TryMatchAgainstResources(transformedAddress, _resourceByName, out name, out matchedResource)) { return true; } @@ -170,7 +160,7 @@ public bool TryResolvePeer(KeyValuePair[] attributes, out string foreach (var transformer in s_addressTransformers) { transformedAddress = transformer(transformedAddress); - if (TryMatchAgainstResourceAddresses(transformedAddress, cachedAddresses, _resourceByName, out name, out matchedResource)) + if (TryMatchAgainstResources(transformedAddress, _resourceByName, out name, out matchedResource)) { return true; } @@ -187,14 +177,11 @@ internal static bool TryResolvePeerNameCore(IDictionary - /// Checks if a transformed peer address matches any of the resource addresses. + /// 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 TryMatchAgainstResourceAddresses(string peerAddress, List<(string Address, ResourceViewModel Resource)> resourceAddresses, IDictionary resources, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch) + private static bool TryMatchAgainstResources(string peerAddress, IDictionary resources, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out ResourceViewModel? resourceMatch) { - foreach (var (resourceAddress, resource) in resourceAddresses) + foreach (var (_, resource) in resources) { - if (DoesAddressMatch(resourceAddress, peerAddress)) + foreach (var resourceAddress in resource.CachedAddresses) { - name = ResourceViewModel.GetResourceName(resource, resources); - resourceMatch = resource; - return true; + if (DoesAddressMatch(resourceAddress, peerAddress)) + { + name = ResourceViewModel.GetResourceName(resource, resources); + resourceMatch = resource; + return true; + } } } @@ -257,45 +247,6 @@ private static bool DoesAddressMatch(string endpoint, string value) return false; } - /// - /// Extracts all possible addresses from resources that can be used for peer matching. - /// Returns a list of (address, resource) pairs for efficient lookup. - /// - private static List<(string Address, ResourceViewModel Resource)> ExtractResourceAddresses(IDictionary resources) - { - var addresses = new List<(string, ResourceViewModel)>(); - - foreach (var (_, resource) in resources) - { - // Extract addresses from URL endpoints - foreach (var service in resource.Urls) - { - var hostAndPort = service.Url.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); - addresses.Add((hostAndPort, resource)); - } - - // Extract addresses from connection strings using comprehensive parsing - if (resource.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, resource)); - } - - // Extract addresses from parameter values (for Parameter resources that contain URLs or host:port values) - if (resource.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, resource)); - } - } - - return addresses; - } - private static readonly List> s_addressTransformers = [ s => { 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;