Skip to content

Commit

Permalink
Add new CassandraContainer implementation (#8616)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
maximevw and eddumelendez authored Aug 13, 2024
1 parent b27316d commit 88e4ac5
Show file tree
Hide file tree
Showing 13 changed files with 1,831 additions and 8 deletions.
25 changes: 17 additions & 8 deletions docs/modules/databases/cassandra.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@

## Usage example

This example connects to the Cassandra Cluster, creates a keyspaces and asserts that is has been created.

<!--codeinclude-->
[Building CqlSession](../../../modules/cassandra/src/test/java/org/testcontainers/containers/CassandraDriver3Test.java) inside_block:cassandra
<!--/codeinclude-->

!!! warning
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.
This example connects to the Cassandra cluster:

1. Define a container:
<!--codeinclude-->
[Container definition](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraDriver4Test.java) inside_block:container-definition
<!--/codeinclude-->

2. Build a `CqlSession`:
<!--codeinclude-->
[Building CqlSession](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraDriver4Test.java) inside_block:cql-session
<!--/codeinclude-->

3. Define a container with custom `cassandra.yaml` located in a directory `cassandra-auth-required-configuration`:

<!--codeinclude-->
[Running init script with required authentication](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:init-with-auth
<!--/codeinclude-->

## Adding this module to your project dependencies

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package org.testcontainers.cassandra;

import com.github.dockerjava.api.command.InspectContainerResponse;
import org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate;
import org.testcontainers.cassandra.wait.CassandraQueryWaitStrategy;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.ext.ScriptUtils;
import org.testcontainers.ext.ScriptUtils.ScriptLoadException;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;

import java.io.File;
import java.net.InetSocketAddress;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Optional;

/**
* Testcontainers implementation for Apache Cassandra.
* <p>
* Supported image: {@code cassandra}
* <p>
* Exposed ports: 9042
*/
public class CassandraContainer extends GenericContainer<CassandraContainer> {

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

private static final Integer CQL_PORT = 9042;

private static final String DEFAULT_LOCAL_DATACENTER = "datacenter1";

private static final String CONTAINER_CONFIG_LOCATION = "/etc/cassandra";

private static final String USERNAME = "cassandra";

private static final String PASSWORD = "cassandra";

private String configLocation;

private String initScriptPath;

public CassandraContainer(String dockerImageName) {
this(DockerImageName.parse(dockerImageName));
}

public CassandraContainer(DockerImageName dockerImageName) {
super(dockerImageName);
dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME);

addExposedPort(CQL_PORT);

withEnv("CASSANDRA_SNITCH", "GossipingPropertyFileSnitch");
withEnv("JVM_OPTS", "-Dcassandra.skip_wait_for_gossip_to_settle=0 -Dcassandra.initial_token=0");
withEnv("HEAP_NEWSIZE", "128M");
withEnv("MAX_HEAP_SIZE", "1024M");
withEnv("CASSANDRA_ENDPOINT_SNITCH", "GossipingPropertyFileSnitch");
withEnv("CASSANDRA_DC", DEFAULT_LOCAL_DATACENTER);

// Use the CassandraQueryWaitStrategy by default to avoid potential issues when the authentication is enabled.
waitingFor(new CassandraQueryWaitStrategy());
}

@Override
protected void configure() {
// Map (effectively replace) directory in Docker with the content of resourceLocation if resource location is
// not null.
Optional
.ofNullable(configLocation)
.map(MountableFile::forClasspathResource)
.ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, CONTAINER_CONFIG_LOCATION));
}

@Override
protected void containerIsStarted(InspectContainerResponse containerInfo) {
runInitScriptIfRequired();
}

/**
* Load init script content and apply it to the database if initScriptPath is set
*/
private void runInitScriptIfRequired() {
if (initScriptPath != null) {
try {
URL resource = Thread.currentThread().getContextClassLoader().getResource(initScriptPath);
if (resource == null) {
logger().warn("Could not load classpath init script: {}", initScriptPath);
throw new ScriptLoadException(
"Could not load classpath init script: " + initScriptPath + ". Resource not found."
);
}
// The init script is executed as is by the cqlsh command, so copy it into the container.
String targetInitScriptName = new File(resource.toURI()).getName();
copyFileToContainer(MountableFile.forClasspathResource(initScriptPath), targetInitScriptName);
new CassandraDatabaseDelegate(this).execute(null, targetInitScriptName, -1, false, false);
} catch (URISyntaxException e) {
logger().warn("Could not copy init script into container: {}", initScriptPath);
throw new ScriptLoadException("Could not copy init script into container: " + initScriptPath, e);
} catch (ScriptUtils.ScriptStatementFailedException e) {
logger().error("Error while executing init script: {}", initScriptPath, e);
throw new ScriptUtils.UncategorizedScriptException(
"Error while executing init script: " + initScriptPath,
e
);
}
}
}

/**
* Initialize Cassandra with the custom overridden Cassandra configuration
* <p>
* Be aware, that Docker effectively replaces all /etc/cassandra content with the content of config location, so if
* Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch
*
* @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration files
*/
public CassandraContainer withConfigurationOverride(String configLocation) {
this.configLocation = configLocation;
return self();
}

/**
* Initialize Cassandra with init CQL script
* <p>
* CQL script will be applied after container is started (see using WaitStrategy).
* </p>
*
* @param initScriptPath relative classpath resource
*/
public CassandraContainer withInitScript(String initScriptPath) {
this.initScriptPath = initScriptPath;
return self();
}

/**
* Get username
*
* By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml
* If username and password need to be used, then authenticator should be set as PasswordAuthenticator
* (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials
* user management should be modified
*/
public String getUsername() {
return USERNAME;
}

