diff --git a/.gitignore b/.gitignore index 06151abef9940..b6f57f8f709be 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 0000000000000..b14b399edfa8d --- /dev/null +++ b/modules/compatibility/DEVNOTES.md @@ -0,0 +1,127 @@ + + +# Compatibility Module — Developer Notes + +## 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 +``` + +> **Note:** If you omit ``, 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:`. +5. Restore the original git state. + +> **Note:** If a distribution archive already exists in `target/bin/`, the build steps will be skipped. + +### Step 2. Run the test + +Run `IgniteRebalanceOnUpgradeTest` from your IDE or via Maven. The source version commit hash **must** be explicitly provided via `-Dru.source.commit.hash`: + +```bash +./mvnw test -pl modules/compatibility -Dtest=IgniteRebalanceOnUpgradeTest \ + -Dru.source.commit.hash= \ + -Psurefire-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.commit.hash=0ad4656eef09acda288cbad96f80f0138732d94a \ + -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/`. + +> **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.commit.hash=0ad4656eef09acda288cbad96f80f0138732d94a \ + -Dru.target.libs.dir=target/bin/apache-ignite--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.commit.hash=0ad4656eef09acda288cbad96f80f0138732d94a \ + -Psurefire-fork-count-1 +``` + +--- + +## System Properties + +| Property | Default | Class | Description | +|----------|-------------------------------------------|-------|-------------| +| `ru.upgrade.mode` | `DOCKER` | `IgniteRebalanceOnUpgradeTest` | Upgrade mode: `LOCAL` or `DOCKER` | +| `ru.source.commit.hash` | - | `IgniteRebalanceOnUpgradeTest` | Commit hash for the source (old-version) Docker image `apacheignite/ignite:` | +| `ru.target.libs.dir` | `/target/ignite-target-libs` | `IgniteContainer` | Host directory with target-version jars (DOCKER mode only) | +| `ru.local.work.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 232cd0b635f2b..284e943ebb0e6 100644 --- a/modules/compatibility/pom.xml +++ b/modules/compatibility/pom.xml @@ -123,6 +123,19 @@ test + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + org.apache.logging.log4j + log4j-slf4j2-impl + test + + @@ -158,4 +171,34 @@ + + + + compatibility-docker + + + + org.codehaus.mojo + exec-maven-plugin + + + prepare-docker-target-libs + + exec + + generate-test-resources + + bash + + ${project.basedir}/src/test/resources/docker/build_distrib.sh + ${project.basedir}/../.. + + + + + + + + + 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 0000000000000..46aa0fa1911aa --- /dev/null +++ b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/ru/IgniteRebalanceOnUpgradeTest.java @@ -0,0 +1,313 @@ +/* + * 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 CONSISTENT_IDS = List.of( + "ad26bff6-5ff5-49f1-9a61-425a827953ed", + "c1099d16-e7d7-49f4-925c-53329286c444", + "7b880b69-8a9e-4b84-b555-250d365e2e67" + ); + + /** Source version image tag, overridable via {@code -Dru.source.commit.hash}. */ + private static final String SOURCE_COMMIT_HASH = System.getProperty("ru.source.commit.hash"); + + /** 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 nodes = new ArrayList<>(); + + /** Consistent ID -> discovery address. */ + private final Map 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_COMMIT_HASH == null) + throw new RuntimeException("Source version image tag must be specified via `-Dru.source.commit.hash`"); + + 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() * 2; + } + + /** Basic RU test. */ + @Test + public void testRollingUpgrade() throws Exception { + try (IgniteClusterContainer cluster = new IgniteClusterContainer(SOURCE_COMMIT_HASH, CONSISTENT_IDS)) { + cluster.start(); + + ClientCacheConfiguration cfg = new ClientCacheConfiguration() + .setName(CACHE_NAME) + .setBackups(1) + .setAtomicityMode(CacheAtomicityMode.TRANSACTIONAL); + + ClientCache 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 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 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 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: the Docker bridge gateway on Linux, the + // host.docker.internal alias on macOS. + String hostIp = IgniteContainer.LINUX + ? con.gatewayIp() + : con.execInContainer("sh", "-c", + "getent ahostsv4 host.docker.internal | awk '{print $1}' | head -1").getStdout().trim(); + + con.stop(); + + addrs.remove(con.consistentId()); + + IgniteEx ignite = startGrid(configuration(con.consistentId(), con.localWorkDirectory(), addrs.values(), 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 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() + .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: (advertised by the containers) is tried. + .setSocketTimeout(1000) + .setNetworkTimeout(20000) + .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:. + 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(1000) + .setMaxConnectTimeout(10000) + // 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: is used. + .setFilterReachableAddresses(true); + + return new IgniteConfiguration() + .setIgniteInstanceName(nodeId) + .setConsistentId(nodeId) + .setWorkDirectory(workDir) + .setDataStorageConfiguration(new DataStorageConfiguration().setDefaultDataRegionConfiguration(dataRegionCfg)) + .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 host address 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). + *
    + *
  • DOCKER — all nodes stay in Docker; in-place upgrade by swapping libs inside containers (default)
  • + *
  • LOCAL — source cluster in Docker, upgraded to local host-JVM nodes
  • + *
