This is an automated email from the ASF dual-hosted git repository.

mpochatkin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new 0a45b29c786 IGNITE-24993 Implement POC for a Compatibility Test 
Framework (#5983)
0a45b29c786 is described below

commit 0a45b29c7860b49fdbbbc23967f909be0faa8385
Author: Vadim Pakhnushev <[email protected]>
AuthorDate: Mon Jun 16 15:45:29 2025 +0300

    IGNITE-24993 Implement POC for a Compatibility Test Framework (#5983)
---
 gradle/libs.versions.toml                          |   3 +
 modules/compatibility-tests/README.md              |  31 ++
 modules/compatibility-tests/build.gradle           | 107 +++++++
 .../ignite/internal/CompatibilityTestBase.java     | 153 +++++++++
 .../org/apache/ignite/internal/IgniteCluster.java  | 345 +++++++++++++++++++++
 .../org/apache/ignite/internal/IgniteVersions.java |  90 ++++++
 .../ignite/internal/ItCompatibilityTest.java       |  49 +++
 .../org/apache/ignite/internal/RunnerNode.java     | 139 +++++++++
 .../integrationTest/resources/igniteVersions.json  |  21 ++
 .../ignite/internal/util/CollectionUtils.java      |  23 ++
 .../java/org/apache/ignite/internal/Cluster.java   |  16 +-
 .../testframework/TestIgnitionManager.java         |  28 +-
 settings.gradle                                    |   2 +
 13 files changed, 989 insertions(+), 18 deletions(-)

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9298ba0f647..102b76b9cb4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -90,6 +90,7 @@ otel = "1.51.0"
 spring-boot = "3.5.0"
 spring-data = "3.5.1"
 testcontainers = "1.21.1"
+gradleToolingApi = "8.6"
 
 #Tools
 pmdTool = "6.55.0"
@@ -295,3 +296,5 @@ commons-collections4 = { module = 
"org.apache.commons:commons-collections4", ver
 commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = 
"commons-lang3" }
 
 spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", 
version.ref = "spotbugsTool" }
+
+gradle-tooling-api = { module = "org.gradle:gradle-tooling-api", version.ref = 
"gradleToolingApi" }
diff --git a/modules/compatibility-tests/README.md 
b/modules/compatibility-tests/README.md
new file mode 100644
index 00000000000..d549c6cd121
--- /dev/null
+++ b/modules/compatibility-tests/README.md
@@ -0,0 +1,31 @@
+# Ignite compatibility tests 
+
+This module contains tests that verify Ignite cluster upgrades.
+
+## Base test
+The `PersistenceTestBase` serves as a base compatibility test. It starts and 
inits a cluster of a specified previous version, calls 
+`setupBaseVersion` method with the Ignite client connected to the first node 
of the cluster. Then it stops the cluster and starts it in the
+embedded mode using current sources. This is done once per class, similar to 
the `ClusterPerClassIntegrationTest`. This base test is
+parameterized using the list of versions from the `versions.json` resource 
file. By default the test takes two latest versions as the base
+version. If the `testAllVersions` system property is defined, then all the 
versions are tested.
+
+## Describing versions
+When new version is released, add new object to the `versions` array in the 
`versions.json` file like so:
+```json
+{
+  "version": "3.1.0"
+}
+```
+In case there's a need for the specific node configuration override for that 
version, the `configOverrides` object can be added:
+```json
+{
+  "version": "3.0.0",
+  "configOverrides": {
+    "ignite.network.membership.scaleCube.metadataTimeout": 10000
+  }
+}
+```
+
+## Running from TeamCity
+Before executing `test` task, `resolveCompatibilityTestDependencies` task 
should be started to download all necessary dependencies from the
+Maven repository.
diff --git a/modules/compatibility-tests/build.gradle 
b/modules/compatibility-tests/build.gradle
new file mode 100644
index 00000000000..a80e575317b
--- /dev/null
+++ b/modules/compatibility-tests/build.gradle
@@ -0,0 +1,107 @@
+import groovy.json.JsonSlurper
+
+/*
+ * 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.
+ */
+
+apply from: "$rootDir/buildscripts/java-core.gradle"
+apply from: "$rootDir/buildscripts/java-integration-test.gradle"
+
+description = 'ignite-compatibility-tests'
+
+repositories {
+    // For Gradle Tooling API
+    maven { url = 'https://repo.gradle.org/gradle/libs-releases' }
+}
+
+dependencies {
+    integrationTestImplementation libs.gradle.tooling.api
+    integrationTestImplementation libs.hamcrest.core
+    integrationTestImplementation libs.awaitility
+    integrationTestImplementation libs.jackson.databind
+    integrationTestImplementation(libs.jsonpath.assert) {
+        //IDEA test runner don't apply Gradle dependency resolve strategy, 
this is just not implemented
+        //So, exclude asm-core transitive dependency to protect of jar-hell.
+        exclude group: 'org.ow2.asm', module: 'asm'
+    }
+
+    integrationTestImplementation testFixtures(project(':ignite-core'))
+    integrationTestImplementation testFixtures(project(':ignite-runner'))
+    integrationTestImplementation project(':ignite-core')
+    integrationTestImplementation project(':ignite-api')
+    integrationTestImplementation project(':ignite-runner')
+    integrationTestImplementation project(':ignite-client')
+    integrationTestImplementation project(':ignite-rest-api')
+}
+
+private def resolveAllDependencies(String dependencyNotation, String... 
additionalNotations) {
+    def notations = [dependencyNotation]
+    notations.addAll(additionalNotations)
+    def dependencies = notations.collect {
+        dependencies.create(it)
+    }
+    Configuration detached = configurations.detachedConfiguration(dependencies 
as Dependency[])
+    detached.transitive = true
+    detached.attributes {
+        it.attribute(Category.CATEGORY_ATTRIBUTE, 
objects.named(Category.class, Category.LIBRARY));
+        it.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class, 
Usage.JAVA_RUNTIME));
+        it.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, 
objects.named(LibraryElements.class, LibraryElements.JAR));
+        it.attribute(Bundling.BUNDLING_ATTRIBUTE, 
objects.named(Bundling.class, Bundling.EXTERNAL));
+        it.attribute(
+                TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE,
+                objects.named(TargetJvmEnvironment.class, 
TargetJvmEnvironment.STANDARD_JVM)
+        )
+    }
+    println "resolving $dependencyNotation"
+    return detached.resolve()  // Set<File> of *.jar
+}
+
+def resolveIgniteVersions() {
+    def versionsFile = 
file("src/integrationTest/resources/igniteVersions.json")
+    def versionsJson = new JsonSlurper().parseText(versionsFile.text)
+    versionsJson.versions.each { version ->
+        versionsJson.artifacts.each { artifact ->
+            resolveAllDependencies("$artifact:$version.version")
+        }
+    }
+}
+
+// This task should be started as a separate preparation step before running 
tests so that all the necessary dependencies are resolved and
+// cached locally
+tasks.register('resolveCompatibilityTestDependencies') {
+    doLast {
+        resolveIgniteVersions()
+    }
+}
+
+tasks.register('constructArgFile') {
+    doLast {
+        def depNotation = project.property('dependencyNotation')
+        def jars = resolveAllDependencies(depNotation)
+        def classPath = files(jars).asPath
+        def classPathFilePath = project.property('argFilePath')
+
+        def classPathFile = file(classPathFilePath)
+        classPathFile.withPrintWriter {
+            it.println '-classpath'
+            // Java argfile quote rules are weird. It's enough to quote spaces.
+            it.println classPath.replace(" ", "\" \"")
+            defaultJvmArgs.each { arg ->
+                it.println arg
+            }
+        }
+    }
+}
diff --git 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/CompatibilityTestBase.java
 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/CompatibilityTestBase.java
