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>

Reply via email to