diff --git a/modules/core/pom.xml b/modules/core/pom.xml
index 959a706488e3c..5c2f906a91edb 100644
--- a/modules/core/pom.xml
+++ b/modules/core/pom.xml
@@ -237,6 +237,20 @@
0.23.0
test
+
+
+ org.testcontainers
+ testcontainers
+ ${testcontainers.version}
+ test
+
+
+
+ org.slf4j
+ slf4j-simple
+ 2.0.12
+ test
+
diff --git a/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteClusterContainer.java b/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteClusterContainer.java
new file mode 100644
index 0000000000000..4b2802208cde2
--- /dev/null
+++ b/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteClusterContainer.java
@@ -0,0 +1,92 @@
+/*
+ * 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.testcontainers;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ListIterator;
+import org.testcontainers.containers.Network;
+import org.testcontainers.lifecycle.Startable;
+import org.testcontainers.lifecycle.Startables;
+
+/** Ignite cluster container. */
+public class IgniteClusterContainer implements Startable {
+ /** Source version. */
+ private final String ver;
+
+ /** Containers. */
+ private final List containers;
+
+ /** Network. */
+ private final Network net = Network.newNetwork();
+
+ /** @param nodeIds Node ids. */
+ public IgniteClusterContainer(String ver, List nodeIds) {
+ this.ver = ver;
+ containers = new ArrayList<>(nodeIds.size());
+
+ for (int i = 0; i < nodeIds.size(); i++) {
+ String hostname = "node" + (1 + i);
+
+ IgniteContainer ignite = new IgniteContainer(ver, net, hostname, nodeIds.get(i));
+
+ containers.add(ignite);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override public void start() {
+ Startables.deepStart(containers).join();
+ }
+
+ /** {@inheritDoc} */
+ @Override public void stop() {
+ for (IgniteContainer container : containers)
+ container.stop();
+
+ net.close();
+ }
+
+ /** @return First started node in cluster. */
+ public IgniteContainer firstNode() {
+ return containers.get(0);
+ }
+
+ /** Rolling upgrade cluster.
+ *
+ * @param ver Target version.
+ */
+ public void upgrade(String ver) {
+ if (this.ver.equals(ver))
+ throw new IllegalArgumentException("Target version matches the current version.");
+
+ ListIterator it = containers.listIterator();
+
+ while (it.hasNext()) {
+ IgniteContainer oldNode = it.next();
+
+ oldNode.stop();
+
+ IgniteContainer newNode = new IgniteContainer(ver, net, oldNode.hostname(), oldNode.nodeId());
+
+ newNode.start();
+
+ it.set(newNode);
+ }
+ }
+}
diff --git a/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteContainer.java b/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteContainer.java
new file mode 100644
index 0000000000000..b22f3bdb162f9
--- /dev/null
+++ b/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteContainer.java
@@ -0,0 +1,228 @@
+/*
+ * 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.testcontainers;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.ignite.IgniteException;
+import org.apache.ignite.cluster.ClusterState;
+import org.apache.ignite.internal.IgniteInterruptedCheckedException;
+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.utility.DockerImageName;
+
+import static org.apache.ignite.testframework.GridTestUtils.waitForCondition;
+import static org.testcontainers.utility.MountableFile.forClasspathResource;
+
+/** Ignite container. */
+public class IgniteContainer extends GenericContainer {
+ /** Property for local work directory. */
+ static final String LOCAL_WORK_DIR_PROP = "local.work.dir";
+
+ /** Local work directory. */
+ static final String LOCAL_WORK_DIR_PATH = System.getProperty(LOCAL_WORK_DIR_PROP,
+ System.getProperty("user.home") + "/test-ignite-work");
+
+ /** 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 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 String ENABLE_EXPERIMENTAL_FLAG = "--enable-experimental";
+
+ /** */
+ private static final Pattern CLUSTER_STATE_PATTERN = Pattern.compile("Cluster state: (ACTIVE|INACTIVE)");
+
+ /** */
+ private static final Pattern RU_STATUS_PATTERN = Pattern.compile("Rolling upgrade status: (enabled|disabled)");
+
+ /** Default thin client port. */
+ private static final int THIN_CLIENT_PORT = 10800;
+
+ /** Hostname. */
+ private final String hostname;
+
+ /** Node ID. */
+ private final String nodeId;
+
+ /** Constructor. */
+ public IgniteContainer(String ver, Network net, String hostname, String nodeId) {
+ super(DockerImageName.parse("apacheignite/ignite:" + ver));
+
+ this.hostname = hostname;
+ this.nodeId = nodeId;
+
+ withEnv("CONFIG_URI", "file://" + CFG_PATH);
+ withEnv("IGNITE_QUIET", "false");
+ withEnv("IGNITE_NODE_NAME", nodeId);
+ withEnv("IGNITE_WORK_DIR", WORK_DIR_PATH + "/" + hostname);
+ withEnv("TZ", ZoneId.systemDefault().toString());
+
+ withFileSystemBind(LOCAL_WORK_DIR_PATH, WORK_DIR_PATH, BindMode.READ_WRITE);
+
+ withCopyFileToContainer(forClasspathResource("docker/test-config.xml"), CFG_PATH);
+
+ withNetwork(net);
+ withNetworkAliases(hostname);
+ withExposedPorts(THIN_CLIENT_PORT);
+
+ waitingFor(Wait.forLogMessage(".*Node started.*", 1)
+ .withStartupTimeout(Duration.ofSeconds(60)));
+ }
+
+ /** @return Hostname. */
+ public String hostname() {
+ return hostname;
+ }
+
+ /** @return Node ID. */
+ public String nodeId() {
+ return nodeId;
+ }
+
+ /** Activate cluster. */
+ public void activateCluster() {
+ execControl("--set-state", "ACTIVE", "--yes");
+
+ try {
+ 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);
+ }
+ catch (IgniteInterruptedCheckedException e) {
+ throw new IgniteException(e);
+ }
+ }
+
+ /** @return Rolling upgrade status. */
+ public RollingUpgradeStatus rollingUpgradeStatus() {
+ String out = execControl("--rolling-upgrade", "status");
+
+ LOGGER.info(">>> Rolling upgrade status: {}", out);
+
+ Matcher matcher = RU_STATUS_PATTERN.matcher(out);
+
+ if (matcher.find())
+ return RollingUpgradeStatus.valueOf(matcher.group(1).toUpperCase());
+
+ throw new IllegalStateException("Failed to parse rolling upgrade status from output:\n" + out);
+ }
+
+ /** Enable rolling upgrade. */
+ public void rollingUpgradeEnable(String targetVer) {
+ execControl("--rolling-upgrade", "enable", targetVer, "--yes");
+ }
+
+ /** Disable rolling upgrade. */
+ public void rollingUpgradeDisable() {
+ execControl("--rolling-upgrade", "disable");
+ }
+
+ /** @return Number of cluster nodes for given release version. */
+ public int nodesCountForVersion(String targetVer) {
+ String out = execControl("--rolling-upgrade", "status");
+
+ // Match the version block
+ Pattern verPattern = Pattern.compile(
+ "Version\\s+" + Pattern.quote(targetVer) + ".*?:\\s*\n" +
+ "((?:\\s*Node\\[.*?\\]\\s*\n)*)",
+ Pattern.DOTALL
+ );
+
+ Matcher verMatcher = verPattern.matcher(out);
+
+ if (!verMatcher.find())
+ return 0;
+
+ String nodesBlock = verMatcher.group(1);
+
+ // Count Node entries
+ Pattern nodePattern = Pattern.compile("^\\s*Node\\[.*?\\]", Pattern.MULTILINE);
+ Matcher nodeMatcher = nodePattern.matcher(nodesBlock);
+
+ int cnt = 0;
+
+ while (nodeMatcher.find())
+ cnt++;
+
+ return cnt;
+ }
+
+ /** @return Client address. */
+ public String clientAddress() {
+ return getHost() + ":" + getMappedPort(THIN_CLIENT_PORT);
+ }
+
+ /** */
+ private String execControl(String... cmd) {
+ String[] fullCmd = new String[cmd.length + 2];
+
+ fullCmd[0] = ROOT_DIR_PATH + "bin/control.sh";
+ fullCmd[1] = ENABLE_EXPERIMENTAL_FLAG;
+
+ System.arraycopy(cmd, 0, fullCmd, 2, 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.toString());
+
+ return result.getStdout();
+ }
+
+ /** Rolling upgrade status. */
+ public enum RollingUpgradeStatus {
+ /** */
+ ENABLED,
+
+ /** */
+ DISABLED
+ }
+}
diff --git a/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteRebalanceOnUpgradeTest.java b/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteRebalanceOnUpgradeTest.java
new file mode 100644
index 0000000000000..4617b8533f934
--- /dev/null
+++ b/modules/core/src/test/java/org/apache/ignite/testcontainers/IgniteRebalanceOnUpgradeTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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.testcontainers;
+
+import java.io.File;
+import java.util.List;
+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.configuration.ClientConfiguration;
+import org.apache.ignite.internal.util.typedef.internal.U;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import static org.apache.ignite.testcontainers.IgniteContainer.LOCAL_WORK_DIR_PATH;
+import static org.apache.ignite.testcontainers.IgniteContainer.RollingUpgradeStatus.DISABLED;
+import static org.apache.ignite.testcontainers.IgniteContainer.RollingUpgradeStatus.ENABLED;
+import static org.junit.Assert.assertEquals;
+
+/** Smoke test for rolling upgrade with persistence. */
+public class IgniteRebalanceOnUpgradeTest {
+ /** Node IDs. */
+ private static final List NODE_IDS = List.of(
+ "ad26bff6-5ff5-49f1-9a61-425a827953ed",
+ "c1099d16-e7d7-49f4-925c-53329286c444",
+ "7b880b69-8a9e-4b84-b555-250d365e2e67"
+ );
+
+ /** Source version. */
+ private static final String SOURCE_VER = "2.18.0";
+
+ /** Target version for RU. */
+ private static final String TARGET_VER = "2.18.1";
+
+ /** 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);
+
+ /** Thin client. */
+ private IgniteClient client;
+
+ /** */
+ @BeforeClass
+ public static void beforeClass() {
+ U.delete(LOCAL_WORK_DIR);
+ }
+
+ /** */
+ @AfterClass
+ public static void afterClass() {
+ U.delete(LOCAL_WORK_DIR);
+ }
+
+ /** Basic RU test. */
+ @Test
+ public void testRollingUpgrade() {
+ try (IgniteClusterContainer cluster = new IgniteClusterContainer(SOURCE_VER, NODE_IDS)) {
+ cluster.start();
+
+ IgniteContainer node = cluster.firstNode();
+
+ node.activateCluster();
+
+ ClientCacheConfiguration cfg = new ClientCacheConfiguration()
+ .setName(CACHE_NAME)
+ .setBackups(1)
+ .setAtomicityMode(CacheAtomicityMode.TRANSACTIONAL);
+
+ ClientCache cache = client(node.clientAddress()).createCache(cfg);
+
+ for (int i = 0; i < 1000; i++)
+ cache.put(i, i);
+
+ assertEquals(DISABLED, node.rollingUpgradeStatus());
+
+ node.rollingUpgradeEnable(TARGET_VER);
+
+ assertEquals(ENABLED, node.rollingUpgradeStatus());
+
+ closeClient();
+
+ cluster.upgrade(TARGET_VER);
+
+ node = cluster.firstNode();
+
+ assertEquals(NODE_IDS.size(), node.nodesCountForVersion(TARGET_VER));
+
+ node.rollingUpgradeDisable();
+
+ assertEquals(DISABLED, node.rollingUpgradeStatus());
+
+ ClientCache targetCache = client(node.clientAddress()).getOrCreateCache(CACHE_NAME);
+
+ for (int i = 0; i < 1000; i++)
+ assertEquals("Data mismatch after upgrade at key: " + i, i, (int)targetCache.get(i));
+
+ targetCache.put(1001, 1001);
+
+ assertEquals(1001, (int)targetCache.get(1001));
+ }
+ finally {
+ closeClient();
+ }
+ }
+
+ /** */
+ 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;
+ }
+ }
+}
diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteRollingUpgradeDockerTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteRollingUpgradeDockerTestSuite.java
new file mode 100644
index 0000000000000..a542deaf7929d
--- /dev/null
+++ b/modules/core/src/test/java/org/apache/ignite/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.testsuites;
+
+import org.apache.ignite.testcontainers.IgniteRebalanceOnUpgradeTest;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+/** Contains RU tests based on testcontainers. */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ IgniteRebalanceOnUpgradeTest.class
+})
+public class IgniteRollingUpgradeDockerTestSuite {
+}
diff --git a/modules/core/src/test/resources/docker/test-config.xml b/modules/core/src/test/resources/docker/test-config.xml
new file mode 100644
index 0000000000000..ce6f034dbdd5d
--- /dev/null
+++ b/modules/core/src/test/resources/docker/test-config.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/parent/pom.xml b/parent/pom.xml
index ac68955a0d172..c28aea123f4c5 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -110,6 +110,7 @@
5.3.39
3.5.4
10.0.27
+ 2.0.5
0.8.3
3.9.5
1.5.7-8