new file mode 100644
index 00000000000..f47193c30cf
--- /dev/null
+++ 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/CompatibilityTestBase.java
@@ -0,0 +1,153 @@
+/*
+ * 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.internal;
+
+import static 
org.apache.ignite.internal.TestDefaultProfilesNames.DEFAULT_AIMEM_PROFILE_NAME;
+import static 
org.apache.ignite.internal.TestDefaultProfilesNames.DEFAULT_AIPERSIST_PROFILE_NAME;
+import static 
org.apache.ignite.internal.TestDefaultProfilesNames.DEFAULT_ROCKSDB_PROFILE_NAME;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.ignite.Ignite;
+import org.apache.ignite.InitParametersBuilder;
+import org.apache.ignite.client.IgniteClient;
+import org.apache.ignite.internal.IgniteVersions.Version;
+import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+import org.apache.ignite.internal.testframework.WorkDirectory;
+import org.apache.ignite.internal.testframework.WorkDirectoryExtension;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.AfterParameterizedClassInvocation;
+import org.junit.jupiter.params.BeforeParameterizedClassInvocation;
+import org.junit.jupiter.params.Parameter;
+import org.junit.jupiter.params.ParameterizedClass;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Base class for testing cluster upgrades. Starts a cluster on an old 
version, initializes it, stops it, then starts it in the
+ * embedded mode using current version.
+ */
+@ExtendWith(WorkDirectoryExtension.class)
+@TestInstance(Lifecycle.PER_CLASS)
+@ParameterizedClass
+@MethodSource("baseVersions")
+public abstract class CompatibilityTestBase extends BaseIgniteAbstractTest {
+    /** Nodes bootstrap configuration pattern. */
+    private static final String NODE_BOOTSTRAP_CFG_TEMPLATE = "ignite {\n"
+            + "  network: {\n"
+            + "    port: {},\n"
+            + "    nodeFinder.netClusterNodes: [ {} ]\n"
+            + "  },\n"
+            + "  storage.profiles: {"
+            + "        " + DEFAULT_AIPERSIST_PROFILE_NAME + ".engine: 
aipersist, "
+            + "        " + DEFAULT_AIMEM_PROFILE_NAME + ".engine: aimem, "
+            + "        " + DEFAULT_ROCKSDB_PROFILE_NAME + ".engine: rocksdb"
+            + "  },\n"
+            + "  clientConnector.port: {},\n"
+            + "  clientConnector.sendServerExceptionStackTraceToClient: 
true,\n"
+            + "  rest.port: {},\n"
+            + "  failureHandler.dumpThreadsOnFailure: false\n"
+            + "}";
+
+    // If there are no fields annotated with @Parameter, constructor injection 
will be used, which is incompatible with the
+    // Lifecycle.PER_CLASS.
+    @SuppressWarnings("unused")
+    @Parameter
+    String baseVersion;
+
+    @WorkDirectory
+    private static Path WORK_DIR;
+
+    protected IgniteCluster cluster;
+
+    @SuppressWarnings("unused")
+    @BeforeParameterizedClassInvocation
+    void startCluster(String baseVersion, TestInfo testInfo) {
+        ClusterConfiguration clusterConfiguration = 
ClusterConfiguration.builder(testInfo, WORK_DIR)
+                
.defaultNodeBootstrapConfigTemplate(NODE_BOOTSTRAP_CFG_TEMPLATE)
+                .build();
+
+        int nodesCount = nodesCount();
+
+        cluster = new IgniteCluster(clusterConfiguration);
+        cluster.start(baseVersion, nodesCount);
+
+        cluster.init(this::configureInitParameters);
+
+        try (IgniteClient client = cluster.createClient()) {
+            setupBaseVersion(client);
+        }
+
+        cluster.stop();
+
+        cluster.startEmbedded(nodesCount);
+    }
+
+    @SuppressWarnings("unused")
+    @AfterParameterizedClassInvocation
+    void stopCluster() {
+        if (cluster != null) {
+            cluster.stop();
+        }
+    }
+
+    protected String getNodeBootstrapConfigTemplate() {
+        return NODE_BOOTSTRAP_CFG_TEMPLATE;
+    }
+
+    protected int nodesCount() {
+        return 3;
+    }
+
+    /**
+     * This method can be overridden to add custom init parameters during 
cluster initialization.
+     */
+    protected void configureInitParameters(InitParametersBuilder builder) {
+    }
+
+    protected abstract void setupBaseVersion(Ignite baseIgnite);
+
+    protected List<List<Object>> sql(String query) {
+        return ClusterPerClassIntegrationTest.sql(cluster.node(0), null, null, 
null, query);
+    }
+
+    private static List<String> baseVersions() {
+        return baseVersions(2);
+    }
+
+    /**
+     * Returns a list of base versions. If {@code testAllVersions} system 
property is set, then all versions are returned, otherwise, at
+     * most {@code numLatest} are taken.
+     *
+     * @param numLatest Number of latest versions to take by default.
+     * @return A list of base versions for a test.
+     */
+    protected static List<String> baseVersions(int numLatest) {
+        List<String> versions = 
IgniteVersions.INSTANCE.versions().stream().map(Version::version).collect(Collectors.toList());
+        if (System.getProperty("testAllVersions") != null) {
+            return versions;
+        } else {
+            // Take at most two latest versions by default.
+            int fromIndex = Math.max(versions.size() - numLatest, 0);
+            return versions.subList(fromIndex, versions.size());
+        }
+    }
+}
diff --git 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteCluster.java
 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteCluster.java
