Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/modules/elasticsearch.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ Add the following dependency to your project file:
dotnet add package Testcontainers.Elasticsearch
```

You can start an Elasticsearch container instance from any .NET application. This example uses xUnit.net's `IAsyncLifetime` interface to manage the lifecycle of the container. The container is started in the `InitializeAsync` method before the test method runs, ensuring that the environment is ready for testing. After the test completes, the container is removed in the `DisposeAsync` method.
You can start an Elasticsearch container instance from any .NET application. Here, we create different container instances and pass them to the base test class. This allows us to test different configurations.

=== "Create Container Instance"
```csharp
--8<-- "tests/Testcontainers.Elasticsearch.Tests/ElasticsearchContainerTest.cs:CreateElasticsearchContainer"
```

This example uses xUnit.net's `IAsyncLifetime` interface to manage the lifecycle of the container. The container is started in the `InitializeAsync` method before the test method runs, ensuring that the environment is ready for testing. After the test completes, the container is removed in the `DisposeAsync` method.

=== "Usage Example"
```csharp
Expand Down
65 changes: 57 additions & 8 deletions src/Testcontainers.Elasticsearch/ElasticsearchBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ public ElasticsearchBuilder WithPassword(string password)
public override ElasticsearchContainer Build()
{
Validate();
return new ElasticsearchContainer(DockerResourceConfiguration);

// By default, the base builder waits until the container is running. However, for Elasticsearch, a more advanced waiting strategy is necessary that requires access to the username and password.
// If the user does not provide a custom waiting strategy, append the default Elasticsearch waiting strategy.
var elasticsearchBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(DockerResourceConfiguration)));
return new ElasticsearchContainer(elasticsearchBuilder.DockerResourceConfiguration);
}

/// <inheritdoc />
Expand All @@ -73,8 +77,7 @@ protected override ElasticsearchBuilder Init()
.WithPassword(DefaultPassword)
.WithEnvironment("discovery.type", "single-node")
.WithEnvironment("ingest.geoip.downloader.enabled", "false")
.WithResourceMapping(DefaultMemoryVmOption, ElasticsearchDefaultMemoryVmOptionFilePath)
.WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil()));
.WithResourceMapping(DefaultMemoryVmOption, ElasticsearchDefaultMemoryVmOptionFilePath);
}

/// <inheritdoc />
Expand Down Expand Up @@ -121,15 +124,61 @@ private ElasticsearchBuilder WithUsername(string username)
/// <inheritdoc cref="IWaitUntil" />
private sealed class WaitUntil : IWaitUntil
{
private static readonly IEnumerable<string> Pattern = new[] { "\"message\":\"started", "\"message\": \"started\"" };
private readonly bool _tlsEnabled;

/// <inheritdoc />
public async Task<bool> UntilAsync(IContainer container)
private readonly string _authToken;

/// <summary>
/// Initializes a new instance of the <see cref="WaitUntil" /> class.
/// </summary>
/// <param name="configuration">The container configuration.</param>
public WaitUntil(ElasticsearchConfiguration configuration)
{
var username = configuration.Username;
var password = configuration.Password;
_tlsEnabled = configuration.TlsEnabled;
_authToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Join(":", username, password)));
}

private static async Task<bool> IsNodeReadyAsync(HttpResponseMessage response)
{
var (stdout, _) = await container.GetLogsAsync(since: container.StoppedTime, timestampsEnabled: false)
const StringComparison comparisonType = StringComparison.OrdinalIgnoreCase;

// https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-cluster-health.
var jsonString = await response.Content.ReadAsStringAsync()
.ConfigureAwait(false);

return Pattern.Any(stdout.Contains);
try
{
var status = JsonDocument.Parse(jsonString)
.RootElement
.GetProperty("status")
.GetString();

return "green".Equals(status, comparisonType) || "yellow".Equals(status, comparisonType);
}
catch
{
return false;
}
}

