Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -53,6 +55,8 @@ public class KibanaContainer extends GenericContainer<KibanaContainer> {

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

private String encryptionKey;

private ElasticsearchContainer elasticsearch;

private String elasticsearchUrl;
Expand Down Expand Up @@ -105,12 +109,58 @@ public KibanaContainer(final DockerImageName dockerImageName) {
super(dockerImageName);
ensureCompatibleVersion(dockerImageName.getVersionPart());
dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME);
this.encryptionKey = stableConfigKey(dockerImageName);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.
*
* <p><b>Supported in external mode only.</b> 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.
*
* <p>Reuse is <b>not supported in managed mode</b> (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.
*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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("-", "");
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";

Expand Down
Loading