new file mode 100644
index 00000000000..df845782b20
--- /dev/null
+++ 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteCluster.java
@@ -0,0 +1,345 @@
+/*
+ * 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.internal;
+
+import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath;
+import static java.util.stream.Collectors.toList;
+import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
+import static org.apache.ignite.internal.util.CollectionUtils.setListAtIndex;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpRequest.Builder;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteServer;
+import org.apache.ignite.InitParameters;
+import org.apache.ignite.InitParametersBuilder;
+import org.apache.ignite.client.IgniteClient;
+import org.apache.ignite.internal.Cluster.ServerRegistration;
+import org.apache.ignite.internal.logger.IgniteLogger;
+import org.apache.ignite.internal.logger.Loggers;
+import org.apache.ignite.internal.rest.api.cluster.InitCommand;
+import org.apache.ignite.internal.testframework.TestIgnitionManager;
+import org.gradle.tooling.GradleConnectionException;
+import org.gradle.tooling.GradleConnector;
+import org.gradle.tooling.ProjectConnection;
+import org.gradle.tooling.model.build.BuildEnvironment;
+
+/**
+ * Cluster of nodes. Can be started with nodes of previous Ignite versions 
running in the external processes or in the embedded mode
+ * using current sources.
+ */
+public class IgniteCluster {
+    private static final IgniteLogger LOG = 
Loggers.forClass(IgniteCluster.class);
+
+    // Embedded nodes
+    private final List<IgniteServer> igniteServers = new 
CopyOnWriteArrayList<>();
+    private final List<Ignite> nodes = new CopyOnWriteArrayList<>();
+    private final HttpClient client = HttpClient.newBuilder().build();
+
+    // External process nodes
+    private List<RunnerNode> runnerNodes;
+
+    private volatile boolean started = false;
+    private volatile boolean stopped = false;
+
+    private final ClusterConfiguration clusterConfiguration;
+
+    IgniteCluster(ClusterConfiguration clusterConfiguration) {
+        this.clusterConfiguration = clusterConfiguration;
+    }
+
+    /**
+     * Starts cluster with nodes of previous version using external process.
+     *
+     * @param igniteVersion Ignite version to run the nodes with.
+     * @param nodesCount Number of nodes in the cluster.
+     */
+    public void start(String igniteVersion, int nodesCount) {
+        if (started) {
+            throw new IllegalStateException("The cluster is already started");
+        }
+
+        runnerNodes = startRunnerNodes(igniteVersion, nodesCount);
+    }
+
+    /**
+     * Starts cluster in embedded mode with nodes of current version.
+     *
+     * @param nodesCount Number of nodes in the cluster.
+     */
+    public void startEmbedded(int nodesCount) {
+        if (started) {
+            throw new IllegalStateException("The cluster is already started");
+        }
+
+        List<ServerRegistration> nodeRegistrations = new ArrayList<>();
+        for (int nodeIndex = 0; nodeIndex < nodesCount; nodeIndex++) {
+            nodeRegistrations.add(startEmbeddedNode(nodeIndex));
+        }
+
+        for (ServerRegistration registration : nodeRegistrations) {
+            assertThat(registration.registrationFuture(), 
willCompleteSuccessfully());
+        }
+
+        started = true;
+        stopped = false;
+    }
+
+    /**
+     * Stops all the nodes in the cluster.
+     */
+    public void stop() {
+        List<IgniteServer> serversToStop = new ArrayList<>(igniteServers);
+
+        List<String> serverNames = serversToStop.stream()
+                .filter(Objects::nonNull)
+                .map(IgniteServer::name)
+                .collect(toList());
+        LOG.info("Shutting the embedded cluster down [nodes={}]", serverNames);
+
+        Collections.fill(igniteServers, null);
+        Collections.fill(nodes, null);
+
+        
serversToStop.parallelStream().filter(Objects::nonNull).forEach(IgniteServer::shutdown);
+
+        LOG.info("Shut the embedded cluster down");
+
+        if (runnerNodes != null) {
+            runnerNodes.forEach(RunnerNode::stop);
+            runnerNodes.clear();
+        }
+
+        started = false;
+        stopped = true;
+    }
+
+    /**
+     * Initializes the cluster using REST API on the first node with default 
settings.
+     */
+    void init(Consumer<InitParametersBuilder> initParametersConfigurator) {
+        init(new int[] { 0 }, initParametersConfigurator);
+    }
+
+    /**
+     * Initializes the cluster using REST API on the first node with specified 
Metastorage and CMG nodes.
+     *
+     * @param cmgNodes Indices of the CMG nodes (also used as Metastorage 
group).
+     */
+    void init(int[] cmgNodes, Consumer<InitParametersBuilder> 
initParametersConfigurator) {
+        // Wait for the node to start accepting requests
+        await()
+                .ignoreExceptions()
+                .timeout(30, TimeUnit.SECONDS)
+                .until(
+                        () -> send(get("/management/v1/node/state")).body(),
+                        hasJsonPath("$.state", is(equalTo("STARTING")))
+                );
+
+        // Initialize the cluster
+        List<String> metaStorageAndCmgNodes = Arrays.stream(cmgNodes)
+                .mapToObj(this::nodeName)
+                .collect(toList());
+
+        InitParametersBuilder builder = InitParameters.builder()
+                .metaStorageNodeNames(metaStorageAndCmgNodes)
+                .clusterName(clusterConfiguration.clusterName());
+
+        initParametersConfigurator.accept(builder);
+
+        sendInitRequest(builder.build());
+
+        // Wait for the cluster to be initialized
+        await()
+                .ignoreExceptions()
+                .timeout(30, TimeUnit.SECONDS)
+                .until(
+                        () -> send(get("/management/v1/node/state")).body(),
+                        hasJsonPath("$.state", is(equalTo("STARTED")))
+                );
+
+        started = true;
+        stopped = false;
+    }
+
+    private void sendInitRequest(InitParameters initParameters) {
+        ObjectMapper mapper = new ObjectMapper();
+        String requestBody;
+        try {
+            InitCommand initCommand = new InitCommand(
+                    initParameters.metaStorageNodeNames(),
+                    initParameters.cmgNodeNames(),
+                    initParameters.clusterName(),
+                    initParameters.clusterConfiguration()
+            );
+            requestBody = mapper.writeValueAsString(initCommand);
+        } catch (JsonProcessingException e) {
+            throw new RuntimeException(e);
+        }
+
+        assertThat(send(post("/management/v1/cluster/init", 
requestBody)).statusCode(), is(200));
+    }
+
+    /**
+     * Creates a client connection to the first node of the cluster.
+     *
+     * @return Ignite client instance.
+     */
+    IgniteClient createClient() {
+        return IgniteClient.builder().addresses("localhost:" + 
clusterConfiguration.baseClientPort()).build();
+    }
+
+    /**
+     * Returns target version embedded node.
+     *
+     * @param index Node index.
+     * @return Embedded node.
+     */
+    public Ignite node(int index) {
+        return Objects.requireNonNull(nodes.get(index), "index=" + index);
+    }
+
+    /**
+     * Returns node name by index.
+     *
+     * @param nodeIndex Index of the node.
+     * @return Node name.
+     */
+    public String nodeName(int nodeIndex) {
+        return 
clusterConfiguration.nodeNamingStrategy().nodeName(clusterConfiguration, 
nodeIndex);
+    }
+
+    private ServerRegistration startEmbeddedNode(int nodeIndex) {
+        String nodeName = nodeName(nodeIndex);
+
+        IgniteServer node = TestIgnitionManager.start(
+                nodeName,
+                null,
+                
clusterConfiguration.workDir().resolve(clusterConfiguration.clusterName()).resolve(nodeName)
+        );
+
+        synchronized (igniteServers) {
+            setListAtIndex(igniteServers, nodeIndex, node);
+        }
+
+        CompletableFuture<Void> registrationFuture = 
node.waitForInitAsync().thenRun(() -> {
+            synchronized (nodes) {
+                setListAtIndex(nodes, nodeIndex, node.api());
+            }
+
+            if (stopped) {
+                // Make sure we stop even a node that finished starting after 
the cluster has been stopped.
+                node.shutdown();
+            }
+        });
+
+        return new ServerRegistration(node, registrationFuture);
+    }
+
+    private List<RunnerNode> startRunnerNodes(String igniteVersion, int 
nodesCount) {
+        try (ProjectConnection connection = GradleConnector.newConnector()
+                // Current directory is modules/compatibility-tests so get two 
parents
+                .forProjectDirectory(Path.of("..", "..").normalize().toFile())
+                .connect()
+        ) {
+            BuildEnvironment environment = 
connection.model(BuildEnvironment.class).get();
+
+            File javaHome = environment.getJava().getJavaHome();
+            File argFile = constructArgFile(connection, 
"org.apache.ignite:ignite-runner:" + igniteVersion);
+
+            List<RunnerNode> result = new ArrayList<>();
+            for (int nodeIndex = 0; nodeIndex < nodesCount; nodeIndex++) {
+                result.add(RunnerNode.startNode(javaHome, argFile, 
igniteVersion, clusterConfiguration, nodesCount, nodeIndex));
+            }
+
+            return result;
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static File constructArgFile(ProjectConnection connection, String 
dependencyNotation) throws IOException {
+        File argFilePath = File.createTempFile("argFilePath", "");
+        argFilePath.deleteOnExit();
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try {
+            connection.newBuild()
+                    .forTasks(":ignite-compatibility-tests:constructArgFile")
+                    .withArguments(
+                            "-PdependencyNotation=" + dependencyNotation,
+                            "-PargFilePath=" + argFilePath
+                    )
+                    .setStandardOutput(baos)
+                    .setStandardError(baos)
+                    .run();
+        } catch (GradleConnectionException | IllegalStateException e) {
+            LOG.error("Failed to run constructArgFile task", e);
+            LOG.error("Gradle task output:" + System.lineSeparator() + baos);
+            throw new RuntimeException(e);
+        }
+
+        return argFilePath;
+    }
+
+    private HttpRequest post(String path, String body) {
+        return newBuilder(path)
+                .header("content-type", "application/json")
+                .POST(BodyPublishers.ofString(body))
+                .build();
+    }
+
+    private HttpRequest get(String path) {
+        return newBuilder(path).build();
+    }
+
+    private Builder newBuilder(String path) {
+        return HttpRequest.newBuilder(URI.create("http://localhost:"; + 
clusterConfiguration.baseHttpPort() + path));
+    }
+
+    private HttpResponse<String> send(HttpRequest request) {
+        try {
+            return client.send(request, BodyHandlers.ofString());
+        } catch (IOException | InterruptedException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteVersions.java
 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteVersions.java
new file mode 100644
index 00000000000..4f8064f0d8b
--- /dev/null
+++ 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteVersions.java
@@ -0,0 +1,90 @@
+/*
+ * 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.internal;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.apache.ignite.internal.logger.Loggers;
+
+/**
+ * POJO with ignite versions data from the {@code igniteVersions.json}. 
Contains a list of artifact names and a list of versions with
+ * optional node config overrides.
+ */
+@SuppressWarnings("unused")
+public class IgniteVersions {
+    public static IgniteVersions INSTANCE = readFromJson();
+
+    private List<String> artifacts;
+    private List<Version> versions;
+
+    public IgniteVersions() {
+    }
+
+    @JsonCreator
+    public IgniteVersions(@JsonProperty("artifacts") List<String> artifacts, 
@JsonProperty("versions") List<Version> versions) {
+        this.artifacts = artifacts;
+        this.versions = versions;
+    }
+
+    public List<String> artifacts() {
+        return artifacts;
+    }
+
+    public List<Version> versions() {
+        return versions;
+    }
+
+    /**
+     * Represents a particular Ignite version with optional node config 
overrides.
+     */
+    public static class Version {
+        private String version;
+        private Map<String, String> configOverrides;
+
+        public Version() {
+        }
+
+        @JsonCreator
+        public Version(@JsonProperty("version") String version, 
@JsonProperty("configOverrides") Map<String, String> configOverrides) {
+            this.version = version;
+            this.configOverrides = configOverrides;
+        }
+
+        public String version() {
+            return version;
+        }
+
+        public Map<String, String> configOverrides() {
+            return configOverrides;
+        }
+    }
+
+    private static IgniteVersions readFromJson() {
+        ObjectMapper mapper = new ObjectMapper();
+        try {
+            return 
mapper.readValue(IgniteVersions.class.getResource("/igniteVersions.json"), 
IgniteVersions.class);
+        } catch (IOException e) {
+            Loggers.forClass(IgniteVersions.class).error("Failed to read 
igniteVersions.json", e);
+            return new IgniteVersions();
+        }
+    }
+}
diff --git 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItCompatibilityTest.java
 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItCompatibilityTest.java
new file mode 100644
index 00000000000..835d6fa7b81
--- /dev/null
+++ 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItCompatibilityTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.internal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+
+import java.util.List;
+import org.apache.ignite.Ignite;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedClass;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@ParameterizedClass
+@MethodSource("baseVersions")
+@Disabled("https://issues.apache.org/jira/browse/IGNITE-25647";)
+class ItCompatibilityTest extends CompatibilityTestBase {
+    @Override
+    protected void setupBaseVersion(Ignite baseIgnite) {
+        baseIgnite.sql().execute(null, "CREATE TABLE TEST(ID INT PRIMARY KEY, 
VAL VARCHAR)");
+        baseIgnite.sql().execute(null, "INSERT INTO TEST VALUES (1, 'str')");
+    }
+
+    @Test
+    void testCompatibility() {
+        List<List<Object>> result = sql("SELECT * FROM TEST");
+        assertThat(result, contains(contains(1, "str")));
+    }
+
+    private static List<String> baseVersions() {
+        return baseVersions(2);
+    }
+}
diff --git 
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/RunnerNode.java
 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/RunnerNode.java
new file mode 100644
index 00000000000..d10b9297822
--- /dev/null
+++ 
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/RunnerNode.java
@@ -0,0 +1,139 @@
+/*
+ * 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.internal;
+
+import static java.util.stream.Collectors.joining;
+import static 
org.apache.ignite.internal.testframework.TestIgnitionManager.DEFAULT_CONFIG_NAME;
+import static 
org.apache.ignite.internal.testframework.TestIgnitionManager.writeConfigurationFile;
+import static 
org.apache.ignite.internal.testframework.TestIgnitionManager.writeConfigurationFileApplyingTestDefaults;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.apache.ignite.internal.IgniteVersions.Version;
+import org.apache.ignite.internal.app.IgniteRunner;
+import org.apache.ignite.internal.lang.IgniteStringFormatter;
+
+/**
+ * Represents the Ignite node running in the external process.
+ */
+public class RunnerNode {
+    private static final Map<String, Map<String, String>> DEFAULTS_PER_VERSION 
= getTestDefaultsPerVersion();
+
+    private final Process process;
+
+    private RunnerNode(Process process) {
+        this.process = process;
+    }
+
+    /**
+     * Starts the Ignite in the external process.
+     *
+     * @param javaHome Path to the Java to run the node with.
+     * @param argFile Java arguments file.
+     * @param igniteVersion Version of the Ignite. Used to get the 
configuration defaults.
+     * @param clusterConfiguration Test cluster configuration.
+     * @param nodesCount Overall number of nodes.
+     * @param nodeIndex Current node index.
+     * @return Instance of the control object.
+     * @throws IOException If an I/O exception occurs.
+     */
+    public static RunnerNode startNode(
+            File javaHome,
+            File argFile,
+            String igniteVersion,
+            ClusterConfiguration clusterConfiguration,
+            int nodesCount,
+            int nodeIndex
+    ) throws IOException {
+        String nodeName = 
clusterConfiguration.nodeNamingStrategy().nodeName(clusterConfiguration, 
nodeIndex);
+        Path workDir = 
clusterConfiguration.workDir().resolve(clusterConfiguration.clusterName()).resolve(nodeName);
+        String configStr = formatConfig(clusterConfiguration, nodeIndex, 
nodesCount);
+
+        Files.createDirectories(workDir);
+        Path configPath = workDir.resolve(DEFAULT_CONFIG_NAME);
+
+        boolean useTestDefaults = true;
+        if (useTestDefaults) {
+            writeConfigurationFileApplyingTestDefaults(configStr, configPath, 
DEFAULTS_PER_VERSION.get(igniteVersion));
+        } else {
+            writeConfigurationFile(configStr, configPath);
+        }
+
+        Process process = executeRunner(javaHome, argFile, configPath, 
workDir, nodeName);
+        return new RunnerNode(process);
+    }
+
+    /**
+     * Stops the node by killing the process.
+     */
+    public void stop() {
+        process.destroy();
+    }
+
+    private static Map<String, Map<String, String>> 
getTestDefaultsPerVersion() {
+        return IgniteVersions.INSTANCE.versions().stream()
+                .filter(version -> version.configOverrides() != null)
+                .collect(Collectors.toMap(
+                        Version::version,
+                        Version::configOverrides
+                ));
+    }
+
+    private static String seedAddressesString(ClusterConfiguration 
clusterConfiguration, int seedsCount) {
+        return IntStream.range(0, seedsCount)
+                .map(nodeIndex -> clusterConfiguration.basePort() + nodeIndex)
+                .mapToObj(port -> "\"localhost:" + port + '\"')
+                .collect(joining(", "));
+    }
+
+    private static String formatConfig(ClusterConfiguration 
clusterConfiguration, int nodeIndex, int nodesCount) {
+        return IgniteStringFormatter.format(
+                clusterConfiguration.defaultNodeBootstrapConfigTemplate(),
+                clusterConfiguration.basePort() + nodeIndex,
+                seedAddressesString(clusterConfiguration, nodesCount),
+                clusterConfiguration.baseClientPort() + nodeIndex,
+                clusterConfiguration.baseHttpPort() + nodeIndex,
+                clusterConfiguration.baseHttpsPort() + nodeIndex
+        );
+    }
+
+    @SuppressWarnings("UseOfProcessBuilder")
+    private static Process executeRunner(
+            File javaHome,
+            File classPathFile,
+            Path configPath,
+            Path workDir,
+            String nodeName
+    ) throws IOException {
+        ProcessBuilder pb = new ProcessBuilder(
+                javaHome.toPath().resolve("bin").resolve("java").toString(),
+                "@" + classPathFile,
+                IgniteRunner.class.getName(),
+                "--node-name", nodeName,
+                "--work-dir", workDir.toString(),
+                "--config-path", configPath.toString()
+        );
+        pb.inheritIO();
+        return pb.start();
+    }
+}
diff --git 
a/modules/compatibility-tests/src/integrationTest/resources/igniteVersions.json 
b/modules/compatibility-tests/src/integrationTest/resources/igniteVersions.json
new file mode 100644
index 00000000000..2375a8de1ef
--- /dev/null
+++ 
b/modules/compatibility-tests/src/integrationTest/resources/igniteVersions.json
@@ -0,0 +1,21 @@
+{
+  "artifacts": [
+    "org.apache.ignite:ignite-runner"
+  ],
+  "versions": [
+    {
+      "version": "3.0.0",
+      "configOverrides": {
+        "ignite.network.membership.scaleCube.metadataTimeout": 10000,
+        "ignite.storage.profiles.default_aipersist.engine": "aipersist",
+        "ignite.storage.profiles.default_aipersist.size": 268435456,
+        "ignite.storage.profiles.default_aimem.engine": "aimem",
+        "ignite.storage.profiles.default_aimem.initSize": 268435456,
+        "ignite.storage.profiles.default_aimem.maxSize": 268435456,
+        "ignite.storage.profiles.default.engine": "aipersist",
+        "ignite.storage.profiles.default.size": 268435456,
+        "ignite.system.properties.aipersistThrottling": "disabled"
+      }
+    }
+  ]
+}
diff --git 
a/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
 
b/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
index a6b92f74112..9e6aa93100e 100644
--- 
a/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
+++ 
b/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
@@ -18,6 +18,7 @@
 package org.apache.ignite.internal.util;
 
 import static java.util.Collections.emptyIterator;
+import static java.util.Collections.nCopies;
 import static java.util.Collections.unmodifiableSet;
 import static java.util.stream.Collectors.toSet;
 
@@ -619,4 +620,26 @@ public final class CollectionUtils {
             }
         };
     }
+
+    /**
+     * Sets list element at the specified index. Expands a list if needed.
+     *
+     * @param list List to update.
+     * @param i Target index.
+     * @param element Element to put.
+     * @param <T> Type of the list elements.
+     */
+    public static <T> void setListAtIndex(List<T> list, int i, T element) {
+        if (list.size() < i) {
+            list.addAll(nCopies(i - list.size(), null));
+        }
+
+        if (list.size() < i + 1) {
+            list.add(element);
+        } else {
+            T prev = list.set(i, element);
+
+            assert prev == null : String.format("Found previous value %s at 
index %d", prev, i);
+        }
+    }
 }
diff --git 
a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/Cluster.java 
b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/Cluster.java
index 6350451d9a6..4b490f0f320 100644
--- 
a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/Cluster.java
+++ 
b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/Cluster.java
@@ -17,7 +17,6 @@
 
 package org.apache.ignite.internal;
 
-import static java.util.Collections.nCopies;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -30,6 +29,7 @@ import static 
org.apache.ignite.internal.lang.IgniteSystemProperties.colocationE
 import static 
org.apache.ignite.internal.testframework.IgniteTestUtils.waitForCondition;
 import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
 import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willSucceedIn;
+import static org.apache.ignite.internal.util.CollectionUtils.setListAtIndex;
 import static 
org.apache.ignite.internal.util.CompletableFutures.nullCompletedFuture;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.is;
@@ -357,20 +357,6 @@ public class Cluster {
         return clusterConfiguration.baseHttpPort() + nodeIndex;
     }
 
-    private static <T> void setListAtIndex(List<T> list, int i, T element) {
-        if (list.size() < i) {
-            list.addAll(nCopies(i - list.size(), null));
-        }
-
-        if (list.size() < i + 1) {
-            list.add(element);
-        } else {
-            T prev = list.set(i, element);
-
-            assert prev == null : String.format("Found previous value %s at 
index %d", prev, i);
-        }
-    }
-
     private String seedAddressesString() {
         int localSeedCountOverride = seedCountOverride;
         // We do this maxing because in some scenarios startAndInit() is not 
invoked, instead startNode() is used directly.
diff --git 
a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/testframework/TestIgnitionManager.java
 
b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/testframework/TestIgnitionManager.java
index 2dbdd347073..dd828cc8098 100644
--- 
a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/testframework/TestIgnitionManager.java
+++ 
b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/testframework/TestIgnitionManager.java
@@ -49,7 +49,7 @@ public class TestIgnitionManager {
     /** Default name of configuration file. */
     public static final String DEFAULT_CONFIG_NAME = "ignite-config.conf";
 
-    private static final int DEFAULT_SCALECUBE_METADATA_TIMEOUT = 10_000;
+    public static final int DEFAULT_SCALECUBE_METADATA_TIMEOUT = 10_000;
 
     /** Default DelayDuration in ms used for tests that is set on node init. */
     public static final int DEFAULT_DELAY_DURATION_MS = 100;
@@ -172,17 +172,39 @@ public class TestIgnitionManager {
     }
 
     private static void writeConfigurationFileApplyingTestDefaults(@Nullable 
String configStr, Path configPath) throws IOException {
+        writeConfigurationFileApplyingTestDefaults(configStr, configPath, 
DEFAULT_NODE_CONFIG);
+    }
+
+    /**
+     * Applies overrides to the config and writes it to disk.
+     *
+     * @param configStr Config string.
+     * @param configPath Config file path.
+     * @param defaults Map of overrides.
+     * @throws IOException If failed to write the file.
+     */
+    public static void writeConfigurationFileApplyingTestDefaults(
+            @Nullable String configStr,
+            Path configPath,
+            Map<String, String> defaults
+    ) throws IOException {
         if (configStr == null && Files.exists(configPath)) {
             // Nothing to do.
             return;
         }
 
-        String configStringToWrite = applyTestDefaultsToConfig(configStr, 
DEFAULT_NODE_CONFIG);
+        String configStringToWrite = applyTestDefaultsToConfig(configStr, 
defaults);
 
         writeConfigurationFile(configStringToWrite, configPath);
     }
 
-    private static void writeConfigurationFile(@Nullable String configStr, 
Path configPath) throws IOException {
+    /**
+     * Writes config to file.
+     *
+     * @param configStr Config string.
+     * @param configPath Config file path.
+     */
+    public static void writeConfigurationFile(@Nullable String configStr, Path 
configPath) throws IOException {
         if (configStr == null && Files.exists(configPath)) {
             // Nothing to do.
             return;
diff --git a/settings.gradle b/settings.gradle
index 048ac23d509..fce7bf2cff5 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -95,6 +95,7 @@ include(':ignite-configuration-root')
 include(':ignite-configuration-system')
 include(':ignite-system-disaster-recovery')
 include(':ignite-system-disaster-recovery-api')
+include(':ignite-compatibility-tests')
 
 project(":ignite-examples").projectDir = file('examples')
 project(":ignite-dev-utilities").projectDir = file('dev-utilities')
@@ -175,6 +176,7 @@ project(":ignite-configuration-root").projectDir = 
file('modules/configuration-r
 project(":ignite-configuration-system").projectDir = 
file('modules/configuration-system')
 project(":ignite-system-disaster-recovery").projectDir = 
file('modules/system-disaster-recovery')
 project(":ignite-system-disaster-recovery-api").projectDir = 
file('modules/system-disaster-recovery-api')
+project(':ignite-compatibility-tests').projectDir = 
file('modules/compatibility-tests')
 
 include(":migration-tools-ignite2-repack")
 include(":migration-tools-ignite3-repack")


Reply via email to