From 33771bfab2425fbe532f025c4a1e5b66d3ff2512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:14:23 +0200 Subject: [PATCH 1/3] Fixed KibanaContainer reusability for external mode. --- .../elasticsearch/KibanaContainer.java | 67 ++++++++++++++++++- .../elasticsearch/KibanaContainerTest.java | 45 +++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java index a7214f0618d..6b0a95ee53f 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java @@ -20,6 +20,9 @@ import org.testcontainers.utility.ComparableVersion; import org.testcontainers.utility.DockerImageName; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -53,6 +56,8 @@ public class KibanaContainer extends GenericContainer { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private String encryptionKey; + private ElasticsearchContainer elasticsearch; private String elasticsearchUrl; @@ -105,12 +110,58 @@ public KibanaContainer(final DockerImageName dockerImageName) { super(dockerImageName); ensureCompatibleVersion(dockerImageName.getVersionPart()); dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + this.encryptionKey = deriveDefaultEncryptionKey(dockerImageName); withExposedPorts(KIBANA_DEFAULT_PORT); //we have to explicitly set wait the strategy later on in configure, once we know the security configuration setWaitStrategy(null); } + /** + * Sets the encryption key used for Kibana's encrypted saved objects. + * The key must be at least 32 characters. When not set, a deterministic default derived from + * the image name is used, which is required for {@link #withReuse(boolean)} to work correctly. + * + * @param encryptionKey the encryption key + * @return this container instance + */ + public KibanaContainer withEncryptionKey(String encryptionKey) { + if (encryptionKey == null || encryptionKey.length() < 32) { + throw new IllegalArgumentException("Kibana encryption key must be at least 32 characters long"); + } + this.encryptionKey = encryptionKey; + return this; + } + + /** + * Enables or disables container reuse across JVM runs. + * + *

Supported in external mode only. When Kibana is configured via + * {@link #withElasticsearchUrl(String)}, the container configuration is fully deterministic + * and TC can reliably locate the running container on subsequent runs. + * + *

Reuse is not supported in managed mode (i.e. when this container was created with + * an {@link ElasticsearchContainer}). Managed mode introduces several non-deterministic inputs + * into the container hash on every run (ad-hoc network ID, random network alias, fresh service + * account token), so TC always sees a different hash and starts a fresh container instead of + * reusing the existing one. This is a framework-level characteristic that affects any container + * connected via {@code withNetwork()} — not specific to {@code KibanaContainer}. + * + * @param reusable whether to enable container reuse + * @return this container instance + * @throws IllegalStateException if {@code reusable} is {@code true} and managed mode is active + */ + @Override + public KibanaContainer withReuse(boolean reusable) { + if (reusable && elasticsearch != null) { + throw new IllegalStateException( + "withReuse(true) is not supported for KibanaContainer in managed mode. " + + "Use external mode (withElasticsearchUrl) to enable reuse." + ); + } + return super.withReuse(reusable); + } + /** * Configures the Elasticsearch URL for external mode. * @@ -212,7 +263,7 @@ public KibanaContainer withElasticsearchCaCertificate(byte[] caCertificate) { protected void configure() { super.configure(); - addEnv("XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY", generateRandomKey(32)); + addEnv("XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY", encryptionKey); addEnv("SERVER_NAME", "kibana"); if (elasticsearchCaCertificate != null) { @@ -541,8 +592,18 @@ private String createKibanaServiceAccountToken(String protocol) { ); } - private String generateRandomKey(int length) { - return Base58.randomString(length); + private static String deriveDefaultEncryptionKey(DockerImageName imageName) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(imageName.asCanonicalNameString().getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(64); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.substring(0, 32); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } } /** diff --git a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java index a6b9466690a..30c36d8d1c2 100644 --- a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java +++ b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java @@ -440,6 +440,51 @@ private static String getKibanaStatus(KibanaContainer kibana) throws IOException } } + @Test + void withReuseShouldReuseTheSameContainer() { + final String kibanaImage = "docker.elastic.co/kibana/kibana:9.2.4"; + + // Kibana reaches ES via host.docker.internal (available on Docker Desktop without any + // TC configuration, so no extra-host entry enters the CreateContainerCmd hash). + // No withNetwork() on Kibana keeps the hash fully deterministic: + // - no dynamic network ID + // - the random tc-* alias added by GenericContainer's constructor is only serialised + // into the CreateContainerCmd when withNetwork() has been called, so it is absent here + // The first Kibana container must stay running while the second one starts, because + // withReuse(true) only skips JVM-shutdown cleanup — an explicit stop() still removes the + // container, so there would be nothing to find. + try ( + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE) + .withEnv("xpack.security.enabled", "false") + .withEnv("xpack.security.http.ssl.enabled", "false") + ) { + es.start(); + String esUrl = "http://host.docker.internal:" + es.getMappedPort(9200); + + KibanaContainer kibana1 = new KibanaContainer(kibanaImage) + .withElasticsearchUrl(esUrl) + .withReuse(true); + KibanaContainer kibana2 = new KibanaContainer(kibanaImage) + .withElasticsearchUrl(esUrl) + .withReuse(true); + + try { + kibana1.start(); + // kibana2 is started while kibana1 is still running; the reuse mechanism should + // find kibana1's container by hash and return the same container ID. + kibana2.start(); + + Assertions + .assertThat(kibana2.getContainerId()) + .as("KibanaContainer with withReuse(true) should reuse the same container on subsequent starts") + .isEqualTo(kibana1.getContainerId()); + } finally { + kibana1.stop(); + kibana2.stop(); + } + } + } + private static void applyTls(ElasticsearchContainer c, byte[] caCrt, byte[] nodeCrt, byte[] nodeKey) { final String certDir = "/usr/share/elasticsearch/config/certs"; From 1a31c444556df2d14ffc3b1d6ad67ee253e164f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:55:38 +0200 Subject: [PATCH 2/3] Addressing PR remarks --- .../elasticsearch/KibanaContainer.java | 25 ++++++++----------- .../elasticsearch/KibanaContainerTest.java | 19 +++++++++++--- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java index 6b0a95ee53f..ee8ce714854 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java @@ -21,10 +21,9 @@ import org.testcontainers.utility.DockerImageName; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.util.Map; +import java.util.UUID; import java.util.concurrent.TimeUnit; /** @@ -110,7 +109,7 @@ public KibanaContainer(final DockerImageName dockerImageName) { super(dockerImageName); ensureCompatibleVersion(dockerImageName.getVersionPart()); dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); - this.encryptionKey = deriveDefaultEncryptionKey(dockerImageName); + this.encryptionKey = stableConfigKey(dockerImageName); withExposedPorts(KIBANA_DEFAULT_PORT); //we have to explicitly set wait the strategy later on in configure, once we know the security configuration @@ -592,18 +591,14 @@ private String createKibanaServiceAccountToken(String protocol) { ); } - private static String deriveDefaultEncryptionKey(DockerImageName imageName) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(imageName.asCanonicalNameString().getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(64); - for (byte b : hash) { - sb.append(String.format("%02x", b)); - } - return sb.substring(0, 32); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } + private static String stableConfigKey(DockerImageName imageName) { + // UUID v3 (name-based) gives a deterministic 32-character string from the image name, + // keeping xpack.encryptedSavedObjects.encryptionKey identical across JVM runs — + // a prerequisite for container reuse. Call withEncryptionKey() for real secret management. + return UUID + .nameUUIDFromBytes(imageName.asCanonicalNameString().getBytes(StandardCharsets.UTF_8)) + .toString() + .replace("-", ""); } /** diff --git a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java index 30c36d8d1c2..b52c537afc1 100644 --- a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java +++ b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java @@ -16,11 +16,15 @@ import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.util.EntityUtils; import org.assertj.core.api.Assertions; +import org.assertj.core.api.Assumptions; import org.junit.jupiter.api.Test; +import org.testcontainers.Testcontainers; import org.testcontainers.containers.Container; import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.Network; import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.TestcontainersConfiguration; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -442,14 +446,21 @@ private static String getKibanaStatus(KibanaContainer kibana) throws IOException @Test void withReuseShouldReuseTheSameContainer() { + Assumptions + .assumeThat(TestcontainersConfiguration.getInstance().environmentSupportsReuse()) + .as("testcontainers.reuse.enable must be true") + .isTrue(); + final String kibanaImage = "docker.elastic.co/kibana/kibana:9.2.4"; - // Kibana reaches ES via host.docker.internal (available on Docker Desktop without any - // TC configuration, so no extra-host entry enters the CreateContainerCmd hash). + // Testcontainers.exposeHostPorts + host.testcontainers.internal lets Kibana reach ES + // from inside the container on any platform (Linux Docker Engine included). // No withNetwork() on Kibana keeps the hash fully deterministic: // - no dynamic network ID // - the random tc-* alias added by GenericContainer's constructor is only serialised // into the CreateContainerCmd when withNetwork() has been called, so it is absent here + // The host.testcontainers.internal extra-host IP is the same for kibana1 and kibana2 + // because they start in the same JVM (same PortForwardingContainer instance). // The first Kibana container must stay running while the second one starts, because // withReuse(true) only skips JVM-shutdown cleanup — an explicit stop() still removes the // container, so there would be nothing to find. @@ -459,7 +470,9 @@ void withReuseShouldReuseTheSameContainer() { .withEnv("xpack.security.http.ssl.enabled", "false") ) { es.start(); - String esUrl = "http://host.docker.internal:" + es.getMappedPort(9200); + int esMappedPort = es.getMappedPort(9200); + Testcontainers.exposeHostPorts(esMappedPort); + String esUrl = "http://" + GenericContainer.INTERNAL_HOST_HOSTNAME + ":" + esMappedPort; KibanaContainer kibana1 = new KibanaContainer(kibanaImage) .withElasticsearchUrl(esUrl) From 78fa314fca32ef759877d74d27b0c83b6e918787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Przyby=C5=82?= <23506256+pioorg@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:58:17 +0200 Subject: [PATCH 3/3] Fixed formatting --- .../testcontainers/elasticsearch/KibanaContainerTest.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java index b52c537afc1..0099b5613ca 100644 --- a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java +++ b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java @@ -474,12 +474,8 @@ void withReuseShouldReuseTheSameContainer() { Testcontainers.exposeHostPorts(esMappedPort); String esUrl = "http://" + GenericContainer.INTERNAL_HOST_HOSTNAME + ":" + esMappedPort; - KibanaContainer kibana1 = new KibanaContainer(kibanaImage) - .withElasticsearchUrl(esUrl) - .withReuse(true); - KibanaContainer kibana2 = new KibanaContainer(kibanaImage) - .withElasticsearchUrl(esUrl) - .withReuse(true); + KibanaContainer kibana1 = new KibanaContainer(kibanaImage).withElasticsearchUrl(esUrl).withReuse(true); + KibanaContainer kibana2 = new KibanaContainer(kibanaImage).withElasticsearchUrl(esUrl).withReuse(true); try { kibana1.start();