From 5923c09bf2c169229d09c10d4f94ede01a9e5de4 Mon Sep 17 00:00:00 2001 From: sanghun Date: Wed, 1 Jul 2026 00:58:42 +0900 Subject: [PATCH 1/6] Use Testcontainers for integration tests Signed-off-by: sanghun --- .../workflows/test-integration-unreleased.yml | 2 +- .github/workflows/test-integration.yml | 25 +-- CHANGELOG.md | 1 + DEVELOPER_GUIDE.md | 21 ++- java-client/build.gradle.kts | 18 +- .../OpenSearchJavaClientTestCase.java | 10 ++ .../integTest/OpenSearchTestContainer.java | 160 ++++++++++++++++++ .../integTest/TestcontainersThreadFilter.java | 20 +++ 8 files changed, 224 insertions(+), 33 deletions(-) create mode 100644 java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java create mode 100644 java-client/src/test/java11/org/opensearch/client/opensearch/integTest/TestcontainersThreadFilter.java diff --git a/.github/workflows/test-integration-unreleased.yml b/.github/workflows/test-integration-unreleased.yml index 965698f4c5..e13cd447a1 100644 --- a/.github/workflows/test-integration-unreleased.yml +++ b/.github/workflows/test-integration-unreleased.yml @@ -90,7 +90,7 @@ jobs: - name: Run Integration Test run: | cd opensearch-java - ./gradlew clean integrationTest -Dhttps=false + ./gradlew clean integrationTest -Dhttps=false -Dtests.opensearch.testcontainers.enabled=false - name: Upload Reports if: failure() diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 821b765f49..43baaf7772 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -40,27 +40,8 @@ jobs: java-version: ${{ matrix.entry.java }} distribution: 'temurin' cache: 'gradle' - - name: Run Docker - run: | - echo "PASSWORD=admin" >> $GITHUB_ENV - docker info - docker compose --project-directory .ci/opensearch build --build-arg OPENSEARCH_VERSION=${{ matrix.entry.opensearch_version }} - docker compose --project-directory .ci/opensearch up -d - sleep 60 - - - name: Sets password (new versions) - run: | - VERSION_COMPONENTS=(${OPENSEARCH_VERSION//./ }) - MAJOR_VERSION=${VERSION_COMPONENTS[0]} - MINOR_VERSION=${VERSION_COMPONENTS[1]} - if (( $MAJOR_VERSION > 2 || ( $MAJOR_VERSION == 2 && $MINOR_VERSION >= 12 ) )); then - echo "PASSWORD=0_aD^min_0" >> $GITHUB_ENV - fi - env: - OPENSEARCH_VERSION: ${{ matrix.entry.opensearch_version }} - - name: Run Integration Test - run: ./gradlew clean integrationTest -Dpassword=${{ env.PASSWORD }} + run: ./gradlew clean integrationTest -Dtests.opensearch.version=${{ matrix.entry.opensearch_version }} - name: Upload Reports if: failure() @@ -69,7 +50,3 @@ jobs: name: test-reports-os${{ matrix.entry.opensearch_version }}-java${{ matrix.entry.java }} path: java-client/build/reports/ retention-days: 7 - - - name: Stop Docker - run: | - docker compose --project-directory .ci/opensearch down \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f85eaecf78..fa68c23cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Bump `org.apache.httpcomponents.client5:httpclient5` from 5.6 to 5.6.1 ([#1967](https://github.com/opensearch-project/opensearch-java/pull/1967)) ### Added +- Run Java client integration tests with a Testcontainers-managed OpenSearch instance by default ([#2033](https://github.com/opensearch-project/opensearch-java/pull/2033)) - Detect AWS SDK `Apache5HttpClient` in `AwsSdk2Transport` body-method guardrail ([#1903](https://github.com/opensearch-project/opensearch-java/pull/1970)) - Support Jackson 3.x release line ([#1810](https://github.com/opensearch-project/opensearch-java/pull/1810)) - Added `equals()` and `hashCode()` implementations to `FieldValue` ([#1998](https://github.com/opensearch-project/opensearch-java/pull/1998)) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6969aba856..109c7adce7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -52,17 +52,28 @@ To run unit tests for the java-client: #### Integration Tests -To run integration tests for the java-client, start an OpenSearch cluster using docker and pass the OpenSearch version: +To run integration tests for the java-client: ``` -docker-compose --project-directory .ci/opensearch build --build-arg OPENSEARCH_VERSION=1.3.0 -docker-compose --project-directory .ci/opensearch up -d +./gradlew clean integrationTest ``` -Run integration tests after starting OpenSearch cluster: +By default, the integration test task starts a single OpenSearch test container for the test JVM. To test against a specific OpenSearch image version, pass the OpenSearch version: ``` -./gradlew clean integrationTest +./gradlew clean integrationTest -Dtests.opensearch.version=3.2.0 +``` + +To use a custom security-enabled OpenSearch image, pass the full image name: + +``` +./gradlew clean integrationTest -Dtests.opensearch.image=opensearchproject/opensearch:3.2.0 +``` + +To run against an already running cluster, disable the test container and pass the cluster endpoint if it is not `localhost:9200`: + +``` +./gradlew clean integrationTest -Dtests.opensearch.testcontainers.enabled=false -Dtests.rest.cluster=localhost:9200 ``` #### AWS Transport Integration Tests diff --git a/java-client/build.gradle.kts b/java-client/build.gradle.kts index 3f23f76b20..2af422f2eb 100644 --- a/java-client/build.gradle.kts +++ b/java-client/build.gradle.kts @@ -143,6 +143,9 @@ tasks.build { dependsOn("spotlessJavaCheck") } +val opensearchVersion = "3.5.0-SNAPSHOT" +val opensearchDockerVersion = opensearchVersion.removeSuffix("-SNAPSHOT") + tasks.test { systemProperty("tests.security.manager", "false") @@ -168,6 +171,16 @@ val integrationTest = task("integrationTest") { systemProperty("https", System.getProperty("https", "true")) systemProperty("user", System.getProperty("user", "admin")) systemProperty("password", System.getProperty("password", "admin")) + systemProperty( + "tests.opensearch.testcontainers.enabled", + System.getProperty("tests.opensearch.testcontainers.enabled", "true") + ) + systemProperty( + "tests.opensearch.version", + System.getProperty("tests.opensearch.version", opensearchDockerVersion) + ) + System.getProperty("tests.rest.cluster")?.let { systemProperty("tests.rest.cluster", it) } + System.getProperty("tests.opensearch.image")?.let { systemProperty("tests.opensearch.image", it) } systemProperty("tests.awsSdk2support.domainHost", System.getProperty("tests.awsSdk2support.domainHost", null)) systemProperty("tests.awsSdk2support.serviceName", @@ -176,8 +189,6 @@ val integrationTest = task("integrationTest") { System.getProperty("tests.awsSdk2support.domainRegion", "us-east-1")) } -val opensearchVersion = "3.5.0-SNAPSHOT" - dependencies { val jacksonVersion = "2.21.2" val jacksonDatabindVersion = "2.21.2" @@ -387,6 +398,7 @@ if (runtimeJavaVersion >= JavaVersion.VERSION_21) { testImplementation("org.opensearch.test", "framework", opensearchVersion) { exclude(group = "org.hamcrest") } + "java21Implementation"("org.testcontainers:testcontainers:2.0.5") } tasks.named("compileJava21Java") { @@ -408,4 +420,4 @@ if (runtimeJavaVersion >= JavaVersion.VERSION_21) { testClassesDirs += java21.output.classesDirs classpath = sourceSets["java21"].runtimeClasspath } -} \ No newline at end of file +} diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java index e4825420a8..c3bca910aa 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java @@ -8,6 +8,7 @@ package org.opensearch.client.opensearch.integTest; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -30,6 +31,7 @@ import org.junit.After; import org.junit.AfterClass; import org.junit.Before; +import org.junit.BeforeClass; import org.opensearch.Version; import org.opensearch.client.RestClient; import org.opensearch.client.RestClientBuilder; @@ -45,6 +47,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.test.rest.OpenSearchRestTestCase; +@ThreadLeakFilters(filters = TestcontainersThreadFilter.class) public abstract class OpenSearchJavaClientTestCase extends OpenSearchRestTestCase implements OpenSearchTransportSupport { private static final List systemIndices = List.of( ".opensearch-observability", @@ -59,9 +62,16 @@ public abstract class OpenSearchJavaClientTestCase extends OpenSearchRestTestCas private static TreeSet nodeVersions; private static List clusterHosts; + // Superclass setup reads tests.rest.cluster before subclass @Before methods run. + @BeforeClass + public static void startOpenSearchTestContainer() { + OpenSearchTestContainer.startIfNeeded(); + } + @Before public void initJavaClient() throws IOException { if (javaClient == null) { + OpenSearchTestContainer.startIfNeeded(); String cluster = getTestRestCluster(); String[] stringUrls = cluster.split(","); List hosts = new ArrayList<>(stringUrls.length); diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java new file mode 100644 index 0000000000..9600526070 --- /dev/null +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java @@ -0,0 +1,160 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.integTest; + +import java.time.Duration; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +final class OpenSearchTestContainer { + static final String ENABLED_PROPERTY = "tests.opensearch.testcontainers.enabled"; + static final String VERSION_PROPERTY = "tests.opensearch.version"; + static final String IMAGE_PROPERTY = "tests.opensearch.image"; + static final String CLUSTER_PROPERTY = "tests.rest.cluster"; + static final String HTTPS_PROPERTY = "https"; + static final String USER_PROPERTY = "user"; + static final String PASSWORD_PROPERTY = "password"; + + private static final String DEFAULT_IMAGE = "opensearchproject/opensearch"; + private static final String ADMIN_USER = "admin"; + private static final String DEFAULT_ADMIN_PASSWORD = "admin"; + // OpenSearch 2.12+ rejects the old admin/admin demo password during container startup. + private static final String DEFAULT_INITIAL_ADMIN_PASSWORD = "OSClient-Test-2026-Password!"; + private static final int HTTP_PORT = 9200; + + private static GenericContainer container; + private static String password; + + private OpenSearchTestContainer() {} + + static synchronized void startIfNeeded() { + if (hasText(System.getProperty(CLUSTER_PROPERTY)) || !testcontainersEnabled()) { + return; + } + + if (container == null) { + String version = System.getProperty(VERSION_PROPERTY); + String imageName = resolveImageName(version); + String passwordCompatibilityVersion = passwordCompatibilityVersion(version, imageName); + password = passwordFor(passwordCompatibilityVersion); + GenericContainer openSearch = createContainer(imageName, passwordCompatibilityVersion, password); + openSearch.start(); + container = openSearch; + } + + System.setProperty(CLUSTER_PROPERTY, container.getHost() + ":" + container.getMappedPort(HTTP_PORT)); + System.setProperty(HTTPS_PROPERTY, "true"); + System.setProperty(USER_PROPERTY, ADMIN_USER); + System.setProperty(PASSWORD_PROPERTY, password); + } + + private static GenericContainer createContainer(String imageName, String passwordCompatibilityVersion, String adminPassword) { + GenericContainer openSearch = new GenericContainer<>(DockerImageName.parse(imageName)).withExposedPorts(HTTP_PORT) + .withEnv("discovery.type", "single-node") + .withEnv("bootstrap.memory_lock", "true") + .withEnv("cluster.routing.allocation.disk.threshold_enabled", "false") + .waitingFor( + new HttpWaitStrategy().forPort(HTTP_PORT) + .usingTls() + .allowInsecure() + .withBasicCredentials(ADMIN_USER, adminPassword) + .forStatusCode(200) + .withReadTimeout(Duration.ofSeconds(10)) + .withStartupTimeout(Duration.ofMinutes(5)) + ); + + if (requiresInitialAdminPassword(passwordCompatibilityVersion)) { + openSearch.withEnv("OPENSEARCH_INITIAL_ADMIN_PASSWORD", adminPassword); + } + + return openSearch; + } + + private static String resolveImageName(String version) { + String image = System.getProperty(IMAGE_PROPERTY); + if (hasText(image)) { + return image; + } + if (!hasText(version)) { + throw new IllegalStateException("Missing " + VERSION_PROPERTY + " for OpenSearch Testcontainers image"); + } + return DEFAULT_IMAGE + ":" + version; + } + + private static String passwordFor(String version) { + String configuredPassword = System.getProperty(PASSWORD_PROPERTY); + if (requiresInitialAdminPassword(version)) { + if (!hasText(configuredPassword) || DEFAULT_ADMIN_PASSWORD.equals(configuredPassword)) { + return DEFAULT_INITIAL_ADMIN_PASSWORD; + } + return configuredPassword; + } + return hasText(configuredPassword) ? configuredPassword : DEFAULT_ADMIN_PASSWORD; + } + + private static boolean requiresInitialAdminPassword(String version) { + if (!hasText(version) || "latest".equalsIgnoreCase(version)) { + return true; + } + + String[] components = version.split("[-+]", 2)[0].split("\\."); + Integer major = parseInt(components, 0); + if (major == null) { + return true; + } + if (major == 2 && components.length == 1) { + return true; + } + + int minor = parseInt(components, 1, 0); + return major > 2 || major == 2 && minor >= 12; + } + + private static Integer parseInt(String[] components, int index) { + if (index >= components.length) { + return null; + } + try { + return Integer.parseInt(components[index]); + } catch (NumberFormatException e) { + return null; + } + } + + private static int parseInt(String[] components, int index, int defaultValue) { + Integer value = parseInt(components, index); + return value == null ? defaultValue : value; + } + + private static String passwordCompatibilityVersion(String version, String imageName) { + String imageTag = imageTag(imageName); + if (hasText(imageTag)) { + return imageTag; + } + return version; + } + + private static String imageTag(String imageName) { + int lastSlash = imageName.lastIndexOf('/'); + int lastColon = imageName.lastIndexOf(':'); + if (lastColon > lastSlash && lastColon < imageName.length() - 1) { + return imageName.substring(lastColon + 1); + } + return null; + } + + private static boolean testcontainersEnabled() { + return Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true")); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/TestcontainersThreadFilter.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/TestcontainersThreadFilter.java new file mode 100644 index 0000000000..727ab00ee8 --- /dev/null +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/TestcontainersThreadFilter.java @@ -0,0 +1,20 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.integTest; + +import com.carrotsearch.randomizedtesting.ThreadFilter; + +public final class TestcontainersThreadFilter implements ThreadFilter { + @Override + public boolean reject(Thread thread) { + String name = thread.getName(); + // Testcontainers owns these helper threads and Ryuk cleans them up after the JVM exits. + return "testcontainers-ryuk".equals(name) || name.startsWith("testcontainers-pull-watchdog-") || name.startsWith("ducttape-"); + } +} From 42fed030c84513cba04a2b0ca2cde2521e4fb503 Mon Sep 17 00:00:00 2001 From: sanghun Date: Wed, 1 Jul 2026 02:06:36 +0900 Subject: [PATCH 2/6] Remove redundant Testcontainers startup call Signed-off-by: sanghun --- .../opensearch/integTest/OpenSearchJavaClientTestCase.java | 1 - 1 file changed, 1 deletion(-) diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java index c3bca910aa..af5d999a56 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java @@ -71,7 +71,6 @@ public static void startOpenSearchTestContainer() { @Before public void initJavaClient() throws IOException { if (javaClient == null) { - OpenSearchTestContainer.startIfNeeded(); String cluster = getTestRestCluster(); String[] stringUrls = cluster.split(","); List hosts = new ArrayList<>(stringUrls.length); From 26fdffb521da0996acb4e53889245201db2377ef Mon Sep 17 00:00:00 2001 From: sanghun Date: Wed, 1 Jul 2026 02:41:58 +0900 Subject: [PATCH 3/6] Handle unknown OpenSearch versions conservatively Signed-off-by: sanghun --- .../integTest/OpenSearchTestContainer.java | 49 ++++++++++++----- .../OpenSearchTestContainerTests.java | 52 +++++++++++++++++++ 2 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java index 9600526070..2374078669 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java @@ -99,7 +99,7 @@ private static String passwordFor(String version) { return hasText(configuredPassword) ? configuredPassword : DEFAULT_ADMIN_PASSWORD; } - private static boolean requiresInitialAdminPassword(String version) { + static boolean requiresInitialAdminPassword(String version) { if (!hasText(version) || "latest".equalsIgnoreCase(version)) { return true; } @@ -109,36 +109,57 @@ private static boolean requiresInitialAdminPassword(String version) { if (major == null) { return true; } - if (major == 2 && components.length == 1) { + if (major > 2) { return true; } - - int minor = parseInt(components, 1, 0); - return major > 2 || major == 2 && minor >= 12; + if (major < 2) { + return false; + } + Integer minor = parseInt(components, 1); + if (minor == null) { + return true; + } + return minor >= 12; } private static Integer parseInt(String[] components, int index) { if (index >= components.length) { return null; } + return parseInt(components[index]); + } + + private static Integer parseInt(String component) { try { - return Integer.parseInt(components[index]); + return Integer.parseInt(component); } catch (NumberFormatException e) { return null; } } - private static int parseInt(String[] components, int index, int defaultValue) { - Integer value = parseInt(components, index); - return value == null ? defaultValue : value; - } - - private static String passwordCompatibilityVersion(String version, String imageName) { + static String passwordCompatibilityVersion(String version, String imageName) { String imageTag = imageTag(imageName); - if (hasText(imageTag)) { + if (isCompatibilityVersion(imageTag)) { return imageTag; } - return version; + return hasText(version) ? version : imageTag; + } + + private static boolean isCompatibilityVersion(String version) { + if (!hasText(version)) { + return false; + } + if ("latest".equalsIgnoreCase(version)) { + return true; + } + + String[] components = version.split("[-+]", 2)[0].split("\\."); + for (String component : components) { + if (parseInt(component) == null) { + return false; + } + } + return true; } private static String imageTag(String imageName) { diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java new file mode 100644 index 0000000000..afd23f0688 --- /dev/null +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.integTest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class OpenSearchTestContainerTests { + + @Test + public void initialAdminPasswordIsNotRequiredForOlderVersions() { + assertFalse(OpenSearchTestContainer.requiresInitialAdminPassword("1")); + assertFalse(OpenSearchTestContainer.requiresInitialAdminPassword("1.x")); + assertFalse(OpenSearchTestContainer.requiresInitialAdminPassword("1.3.20")); + assertFalse(OpenSearchTestContainer.requiresInitialAdminPassword("2.11.0")); + } + + @Test + public void initialAdminPasswordIsRequiredForNewerVersions() { + assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("2.12.0")); + assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("3.2.0")); + } + + @Test + public void initialAdminPasswordIsRequiredForMissingOrRollingVersions() { + assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword(null)); + assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("2")); + assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("latest")); + } + + @Test + public void initialAdminPasswordIsRequiredForUnknownVersions() { + assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("2.x")); + assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("2.foo")); + assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("custom")); + } + + @Test + public void customImageTagsUseConfiguredVersionForPasswordCompatibility() { + assertEquals("2.11.0", OpenSearchTestContainer.passwordCompatibilityVersion("2.11.0", "registry.example.com/opensearch:custom")); + assertEquals("2.12.0", OpenSearchTestContainer.passwordCompatibilityVersion("2.11.0", "registry.example.com/opensearch:2.12.0")); + } +} From 728e3fe1bed123735181b3163765afa969c587c2 Mon Sep 17 00:00:00 2001 From: sanghun Date: Wed, 1 Jul 2026 08:14:56 +0900 Subject: [PATCH 4/6] Use official OpenSearch Testcontainers Signed-off-by: sanghun --- DEVELOPER_GUIDE.md | 2 +- java-client/build.gradle.kts | 7 +- .../integTest/OpenSearchTestContainer.java | 130 ++---------------- .../OpenSearchTestContainerTests.java | 43 +++--- 4 files changed, 37 insertions(+), 145 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 109c7adce7..208a6644e1 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -64,7 +64,7 @@ By default, the integration test task starts a single OpenSearch test container ./gradlew clean integrationTest -Dtests.opensearch.version=3.2.0 ``` -To use a custom security-enabled OpenSearch image, pass the full image name: +To pass the full official OpenSearch image name, use: ``` ./gradlew clean integrationTest -Dtests.opensearch.image=opensearchproject/opensearch:3.2.0 diff --git a/java-client/build.gradle.kts b/java-client/build.gradle.kts index 2af422f2eb..2bb781de1c 100644 --- a/java-client/build.gradle.kts +++ b/java-client/build.gradle.kts @@ -398,7 +398,12 @@ if (runtimeJavaVersion >= JavaVersion.VERSION_21) { testImplementation("org.opensearch.test", "framework", opensearchVersion) { exclude(group = "org.hamcrest") } - "java21Implementation"("org.testcontainers:testcontainers:2.0.5") + // opensearch-testcontainers exposes OpenSearchContainer as a GenericContainer subclass, but publishes + // Testcontainers as a runtime dependency. javac needs the superclass API for inherited methods. + "java21CompileOnly"("org.testcontainers:testcontainers:2.0.3") { + isTransitive = false + } + "java21Implementation"("org.opensearch:opensearch-testcontainers:4.1.0") } tasks.named("compileJava21Java") { diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java index 2374078669..26b173a90e 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java @@ -8,10 +8,7 @@ package org.opensearch.client.opensearch.integTest; -import java.time.Duration; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; -import org.testcontainers.utility.DockerImageName; +import org.opensearch.testcontainers.OpenSearchContainer; final class OpenSearchTestContainer { static final String ENABLED_PROPERTY = "tests.opensearch.testcontainers.enabled"; @@ -23,14 +20,10 @@ final class OpenSearchTestContainer { static final String PASSWORD_PROPERTY = "password"; private static final String DEFAULT_IMAGE = "opensearchproject/opensearch"; - private static final String ADMIN_USER = "admin"; private static final String DEFAULT_ADMIN_PASSWORD = "admin"; - // OpenSearch 2.12+ rejects the old admin/admin demo password during container startup. - private static final String DEFAULT_INITIAL_ADMIN_PASSWORD = "OSClient-Test-2026-Password!"; private static final int HTTP_PORT = 9200; - private static GenericContainer container; - private static String password; + private static OpenSearchContainer container; private OpenSearchTestContainer() {} @@ -41,44 +34,32 @@ static synchronized void startIfNeeded() { if (container == null) { String version = System.getProperty(VERSION_PROPERTY); - String imageName = resolveImageName(version); - String passwordCompatibilityVersion = passwordCompatibilityVersion(version, imageName); - password = passwordFor(passwordCompatibilityVersion); - GenericContainer openSearch = createContainer(imageName, passwordCompatibilityVersion, password); + String imageName = resolveImageName(version, System.getProperty(IMAGE_PROPERTY)); + OpenSearchContainer openSearch = createContainer(imageName); openSearch.start(); container = openSearch; } System.setProperty(CLUSTER_PROPERTY, container.getHost() + ":" + container.getMappedPort(HTTP_PORT)); - System.setProperty(HTTPS_PROPERTY, "true"); - System.setProperty(USER_PROPERTY, ADMIN_USER); - System.setProperty(PASSWORD_PROPERTY, password); + System.setProperty(HTTPS_PROPERTY, Boolean.toString(container.isSecurityEnabled())); + System.setProperty(USER_PROPERTY, container.getUsername()); + System.setProperty(PASSWORD_PROPERTY, container.getPassword()); } - private static GenericContainer createContainer(String imageName, String passwordCompatibilityVersion, String adminPassword) { - GenericContainer openSearch = new GenericContainer<>(DockerImageName.parse(imageName)).withExposedPorts(HTTP_PORT) - .withEnv("discovery.type", "single-node") + private static OpenSearchContainer createContainer(String imageName) { + OpenSearchContainer openSearch = new OpenSearchContainer<>(imageName).withSecurityEnabled() .withEnv("bootstrap.memory_lock", "true") - .withEnv("cluster.routing.allocation.disk.threshold_enabled", "false") - .waitingFor( - new HttpWaitStrategy().forPort(HTTP_PORT) - .usingTls() - .allowInsecure() - .withBasicCredentials(ADMIN_USER, adminPassword) - .forStatusCode(200) - .withReadTimeout(Duration.ofSeconds(10)) - .withStartupTimeout(Duration.ofMinutes(5)) - ); + .withEnv("cluster.routing.allocation.disk.threshold_enabled", "false"); - if (requiresInitialAdminPassword(passwordCompatibilityVersion)) { - openSearch.withEnv("OPENSEARCH_INITIAL_ADMIN_PASSWORD", adminPassword); + String configuredPassword = System.getProperty(PASSWORD_PROPERTY); + if (hasText(configuredPassword) && !DEFAULT_ADMIN_PASSWORD.equals(configuredPassword)) { + openSearch.withEnv("OPENSEARCH_INITIAL_ADMIN_PASSWORD", configuredPassword); } return openSearch; } - private static String resolveImageName(String version) { - String image = System.getProperty(IMAGE_PROPERTY); + static String resolveImageName(String version, String image) { if (hasText(image)) { return image; } @@ -88,89 +69,6 @@ private static String resolveImageName(String version) { return DEFAULT_IMAGE + ":" + version; } - private static String passwordFor(String version) { - String configuredPassword = System.getProperty(PASSWORD_PROPERTY); - if (requiresInitialAdminPassword(version)) { - if (!hasText(configuredPassword) || DEFAULT_ADMIN_PASSWORD.equals(configuredPassword)) { - return DEFAULT_INITIAL_ADMIN_PASSWORD; - } - return configuredPassword; - } - return hasText(configuredPassword) ? configuredPassword : DEFAULT_ADMIN_PASSWORD; - } - - static boolean requiresInitialAdminPassword(String version) { - if (!hasText(version) || "latest".equalsIgnoreCase(version)) { - return true; - } - - String[] components = version.split("[-+]", 2)[0].split("\\."); - Integer major = parseInt(components, 0); - if (major == null) { - return true; - } - if (major > 2) { - return true; - } - if (major < 2) { - return false; - } - Integer minor = parseInt(components, 1); - if (minor == null) { - return true; - } - return minor >= 12; - } - - private static Integer parseInt(String[] components, int index) { - if (index >= components.length) { - return null; - } - return parseInt(components[index]); - } - - private static Integer parseInt(String component) { - try { - return Integer.parseInt(component); - } catch (NumberFormatException e) { - return null; - } - } - - static String passwordCompatibilityVersion(String version, String imageName) { - String imageTag = imageTag(imageName); - if (isCompatibilityVersion(imageTag)) { - return imageTag; - } - return hasText(version) ? version : imageTag; - } - - private static boolean isCompatibilityVersion(String version) { - if (!hasText(version)) { - return false; - } - if ("latest".equalsIgnoreCase(version)) { - return true; - } - - String[] components = version.split("[-+]", 2)[0].split("\\."); - for (String component : components) { - if (parseInt(component) == null) { - return false; - } - } - return true; - } - - private static String imageTag(String imageName) { - int lastSlash = imageName.lastIndexOf('/'); - int lastColon = imageName.lastIndexOf(':'); - if (lastColon > lastSlash && lastColon < imageName.length() - 1) { - return imageName.substring(lastColon + 1); - } - return null; - } - private static boolean testcontainersEnabled() { return Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true")); } diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java index afd23f0688..657cc51220 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java @@ -9,44 +9,33 @@ package org.opensearch.client.opensearch.integTest; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import org.junit.Test; public class OpenSearchTestContainerTests { @Test - public void initialAdminPasswordIsNotRequiredForOlderVersions() { - assertFalse(OpenSearchTestContainer.requiresInitialAdminPassword("1")); - assertFalse(OpenSearchTestContainer.requiresInitialAdminPassword("1.x")); - assertFalse(OpenSearchTestContainer.requiresInitialAdminPassword("1.3.20")); - assertFalse(OpenSearchTestContainer.requiresInitialAdminPassword("2.11.0")); + public void defaultImageUsesOfficialOpenSearchImage() { + assertEquals("opensearchproject/opensearch:3.2.0", OpenSearchTestContainer.resolveImageName("3.2.0", null)); } @Test - public void initialAdminPasswordIsRequiredForNewerVersions() { - assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("2.12.0")); - assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("3.2.0")); + public void configuredImageTakesPrecedenceOverVersion() { + assertEquals( + "opensearchproject/opensearch:2.19.2", + OpenSearchTestContainer.resolveImageName("3.2.0", "opensearchproject/opensearch:2.19.2") + ); } @Test - public void initialAdminPasswordIsRequiredForMissingOrRollingVersions() { - assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword(null)); - assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("2")); - assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("latest")); - } - - @Test - public void initialAdminPasswordIsRequiredForUnknownVersions() { - assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("2.x")); - assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("2.foo")); - assertTrue(OpenSearchTestContainer.requiresInitialAdminPassword("custom")); - } - - @Test - public void customImageTagsUseConfiguredVersionForPasswordCompatibility() { - assertEquals("2.11.0", OpenSearchTestContainer.passwordCompatibilityVersion("2.11.0", "registry.example.com/opensearch:custom")); - assertEquals("2.12.0", OpenSearchTestContainer.passwordCompatibilityVersion("2.11.0", "registry.example.com/opensearch:2.12.0")); + public void missingVersionWithoutCustomImageFailsFast() { + try { + OpenSearchTestContainer.resolveImageName(null, null); + } catch (IllegalStateException e) { + assertEquals("Missing tests.opensearch.version for OpenSearch Testcontainers image", e.getMessage()); + return; + } + + throw new AssertionError("Expected missing OpenSearch version to fail"); } } From 65bb540678214d9d47c4028e87568cd3c59dce29 Mon Sep 17 00:00:00 2001 From: sanghun Date: Sun, 5 Jul 2026 17:53:14 +0900 Subject: [PATCH 5/6] Wire the test container through a JUnit 4 class rule Replace the static helper with an ExternalResource under src/test/java21, use the suggested testImplementation dependencies, and drop the local image-name tests. Keep the thread-leak filter as agreed in review. Signed-off-by: sanghun --- java-client/build.gradle.kts | 9 ++-- .../OpenSearchJavaClientTestCase.java | 11 ++--- .../OpenSearchTestContainerTests.java | 41 ---------------- .../OpenSearchTestContainerRule.java} | 48 ++++++++++++------- 4 files changed, 39 insertions(+), 70 deletions(-) delete mode 100644 java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java rename java-client/src/test/{java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java => java21/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerRule.java} (55%) diff --git a/java-client/build.gradle.kts b/java-client/build.gradle.kts index 2bb781de1c..14ae23bc3c 100644 --- a/java-client/build.gradle.kts +++ b/java-client/build.gradle.kts @@ -388,6 +388,7 @@ if (runtimeJavaVersion >= JavaVersion.VERSION_21) { compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output srcDir("src/test/java11") + srcDir("src/test/java21") } } @@ -398,12 +399,8 @@ if (runtimeJavaVersion >= JavaVersion.VERSION_21) { testImplementation("org.opensearch.test", "framework", opensearchVersion) { exclude(group = "org.hamcrest") } - // opensearch-testcontainers exposes OpenSearchContainer as a GenericContainer subclass, but publishes - // Testcontainers as a runtime dependency. javac needs the superclass API for inherited methods. - "java21CompileOnly"("org.testcontainers:testcontainers:2.0.3") { - isTransitive = false - } - "java21Implementation"("org.opensearch:opensearch-testcontainers:4.1.0") + testImplementation("org.opensearch:opensearch-testcontainers:4.1.0") + testImplementation("org.testcontainers:testcontainers:2.0.4") } tasks.named("compileJava21Java") { diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java index af5d999a56..9b23bb7fa3 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchJavaClientTestCase.java @@ -31,7 +31,7 @@ import org.junit.After; import org.junit.AfterClass; import org.junit.Before; -import org.junit.BeforeClass; +import org.junit.ClassRule; import org.opensearch.Version; import org.opensearch.client.RestClient; import org.opensearch.client.RestClientBuilder; @@ -62,11 +62,10 @@ public abstract class OpenSearchJavaClientTestCase extends OpenSearchRestTestCas private static TreeSet nodeVersions; private static List clusterHosts; - // Superclass setup reads tests.rest.cluster before subclass @Before methods run. - @BeforeClass - public static void startOpenSearchTestContainer() { - OpenSearchTestContainer.startIfNeeded(); - } + // The integration tests run through JUnit 4 (RandomizedRunner), so @ClassRule is the pre/post + // lifecycle hook; the rule starts a single container shared by the whole test JVM. + @ClassRule + public static final OpenSearchTestContainerRule testContainer = new OpenSearchTestContainerRule(); @Before public void initJavaClient() throws IOException { diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java deleted file mode 100644 index 657cc51220..0000000000 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerTests.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - */ - -package org.opensearch.client.opensearch.integTest; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public class OpenSearchTestContainerTests { - - @Test - public void defaultImageUsesOfficialOpenSearchImage() { - assertEquals("opensearchproject/opensearch:3.2.0", OpenSearchTestContainer.resolveImageName("3.2.0", null)); - } - - @Test - public void configuredImageTakesPrecedenceOverVersion() { - assertEquals( - "opensearchproject/opensearch:2.19.2", - OpenSearchTestContainer.resolveImageName("3.2.0", "opensearchproject/opensearch:2.19.2") - ); - } - - @Test - public void missingVersionWithoutCustomImageFailsFast() { - try { - OpenSearchTestContainer.resolveImageName(null, null); - } catch (IllegalStateException e) { - assertEquals("Missing tests.opensearch.version for OpenSearch Testcontainers image", e.getMessage()); - return; - } - - throw new AssertionError("Expected missing OpenSearch version to fail"); - } -} diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java b/java-client/src/test/java21/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerRule.java similarity index 55% rename from java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java rename to java-client/src/test/java21/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerRule.java index 26b173a90e..2cd8823d2a 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/OpenSearchTestContainer.java +++ b/java-client/src/test/java21/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerRule.java @@ -8,9 +8,17 @@ package org.opensearch.client.opensearch.integTest; +import java.net.URI; +import org.junit.rules.ExternalResource; import org.opensearch.testcontainers.OpenSearchContainer; +import org.opensearch.testcontainers.OpenSearchDockerImage; -final class OpenSearchTestContainer { +/** + * Starts one OpenSearch test container for the whole test JVM and points the integration tests at + * it through system properties. The container is shared by every test class, so it is left running + * after each class; Testcontainers' Ryuk reaper removes it once the JVM exits. + */ +final class OpenSearchTestContainerRule extends ExternalResource { static final String ENABLED_PROPERTY = "tests.opensearch.testcontainers.enabled"; static final String VERSION_PROPERTY = "tests.opensearch.version"; static final String IMAGE_PROPERTY = "tests.opensearch.image"; @@ -19,13 +27,14 @@ final class OpenSearchTestContainer { static final String USER_PROPERTY = "user"; static final String PASSWORD_PROPERTY = "password"; - private static final String DEFAULT_IMAGE = "opensearchproject/opensearch"; private static final String DEFAULT_ADMIN_PASSWORD = "admin"; - private static final int HTTP_PORT = 9200; private static OpenSearchContainer container; - private OpenSearchTestContainer() {} + @Override + protected void before() { + startIfNeeded(); + } static synchronized void startIfNeeded() { if (hasText(System.getProperty(CLUSTER_PROPERTY)) || !testcontainersEnabled()) { @@ -33,24 +42,31 @@ static synchronized void startIfNeeded() { } if (container == null) { - String version = System.getProperty(VERSION_PROPERTY); - String imageName = resolveImageName(version, System.getProperty(IMAGE_PROPERTY)); - OpenSearchContainer openSearch = createContainer(imageName); + OpenSearchContainer openSearch = createContainer(); openSearch.start(); container = openSearch; } - System.setProperty(CLUSTER_PROPERTY, container.getHost() + ":" + container.getMappedPort(HTTP_PORT)); + // getHttpHostAddress() is scheme-prefixed, but tests.rest.cluster expects host:port. + URI httpHostAddress = URI.create(container.getHttpHostAddress()); + System.setProperty(CLUSTER_PROPERTY, httpHostAddress.getHost() + ":" + httpHostAddress.getPort()); System.setProperty(HTTPS_PROPERTY, Boolean.toString(container.isSecurityEnabled())); System.setProperty(USER_PROPERTY, container.getUsername()); System.setProperty(PASSWORD_PROPERTY, container.getPassword()); } - private static OpenSearchContainer createContainer(String imageName) { - OpenSearchContainer openSearch = new OpenSearchContainer<>(imageName).withSecurityEnabled() - .withEnv("bootstrap.memory_lock", "true") - .withEnv("cluster.routing.allocation.disk.threshold_enabled", "false"); + private static OpenSearchContainer createContainer() { + String image = System.getProperty(IMAGE_PROPERTY); + OpenSearchContainer openSearch = hasText(image) + ? new OpenSearchContainer<>(image) + : new OpenSearchContainer<>(OpenSearchDockerImage.ofVersion(requiredVersion())); + // Disk watermarks must stay disabled; constrained disks otherwise trip index_create_block_exception. + openSearch.withSecurityEnabled().withEnv("cluster.routing.allocation.disk.threshold_enabled", "false"); + + // The container has no password setter; OPENSEARCH_INITIAL_ADMIN_PASSWORD is its supported + // input and getPassword() reflects it. Only forward a non-default override: when the env is + // unset, the container substitutes its own strong default on images >= 2.12. String configuredPassword = System.getProperty(PASSWORD_PROPERTY); if (hasText(configuredPassword) && !DEFAULT_ADMIN_PASSWORD.equals(configuredPassword)) { openSearch.withEnv("OPENSEARCH_INITIAL_ADMIN_PASSWORD", configuredPassword); @@ -59,14 +75,12 @@ private static OpenSearchContainer createContainer(String imageName) { return openSearch; } - static String resolveImageName(String version, String image) { - if (hasText(image)) { - return image; - } + private static String requiredVersion() { + String version = System.getProperty(VERSION_PROPERTY); if (!hasText(version)) { throw new IllegalStateException("Missing " + VERSION_PROPERTY + " for OpenSearch Testcontainers image"); } - return DEFAULT_IMAGE + ":" + version; + return version; } private static boolean testcontainersEnabled() { From d460616f32037d227eeadce2c77fefebec853483 Mon Sep 17 00:00:00 2001 From: sanghun Date: Mon, 6 Jul 2026 00:01:03 +0900 Subject: [PATCH 6/6] Make the container rule fields private and drop synchronized The constants are only used within the rule, and JUnit 4 initializes the class rule on a single thread per JVM fork, so the lock guards nothing. Signed-off-by: sanghun --- .../integTest/OpenSearchTestContainerRule.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/java-client/src/test/java21/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerRule.java b/java-client/src/test/java21/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerRule.java index 2cd8823d2a..6e8b1af0a7 100644 --- a/java-client/src/test/java21/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerRule.java +++ b/java-client/src/test/java21/org/opensearch/client/opensearch/integTest/OpenSearchTestContainerRule.java @@ -19,13 +19,13 @@ * after each class; Testcontainers' Ryuk reaper removes it once the JVM exits. */ final class OpenSearchTestContainerRule extends ExternalResource { - static final String ENABLED_PROPERTY = "tests.opensearch.testcontainers.enabled"; - static final String VERSION_PROPERTY = "tests.opensearch.version"; - static final String IMAGE_PROPERTY = "tests.opensearch.image"; - static final String CLUSTER_PROPERTY = "tests.rest.cluster"; - static final String HTTPS_PROPERTY = "https"; - static final String USER_PROPERTY = "user"; - static final String PASSWORD_PROPERTY = "password"; + private static final String ENABLED_PROPERTY = "tests.opensearch.testcontainers.enabled"; + private static final String VERSION_PROPERTY = "tests.opensearch.version"; + private static final String IMAGE_PROPERTY = "tests.opensearch.image"; + private static final String CLUSTER_PROPERTY = "tests.rest.cluster"; + private static final String HTTPS_PROPERTY = "https"; + private static final String USER_PROPERTY = "user"; + private static final String PASSWORD_PROPERTY = "password"; private static final String DEFAULT_ADMIN_PASSWORD = "admin"; @@ -36,7 +36,7 @@ protected void before() { startIfNeeded(); } - static synchronized void startIfNeeded() { + private static void startIfNeeded() { if (hasText(System.getProperty(CLUSTER_PROPERTY)) || !testcontainersEnabled()) { return; }