/// <inheritdoc cref="IWaitUntil.UntilAsync" />
public async Task<bool> UntilAsync(IContainer container)
{
using var httpMessageHandler = new HttpClientHandler();
httpMessageHandler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;

var httpWaitStrategy = new HttpWaitStrategy()
.UsingHttpMessageHandler(httpMessageHandler)
.UsingTls(_tlsEnabled)
.ForPort(ElasticsearchHttpsPort)
.ForPath("/_cluster/health")
.WithHeader("Authorization", "Basic " + _authToken)
.ForResponseMessageMatching(IsNodeReadyAsync);

return await httpWaitStrategy.UntilAsync(container)
.ConfigureAwait(false);
}
}
}
23 changes: 23 additions & 0 deletions src/Testcontainers.Elasticsearch/ElasticsearchConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,27 @@ public ElasticsearchConfiguration(ElasticsearchConfiguration oldValue, Elasticse
/// Gets the Elasticsearch password.
/// </summary>
public string Password { get; }

/// <summary>
/// Gets a value indicating whether TLS is enabled or not.
/// </summary>
public bool TlsEnabled
{
get
{
var hasSecurityEnabled = Environments
.TryGetValue("xpack.security.enabled", out var securityEnabled);

var hasHttpSslEnabled = Environments
.TryGetValue("xpack.security.http.ssl.enabled", out var httpSslEnabled);
Comment on lines +79 to +83
Copy link

@uladz-zubrycki uladz-zubrycki Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to not work for 7.x.x images, which don't have security enabled by default. So even though neither of the "xpack.security.enabled" or "xpack.security.http.ssl.enabled"environment variables are provided, Elasticsearch still expects HTTP, but ElasticsearchConfiguration assumes HTTPS.

HttpWaitStrategy then gets stuck in a loop, as it catches the following exception and returns false.

System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.
 ---> System.Security.Authentication.AuthenticationException: Cannot determine the frame size or a corrupted frame was received.
   at System.Net.Security.SslStream.GetFrameSize(ReadOnlySpan`1 buffer)
   at System.Net.Security.SslStream.EnsureFullTlsFrameAsync[TIOAdapter](CancellationToken cancellationToken, Int32 estimatedSize)
   at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
   at System.Net.Security.SslStream.ReceiveHandshakeFrameAsync[TIOAdapter](CancellationToken cancellationToken)
   at System.Net.Security.SslStream.ForceAuthenticationAsync[TIOAdapter](Boolean receiveFirst, Byte[] reAuthenticationData, CancellationToken cancellationToken)
   at System.Net.Http.ConnectHelper.EstablishSslConnectionAsync(SslClientAuthenticationOptions sslOptions, HttpRequestMessage request, Boolean async, Stream stream, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
   at System.Net.Http.ConnectHelper.EstablishSslConnectionAsync(SslClientAuthenticationOptions sslOptions, HttpRequestMessage request, Boolean async, Stream stream, CancellationToken cancellationToken)
   at System.Net.Http.HttpConnectionPool.ConnectAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
   at System.Net.Http.HttpConnectionPool.CreateHttp11ConnectionAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
   at System.Net.Http.HttpConnectionPool.InjectNewHttp11ConnectionAsync(QueueItem queueItem)
   at System.Threading.Tasks.TaskCompletionSourceWithCancellation`1.WaitWithCancellationAsync(CancellationToken cancellationToken)
   at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
   at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
   at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)
   at HttpWaitStrategy.UntilAsync(IContainer container)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please test and set the environment variables explicitly to false?

_ = new ElasticsearchBuilder()
    .WithEnvironment("xpack.security.enabled", "false")
    .WithEnvironment("xpack.security.http.ssl.enabled", "false")
    .Build();

I believe the expression is incorrect when SSL is not enabled by default. For version 8, you need to disable it explicitly. The has... variables will be false for images that don't have SSL enabled by default (like version 7).

Copy link

@uladz-zubrycki uladz-zubrycki Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a workaround I ended up with. If variables are there and are set to false, then TlsEnabled is evaluated to false and HTTP is used, just as expected.

Perhaps you need to also check the tag and apply different logic for versions less than 8, where HTTPS is not enabled by default. Or maybe there is some better option.

And possibly handle that specific exception and rethrow it with a clear message indicating protocol mismatch instead of swallowing it.


var httpsDisabled =
hasSecurityEnabled &&
hasHttpSslEnabled &&
"false".Equals(securityEnabled, StringComparison.OrdinalIgnoreCase) &&
"false".Equals(httpSslEnabled, StringComparison.OrdinalIgnoreCase);

return !httpsDisabled;
}
}
}
15 changes: 1 addition & 14 deletions src/Testcontainers.Elasticsearch/ElasticsearchContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,7 @@ public ElasticsearchContainer(ElasticsearchConfiguration configuration)
/// <returns>The Elasticsearch connection string.</returns>
public string GetConnectionString()
{
var hasSecurityEnabled = _configuration.Environments
.TryGetValue("xpack.security.enabled", out var securityEnabled);

var hasHttpSslEnabled = _configuration.Environments
.TryGetValue("xpack.security.http.ssl.enabled", out var httpSslEnabled);

var httpsDisabled =
hasSecurityEnabled &&
hasHttpSslEnabled &&
"false".Equals(securityEnabled, StringComparison.OrdinalIgnoreCase) &&
"false".Equals(httpSslEnabled, StringComparison.OrdinalIgnoreCase);

var scheme = httpsDisabled ? Uri.UriSchemeHttp : Uri.UriSchemeHttps;

var scheme = _configuration.TlsEnabled ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(ElasticsearchBuilder.ElasticsearchHttpsPort));
endpoint.UserName = _configuration.Username;
endpoint.Password = _configuration.Password;
Expand Down
3 changes: 2 additions & 1 deletion src/Testcontainers.Elasticsearch/Usings.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Net.Http;
global using System.Text;
global using System.Text.Json;
global using System.Threading.Tasks;
global using Docker.DotNet.Models;
global using DotNet.Testcontainers;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
namespace Testcontainers.Elasticsearch;

