Skip to content

Commit d39da14

Browse files
authored
Extend service discovery to support Consul-based DNS lookups: (#6914)
* Extend service discovery to support Consul-based DNS lookups: - Enable specifying how to construct the DNS SRV query - Enable adding the Consul DNS server host/port * Address review feedback * Review feedback: Move DnsResolverOptions to parent namespace * Review feedback: Use options pattern for DnsResolverOptions * Simplify Configure calls, fix broken tests
1 parent bde3078 commit d39da14

File tree

17 files changed

+303
-109
lines changed

17 files changed

+303
-109
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Net;
5+
6+
namespace Microsoft.Extensions.ServiceDiscovery.Dns;
7+
8+
/// <summary>
9+
/// Provides configuration options for DNS resolution, including server endpoints, retry attempts, and timeout settings.
10+
/// </summary>
11+
public class DnsResolverOptions
12+
{
13+
/// <summary>
14+
/// Gets or sets the collection of server endpoints used for network connections.
15+
/// </summary>
16+
public IList<IPEndPoint> Servers { get; set; } = new List<IPEndPoint>();
17+
18+
/// <summary>
19+
/// Gets or sets the maximum number of attempts per server.
20+
/// </summary>
21+
public int MaxAttempts { get; set; } = 2;
22+
23+
/// <summary>
24+
/// Gets or sets the maximum duration per attempt to wait before timing out.
25+
/// </summary>
26+
/// <remarks>
27+
/// The maximum time for resolving a query is <see cref="MaxAttempts"/> * <see cref="Servers"/> count * <see cref="Timeout"/>.
28+
/// </remarks>
29+
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(3);
30+
31+
// override for testing purposes
32+
internal Func<Memory<byte>, int, int>? _transportOverride;
33+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace Microsoft.Extensions.ServiceDiscovery.Dns;
8+
9+
internal sealed class DnsResolverOptionsValidator : IValidateOptions<DnsResolverOptions>
10+
{
11+
// CancellationTokenSource.CancelAfter has a maximum timeout of Int32.MaxValue milliseconds.
12+
private static readonly TimeSpan s_maxTimeout = TimeSpan.FromMilliseconds(int.MaxValue);
13+
14+
public ValidateOptionsResult Validate(string? name, DnsResolverOptions options)
15+
{
16+
if (options.Servers is null)
17+
{
18+
return ValidateOptionsResult.Fail($"{nameof(options.Servers)} must not be null.");
19+
}
20+
21+
if (options.MaxAttempts < 1)
22+
{
23+
return ValidateOptionsResult.Fail($"{nameof(options.MaxAttempts)} must be one or greater.");
24+
}
25+
26+
if (options.Timeout != Timeout.InfiniteTimeSpan)
27+
{
28+
if (options.Timeout <= TimeSpan.Zero)
29+
{
30+
return ValidateOptionsResult.Fail($"{nameof(options.Timeout)} must not be negative or zero.");
31+
}
32+
33+
if (options.Timeout > s_maxTimeout)
34+
{
35+
return ValidateOptionsResult.Fail($"{nameof(options.Timeout)} must not be greater than {s_maxTimeout.TotalMilliseconds} milliseconds.");
36+
}
37+
}
38+
39+
return ValidateOptionsResult.Success;
40+
}
41+
}

src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderFactory.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ internal sealed partial class DnsSrvServiceEndpointProviderFactory(
2222
/// <inheritdoc/>
2323
public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider)
2424
{
25+
var optionsValue = options.CurrentValue;
26+
2527
// If a default namespace is not specified, then this provider will attempt to infer the namespace from the service name, but only when running inside Kubernetes.
2628
// Kubernetes DNS spec: https://github.com/kubernetes/dns/blob/master/docs/specification.md
2729
// SRV records are available for headless services with named ports.
@@ -30,19 +32,26 @@ public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] ou
3032
// Otherwise, the namespace can be read from /var/run/secrets/kubernetes.io/serviceaccount/namespace and combined with an assumed suffix of "svc.cluster.local".
3133
// The protocol is assumed to be "tcp".
3234
// The portName is the name of the port in the service definition. If the serviceName parses as a URI, we use the scheme as the port name, otherwise "default".
33-
if (string.IsNullOrWhiteSpace(_querySuffix))
35+
if (optionsValue.ServiceDomainNameCallback == null && string.IsNullOrWhiteSpace(_querySuffix))
3436
{
3537
DnsServiceEndpointProviderBase.Log.NoDnsSuffixFound(logger, query.ToString()!);
3638
provider = default;
3739
return false;
3840
}
3941

40-
var portName = query.EndpointName ?? "default";
41-
var srvQuery = $"_{portName}._tcp.{query.ServiceName}.{_querySuffix}";
42+
var srvQuery = optionsValue.ServiceDomainNameCallback != null
43+
? optionsValue.ServiceDomainNameCallback(query)
44+
: DefaultServiceDomainNameCallback(query, optionsValue);
4245
provider = new DnsSrvServiceEndpointProvider(query, srvQuery, hostName: query.ServiceName, options, logger, resolver, timeProvider);
4346
return true;
4447
}
4548

49+
private static string DefaultServiceDomainNameCallback(ServiceEndpointQuery query, DnsSrvServiceEndpointProviderOptions options)
50+
{
51+
var portName = query.EndpointName ?? "default";
52+
return $"_{portName}._tcp.{query.ServiceName}.{options.QuerySuffix}";
53+
}
54+
4655
private static string? GetKubernetesHostDomain()
4756
{
4857
// Check that we are running in Kubernetes first.

src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ public class DnsSrvServiceEndpointProviderOptions
3636
/// </remarks>
3737
public string? QuerySuffix { get; set; }
3838

39+
/// <summary>
40+
/// Gets or sets a delegate that generates a DNS SRV query from a specified <see cref="ServiceEndpointQuery"/> instance.
41+
/// </summary>
42+
public Func<ServiceEndpointQuery, string>? ServiceDomainNameCallback { get; set; }
43+
3944
/// <summary>
4045
/// Gets or sets a delegate used to determine whether to apply host name metadata to each resolved endpoint. Defaults to <c>false</c>.
4146
/// </summary>

src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Security.Cryptography;
1212
using Microsoft.Extensions.Logging;
1313
using Microsoft.Extensions.Logging.Abstractions;
14+
using Microsoft.Extensions.Options;
1415

1516
namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver;
1617

@@ -19,43 +20,40 @@ internal sealed partial class DnsResolver : IDnsResolver, IDisposable
1920
private const int IPv4Length = 4;
2021
private const int IPv6Length = 16;
2122

22-
// CancellationTokenSource.CancelAfter has a maximum timeout of Int32.MaxValue milliseconds.
23-
private static readonly TimeSpan s_maxTimeout = TimeSpan.FromMilliseconds(int.MaxValue);
24-
2523
private bool _disposed;
26-
private readonly ResolverOptions _options;
24+
private readonly DnsResolverOptions _options;
2725
private readonly CancellationTokenSource _pendingRequestsCts = new();
2826
private readonly TimeProvider _timeProvider;
2927
private readonly ILogger<DnsResolver> _logger;
3028

31-
public DnsResolver(TimeProvider timeProvider, ILogger<DnsResolver> logger) : this(timeProvider, logger, OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() ? ResolvConf.GetOptions() : NetworkInfo.GetOptions())
32-
{
33-
}
34-
35-
internal DnsResolver(TimeProvider timeProvider, ILogger<DnsResolver> logger, ResolverOptions options)
29+
public DnsResolver(TimeProvider timeProvider, ILogger<DnsResolver> logger, IOptions<DnsResolverOptions> options)
3630
{
3731
_timeProvider = timeProvider;
3832
_logger = logger;
39-
_options = options;
40-
Debug.Assert(_options.Servers.Count > 0);
33+
_options = options.Value;
4134

42-
if (options.Timeout != Timeout.InfiniteTimeSpan)
35+
if (_options.Servers.Count == 0)
4336
{
44-
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(options.Timeout, TimeSpan.Zero);
45-
ArgumentOutOfRangeException.ThrowIfGreaterThan(options.Timeout, s_maxTimeout);
46-
}
47-
}
48-
49-
internal DnsResolver(ResolverOptions options) : this(TimeProvider.System, NullLogger<DnsResolver>.Instance, options)
50-
{
51-
}
37+
foreach (var server in OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()
38+
? ResolvConf.GetServers()
39+
: NetworkInfo.GetServers())
40+
{
41+
_options.Servers.Add(server);
42+
}
5243

53-
internal DnsResolver(IEnumerable<IPEndPoint> servers) : this(new ResolverOptions(servers.ToArray()))
54-
{
44+
if (_options.Servers.Count == 0)
45+
{
46+
throw new ArgumentException("At least one DNS server is required.", nameof(options));
47+
}
48+
}
5549
}
5650

57-
internal DnsResolver(IPEndPoint server) : this(new ResolverOptions(server))
51+
// This constructor is for unit testing only. Does not auto-add system DNS servers.
52+
internal DnsResolver(DnsResolverOptions options)
5853
{
54+
_timeProvider = TimeProvider.System;
55+
_logger = NullLogger<DnsResolver>.Instance;
56+
_options = options;
5957
}
6058

6159
public ValueTask<ServiceResult[]> ResolveServiceAsync(string name, CancellationToken cancellationToken = default)
@@ -365,7 +363,7 @@ internal struct SendQueryResult
365363
{
366364
IPEndPoint serverEndPoint = _options.Servers[index];
367365

368-
for (int attempt = 1; attempt <= _options.Attempts; attempt++)
366+
for (int attempt = 1; attempt <= _options.MaxAttempts; attempt++)
369367
{
370368
DnsResponse response = default;
371369
try

src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/NetworkInfo.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver;
88

99
internal static class NetworkInfo
1010
{
11-
// basic option to get DNS serves via NetworkInfo. We may get it directly later via proper APIs.
12-
public static ResolverOptions GetOptions()
11+
// basic option to get DNS servers via NetworkInfo. We may get it directly later via proper APIs.
12+
public static IList<IPEndPoint> GetServers()
1313
{
1414
List<IPEndPoint> servers = new List<IPEndPoint>();
1515

@@ -31,6 +31,6 @@ public static ResolverOptions GetOptions()
3131
}
3232
}
3333

34-
return new ResolverOptions(servers);
34+
return servers;
3535
}
3636
}

src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResolvConf.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ internal static class ResolvConf
1010
{
1111
[SupportedOSPlatform("linux")]
1212
[SupportedOSPlatform("osx")]
13-
public static ResolverOptions GetOptions()
13+
public static IList<IPEndPoint> GetServers()
1414
{
15-
return GetOptions(new StreamReader("/etc/resolv.conf"));
15+
return GetServers(new StreamReader("/etc/resolv.conf"));
1616
}
1717

18-
public static ResolverOptions GetOptions(TextReader reader)
18+
public static IList<IPEndPoint> GetServers(TextReader reader)
1919
{
2020
List<IPEndPoint> serverList = new();
2121

@@ -40,9 +40,9 @@ public static ResolverOptions GetOptions(TextReader reader)
4040
if (serverList.Count == 0)
4141
{
4242
// If no nameservers are configured, fall back to the default behavior of using the system resolver configuration.
43-
return NetworkInfo.GetOptions();
43+
return NetworkInfo.GetServers();
4444
}
4545

46-
return new ResolverOptions(serverList);
46+
return serverList;
4747
}
4848
}

src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResolverOptions.cs

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/ServiceDiscoveryDnsServiceCollectionExtensions.cs

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using Microsoft.Extensions.DependencyInjection;
55
using Microsoft.Extensions.DependencyInjection.Extensions;
6+
using Microsoft.Extensions.Options;
67
using Microsoft.Extensions.ServiceDiscovery;
78
using Microsoft.Extensions.ServiceDiscovery.Dns;
89
using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver;
@@ -59,24 +60,10 @@ public static IServiceCollection AddDnsSrvServiceEndpointProvider(this IServiceC
5960

6061
services.AddSingleton<IServiceEndpointProviderFactory, DnsSrvServiceEndpointProviderFactory>();
6162
var options = services.AddOptions<DnsSrvServiceEndpointProviderOptions>();
62-
options.Configure(o => configureOptions?.Invoke(o));
63+
options.Configure(configureOptions);
64+
6365
return services;
6466

65-
static bool GetDnsClientFallbackFlag()
66-
{
67-
if (AppContext.TryGetSwitch("Microsoft.Extensions.ServiceDiscovery.Dns.UseDnsClientFallback", out var value))
68-
{
69-
return value;
70-
}
71-
72-
var envVar = Environment.GetEnvironmentVariable("MICROSOFT_EXTENSIONS_SERVICE_DISCOVERY_DNS_USE_DNSCLIENT_FALLBACK");
73-
if (envVar is not null && (envVar.Equals("true", StringComparison.OrdinalIgnoreCase) || envVar.Equals("1")))
74-
{
75-
return true;
76-
}
77-
78-
return false;
79-
}
8067
}
8168

8269
/// <summary>
@@ -109,9 +96,55 @@ public static IServiceCollection AddDnsServiceEndpointProvider(this IServiceColl
10996
ArgumentNullException.ThrowIfNull(configureOptions);
11097

11198
services.AddServiceDiscoveryCore();
99+
100+
if (!GetDnsClientFallbackFlag())
101+
{
102+
services.TryAddSingleton<IDnsResolver, DnsResolver>();
103+
}
104+
else
105+
{
106+
services.TryAddSingleton<IDnsResolver, FallbackDnsResolver>();
107+
services.TryAddSingleton<DnsClient.LookupClient>();
108+
}
109+
112110
services.AddSingleton<IServiceEndpointProviderFactory, DnsServiceEndpointProviderFactory>();
113111
var options = services.AddOptions<DnsServiceEndpointProviderOptions>();
114-
options.Configure(o => configureOptions?.Invoke(o));
112+
options.Configure(configureOptions);
113+
114+
return services;
115+
}
116+
117+
/// <summary>
118+
/// Configures the DNS resolver used for service discovery.
119+
/// </summary>
120+
/// <param name="services">The service collection.</param>
121+
/// <param name="configureOptions">The DNS resolver options.</param>
122+
/// <returns>The provided <see cref="IServiceCollection"/>.</returns>
123+
public static IServiceCollection ConfigureDnsResolver(this IServiceCollection services, Action<DnsResolverOptions> configureOptions)
124+
{
125+
ArgumentNullException.ThrowIfNull(services);
126+
ArgumentNullException.ThrowIfNull(configureOptions);
127+
128+
var options = services.AddOptions<DnsResolverOptions>();
129+
options.Configure(configureOptions);
130+
services.AddTransient<IValidateOptions<DnsResolverOptions>, DnsResolverOptionsValidator>();
115131
return services;
116132
}
133+
134+
private static bool GetDnsClientFallbackFlag()
135+
{
136+
if (AppContext.TryGetSwitch("Microsoft.Extensions.ServiceDiscovery.Dns.UseDnsClientFallback", out var value))
137+
{
138+
return value;
139+
}
140+
141+
var envVar = Environment.GetEnvironmentVariable("MICROSOFT_EXTENSIONS_SERVICE_DISCOVERY_DNS_USE_DNSCLIENT_FALLBACK");
142+
if (envVar is not null && (envVar.Equals("true", StringComparison.OrdinalIgnoreCase) || envVar.Equals("1")))
143+
{
144+
return true;
145+
}
146+
147+
return false;
148+
}
149+
117150
}

test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/DnsResponseFuzzer.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ public void FuzzTarget(ReadOnlySpan<byte> data)
1919
if (_resolver == null)
2020
{
2121
_buffer = new byte[4096];
22-
_resolver = new DnsResolver(new ResolverOptions(new IPEndPoint(IPAddress.Loopback, 53))
22+
_resolver = new DnsResolver(new DnsResolverOptions
2323
{
24+
Servers = [new IPEndPoint(IPAddress.Loopback, 53)],
2425
Timeout = TimeSpan.FromSeconds(5),
25-
Attempts = 1,
26+
MaxAttempts = 1,
2627
_transportOverride = (buffer, length) =>
2728
{
2829
// the first two bytes are the random transaction ID, so we keep that
@@ -41,4 +42,4 @@ public void FuzzTarget(ReadOnlySpan<byte> data)
4142
Debug.Assert(task.IsCompleted, "Task should be completed synchronously");
4243
task.GetAwaiter().GetResult();
4344
}
44-
}
45+
}

0 commit comments

Comments
 (0)