+ */ + 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 0000000000000..e4630ca4f4e63 --- /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.} 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 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 0000000000000..705b7c289303b --- /dev/null +++ b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteClusterContainer.java @@ -0,0 +1,71 @@ +/* + * 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.IOException; +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 containers; + + /** Network. */ + private final Network net = Network.newNetwork(); + + /** + * @param commitHash Commit hash. + * @param consistentIds Consistent ID's. + */ + public IgniteClusterContainer(String commitHash, List consistentIds) throws IOException { + containers = new ArrayList<>(consistentIds.size()); + + for (int i = 0; i < consistentIds.size(); i++) { + String hostname = "node" + (1 + i); + + IgniteContainer ignite = new IgniteContainer(commitHash, net, hostname, consistentIds.get(i), i); + + containers.add(ignite); + } + } + + /** {@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 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 0000000000000..0c9600a371ff4 --- /dev/null +++ b/modules/compatibility/src/test/java/org/apache/ignite/compatibility/testframework/testcontainers/IgniteContainer.java @@ -0,0 +1,434 @@ +/* + * 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.File; +import java.io.FileOutputStream; +import java.io.IOException; +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.Arrays; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import com.github.dockerjava.api.model.ContainerNetwork; +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.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 { + /** 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 TEST_CLASSES = List.of( + ContainerAddressResolver.class.getName(), + TestCompatibilityPluginProvider.class.getName(), + DisabledRollingUpgradeProcessor.class.getName(), + DisabledValidationProcessor.class.getName() + ); + + /** Jar holding {@link #TEST_CLASSES}, injected so the old image can load it. */ + private static volatile File testClassesJar; + + /** Hostname. */ + private final String hostname; + + /** Consistent ID. */ + private final String consistentId; + + /** Path to work directory. */ + private final String workDirPath; + + /** + * Constructor with a commit hash (image tag). + * Uses {@code apacheignite/ignite:} as the Docker image. + */ + public IgniteContainer(String commitHash, Network net, String hostname, String consistentId, int idx) throws IOException { + super(DockerImageName.parse("apacheignite/ignite:" + commitHash)); + + 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; + + // Proxy-networking hosts (macOS/Windows) can't reach container-internal addresses, so each node advertises + // its host-published ports (127.0.0.1:hostPort) via ContainerAddressResolver. On Linux containers are + // directly routable and advertise their real address, so no override is needed. + if (!LINUX) { + 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); + + withFileSystemBind(LOCAL_WORK_DIR_PATH, WORK_DIR_PATH, BindMode.READ_WRITE); + 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())); + + // Proxy-networking hosts only: publish fixed host ports so the host JVM node can target each container at + // 127.0.0.1:. On Linux the host reaches containers at their bridge IP directly, so nothing is published. + if (!LINUX) { + 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: clean libs → graceful stop → swap libs → restart. */ + public void upgradeAndRestart() throws Exception { + LOGGER.info("Cleaning up old libs in container {}", hostname); + + ExecResult result = execInContainer("sh", "-c", "rm -f " + LIBS_DIR_PATH + "*"); + + if (result.getExitCode() != 0) + throw new IllegalStateException("Failed to clean libs: " + result.getStderr()); + + stopGraceful(); + + restartWithTargetLibs(TARGET_LIBS_DIR); + + assertTrue("Upgraded Docker node is not running", isRunning()); + } + + /** + * Stop the container gracefully without removing it (container stays in "Exited" state). + * Call this before {@link #restartWithTargetLibs(Path)}. + * + *

Uses {@code docker stop} (SIGTERM + wait + SIGKILL after timeout) via the Docker API. + * This gives Ignite time to flush persistence data, and falls back to SIGKILL if needed.

+ */ + private void stopGraceful() { + if (!isRunning()) + return; + + LOGGER.info("Graceful stop of node {}", hostname); + + getDockerClient().stopContainerCmd(getContainerId()) + .withTimeout(30) + .exec(); + + LOGGER.info("Node {} stopped", hostname); + } + + /** + * Restart the stopped container after swapping its {@code libs/} directory. + *

+ * Copies all jars from the provided host directory into the container's {@code /opt/ignite/apache-ignite/libs/}, + * then re-injects the test-classes jar and starts the container. + *

+ * + * @param targetLibsHostDir Host directory containing target-version jars. + */ + private void restartWithTargetLibs(Path targetLibsHostDir) throws Exception { + LOGGER.info("Replacing libs in container {} with jars from {}", hostname, targetLibsHostDir); + + try (Stream files = Files.list(targetLibsHostDir)) { + for (Path file : files.toArray(Path[]::new)) + if (Files.isRegularFile(file)) + copyFileToContainer(forHostPath(file.toAbsolutePath().toString()), LIBS_DIR_PATH + file.getFileName().toString()); + } + + // Re-inject the test-classes jar. + copyFileToContainer(forHostPath(testClassesJar().getAbsolutePath()), LIBS_DIR_PATH + "test-classes.jar"); + + 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); + } + + /** @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, 5_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 Address the host JVM uses to reach this container's {@code port}. */ + private String address(int port) { + return LINUX ? bridgeIp() + ":" + port : getHost() + ":" + getMappedPort(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(); + } + + /** @return This container's IP on the test Docker network (directly routable from the host on Linux). */ + private String bridgeIp() { + return network().getIpAddress(); + } + + /** @return This container's attachment to the single test Docker network. */ + private ContainerNetwork network() { + return getContainerInfo().getNetworkSettings().getNetworks().values().iterator().next(); + } + + /** */ + 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; + } + } +} 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 0000000000000..e826e5cbb7a48 --- /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) +@Suite.SuiteClasses({ + 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 0000000000000..28ebc10757018 --- /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 0000000000000..868fde3823604 --- /dev/null +++ b/modules/compatibility/src/test/resources/docker/build_docker_image.sh @@ -0,0 +1,172 @@ +#!/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 + +# 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 + } + 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" \ No newline at end of file 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 0000000000000..951856c61567b --- /dev/null +++ b/modules/compatibility/src/test/resources/docker/test-config.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + node1:47500 + node2:47500 + node3:47500 + + + + + + + + + + + + + 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 0000000000000..10d539531882b --- /dev/null +++ b/modules/compatibility/src/test/resources/log4j2-test.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + 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 eb868103ad1c2..a4b81d292fbb3 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 @@ LinkedHashSet getEffectiveNodeAddresses(TcpDiscoveryNode node // 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 4920d029538a6..102ee0cc76dc9 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.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 void testCheckRingLatency() throws Exception { } } + /** + * 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 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 @@ private Ignite startGridNoOptimize(int idx) throws Exception { 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 socketAddresses() { + List 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 507e6d27edf36..c263266e86f62 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -110,6 +110,7 @@ 5.3.39 3.5.6 10.0.27 + 2.0.5 0.8.3 3.9.5 1.5.7-8