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..ee8ce714854 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java @@ -20,8 +20,10 @@ import org.testcontainers.utility.ComparableVersion; import org.testcontainers.utility.DockerImageName; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; +import java.util.UUID; import java.util.concurrent.TimeUnit; /** @@ -53,6 +55,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 +109,58 @@ public KibanaContainer(final DockerImageName dockerImageName) { super(dockerImageName); ensureCompatibleVersion(dockerImageName.getVersionPart()); dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + 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 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 +262,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 +591,14 @@ private String createKibanaServiceAccountToken(String protocol) { ); } - private String generateRandomKey(int length) { - return Base58.randomString(length); + 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 a6b9466690a..0099b5613ca 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; @@ -440,6 +444,56 @@ 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"; + + // 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. + try ( + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE) + .withEnv("xpack.security.enabled", "false") + .withEnv("xpack.security.http.ssl.enabled", "false") + ) { + es.start(); + int esMappedPort = es.getMappedPort(9200); + 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); + + 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";