Skip to content

Commit 88e4ac5

Browse files
Add new CassandraContainer implementation (#8616)
Current implementation under `org.testcontainers.containers` relies on some types from the cassandra client. The new implementation is under `org.testcontainers.cassandra` and remove the dependency from the cassandra client. --------- Co-authored-by: Eddú Meléndez Gonzales <[email protected]>
1 parent b27316d commit 88e4ac5

File tree

13 files changed

+1831
-8
lines changed

13 files changed

+1831
-8
lines changed

docs/modules/databases/cassandra.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@
22

33
## Usage example
44

5-
This example connects to the Cassandra Cluster, creates a keyspaces and asserts that is has been created.
6-
7-
<!--codeinclude-->
8-
[Building CqlSession](../../../modules/cassandra/src/test/java/org/testcontainers/containers/CassandraDriver3Test.java) inside_block:cassandra
9-
<!--/codeinclude-->
10-
11-
!!! warning
12-
All methods returning instances of the Cassandra Driver's Cluster object in `CassandraContainer` have been deprecated. Providing these methods unnecessarily couples the Container to the Driver and creates potential breaking changes if the driver is updated.
5+
This example connects to the Cassandra cluster:
6+
7+
1. Define a container:
8+
<!--codeinclude-->
9+
[Container definition](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraDriver4Test.java) inside_block:container-definition
10+
<!--/codeinclude-->
11+
12+
2. Build a `CqlSession`:
13+
<!--codeinclude-->
14+
[Building CqlSession](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraDriver4Test.java) inside_block:cql-session
15+
<!--/codeinclude-->
16+
17+
3. Define a container with custom `cassandra.yaml` located in a directory `cassandra-auth-required-configuration`:
18+
19+
<!--codeinclude-->
20+
[Running init script with required authentication](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:init-with-auth
21+
<!--/codeinclude-->
1322

1423
## Adding this module to your project dependencies
1524

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package org.testcontainers.cassandra;
2+
3+
import com.github.dockerjava.api.command.InspectContainerResponse;
4+
import org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate;
5+
import org.testcontainers.cassandra.wait.CassandraQueryWaitStrategy;
6+
import org.testcontainers.containers.GenericContainer;
7+
import org.testcontainers.ext.ScriptUtils;
8+
import org.testcontainers.ext.ScriptUtils.ScriptLoadException;
9+
import org.testcontainers.utility.DockerImageName;
10+
import org.testcontainers.utility.MountableFile;
11+
12+
import java.io.File;
13+
import java.net.InetSocketAddress;
14+
import java.net.URISyntaxException;
15+
import java.net.URL;
16+
import java.util.Optional;
17+
18+
/**
19+
* Testcontainers implementation for Apache Cassandra.
20+
* <p>
21+
* Supported image: {@code cassandra}
22+
* <p>
23+
* Exposed ports: 9042
24+
*/
25+
public class CassandraContainer extends GenericContainer<CassandraContainer> {
26+
27+
private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cassandra");
28+
29+
private static final Integer CQL_PORT = 9042;
30+
31+
private static final String DEFAULT_LOCAL_DATACENTER = "datacenter1";
32+
33+
private static final String CONTAINER_CONFIG_LOCATION = "/etc/cassandra";
34+
35+
private static final String USERNAME = "cassandra";
36+
37+
private static final String PASSWORD = "cassandra";
38+
39+
private String configLocation;
40+
41+
private String initScriptPath;
42+
43+
public CassandraContainer(String dockerImageName) {
44+
this(DockerImageName.parse(dockerImageName));
45+
}
46+
47+
public CassandraContainer(DockerImageName dockerImageName) {
48+
super(dockerImageName);
49+
dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME);
50+
51+
addExposedPort(CQL_PORT);
52+
53+
withEnv("CASSANDRA_SNITCH", "GossipingPropertyFileSnitch");
54+
withEnv("JVM_OPTS", "-Dcassandra.skip_wait_for_gossip_to_settle=0 -Dcassandra.initial_token=0");
55+
withEnv("HEAP_NEWSIZE", "128M");
56+
withEnv("MAX_HEAP_SIZE", "1024M");
57+
withEnv("CASSANDRA_ENDPOINT_SNITCH", "GossipingPropertyFileSnitch");
58+
withEnv("CASSANDRA_DC", DEFAULT_LOCAL_DATACENTER);
59+
60+
// Use the CassandraQueryWaitStrategy by default to avoid potential issues when the authentication is enabled.
61+
waitingFor(new CassandraQueryWaitStrategy());
62+
}
63+
64+
@Override
65+
protected void configure() {
66+
// Map (effectively replace) directory in Docker with the content of resourceLocation if resource location is
67+
// not null.
68+
Optional
69+
.ofNullable(configLocation)
70+
.map(MountableFile::forClasspathResource)
71+
.ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, CONTAINER_CONFIG_LOCATION));
72+
}
73+
74+
@Override
75+
protected void containerIsStarted(InspectContainerResponse containerInfo) {
76+
runInitScriptIfRequired();
77+
}
78+
79+
/**
80+
* Load init script content and apply it to the database if initScriptPath is set
81+
*/
82+
private void runInitScriptIfRequired() {
83+
if (initScriptPath != null) {
84+
try {
85+
URL resource = Thread.currentThread().getContextClassLoader().getResource(initScriptPath);
86+
if (resource == null) {
87+
logger().warn("Could not load classpath init script: {}", initScriptPath);
88+
throw new ScriptLoadException(
89+
"Could not load classpath init script: " + initScriptPath + ". Resource not found."
90+
);
91+
}
92+
// The init script is executed as is by the cqlsh command, so copy it into the container.
93+
String targetInitScriptName = new File(resource.toURI()).getName();
94+
copyFileToContainer(MountableFile.forClasspathResource(initScriptPath), targetInitScriptName);
95+
new CassandraDatabaseDelegate(this).execute(null, targetInitScriptName, -1, false, false);
96+
} catch (URISyntaxException e) {
97+
logger().warn("Could not copy init script into container: {}", initScriptPath);
98+
throw new ScriptLoadException("Could not copy init script into container: " + initScriptPath, e);
99+
} catch (ScriptUtils.ScriptStatementFailedException e) {
100+
logger().error("Error while executing init script: {}", initScriptPath, e);
101+
throw new ScriptUtils.UncategorizedScriptException(
102+
"Error while executing init script: " + initScriptPath,
103+
e
104+
);
105+
}
106+
}
107+
}
108+
109+
/**
110+
* Initialize Cassandra with the custom overridden Cassandra configuration
111+
* <p>
112+
* Be aware, that Docker effectively replaces all /etc/cassandra content with the content of config location, so if
113+
* Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch
114+
*
115+
* @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration files
116+
*/
117+
public CassandraContainer withConfigurationOverride(String configLocation) {
118+
this.configLocation = configLocation;
119+
return self();
120+
}
121+
122+
/**
123+
* Initialize Cassandra with init CQL script
124+
* <p>
125+
* CQL script will be applied after container is started (see using WaitStrategy).
126+
* </p>
127+
*
128+
* @param initScriptPath relative classpath resource
129+
*/
130+
public CassandraContainer withInitScript(String initScriptPath) {
131+
this.initScriptPath = initScriptPath;
132+
return self();
133+
}
134+
135+
/**
136+
* Get username
137+
*
138+
* By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml
139+
* If username and password need to be used, then authenticator should be set as PasswordAuthenticator
140+
* (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials
141+
* user management should be modified
142+
*/
143+
public String getUsername() {
144+
return USERNAME;
145+
}
146+
147+
/**
148+
* Get password
149+
*
150+
* By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml
151+
* If username and password need to be used, then authenticator should be set as PasswordAuthenticator
152+
* (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials
153+
* user management should be modified
154+
*/
155+
public String getPassword() {
156+
return PASSWORD;
157+
}
158+
159+
/**
160+
* Retrieve an {@link InetSocketAddress} for connecting to the Cassandra container via the driver.
161+
*
162+
* @return A InetSocketAddrss representation of this Cassandra container's host and port.
163+
*/
164+
public InetSocketAddress getContactPoint() {
165+
return new InetSocketAddress(getHost(), getMappedPort(CQL_PORT));
166+
}
167+
168+
/**
169+
* Retrieve the Local Datacenter for connecting to the Cassandra container via the driver.
170+
*
171+
* @return The configured local Datacenter name.
172+
*/
173+
public String getLocalDatacenter() {
174+
return getEnvMap().getOrDefault("CASSANDRA_DC", DEFAULT_LOCAL_DATACENTER);
175+
}
176+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package org.testcontainers.cassandra.delegate;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.apache.commons.lang3.ArrayUtils;
6+
import org.apache.commons.lang3.StringUtils;
7+
import org.testcontainers.cassandra.CassandraContainer;
8+
import org.testcontainers.containers.Container;
9+
import org.testcontainers.containers.ContainerState;
10+
import org.testcontainers.containers.ExecConfig;
11+
import org.testcontainers.delegate.AbstractDatabaseDelegate;
12+
import org.testcontainers.ext.ScriptUtils.ScriptStatementFailedException;
13+
14+
import java.io.IOException;
15+
16+
/**
17+
* Cassandra database delegate
18+
*/
19+
@Slf4j
20+
@RequiredArgsConstructor
21+
public class CassandraDatabaseDelegate extends AbstractDatabaseDelegate<Void> {
22+
23+
private final ContainerState container;
24+
25+
@Override
26+
protected Void createNewConnection() {
27+
// Return null here, because we run scripts using cqlsh command directly in the container.
28+
// So, we don't use connection object in the execute() method.
29+
return null;
30+
}
31+
32+
@Override
33+
public void execute(
34+
String statement,
35+
String scriptPath,
36+
int lineNumber,
37+
boolean continueOnError,
38+
boolean ignoreFailedDrops
39+
) {
40+
try {
41+
// Use cqlsh command directly inside the container to execute statements
42+
// See documentation here: https://cassandra.apache.org/doc/stable/cassandra/tools/cqlsh.html
43+
String[] cqlshCommand = new String[] { "cqlsh" };
44+
45+
if (this.container instanceof CassandraContainer) {
46+
CassandraContainer cassandraContainer = (CassandraContainer) this.container;
47+
String username = cassandraContainer.getUsername();
48+
String password = cassandraContainer.getPassword();
49+
cqlshCommand = ArrayUtils.addAll(cqlshCommand, "-u", username, "-p", password);
50+
}
51+
52+
// If no statement specified, directly execute the script specified into scriptPath (using -f argument),
53+
// otherwise execute the given statement (using -e argument).
54+
String executeArg = "-e";
55+
String executeArgValue = statement;
56+
if (StringUtils.isBlank(statement)) {
57+
executeArg = "-f";
58+
executeArgValue = scriptPath;
59+
}
60+
cqlshCommand = ArrayUtils.addAll(cqlshCommand, executeArg, executeArgValue);
61+
62+
Container.ExecResult result =
63+
this.container.execInContainer(ExecConfig.builder().command(cqlshCommand).build());
64+
if (result.getExitCode() == 0) {
65+
if (StringUtils.isBlank(statement)) {
66+
log.info("CQL script {} successfully executed", scriptPath);
67+
} else {
68+
log.info("CQL statement {} was applied", statement);
69+
}
70+
} else {
71+
log.error("CQL script execution failed with error: \n{}", result.getStderr());
72+
throw new ScriptStatementFailedException(statement, lineNumber, scriptPath);
73+
}
74+
} catch (IOException | InterruptedException e) {
75+
throw new ScriptStatementFailedException(statement, lineNumber, scriptPath, e);
76+
}
77+
}
78+
79+
@Override
80+
protected void closeConnectionQuietly(Void session) {
81+
// Nothing to do here, because we run scripts using cqlsh command directly in the container.
82+
}
83+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package org.testcontainers.cassandra.wait;
2+
3+
import org.rnorth.ducttape.TimeoutException;
4+
import org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate;
5+
import org.testcontainers.containers.ContainerLaunchException;
6+
import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy;
7+
import org.testcontainers.delegate.DatabaseDelegate;
8+
9+
import java.util.concurrent.TimeUnit;
10+
11+
import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess;
12+
13+
/**
14+
* Waits until Cassandra returns its version
15+
*/
16+
public class CassandraQueryWaitStrategy extends AbstractWaitStrategy {
17+
18+
private static final String SELECT_VERSION_QUERY = "SELECT release_version FROM system.local";
19+
20+
private static final String TIMEOUT_ERROR = "Timed out waiting for Cassandra to be accessible for query execution";
21+
22+
@Override
23+
protected void waitUntilReady() {
24+
// execute select version query until success or timeout
25+
try {
26+
retryUntilSuccess(
27+
(int) startupTimeout.getSeconds(),
28+
TimeUnit.SECONDS,
29+
() -> {
30+
getRateLimiter()
31+
.doWhenReady(() -> {
32+
try (DatabaseDelegate databaseDelegate = getDatabaseDelegate()) {
33+
databaseDelegate.execute(SELECT_VERSION_QUERY, "", 1, false, false);
34+
}
35+
});
36+
return true;
37+
}
38+
);
39+
} catch (TimeoutException e) {
40+
throw new ContainerLaunchException(TIMEOUT_ERROR);
41+
}
42+
}
43+
44+
private DatabaseDelegate getDatabaseDelegate() {
45+
return new CassandraDatabaseDelegate(waitStrategyTarget);
46+
}
47+
}

modules/cassandra/src/main/java/org/testcontainers/containers/CassandraContainer.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
* Supported image: {@code cassandra}
2525
* <p>
2626
* Exposed ports: 9042
27+
*
28+
* @deprecated use {@link org.testcontainers.cassandra.CassandraContainer} instead.
2729
*/
30+
@Deprecated
2831
public class CassandraContainer<SELF extends CassandraContainer<SELF>> extends GenericContainer<SELF> {
2932

3033
private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cassandra");

modules/cassandra/src/main/java/org/testcontainers/containers/delegate/CassandraDatabaseDelegate.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313

1414
/**
1515
* Cassandra database delegate
16+
*
17+
* @deprecated use {@link org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate} instead.
1618
*/
1719
@Slf4j
1820
@RequiredArgsConstructor
21+
@Deprecated
1922
public class CassandraDatabaseDelegate extends AbstractDatabaseDelegate<Session> {
2023

2124
private final ContainerState container;

modules/cassandra/src/main/java/org/testcontainers/containers/wait/CassandraQueryWaitStrategy.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212

1313
/**
1414
* Waits until Cassandra returns its version
15+
*
16+
* @deprecated use {@link org.testcontainers.cassandra.wait.CassandraQueryWaitStrategy} instead.
1517
*/
18+
@Deprecated
1619
public class CassandraQueryWaitStrategy extends AbstractWaitStrategy {
1720

1821
private static final String SELECT_VERSION_QUERY = "SELECT release_version FROM system.local";

0 commit comments

Comments
 (0)