/**
* Get password
*
* By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml
* If username and password need to be used, then authenticator should be set as PasswordAuthenticator
* (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials
* user management should be modified
*/
public String getPassword() {
return PASSWORD;
}

/**
* Retrieve an {@link InetSocketAddress} for connecting to the Cassandra container via the driver.
*
* @return A InetSocketAddrss representation of this Cassandra container's host and port.
*/
public InetSocketAddress getContactPoint() {
return new InetSocketAddress(getHost(), getMappedPort(CQL_PORT));
}

/**
* Retrieve the Local Datacenter for connecting to the Cassandra container via the driver.
*
* @return The configured local Datacenter name.
*/
public String getLocalDatacenter() {
return getEnvMap().getOrDefault("CASSANDRA_DC", DEFAULT_LOCAL_DATACENTER);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.testcontainers.cassandra.delegate;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.testcontainers.cassandra.CassandraContainer;
import org.testcontainers.containers.Container;
import org.testcontainers.containers.ContainerState;
import org.testcontainers.containers.ExecConfig;
import org.testcontainers.delegate.AbstractDatabaseDelegate;
import org.testcontainers.ext.ScriptUtils.ScriptStatementFailedException;

import java.io.IOException;

/**
* Cassandra database delegate
*/
@Slf4j
@RequiredArgsConstructor
public class CassandraDatabaseDelegate extends AbstractDatabaseDelegate<Void> {

private final ContainerState container;

@Override
protected Void createNewConnection() {
// Return null here, because we run scripts using cqlsh command directly in the container.
// So, we don't use connection object in the execute() method.
return null;
}

@Override
public void execute(
String statement,
String scriptPath,
int lineNumber,
boolean continueOnError,
boolean ignoreFailedDrops
) {
try {
// Use cqlsh command directly inside the container to execute statements
// See documentation here: https://cassandra.apache.org/doc/stable/cassandra/tools/cqlsh.html
String[] cqlshCommand = new String[] { "cqlsh" };

if (this.container instanceof CassandraContainer) {
CassandraContainer cassandraContainer = (CassandraContainer) this.container;
String username = cassandraContainer.getUsername();
String password = cassandraContainer.getPassword();
cqlshCommand = ArrayUtils.addAll(cqlshCommand, "-u", username, "-p", password);
}

// If no statement specified, directly execute the script specified into scriptPath (using -f argument),
// otherwise execute the given statement (using -e argument).
String executeArg = "-e";
String executeArgValue = statement;
if (StringUtils.isBlank(statement)) {
executeArg = "-f";
executeArgValue = scriptPath;
}
cqlshCommand = ArrayUtils.addAll(cqlshCommand, executeArg, executeArgValue);

Container.ExecResult result =
this.container.execInContainer(ExecConfig.builder().command(cqlshCommand).build());
if (result.getExitCode() == 0) {
if (StringUtils.isBlank(statement)) {
log.info("CQL script {} successfully executed", scriptPath);
} else {
log.info("CQL statement {} was applied", statement);
}
} else {
log.error("CQL script execution failed with error: \n{}", result.getStderr());
throw new ScriptStatementFailedException(statement, lineNumber, scriptPath);
}
} catch (IOException | InterruptedException e) {
throw new ScriptStatementFailedException(statement, lineNumber, scriptPath, e);
}
}

@Override
protected void closeConnectionQuietly(Void session) {
// Nothing to do here, because we run scripts using cqlsh command directly in the container.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.testcontainers.cassandra.wait;

import org.rnorth.ducttape.TimeoutException;
import org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate;
import org.testcontainers.containers.ContainerLaunchException;
import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy;
import org.testcontainers.delegate.DatabaseDelegate;

import java.util.concurrent.TimeUnit;

import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess;

/**
* Waits until Cassandra returns its version
*/
public class CassandraQueryWaitStrategy extends AbstractWaitStrategy {

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

private static final String TIMEOUT_ERROR = "Timed out waiting for Cassandra to be accessible for query execution";

@Override
protected void waitUntilReady() {
// execute select version query until success or timeout
try {
retryUntilSuccess(
(int) startupTimeout.getSeconds(),
TimeUnit.SECONDS,
() -> {
getRateLimiter()
.doWhenReady(() -> {
try (DatabaseDelegate databaseDelegate = getDatabaseDelegate()) {
databaseDelegate.execute(SELECT_VERSION_QUERY, "", 1, false, false);
}
});
return true;
}
);
} catch (TimeoutException e) {
throw new ContainerLaunchException(TIMEOUT_ERROR);
}
}

private DatabaseDelegate getDatabaseDelegate() {
return new CassandraDatabaseDelegate(waitStrategyTarget);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
* Supported image: {@code cassandra}
* <p>
* Exposed ports: 9042
*
* @deprecated use {@link org.testcontainers.cassandra.CassandraContainer} instead.
*/
@Deprecated
public class CassandraContainer<SELF extends CassandraContainer<SELF>> extends GenericContainer<SELF> {

private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cassandra");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@

/**
* Cassandra database delegate
*
* @deprecated use {@link org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate} instead.
*/
@Slf4j
@RequiredArgsConstructor
@Deprecated
public class CassandraDatabaseDelegate extends AbstractDatabaseDelegate<Session> {

private final ContainerState container;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@

/**
* Waits until Cassandra returns its version
*
* @deprecated use {@link org.testcontainers.cassandra.wait.CassandraQueryWaitStrategy} instead.
*/
@Deprecated
public class CassandraQueryWaitStrategy extends AbstractWaitStrategy {

private static final String SELECT_VERSION_QUERY = "SELECT release_version FROM system.local";
Expand Down
Loading

0 comments on commit 88e4ac5

Please sign in to comment.