public sealed class ElasticsearchContainerTest : IAsyncLifetime
public abstract class ElasticsearchContainerTest : IAsyncLifetime
{
// # --8<-- [start:UseElasticsearchContainer]
private readonly ElasticsearchContainer _elasticsearchContainer = new ElasticsearchBuilder().Build();
private readonly ElasticsearchContainer _elasticsearchContainer;

private ElasticsearchContainerTest(ElasticsearchContainer elasticsearchContainer)
{
_elasticsearchContainer = elasticsearchContainer;
}

// # --8<-- [start:UseElasticsearchContainer]
public async ValueTask InitializeAsync()
{
await _elasticsearchContainer.StartAsync()
.ConfigureAwait(false);
}

public ValueTask DisposeAsync()
public async ValueTask DisposeAsync()
{
return _elasticsearchContainer.DisposeAsync();
await DisposeAsyncCore()
.ConfigureAwait(false);

GC.SuppressFinalize(this);
}

[Fact]
Expand All @@ -33,4 +41,29 @@ public void PingReturnsValidResponse()
Assert.True(response.IsValidResponse);
}
// # --8<-- [end:UseElasticsearchContainer]

protected virtual ValueTask DisposeAsyncCore()
{
return _elasticsearchContainer.DisposeAsync();
}

// # --8<-- [start:CreateElasticsearchContainer]
[UsedImplicitly]
public sealed class ElasticsearchDefaultConfiguration : ElasticsearchContainerTest
{
public ElasticsearchDefaultConfiguration()
: base(new ElasticsearchBuilder().Build())
{
}
}

[UsedImplicitly]
public sealed class ElasticsearchAuthConfiguration : ElasticsearchContainerTest
{
public ElasticsearchAuthConfiguration()
: base(new ElasticsearchBuilder().WithPassword("some-password").Build())
{
}
}
// # --8<-- [end:CreateElasticsearchContainer]
}
1 change: 1 addition & 0 deletions tests/Testcontainers.Elasticsearch.Tests/Usings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
global using DotNet.Testcontainers.Commons;
global using Elastic.Clients.Elasticsearch;
global using Elastic.Transport;
global using JetBrains.Annotations;
global using Xunit;
4 changes: 2 additions & 2 deletions tests/Testcontainers.Grafana.Tests/GrafanaContainerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ await DisposeAsyncCore()
public async Task GetCurrentOrganizationReturnsHttpStatusCodeOk()
{
// Given
var basicAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Join(":", _username, _password)));
var authToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Join(":", _username, _password)));

using var httpClient = new HttpClient();
httpClient.BaseAddress = new Uri(_grafanaContainer.GetBaseAddress());
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", basicAuth);
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authToken);

// When
using var httpResponse = await httpClient.GetAsync("api/org", TestContext.Current.CancellationToken)
Expand Down