This is an automated email from the ASF dual-hosted git repository.
wernerdv pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite.git
The following commit(s) were added to refs/heads/master by this push:
new 3dfb9229134 IGNITE-28738 Implement basic Rolling Upgrade test using
testcontainers (#13184)
3dfb9229134 is described below
commit 3dfb9229134c79f926adf9be45d8efb33614d287
Author: Dmitry Werner <[email protected]>
AuthorDate: Fri Jul 24 22:33:17 2026 +0500
IGNITE-28738 Implement basic Rolling Upgrade test using testcontainers
(#13184)
---
.gitignore | 4 +
modules/compatibility/DEVNOTES.md | 152 ++++++
modules/compatibility/pom.xml | 43 ++
.../ru/IgniteRebalanceOnUpgradeTest.java | 338 ++++++++++++
.../testcontainers/ContainerAddressResolver.java | 45 ++
.../testcontainers/IgniteClusterContainer.java | 65 +++
.../testcontainers/IgniteContainer.java | 593 +++++++++++++++++++++
.../IgniteRollingUpgradeDockerTestSuite.java | 30 ++
.../src/test/resources/docker/build_distrib.sh | 74 +++
.../test/resources/docker/build_docker_image.sh | 180 +++++++
.../src/test/resources/docker/test-config.xml | 69 +++
.../src/test/resources/log4j2-test.xml | 34 ++
.../ignite/spi/discovery/tcp/TcpDiscoverySpi.java | 3 +-
.../spi/discovery/tcp/TcpDiscoverySelfTest.java | 75 +++
parent/pom.xml | 1 +
15 files changed, 1705 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 64ad541bb0f..1e684823db1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,10 @@ git-patch-prop-local.sh
**/dotnet/libs/
*.classname*
*.exe
+deliveries/docker/apache-ignite/arm64/run.sh
+deliveries/docker/apache-ignite/arm64/apache-ignite-*
+deliveries/docker/apache-ignite/x86_64/run.sh
+deliveries/docker/apache-ignite/x86_64/apache-ignite-*
#Ignore all Intellij IDEA files (except default inspections config)
.idea/
diff --git a/modules/compatibility/DEVNOTES.md
b/modules/compatibility/DEVNOTES.md
new file mode 100644
index 00000000000..539080553a5
--- /dev/null
+++ b/modules/compatibility/DEVNOTES.md
@@ -0,0 +1,152 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+# Compatibility Module — Developer Notes
+
+## Docker Compatibility Tests
+
+This module contains tests that verify upgrade compatibility between Ignite
versions using Docker containers.
+Docker compatibility tests (e.g. `IgniteRebalanceOnUpgradeTest`) require:
+- **Docker** installed and running
+- A pre-built Docker image of the source (old) Ignite version (see below)
+- The `-Dru.source.image.name` property pointing to that image
+- Maven profile `compatibility-docker` — activates Docker-specific build steps
(only required for `DOCKER` upgrade mode; not needed in `LOCAL` mode)
+
+Example command:
+
+```bash
+export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStoreType=KeychainStore"
+./mvnw test -pl modules/compatibility
-Pcompatibility-docker,surefire-fork-count-1 \
+ -DskipTests=false \
+
-Dtest=org.apache.ignite.compatibility.ru.IgniteRebalanceOnUpgradeTest#testRollingUpgrade
\
+ -Dru.source.image.name=<image_name>
+```
+
+## Prerequisites
+
+- [Docker](https://www.docker.com/get-started) (Docker Desktop or Docker
Engine) must be installed and running.
+
+## Running IgniteRebalanceOnUpgradeTest
+
+This test verifies that data rebalancing works correctly when upgrading Ignite
from a specific version to the current codebase.
+It supports two upgrade modes:
+
+- **DOCKER** (default) — all nodes stay in Docker containers; each node is
upgraded in-place by swapping its `libs/` directory and restarting.
+- **LOCAL** — the source cluster runs in Docker containers, then nodes are
upgraded to local host-JVM instances.
+
+### Step 1. Build a local Docker image for the source (old) version
+
+Run the following script from the **project root**, passing the commit hash of
the version you want to test against:
+
+```bash
+./modules/compatibility/src/test/resources/docker/build_docker_image.sh
<commit_hash>
+```
+
+> **Note:** If you omit `<commit_hash>`, the script will use the hash of the
latest commit in the current branch.
+
+The script will:
+1. Checkout the specified commit.
+2. Build the project (`./mvnw clean install -T1C -Pall-java,licenses
-DskipTests`).
+3. Initialize the release (`./mvnw initialize -Prelease`).
+4. Build a Docker image tagged as `apacheignite/ignite:<commit_hash>`.
+5. Restore the original git state.
+
+> **Note:** If a distribution archive already exists in `target/bin/`, the
build steps will be skipped.
+
+> **Note:** If the Docker image `apacheignite/ignite:<commit_hash>` is already
built (e.g. from a previous run), you can skip Step 1 entirely and go directly
to Step 2.
+
+### Step 2. Run the test
+
+Run `IgniteRebalanceOnUpgradeTest` from your IDE or via Maven. The source
version image name **must** be explicitly provided via `-Dru.source.image.name`:
+
+```bash
+./mvnw test -pl modules/compatibility -Dtest=IgniteRebalanceOnUpgradeTest \
+ -Dru.source.image.name=<image_name> \
+ -Pcompatibility-docker,surefire-fork-count-1
+```
+
+---
+
+## Upgrade Modes
+
+### DOCKER mode (default)
+
+All nodes stay in Docker containers throughout the test. Each node is upgraded
in-place:
+1. The container is gracefully stopped (`docker stop`).
+2. Source jars in `/opt/ignite/apache-ignite/libs/` are replaced by target
jars from the host.
+3. The container is restarted (`docker start`).
+
+The Docker image for the source cluster is the same as in LOCAL mode — only
one image is needed.
+The target-version jars are provided from the host filesystem.
+
+**Option A: Automatic (recommended)** — use the `compatibility-docker` profile:
+
+```bash
+./mvnw test -pl modules/compatibility -Dtest=IgniteRebalanceOnUpgradeTest \
+ -Dru.source.image.name=<image_name> \
+ -Psurefire-fork-count-1,compatibility-docker
+```
+
+The profile will automatically:
+1. Check if `project/target/ignite-target-libs` symlink exists.
+2. If not, check for a distribution ZIP in `project/target/bin/`.
+3. If the ZIP is missing, build the project and distribution (`mvn install` +
`mvn initialize -Prelease`).
+4. Extract the ZIP into `project/target/bin/` (the distribution lands in
`project/target/bin/apache-ignite-*-bin/`).
+5. Create a symlink `project/target/ignite-target-libs` →
`project/target/bin/apache-ignite-*-bin/libs/`:
+
+```bash
+ln -s "$(ls -d target/bin/apache-ignite-*-bin/libs)" target/ignite-target-libs
+```
+
+> **Note:** Subsequent runs will skip the build if the symlink or the
distribution ZIP already exists.
+
+**Option B: Manual** — build, extract, and specify the libs directory:
+
+```bash
+./mvnw clean install -T1C -Pall-java -DskipTests
+./mvnw initialize -Prelease
+cd target/bin && unzip apache-ignite-*-bin.zip && cd ../..
+
+./mvnw test -pl modules/compatibility -Dtest=IgniteRebalanceOnUpgradeTest \
+ -Dru.source.image.name=<image_name> \
+ -Dru.target.libs.dir=target/bin/apache-ignite-<version>-bin/libs \
+ -Psurefire-fork-count-1
+```
+
+### LOCAL mode
+
+The source (old-version) cluster starts in Docker containers. During rolling
upgrade each container is stopped and replaced by a local host-JVM node with
the same `consistentId` and persistence directory.
+
+- Controlled by `-Dru.upgrade.mode=LOCAL`.
+
+```bash
+./mvnw test -pl modules/compatibility -Dtest=IgniteRebalanceOnUpgradeTest \
+ -Dru.upgrade.mode=LOCAL \
+ -Dru.source.image.name=<image_name> \
+ -Psurefire-fork-count-1
+```
+
+---
+
+## System Properties
+
+| Property | Default | Class |
Description
|
+|------------------------|-------------------------------------------|-------|--------------------------------------------------------------------------------------------|
+| `ru.upgrade.mode` | `DOCKER` |
`IgniteRebalanceOnUpgradeTest` | Upgrade mode: `LOCAL` or `DOCKER`
|
+| `ru.source.image.name` | - | `IgniteRebalanceOnUpgradeTest` | The source
(old-version) Docker image name, e.g. `apacheignite/ignite:2.18.0`
|
+| `ru.target.libs.dir` | `<project.dir>/target/ignite-target-libs` |
`IgniteContainer` | Host directory with target-version jars (DOCKER mode only)
|
+| `ru.local.work.dir` | `<project.dir>/target/test-ignite-work` |
`IgniteContainer` | Local directory bind-mounted as Ignite work directory
(persists across container restarts) |
diff --git a/modules/compatibility/pom.xml b/modules/compatibility/pom.xml
index 232cd0b635f..284e943ebb0 100644
--- a/modules/compatibility/pom.xml
+++ b/modules/compatibility/pom.xml
@@ -123,6 +123,19 @@
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers</artifactId>
+ <version>${testcontainers.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-slf4j2-impl</artifactId>
+ <scope>test</scope>
+ </dependency>
+
<!-- Note: when adding new Ignite module dependency please register it
in
org.apache.ignite.compatibility.testframework.junits.IgniteCompatibilityAbstractTest.getDependencies()
method. This will allow to use original version dependency instead
current code base. -->
@@ -158,4 +171,34 @@
</plugin>
</plugins>
</build>
+
+ <profiles>
+ <profile>
+ <id>compatibility-docker</id>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>prepare-docker-target-libs</id>
+ <goals>
+ <goal>exec</goal>
+ </goals>
+ <phase>generate-test-resources</phase>
+ <configuration>
+ <executable>bash</executable>
+ <arguments>
+
<argument>${project.basedir}/src/test/resources/docker/build_distrib.sh</argument>
+
<argument>${project.basedir}/../..</argument>
+ </arguments>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
</project>
diff --git
a/modules/compatibility/src/test/java/org/apache/ignite/compatibility/ru/IgniteRebalanceOnUpgradeTest.java
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/ru/IgniteRebalanceOnUpgradeTest.java
new file mode 100644
index 00000000000..e21ca5160f5
--- /dev/null
+++
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/ru/IgniteRebalanceOnUpgradeTest.java
@@ -0,0 +1,338 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.compatibility.ru;
+
+import java.io.File;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.Ignition;
+import org.apache.ignite.cache.CacheAtomicityMode;
+import org.apache.ignite.client.ClientCache;
+import org.apache.ignite.client.ClientCacheConfiguration;
+import org.apache.ignite.client.IgniteClient;
+import
org.apache.ignite.compatibility.testframework.testcontainers.IgniteClusterContainer;
+import
org.apache.ignite.compatibility.testframework.testcontainers.IgniteContainer;
+import org.apache.ignite.configuration.ClientConfiguration;
+import org.apache.ignite.configuration.DataRegionConfiguration;
+import org.apache.ignite.configuration.DataStorageConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.util.typedef.internal.U;
+import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi;
+import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi;
+import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.AfterClass;
+import org.junit.Assume;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.testcontainers.DockerClientFactory;
+
+import static
org.apache.ignite.compatibility.testframework.testcontainers.IgniteContainer.LOCAL_WORK_DIR_PATH;
+import static org.apache.ignite.testframework.GridTestUtils.DFLT_TEST_TIMEOUT;
+import static org.apache.ignite.testframework.GridTestUtils.waitForCondition;
+
+/** Smoke test for rolling upgrade with persistence. */
+public class IgniteRebalanceOnUpgradeTest extends GridCommonAbstractTest {
+ /** Consistent ID's. */
+ private static final List<String> CONSISTENT_IDS = List.of(
+ "ad26bff6-5ff5-49f1-9a61-425a827953ed",
+ "c1099d16-e7d7-49f4-925c-53329286c444",
+ "7b880b69-8a9e-4b84-b555-250d365e2e67"
+ );
+
+ /** Source image name, overridable via {@code -Dru.source.image.name}. */
+ private static final String SOURCE_IMAGE_NAME =
System.getProperty("ru.source.image.name");
+
+ /** Upgrade mode. */
+ private static final UpgradeMode UPGRADE_MODE =
UpgradeMode.valueOf(System.getProperty("ru.upgrade.mode",
+ UpgradeMode.DOCKER.name()));
+
+ /** Cache name. */
+ private static final String CACHE_NAME = "ru-test-cache";
+
+ /** Local work directory. */
+ private static final File LOCAL_WORK_DIR = new File(LOCAL_WORK_DIR_PATH);
+
+ /** Local host-JVM nodes (LOCAL mode only). */
+ private final List<IgniteEx> nodes = new ArrayList<>();
+
+ /** Consistent ID -> discovery address. */
+ private final Map<String, String> addrs = new HashMap<>();
+
+ /** Thin client. */
+ private IgniteClient client;
+
+ /** */
+ @BeforeClass
+ public static void beforeClass() {
+ Assume.assumeTrue("Docker is required for this test",
DockerClientFactory.instance().isDockerAvailable());
+
+ if (SOURCE_IMAGE_NAME == null)
+ throw new RuntimeException("Source image name must be specified
via `-Dru.source.image.name`");
+
+ U.delete(LOCAL_WORK_DIR);
+ }
+
+ /** */
+ @AfterClass
+ public static void afterClass() {
+ U.delete(LOCAL_WORK_DIR);
+ }
+
+ /** {@inheritDoc} */
+ @Override protected boolean isMultiJvm() {
+ return false;
+ }
+
+ /** {@inheritDoc} */
+ @Override protected long getTestTimeout() {
+ return super.getTestTimeout() * 3;
+ }
+
+ /** Basic RU test. */
+ @Test
+ public void testRollingUpgrade() throws Exception {
+ try (IgniteClusterContainer cluster = new
IgniteClusterContainer(SOURCE_IMAGE_NAME, CONSISTENT_IDS)) {
+ cluster.start();
+
+ ClientCacheConfiguration cfg = new ClientCacheConfiguration()
+ .setName(CACHE_NAME)
+ .setBackups(1)
+ .setAtomicityMode(CacheAtomicityMode.TRANSACTIONAL);
+
+ ClientCache<Integer, Integer> cache =
client(cluster.containers().get(0).clientAddress()).createCache(cfg);
+
+ for (int i = 0; i < 1000; i++)
+ cache.put(i, i);
+
+ closeClient();
+
+ upgradeCluster(cluster);
+
+ if (UPGRADE_MODE == UpgradeMode.DOCKER)
+ verifyViaDockerNodes(cluster);
+ else
+ verifyViaLocalNodes();
+ }
+ finally {
+ closeClient();
+
+ if (UPGRADE_MODE == UpgradeMode.LOCAL)
+ stopLocalNodes();
+ }
+ }
+
+ /** Verify data via local host-JVM nodes. */
+ private void verifyViaLocalNodes() {
+ IgniteCache<Integer, Integer> targetCache =
nodes.get(0).cache(CACHE_NAME);
+
+ for (int i = 0; i < 1000; i++)
+ assertEquals("Data mismatch after upgrade at key: " + i,
(Integer)i, targetCache.get(i));
+
+ targetCache.put(1001, 1001);
+
+ assertEquals((Integer)1001, targetCache.get(1001));
+ }
+
+ /** Verify data via thin client connected to upgraded Docker nodes. */
+ private void verifyViaDockerNodes(IgniteClusterContainer cluster) {
+ IgniteContainer con = cluster.containers().get(0);
+
+ con.checkNodeCount(cluster.containers().size());
+
+ ClientCache<Integer, Integer> targetCache =
client(con.clientAddress()).getOrCreateCache(CACHE_NAME);
+
+ for (int i = 0; i < 1000; i++)
+ assertEquals("Data mismatch after upgrade at key: " + i,
(Integer)i, targetCache.get(i));
+
+ targetCache.put(1001, 1001);
+
+ assertEquals((Integer)1001, targetCache.get(1001));
+ }
+
+ /** */
+ private void upgradeCluster(IgniteClusterContainer srcCluster) throws
Exception {
+ List<IgniteContainer> srcContainers = srcCluster.containers();
+
+ if (UPGRADE_MODE == UpgradeMode.LOCAL) {
+ for (IgniteContainer con : srcContainers)
+ addrs.put(con.consistentId(), con.discoveryAddress());
+ }
+
+ for (int i = 0; i < srcContainers.size(); i++) {
+ IgniteContainer con = srcContainers.get(i);
+
+ log.info(">>> Upgrade node=" + con.consistentId() + " (mode=" +
UPGRADE_MODE + ")");
+
+ if (UPGRADE_MODE == UpgradeMode.DOCKER)
+ con.upgradeAndRestart();
+ else
+ upgradeLocally(con, i);
+ }
+ }
+
+ /** Stop container, start a local host-JVM node with the same consistent
ID. */
+ private void upgradeLocally(IgniteContainer con, int idx) throws Exception
{
+ // Address containers use to reach this (host JVM) node:
+ // - Linux: Docker bridge gateway IP (e.g. 172.24.0.1) — always
reachable from containers,
+ // and the host can bind to it. The host's LAN IP is
unreliable (on Debian/Ubuntu
+ // java.net.Inet4Address.getLocalHost() returns
127.0.1.1, which is loopback and
+ // unreachable from inside containers).
+ // - macOS: host.docker.internal alias resolved inside the
container — gives the macOS host's
+ // LAN IP, reachable from the Docker VM.
+ String hostIp = IgniteContainer.LINUX
+ ? con.gatewayIp()
+ : con.execInContainer("sh", "-c",
+ "getent ahostsv4 host.docker.internal | awk '{print $1}' |
head -1").getStdout().trim();
+
+ // Snapshot remaining container addresses before stopping this one.
+ // Published ports (localhost:5050x) are used on all platforms because
Docker bridge IPs
+ // (172.x.x.x) may be unroutable from the host due to firewall rules
(nftables/iptables)
+ // on Linux, and are never routable on macOS/Windows Docker Desktop.
+ Collection<String> ipFinderAddrs = new ArrayList<>(addrs.size());
+
+ for (Map.Entry<String, String> entry : addrs.entrySet()) {
+ if (entry.getKey().equals(con.consistentId()))
+ continue;
+
+ ipFinderAddrs.add(entry.getValue());
+ }
+
+ con.stop();
+
+ addrs.remove(con.consistentId());
+
+ IgniteEx ignite = startGrid(configuration(con.consistentId(),
con.localWorkDirectory(), ipFinderAddrs, hostIp, idx));
+
+ assertTrue("Upgraded node did not rejoin the full topology in time",
+ waitForCondition(() -> CONSISTENT_IDS.size() ==
ignite.cluster().nodes().size(), DFLT_TEST_TIMEOUT));
+
+ addrs.put(con.consistentId(), "127.0.0.1:" + (48500 + idx));
+
+ nodes.add(ignite);
+ }
+
+ /** */
+ private IgniteConfiguration configuration(String nodeId, String workDir,
Collection<String> addrs0, String ip, int idx) {
+ DataRegionConfiguration dataRegionCfg = new DataRegionConfiguration()
+ .setName("testRegion")
+ .setInitialSize(1024L * 1024 * 1024)
+ .setMaxSize(10L * 1024 * 1024 * 1024)
+ .setPersistenceEnabled(true);
+
+ TcpDiscoverySpi discoverySpi = new TcpDiscoverySpi()
+ // Bind to all interfaces. The AddressResolver (below) maps the
node's addresses to the
+ // Docker bridge gateway IP so containers know where to reach this
host node.
+ .setLocalAddress("0.0.0.0")
+ .setIpFinder(new TcpDiscoveryVmIpFinder().setAddresses(addrs0))
+ // Short socket timeout: unreachable container-internal addresses
must fail fast before the
+ // host-reachable 127.0.0.1:<published-port> (advertised by the
containers) is tried.
+ .setSocketTimeout(500)
+ .setNetworkTimeout(10000)
+ .setJoinTimeout(30000)
+ .setLocalPort(48500 + idx);
+
+ // On macOS communication binds to loopback (discovery stays on
0.0.0.0 to satisfy Ignite's non-loopback
+ // join check) so the node advertises only 127.0.0.1 + the
resolver-mapped Docker host address -- no
+ // unreachable host LAN IPs for the containers to stall on. A short
connect timeout makes the node's
+ // own outgoing attempts to unreachable container-internal (172.x)
addresses give up in ~1s (they
+ // otherwise hang in SYN_SENT) and fall through to the reachable
127.0.0.1:<published-port>.
+ TcpCommunicationSpi commSpi = new TcpCommunicationSpi()
+ // macOS: bind comm to loopback (advertised to containers via the
resolver as the Docker-host address).
+ // Linux: bind to all interfaces so containers reach this host
node at the Docker bridge gateway IP.
+ .setLocalAddress(IgniteContainer.LINUX ? "0.0.0.0" : "127.0.0.1")
+ .setLocalPort(49100 + idx)
+ .setConnectTimeout(500)
+ .setMaxConnectTimeout(5000)
+ // The NIO connect to a blackholed container-internal (172.x)
address is not aborted by
+ // connectTimeout on macOS (it hangs in SYN_SENT for the OS
timeout, ~75s), stalling the exchange.
+ // Pre-filter unreachable addresses so only the reachable
127.0.0.1:<published-port> is used.
+ .setFilterReachableAddresses(true);
+
+ return new IgniteConfiguration()
+ .setIgniteInstanceName(nodeId)
+ .setConsistentId(nodeId)
+ .setWorkDirectory(workDir)
+ .setDataStorageConfiguration(new
DataStorageConfiguration().setDefaultDataRegionConfiguration(dataRegionCfg))
+ // Failure detection timeout controls the connect/handshake
timeout for each address.
+ // Default 10s per address × 6+ unreachable addresses = 60s+
stall. 5s cuts it to ~30s.
+ .setFailureDetectionTimeout(5000)
+ .setDiscoverySpi(discoverySpi)
+ .setAddressResolver(addr -> {
+ int port = addr.getPort();
+
+ // Each sequentially started host node binds the next port in
the discovery (48500+) and
+ // communication (49100+) ranges; map them all to the Docker
bridge gateway IP so the
+ // containers can reach every host JVM node.
+ if ((port >= 48500 && port < 48600) || (port >= 49100 && port
< 49200))
+ return Set.of(new InetSocketAddress(ip, port));
+
+ return Set.of(addr);
+ })
+ .setCommunicationSpi(commSpi);
+ }
+
+ /** */
+ private IgniteClient client(String addr) {
+ if (client == null)
+ client = Ignition.startClient(new
ClientConfiguration().setAddresses(addr));
+
+ return client;
+ }
+
+ /** */
+ private void closeClient() {
+ if (client != null) {
+ client.close();
+
+ client = null;
+ }
+ }
+
+ /** */
+ private void stopLocalNodes() {
+ for (IgniteEx node : nodes) {
+ if (node != null)
+ Ignition.stop(node.name(), false);
+ }
+
+ nodes.clear();
+ }
+
+ /**
+ * Upgrade mode, overridable via {@code -Dru.upgrade.mode} (DOCKER|LOCAL).
+ * <ul>
+ * <li>DOCKER — all nodes stay in Docker; in-place upgrade by swapping
libs inside containers (default)</li>
+ * <li>LOCAL — source cluster in Docker, upgraded to local host-JVM
nodes</li>
+ * </ul>
+ */
+ private enum UpgradeMode {
+ /** */
+ DOCKER,
+
+ /** */
+ LOCAL
+ }
+}
diff --git
a/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/ContainerAddressResolver.java
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/ContainerAddressResolver.java
new file mode 100644
index 00000000000..e4630ca4f4e
--- /dev/null
+++
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/ContainerAddressResolver.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.compatibility.testframework.testcontainers;
+
+import java.net.InetSocketAddress;
+import java.util.Collection;
+import java.util.Collections;
+import org.apache.ignite.configuration.AddressResolver;
+
+/**
+ * Advertises a containerized node by the host-published port so a host-JVM
node can reach it on macOS,
+ * where container-internal addresses are not routable. External address per
bound port is taken from the
+ * {@code external.address.<port>} system property (e.g. {@code
-Dexternal.address.47500=127.0.0.1:50500}).
+ */
+public class ContainerAddressResolver implements AddressResolver {
+ /** Prefix of the system property that holds the external {@code
host:port} for a given bound port. */
+ static final String EXT_ADDR_PROP_PREFIX = "external.address.";
+
+ /** {@inheritDoc} */
+ @Override public Collection<InetSocketAddress>
getExternalAddresses(InetSocketAddress addr) {
+ String ext = System.getProperty(EXT_ADDR_PROP_PREFIX + addr.getPort());
+
+ if (ext == null)
+ return Collections.singletonList(addr);
+
+ int sep = ext.lastIndexOf(':');
+
+ return Collections.singletonList(new
InetSocketAddress(ext.substring(0, sep), Integer.parseInt(ext.substring(sep +
1))));
+ }
+}
diff --git
a/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteClusterContainer.java
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteClusterContainer.java
new file mode 100644
index 00000000000..ffeb0ad6c88
--- /dev/null
+++
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteClusterContainer.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.compatibility.testframework.testcontainers;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.testcontainers.containers.Network;
+import org.testcontainers.lifecycle.Startable;
+import org.testcontainers.lifecycle.Startables;
+
+/** Ignite cluster container. */
+public class IgniteClusterContainer implements Startable {
+ /** Containers. */
+ private final List<IgniteContainer> containers;
+
+ /** Network. */
+ private final Network net = Network.newNetwork();
+
+ /**
+ * @param imageName Image name.
+ * @param consistentIds Consistent ID's.
+ */
+ public IgniteClusterContainer(String imageName, List<String>
consistentIds) throws Exception {
+ containers = new ArrayList<>(consistentIds.size());
+
+ for (int i = 0; i < consistentIds.size(); i++)
+ containers.add(new IgniteContainer(imageName, net, "node" + (1 +
i), consistentIds.get(i), i));
+ }
+
+ /** {@inheritDoc} */
+ @Override public void start() {
+ Startables.deepStart(containers).join();
+
+ containers.get(0).activateCluster(containers.size());
+ }
+
+ /** {@inheritDoc} */
+ @Override public void stop() {
+ for (IgniteContainer container : containers)
+ container.stop();
+
+ net.close();
+ }
+
+ /** */
+ public List<IgniteContainer> containers() {
+ return Collections.unmodifiableList(containers);
+ }
+}
diff --git
a/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteContainer.java
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteContainer.java
new file mode 100644
index 00000000000..1f08e917a09
--- /dev/null
+++
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteContainer.java
@@ -0,0 +1,593 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.compatibility.testframework.testcontainers;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import com.github.dockerjava.api.command.ExecCreateCmdResponse;
+import com.github.dockerjava.api.command.InspectExecResponse;
+import com.github.dockerjava.api.command.StopContainerCmd;
+import com.github.dockerjava.api.model.ContainerNetwork;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
+import org.apache.ignite.IgniteException;
+import org.apache.ignite.cluster.ClusterState;
+import
org.apache.ignite.compatibility.testframework.plugins.DisabledRollingUpgradeProcessor;
+import
org.apache.ignite.compatibility.testframework.plugins.DisabledValidationProcessor;
+import
org.apache.ignite.compatibility.testframework.plugins.TestCompatibilityPluginProvider;
+import org.apache.ignite.configuration.ClientConnectorConfiguration;
+import org.apache.ignite.internal.IgniteInterruptedCheckedException;
+import org.apache.ignite.internal.util.typedef.internal.U;
+import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi;
+import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.BindMode;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.Network;
+import org.testcontainers.containers.wait.strategy.Wait;
+import
org.testcontainers.shaded.com.github.dockerjava.core.command.ExecStartResultCallback;
+import org.testcontainers.utility.DockerImageName;
+
+import static
org.apache.ignite.compatibility.testframework.testcontainers.ContainerAddressResolver.EXT_ADDR_PROP_PREFIX;
+import static org.apache.ignite.testframework.GridTestUtils.DFLT_TEST_TIMEOUT;
+import static org.apache.ignite.testframework.GridTestUtils.waitForCondition;
+import static org.junit.Assert.assertTrue;
+import static org.testcontainers.utility.MountableFile.forClasspathResource;
+import static org.testcontainers.utility.MountableFile.forHostPath;
+
+/** Ignite container. */
+public class IgniteContainer extends GenericContainer<IgniteContainer> {
+ /** Local work directory. */
+ public static final String LOCAL_WORK_DIR_PATH =
System.getProperty("ru.local.work.dir",
+ U.getIgniteHome() + "/target/test-ignite-work");
+
+ /**
+ * {@code true} on Linux, where the host shares the Docker bridge and
reaches containers directly. Elsewhere
+ * (macOS/Windows Docker Desktop) the host talks to containers through a
VM proxy, so the address hacks
+ * (published ports + ContainerAddressResolver + host.docker.internal) are
used instead.
+ */
+ public static final boolean LINUX = System.getProperty("os.name",
"").toLowerCase().contains("linux");
+
+ /** Host directory with target-version jars for DOCKER upgrade mode,
overridable via {@code -Dru.target.libs.dir}. */
+ private static final Path TARGET_LIBS_DIR =
Path.of(System.getProperty("ru.target.libs.dir",
+ U.getIgniteHome() + "/target/ignite-target-libs"));
+
+ /** Logger. */
+ private static final Logger LOGGER =
LoggerFactory.getLogger(IgniteContainer.class);
+
+ /** Ignite root directory in container. */
+ private static final String ROOT_DIR_PATH = "/opt/ignite/apache-ignite/";
+
+ /** Ignite libs directory in container. */
+ private static final String LIBS_DIR_PATH = ROOT_DIR_PATH + "libs/";
+
+ /** Ignite work directory in container. */
+ private static final String WORK_DIR_PATH = ROOT_DIR_PATH + "work";
+
+ /** Config path in container. */
+ private static final String CFG_PATH = ROOT_DIR_PATH +
"config/test-config.xml";
+
+ /** */
+ private static final Pattern CLUSTER_STATE_PATTERN =
Pattern.compile("Cluster state: (ACTIVE|INACTIVE)");
+
+ /** Base host port for the published discovery port (node index added).
Kept clear of the host-node ports. */
+ private static final int DISCO_HOST_PORT_BASE = 50500;
+
+ /** Base host port for the published communication port (node index
added). */
+ private static final int COMM_HOST_PORT_BASE = 50100;
+
+ /** Base host port for the published thin-client port (node index added).
*/
+ private static final int CLIENT_HOST_PORT_BASE = 50800;
+
+ /** Custom classes (with their nested classes) used by node in containers.
*/
+ private static final List<String> TEST_CLASSES = List.of(
+ ContainerAddressResolver.class.getName(),
+ TestCompatibilityPluginProvider.class.getName(),
+ DisabledRollingUpgradeProcessor.class.getName(),
+ DisabledValidationProcessor.class.getName()
+ );
+
+ /** Seconds to wait after SIGTERM before SIGKILL. */
+ private static final int SHUTDOWN_TIMEOUT_SEC = 30;
+
+ /** Jar holding {@link #TEST_CLASSES}, injected so the old image can load
it. */
+ private static volatile File testClassesJar;
+
+ /** Cached tar archive of {@link #TARGET_LIBS_DIR} + test-classes.jar,
built once and reused for all containers. */
+ private static volatile Path targetLibsArchive;
+
+ /** Cached "uid:gid" of the host user. */
+ private static volatile String hostUidGid;
+
+ /** Hostname. */
+ private final String hostname;
+
+ /** Consistent ID. */
+ private final String consistentId;
+
+ /** Path to work directory. */
+ private final String workDirPath;
+
+ /**
+ * @param imageName Image name.
+ * @param net Network.
+ * @param hostname Hostname.
+ * @param consistentId Consistent ID.
+ * param idx Node index.
+ */
+ public IgniteContainer(String imageName, Network net, String hostname,
String consistentId, int idx) throws Exception {
+ super(DockerImageName.parse(imageName));
+
+ this.hostname = hostname;
+ this.consistentId = consistentId;
+ workDirPath = WORK_DIR_PATH + "/" + hostname;
+
+ int discoHostPort = DISCO_HOST_PORT_BASE + idx;
+ int commHostPort = COMM_HOST_PORT_BASE + idx;
+
+ withEnv("CONFIG_URI", "file://" + CFG_PATH);
+ withEnv("IGNITE_QUIET", "false");
+ withEnv("IGNITE_WORK_DIR", workDirPath);
+ withEnv("IGNITE_LOCAL_HOST", "0.0.0.0");
+ withEnv("TZ", ZoneId.systemDefault().toString());
+
+ // node.consistent.id pins the node's consistent id (and thus its
persistence folder) so the upgraded host
+ // node, started with the same consistent id, inherits this node's
persisted data.
+ String jvmOpts = "-Xms512m -Xmx1g -Dnode.consistent.id=" +
consistentId;
+
+ // Containers advertise published ports as external addresses via
ContainerAddressResolver.
+ // This is needed on all platforms:
+ // - macOS/Windows: container-internal bridge IPs are not routable
from the host
+ // - Linux: bridge IPs (172.x.x.x) may be blocked by host firewall
(nftables/iptables)
+ // Published ports (127.0.0.1:5050x) are always reachable from the
host via Docker port forwarding.
+ jvmOpts += " -D" + EXT_ADDR_PROP_PREFIX + TcpDiscoverySpi.DFLT_PORT +
"=127.0.0.1:" + discoHostPort
+ + " -D" + EXT_ADDR_PROP_PREFIX + TcpCommunicationSpi.DFLT_PORT +
"=127.0.0.1:" + commHostPort;
+
+ withEnv("JVM_OPTS", jvmOpts);
+
+ File locWorkDir = new File(LOCAL_WORK_DIR_PATH);
+
+ if (!locWorkDir.exists())
+ locWorkDir.mkdirs();
+
+ withFileSystemBind(LOCAL_WORK_DIR_PATH, WORK_DIR_PATH,
BindMode.READ_WRITE);
+
+ // On Linux, run as the host user so bind-mounted directories (work
dir, etc.) are owned by
+ // the host user and can be cleaned up without root. Docker supports
numeric UID:GID without
+ // the user existing in the container's /etc/passwd.
+ if (LINUX) {
+ String uidGid = hostUserUidGid();
+
+ LOGGER.info("Running container {} as host user uid/gid: {}",
hostname, uidGid);
+
+ withCreateContainerCmdModifier(cmd -> cmd.withUser(uidGid));
+ }
+
+
withCopyFileToContainer(forClasspathResource("docker/test-config.xml"),
CFG_PATH);
+
withCopyFileToContainer(forHostPath(testClassesJar().getAbsolutePath()),
LIBS_DIR_PATH + "test-classes.jar");
+
+ withNetwork(net);
+ withNetworkAliases(hostname);
+
+ withLogConsumer(frame -> System.out.println("[" + consistentId + "] "
+ frame.getUtf8String().trim()));
+
+ // Always publish fixed host ports so the host JVM node and thin
client can reach each container at
+ // 127.0.0.1:<port> via Testcontainers port forwarding. On Linux the
bridge-internal IP (172.x) may
+ // be unreachable due to host firewall rules (firewalld/nftables) or
Docker-in-VM setups (WSL2,
+ // VirtualBox), so published ports are the only reliable
cross-platform approach.
+ addFixedExposedPort(CLIENT_HOST_PORT_BASE + idx,
ClientConnectorConfiguration.DFLT_PORT);
+ addFixedExposedPort(commHostPort, TcpCommunicationSpi.DFLT_PORT);
+ addFixedExposedPort(discoHostPort, TcpDiscoverySpi.DFLT_PORT);
+
+ waitingFor(Wait.forLogMessage(".*Node started.*",
1).withStartupTimeout(Duration.ofSeconds(600)));
+ }
+
+ /** {@inheritDoc} */
+ @Override public void stop() {
+ if (isRunning()) {
+ try {
+ stopGraceful();
+ }
+ catch (Exception e) {
+ LOGGER.warn("Graceful shutdown failed for node {}. Proceeding
with forceful stop.", hostname, e);
+ }
+ }
+
+ super.stop();
+ }
+
+ /** In-place upgrade inside Docker: copy archive → clean+extract →
graceful stop → restart. */
+ public void upgradeAndRestart() throws Exception {
+ LOGGER.info("Upgrading libs in container {}", hostname);
+
+ // Build/retrieve the cached tar archive (shared across all
containers).
+ Path archivePath = libsArchive();
+
+ String archivePathInContainer = "/tmp/target-libs.tar";
+
+
copyFileToContainer(forHostPath(archivePath.toAbsolutePath().toString()),
archivePathInContainer);
+
+ ExecCreateCmdResponse execResp =
getDockerClient().execCreateCmd(getContainerId())
+ .withUser("root")
+ .withAttachStdout(true)
+ .withAttachStderr(true)
+ .withCmd("sh", "-c",
+ "rm -rf " + LIBS_DIR_PATH + "* && tar xf " +
archivePathInContainer + " -C " + LIBS_DIR_PATH
+ + " && rm -f " + archivePathInContainer)
+ .exec();
+
+ ByteArrayOutputStream err = new ByteArrayOutputStream();
+
+ getDockerClient().execStartCmd(execResp.getId())
+ .exec(new ExecStartResultCallback(new ByteArrayOutputStream(),
err))
+ .awaitCompletion(60, TimeUnit.SECONDS);
+
+ InspectExecResponse resp =
getDockerClient().inspectExecCmd(execResp.getId()).exec();
+
+ if (!Boolean.TRUE.equals(resp.isRunning()) && resp.getExitCodeLong()
!= null && resp.getExitCodeLong() != 0)
+ throw new IllegalStateException("Failed to clean and extract libs:
" + err);
+
+ stopGraceful();
+
+ LOGGER.info("Starting container {} with target libraries...",
hostname);
+
+ getDockerClient().startContainerCmd(getContainerId()).exec();
+
+ // isRunning() may cache stale state — inspect directly.
+ assertTrue(waitForCondition(() -> {
+ try {
+ return
"running".equals(getDockerClient().inspectContainerCmd(getContainerId()).exec().getState().getStatus());
+ }
+ catch (Exception e) {
+ return false;
+ }
+ }, DFLT_TEST_TIMEOUT));
+
+ LOGGER.info("Restarted node {} with target libraries", hostname);
+
+ assertTrue("Upgraded Docker node is not running", isRunning());
+ }
+
+ /** @return Consistent ID. */
+ public String consistentId() {
+ return consistentId;
+ }
+
+ /** */
+ public String localWorkDirectory() {
+ return LOCAL_WORK_DIR_PATH + "/" + hostname;
+ }
+
+ /** Activate cluster. */
+ public void activateCluster(int nodeCnt) {
+ execControl("--set-state", "ACTIVE", "--yes");
+
+ try {
+ boolean success = waitForCondition(() -> {
+ String out = execControl("--state");
+
+ Matcher matcher = CLUSTER_STATE_PATTERN.matcher(out);
+
+ if (matcher.find())
+ return ClusterState.valueOf(matcher.group(1)) ==
ClusterState.ACTIVE;
+
+ return false;
+ }, 30_000);
+
+ if (!success)
+ throw new IllegalStateException("Failed to set state ACTIVE");
+
+ checkNodeCount(nodeCnt);
+ }
+ catch (IgniteInterruptedCheckedException e) {
+ throw new IgniteException(e);
+ }
+ }
+
+ /** Check node count in cluster.*/
+ public void checkNodeCount(int nodeCnt) {
+ try {
+ boolean success = waitForCondition(() -> {
+ String out = execControl("--baseline");
+
+ LOGGER.debug(">>> Baseline output={}", out);
+
+ return out.contains("Number of baseline nodes: " + nodeCnt);
+ }, 30_000, 1_000);
+
+ if (!success)
+ throw new IllegalStateException("Check cluster count failed");
+ }
+ catch (IgniteInterruptedCheckedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** @return Client address. */
+ public String clientAddress() {
+ return address(ClientConnectorConfiguration.DFLT_PORT);
+ }
+
+ /** */
+ public String discoveryAddress() {
+ return address(TcpDiscoverySpi.DFLT_PORT);
+ }
+
+ /** @return Gateway IP of the test Docker network — the address containers
use to reach the host JVM on Linux. */
+ public String gatewayIp() {
+ return network().getGateway();
+ }
+
+ /** */
+ private String execControl(String... cmd) {
+ String[] fullCmd = new String[cmd.length + 1];
+
+ fullCmd[0] = ROOT_DIR_PATH + "bin/control.sh";
+
+ System.arraycopy(cmd, 0, fullCmd, 1, cmd.length);
+
+ ExecResult result;
+
+ try {
+ LOGGER.info("Running command: {}",
Arrays.toString(fullCmd).replace(", ", " "));
+
+ result = execInContainer(fullCmd);
+ }
+ catch (IOException | InterruptedException e) {
+ throw new IgniteException(e);
+ }
+
+ if (result.getExitCode() != 0)
+ throw new IllegalStateException(result.getStderr());
+
+ return result.getStdout();
+ }
+
+ /** @return Jar with {@link #TEST_CLASSES}, built once and reused for all
containers. */
+ private static File testClassesJar() throws IOException {
+ File jar = testClassesJar;
+
+ if (jar != null)
+ return jar;
+
+ synchronized (IgniteContainer.class) {
+ if (testClassesJar != null)
+ return testClassesJar;
+
+ jar = File.createTempFile("test-classes", ".jar");
+ jar.deleteOnExit();
+
+ try (JarOutputStream out = new JarOutputStream(new
FileOutputStream(jar))) {
+ for (String cls : TEST_CLASSES) {
+ String clsPath = cls.replace('.', '/') + ".class";
+
+ URL url =
IgniteContainer.class.getClassLoader().getResource(clsPath);
+
+ if (url == null)
+ throw new IOException("Class not found on classpath: "
+ clsPath);
+
+ File dir;
+
+ try {
+ dir = new File(url.toURI()).getParentFile();
+ }
+ catch (URISyntaxException e) {
+ throw new IOException(e);
+ }
+
+ String pkg = clsPath.substring(0, clsPath.lastIndexOf('/')
+ 1);
+ String simple = cls.substring(cls.lastIndexOf('.') + 1);
+
+ // Include the class and its nested classes (e.g. the
provider's anonymous $1).
+ File[] clsFiles = dir.listFiles((d, name) ->
+ name.equals(simple + ".class") ||
name.startsWith(simple + '$'));
+
+ if (clsFiles == null)
+ throw new IOException("Cannot list class directory: "
+ dir);
+
+ for (File f : clsFiles) {
+ out.putNextEntry(new JarEntry(pkg + f.getName()));
+
+ Files.copy(f.toPath(), out);
+
+ out.closeEntry();
+ }
+ }
+ }
+
+ return testClassesJar = jar;
+ }
+ }
+
+ /**
+ * Returns a cached tar archive (plain, no gzip) containing all files from
{@link #TARGET_LIBS_DIR}
+ * plus the test-classes jar. Built once and reused for all container
upgrades.
+ *
+ * @return Path to the tar file on the host.
+ */
+ private static Path libsArchive() throws IOException {
+ Path archive = targetLibsArchive;
+
+ if (archive != null)
+ return archive;
+
+ synchronized (IgniteContainer.class) {
+ if (targetLibsArchive != null)
+ return targetLibsArchive;
+
+ File targetLibsFile = TARGET_LIBS_DIR.toFile();
+
+ LOGGER.info("Building libs archive from: {}", TARGET_LIBS_DIR);
+
+ File archiveFile = File.createTempFile("target-libs", ".tar");
+ archiveFile.deleteOnExit();
+
+ try (TarArchiveOutputStream taos = new TarArchiveOutputStream(new
FileOutputStream(archiveFile))) {
+ taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
+ taos.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_STAR);
+
+ List<File> allFiles = new ArrayList<>();
+
+ collectFiles(targetLibsFile, allFiles);
+
+ LOGGER.info("Found {} entries in {}", allFiles.size(),
TARGET_LIBS_DIR);
+
+ for (File srcFile : allFiles) {
+ String entryName =
TARGET_LIBS_DIR.relativize(srcFile.toPath()).toString();
+
+ if (entryName.isEmpty())
+ continue;
+
+ TarArchiveEntry entry = new TarArchiveEntry(srcFile,
entryName);
+ taos.putArchiveEntry(entry);
+
+ if (entry.isFile())
+ Files.copy(srcFile.toPath(), taos);
+
+ taos.closeArchiveEntry();
+ }
+
+ File testJar = testClassesJar();
+
+ taos.putArchiveEntry(new TarArchiveEntry(testJar,
"test-classes.jar"));
+
+ Files.copy(testJar.toPath(), taos);
+
+ taos.closeArchiveEntry();
+ }
+
+ LOGGER.info("Libs archive built: {} ({} bytes)", archiveFile,
archiveFile.length());
+
+ return targetLibsArchive = archiveFile.toPath();
+ }
+ }
+
+ /** Recursively collects all files and directories under {@code root} into
{@code result}. */
+ private static void collectFiles(File root, List<File> result) {
+ File[] children = root.listFiles();
+
+ if (children == null)
+ return;
+
+ for (File child : children) {
+ result.add(child);
+
+ if (child.isDirectory())
+ collectFiles(child, result);
+ }
+ }
+
+ /** Returns the "uid:gid" of the current host user, cached after the first
call. */
+ private static String hostUserUidGid() throws IOException,
InterruptedException {
+ String cached = hostUidGid;
+
+ if (cached != null)
+ return cached;
+
+ synchronized (IgniteContainer.class) {
+ if (hostUidGid != null)
+ return hostUidGid;
+
+ ProcessBuilder pb = new ProcessBuilder("sh", "-c", "echo $(id
-u):$(id -g)");
+ pb.redirectErrorStream(true);
+
+ Process p = pb.start();
+ String resultLine;
+
+ try (BufferedReader r = new BufferedReader(new
InputStreamReader(p.getInputStream()))) {
+ resultLine = r.readLine();
+ }
+
+ if (!p.waitFor(5, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+
+ throw new IOException("Timed out waiting for id command");
+ }
+
+ if (p.exitValue() != 0)
+ throw new IOException("Command 'id' failed with exit code: " +
p.exitValue() + ". Output: " + resultLine);
+
+ if (resultLine == null || resultLine.trim().isEmpty())
+ throw new IOException("Empty output from 'id' command");
+
+ return hostUidGid = resultLine.trim();
+ }
+ }
+
+ /**
+ * Stop the container gracefully <b>without removing it</b> (container
stays in "Exited" state).
+ *
+ * <p>Sends SIGTERM via {@code docker stop} and waits up to {@value
#SHUTDOWN_TIMEOUT_SEC} seconds
+ * before SIGKILL. The entrypoint {@code run.sh} uses {@code exec java},
so JVM receives SIGTERM directly
+ * and runs its shutdown hooks. The extended timeout ensures Ignite can
leave the cluster properly
+ * (flush persistence, notify discovery neighbors, close socket
connections) so that remaining nodes
+ * don't trigger spurious "Failed to check connection to previous node"
warnings during teardown.</p>
+ */
+ private void stopGraceful() {
+ if (!isRunning())
+ return;
+
+ LOGGER.info("Graceful stop of node {} (timeout {} s)", hostname,
SHUTDOWN_TIMEOUT_SEC);
+
+ try (StopContainerCmd cmd =
getDockerClient().stopContainerCmd(getContainerId())) {
+ cmd.withTimeout(SHUTDOWN_TIMEOUT_SEC).exec();
+ }
+ catch (Exception e) {
+ LOGGER.warn("Graceful stop (SIGTERM) failed for node {},
attempting SIGKILL fallback. Error: {}",
+ hostname, e.getMessage());
+
+ try {
+ getDockerClient().killContainerCmd(getContainerId()).exec();
+ }
+ catch (Exception killEx) {
+ LOGGER.error("SIGKILL fallback also failed for node {}",
hostname, killEx);
+ }
+ }
+
+ LOGGER.info("Node {} stopped", hostname);
+ }
+
+ /** @return Address the host JVM uses to reach this container's {@code
port}. */
+ private String address(int port) {
+ return getHost() + ":" + getMappedPort(port);
+ }
+
+ /** @return This container's attachment to the single test Docker network.
*/
+ private ContainerNetwork network() {
+ return
getContainerInfo().getNetworkSettings().getNetworks().values().iterator().next();
+ }
+}
diff --git
a/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testsuites/IgniteRollingUpgradeDockerTestSuite.java
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testsuites/IgniteRollingUpgradeDockerTestSuite.java
new file mode 100644
index 00000000000..e826e5cbb7a
--- /dev/null
+++
b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testsuites/IgniteRollingUpgradeDockerTestSuite.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.compatibility.testsuites;
+
+import org.apache.ignite.compatibility.ru.IgniteRebalanceOnUpgradeTest;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+/** Contains RU tests based on testcontainers. */
+@RunWith(Suite.class)
[email protected]({
+ IgniteRebalanceOnUpgradeTest.class
+})
+public class IgniteRollingUpgradeDockerTestSuite {
+}
diff --git a/modules/compatibility/src/test/resources/docker/build_distrib.sh
b/modules/compatibility/src/test/resources/docker/build_distrib.sh
new file mode 100755
index 00000000000..28ebc107570
--- /dev/null
+++ b/modules/compatibility/src/test/resources/docker/build_distrib.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Prepares target libs for DOCKER upgrade mode:
+# 1. Builds the distribution ZIP if missing.
+# 2. Extracts the ZIP into target/bin/.
+# 3. Creates a symlink project/target/ignite-target-libs ->
project/target/bin/apache-ignite-*-bin/libs.
+#
+# Arguments:
+# $1 - path to project root (where mvnw is located)
+
+set -e
+
+PROJECT_ROOT="$1"
+LIBS_SYMLINK="${PROJECT_ROOT}/target/ignite-target-libs"
+
+# If symlink already points to a valid directory — nothing to do
+if [ -L "$LIBS_SYMLINK" ] && [ -d "$LIBS_SYMLINK" ]; then
+ echo "[compatibility-docker] Target libs symlink exists at ${LIBS_SYMLINK}
— skipping"
+ exit 0
+fi
+
+DIST_ZIP_DIR="${PROJECT_ROOT}/target/bin"
+
+# Build distribution if ZIP archive is missing
+if [ ! -d "$DIST_ZIP_DIR" ] || [ -z "$(ls
"$DIST_ZIP_DIR"/apache-ignite-*-bin.zip 2>/dev/null)" ]; then
+ echo "[compatibility-docker] Distribution ZIP not found. Building project
and distribution..."
+ cd "$PROJECT_ROOT"
+ ./mvnw clean install -T1C -Pall-java -DskipTests
+ ./mvnw initialize -Prelease
+fi
+
+# Find the distribution ZIP
+DIST_ZIP=$(ls -1 "$DIST_ZIP_DIR"/apache-ignite-*-bin.zip 2>/dev/null | head -1)
+
+if [ -z "$DIST_ZIP" ]; then
+ echo "[compatibility-docker] ERROR: Distribution ZIP not found in
${DIST_ZIP_DIR}" >&2
+ exit 1
+fi
+
+# Extract ZIP into target/bin/
+echo "[compatibility-docker] Extracting ${DIST_ZIP} into ${DIST_ZIP_DIR}..."
+unzip -q -o "$DIST_ZIP" -d "$DIST_ZIP_DIR"
+
+# Find the unpacked libs directory (structure: apache-ignite-*/libs/)
+LIBS_DIR=$(find "$DIST_ZIP_DIR" -mindepth 2 -maxdepth 2 -type d -name libs |
head -1)
+
+if [ -z "$LIBS_DIR" ]; then
+ echo "[compatibility-docker] ERROR: libs/ directory not found after
extraction" >&2
+ exit 1
+fi
+
+# Remove stale symlink if it exists
+rm -f "$LIBS_SYMLINK"
+
+# Create symlink: target/ignite-target-libs ->
target/bin/apache-ignite-*-bin/libs
+ln -s "$LIBS_DIR" "$LIBS_SYMLINK"
+
+echo "[compatibility-docker] Done. Symlink: ${LIBS_SYMLINK} -> ${LIBS_DIR}"
diff --git
a/modules/compatibility/src/test/resources/docker/build_docker_image.sh
b/modules/compatibility/src/test/resources/docker/build_docker_image.sh
new file mode 100755
index 00000000000..faa0854bdc4
--- /dev/null
+++ b/modules/compatibility/src/test/resources/docker/build_docker_image.sh
@@ -0,0 +1,180 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Get the absolute path to the project root directory
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../../../" && pwd)"
+
+# Change to the project root directory
+cd "$PROJECT_ROOT" || exit 1
+
+# Save current git state (branch name and commit hash)
+ORIGINAL_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
+ORIGINAL_COMMIT=$(git rev-parse HEAD)
+
+# Track whether Dockerfiles were patched (for cleanup on exit)
+DOCKERFILES_PATCHED=0
+PATCHED_DOCKERFILES=""
+
+# Function to restore original Dockerfiles
+restore_dockerfiles() {
+ if [ "$DOCKERFILES_PATCHED" -eq 1 ] && [ -n "$PATCHED_DOCKERFILES" ]; then
+ echo -e "\nRestoring patched Dockerfiles"
+ git checkout "$ORIGINAL_COMMIT" -- $PATCHED_DOCKERFILES
+ fi
+}
+
+# Function to restore original git state
+restore_git_state() {
+ if [ -n "$ORIGINAL_BRANCH" ]; then
+ echo -e "\nRestoring git state to branch: $ORIGINAL_BRANCH"
+ git checkout "$ORIGINAL_BRANCH" 2>/dev/null
+ else
+ echo -e "\nRestoring git state to detached commit: $ORIGINAL_COMMIT"
+ git checkout "$ORIGINAL_COMMIT" 2>/dev/null
+ fi
+ restore_dockerfiles
+}
+
+# Set trap to restore git state on exit (success or failure)
+trap restore_git_state EXIT
+
+# Check that commit hash is provided, or use the latest commit in current
branch
+if [ $# -eq 1 ]; then
+ COMMIT_HASH=$1
+elif [ $# -eq 0 ]; then
+ COMMIT_HASH=$(git rev-parse HEAD)
+else
+ echo "Usage: $0 [commit_hash]"
+ exit 1
+fi
+
+# Perform git checkout to the specified commit
+echo -e "\nPerforming git checkout to commit: $COMMIT_HASH"
+git checkout "$COMMIT_HASH"
+
+# Build the project (skip if distribution archive already exists)
+SOURCE_ARCHIVE="$PROJECT_ROOT/target/bin/apache-ignite-*.zip"
+
+if ls $SOURCE_ARCHIVE 1> /dev/null 2>&1; then
+ echo -e "\nDistribution archive already found, skipping build steps"
+else
+ echo -e "\nBuilding the project: ./mvnw clean install -T1C
-Pall-java,licenses -DskipTests"
+ ./mvnw clean install -T1C -Pall-java,licenses -DskipTests
+
+ # Check success of previous command
+ if [ $? -ne 0 ]; then
+ echo -e "\nError during project build"
+ exit 1
+ fi
+
+ # Initialize the release
+ echo -e "\nInitializing release: ./mvnw initialize -Prelease"
+ ./mvnw initialize -Prelease
+
+ # Check success of previous command
+ if [ $? -ne 0 ]; then
+ echo -e "\nError during release initialization"
+ exit 1
+ fi
+fi
+
+# Copy and unpack the release archive
+echo -e "\nCopying and unpacking the release archive"
+# Detect CPU architecture
+ARCH=$(uname -m)
+case "$ARCH" in
+ arm64|aarch64)
+ TARGET_DIR="$PROJECT_ROOT/deliveries/docker/apache-ignite/arm64"
+ ;;
+ x86_64)
+ TARGET_DIR="$PROJECT_ROOT/deliveries/docker/apache-ignite/x86_64"
+ ;;
+ *)
+ echo "Unsupported architecture: $ARCH"
+ exit 1
+ ;;
+esac
+
+# Remove previously unpacked distribution directory (if any)
+rm -rf "$TARGET_DIR/apache-ignite-"*-bin
+
+# Check if archive exists
+if [ ! -f $(echo $SOURCE_ARCHIVE) ]; then
+ echo -e "\nArchive not found: $SOURCE_ARCHIVE"
+ exit 1
+fi
+
+# Copy archive to target directory
+cp $(echo $SOURCE_ARCHIVE) "$TARGET_DIR/"
+
+# Unpack the archive
+cd "$TARGET_DIR"
+unzip -o "$(basename $(echo $SOURCE_ARCHIVE))"
+
+# Return to project root directory
+cd "$PROJECT_ROOT"
+
+# Copy the startup script
+echo -e "\nCopying startup script"
+cp "$PROJECT_ROOT/deliveries/docker/apache-ignite/run.sh" "$TARGET_DIR/"
+
+# Patch Dockerfiles: switch apk repos to HTTP (bypasses TLS cert issues in
corporate networks)
+PATCHED_DOCKERFILES=$(find "$PROJECT_ROOT/deliveries/docker/apache-ignite"
-name Dockerfile)
+for DF in $PATCHED_DOCKERFILES; do
+ echo -e "\nPatching $DF (Debian base image instead of Alpine)"
+ awk '{
+ # Replace Alpine base image with Debian (avoids TLS cert issues in
corporate networks)
+ if (/jre-alpine/) {
+ sub(/jre-alpine/, "jre")
+ print
+ next
+ }
+ # Skip the apk block entirely (Debian has bash built-in)
+ if (/^RUN apk/) {
+ skip = 1
+ next
+ }
+ if (skip && /^[[:space:]]*add bash/) {
+ skip = 0
+ next
+ }
+ # Fix COPY directive: when multiple files match the wildcard,
+ # the destination must be a directory (end with /)
+ if (/COPY apache-ignite\* apache-ignite$/) {
+ sub(/COPY apache-ignite\* apache-ignite$/, "COPY apache-ignite*
apache-ignite/")
+ }
+ print
+ }' "$DF" > "${DF}.tmp" && mv "${DF}.tmp" "$DF"
+done
+DOCKERFILES_PATCHED=1
+
+# Build Docker image
+echo -e "\nBuilding Docker image - apacheignite/ignite:$COMMIT_HASH"
+cd "$TARGET_DIR"
+docker build . -t apacheignite/ignite:"$COMMIT_HASH"
+
+# Check success of previous command
+if [ $? -ne 0 ]; then
+ echo -e "\nError during Docker image build"
+ exit 1
+fi
+
+# Return to project root directory
+cd "$PROJECT_ROOT"
+
+echo -e "\nDocker image built successfully - apacheignite/ignite:$COMMIT_HASH"
diff --git a/modules/compatibility/src/test/resources/docker/test-config.xml
b/modules/compatibility/src/test/resources/docker/test-config.xml
new file mode 100644
index 00000000000..66d3ca00e44
--- /dev/null
+++ b/modules/compatibility/src/test/resources/docker/test-config.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<beans xmlns="http://www.springframework.org/schema/beans"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="
+ http://www.springframework.org/schema/beans
+ http://www.springframework.org/schema/beans/spring-beans.xsd">
+ <bean id="grid.cfg"
class="org.apache.ignite.configuration.IgniteConfiguration">
+ <property name="consistentId"
value="#{systemProperties['node.consistent.id']}"/>
+ <property name="addressResolver">
+ <bean
class="org.apache.ignite.compatibility.testframework.testcontainers.ContainerAddressResolver"/>
+ </property>
+ <property name="failureDetectionTimeout" value="#{5000L}"/>
+ <property name="pluginProviders">
+ <bean
class="org.apache.ignite.compatibility.testframework.plugins.TestCompatibilityPluginProvider"/>
+ </property>
+ <property name="dataStorageConfiguration">
+ <bean
class="org.apache.ignite.configuration.DataStorageConfiguration">
+ <property name="defaultDataRegionConfiguration">
+ <bean
class="org.apache.ignite.configuration.DataRegionConfiguration">
+ <property name="name" value="testRegion"/>
+ <property name="initialSize" value="#{1L * 1024 * 1024
* 1024}"/>
+ <property name="maxSize" value="#{10L * 1024 * 1024 *
1024}"/>
+ <property name="persistenceEnabled" value="true"/>
+ </bean>
+ </property>
+ </bean>
+ </property>
+ <property name="discoverySpi">
+ <bean class="org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi">
+ <property name="localPort" value="47500"/>
+ <property name="socketTimeout" value="1000"/>
+ <property name="ipFinder">
+ <bean
class="org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder">
+ <property name="addresses">
+ <list>
+ <value>node1:47500</value>
+ <value>node2:47500</value>
+ <value>node3:47500</value>
+ </list>
+ </property>
+ </bean>
+ </property>
+ </bean>
+ </property>
+ <property name="communicationSpi">
+ <bean
class="org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi">
+ <property name="localPort" value="47100"/>
+ </bean>
+ </property>
+ </bean>
+</beans>
diff --git a/modules/compatibility/src/test/resources/log4j2-test.xml
b/modules/compatibility/src/test/resources/log4j2-test.xml
new file mode 100644
index 00000000000..10d53953188
--- /dev/null
+++ b/modules/compatibility/src/test/resources/log4j2-test.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<Configuration status="WARN">
+ <Appenders>
+ <Console name="Console" target="SYSTEM_OUT">
+ <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36}
- %msg%n"/>
+ </Console>
+ </Appenders>
+
+ <Loggers>
+ <Logger name="org.testcontainers" level="INFO" additivity="false">
+ <AppenderRef ref="Console"/>
+ </Logger>
+
+ <Root level="INFO">
+ <AppenderRef ref="Console"/>
+ </Root>
+ </Loggers>
+</Configuration>
diff --git
a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java
b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java
index 5594e77395b..a716117a66d 100644
---
a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java
+++
b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java
@@ -1269,7 +1269,8 @@ public class TcpDiscoverySpi extends IgniteSpiAdapter
implements IgniteDiscovery
// Do not give own loopback to avoid requesting current node.
if (!node.equals(locNode))
- addrs.removeIf(addr -> addr.getAddress().isLoopbackAddress() &&
locNode.socketAddresses().contains(addr));
+ addrs.removeIf(addr -> addr.getAddress() == null ||
+ (addr.getAddress().isLoopbackAddress() &&
locNode.socketAddresses().contains(addr)));
addrs.sort(U.inetAddressesComparator(sameHost));
diff --git
a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySelfTest.java
b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySelfTest.java
index 4920d029538..102ee0cc76d 100644
---
a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySelfTest.java
+++
b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySelfTest.java
@@ -115,6 +115,7 @@ import static
org.apache.ignite.events.EventType.EVT_TASK_FINISHED;
import static
org.apache.ignite.internal.GridComponent.DiscoveryDataExchangeType.MARSHALLER_PROC;
import static org.apache.ignite.internal.MarshallerPlatformIds.JAVA_ID;
import static org.apache.ignite.spi.IgnitePortProtocol.UDP;
+import static org.junit.Assert.assertNotEquals;
/**
* Test for {@link TcpDiscoverySpi}.
@@ -2363,6 +2364,57 @@ public class TcpDiscoverySelfTest extends
GridCommonAbstractTest {
}
}
+ /**
+ * Verifies that {@link
TcpDiscoverySpi#getEffectiveNodeAddresses(TcpDiscoveryNode, boolean)}
+ * does not throw NPE when a far node has an unresolved {@link
InetSocketAddress}
+ * (i.e., {@code getAddress() == null}) and that such addresses are
filtered out.
+ *
+ * @throws Exception If failed.
+ */
+ @Test
+ public void
testGetEffectiveNodeAddressesFiltersUnresolvedAddressWithoutNpe() throws
Exception {
+ try {
+ IgniteEx ignite1 = startGrid(1);
+ IgniteEx ignite2 = startGrid(2);
+
+ TcpDiscoverySpi spi1 =
(TcpDiscoverySpi)ignite1.configuration().getDiscoverySpi();
+
+ TcpDiscoveryNode realNode =
(TcpDiscoveryNode)spi1.getNode(ignite2.localNode().id());
+
+ assertNotNull(realNode);
+
+ // Wrap real node so that socketAddresses() returns an unresolved
InetSocketAddress.
+ TcpDiscoveryNode nodeWithUnresolvedAddr = new
TestTcpDiscoveryNodeWithUnresolvedAddress(realNode);
+
+ // Ensure the wrapped node is considered different from the local
node by UUID.
+ assertNotEquals(ignite1.localNode().id(),
nodeWithUnresolvedAddr.id());
+
+ // Verify test node has at least one unresolved address.
+ boolean hasUnresolved = false;
+
+ for (InetSocketAddress addr :
nodeWithUnresolvedAddr.socketAddresses()) {
+ if (addr.getAddress() == null) {
+ hasUnresolved = true;
+
+ break;
+ }
+ }
+
+ assertTrue("Test node should have at least one unresolved
address", hasUnresolved);
+
+ // Should not throw NPE; unresolved addresses must be filtered out.
+ LinkedHashSet<InetSocketAddress> effAddrs =
spi1.getEffectiveNodeAddresses(nodeWithUnresolvedAddr, false);
+
+ assertNotNull(effAddrs);
+
+ for (InetSocketAddress addr : effAddrs)
+ assertNotNull("Unresolved address (getAddress() == null)
should have been filtered out", addr.getAddress());
+ }
+ finally {
+ stopAllGrids();
+ }
+ }
+
/**
* @param nodeName Node name.
* @throws Exception If failed.
@@ -2917,4 +2969,27 @@ public class TcpDiscoverySelfTest extends
GridCommonAbstractTest {
private Ignite startGridNoOptimize(String igniteInstanceName) throws
Exception {
return G.start(getConfiguration(igniteInstanceName));
}
+
+ /** Test node wrapper that injects an unresolved {@link InetSocketAddress}
into the addresses list. */
+ private static class TestTcpDiscoveryNodeWithUnresolvedAddress extends
TcpDiscoveryNode {
+ /** */
+ public TestTcpDiscoveryNodeWithUnresolvedAddress() {
+ // No-op.
+ }
+
+ /** @param delegate Original node to delegate to. */
+ public TestTcpDiscoveryNodeWithUnresolvedAddress(TcpDiscoveryNode
delegate) {
+ super(delegate);
+ }
+
+ /** {@inheritDoc} */
+ @Override public Collection<InetSocketAddress> socketAddresses() {
+ List<InetSocketAddress> addrs = new
ArrayList<>(super.socketAddresses());
+
+ // Unresolved address: getAddress() returns null.
+ addrs.add(new
InetSocketAddress("unresolved-host-name-that-does-not-resolve-xyz", 47500));
+
+ return addrs;
+ }
+ }
}
diff --git a/parent/pom.xml b/parent/pom.xml
index ded71e2db60..df8768974a2 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -110,6 +110,7 @@
<spring.version>6.2.19</spring.version>
<surefire.version>3.5.6</surefire.version>
<tomcat.version>10.0.27</tomcat.version>
+ <testcontainers.version>2.0.5</testcontainers.version>
<yardstick.version>0.8.3</yardstick.version>
<zookeeper.version>3.9.5</zookeeper.version>
<zstd.version>1.5.7-8</zstd.version>