Skip to content

Commit 8aa6520

Browse files
fix(Elasticsearch): Use HTTP wait strategy (#1593)
Co-authored-by: Andre Hofmeister <[email protected]>
1 parent 5e7aad7 commit 8aa6520

File tree

8 files changed

+132
-31
lines changed

8 files changed

+132
-31
lines changed

docs/modules/elasticsearch.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ Add the following dependency to your project file:
88
dotnet add package Testcontainers.Elasticsearch
99
```
1010

11-
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.
11+
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.
12+
13+
=== "Create Container Instance"
14+
```csharp
15+
--8<-- "tests/Testcontainers.Elasticsearch.Tests/ElasticsearchContainerTest.cs:CreateElasticsearchContainer"
16+
```
17+
18+
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.
1219

1320
=== "Usage Example"
1421
```csharp

src/Testcontainers.Elasticsearch/ElasticsearchBuilder.cs

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ public ElasticsearchBuilder WithPassword(string password)
5959
public override ElasticsearchContainer Build()
6060
{
6161
Validate();
62-
return new ElasticsearchContainer(DockerResourceConfiguration);
62+
63+
// 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.
64+
// If the user does not provide a custom waiting strategy, append the default Elasticsearch waiting strategy.
65+
var elasticsearchBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(DockerResourceConfiguration)));
66+
return new ElasticsearchContainer(elasticsearchBuilder.DockerResourceConfiguration);
6367
}
6468

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

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

126-
/// <inheritdoc />
127-
public async Task<bool> UntilAsync(IContainer container)
129+
private readonly string _authToken;
130+
131+
/// <summary>
132+
/// Initializes a new instance of the <see cref="WaitUntil" /> class.
133+
/// </summary>
134+
/// <param name="configuration">The container configuration.</param>
135+
public WaitUntil(ElasticsearchConfiguration configuration)
136+
{
137+
var username = configuration.Username;
138+
var password = configuration.Password;
139+
_tlsEnabled = configuration.TlsEnabled;
140+
_authToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Join(":", username, password)));
141+
}
142+
143+
private static async Task<bool> IsNodeReadyAsync(HttpResponseMessage response)
128144
{
129-
var (stdout, _) = await container.GetLogsAsync(since: container.StoppedTime, timestampsEnabled: false)
145+
const StringComparison comparisonType = StringComparison.OrdinalIgnoreCase;
146+
147+
// https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-cluster-health.
148+
var jsonString = await response.Content.ReadAsStringAsync()
130149
.ConfigureAwait(false);
131150

132-
return Pattern.Any(stdout.Contains);
151+
try
152+
{
153+
var status = JsonDocument.Parse(jsonString)
154+
.RootElement
155+
.GetProperty("status")
156+
.GetString();
157+
158+
return "green".Equals(status, comparisonType) || "yellow".Equals(status, comparisonType);
159+
}
160+
catch
161+
{
162+
return false;
163+
}
164+
}
165+
166+
/// <inheritdoc cref="IWaitUntil.UntilAsync" />
167+
public async Task<bool> UntilAsync(IContainer container)
168+
{
169+
using var httpMessageHandler = new HttpClientHandler();
170+
httpMessageHandler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
171+
172+
var httpWaitStrategy = new HttpWaitStrategy()
173+
.UsingHttpMessageHandler(httpMessageHandler)
174+
.UsingTls(_tlsEnabled)
175+
.ForPort(ElasticsearchHttpsPort)
176+
.ForPath("/_cluster/health")
177+
.WithHeader("Authorization", "Basic " + _authToken)
178+
.ForResponseMessageMatching(IsNodeReadyAsync);
179+
180+
return await httpWaitStrategy.UntilAsync(container)
181+
.ConfigureAwait(false);
133182
}
134183
}
135184
}

src/Testcontainers.Elasticsearch/ElasticsearchConfiguration.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,27 @@ public ElasticsearchConfiguration(ElasticsearchConfiguration oldValue, Elasticse
6868
/// Gets the Elasticsearch password.
6969
/// </summary>
7070
public string Password { get; }
71+
72+
/// <summary>
73+
/// Gets a value indicating whether TLS is enabled or not.
74+
/// </summary>
75+
public bool TlsEnabled
76+
{
77+
get
78+
{
79+
var hasSecurityEnabled = Environments
80+
.TryGetValue("xpack.security.enabled", out var securityEnabled);
81+
82+
var hasHttpSslEnabled = Environments
83+
.TryGetValue("xpack.security.http.ssl.enabled", out var httpSslEnabled);
84+
85+
var httpsDisabled =
86+
hasSecurityEnabled &&
87+
hasHttpSslEnabled &&
88+
"false".Equals(securityEnabled, StringComparison.OrdinalIgnoreCase) &&
89+
"false".Equals(httpSslEnabled, StringComparison.OrdinalIgnoreCase);
90+
91+
return !httpsDisabled;
92+
}
93+
}
7194
}

