diff --git a/Microsoft.Azure.Cosmos/src/DocumentClient.cs b/Microsoft.Azure.Cosmos/src/DocumentClient.cs index 34bdc40148..02456b67c1 100644 --- a/Microsoft.Azure.Cosmos/src/DocumentClient.cs +++ b/Microsoft.Azure.Cosmos/src/DocumentClient.cs @@ -6782,6 +6782,9 @@ private void InitializeDirectConnectivity(IStoreClientFactory storeClientFactory remoteCertificateValidationCallback: this.remoteCertificateValidationCallback, distributedTracingOptions: distributedTracingOptions, enableChannelMultiplexing: ConfigurationManager.IsTcpChannelMultiplexingEnabled(), + dnsResolutionFunction: ConfigurationManager.IsTcpDnsDotSuffixEnabled() + ? DnsDotSuffixHelper.ResolveHostAsync + : null, chaosInterceptor: this.chaosInterceptor); if (this.transportClientHandlerFactory != null) diff --git a/Microsoft.Azure.Cosmos/src/Util/ConfigurationManager.cs b/Microsoft.Azure.Cosmos/src/Util/ConfigurationManager.cs index b5b4d8eec2..93ce57f25b 100644 --- a/Microsoft.Azure.Cosmos/src/Util/ConfigurationManager.cs +++ b/Microsoft.Azure.Cosmos/src/Util/ConfigurationManager.cs @@ -125,6 +125,14 @@ internal static class ConfigurationManager /// internal static readonly string UseLengthAwareRangeComparator = "AZURE_COSMOS_USE_LENGTH_AWARE_RANGE_COMPARATOR"; + /// + /// Environment variable name to enable DNS dot-suffix (FQDN trailing dot) for + /// Direct mode TCP connections. When enabled, appends a trailing '.' to hostnames + /// before DNS resolution to bypass Kubernetes ndots search-domain expansion. + /// See: https://github.com/Azure/azure-cosmos-dotnet-v3/issues/5730 + /// + internal static readonly string TcpDnsDotSuffixEnabled = "AZURE_COSMOS_TCP_DNS_DOT_SUFFIX_ENABLED"; + public static T GetEnvironmentVariable(string variable, T defaultValue) { string value = Environment.GetEnvironmentVariable(variable); @@ -401,5 +409,22 @@ public static bool IsLengthAwareRangeComparatorEnabled() variable: ConfigurationManager.UseLengthAwareRangeComparator, defaultValue: defaultValue); } + + /// + /// Gets the boolean value indicating if DNS dot-suffix (FQDN trailing dot) is enabled + /// for Direct mode TCP connections. When enabled, appends a trailing '.' to hostnames + /// before DNS resolution, causing the resolver to treat them as absolute (fully qualified) + /// names and skip search-domain expansion. This avoids unnecessary DNS lookups on Kubernetes + /// where ndots:5 causes multiple failed search-domain attempts for Cosmos DB endpoints. + /// Default: false (opt-in). + /// + /// A boolean flag indicating if TCP DNS dot-suffix is enabled. + public static bool IsTcpDnsDotSuffixEnabled() + { + return ConfigurationManager + .GetEnvironmentVariable( + variable: ConfigurationManager.TcpDnsDotSuffixEnabled, + defaultValue: false); + } } } diff --git a/Microsoft.Azure.Cosmos/src/Util/DnsDotSuffixHelper.cs b/Microsoft.Azure.Cosmos/src/Util/DnsDotSuffixHelper.cs new file mode 100644 index 0000000000..ae37f993f9 --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Util/DnsDotSuffixHelper.cs @@ -0,0 +1,51 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos +{ + using System.Net; + using System.Threading.Tasks; + using Microsoft.Azure.Documents.Rntbd; + + /// + /// Helper for DNS dot-suffix (FQDN trailing dot) resolution. + /// Appending a trailing dot to a hostname signals the DNS resolver that the name is + /// fully qualified (absolute) and must not be subject to search-domain expansion. + /// This avoids multiple unnecessary DNS lookups on Kubernetes where the default + /// ndots:5 configuration causes search-domain attempts for Cosmos DB endpoints. + /// + internal static class DnsDotSuffixHelper + { + /// + /// Appends a trailing dot to the hostname to make it a fully qualified domain name (FQDN). + /// Returns the hostname unchanged if it is null/empty, already ends with a dot, + /// or is an IP address (IPv4 or IPv6). + /// + /// The hostname to convert to FQDN form. + /// The hostname with a trailing dot appended, or unchanged if not applicable. + internal static string ToFqdnHostName(string hostName) + { + if (string.IsNullOrEmpty(hostName) + || hostName.EndsWith(".") + || IPAddress.TryParse(hostName, out _)) + { + return hostName; + } + + return hostName + "."; + } + + /// + /// Creates a DNS resolution function that appends a trailing dot to the hostname + /// before resolving, to bypass Kubernetes ndots search-domain expansion. + /// Intended for use with StoreClientFactory.dnsResolutionFunction. + /// + /// A function that resolves a dot-suffixed hostname to an . + internal static Task ResolveHostAsync(string hostName) + { + string fqdnHost = DnsDotSuffixHelper.ToFqdnHostName(hostName); + return Connection.ResolveHostAsync(fqdnHost, includeIPv6Addresses: true); + } + } +} diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DnsDotSuffixHelperTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DnsDotSuffixHelperTests.cs new file mode 100644 index 0000000000..71962e26d9 --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/DnsDotSuffixHelperTests.cs @@ -0,0 +1,78 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Tests +{ + using System; + using System.Net; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class DnsDotSuffixHelperTests + { + [TestMethod] + public void ToFqdnHostName_AppendsTrailingDot() + { + string result = DnsDotSuffixHelper.ToFqdnHostName("myaccount.documents.azure.com"); + Assert.AreEqual("myaccount.documents.azure.com.", result); + } + + [TestMethod] + public void ToFqdnHostName_IdempotentWhenAlreadyDotSuffixed() + { + string result = DnsDotSuffixHelper.ToFqdnHostName("myaccount.documents.azure.com."); + Assert.AreEqual("myaccount.documents.azure.com.", result); + } + + [TestMethod] + public void ToFqdnHostName_SkipsIPv4Address() + { + string result = DnsDotSuffixHelper.ToFqdnHostName("10.0.0.1"); + Assert.AreEqual("10.0.0.1", result); + } + + [TestMethod] + public void ToFqdnHostName_SkipsIPv6Address() + { + string result = DnsDotSuffixHelper.ToFqdnHostName("::1"); + Assert.AreEqual("::1", result); + } + + [TestMethod] + public void ToFqdnHostName_SkipsIPv6FullAddress() + { + string result = DnsDotSuffixHelper.ToFqdnHostName("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + Assert.AreEqual("2001:0db8:85a3:0000:0000:8a2e:0370:7334", result); + } + + [TestMethod] + public void ToFqdnHostName_ReturnsNullForNull() + { + string result = DnsDotSuffixHelper.ToFqdnHostName(null); + Assert.IsNull(result); + } + + [TestMethod] + public void ToFqdnHostName_ReturnsEmptyForEmpty() + { + string result = DnsDotSuffixHelper.ToFqdnHostName(string.Empty); + Assert.AreEqual(string.Empty, result); + } + + [TestMethod] + public void ToFqdnHostName_AppendsTrailingDotToLocalhost() + { + string result = DnsDotSuffixHelper.ToFqdnHostName("localhost"); + Assert.AreEqual("localhost.", result); + } + + [TestMethod] + public void ToFqdnHostName_AppendsTrailingDotToSingleLabel() + { + string result = DnsDotSuffixHelper.ToFqdnHostName("cosmosdb"); + Assert.AreEqual("cosmosdb.", result); + } + } +}