Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
46 changes: 41 additions & 5 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 @@ -121,15 +121,51 @@ private ElasticsearchBuilder WithUsername(string username)
/// <inheritdoc cref="IWaitUntil" />
private sealed class WaitUntil : IWaitUntil
{
private static readonly IEnumerable<string> Pattern = new[] { "\"message\":\"started", "\"message\": \"started\"" };

/// <inheritdoc />
public async Task<bool> UntilAsync(IContainer container)
{
var (stdout, _) = await container.GetLogsAsync(since: container.StoppedTime, timestampsEnabled: false)
if (container is not ElasticsearchContainer elasticContainer) return false;
var containerCredentials = elasticContainer.GetCredentials();
var username = containerCredentials.UserName ?? DefaultUsername;
var password = containerCredentials.Password ?? DefaultPassword;
var base64Credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));

using var httpMessageHandler = new HttpClientHandler();
httpMessageHandler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; // intentionally trusting the self‑signed test certificate

var httpWaitStrategy = new HttpWaitStrategy()
.UsingHttpMessageHandler(httpMessageHandler)
.UsingTls(elasticContainer.HttpsEnabled)
.ForPath("/_cluster/health")
.ForPort(ElasticsearchHttpsPort)
.ForStatusCode(HttpStatusCode.OK)
.WithHeader("Authorization", "Basic " + base64Credentials)
.ForResponseMessageMatching(async m =>
{
var content = await m.Content.ReadAsStringAsync().ConfigureAwait(false);
try
{
var response = JsonSerializer.Deserialize<ElasticHealthResponse>(content) ?? new ElasticHealthResponse();
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>
/// Returns <c>true</c> if https connection to container is enabled, 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;
}
}
}
25 changes: 12 additions & 13 deletions src/Testcontainers.Elasticsearch/ElasticsearchContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ public ElasticsearchContainer(ElasticsearchConfiguration configuration)
_configuration = configuration;
}

/// <summary>
/// Returns <c>true</c> if https connection to container is enabled.
/// </summary>
public bool HttpsEnabled => _configuration.HttpsEnabled;

/// <summary>
/// Gets the Elasticsearch credentials.
/// </summary>
/// <returns>The Elasticsearch credentials.</returns>
public NetworkCredential GetCredentials() => new(_configuration.Username, _configuration.Password);

/// <summary>
/// Gets the Elasticsearch connection string.
/// </summary>
Expand All @@ -28,19 +39,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
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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;

protected ElasticsearchContainerTest(ElasticsearchContainer container)
{
_elasticsearchContainer = container;
}

public async ValueTask InitializeAsync()
{
Expand Down Expand Up @@ -33,4 +38,18 @@ public void PingReturnsValidResponse()
Assert.True(response.IsValidResponse);
}
// # --8<-- [end:UseElasticsearchContainer]

public sealed class DefaultConfiguration : ElasticsearchContainerTest
{
public DefaultConfiguration() : base(new ElasticsearchBuilder().Build())
{
}
}

public sealed class CustomCredentialsConfiguration : ElasticsearchContainerTest
{
public CustomCredentialsConfiguration() : base(new ElasticsearchBuilder().WithPassword("CustomCredentialsConfiguration").Build())
{
}
}
}