From f0ffa5ca14ac141b6449c5640752fd57feeea0f1 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 30 Jul 2026 17:27:42 +0200 Subject: [PATCH 1/7] fix: rename 3.x driver-core CCM ITs to *Test.java so Surefire discovers them driver-core has no Failsafe plugin binding (only bound in driver-tests/osgi/*), and Surefire's default includes never match *IT.java, so these CCM integration tests were silently never executed by `mvn verify -Pshort`/`-Plong`. Renaming to *Test.java matches Surefire's default discovery pattern, mirroring the fix already applied to DriverConfigReportingCcmIT in #973. Renamed: TabletsIT, ZeroTokenNodesIT, LWTLoadBalancingIT, SchemaBuilderIT. Now that LWTLoadBalancingTest actually runs, it surfaced a real (previously undetected) bug: both test methods constructed a SimpleStatement with bound values and then passed it to session.prepare(), which rejects statements carrying values. Fixed by preparing the value-free statement and binding values only on the resulting PreparedStatement, as the tests already intended. All classes verified live against ScyllaDB 2026.1.0: Tablets (3), ZeroTokenNodes (7), and LWTLoadBalancing (2) tests pass. SchemaBuilderTest's 6 methods remain pre-existing enabled=false, unrelated to this fix. Fixes scylladb/java-driver#981. Co-Authored-By: Claude Sonnet 5 --- .../driver/core/{TabletsIT.java => TabletsTest.java} | 4 ++-- .../core/{ZeroTokenNodesIT.java => ZeroTokenNodesTest.java} | 2 +- .../{LWTLoadBalancingIT.java => LWTLoadBalancingTest.java} | 6 +++--- .../{SchemaBuilderIT.java => SchemaBuilderTest.java} | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename driver-core/src/test/java/com/datastax/driver/core/{TabletsIT.java => TabletsTest.java} (99%) rename driver-core/src/test/java/com/datastax/driver/core/{ZeroTokenNodesIT.java => ZeroTokenNodesTest.java} (99%) rename driver-core/src/test/java/com/datastax/driver/core/policies/{LWTLoadBalancingIT.java => LWTLoadBalancingTest.java} (97%) rename driver-core/src/test/java/com/datastax/driver/core/schemabuilder/{SchemaBuilderIT.java => SchemaBuilderTest.java} (99%) diff --git a/driver-core/src/test/java/com/datastax/driver/core/TabletsIT.java b/driver-core/src/test/java/com/datastax/driver/core/TabletsTest.java similarity index 99% rename from driver-core/src/test/java/com/datastax/driver/core/TabletsIT.java rename to driver-core/src/test/java/com/datastax/driver/core/TabletsTest.java index f3ecdd362c0..c77b959adf5 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/TabletsIT.java +++ b/driver-core/src/test/java/com/datastax/driver/core/TabletsTest.java @@ -24,8 +24,8 @@ }) @ScyllaOnly @ScyllaVersion(minOSS = "6.0.0", minEnterprise = "2024.2", description = "Needs to support tablets") -public class TabletsIT extends CCMTestsSupport { - private static final Logger LOG = LoggerFactory.getLogger(TabletsIT.class); +public class TabletsTest extends CCMTestsSupport { + private static final Logger LOG = LoggerFactory.getLogger(TabletsTest.class); private static final int INITIAL_TABLETS = 32; private static final int QUERIES = 1600; private static final int REPLICATION_FACTOR = 2; diff --git a/driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesIT.java b/driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesTest.java similarity index 99% rename from driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesIT.java rename to driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesTest.java index ebcf5bdf352..cb537f030c1 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesIT.java +++ b/driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesTest.java @@ -14,7 +14,7 @@ import org.testng.annotations.DataProvider; import org.testng.annotations.Test; -public class ZeroTokenNodesIT { +public class ZeroTokenNodesTest { @DataProvider(name = "loadBalancingPolicies") public static Object[][] loadBalancingPolicies() { diff --git a/driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingIT.java b/driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingTest.java similarity index 97% rename from driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingIT.java rename to driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingTest.java index eed462e1dc6..5c10d31c2c0 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingIT.java +++ b/driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingTest.java @@ -39,7 +39,7 @@ * through the LWT load-balancing path (PRESERVE_REPLICA_ORDER). */ @CCMConfig(numberOfNodes = 3) -public class LWTLoadBalancingIT extends CCMTestsSupport { +public class LWTLoadBalancingTest extends CCMTestsSupport { @Override public Cluster.Builder createClusterBuilder() { @@ -61,7 +61,7 @@ public void should_route_local_serial_select_through_lwt_path() { Session session = session(); SimpleStatement simpleSelect = - new SimpleStatement("SELECT * FROM test_lwt WHERE pk = ? AND ck = ?", 1, 0); + new SimpleStatement("SELECT * FROM test_lwt WHERE pk = ? AND ck = ?"); simpleSelect.setConsistencyLevel(ConsistencyLevel.LOCAL_SERIAL); PreparedStatement preparedSelect = session.prepare(simpleSelect); @@ -91,7 +91,7 @@ public void should_route_serial_select_through_lwt_path() { Session session = session(); SimpleStatement simpleSelect = - new SimpleStatement("SELECT * FROM test_lwt WHERE pk = ? AND ck = ?", 2, 0); + new SimpleStatement("SELECT * FROM test_lwt WHERE pk = ? AND ck = ?"); simpleSelect.setConsistencyLevel(ConsistencyLevel.SERIAL); PreparedStatement preparedSelect = session.prepare(simpleSelect); diff --git a/driver-core/src/test/java/com/datastax/driver/core/schemabuilder/SchemaBuilderIT.java b/driver-core/src/test/java/com/datastax/driver/core/schemabuilder/SchemaBuilderTest.java similarity index 99% rename from driver-core/src/test/java/com/datastax/driver/core/schemabuilder/SchemaBuilderIT.java rename to driver-core/src/test/java/com/datastax/driver/core/schemabuilder/SchemaBuilderTest.java index 1a32eb2d88d..9e1b696ad4e 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/schemabuilder/SchemaBuilderIT.java +++ b/driver-core/src/test/java/com/datastax/driver/core/schemabuilder/SchemaBuilderTest.java @@ -37,7 +37,7 @@ import java.util.Iterator; import org.testng.annotations.Test; -public class SchemaBuilderIT extends CCMTestsSupport { +public class SchemaBuilderTest extends CCMTestsSupport { // Test relies on existence of 'ks' keyspace, // but no such keyspace is created. If (fixed) created, From 11190888022535a76d04096f002046df927f9fac Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 30 Jul 2026 22:31:55 +0200 Subject: [PATCH 2/7] fix: don't pass Thrift interface flag to `ccm add` for Scylla clusters CCMBridge.add(int, int) unconditionally included `-t ` in the `ccm add` command, but scylla-ccm's `add` command has no Thrift option at all (Scylla never had a Thrift interface) -- passing it makes the whole command fail with "ccm: error: no such option: -t". Confirmed against scylladb/scylla-ccm's actual ClusterAddCmd parser (ccmlib/cmds/cluster_cmds.py, master). ZeroTokenNodesTest is the only caller of this method, and it never ran before the #981 rename fix, so this was never caught. All three "Scylla ITs" CI matrix legs on #982 failed with the identical error once the rename made the test actually execute. Verified locally against a venv with the real `scylla-ccm` (master, same as CI's `make install-scylla-ccm`) installed: all 7 ZeroTokenNodesTest methods pass, plus a full regression of the other 3 renamed classes (12/12). Co-Authored-By: Claude Sonnet 5 --- .../com/datastax/driver/core/CCMBridge.java | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java index 344678c8149..989cb34ac43 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java @@ -733,24 +733,38 @@ public void add(int n) { public void add(int dc, int n) { logger.debug( String.format("Adding: node %s (%s%s:%s) to %s", n, ipPrefix, n, binaryPort, this)); - String thriftItf = ipOfNode(n) + ":" + thriftPort; String storageItf = ipOfNode(n) + ":" + storagePort; String binaryItf = ipOfNode(n) + ":" + binaryPort; String remoteLogItf = ipOfNode(n) + ":" + TestUtils.findAvailablePort(); - execute( - CCM_COMMAND - + " add node%d -d dc%s -i %s%d -t %s -l %s --binary-itf %s -j %d -r %s -s -b" - + (isDSE ? " --dse" : "") - + (isScylla ? " --scylla" : ""), - n, - dc, - ipPrefix, - n, - thriftItf, - storageItf, - binaryItf, - TestUtils.findAvailablePort(), - remoteLogItf); + if (isScylla) { + // scylla-ccm's `add` command has no thrift option: Scylla never had a Thrift interface. + execute( + CCM_COMMAND + + " add node%d -d dc%s -i %s%d -l %s --binary-itf %s -j %d -r %s -s -b --scylla", + n, + dc, + ipPrefix, + n, + storageItf, + binaryItf, + TestUtils.findAvailablePort(), + remoteLogItf); + } else { + String thriftItf = ipOfNode(n) + ":" + thriftPort; + execute( + CCM_COMMAND + + " add node%d -d dc%s -i %s%d -t %s -l %s --binary-itf %s -j %d -r %s -s -b" + + (isDSE ? " --dse" : ""), + n, + dc, + ipPrefix, + n, + thriftItf, + storageItf, + binaryItf, + TestUtils.findAvailablePort(), + remoteLogItf); + } } @Override From 5b3588ebe0d49e7e4ca1254b703818bc3c5a3bec Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 31 Jul 2026 17:30:09 +0200 Subject: [PATCH 3/7] test: make the newly-discovered CCM tests actually assert something Review follow-up on #982. Renaming these classes made them execute for the first time, but several of their assertions could not fail. Each fix below was verified live against ScyllaDB 2026.1.0. TabletsTest, two independent false-passes: - `removeTableMappings(KEYSPACE_NAME)` passed the mixed-case "tabletsTest" while TabletMap keys arrive lowercased from the server and are matched with an exact equals(), so the "empty out tablets information" step silently cleared nothing and an iteration could be satisfied by state learned in a previous one. Lowercased, as the three sibling call sites already do. - `executeOnAllHostsAndReturnIfResultHasTabletsInfo` pins the statement via setHost() and never clears it, so checkIfRoutedProperly re-executed a pinned statement and always observed exactly one coordinator -- `nodes.size() <= REPLICATION_FACTOR` could not fail. The pin is now cleared before the routing check. - Additionally, checkIfRoutedProperly now clears Statement.getLastHost() per iteration. PagingOptimizingLoadBalancingPolicy returns that host ahead of the real query plan and PagingOptimizingLatencyTracker sets it after every successful BoundStatement execution, which pinned the loop to its first coordinator for the bound-statement half of the matrix. LWTLoadBalancingTest could not distinguish PRESERVE_REPLICA_ORDER from RANDOM, for two reasons: - The framework's default keyspace is hardcoded to RF=1, so "the first replica" was trivially unique and hasSize(1) held under REGULAR routing too. initTestKeyspace() is now overridden to create an RF=3 keyspace (tablets disabled on Scylla, as elsewhere for replica-placement tests), following SingleTokenIntegrationTest's template. - Both tests re-executed one BoundStatement instance, so the paging optimisation described above pinned the coordinator after the first query -- hasSize(1) was guaranteed by that, not by the LWT path. Coordinator collection now binds a fresh statement per execution. - Added should_spread_non_serial_select_across_replicas as the control: same statement, same table, same policy, non-serial consistency level, asserting the coordinator does vary. Verified that it fails (1 coordinator) if REPLICATION_FACTOR is dropped back to 1, so the two hasSize(1) assertions are now load-bearing. ZeroTokenNodesTest asserted on a HashSet with containsExactly, which is order-sensitive; HashSet iteration order is hash-derived, so this was a latent flake. Switched to containsOnly, already used everywhere else in the file (AssertJ 1.7.1, which this module pins, has no containsExactlyInAnyOrder; for a Set containsOnly is equivalent). CCMBridge derived the instance's `isScylla` from the global scylla.version property instead of the constructor's scyllaVersion, unlike the sibling `isDSE = dseVersion != null` one line above. Now derived from the instance. Behaviour-neutral today -- Builder.scylla already defaults to the global, withScylla() has no callers, and every bridge that is actually built takes build()'s !versionConfigured branch where scyllaVersion *is* the global -- so this removes a footgun rather than fixing a live bug. Verified: TabletsTest 3/3, ZeroTokenNodesTest 7/7, LWTLoadBalancingTest 3/3 against ScyllaDB 2026.1.0. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/datastax/driver/core/CCMBridge.java | 2 +- .../com/datastax/driver/core/TabletsTest.java | 18 ++- .../driver/core/ZeroTokenNodesTest.java | 9 +- .../core/policies/LWTLoadBalancingTest.java | 116 +++++++++++++++--- 4 files changed, 120 insertions(+), 25 deletions(-) diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java index 989cb34ac43..1012033ffd4 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java @@ -430,7 +430,7 @@ protected CCMBridge( this.thriftPort = thriftPort; this.binaryPort = binaryPort; this.isDSE = dseVersion != null; - this.isScylla = (getGlobalScyllaVersion() != null); + this.isScylla = (scyllaVersion != null); this.jvmArgs = jvmArgs; this.nodes = nodes; this.ccmDir = Files.createTempDir(); diff --git a/driver-core/src/test/java/com/datastax/driver/core/TabletsTest.java b/driver-core/src/test/java/com/datastax/driver/core/TabletsTest.java index c77b959adf5..4b4694ed2fe 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/TabletsTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/TabletsTest.java @@ -190,8 +190,13 @@ public void every_statement_should_deliver_tablet_info() { continue; } Session session = sessionEntry.getValue().get(); - // Empty out tablets information - session.getCluster().getMetadata().getTabletMap().removeTableMappings(KEYSPACE_NAME); + // Empty out tablets information. The mapping is keyed by the lowercased keyspace name, as + // reported by the server, so the key has to be lowercased here too or this is a no-op. + session + .getCluster() + .getMetadata() + .getTabletMap() + .removeTableMappings(KEYSPACE_NAME.toLowerCase()); Statement stmt; try { stmt = stmtEntry.getValue().apply(session); @@ -226,6 +231,10 @@ public void every_statement_should_deliver_tablet_info() { stmtEntry.getKey(), sessionEntry.getKey())); continue; } + // executeOnAllHostsAndReturnIfResultHasTabletsInfo pins the statement to a specific host + // while hunting for tablet info. Clear that pin, otherwise the routing check below always + // observes the pinned host and can never detect misrouting. + stmt.setHost(null); if (!checkIfRoutedProperly(session, stmt)) { testErrors.add( String.format( @@ -343,6 +352,11 @@ private static boolean checkIfRoutedProperly(Session session, Statement stmt) { int expectedNodesCount = stmt.isLWT() ? 1 : REPLICATION_FACTOR; Set nodes = new HashSet<>(); for (int i = 0; i < REPLICATION_FACTOR * 3; i++) { + // PagingOptimizingLoadBalancingPolicy returns Statement.getLastHost() ahead of the real query + // plan, and that field is set after every successful BoundStatement execution. Clearing it + // keeps the loop from being pinned to the first coordinator, which would let any routing + // behaviour satisfy the check below. + stmt.setLastHost(null); nodes.add(session.execute(stmt).getExecutionInfo().getQueriedHost()); } return nodes.size() <= expectedNodesCount; diff --git a/driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesTest.java b/driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesTest.java index cb537f030c1..26c0041303e 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/ZeroTokenNodesTest.java @@ -173,12 +173,17 @@ public void should_discover_zero_token_DC_when_option_is_enabled( queriedNodes.add(rs.getExecutionInfo().getQueriedHost().getEndPoint().resolve()); } + // containsOnly, not containsExactly: queriedNodes is a HashSet, whose iteration order is + // hash-derived rather than insertion order, so an order-sensitive assertion is a latent + // flake. + // AssertJ 1.7.1, pinned by this module, has no containsExactlyInAnyOrder; for a Set, + // containsOnly is equivalent to it. if (isDcAware) { assertThat(queriedNodes) - .containsExactly(ccmBridge.addressOfNode(1), ccmBridge.addressOfNode(2)); + .containsOnly(ccmBridge.addressOfNode(1), ccmBridge.addressOfNode(2)); } else { assertThat(queriedNodes) - .containsExactly( + .containsOnly( ccmBridge.addressOfNode(1), ccmBridge.addressOfNode(2), ccmBridge.addressOfNode(3), diff --git a/driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingTest.java b/driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingTest.java index 5c10d31c2c0..f9eb78bc544 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/policies/LWTLoadBalancingTest.java @@ -29,9 +29,14 @@ import com.datastax.driver.core.ResultSet; import com.datastax.driver.core.Session; import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.TestUtils; +import com.google.common.base.Throwables; import java.net.InetSocketAddress; import java.util.HashSet; +import java.util.Objects; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.testng.annotations.Test; /** @@ -41,6 +46,13 @@ @CCMConfig(numberOfNodes = 3) public class LWTLoadBalancingTest extends CCMTestsSupport { + private static final Logger LOGGER = LoggerFactory.getLogger(LWTLoadBalancingTest.class); + + /** Equal to the node count, so that every node is a replica of every partition. */ + private static final int REPLICATION_FACTOR = 3; + + private static final int EXECUTIONS = 30; + @Override public Cluster.Builder createClusterBuilder() { return Cluster.builder() @@ -48,6 +60,40 @@ public Cluster.Builder createClusterBuilder() { new TokenAwarePolicy(new RoundRobinPolicy(), TokenAwarePolicy.ReplicaOrdering.RANDOM)); } + /** + * Override to create the keyspace with a replication factor greater than 1. The default test + * keyspace created by {@link CCMTestsSupport} is hardcoded to RF=1, and with a single replica per + * partition "the first replica" is trivially unique — every assertion below would hold under + * {@code REGULAR} routing too, so the tests could not tell {@code PRESERVE_REPLICA_ORDER} apart + * from {@code RANDOM}. + * + *

Tablets are disabled when running against Scylla: with tablets enabled, replica placement + * comes from the tablet map, which is empty until it has been learned from a misrouted query, and + * an empty replica list makes the LWT query plan fall back to the child policy — a non-replica + * coordinator on the first execution. Cassandra does not support the tablets property. + */ + @Override + protected void initTestKeyspace() { + try { + keyspace = TestUtils.generateIdentifier("ks_"); + LOGGER.debug("Using keyspace " + keyspace); + boolean isScylla = Objects.nonNull(ccm().getScyllaVersion()); + session() + .execute( + String.format( + "CREATE KEYSPACE %s WITH replication = {'class': 'NetworkTopologyStrategy'," + + " 'datacenter1': %d}" + + (isScylla ? " AND tablets = {'enabled': false}" : ""), + keyspace, + REPLICATION_FACTOR)); + useKeyspace(keyspace); + } catch (Exception e) { + errorOut(); + LOGGER.error("Could not create test keyspace", e); + Throwables.propagate(e); + } + } + @Override public void onTestContextInitialized() { execute("CREATE TABLE IF NOT EXISTS test_lwt (pk int, ck int, v int, PRIMARY KEY (pk, ck))"); @@ -65,25 +111,15 @@ public void should_route_local_serial_select_through_lwt_path() { simpleSelect.setConsistencyLevel(ConsistencyLevel.LOCAL_SERIAL); PreparedStatement preparedSelect = session.prepare(simpleSelect); - BoundStatement boundSelect = preparedSelect.bind(1, 0); // Verify statement properties assertThat(simpleSelect.isLWT()).isFalse(); assertThat(simpleSelect.getConsistencyLevel()).isEqualTo(ConsistencyLevel.LOCAL_SERIAL); - // Execute multiple times and collect coordinators — with PRESERVE_REPLICA_ORDER routing, - // the same partition key should always be routed to the same first replica. - Set coordinators = new HashSet<>(); - for (int i = 0; i < 30; i++) { - ResultSet rs = session.execute(boundSelect); - Host coordinator = rs.getExecutionInfo().getQueriedHost(); - assertThat(coordinator).isNotNull(); - coordinators.add(coordinator.getEndPoint().resolve()); - } - // With PRESERVE_REPLICA_ORDER, the first replica is deterministic for a given partition key, - // so all 30 executions should hit the same coordinator. - assertThat(coordinators).hasSize(1); + // so every execution should hit the same coordinator. Contrast with the non-serial control in + // should_spread_non_serial_select_across_replicas, which shares the same statement and policy. + assertThat(collectCoordinators(session, preparedSelect, 1)).hasSize(1); } @Test(groups = "short") @@ -95,18 +131,58 @@ public void should_route_serial_select_through_lwt_path() { simpleSelect.setConsistencyLevel(ConsistencyLevel.SERIAL); PreparedStatement preparedSelect = session.prepare(simpleSelect); - BoundStatement boundSelect = preparedSelect.bind(2, 0); - // Execute multiple times and collect coordinators + // With PRESERVE_REPLICA_ORDER, the first replica is deterministic for a given partition key. + assertThat(collectCoordinators(session, preparedSelect, 2)).hasSize(1); + } + + /** + * Control for the two tests above. This is the same statement against the same table, executed by + * the same {@code TokenAwarePolicy(RoundRobinPolicy, RANDOM)} — only the consistency level + * differs. A non-serial level takes the {@code REGULAR} routing path, which shuffles the replicas + * on every query, so the coordinator must vary. If this test ever collapses to a single + * coordinator as well, the {@code hasSize(1)} assertions above have stopped proving anything + * about {@code PRESERVE_REPLICA_ORDER}. + */ + @Test(groups = "short") + public void should_spread_non_serial_select_across_replicas() { + Session session = session(); + + SimpleStatement simpleSelect = + new SimpleStatement("SELECT * FROM test_lwt WHERE pk = ? AND ck = ?"); + simpleSelect.setConsistencyLevel(ConsistencyLevel.ONE); + + PreparedStatement preparedSelect = session.prepare(simpleSelect); + BoundStatement boundSelect = preparedSelect.bind(3, 0); + + assertThat(boundSelect.isLWT()).isFalse(); + assertThat(boundSelect.getConsistencyLevel().isSerial()).isFalse(); + + // Uniform over REPLICATION_FACTOR replicas across EXECUTIONS queries, so the probability of a + // false failure here is REPLICATION_FACTOR^(1 - EXECUTIONS). + assertThat(collectCoordinators(session, preparedSelect, 3).size()).isGreaterThan(1); + } + + /** + * Executes {@code prepared} against partition {@code pk} {@link #EXECUTIONS} times and returns + * the distinct coordinators used. + * + *

A fresh {@link BoundStatement} is bound for every execution on purpose. {@link + * PagingOptimizingLoadBalancingPolicy}, which the driver wraps around the configured policy, + * returns {@code Statement.getLastHost()} ahead of the real query plan, and that field is set on + * every successful {@code BoundStatement} execution. Reusing a single instance would therefore + * pin the coordinator after the first query and make every assertion in this class hold + * regardless of how routing actually behaves. + */ + private static Set collectCoordinators( + Session session, PreparedStatement prepared, int pk) { Set coordinators = new HashSet<>(); - for (int i = 0; i < 30; i++) { - ResultSet rs = session.execute(boundSelect); + for (int i = 0; i < EXECUTIONS; i++) { + ResultSet rs = session.execute(prepared.bind(pk, 0)); Host coordinator = rs.getExecutionInfo().getQueriedHost(); assertThat(coordinator).isNotNull(); coordinators.add(coordinator.getEndPoint().resolve()); } - - // With PRESERVE_REPLICA_ORDER, the first replica is deterministic for a given partition key. - assertThat(coordinators).hasSize(1); + return coordinators; } } From 099a169abf3b42c3cb462b55298c5ef9341949ab Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 31 Jul 2026 18:49:41 +0200 Subject: [PATCH 4/7] test: pass --scylla to `ccm create` when Scylla mode is explicit `CCMBridge.Builder.build()` resolves an explicitly configured version into a Scylla version, a DSE version or a Cassandra version, but `buildCreateCommand()` only ever received the Cassandra and DSE ones. For Scylla the Cassandra version is hardcoded to 3.0.8 (what Scylla reports in `system.local`), so `withScylla(true).withVersion(...)` silently created an Apache Cassandra 3.0.8 cluster, while `add()` and `getScyllaVersion()` treated it as Scylla. Pass the whole resolution to `buildCreateCommand()` and emit `--scylla -v release:`, the same shape the globally configured Scylla version already produces. The version resolution moves into `resolveVersions()` so it can be asserted without starting a cluster. The two remaining Scylla checks that have a per-instance equivalent (`jmxAddressOfNode()` and the Scylla-only yaml ports) now use it instead of the global version. `withSSL()`/`withAuth()` keep reading the global one: they run before the flavor is resolved. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/datastax/driver/core/CCMBridge.java | 91 ++++++++++++------- .../core/CCMBridgeCreateCommandTest.java | 72 +++++++++++++++ 2 files changed, 131 insertions(+), 32 deletions(-) create mode 100644 driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java index 1012033ffd4..77a0f8a99ea 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java @@ -480,7 +480,7 @@ public InetSocketAddress addressOfNode(int n) { @Override public InetSocketAddress jmxAddressOfNode(int n) { - if (GLOBAL_SCYLLA_VERSION_NUMBER != null) { + if (isScylla) { return new InetSocketAddress(ipOfNode(n), jmxPorts[n - 1]); } else { return new InetSocketAddress("localhost", jmxPorts[n - 1]); @@ -1186,37 +1186,60 @@ public Builder withWorkload(int node, Workload... workload) { return this; } - public CCMBridge build() { - // be careful NOT to alter internal state (hashCode/equals) during build! - String clusterName = TestUtils.generateIdentifier("ccm_"); - - if (providedClusterName != null) clusterName = providedClusterName; + /** The server versions this builder's configuration resolves to. */ + static class ResolvedVersions { + final boolean versionConfigured; + final VersionNumber cassandra; + final VersionNumber dse; + final VersionNumber scylla; + + ResolvedVersions( + boolean versionConfigured, + VersionNumber cassandra, + VersionNumber dse, + VersionNumber scylla) { + this.versionConfigured = versionConfigured; + this.cassandra = cassandra; + this.dse = dse; + this.scylla = scylla; + } + } - VersionNumber dseVersion; - VersionNumber cassandraVersion; - VersionNumber scyllaVersion; + /** + * Resolves which flavor and version this builder will create, from the explicitly configured + * version (if any) and the globally configured defaults. + */ + ResolvedVersions resolveVersions() { boolean versionConfigured = this.version != null; // No version was explicitly provided, fallback on global config. if (!versionConfigured) { - scyllaVersion = GLOBAL_SCYLLA_VERSION_NUMBER; - dseVersion = GLOBAL_DSE_VERSION_NUMBER; - cassandraVersion = GLOBAL_CASSANDRA_VERSION_NUMBER; + return new ResolvedVersions( + false, + GLOBAL_CASSANDRA_VERSION_NUMBER, + GLOBAL_DSE_VERSION_NUMBER, + GLOBAL_SCYLLA_VERSION_NUMBER); } else if (dse) { // given version is the DSE version, base cassandra version on DSE version. - scyllaVersion = null; - dseVersion = this.version; - cassandraVersion = getCassandraVersion(dseVersion); + return new ResolvedVersions(true, getCassandraVersion(this.version), this.version, null); } else if (scylla) { - scyllaVersion = this.version; - dseVersion = null; // Versions from 5.1 to 6.2.0 seem to report release_version 3.0.8 in system_local - cassandraVersion = VersionNumber.parse("3.0.8"); + return new ResolvedVersions(true, VersionNumber.parse("3.0.8"), null, this.version); } else { // given version is cassandra version. - scyllaVersion = null; - dseVersion = null; - cassandraVersion = this.version; + return new ResolvedVersions(true, this.version, null, null); } + } + + public CCMBridge build() { + // be careful NOT to alter internal state (hashCode/equals) during build! + String clusterName = TestUtils.generateIdentifier("ccm_"); + + if (providedClusterName != null) clusterName = providedClusterName; + + ResolvedVersions versions = resolveVersions(); + VersionNumber dseVersion = versions.dse; + VersionNumber cassandraVersion = versions.cassandra; + VersionNumber scyllaVersion = versions.scylla; Map cassandraConfiguration = randomizePorts(this.cassandraConfiguration); int storagePort = Integer.parseInt(cassandraConfiguration.get("storage_port").toString()); @@ -1255,7 +1278,7 @@ public CCMBridge build() { cassandraConfiguration.put("enable_sasi_indexes", true); } } - if (GLOBAL_SCYLLA_VERSION_NUMBER != null) { + if (scyllaVersion != null) { cassandraConfiguration.put("prometheus_port", RANDOM_PORT); cassandraConfiguration.put("api_port", RANDOM_PORT); cassandraConfiguration.put("native_shard_aware_transport_port", RANDOM_PORT); @@ -1283,7 +1306,7 @@ public void run() { ccm.close(); } }); - ccm.execute(buildCreateCommand(clusterName, versionConfigured, cassandraVersion, dseVersion)); + ccm.execute(buildCreateCommand(clusterName, versions)); updateNodeConf(ccm); ccm.updateConfig(cassandraConfiguration); if (dseVersion != null) { @@ -1347,11 +1370,7 @@ private String joinJvmArgs() { return allJvmArgs.toString(); } - private String buildCreateCommand( - String clusterName, - boolean versionConfigured, - VersionNumber cassandraVersion, - VersionNumber dseVersion) { + String buildCreateCommand(String clusterName, ResolvedVersions versions) { StringBuilder result = new StringBuilder(CCM_COMMAND + " create"); result.append(" ").append(clusterName); result.append(" -i ").append(ipPrefix); @@ -1366,17 +1385,25 @@ private String buildCreateCommand( } Set lCreateOptions = new LinkedHashSet(createOptions); - if (!versionConfigured) { + if (!versions.versionConfigured) { // If no version was provided, use the default install ags. lCreateOptions.addAll(CASSANDRA_INSTALL_ARGS); } else { - if (dseVersion != null) { + if (versions.dse != null) { lCreateOptions.add("--dse"); lCreateOptions.add("-v"); - lCreateOptions.add(dseVersion.toString()); + lCreateOptions.add(versions.dse.toString()); + } else if (versions.scylla != null) { + // Same shape as the Scylla entries of CASSANDRA_INSTALL_ARGS. Note that + // SCYLLA_PRODUCT is only derived from the globally configured version: the + // environment is a static map shared by every cluster, so an explicitly + // configured enterprise version still installs from the OSS repository. + lCreateOptions.add("--scylla"); + lCreateOptions.add("-v"); + lCreateOptions.add("release:" + versions.scylla); } else { lCreateOptions.add("-v"); - lCreateOptions.add(cassandraVersion.toString()); + lCreateOptions.add(versions.cassandra.toString()); } } result.append(" ").append(Joiner.on(" ").join(randomizePorts(lCreateOptions))); diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java new file mode 100644 index 00000000000..36c960e9559 --- /dev/null +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java @@ -0,0 +1,72 @@ +package com.datastax.driver.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.driver.core.CCMBridge.Builder.ResolvedVersions; +import org.testng.annotations.Test; + +/** + * Unit tests for the part of {@link CCMBridge.Builder} that decides which server flavor and version + * to install. No CCM cluster is created. + * + *

Each test configures the flavor explicitly, so that it doesn't depend on the {@code + * scylla.version} / {@code dse} system properties of the surrounding run. + */ +public class CCMBridgeCreateCommandTest { + + @Test(groups = "unit") + public void should_create_scylla_cluster_when_scylla_version_configured() { + CCMBridge.Builder builder = + CCMBridge.builder() + .withDSE(false) + .withScylla(true) + .withVersion(VersionNumber.parse("2026.1.0")); + + ResolvedVersions versions = builder.resolveVersions(); + assertThat(versions.scylla).isEqualTo(VersionNumber.parse("2026.1.0")); + assertThat(versions.cassandra).isEqualTo(VersionNumber.parse("3.0.8")); + assertThat(versions.dse).isNull(); + + String command = builder.buildCreateCommand("test_cluster", versions); + assertThat(command).contains("--scylla").contains("-v release:2026.1.0"); + assertThat(command).doesNotContain("--dse"); + // 3.0.8 is only what Scylla reports in system.local, it is never an install target + assertThat(command).doesNotContain("3.0.8"); + } + + @Test(groups = "unit") + public void should_create_cassandra_cluster_when_cassandra_version_configured() { + CCMBridge.Builder builder = + CCMBridge.builder() + .withDSE(false) + .withScylla(false) + .withVersion(VersionNumber.parse("4.1.3")); + + ResolvedVersions versions = builder.resolveVersions(); + assertThat(versions.cassandra).isEqualTo(VersionNumber.parse("4.1.3")); + assertThat(versions.dse).isNull(); + assertThat(versions.scylla).isNull(); + + String command = builder.buildCreateCommand("test_cluster", versions); + assertThat(command).contains("-v 4.1.3"); + assertThat(command).doesNotContain("--scylla").doesNotContain("--dse"); + } + + @Test(groups = "unit") + public void should_create_dse_cluster_when_dse_version_configured() { + CCMBridge.Builder builder = + CCMBridge.builder() + .withDSE(true) + .withScylla(false) + .withVersion(VersionNumber.parse("6.8.0")); + + ResolvedVersions versions = builder.resolveVersions(); + assertThat(versions.dse).isEqualTo(VersionNumber.parse("6.8.0")); + assertThat(versions.cassandra).isNotNull(); + assertThat(versions.scylla).isNull(); + + String command = builder.buildCreateCommand("test_cluster", versions); + assertThat(command).contains("--dse").contains("-v 6.8.0"); + assertThat(command).doesNotContain("--scylla"); + } +} From 9f34037f3b797391071dd534eea1771fe336995d Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 31 Jul 2026 19:17:17 +0200 Subject: [PATCH 5/7] test: derive SCYLLA_PRODUCT from the version each CCM cluster installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SCYLLA_PRODUCT` was put into `ENVIRONMENT_MAP` once, in the static initializer, from the global `scylla.version` property. Since that map is immutable and shared by every cluster, a builder configuring its own Scylla version got the wrong repository in both directions: an explicit Enterprise version installed from the OSS repository, and an explicit OSS version inherited `SCYLLA_PRODUCT=enterprise` from a global Enterprise run. Split the map into a flavor-independent `BASE_ENVIRONMENT_MAP` plus a per-cluster environment derived from `ResolvedVersions`, threaded through the `CCMBridge` instance so every ccm command it issues sees it. The globally configured path keeps `ENVIRONMENT_MAP` verbatim: that one is derived from the raw property string, which may be a branch spec whose resolved version number looks like an Enterprise one without being installed as such. Not reachable from CI — `withScylla()` has no callers and no short-group test configures a version while creating a cluster — so this is footgun removal rather than a live bug fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/datastax/driver/core/CCMBridge.java | 88 ++++++++++++++++--- .../core/CCMBridgeCreateCommandTest.java | 42 ++++++++- 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java index 77a0f8a99ea..bd78fd28634 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java @@ -125,9 +125,19 @@ public class CCMBridge implements CCMAccess { *

At times it is necessary to use a separate java install for CCM then what is being used for * running tests. For example, if you want to run tests with JDK 6 but against Cassandra 2.0, * which requires JDK 7. + * + *

This is the environment for the globally configured server version; a cluster that + * configures its own version gets one derived from {@link #BASE_ENVIRONMENT_MAP} instead, see + * {@link Builder#buildEnvironmentMap(Builder.ResolvedVersions)}. */ private static final Map ENVIRONMENT_MAP; + /** + * {@link #ENVIRONMENT_MAP} without the variables that depend on which server flavor and version + * is installed, i.e. everything a cluster configuring its own version can reuse. + */ + private static final Map BASE_ENVIRONMENT_MAP; + /** * A mapping of full DSE versions to their C* counterpart. This is not meant to be comprehensive. * If C* version cannot be derived, the method makes a 'best guess'. @@ -200,6 +210,7 @@ public class CCMBridge implements CCMAccess { String branch = System.getProperty("cassandra.branch"); // Inherit the current environment. Map envMap = Maps.newHashMap(new ProcessBuilder().environment()); + boolean globalScyllaEnterprise = false; ImmutableSet.Builder installArgs = ImmutableSet.builder(); if (installDirectory != null && !installDirectory.trim().isEmpty()) { installArgs.add("--install-dir=" + new File(installDirectory).getAbsolutePath()); @@ -212,11 +223,7 @@ public class CCMBridge implements CCMAccess { } else { installArgs.add("-v " + inputScyllaVersion); } - // Detect Scylla Enterprise - it should start with - // a 4-digit year. - if (inputScyllaVersion.matches("\\d{4}\\..*")) { - envMap.put("SCYLLA_PRODUCT", "enterprise"); - } + globalScyllaEnterprise = isScyllaEnterpriseVersion(inputScyllaVersion); } else if (inputCassandraVersion != null && !inputCassandraVersion.trim().isEmpty()) { installArgs.add("-v " + inputCassandraVersion); } @@ -248,7 +255,9 @@ public class CCMBridge implements CCMAccess { if (ccmJavaHome != null) { envMap.put("JAVA_HOME", ccmJavaHome); } - ENVIRONMENT_MAP = ImmutableMap.copyOf(envMap); + BASE_ENVIRONMENT_MAP = ImmutableMap.copyOf(envMap); + ENVIRONMENT_MAP = + globalScyllaEnterprise ? withScyllaEnterprise(BASE_ENVIRONMENT_MAP) : BASE_ENVIRONMENT_MAP; if (isDse()) { GLOBAL_DSE_VERSION_NUMBER = VersionNumber.parse(inputCassandraVersion); @@ -348,6 +357,23 @@ public static boolean isWindows() { return osName != null && osName.startsWith("Windows"); } + /** + * Scylla Enterprise versions start with a 4-digit year (e.g. 2026.1.0), OSS ones don't. CCM + * installs them from a different repository, selected with the {@code SCYLLA_PRODUCT} variable. + */ + private static boolean isScyllaEnterpriseVersion(String versionString) { + return versionString != null && versionString.matches("\\d{4}\\..*"); + } + + /** Adds the CCM variable that makes Scylla install from the Enterprise repository. */ + private static Map withScyllaEnterprise(Map environmentMap) { + // Not an ImmutableMap.Builder: the inherited environment may already define the variable, and + // duplicate keys would make build() throw. + Map envMap = Maps.newHashMap(environmentMap); + envMap.put("SCYLLA_PRODUCT", "enterprise"); + return ImmutableMap.copyOf(envMap); + } + private static boolean isVersionNumber(String versionString) { try { VersionNumber.parse(versionString); @@ -408,6 +434,9 @@ private static VersionNumber parseScyllaInputVersion(String versionString) { private final int[] jmxPorts; + /** The environment to use for this cluster's CCM commands, see {@link #ENVIRONMENT_MAP}. */ + private final Map environmentMap; + protected CCMBridge( String clusterName, VersionNumber cassandraVersion, @@ -419,7 +448,8 @@ protected CCMBridge( int binaryPort, int[] jmxPorts, String jvmArgs, - int[] nodes) { + int[] nodes, + Map environmentMap) { this.clusterName = clusterName; this.cassandraVersion = cassandraVersion; @@ -435,6 +465,7 @@ protected CCMBridge( this.nodes = nodes; this.ccmDir = Files.createTempDir(); this.jmxPorts = jmxPorts; + this.environmentMap = environmentMap; } public static Builder builder() { @@ -851,7 +882,19 @@ private static VersionNumber getScyllaVersionThroughCcm(String versionString) { } } + /** + * Runs a CCM command with the environment of the globally configured server version. + * + *

Note that {@link #getScyllaVersionThroughCcm(String)} reaches this from the static + * initializer, before that environment is assigned; commons-exec then inherits this process's + * environment as-is. + */ private static String execute(File ccmDir, String command, Object... args) { + return execute(ccmDir, ENVIRONMENT_MAP, command, args); + } + + private static String execute( + File ccmDir, Map environmentMap, String command, Object... args) { Logger logger = CCMBridge.logger; String fullCommand = String.format(command, args) + " --config-dir=" + ccmDir; Closer closer = Closer.create(); @@ -887,7 +930,7 @@ protected void processLine(String line, int logLevel) { ExecuteStreamHandler streamHandler = new PumpStreamHandler(outStream, errStream); executor.setStreamHandler(streamHandler); executor.setWatchdog(watchDog); - int retValue = executor.execute(cli, ENVIRONMENT_MAP); + int retValue = executor.execute(cli, environmentMap); if (retValue != 0) { logger.error( "Non-zero exit code ({}) returned from executing ccm command: {}", @@ -917,7 +960,7 @@ protected void processLine(String line, int logLevel) { } private String execute(String command, Object... args) { - return execute(this.ccmDir, command, args); + return execute(this.ccmDir, this.environmentMap, command, args); } /** @@ -1296,7 +1339,8 @@ public CCMBridge build() { binaryPort, generatedJmxPorts, joinJvmArgs(), - nodes); + nodes, + buildEnvironmentMap(versions)); Runtime.getRuntime() .addShutdownHook( @@ -1394,10 +1438,8 @@ String buildCreateCommand(String clusterName, ResolvedVersions versions) { lCreateOptions.add("-v"); lCreateOptions.add(versions.dse.toString()); } else if (versions.scylla != null) { - // Same shape as the Scylla entries of CASSANDRA_INSTALL_ARGS. Note that - // SCYLLA_PRODUCT is only derived from the globally configured version: the - // environment is a static map shared by every cluster, so an explicitly - // configured enterprise version still installs from the OSS repository. + // Same shape as the Scylla entries of CASSANDRA_INSTALL_ARGS. Which repository this + // installs from is decided by buildEnvironmentMap. lCreateOptions.add("--scylla"); lCreateOptions.add("-v"); lCreateOptions.add("release:" + versions.scylla); @@ -1410,6 +1452,24 @@ String buildCreateCommand(String clusterName, ResolvedVersions versions) { return result.toString(); } + /** + * The environment for the CCM commands of a cluster with these versions. + * + *

{@code SCYLLA_PRODUCT} has to follow the version this particular cluster installs, not the + * globally configured one: otherwise an explicitly configured Enterprise version would install + * from the OSS repository (and vice-versa). + */ + static Map buildEnvironmentMap(ResolvedVersions versions) { + if (!versions.versionConfigured) { + // The global environment is already derived from the same version. + return ENVIRONMENT_MAP; + } else if (versions.scylla != null && isScyllaEnterpriseVersion(versions.scylla.toString())) { + return withScyllaEnterprise(BASE_ENVIRONMENT_MAP); + } else { + return BASE_ENVIRONMENT_MAP; + } + } + /** * This is a workaround for an oddity in CCM: when we create a cluster with -n option and * non-standard ports, the node.conf files are not updated accordingly. diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java index 36c960e9559..5513788e26a 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java @@ -7,7 +7,8 @@ /** * Unit tests for the part of {@link CCMBridge.Builder} that decides which server flavor and version - * to install. No CCM cluster is created. + * to install, i.e. the {@code ccm create} command and the environment it runs in. No CCM cluster is + * created. * *

Each test configures the flavor explicitly, so that it doesn't depend on the {@code * scylla.version} / {@code dse} system properties of the surrounding run. @@ -32,6 +33,25 @@ public void should_create_scylla_cluster_when_scylla_version_configured() { assertThat(command).doesNotContain("--dse"); // 3.0.8 is only what Scylla reports in system.local, it is never an install target assertThat(command).doesNotContain("3.0.8"); + + // 2026.1.0 is an Enterprise version, it must not be installed from the OSS repository + assertThat(CCMBridge.Builder.buildEnvironmentMap(versions)) + .containsEntry("SCYLLA_PRODUCT", "enterprise"); + } + + @Test(groups = "unit") + public void should_not_use_enterprise_repository_for_open_source_scylla_version() { + CCMBridge.Builder builder = + CCMBridge.builder() + .withDSE(false) + .withScylla(true) + .withVersion(VersionNumber.parse("6.2.0")); + + ResolvedVersions versions = builder.resolveVersions(); + assertThat(versions.scylla).isEqualTo(VersionNumber.parse("6.2.0")); + + // Would leak in from a `-Dscylla.version=.` run if the product was global + assertThat(CCMBridge.Builder.buildEnvironmentMap(versions)).doesNotContainKey("SCYLLA_PRODUCT"); } @Test(groups = "unit") @@ -50,6 +70,26 @@ public void should_create_cassandra_cluster_when_cassandra_version_configured() String command = builder.buildCreateCommand("test_cluster", versions); assertThat(command).contains("-v 4.1.3"); assertThat(command).doesNotContain("--scylla").doesNotContain("--dse"); + + assertThat(CCMBridge.Builder.buildEnvironmentMap(versions)).doesNotContainKey("SCYLLA_PRODUCT"); + } + + /** + * The globally configured version keeps the environment built for it in the static initializer: + * that one is derived from the raw {@code scylla.version} string, which may be a branch spec + * whose resolved version number looks like an Enterprise one without being installed as such. + */ + @Test(groups = "unit") + public void should_use_global_environment_when_no_version_configured() { + VersionNumber cassandra = VersionNumber.parse("3.0.8"); + VersionNumber enterpriseScylla = VersionNumber.parse("2026.1.0"); + + assertThat( + CCMBridge.Builder.buildEnvironmentMap( + new ResolvedVersions(false, cassandra, null, enterpriseScylla))) + .isSameAs( + CCMBridge.Builder.buildEnvironmentMap( + new ResolvedVersions(false, cassandra, null, null))); } @Test(groups = "unit") From 58bf916fc6aff9aa9381fd511025f0b65f1ed2ab Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 31 Jul 2026 20:05:56 +0200 Subject: [PATCH 6/7] test: resolve CCM client encryption and SCYLLA_PRODUCT per cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two remaining reads of global state from per-instance code paths in CCMBridge, both in the same family as the previous rounds. `BASE_ENVIRONMENT_MAP` copied the process environment verbatim, so a SCYLLA_PRODUCT inherited from the surrounding shell reached every cluster that configures its own version — installing an explicitly configured OSS version from the Enterprise repository. It is now stripped there, leaving `withScyllaEnterprise` as the only way the variable is set for a version actually resolved as Enterprise. The globally configured version keeps the inherited value: a non-numeric `scylla.version` (a branch spec) can't be recognised as Enterprise, and exporting the variable is the only way to select the repository in that case. `withSSL()`/`withAuth()` picked JKS keystore vs PEM certificate settings from the global `scylla.version` at builder-configuration time, before the flavor is resolved, so an explicitly versioned cluster got the other flavor's TLS yaml in both directions. They now only record the request; `buildClientEncryptionOptions(ResolvedVersions)` derives the yaml at build time, sibling to `buildCreateCommand` and `buildEnvironmentMap`. Explicitly configured entries still win over these defaults, as they did when `withCassandraConfiguration()` ran after `withSSL()`. Because ssl/auth no longer show up in `cassandraConfiguration` at configuration time, `Builder.equals`/`hashCode` have to compare them directly — `CCMCache` keys cached clusters on the builder and would otherwise hand an encrypted cluster to a test that asked for a plaintext one. `hashCode` also picks up `scylla`, which `equals` already compared. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/datastax/driver/core/CCMBridge.java | 112 +++++++++++++----- .../core/CCMBridgeCreateCommandTest.java | 88 +++++++++++++- 2 files changed, 168 insertions(+), 32 deletions(-) diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java index bd78fd28634..1571200bc27 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java @@ -134,7 +134,9 @@ public class CCMBridge implements CCMAccess { /** * {@link #ENVIRONMENT_MAP} without the variables that depend on which server flavor and version - * is installed, i.e. everything a cluster configuring its own version can reuse. + * is installed, i.e. everything a cluster configuring its own version can reuse. {@code + * SCYLLA_PRODUCT} is stripped even if the surrounding process defined it, so that it can only + * ever be re-added for a version that was actually resolved as Enterprise. */ private static final Map BASE_ENVIRONMENT_MAP; @@ -255,9 +257,16 @@ public class CCMBridge implements CCMAccess { if (ccmJavaHome != null) { envMap.put("JAVA_HOME", ccmJavaHome); } + // The global environment keeps an inherited SCYLLA_PRODUCT: a non-numeric scylla.version (a + // branch spec) can't be recognized as Enterprise here, and exporting the variable is the only + // way to select the repository in that case. + Map globalEnvMap = ImmutableMap.copyOf(envMap); + ENVIRONMENT_MAP = globalScyllaEnterprise ? withScyllaEnterprise(globalEnvMap) : globalEnvMap; + // A cluster that configures its own version derives SCYLLA_PRODUCT from that version instead, + // so an inherited value must not reach it: it would install an explicitly configured OSS + // version from the Enterprise repository. + envMap.remove("SCYLLA_PRODUCT"); BASE_ENVIRONMENT_MAP = ImmutableMap.copyOf(envMap); - ENVIRONMENT_MAP = - globalScyllaEnterprise ? withScyllaEnterprise(BASE_ENVIRONMENT_MAP) : BASE_ENVIRONMENT_MAP; if (isDse()) { GLOBAL_DSE_VERSION_NUMBER = VersionNumber.parse(inputCassandraVersion); @@ -1062,6 +1071,8 @@ public static class Builder { private boolean start = true; private boolean dse = isDse(); private boolean scylla = GLOBAL_SCYLLA_VERSION_NUMBER != null; + private boolean ssl = false; + private boolean auth = false; private VersionNumber version = null; private final Set createOptions = new LinkedHashSet(); private final Set jvmArgs = new LinkedHashSet(); @@ -1103,40 +1114,22 @@ public Builder withClusterName(String clusterName) { return this; } - /** Enables SSL encryption. */ + /** + * Enables SSL encryption. + * + *

Only records the request: which keys and certificates to point the server at depends on + * the server flavor, which isn't resolved until {@link #build()}, see {@link + * #buildClientEncryptionOptions(ResolvedVersions)}. + */ public Builder withSSL() { - cassandraConfiguration.put("client_encryption_options.enabled", "true"); - if (GLOBAL_SCYLLA_VERSION_NUMBER != null) { - cassandraConfiguration.put( - "client_encryption_options.certificate", - DEFAULT_SERVER_CERT_CHAIN_FILE.getAbsolutePath()); - cassandraConfiguration.put( - "client_encryption_options.keyfile", DEFAULT_SERVER_PRIVATE_KEY_FILE.getAbsolutePath()); - } else { - cassandraConfiguration.put("client_encryption_options.optional", "false"); - cassandraConfiguration.put( - "client_encryption_options.keystore", DEFAULT_SERVER_KEYSTORE_FILE.getAbsolutePath()); - cassandraConfiguration.put( - "client_encryption_options.keystore_password", DEFAULT_SERVER_KEYSTORE_PASSWORD); - } + this.ssl = true; return this; } /** Enables client authentication. This also enables encryption ({@link #withSSL()}. */ public Builder withAuth() { withSSL(); - cassandraConfiguration.put("client_encryption_options.require_client_auth", "true"); - if (GLOBAL_SCYLLA_VERSION_NUMBER != null) { - cassandraConfiguration.put( - "client_encryption_options.truststore", - DEFAULT_SERVER_TRUSTSTORE_PEM_FILE.getAbsolutePath()); - } else { - cassandraConfiguration.put( - "client_encryption_options.truststore", - DEFAULT_SERVER_TRUSTSTORE_FILE.getAbsolutePath()); - cassandraConfiguration.put( - "client_encryption_options.truststore_password", DEFAULT_SERVER_TRUSTSTORE_PASSWORD); - } + this.auth = true; return this; } @@ -1327,6 +1320,13 @@ public CCMBridge build() { cassandraConfiguration.put("native_shard_aware_transport_port", RANDOM_PORT); cassandraConfiguration = randomizePorts(cassandraConfiguration); } + // Explicitly configured entries keep winning over these defaults, as they did when withSSL() + // wrote them at builder-configuration time and withCassandraConfiguration() ran after it. + for (Map.Entry option : buildClientEncryptionOptions(versions).entrySet()) { + if (!cassandraConfiguration.containsKey(option.getKey())) { + cassandraConfiguration.put(option.getKey(), option.getValue()); + } + } final CCMBridge ccm = new CCMBridge( clusterName, @@ -1452,6 +1452,51 @@ String buildCreateCommand(String clusterName, ResolvedVersions versions) { return result.toString(); } + /** + * The client encryption yaml for a cluster with these versions, empty unless {@link #withSSL()} + * or {@link #withAuth()} was called. + * + *

Cassandra and DSE read a JKS keystore and truststore, Scylla reads a PEM certificate, key + * and truststore, so the flavor has to be resolved first — which is why this can't be decided + * in {@code withSSL()} itself. + */ + Map buildClientEncryptionOptions(ResolvedVersions versions) { + if (!ssl) { + return ImmutableMap.of(); + } + boolean isScylla = versions.scylla != null; + Map options = Maps.newLinkedHashMap(); + options.put("client_encryption_options.enabled", "true"); + if (isScylla) { + options.put( + "client_encryption_options.certificate", + DEFAULT_SERVER_CERT_CHAIN_FILE.getAbsolutePath()); + options.put( + "client_encryption_options.keyfile", DEFAULT_SERVER_PRIVATE_KEY_FILE.getAbsolutePath()); + } else { + options.put("client_encryption_options.optional", "false"); + options.put( + "client_encryption_options.keystore", DEFAULT_SERVER_KEYSTORE_FILE.getAbsolutePath()); + options.put( + "client_encryption_options.keystore_password", DEFAULT_SERVER_KEYSTORE_PASSWORD); + } + if (auth) { + options.put("client_encryption_options.require_client_auth", "true"); + if (isScylla) { + options.put( + "client_encryption_options.truststore", + DEFAULT_SERVER_TRUSTSTORE_PEM_FILE.getAbsolutePath()); + } else { + options.put( + "client_encryption_options.truststore", + DEFAULT_SERVER_TRUSTSTORE_FILE.getAbsolutePath()); + options.put( + "client_encryption_options.truststore_password", DEFAULT_SERVER_TRUSTSTORE_PASSWORD); + } + } + return options; + } + /** * The environment for the CCM commands of a cluster with these versions. * @@ -1565,6 +1610,10 @@ public boolean equals(Object o) { if (ipPrefix != builder.ipPrefix) return false; if (dse != builder.dse) return false; if (scylla != builder.scylla) return false; + // Not reflected in cassandraConfiguration until build(), so they have to be compared here — + // otherwise an encrypted cluster could be reused for a test that expects a plaintext one. + if (ssl != builder.ssl) return false; + if (auth != builder.auth) return false; if (!Arrays.equals(nodes, builder.nodes)) return false; if (version != null ? !version.equals(builder.version) : builder.version != null) return false; @@ -1580,6 +1629,9 @@ public int hashCode() { // do not include start as it is not relevant to the settings of the cluster. int result = Arrays.hashCode(nodes); result = 31 * result + (dse ? 1 : 0); + result = 31 * result + (scylla ? 1 : 0); + result = 31 * result + (ssl ? 1 : 0); + result = 31 * result + (auth ? 1 : 0); result = 31 * result + ipPrefix.hashCode(); result = 31 * result + (version != null ? version.hashCode() : 0); result = 31 * result + createOptions.hashCode(); diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java index 5513788e26a..50ee2d94a80 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java @@ -3,12 +3,13 @@ import static org.assertj.core.api.Assertions.assertThat; import com.datastax.driver.core.CCMBridge.Builder.ResolvedVersions; +import java.util.Map; import org.testng.annotations.Test; /** * Unit tests for the part of {@link CCMBridge.Builder} that decides which server flavor and version - * to install, i.e. the {@code ccm create} command and the environment it runs in. No CCM cluster is - * created. + * to install, i.e. the {@code ccm create} command, the environment it runs in and the + * flavor-specific yaml it writes. No CCM cluster is created. * *

Each test configures the flavor explicitly, so that it doesn't depend on the {@code * scylla.version} / {@code dse} system properties of the surrounding run. @@ -92,6 +93,89 @@ public void should_use_global_environment_when_no_version_configured() { new ResolvedVersions(false, cassandra, null, null))); } + /** + * Scylla reads a PEM certificate and key, not the JKS keystore Cassandra reads, so an explicitly + * configured Scylla cluster must not be given the Cassandra settings just because the surrounding + * run has no {@code scylla.version}. + */ + @Test(groups = "unit") + public void should_use_pem_client_encryption_for_configured_scylla_version() { + CCMBridge.Builder builder = + CCMBridge.builder() + .withDSE(false) + .withScylla(true) + .withVersion(VersionNumber.parse("2026.1.0")) + .withAuth(); + + Map options = builder.buildClientEncryptionOptions(builder.resolveVersions()); + + assertThat(options) + .containsEntry("client_encryption_options.enabled", "true") + .containsEntry("client_encryption_options.require_client_auth", "true") + .containsKey("client_encryption_options.certificate") + .containsKey("client_encryption_options.keyfile") + .containsKey("client_encryption_options.truststore"); + assertThat(options) + .doesNotContainKey("client_encryption_options.keystore") + .doesNotContainKey("client_encryption_options.keystore_password") + .doesNotContainKey("client_encryption_options.truststore_password"); + } + + /** The mirror image: an explicit Cassandra version under a global Scylla run. */ + @Test(groups = "unit") + public void should_use_keystore_client_encryption_for_configured_cassandra_version() { + CCMBridge.Builder builder = + CCMBridge.builder() + .withDSE(false) + .withScylla(false) + .withVersion(VersionNumber.parse("4.1.3")) + .withAuth(); + + Map options = builder.buildClientEncryptionOptions(builder.resolveVersions()); + + assertThat(options) + .containsEntry("client_encryption_options.enabled", "true") + .containsEntry("client_encryption_options.require_client_auth", "true") + .containsKey("client_encryption_options.keystore") + .containsKey("client_encryption_options.keystore_password") + .containsKey("client_encryption_options.truststore") + .containsKey("client_encryption_options.truststore_password"); + assertThat(options) + .doesNotContainKey("client_encryption_options.certificate") + .doesNotContainKey("client_encryption_options.keyfile"); + } + + /** {@code withSSL()} alone must not enable client certificate authentication. */ + @Test(groups = "unit") + public void should_not_require_client_auth_without_with_auth() { + CCMBridge.Builder sslOnly = CCMBridge.builder().withDSE(false).withScylla(true).withSSL(); + assertThat(sslOnly.buildClientEncryptionOptions(sslOnly.resolveVersions())) + .containsEntry("client_encryption_options.enabled", "true") + .doesNotContainKey("client_encryption_options.require_client_auth"); + + CCMBridge.Builder plaintext = CCMBridge.builder().withDSE(false).withScylla(true); + assertThat(plaintext.buildClientEncryptionOptions(plaintext.resolveVersions())).isEmpty(); + } + + /** + * {@code ssl}/{@code auth} are no longer reflected in {@code cassandraConfiguration} at + * configuration time, so {@link CCMBridge.Builder} has to compare them itself: {@link CCMCache} + * keys cached clusters on the builder, and would otherwise hand an encrypted cluster to a test + * that asked for a plaintext one. + */ + @Test(groups = "unit") + public void should_not_consider_encrypted_and_plaintext_clusters_equal() { + CCMBridge.Builder plaintext = CCMBridge.builder().withNodes(1); + CCMBridge.Builder encrypted = CCMBridge.builder().withNodes(1).withSSL(); + CCMBridge.Builder authenticated = CCMBridge.builder().withNodes(1).withAuth(); + + assertThat(plaintext).isNotEqualTo(encrypted).isNotEqualTo(authenticated); + assertThat(encrypted).isNotEqualTo(authenticated); + assertThat(encrypted).isEqualTo(CCMBridge.builder().withNodes(1).withSSL()); + assertThat(encrypted.hashCode()) + .isEqualTo(CCMBridge.builder().withNodes(1).withSSL().hashCode()); + } + @Test(groups = "unit") public void should_create_dse_cluster_when_dse_version_configured() { CCMBridge.Builder builder = From 82028f1425edb0cfd0d577ee92999d161453efb9 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 31 Jul 2026 20:41:16 +0200 Subject: [PATCH 7/7] test: resolve the last two CCM flavor decisions from per-cluster state The two remaining review findings on this branch. Both are pre-existing scylla-3.x behavior rather than regressions from the commits before this one, and neither is reachable from CI, so this is footgun removal. SCYLLA_PRODUCT could still be inherited for the globally configured version. The previous commit stripped an inherited value from BASE_ENVIRONMENT_MAP, so no cluster configuring its own version could pick one up, but ENVIRONMENT_MAP was copied before that strip and kept whatever the surrounding shell exported. That is what scylla-3.x always did -- it has a single env map and no strip at all -- yet the comment justifying it only covered a branch spec, while the code applied it to a numeric OSS version, a pure Cassandra run, and no configured version too. In each of those the repository is already known from the resolved flavor, so a stale SCYLLA_PRODUCT=enterprise left in the shell would install an OSS version from the Enterprise repository. The assembly moved into buildGlobalEnvironmentMap, which now strips the inherited value unless the version is a branch spec -- the one case isScyllaEnterpriseVersion cannot classify, where exporting the variable is the only way to select the repository. Extracting it is what makes the decision testable: it is otherwise reachable only through a static initializer reading the live process environment, which is why the existing global-path test could assert nothing but isSameAs. @CCMConfig could not declare Scylla. Builder.scylla defaults to GLOBAL_SCYLLA_VERSION_NUMBER != null and withVersion() does not touch it, so under a Scylla run an explicit Cassandra version is resolved as a Scylla release. Base silently installed C* 3.0.8 and discarded the requested version; since buildCreateCommand started honouring the flag it fails loudly instead. Neither is right, and a test could not fix itself: @CCMConfig forwards dse but had no Scylla equivalent, and version()'s javadoc still called itself "the C* or DSE version to use". Added the scylla attribute, forwarded it from CCMTestsSupport alongside dse and ahead of withVersion, and documented on both withVersion() and @CCMConfig.version that the flavor flags decide what the version names. The two call sites that configure a version without a flavor now say which they mean: ProtocolVersionRenegotiationTest's 2.1.16 and RecommissionedNodeTest's 2.1.20 are both Cassandra versions. Testing: CCMBridgeCreateCommandTest grows to 13 cases, covering all four SCYLLA_PRODUCT paths against a synthetic inherited environment, so they hold regardless of the surrounding run. 13/13 with and without -Dscylla.version=2026.1.0 and with SCYLLA_PRODUCT=enterprise exported. Falsified by making buildGlobalEnvironmentMap always keep the inherited value: exactly the two strip cases fail. Full driver-core unit group green, 640 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/datastax/driver/core/CCMBridge.java | 52 +++++++++++++--- .../core/CCMBridgeCreateCommandTest.java | 60 +++++++++++++++++++ .../com/datastax/driver/core/CCMConfig.java | 25 +++++++- .../datastax/driver/core/CCMTestsSupport.java | 17 +++++- .../ProtocolVersionRenegotiationTest.java | 2 +- .../driver/core/RecommissionedNodeTest.java | 2 + 6 files changed, 144 insertions(+), 14 deletions(-) diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java index 1571200bc27..6a4de17ad0e 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridge.java @@ -126,9 +126,10 @@ public class CCMBridge implements CCMAccess { * running tests. For example, if you want to run tests with JDK 6 but against Cassandra 2.0, * which requires JDK 7. * - *

This is the environment for the globally configured server version; a cluster that - * configures its own version gets one derived from {@link #BASE_ENVIRONMENT_MAP} instead, see - * {@link Builder#buildEnvironmentMap(Builder.ResolvedVersions)}. + *

This is the environment for the globally configured server version, assembled by {@link + * #buildGlobalEnvironmentMap}; a cluster that configures its own version gets one derived from + * {@link #BASE_ENVIRONMENT_MAP} instead, see {@link + * Builder#buildEnvironmentMap(Builder.ResolvedVersions)}. */ private static final Map ENVIRONMENT_MAP; @@ -213,6 +214,7 @@ public class CCMBridge implements CCMAccess { // Inherit the current environment. Map envMap = Maps.newHashMap(new ProcessBuilder().environment()); boolean globalScyllaEnterprise = false; + boolean globalScyllaBranchSpec = false; ImmutableSet.Builder installArgs = ImmutableSet.builder(); if (installDirectory != null && !installDirectory.trim().isEmpty()) { installArgs.add("--install-dir=" + new File(installDirectory).getAbsolutePath()); @@ -224,6 +226,7 @@ public class CCMBridge implements CCMAccess { installArgs.add("-v release:" + inputScyllaVersion); } else { installArgs.add("-v " + inputScyllaVersion); + globalScyllaBranchSpec = true; } globalScyllaEnterprise = isScyllaEnterpriseVersion(inputScyllaVersion); } else if (inputCassandraVersion != null && !inputCassandraVersion.trim().isEmpty()) { @@ -257,11 +260,8 @@ public class CCMBridge implements CCMAccess { if (ccmJavaHome != null) { envMap.put("JAVA_HOME", ccmJavaHome); } - // The global environment keeps an inherited SCYLLA_PRODUCT: a non-numeric scylla.version (a - // branch spec) can't be recognized as Enterprise here, and exporting the variable is the only - // way to select the repository in that case. - Map globalEnvMap = ImmutableMap.copyOf(envMap); - ENVIRONMENT_MAP = globalScyllaEnterprise ? withScyllaEnterprise(globalEnvMap) : globalEnvMap; + ENVIRONMENT_MAP = + buildGlobalEnvironmentMap(envMap, globalScyllaEnterprise, globalScyllaBranchSpec); // A cluster that configures its own version derives SCYLLA_PRODUCT from that version instead, // so an inherited value must not reach it: it would install an explicitly configured OSS // version from the Enterprise repository. @@ -374,6 +374,36 @@ private static boolean isScyllaEnterpriseVersion(String versionString) { return versionString != null && versionString.matches("\\d{4}\\..*"); } + /** + * Builds {@link #ENVIRONMENT_MAP}, the environment for the globally configured server version, + * from an inherited process environment. + * + *

Package-private and free of static state so that the {@code SCYLLA_PRODUCT} decision is + * assertable without a live process environment -- it is otherwise only reachable through a + * static initializer. + * + *

An inherited {@code SCYLLA_PRODUCT} is honoured only for a branch spec, where {@link + * #isScyllaEnterpriseVersion} cannot classify the string and exporting the variable is the only + * way to select the repository. For a version number, a pure Cassandra run, or no configured + * version, the resolved flavor wins and an inherited value is dropped: otherwise a stale {@code + * SCYLLA_PRODUCT=enterprise} in the surrounding shell would install an OSS version from the + * Enterprise repository. + */ + static Map buildGlobalEnvironmentMap( + Map inheritedEnvironment, + boolean scyllaEnterprise, + boolean scyllaBranchSpec) { + if (scyllaEnterprise) { + return withScyllaEnterprise(inheritedEnvironment); + } + if (scyllaBranchSpec) { + return ImmutableMap.copyOf(inheritedEnvironment); + } + Map envMap = Maps.newHashMap(inheritedEnvironment); + envMap.remove("SCYLLA_PRODUCT"); + return ImmutableMap.copyOf(envMap); + } + /** Adds the CCM variable that makes Scylla install from the Enterprise repository. */ private static Map withScyllaEnterprise(Map environmentMap) { // Not an ImmutableMap.Builder: the inherited environment may already define the variable, and @@ -1142,6 +1172,12 @@ public Builder notStarted() { /** * The Cassandra or DSE or Scylla version to use. If not specified the globally configured * version is used instead. + * + *

Which of the three this version names is decided by {@link #withDSE(boolean)} and {@link + * #withScylla(boolean)}, which default to the flavor of the surrounding run rather than to + * anything about this version. Call the matching one alongside this method, or a Cassandra + * version passed under {@code -Dscylla.version=...} is resolved as a Scylla release (and vice + * versa). */ public Builder withVersion(VersionNumber version) { this.version = version; diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java b/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java index 50ee2d94a80..3a8fbcb07bc 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMBridgeCreateCommandTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.datastax.driver.core.CCMBridge.Builder.ResolvedVersions; +import com.google.common.collect.ImmutableMap; import java.util.Map; import org.testng.annotations.Test; @@ -193,4 +194,63 @@ public void should_create_dse_cluster_when_dse_version_configured() { assertThat(command).contains("--dse").contains("-v 6.8.0"); assertThat(command).doesNotContain("--scylla"); } + + /** + * An environment as inherited from a shell that exported {@code SCYLLA_PRODUCT}, e.g. left over + * from an earlier step of the same CI job. + */ + private static Map inheritedEnterpriseEnvironment() { + return ImmutableMap.of("PATH", "/usr/bin", "SCYLLA_PRODUCT", "enterprise"); + } + + @Test(groups = "unit") + public void should_use_enterprise_repository_for_global_enterprise_version() { + Map environment = + CCMBridge.buildGlobalEnvironmentMap(inheritedEnterpriseEnvironment(), true, false); + + assertThat(environment).containsEntry("SCYLLA_PRODUCT", "enterprise"); + assertThat(environment).containsEntry("PATH", "/usr/bin"); + } + + /** + * The global version is a number that isn't Enterprise, so the repository is known: an inherited + * value must not override it, or an OSS version is looked up in the Enterprise repository. + */ + @Test(groups = "unit") + public void should_drop_inherited_product_for_global_open_source_version() { + Map environment = + CCMBridge.buildGlobalEnvironmentMap(inheritedEnterpriseEnvironment(), false, false); + + assertThat(environment).doesNotContainKey("SCYLLA_PRODUCT"); + assertThat(environment).containsEntry("PATH", "/usr/bin"); + } + + /** + * A branch spec can't be classified as Enterprise or OSS by its version string, so exporting + * {@code SCYLLA_PRODUCT} is the only way to select the repository: that one inherited value has + * to survive. + */ + @Test(groups = "unit") + public void should_keep_inherited_product_for_global_branch_spec() { + Map environment = + CCMBridge.buildGlobalEnvironmentMap(inheritedEnterpriseEnvironment(), false, true); + + assertThat(environment).containsEntry("SCYLLA_PRODUCT", "enterprise"); + } + + /** + * A pure Cassandra run, or no configured version at all: nothing about the run asks for the + * Enterprise repository, so a stale inherited value must not reach ccm. + */ + @Test(groups = "unit") + public void should_drop_inherited_product_when_no_scylla_version_configured() { + Map environment = + CCMBridge.buildGlobalEnvironmentMap( + ImmutableMap.of("JAVA_HOME", "/opt/java", "SCYLLA_PRODUCT", "enterprise"), + false, + false); + + assertThat(environment).doesNotContainKey("SCYLLA_PRODUCT"); + assertThat(environment).containsEntry("JAVA_HOME", "/opt/java"); + } } diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMConfig.java b/driver-core/src/test/java/com/datastax/driver/core/CCMConfig.java index d43b39db95b..4f21a8494b8 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMConfig.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMConfig.java @@ -50,15 +50,20 @@ final class Undefined {} int[] numberOfNodes() default {}; /** - * The C* or DSE version to use; defaults to the version defined by the System property {@code - * cassandra.version}. + * The C*, DSE or Scylla version to use; defaults to the version defined by the System property + * {@code cassandra.version}. * *

Note that setting this attribute completely overrides the System properties {@code * cassandra.version} and {@code cassandra.directory}. * + *

Which server this version names is decided by {@link #dse()} and {@link #scylla()}, which + * default to the flavor of the surrounding run. Set the matching one explicitly whenever this + * attribute is set, or a Cassandra version will be installed as Scylla (or vice versa) depending + * on how the test run was invoked. + * *

This attribute is ignored if {@link #ccmProvider()} is defined. * - * @return The C* or DSE version to use + * @return The C*, DSE or Scylla version to use * @see CCMBridge#getCassandraVersion() */ String version() default ""; @@ -75,6 +80,20 @@ final class Undefined {} */ boolean[] dse() default {}; + /** + * Whether to launch a Scylla instance rather than an OSS C*. + * + *

Note that setting this attribute completely overrides the System property {@code + * scylla.version}: only whether Scylla is launched, not which version. Set it together with + * {@link #version()} so that an explicitly configured version is installed as the server it + * actually names, instead of inheriting the flavor of the surrounding run. + * + *

This attribute is ignored if {@link #ccmProvider()} is defined. + * + * @return {@code true} to launch a Scylla instance, {@code false} to launch an OSS C* instance. + */ + boolean[] scylla() default {}; + /** * Configuration items to add to cassandra.yaml configuration file. Each configuration item must * be in the form {@code key:value}. diff --git a/driver-core/src/test/java/com/datastax/driver/core/CCMTestsSupport.java b/driver-core/src/test/java/com/datastax/driver/core/CCMTestsSupport.java index c8627f1d0b4..1af9c7695cc 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/CCMTestsSupport.java +++ b/driver-core/src/test/java/com/datastax/driver/core/CCMTestsSupport.java @@ -344,6 +344,14 @@ private Boolean dse() { return null; } + @SuppressWarnings("SimplifiableIfStatement") + private Boolean scylla() { + for (CCMConfig ann : annotations) { + if (ann != null && ann.scylla().length > 0) return ann.scylla()[0]; + } + return null; + } + @SuppressWarnings("SimplifiableIfStatement") private boolean ssl() { for (CCMConfig ann : annotations) { @@ -497,14 +505,19 @@ private CCMBridge.Builder ccmBuilder(Object testInstance) throws Exception { ccmBuilder = CCMBridge.builder().withNodes(numberOfNodes()).notStarted(); } + // Set the flavor before the version: which server an explicitly configured version names + // is decided by these flags, which otherwise default to the flavor of the surrounding run. + Boolean dse = dse(); + if (dse != null) ccmBuilder.withDSE(dse); + Boolean scylla = scylla(); + if (scylla != null) ccmBuilder.withScylla(scylla); + String versionStr = version(); if (versionStr != null) { VersionNumber version = VersionNumber.parse(versionStr); ccmBuilder.withVersion(version); } - Boolean dse = dse(); - if (dse != null) ccmBuilder.withDSE(dse); if (ssl()) ccmBuilder.withSSL(); if (auth()) ccmBuilder.withAuth(); for (Map.Entry entry : config().entrySet()) { diff --git a/driver-core/src/test/java/com/datastax/driver/core/ProtocolVersionRenegotiationTest.java b/driver-core/src/test/java/com/datastax/driver/core/ProtocolVersionRenegotiationTest.java index 5de374b66d7..9552ca2b5b5 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/ProtocolVersionRenegotiationTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/ProtocolVersionRenegotiationTest.java @@ -84,7 +84,7 @@ public void should_fail_when_beta_allowed_and_too_high() { /** @jira_ticket JAVA-1367 */ @Test(groups = "short", enabled = false /* @IntegrationTestDisabledCassandra3Failure */) - @CCMConfig(version = "2.1.16", createCluster = false) + @CCMConfig(version = "2.1.16", scylla = false, createCluster = false) public void should_negotiate_when_no_version_provided() { if (protocolVersion.compareTo(ProtocolVersion.NEWEST_SUPPORTED) >= 0) { throw new SkipException("Server supports newest protocol version driver supports"); diff --git a/driver-core/src/test/java/com/datastax/driver/core/RecommissionedNodeTest.java b/driver-core/src/test/java/com/datastax/driver/core/RecommissionedNodeTest.java index 3e9907c57aa..e3686eedf88 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/RecommissionedNodeTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/RecommissionedNodeTest.java @@ -172,6 +172,8 @@ public void should_ignore_node_that_does_not_support_protocol_version_on_session .withStoragePort(mainCcm.getStoragePort()) .withThriftPort(mainCcm.getThriftPort()) .withBinaryPort(mainCcm.getBinaryPort()) + // 2.1.20 is a Cassandra version: say so, or a Scylla run would install it as Scylla. + .withScylla(false) .withVersion(VersionNumber.parse("2.1.20")); otherCcm = CCMCache.get(otherCcmBuilder); otherCcm.waitForUp(1);