src/Testcontainers.Elasticsearch/ElasticsearchContainer.cs

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,7 @@ public ElasticsearchContainer(ElasticsearchConfiguration configuration)
2828
/// <returns>The Elasticsearch connection string.</returns>
2929
public string GetConnectionString()
3030
{
31-
var hasSecurityEnabled = _configuration.Environments
32-
.TryGetValue("xpack.security.enabled", out var securityEnabled);
33-
34-
var hasHttpSslEnabled = _configuration.Environments
35-
.TryGetValue("xpack.security.http.ssl.enabled", out var httpSslEnabled);
36-
37-
var httpsDisabled =
38-
hasSecurityEnabled &&
39-
hasHttpSslEnabled &&
40-
"false".Equals(securityEnabled, StringComparison.OrdinalIgnoreCase) &&
41-
"false".Equals(httpSslEnabled, StringComparison.OrdinalIgnoreCase);
42-
43-
var scheme = httpsDisabled ? Uri.UriSchemeHttp : Uri.UriSchemeHttps;
44-
31+
var scheme = _configuration.TlsEnabled ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
4532
var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(ElasticsearchBuilder.ElasticsearchHttpsPort));
4633
endpoint.UserName = _configuration.Username;
4734
endpoint.Password = _configuration.Password;

src/Testcontainers.Elasticsearch/Usings.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
global using System;
2-
global using System.Collections.Generic;
32
global using System.Linq;
3+
global using System.Net.Http;
44
global using System.Text;
5+
global using System.Text.Json;
56
global using System.Threading.Tasks;
67
global using Docker.DotNet.Models;
78
global using DotNet.Testcontainers;

tests/Testcontainers.Elasticsearch.Tests/ElasticsearchContainerTest.cs

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
namespace Testcontainers.Elasticsearch;
22

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

7+
private ElasticsearchContainerTest(ElasticsearchContainer elasticsearchContainer)
8+
{
9+
_elasticsearchContainer = elasticsearchContainer;
10+
}
11+
12+
// # --8<-- [start:UseElasticsearchContainer]
813
public async ValueTask InitializeAsync()
914
{
1015
await _elasticsearchContainer.StartAsync()
1116
.ConfigureAwait(false);
1217
}
1318

14-
public ValueTask DisposeAsync()
19+
public async ValueTask DisposeAsync()
1520
{
16-
return _elasticsearchContainer.DisposeAsync();
21+
await DisposeAsyncCore()
22+
.ConfigureAwait(false);
23+
24+
GC.SuppressFinalize(this);
1725
}
1826

1927
[Fact]
@@ -33,4 +41,29 @@ public void PingReturnsValidResponse()
3341
Assert.True(response.IsValidResponse);
3442
}
3543
// # --8<-- [end:UseElasticsearchContainer]
44+
45+
protected virtual ValueTask DisposeAsyncCore()
46+
{
47+
return _elasticsearchContainer.DisposeAsync();
48+
}
49+
50+
// # --8<-- [start:CreateElasticsearchContainer]
51+
[UsedImplicitly]
52+
public sealed class ElasticsearchDefaultConfiguration : ElasticsearchContainerTest
53+
{
54+
public ElasticsearchDefaultConfiguration()
55+
: base(new ElasticsearchBuilder().Build())
56+
{
57+
}
58+
}
59+
60+
[UsedImplicitly]
61+
public sealed class ElasticsearchAuthConfiguration : ElasticsearchContainerTest
62+
{
63+
public ElasticsearchAuthConfiguration()
64+
: base(new ElasticsearchBuilder().WithPassword("some-password").Build())
65+
{
66+
}
67+
}
68+
// # --8<-- [end:CreateElasticsearchContainer]
3669
}

tests/Testcontainers.Elasticsearch.Tests/Usings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
global using DotNet.Testcontainers.Commons;
44
global using Elastic.Clients.Elasticsearch;
55
global using Elastic.Transport;
6+
global using JetBrains.Annotations;
67
global using Xunit;

tests/Testcontainers.Grafana.Tests/GrafanaContainerTest.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ await DisposeAsyncCore()
3535
public async Task GetCurrentOrganizationReturnsHttpStatusCodeOk()
3636
{
3737
// Given
38-
var basicAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Join(":", _username, _password)));
38+
var authToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Join(":", _username, _password)));
3939

4040
using var httpClient = new HttpClient();
4141
httpClient.BaseAddress = new Uri(_grafanaContainer.GetBaseAddress());
42-
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", basicAuth);
42+
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authToken);
4343

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

0 commit comments

Comments
 (0)