Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
57 changes: 50 additions & 7 deletions src/Testcontainers.Elasticsearch/ElasticsearchBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace Testcontainers.Elasticsearch;
namespace Testcontainers.Elasticsearch;

/// <inheritdoc cref="ContainerBuilder{TBuilderEntity, TContainerEntity, TConfigurationEntity}" />
[PublicAPI]
Expand Down Expand Up @@ -65,16 +65,17 @@ public override ElasticsearchContainer Build()
/// <inheritdoc />
protected override ElasticsearchBuilder Init()
{
return base.Init()
var builder = base.Init()
.WithImage(ElasticsearchImage)
.WithPortBinding(ElasticsearchHttpsPort, true)
.WithPortBinding(ElasticsearchTcpPort, true)
.WithUsername(DefaultUsername)
.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);

return builder.WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(builder.DockerResourceConfiguration)));
}

/// <inheritdoc />
Expand Down Expand Up @@ -121,15 +122,57 @@ 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 ElasticsearchConfiguration _configuration;
private readonly string _credentials;

public WaitUntil(ElasticsearchConfiguration configuration)
{
_configuration = configuration;

var username = _configuration.Username ?? DefaultUsername;
var password = _configuration.Password ?? DefaultPassword;
_credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
}

/// <inheritdoc />
public async Task<bool> UntilAsync(IContainer container)
{
var (stdout, _) = await container.GetLogsAsync(since: container.StoppedTime, timestampsEnabled: false)
using var httpMessageHandler = new HttpClientHandler();
httpMessageHandler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; // intentionally trusting the self‑signed test certificate

var httpWaitStrategy = new HttpWaitStrategy()
.UsingHttpMessageHandler(httpMessageHandler)
.UsingTls(_configuration.HttpsEnabled)
.ForPath("/_cluster/health")
.ForPort(ElasticsearchHttpsPort)
.ForStatusCode(HttpStatusCode.OK)
.WithHeader("Authorization", "Basic " + _credentials)
.ForResponseMessageMatching(async m =>
{
var content = await m.Content.ReadAsStringAsync().ConfigureAwait(false);
try
{
var response = JsonSerializer.Deserialize<ElasticHealthResponse>(content);
return string.Equals(ElasticHealthResponse.YellowStatus, response.Status, StringComparison.OrdinalIgnoreCase) ||
string.Equals(ElasticHealthResponse.GreenStatus, response.Status, StringComparison.OrdinalIgnoreCase);
}
catch (JsonException)
{
return false;
}
});

return await httpWaitStrategy.UntilAsync(container)
.ConfigureAwait(false);
}

private class ElasticHealthResponse
{
public const string YellowStatus = "yellow";
public const string GreenStatus = "green";

return Pattern.Any(stdout.Contains);
[JsonPropertyName("status")]
public string Status { get; set; } = "unknown";
}
}
}
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>
/// Checks if https connection to container is supported, based on configuration environment variables.
/// </summary>
public bool HttpsEnabled
{
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;
}
}
}
14 changes: 1 addition & 13 deletions src/Testcontainers.Elasticsearch/ElasticsearchContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +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.HttpsEnabled ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;

var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(ElasticsearchBuilder.ElasticsearchHttpsPort));
endpoint.UserName = _configuration.Username;
Expand Down
4 changes: 4 additions & 0 deletions src/Testcontainers.Elasticsearch/Usings.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Net;
global using System.Net.Http;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Threading.Tasks;
global using Docker.DotNet.Models;
global using DotNet.Testcontainers;
Expand Down