diff --git a/src/HealthChecks.Elasticsearch/DependencyInjection/ElasticsearchHealthCheckBuilderExtensions.cs b/src/HealthChecks.Elasticsearch/DependencyInjection/ElasticsearchHealthCheckBuilderExtensions.cs index 89e8b323c1..be7d3620db 100644 --- a/src/HealthChecks.Elasticsearch/DependencyInjection/ElasticsearchHealthCheckBuilderExtensions.cs +++ b/src/HealthChecks.Elasticsearch/DependencyInjection/ElasticsearchHealthCheckBuilderExtensions.cs @@ -33,7 +33,6 @@ public static IHealthChecksBuilder AddElasticsearch( { var options = new ElasticsearchOptions(); options.UseServer(elasticsearchUri); - return builder.Add(new HealthCheckRegistration( name ?? NAME, sp => new ElasticsearchHealthCheck(options), @@ -68,6 +67,11 @@ public static IHealthChecksBuilder AddElasticsearch( options.RequestTimeout ??= timeout; + if (options.Uri is null && !options.AuthenticateWithElasticCloud) + { + throw new InvalidOperationException($"there is no server to connect. consider using ${nameof(ElasticsearchOptions.UseElasticCloud)} or ${nameof(ElasticsearchOptions.UseServer)}"); + } + return builder.Add(new HealthCheckRegistration( name ?? NAME, sp => new ElasticsearchHealthCheck(options), @@ -75,4 +79,46 @@ public static IHealthChecksBuilder AddElasticsearch( tags, timeout)); } + + /// + /// Add a health check for Elasticsearch databases. + /// + /// The . + /// + /// An optional factory to obtain instance. + /// When not provided, is simply resolved from . + /// The health check name. Optional. If null the type name 'elasticsearch' will be used for the name. + /// + /// The that should be reported when the health check fails. Optional. If null then + /// the default status of will be reported. + /// + /// A list of tags that can be used to filter sets of health checks. Optional. + /// An optional representing the timeout of the check. + /// The specified . + public static IHealthChecksBuilder AddElasticsearch( + this IHealthChecksBuilder builder, + Func? clientFactory = null, + string? name = default, + HealthStatus? failureStatus = default, + IEnumerable? tags = default, + TimeSpan? timeout = default) + { + + return builder.Add(new HealthCheckRegistration( + name ?? NAME, + sp => + { + ElasticsearchOptions options = new() + { + RequestTimeout = timeout, + Client = clientFactory?.Invoke(sp) ?? sp.GetRequiredService() + }; + + + return new ElasticsearchHealthCheck(options); + }, + failureStatus, + tags, + timeout)); + } } diff --git a/src/HealthChecks.Elasticsearch/ElasticsearchHealthCheck.cs b/src/HealthChecks.Elasticsearch/ElasticsearchHealthCheck.cs index 10752e1e60..5cf81068b0 100644 --- a/src/HealthChecks.Elasticsearch/ElasticsearchHealthCheck.cs +++ b/src/HealthChecks.Elasticsearch/ElasticsearchHealthCheck.cs @@ -1,18 +1,20 @@ using System.Collections.Concurrent; -using Elasticsearch.Net; +using System.Diagnostics; +using Elastic.Clients.Elasticsearch; +using Elastic.Transport; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Nest; namespace HealthChecks.Elasticsearch; public class ElasticsearchHealthCheck : IHealthCheck { - private static readonly ConcurrentDictionary _connections = new(); + private static readonly ConcurrentDictionary _connections = new(); private readonly ElasticsearchOptions _options; public ElasticsearchHealthCheck(ElasticsearchOptions options) { + Debug.Assert(options.Uri is not null || options.Client is not null || options.AuthenticateWithElasticCloud); _options = Guard.ThrowIfNull(options); } @@ -21,60 +23,90 @@ public async Task CheckHealthAsync(HealthCheckContext context { try { - if (!_connections.TryGetValue(_options.Uri, out var lowLevelClient)) + ElasticsearchClient? elasticsearchClient = null; + if (_options.Client is not null) { - var settings = new ConnectionSettings(new Uri(_options.Uri)); + elasticsearchClient = _options.Client; + } + else + { + ElasticsearchClientSettings? settings = null; + + settings = _options.AuthenticateWithElasticCloud + ? new ElasticsearchClientSettings(_options.CloudId!, new ApiKey(_options.ApiKey!)) + : new ElasticsearchClientSettings(new Uri(_options.Uri!)); + if (_options.RequestTimeout.HasValue) { settings = settings.RequestTimeout(_options.RequestTimeout.Value); } - if (_options.AuthenticateWithBasicCredentials) + if (!_connections.TryGetValue(_options.Uri!, out elasticsearchClient)) { - settings = settings.BasicAuthentication(_options.UserName, _options.Password); - } - else if (_options.AuthenticateWithCertificate) - { - settings = settings.ClientCertificate(_options.Certificate); - } - else if (_options.AuthenticateWithApiKey) - { - settings = settings.ApiKeyAuthentication(_options.ApiKeyAuthenticationCredentials); - } - if (_options.CertificateValidationCallback != null) - { - settings = settings.ServerCertificateValidationCallback(_options.CertificateValidationCallback); - } + if (_options.AuthenticateWithBasicCredentials) + { + if (_options.UserName is null) + { + throw new ArgumentNullException(nameof(_options.UserName)); + } + if (_options.Password is null) + { + throw new ArgumentNullException(nameof(_options.Password)); + } + settings = settings.Authentication(new BasicAuthentication(_options.UserName, _options.Password)); + } + else if (_options.AuthenticateWithCertificate) + { + if (_options.Certificate is null) + { + throw new ArgumentNullException(nameof(_options.Certificate)); + } + settings = settings.ClientCertificate(_options.Certificate); + } + else if (_options.AuthenticateWithApiKey) + { + if (_options.ApiKey is null) + { + throw new ArgumentNullException(nameof(_options.ApiKey)); + } + settings.Authentication(new ApiKey(_options.ApiKey)); + } - lowLevelClient = new ElasticClient(settings); + if (_options.CertificateValidationCallback != null) + { + settings = settings.ServerCertificateValidationCallback(_options.CertificateValidationCallback); + } - if (!_connections.TryAdd(_options.Uri, lowLevelClient)) - { - lowLevelClient = _connections[_options.Uri]; + elasticsearchClient = new ElasticsearchClient(settings); + + if (!_connections.TryAdd(_options.Uri!, elasticsearchClient)) + { + elasticsearchClient = _connections[_options.Uri!]; + } } } if (_options.UseClusterHealthApi) { - var healthResponse = await lowLevelClient.Cluster.HealthAsync(ct: cancellationToken).ConfigureAwait(false); + var healthResponse = await elasticsearchClient.Cluster.HealthAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - if (healthResponse.ApiCall.HttpStatusCode != 200) + if (healthResponse.ApiCallDetails.HttpStatusCode != 200) { return new HealthCheckResult(context.Registration.FailureStatus); } return healthResponse.Status switch { - Health.Green => HealthCheckResult.Healthy(), - Health.Yellow => HealthCheckResult.Degraded(), + Elastic.Clients.Elasticsearch.HealthStatus.Green => HealthCheckResult.Healthy(), + Elastic.Clients.Elasticsearch.HealthStatus.Yellow => HealthCheckResult.Degraded(), _ => new HealthCheckResult(context.Registration.FailureStatus) }; } - var pingResult = await lowLevelClient.PingAsync(ct: cancellationToken).ConfigureAwait(false); - bool isSuccess = pingResult.ApiCall.HttpStatusCode == 200; + var pingResult = await elasticsearchClient.PingAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + bool isSuccess = pingResult.ApiCallDetails.HttpStatusCode == 200; return isSuccess ? HealthCheckResult.Healthy() diff --git a/src/HealthChecks.Elasticsearch/ElasticsearchOptions.cs b/src/HealthChecks.Elasticsearch/ElasticsearchOptions.cs index 61585104cd..34a144afc4 100644 --- a/src/HealthChecks.Elasticsearch/ElasticsearchOptions.cs +++ b/src/HealthChecks.Elasticsearch/ElasticsearchOptions.cs @@ -1,6 +1,6 @@ using System.Net.Security; using System.Security.Cryptography.X509Certificates; -using Elasticsearch.Net; +using Elastic.Clients.Elasticsearch; namespace HealthChecks.Elasticsearch; @@ -9,15 +9,17 @@ namespace HealthChecks.Elasticsearch; /// public class ElasticsearchOptions { - public string Uri { get; private set; } = null!; + public string? Uri { get; private set; } public string? UserName { get; private set; } public string? Password { get; private set; } - public X509Certificate? Certificate { get; private set; } + public string? CloudId { get; private set; } + + public string? CloudApiKey { get; private set; } - public ApiKeyAuthenticationCredentials? ApiKeyAuthenticationCredentials { get; private set; } + public X509Certificate? Certificate { get; private set; } public bool AuthenticateWithBasicCredentials { get; private set; } @@ -25,20 +27,29 @@ public class ElasticsearchOptions public bool AuthenticateWithApiKey { get; private set; } + public bool AuthenticateWithElasticCloud { get; private set; } + public bool UseClusterHealthApi { get; set; } + public string? ApiKey { get; private set; } + public Func? CertificateValidationCallback { get; private set; } public TimeSpan? RequestTimeout { get; set; } + public ElasticsearchClient? Client { get; internal set; } + public ElasticsearchOptions UseBasicAuthentication(string name, string password) { UserName = Guard.ThrowIfNull(name); Password = Guard.ThrowIfNull(password); + CloudId = string.Empty; + CloudApiKey = string.Empty; Certificate = null; AuthenticateWithApiKey = false; AuthenticateWithCertificate = false; + AuthenticateWithElasticCloud = false; AuthenticateWithBasicCredentials = true; return this; } @@ -49,26 +60,48 @@ public ElasticsearchOptions UseCertificate(X509Certificate certificate) UserName = string.Empty; Password = string.Empty; + CloudId = string.Empty; + CloudApiKey = string.Empty; AuthenticateWithApiKey = false; AuthenticateWithBasicCredentials = false; + AuthenticateWithElasticCloud = false; AuthenticateWithCertificate = true; return this; } - public ElasticsearchOptions UseApiKey(ApiKeyAuthenticationCredentials apiKey) + public ElasticsearchOptions UseApiKey(string apiKey) { - ApiKeyAuthenticationCredentials = Guard.ThrowIfNull(apiKey); + ApiKey = Guard.ThrowIfNull(apiKey); UserName = string.Empty; Password = string.Empty; + CloudId = string.Empty; + CloudApiKey = string.Empty; Certificate = null; AuthenticateWithBasicCredentials = false; AuthenticateWithCertificate = false; + AuthenticateWithElasticCloud = false; AuthenticateWithApiKey = true; return this; } + public ElasticsearchOptions UseElasticCloud(string cloudId, string cloudApiKey) + { + CloudId = Guard.ThrowIfNull(cloudId); + CloudApiKey = Guard.ThrowIfNull(cloudApiKey); + + UserName = string.Empty; + Password = string.Empty; + Certificate = null; + AuthenticateWithBasicCredentials = false; + AuthenticateWithCertificate = false; + AuthenticateWithApiKey = false; + AuthenticateWithElasticCloud = true; + return this; + } + + public ElasticsearchOptions UseServer(string uri) { Uri = Guard.ThrowIfNull(uri); diff --git a/src/HealthChecks.Elasticsearch/HealthChecks.Elasticsearch.csproj b/src/HealthChecks.Elasticsearch/HealthChecks.Elasticsearch.csproj index fdca96ac0b..3ffe36a1b0 100644 --- a/src/HealthChecks.Elasticsearch/HealthChecks.Elasticsearch.csproj +++ b/src/HealthChecks.Elasticsearch/HealthChecks.Elasticsearch.csproj @@ -8,8 +8,9 @@ - - + + + diff --git a/test/HealthChecks.Elasticsearch.Tests/DependencyInjection/RegistrationTests.cs b/test/HealthChecks.Elasticsearch.Tests/DependencyInjection/RegistrationTests.cs index a2cc123751..8de6dfe986 100644 --- a/test/HealthChecks.Elasticsearch.Tests/DependencyInjection/RegistrationTests.cs +++ b/test/HealthChecks.Elasticsearch.Tests/DependencyInjection/RegistrationTests.cs @@ -39,12 +39,15 @@ public void add_named_health_check_when_properly_configured() [Fact] public void create_client_with_user_configured_request_timeout() { + var connectionString = @"https://localhost:9200"; + var services = new ServiceCollection(); var settings = new ElasticsearchOptions(); services.AddHealthChecks().AddElasticsearch(setup => { - setup = settings; + setup.UseServer(connectionString); setup.RequestTimeout = new TimeSpan(0, 0, 6); + settings = setup; }); //Ensure no further modifications were carried by extension method @@ -55,9 +58,15 @@ public void create_client_with_user_configured_request_timeout() [Fact] public void create_client_with_configured_healthcheck_timeout_when_no_request_timeout_is_configured() { + var connectionString = @"https://localhost:9200"; var services = new ServiceCollection(); var settings = new ElasticsearchOptions(); - services.AddHealthChecks().AddElasticsearch(setup => settings = setup, timeout: new TimeSpan(0, 0, 7)); + + services.AddHealthChecks().AddElasticsearch(setup => + { + setup.UseServer(connectionString); + settings = setup; + }, timeout: new TimeSpan(0, 0, 7)); settings.RequestTimeout.ShouldNotBeNull(); settings.RequestTimeout.ShouldBe(new TimeSpan(0, 0, 7)); @@ -66,10 +75,88 @@ public void create_client_with_configured_healthcheck_timeout_when_no_request_ti [Fact] public void create_client_with_no_timeout_when_no_option_is_configured() { + var connectionString = @"https://localhost:9200"; + var services = new ServiceCollection(); var settings = new ElasticsearchOptions(); - services.AddHealthChecks().AddElasticsearch(setup => settings = setup); + + services.AddHealthChecks().AddElasticsearch(setup => + { + setup.UseServer(connectionString); + settings = setup; + }); settings.RequestTimeout.ShouldBeNull(); } + + [Fact] + public void throw_exception_when_create_client_without_using_elasic_cloud_or_server() + { + var services = new ServiceCollection(); + var settings = new ElasticsearchOptions(); + + Assert.Throws(() => services.AddHealthChecks().AddElasticsearch(setup => settings = setup)); + } + + [Fact] + public void create_client_when_using_elasic_cloud() + { + var services = new ServiceCollection(); + + var settings = new ElasticsearchOptions(); + + services.AddHealthChecks().AddElasticsearch(setup => + { + setup.UseElasticCloud("cloudId", "cloudApiKey"); + settings = setup; + }); + + settings.AuthenticateWithElasticCloud.ShouldBeTrue(); + settings.CloudApiKey.ShouldNotBeNull(); + settings.CloudId.ShouldNotBeNull(); + } + + [Fact] + public void client_should_resolve_from_di() + { + var client = new Elastic.Clients.Elasticsearch.ElasticsearchClient(); + var services = new ServiceCollection(); + services.AddSingleton(client); + + services.AddHealthChecks().AddElasticsearch(); + + using var serviceProvider = services.BuildServiceProvider(); + + var options = serviceProvider.GetRequiredService>(); + + var registration = options.Value.Registrations.First(); + var check = registration.Factory(serviceProvider); + + check.ShouldBeOfType(); + } + + [Fact] + public void use_client_factory() + { + var client = new Elastic.Clients.Elasticsearch.ElasticsearchClient(); + var services = new ServiceCollection(); + var settings = new ElasticsearchOptions(); + var factoryCalled = false; + + services.AddHealthChecks().AddElasticsearch(clientFactory: (sp => + { + factoryCalled = true; + return client; + })); + + using var serviceProvider = services.BuildServiceProvider(); + + var options = serviceProvider.GetRequiredService>(); + + var registration = options.Value.Registrations.First(); + var check = registration.Factory(serviceProvider); + + check.ShouldBeOfType(); + factoryCalled.ShouldBeTrue(); + } } diff --git a/test/HealthChecks.Elasticsearch.Tests/Functional/ElasticsearchAuthenticationTests.cs b/test/HealthChecks.Elasticsearch.Tests/Functional/ElasticsearchAuthenticationTests.cs index 2bcf44def3..622996c3bf 100644 --- a/test/HealthChecks.Elasticsearch.Tests/Functional/ElasticsearchAuthenticationTests.cs +++ b/test/HealthChecks.Elasticsearch.Tests/Functional/ElasticsearchAuthenticationTests.cs @@ -1,5 +1,4 @@ using System.Net; -using Elasticsearch.Net; using HealthChecks.Elasticsearch.Tests.Fixtures; namespace HealthChecks.Elasticsearch.Tests.Functional; @@ -25,7 +24,7 @@ public async Task be_healthy_if_elasticsearch_is_using_valid_api_key() .AddElasticsearch(options => { options.UseServer(connectionString); - options.UseApiKey(new ApiKeyAuthenticationCredentials(_fixture.ApiKey)); + options.UseApiKey(_fixture.ApiKey!); options.UseCertificateValidationCallback(delegate { return true; diff --git a/test/HealthChecks.Elasticsearch.Tests/HealthChecks.Elasticsearch.approved.txt b/test/HealthChecks.Elasticsearch.Tests/HealthChecks.Elasticsearch.approved.txt index 30efb3339b..64aa2c8378 100644 --- a/test/HealthChecks.Elasticsearch.Tests/HealthChecks.Elasticsearch.approved.txt +++ b/test/HealthChecks.Elasticsearch.Tests/HealthChecks.Elasticsearch.approved.txt @@ -8,21 +8,26 @@ namespace HealthChecks.Elasticsearch public class ElasticsearchOptions { public ElasticsearchOptions() { } - public Elasticsearch.Net.ApiKeyAuthenticationCredentials? ApiKeyAuthenticationCredentials { get; } + public string? ApiKey { get; } public bool AuthenticateWithApiKey { get; } public bool AuthenticateWithBasicCredentials { get; } public bool AuthenticateWithCertificate { get; } + public bool AuthenticateWithElasticCloud { get; } public System.Security.Cryptography.X509Certificates.X509Certificate? Certificate { get; } public System.Func? CertificateValidationCallback { get; } + public Elastic.Clients.Elasticsearch.ElasticsearchClient? Client { get; } + public string? CloudApiKey { get; } + public string? CloudId { get; } public string? Password { get; } public System.TimeSpan? RequestTimeout { get; set; } - public string Uri { get; } + public string? Uri { get; } public bool UseClusterHealthApi { get; set; } public string? UserName { get; } - public HealthChecks.Elasticsearch.ElasticsearchOptions UseApiKey(Elasticsearch.Net.ApiKeyAuthenticationCredentials apiKey) { } + public HealthChecks.Elasticsearch.ElasticsearchOptions UseApiKey(string apiKey) { } public HealthChecks.Elasticsearch.ElasticsearchOptions UseBasicAuthentication(string name, string password) { } public HealthChecks.Elasticsearch.ElasticsearchOptions UseCertificate(System.Security.Cryptography.X509Certificates.X509Certificate certificate) { } public HealthChecks.Elasticsearch.ElasticsearchOptions UseCertificateValidationCallback(System.Func callback) { } + public HealthChecks.Elasticsearch.ElasticsearchOptions UseElasticCloud(string cloudId, string cloudApiKey) { } public HealthChecks.Elasticsearch.ElasticsearchOptions UseServer(string uri) { } } } @@ -31,6 +36,7 @@ namespace Microsoft.Extensions.DependencyInjection public static class ElasticsearchHealthCheckBuilderExtensions { public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddElasticsearch(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Action? setup, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddElasticsearch(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Func? clientFactory = null, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddElasticsearch(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string elasticsearchUri, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } } } \ No newline at end of file