From 60155e0112273f37ee69d6e80fcec098e44942e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:50:23 +0200 Subject: [PATCH 1/6] Wait for cluster health instead of log message in ElasticsearchContainer Elasticsearch may open port 9200 and emit the "started" log message before completing security initialization (e.g. generating keys, creating internal indices). Replace the log-message wait strategy with an HTTP health check on /_cluster/health that only passes when the cluster status is yellow or green. The wait strategy is configured in configure() so it can read the final env map, including any password or SSL settings the user applies after construction. When the image tag is non-numeric (e.g. "latest"), isAtLeastMajorVersion8 may be true even though the actual image is older and uses HTTP. Use the version-based HTTPS default only when the tag is a concrete numeric version. --- .../elasticsearch/ElasticsearchContainer.java | 76 +++++++++++++++---- .../ElasticsearchContainerTest.java | 21 +++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java index 2080d21649a..ae6cf6d174c 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -6,6 +6,7 @@ import org.apache.commons.lang3.StringUtils; import org.testcontainers.containers.BindMode; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.ComparableVersion; import org.testcontainers.utility.DockerImageName; @@ -15,6 +16,7 @@ import java.security.KeyStore; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; +import java.time.Duration; import java.util.Optional; import javax.net.ssl.SSLContext; @@ -73,8 +75,12 @@ public class ElasticsearchContainer extends GenericContainer + body.contains("\"status\":\"green\"") || body.contains("\"status\":\"yellow\"") + ) + .withStartupTimeout(healthCheckTimeout) + ); + } + public String getHttpHostAddress() { return getHost() + ":" + getMappedPort(ELASTICSEARCH_DEFAULT_PORT); } /** - * Checks env first if this implies HTTP/HTTPS. - * Otherwise, detects the scheme used by Elasticsearch using curl + * Checks env first if this implies HTTP/HTTPS, then falls back to version-based defaults. + * For 7.x with non-standard SSL configured outside env vars, runs a curl probe (requires a running container). * * @return "http" or "https" */ @@ -235,10 +276,17 @@ String getHttpScheme() { return "https"; } + // Version-based default: 8.x uses HTTPS by default. + // Only apply when the version tag is a concrete numeric version; ambiguous tags + // like "latest" may point to an older image that uses HTTP. + if (isAtLeastMajorVersion8 && isVersionNumeric) { + return "https"; + } + + // 7.x without explicit SSL config: HTTP is the default. + // When running, we probe with curl in case SSL was configured outside env vars. if (!isRunning()) { - throw new IllegalStateException( - "Cannot determine HTTP scheme: environment variables are not set and container is not running for curl probe" - ); + return "http"; } ExecResult httpsResult = null; diff --git a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java index 52cd78b4a34..00494948f1d 100644 --- a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java +++ b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java @@ -45,6 +45,10 @@ class ElasticsearchContainerTest { .parse("docker.elastic.co/elasticsearch/elasticsearch") .withTag(ELASTICSEARCH_VERSION); + private static final DockerImageName ELASTICSEARCH_LATEST_IMAGE = DockerImageName.parse( + "docker.elastic.co/elasticsearch/elasticsearch:9.2.4" + ); + /** * Elasticsearch default username, when secured */ @@ -162,6 +166,23 @@ void elasticsearchVersion83() throws IOException { } } + @Test + void clusterHealthIsAtLeastYellowAfterStart() throws IOException { + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_LATEST_IMAGE)) { + container.start(); + + Response response = getClient(container).performRequest(new Request("GET", "/_cluster/health")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + String body = EntityUtils.toString(response.getEntity()); + assertThat(body) + .as("Cluster health status should be at least yellow after container start") + .satisfiesAnyOf( + b -> assertThat(b).contains("\"status\":\"yellow\""), + b -> assertThat(b).contains("\"status\":\"green\"") + ); + } + } + @Test void elasticsearchOssImage() throws IOException { try ( From 156d07effb9734b5979e14fe7a426037a837ea05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:14:35 +0200 Subject: [PATCH 2/6] Tighten isVersionNumeric to require major.minor prefix Tags like "8.custom" start with a digit and a dot but are not a real version. Require at least major.minor (both numeric) so that valid variant tags such as "9.5.2-arm64" are still accepted while ambiguous custom tags are not. --- .../testcontainers/elasticsearch/ElasticsearchContainer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java index ae6cf6d174c..9f1a171eefd 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -121,7 +121,7 @@ public ElasticsearchContainer(final DockerImageName dockerImageName) { addExposedPorts(ELASTICSEARCH_DEFAULT_PORT, ELASTICSEARCH_DEFAULT_TCP_PORT); String versionPart = dockerImageName.getVersionPart(); this.isAtLeastMajorVersion8 = new ComparableVersion(versionPart).isGreaterThanOrEqualTo("8.0.0"); - this.isVersionNumeric = versionPart.matches("\\d+\\..*"); + this.isVersionNumeric = versionPart.matches("\\d+\\.\\d+.*"); // Wait strategy is deferred to configure() so it can read the final env map // (e.g. password and SSL settings that the user may set after construction). setWaitStrategy(null); From e08d9608f3a440410af0fc5d057810201eac70fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:06:58 +0200 Subject: [PATCH 3/6] Defer scheme detection to wait time via AbstractWaitStrategy Previously getHttpScheme() was called from configure(), before the container starts. For 7.x with SSL configured via a mounted config file rather than env vars, this would always resolve to HTTP because the curl probe requires a running container. Wrapping the health check in an AbstractWaitStrategy defers the getHttpScheme() call to the moment the container is already up, so the curl probe is available for all configurations. --- .../elasticsearch/ElasticsearchContainer.java | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java index 9f1a171eefd..ab2cc090a5f 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -6,6 +6,7 @@ import org.apache.commons.lang3.StringUtils; import org.testcontainers.containers.BindMode; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.ComparableVersion; @@ -235,21 +236,29 @@ private void configureWaitStrategy() { if (getWaitStrategy() != null) { return; } - HttpWaitStrategy strategy = "https".equals(getHttpScheme()) - ? Wait.forHttps("/_cluster/health").forPort(ELASTICSEARCH_DEFAULT_PORT).allowInsecure() - : Wait.forHttp("/_cluster/health").forPort(ELASTICSEARCH_DEFAULT_PORT); - String password = getEnvMap().get("ELASTIC_PASSWORD"); - if (password != null) { - strategy = strategy.withBasicCredentials("elastic", password); - } - setWaitStrategy( - strategy - .forStatusCode(200) - .forResponsePredicate(body -> - body.contains("\"status\":\"green\"") || body.contains("\"status\":\"yellow\"") - ) + new AbstractWaitStrategy() { + @Override + protected void waitUntilReady() { + // getHttpScheme() is called here, after the container has started, + // so the curl probe is available for cases where the scheme cannot + // be determined from env vars or the version tag alone. + HttpWaitStrategy inner = "https".equals(getHttpScheme()) + ? Wait.forHttps("/_cluster/health").forPort(ELASTICSEARCH_DEFAULT_PORT).allowInsecure() + : Wait.forHttp("/_cluster/health").forPort(ELASTICSEARCH_DEFAULT_PORT); + if (password != null) { + inner = inner.withBasicCredentials("elastic", password); + } + inner + .forStatusCode(200) + .forResponsePredicate(body -> + body.contains("\"status\":\"green\"") || body.contains("\"status\":\"yellow\"") + ) + .withStartupTimeout(startupTimeout) + .waitUntilReady(waitStrategyTarget); + } + } .withStartupTimeout(healthCheckTimeout) ); } From 141984b3dfb05f9012e6ded25a71ec897ead28c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:40:21 +0200 Subject: [PATCH 4/6] Wait for port 9200 before curl probe; drop version heuristic from getHttpScheme Before calling getHttpScheme() in the wait strategy, wait for port 9200 to accept TCP connections. This guarantees the curl probe always finds a live socket, so the version-based isAtLeastMajorVersion8/isVersionNumeric shortcut is no longer needed and is removed. getHttpScheme() now relies solely on explicit env-var config and the curl probe, matching its original behaviour. --- .../elasticsearch/ElasticsearchContainer.java | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java index ab2cc090a5f..f6c6fa0138c 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -76,8 +76,6 @@ public class ElasticsearchContainer extends GenericContainer - body.contains("\"status\":\"green\"") || body.contains("\"status\":\"yellow\"") - ) + .forResponsePredicate(body -> { + return body.contains("\"status\":\"green\"") || body.contains("\"status\":\"yellow\""); + }) .withStartupTimeout(startupTimeout) .waitUntilReady(waitStrategyTarget); } @@ -268,8 +266,8 @@ public String getHttpHostAddress() { } /** - * Checks env first if this implies HTTP/HTTPS, then falls back to version-based defaults. - * For 7.x with non-standard SSL configured outside env vars, runs a curl probe (requires a running container). + * Detects the HTTP scheme used by Elasticsearch. Respects explicit env-var config first; + * when ambiguous, probes the live socket with curl (requires a running container on port 9200). * * @return "http" or "https" */ @@ -285,17 +283,10 @@ String getHttpScheme() { return "https"; } - // Version-based default: 8.x uses HTTPS by default. - // Only apply when the version tag is a concrete numeric version; ambiguous tags - // like "latest" may point to an older image that uses HTTP. - if (isAtLeastMajorVersion8 && isVersionNumeric) { - return "https"; - } - - // 7.x without explicit SSL config: HTTP is the default. - // When running, we probe with curl in case SSL was configured outside env vars. if (!isRunning()) { - return "http"; + throw new IllegalStateException( + "Cannot determine HTTP scheme: environment variables are not set and container is not running for curl probe" + ); } ExecResult httpsResult = null; From 248c124885e947af733f5df31cfea4118f07190f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:42:29 +0200 Subject: [PATCH 5/6] Share single deadline between port wait and cluster health wait Both steps now draw from one deadline computed at the start of waitUntilReady(), so the total wait is bounded by startupTimeout instead of potentially 2x that. --- .../elasticsearch/ElasticsearchContainer.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java index f6c6fa0138c..6273e12350b 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -18,6 +18,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateFactory; import java.time.Duration; +import java.time.Instant; import java.util.Optional; import javax.net.ssl.SSLContext; @@ -241,7 +242,10 @@ protected void waitUntilReady() { // Wait for port 9200 to accept TCP connections first, so that // getHttpScheme()'s curl probe always finds a live socket and no // version-based heuristics are needed. + // Track the deadline so both steps share the same total timeout. + Instant deadline = Instant.now().plus(startupTimeout); Wait.forListeningPort().withStartupTimeout(startupTimeout).waitUntilReady(waitStrategyTarget); + Duration remaining = Duration.between(Instant.now(), deadline); HttpWaitStrategy inner = "https".equals(getHttpScheme()) ? Wait.forHttps("/_cluster/health").forPort(ELASTICSEARCH_DEFAULT_PORT).allowInsecure() : Wait.forHttp("/_cluster/health").forPort(ELASTICSEARCH_DEFAULT_PORT); @@ -253,7 +257,7 @@ protected void waitUntilReady() { .forResponsePredicate(body -> { return body.contains("\"status\":\"green\"") || body.contains("\"status\":\"yellow\""); }) - .withStartupTimeout(startupTimeout) + .withStartupTimeout(remaining.isNegative() ? Duration.ZERO : remaining) .waitUntilReady(waitStrategyTarget); } } From e1d5f420dc1ae2bed958b7ee4dadbd172f898344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:50:07 +0200 Subject: [PATCH 6/6] Read password at wait time to handle stop/restart with changed credentials Password was previously captured at configure() time and baked into the wait-strategy closure. On a stop/withPassword/start cycle the guard `if (getWaitStrategy() != null) return` prevented reconfiguration, so the health check kept using the original password and timed out. Moving the env-map lookup inside waitUntilReady() ensures the current password is always used, regardless of how many times the container is restarted. --- .../testcontainers/elasticsearch/ElasticsearchContainer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java index 6273e12350b..e08db06581e 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -234,11 +234,11 @@ private void configureWaitStrategy() { if (getWaitStrategy() != null) { return; } - String password = getEnvMap().get("ELASTIC_PASSWORD"); setWaitStrategy( new AbstractWaitStrategy() { @Override protected void waitUntilReady() { + String password = getEnvMap().get("ELASTIC_PASSWORD"); // Wait for port 9200 to accept TCP connections first, so that // getHttpScheme()'s curl probe always finds a live socket and no // version-based heuristics are needed.