org.projectlombok
lombok
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/TarantoolCartridgeContainer.java b/testcontainers/src/main/java/org/testcontainers/containers/TarantoolCartridgeContainer.java
new file mode 100644
index 0000000..1509e88
--- /dev/null
+++ b/testcontainers/src/main/java/org/testcontainers/containers/TarantoolCartridgeContainer.java
@@ -0,0 +1,729 @@
+/*
+ * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY
+ * All Rights Reserved.
+ */
+
+package org.testcontainers.containers;
+
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Supplier;
+
+import static org.testcontainers.containers.utils.PathUtils.normalizePath;
+import com.github.dockerjava.api.command.InspectContainerResponse;
+import org.apache.commons.lang3.ArrayUtils;
+import org.testcontainers.containers.exceptions.CartridgeTopologyException;
+import org.testcontainers.containers.utils.CartridgeConfigParser;
+import org.testcontainers.containers.utils.SslContext;
+import org.testcontainers.images.builder.ImageFromDockerfile;
+
+/**
+ * Sets up a Tarantool Cartridge cluster and provides API for configuring it.
+ *
+ * The container constructors accept the classpath resources relative path to the instances.yml
+ * file, which contents may look like
+ *
+ *
+ *
+ * testapp.router:
+ * workdir: ./tmp/db_dev/3301
+ * advertise_uri: localhost:3301
+ * http_port: 8081
+ *
+ * testapp.s1-master:
+ * workdir: ./tmp/db_dev/3302
+ * advertise_uri: localhost:3302
+ * http_port: 8082
+ *
+ * testapp.s1-replica:
+ * workdir: ./tmp/db_dev/3303
+ * advertise_uri: localhost:3303
+ * http_port: 8083
+ *
+ * testapp.s2-master:
+ * workdir: ./tmp/db_dev/3304
+ * advertise_uri: localhost:3304
+ * http_port: 8084
+ *
+ * testapp.s2-replica:
+ * workdir: ./tmp/db_dev/3305
+ * advertise_uri: localhost:3305
+ * http_port: 8085
+ *
+ *
+ *
+ * and the classpath resources relative path to a topology bootstrap script, which contents may
+ * look like
+ *
+ *
+ *
+ * cartridge = require('cartridge')
+ * replicasets = {{
+ * alias = 'app-router',
+ * roles = {'vshard-router', 'app.roles.custom', 'app.roles.api_router'},
+ * join_servers = {{uri = 'localhost:3301'}}
+ * }, {
+ * alias = 's1-storage',
+ * roles = {'vshard-storage', 'app.roles.api_storage'},
+ * join_servers = {{uri = 'localhost:3302'}, {uri = 'localhost:3303'}}
+ * }, {
+ * alias = 's2-storage',
+ * roles = {'vshard-storage', 'app.roles.api_storage'},
+ * join_servers = {{uri = 'localhost:3304'}, {uri = 'localhost:3305'}}
+ * }}
+ * return cartridge.admin_edit_topology({replicasets = replicasets})
+ *
+ *
+ *
+ * After the topology changes are applied, the vshard bootstrap command will be executed.
+ *
+ *
The instances.yml file will be analyzed and the ports, specified in advertise_uri options
+ * together with the ports, specified in the http_port options, will be exposed.
+ */
+public class TarantoolCartridgeContainer extends GenericContainer
+ implements TarantoolContainerOperations {
+
+ protected static final String ROUTER_HOST = "localhost";
+ protected static final int ROUTER_PORT = 3301;
+ protected static final String CARTRIDGE_DEFAULT_USERNAME = "admin";
+ protected static final String CARTRIDGE_DEFAULT_PASSWORD = "secret-cluster-cookie";
+ protected static final String DOCKERFILE = "Dockerfile";
+ protected static final int API_PORT = 8081;
+ protected static final String VSHARD_BOOTSTRAP_COMMAND =
+ "return require('cartridge').admin_bootstrap_vshard()";
+ protected static final String SCRIPT_RESOURCE_DIRECTORY = "";
+ protected static final String INSTANCE_DIR = "/app";
+
+ public static final String ENV_TARANTOOL_VERSION = "TARANTOOL_VERSION";
+ public static final String ENV_TARANTOOL_SERVER_USER = "TARANTOOL_SERVER_USER";
+ public static final String ENV_TARANTOOL_SERVER_UID = "TARANTOOL_SERVER_UID";
+ public static final String ENV_TARANTOOL_SERVER_GROUP = "TARANTOOL_SERVER_GROUP";
+ public static final String ENV_TARANTOOL_SERVER_GID = "TARANTOOL_SERVER_GID";
+ public static final String ENV_TARANTOOL_WORKDIR = "TARANTOOL_WORKDIR";
+ public static final String ENV_TARANTOOL_RUNDIR = "TARANTOOL_RUNDIR";
+ public static final String ENV_TARANTOOL_LOGDIR = "TARANTOOL_LOGDIR";
+ public static final String ENV_TARANTOOL_DATADIR = "TARANTOOL_DATADIR";
+ public static final String ENV_TARANTOOL_INSTANCES_FILE = "TARANTOOL_INSTANCES_FILE";
+ public static final String ENV_TARANTOOL_CLUSTER_COOKIE = "TARANTOOL_CLUSTER_COOKIE";
+ protected static final String healthyCmd = "return require('cartridge').is_healthy()";
+ protected static final int TIMEOUT_ROUTER_UP_CARTRIDGE_HEALTH_IN_SECONDS = 60;
+
+ protected final CartridgeConfigParser instanceFileParser;
+ protected final TarantoolContainerClientHelper clientHelper;
+ protected final String TARANTOOL_RUN_DIR;
+
+ protected boolean useFixedPorts = false;
+ protected String routerHost = ROUTER_HOST;
+ protected int routerPort = ROUTER_PORT;
+ protected int apiPort = API_PORT;
+ protected String routerUsername = CARTRIDGE_DEFAULT_USERNAME;
+ protected String routerPassword = CARTRIDGE_DEFAULT_PASSWORD;
+ protected String directoryResourcePath = SCRIPT_RESOURCE_DIRECTORY;
+ protected String instanceDir = INSTANCE_DIR;
+ protected String topologyConfigurationFile;
+ protected String instancesFile;
+ protected SslContext sslContext;
+
+ /**
+ * Create a container with default image and specified instances file from the classpath
+ * resources. Assumes that there is a file named Dockerfile in the project resources classpath.
+ *
+ * @param instancesFile path to instances.yml, relative to the classpath resources
+ * @param topologyConfigurationFile path to a topology bootstrap script, relative to the classpath
+ * resources
+ */
+ public TarantoolCartridgeContainer(String instancesFile, String topologyConfigurationFile) {
+ this(DOCKERFILE, instancesFile, topologyConfigurationFile);
+ }
+
+ /**
+ * Create a container with default image and specified instances file from the classpath
+ * resources. Assumes that there is a file named Dockerfile in the project resources classpath.
+ *
+ * @param instancesFile path to instances.yml, relative to the classpath resources
+ * @param topologyConfigurationFile path to a topology bootstrap script, relative to the classpath
+ * resources
+ * @param buildArgs a map of arguments that will be passed to docker ARG commands on image build.
+ * This values can be overridden by environment.
+ */
+ public TarantoolCartridgeContainer(
+ String instancesFile, String topologyConfigurationFile, Map buildArgs) {
+ this(DOCKERFILE, "", instancesFile, topologyConfigurationFile, buildArgs);
+ }
+
+ /**
+ * Create a container with default image and specified instances file from the classpath resources
+ *
+ * @param dockerFile path to a Dockerfile which configures Cartridge and other necessary services
+ * @param instancesFile path to instances.yml, relative to the classpath resources
+ * @param topologyConfigurationFile path to a topology bootstrap script, relative to the classpath
+ * resources
+ */
+ public TarantoolCartridgeContainer(
+ String dockerFile, String instancesFile, String topologyConfigurationFile) {
+ this(dockerFile, "", instancesFile, topologyConfigurationFile);
+ }
+
+ /**
+ * Create a container with specified image and specified instances file from the classpath
+ * resources. By providing the result Cartridge container image name, you can cache the image and
+ * avoid rebuilding on each test run (the image is tagged with the provided name and not deleted
+ * after tests finishing).
+ *
+ * @param dockerFile URL resource path to a Dockerfile which configures Cartridge and other
+ * necessary services
+ * @param buildImageName Specify a stable image name for the test container to prevent rebuilds
+ * @param instancesFile URL resource path to instances.yml relative in the classpath
+ * @param topologyConfigurationFile URL resource path to a topology bootstrap script in the
+ * classpath
+ */
+ public TarantoolCartridgeContainer(
+ String dockerFile,
+ String buildImageName,
+ String instancesFile,
+ String topologyConfigurationFile) {
+ this(
+ dockerFile,
+ buildImageName,
+ instancesFile,
+ topologyConfigurationFile,
+ Collections.emptyMap());
+ }
+
+ /**
+ * Create a container with specified image and specified instances file from the classpath
+ * resources. By providing the result Cartridge container image name, you can cache the image and
+ * avoid rebuilding on each test run (the image is tagged with the provided name and not deleted
+ * after tests finishing).
+ *
+ * @param dockerFile URL resource path to a Dockerfile which configures Cartridge and other
+ * necessary services
+ * @param buildImageName Specify a stable image name for the test container to prevent rebuilds
+ * @param instancesFile URL resource path to instances.yml relative in the classpath
+ * @param topologyConfigurationFile URL resource path to a topology bootstrap script in the
+ * classpath
+ * @param buildArgs a map of arguments that will be passed to docker ARG commands on image build.
+ * This values can be overridden by environment.
+ */
+ public TarantoolCartridgeContainer(
+ String dockerFile,
+ String buildImageName,
+ String instancesFile,
+ String topologyConfigurationFile,
+ final Map buildArgs) {
+ this(
+ buildImage(dockerFile, buildImageName, buildArgs),
+ instancesFile,
+ topologyConfigurationFile,
+ buildArgs);
+ }
+
+ protected TarantoolCartridgeContainer(
+ ImageFromDockerfile image,
+ String instancesFile,
+ String topologyConfigurationFile,
+ Map buildArgs) {
+ super(withBuildArgs(image, buildArgs));
+
+ TARANTOOL_RUN_DIR =
+ mergeBuildArguments(buildArgs).getOrDefault(ENV_TARANTOOL_RUNDIR, "/tmp/run");
+
+ if (instancesFile == null || instancesFile.isEmpty()) {
+ throw new IllegalArgumentException("Instance file name must not be null or empty");
+ }
+ if (topologyConfigurationFile == null || topologyConfigurationFile.isEmpty()) {
+ throw new IllegalArgumentException("Topology configuration file must not be null or empty");
+ }
+ this.instancesFile = instancesFile;
+ this.topologyConfigurationFile = topologyConfigurationFile;
+ this.instanceFileParser = new CartridgeConfigParser(instancesFile);
+ this.clientHelper = new TarantoolContainerClientHelper(this);
+ }
+
+ protected static ImageFromDockerfile withBuildArgs(
+ ImageFromDockerfile image, Map buildArgs) {
+ Map args = mergeBuildArguments(buildArgs);
+
+ if (!args.isEmpty()) {
+ image.withBuildArgs(args);
+ }
+
+ return image;
+ }
+
+ public TarantoolCartridgeContainer withFixedExposedPort(int hostPort, int containerPort) {
+ super.addFixedExposedPort(hostPort, containerPort);
+ return this;
+ }
+
+ public TarantoolCartridgeContainer withExposedPort(Integer port) {
+ super.addExposedPort(port);
+ return this;
+ }
+
+ protected static Map mergeBuildArguments(Map buildArgs) {
+ Map args = new HashMap<>(buildArgs);
+
+ for (String envVariable :
+ Arrays.asList(
+ ENV_TARANTOOL_VERSION,
+ ENV_TARANTOOL_SERVER_USER,
+ ENV_TARANTOOL_SERVER_UID,
+ ENV_TARANTOOL_SERVER_GROUP,
+ ENV_TARANTOOL_SERVER_GID,
+ ENV_TARANTOOL_WORKDIR,
+ ENV_TARANTOOL_RUNDIR,
+ ENV_TARANTOOL_LOGDIR,
+ ENV_TARANTOOL_DATADIR,
+ ENV_TARANTOOL_INSTANCES_FILE,
+ ENV_TARANTOOL_CLUSTER_COOKIE)) {
+ String variableValue = System.getenv(envVariable);
+ if (variableValue != null && !args.containsKey(envVariable)) {
+ args.put(envVariable, variableValue);
+ }
+ }
+ return args;
+ }
+
+ protected static ImageFromDockerfile buildImage(
+ String dockerFile, String buildImageName, final Map buildArgs) {
+ ImageFromDockerfile image;
+ if (buildImageName != null && !buildImageName.isEmpty()) {
+ image = new ImageFromDockerfile(buildImageName, false);
+ } else {
+ image = new ImageFromDockerfile();
+ }
+ return image
+ .withFileFromClasspath("Dockerfile", dockerFile)
+ .withFileFromClasspath(
+ "cartridge",
+ buildArgs.get("CARTRIDGE_SRC_DIR") == null
+ ? "cartridge"
+ : buildArgs.get("CARTRIDGE_SRC_DIR"));
+ }
+
+ /**
+ * Get the router host
+ *
+ * @return router hostname
+ */
+ public String getRouterHost() {
+ return routerHost;
+ }
+
+ /**
+ * Get the router port
+ *
+ * @return router mapped port
+ */
+ public int getRouterPort() {
+ if (useFixedPorts) {
+ return routerPort;
+ }
+ return getMappedPort(routerPort);
+ }
+
+ /**
+ * Get the user name for connecting to the router
+ *
+ * @return a user name
+ */
+ public String getRouterUsername() {
+ return routerUsername;
+ }
+
+ /**
+ * Get the user password for connecting to the router
+ *
+ * @return a user password
+ */
+ public String getRouterPassword() {
+ return routerPassword;
+ }
+
+ @Override
+ public String getHost() {
+ return getRouterHost();
+ }
+
+ @Override
+ public int getPort() {
+ return getRouterPort();
+ }
+
+ @Override
+ public String getUsername() {
+ return getRouterUsername();
+ }
+
+ @Override
+ public String getPassword() {
+ return getRouterPassword();
+ }
+
+ @Override
+ public String getDirectoryBinding() {
+ return directoryResourcePath;
+ }
+
+ /**
+ * Specify the directory inside container that the resource directory will be mounted to. The
+ * default value is "/app".
+ *
+ * @param instanceDir valid directory path
+ * @return this container instance
+ */
+ public TarantoolCartridgeContainer withInstanceDir(String instanceDir) {
+ checkNotRunning();
+ this.instanceDir = instanceDir;
+ return this;
+ }
+
+ @Override
+ public String getInstanceDir() {
+ return instanceDir;
+ }
+
+ @Override
+ public int getInternalPort() {
+ return routerPort;
+ }
+
+ /**
+ * Get Cartridge router HTTP API hostname
+ *
+ * @return HTTP API hostname
+ */
+ public String getAPIHost() {
+ return routerHost;
+ }
+
+ /** Checks if already running and if so raises an exception to prevent too-late setters. */
+ protected void checkNotRunning() {
+ if (isRunning()) {
+ throw new IllegalStateException(
+ "This option can be changed only before the container is running");
+ }
+ }
+
+ /**
+ * Specify the root directory of a Cartridge project relative to the resource classpath. The
+ * default directory is the root resource directory.
+ *
+ * @param directoryResourcePath a valid directory path
+ * @return this container instance
+ */
+ public TarantoolCartridgeContainer withDirectoryBinding(String directoryResourcePath) {
+ checkNotRunning();
+ URL resource = getClass().getClassLoader().getResource(directoryResourcePath);
+ if (resource == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "No resource path found for the specified resource %s", directoryResourcePath));
+ }
+ this.directoryResourcePath = normalizePath(resource.getPath());
+ return this;
+ }
+
+ /**
+ * Get Cartridge router HTTP API port
+ *
+ * @return HTTP API port
+ */
+ public int getAPIPort() {
+ if (useFixedPorts) {
+ return apiPort;
+ }
+ return getMappedPort(apiPort);
+ }
+
+ /**
+ * Use fixed ports binding. Defaults to false.
+ *
+ * @param useFixedPorts fixed ports for tarantool
+ * @return HTTP API port
+ */
+ public TarantoolCartridgeContainer withUseFixedPorts(boolean useFixedPorts) {
+ this.useFixedPorts = useFixedPorts;
+ return this;
+ }
+
+ /**
+ * Set Cartridge router hostname
+ *
+ * @param routerHost a hostname, default is "localhost"
+ * @return this container instance
+ */
+ public TarantoolCartridgeContainer withRouterHost(String routerHost) {
+ checkNotRunning();
+ this.routerHost = routerHost;
+ return this;
+ }
+
+ /**
+ * Set Cartridge router binary port
+ *
+ * @param routerPort router Tarantool node port, usually 3301
+ * @return this container instance
+ */
+ public TarantoolCartridgeContainer withRouterPort(int routerPort) {
+ checkNotRunning();
+ this.routerPort = routerPort;
+ return this;
+ }
+
+ /**
+ * Set Cartridge router HTTP API port
+ *
+ * @param apiPort HTTP API port, usually 8081
+ * @return this container instance
+ */
+ public TarantoolCartridgeContainer withAPIPort(int apiPort) {
+ checkNotRunning();
+ this.apiPort = apiPort;
+ return this;
+ }
+
+ /**
+ * Set the username for accessing the router node
+ *
+ * @param routerUsername a user name, default is "admin"
+ * @return this container instance
+ */
+ public TarantoolCartridgeContainer withRouterUsername(String routerUsername) {
+ checkNotRunning();
+ this.routerUsername = routerUsername;
+ return this;
+ }
+
+ /**
+ * Set the user password for accessing the router node
+ *
+ * @param routerPassword a user password, usually is a value of the "cluster_cookie" option in
+ * cartridge.cfg({...})
+ * @return this container instance
+ */
+ public TarantoolCartridgeContainer withRouterPassword(String routerPassword) {
+ checkNotRunning();
+ this.routerPassword = routerPassword;
+ return this;
+ }
+
+ @Override
+ protected void configure() {
+ if (!getDirectoryBinding().isEmpty()) {
+ withFileSystemBind(getDirectoryBinding(), getInstanceDir(), BindMode.READ_WRITE);
+ }
+ if (useFixedPorts) {
+ for (Integer port : instanceFileParser.getExposablePorts()) {
+ addFixedExposedPort(port, port);
+ }
+ } else {
+ addExposedPorts(ArrayUtils.toPrimitive(instanceFileParser.getExposablePorts()));
+ }
+ }
+
+ @Override
+ protected void containerIsStarting(InspectContainerResponse containerInfo) {
+ logger().info("Tarantool Cartridge cluster is starting");
+ }
+
+ protected boolean setupTopology() {
+ String fileType =
+ topologyConfigurationFile.substring(topologyConfigurationFile.lastIndexOf('.') + 1);
+ if (fileType.equals("yml")) {
+ String replicasetsFileName =
+ topologyConfigurationFile.substring(topologyConfigurationFile.lastIndexOf('/') + 1);
+ String instancesFileName = instancesFile.substring(instancesFile.lastIndexOf('/') + 1);
+ try {
+ ExecResult result =
+ execInContainer(
+ "cartridge",
+ "replicasets",
+ "--run-dir=" + TARANTOOL_RUN_DIR,
+ "--file=" + replicasetsFileName,
+ "--cfg=" + instancesFileName,
+ "setup",
+ "--bootstrap-vshard");
+ if (result.getExitCode() != 0) {
+ throw new CartridgeTopologyException(
+ "Failed to change the app topology via cartridge CLI: " + result.getStdout());
+ }
+ } catch (Exception e) {
+ throw new CartridgeTopologyException(e);
+ }
+
+ } else {
+ try {
+ List> res = executeScriptDecoded(topologyConfigurationFile);
+ if (res.size() >= 2 && res.get(1) != null && res.get(1) instanceof Map) {
+ HashMap, ?> error = ((HashMap, ?>) res.get(1));
+ // that means topology already exists
+ return error.get("str").toString().contains("collision with another server");
+ }
+ // The client connection will be closed after that command
+ } catch (Exception e) {
+ if (e instanceof ExecutionException) {
+ if (e.getCause() instanceof TimeoutException) {
+ return true;
+ // Do nothing, the cluster is reloading
+ }
+ } else {
+ throw new CartridgeTopologyException(e);
+ }
+ }
+ }
+ return true;
+ }
+
+ protected void retryingSetupTopology() {
+ if (!setupTopology()) {
+ try {
+ logger().info("Retrying setup topology in 10 seconds");
+ Thread.sleep(10_000);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ if (!setupTopology()) {
+ throw new CartridgeTopologyException("Failed to change the app topology after retry");
+ }
+ }
+ }
+
+ protected void bootstrapVshard() {
+ try {
+ executeCommand(VSHARD_BOOTSTRAP_COMMAND);
+ } catch (Exception e) {
+ logger().error("Failed to bootstrap vshard cluster", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) {
+ super.containerIsStarted(containerInfo, reused);
+
+ waitUntilRouterIsUp(TIMEOUT_ROUTER_UP_CARTRIDGE_HEALTH_IN_SECONDS);
+ retryingSetupTopology();
+ // wait until Roles are configured
+ waitUntilCartridgeIsHealthy(TIMEOUT_ROUTER_UP_CARTRIDGE_HEALTH_IN_SECONDS);
+ bootstrapVshard();
+
+ logger().info("Tarantool Cartridge cluster is started");
+ logger()
+ .info("Tarantool Cartridge router is listening at {}:{}", getRouterHost(), getRouterPort());
+ logger().info("Tarantool Cartridge HTTP API is available at {}:{}", getAPIHost(), getAPIPort());
+ }
+
+ protected void waitUntilRouterIsUp(int secondsToWait) {
+ if (!waitUntilTrue(secondsToWait, this::routerIsUp)) {
+ throw new RuntimeException(
+ "Timeout exceeded during router starting stage." + " See the specific error in logs.");
+ }
+ }
+
+ protected void waitUntilCartridgeIsHealthy(int secondsToWait) {
+ if (!waitUntilTrue(secondsToWait, this::isCartridgeHealthy)) {
+ throw new RuntimeException(
+ "Timeout exceeded during cartridge topology applying stage."
+ + " See the specific error in logs.");
+ }
+ }
+
+ protected boolean waitUntilTrue(int secondsToWait, Supplier waitFunc) {
+ int secondsPassed = 0;
+ boolean result = waitFunc.get();
+ while (!result && secondsPassed < secondsToWait) {
+ result = waitFunc.get();
+ try {
+ Thread.sleep(1_000);
+ secondsPassed++;
+ } catch (InterruptedException e) {
+ break;
+ }
+ }
+ return result;
+ }
+
+ protected boolean routerIsUp() {
+ ExecResult result;
+ try {
+ result = executeCommand(healthyCmd);
+ if (result.getExitCode() != 0
+ && result.getStderr().contains("Connection refused")
+ && result.getStdout().isEmpty()) {
+ return false;
+ } else if (result.getExitCode() != 0) {
+ logger()
+ .error(
+ "exit code: {}, stdout: {}, stderr: {}",
+ result.getExitCode(),
+ result.getStdout(),
+ result.getStderr());
+ return false;
+ } else {
+ return true;
+ }
+ } catch (Exception e) {
+ logger().error(e.getMessage());
+ return false;
+ }
+ }
+
+ protected boolean isCartridgeHealthy() {
+ ExecResult result;
+ try {
+ result = executeCommand(healthyCmd);
+ if (result.getExitCode() != 0) {
+ logger()
+ .error(
+ "exitCode: {}, stdout: {}, stderr: {}",
+ result.getExitCode(),
+ result.getStdout(),
+ result.getStderr());
+ return false;
+ } else if (result.getStdout().startsWith("---\n- null\n")) {
+ return false;
+ } else if (result.getStdout().contains("true")) {
+ return true;
+ } else {
+ logger()
+ .warn(
+ "exitCode: {}, stdout: {}, stderr: {}",
+ result.getExitCode(),
+ result.getStdout(),
+ result.getStderr());
+ return false;
+ }
+ } catch (Exception e) {
+ logger().error("Error while waiting for cartridge healthy state: " + e.getMessage());
+ return false;
+ }
+ }
+
+ @Override
+ public ExecResult executeScript(String scriptResourcePath) throws Exception {
+ return clientHelper.executeScript(scriptResourcePath, this.sslContext);
+ }
+
+ @Override
+ public T executeScriptDecoded(String scriptResourcePath) throws Exception {
+ return clientHelper.executeScriptDecoded(scriptResourcePath, this.sslContext);
+ }
+
+ @Override
+ public ExecResult executeCommand(String command) throws Exception {
+ return clientHelper.executeCommand(command, this.sslContext);
+ }
+
+ @Override
+ public T executeCommandDecoded(String command) throws Exception {
+ return clientHelper.executeCommandDecoded(command, this.sslContext);
+ }
+}
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/TarantoolContainerClientHelper.java b/testcontainers/src/main/java/org/testcontainers/containers/TarantoolContainerClientHelper.java
new file mode 100644
index 0000000..0a9a10b
--- /dev/null
+++ b/testcontainers/src/main/java/org/testcontainers/containers/TarantoolContainerClientHelper.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY
+ * All Rights Reserved.
+ */
+
+package org.testcontainers.containers;
+
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.util.concurrent.ExecutionException;
+
+import static org.testcontainers.containers.utils.PathUtils.normalizePath;
+import org.testcontainers.containers.utils.SslContext;
+import org.testcontainers.utility.MountableFile;
+import org.yaml.snakeyaml.Yaml;
+
+/** Helper class for executing commands on Tarantool containers. */
+public final class TarantoolContainerClientHelper {
+
+ private static final String TMP_DIR = "/tmp";
+ private static final Yaml yaml = new Yaml();
+
+ private final TarantoolContainerOperations extends Container>> container;
+ private final String EXECUTE_SCRIPT_ERROR_TEMPLATE =
+ "Executed script %s with exit code %d, stderr: \"%s\", stdout: \"%s\"";
+ private static final String EXECUTE_COMMAND_ERROR_TEMPLATE =
+ "Executed command \"%s\" with exit code %d, stderr: \"%s\", stdout: \"%s\"";
+ // Generates bash command witch creates executable lua file with connection to required node
+ // and evaluation of needed lua code
+ private static final String MTLS_COMMAND_TEMPLATE =
+ "echo \" print(require('yaml').encode( {require('net.box').connect( {"
+ + " uri='%s:%d', params = { transport='ssl', ssl_key_file = '%s', ssl_cert_file = '%s'"
+ + " }}, { user = '%s', password = '%s' } ):eval('%s')}) "
+ + " ); os.exit(); \" > container-tmp.lua && tarantool container-tmp.lua";
+ private static final String SSL_COMMAND_TEMPLATE =
+ "echo \" "
+ + " print(require('yaml').encode( "
+ + " {require('net.box').connect( "
+ + " { uri='%s:%d', params = { transport='ssl' }}, "
+ + " { user = '%s', password = '%s' } "
+ + " ):eval('%s')}) "
+ + " ); "
+ + " os.exit(); "
+ + "\" > container-tmp.lua &&"
+ + " tarantool container-tmp.lua";
+ private static final String COMMAND_TEMPLATE =
+ "echo \" "
+ + " print(require('yaml').encode( "
+ + " {require('net.box').connect( "
+ + " '%s:%d', "
+ + " { user = '%s', password = '%s' } "
+ + " ):eval('%s')}) "
+ + " ); "
+ + " os.exit(); "
+ + "\" > container-tmp.lua &&"
+ + " tarantool container-tmp.lua";
+
+ public TarantoolContainerClientHelper(
+ TarantoolContainerOperations extends Container>> container) {
+ this.container = container;
+ }
+
+ public Container.ExecResult executeScript(String scriptResourcePath, SslContext sslContext)
+ throws IOException, InterruptedException {
+ if (!container.isRunning()) {
+ throw new IllegalStateException("Cannot execute scripts in stopped container");
+ }
+
+ String scriptName = Paths.get(scriptResourcePath).getFileName().toString();
+ String containerPath = normalizePath(Paths.get(TMP_DIR, scriptName));
+ container.copyFileToContainer(
+ MountableFile.forClasspathResource(scriptResourcePath), containerPath);
+ return executeCommand(String.format("return dofile('%s')", containerPath), sslContext);
+ }
+
+ public T executeScriptDecoded(String scriptResourcePath, SslContext sslContext)
+ throws IOException, InterruptedException, ExecutionException {
+ Container.ExecResult result = executeScript(scriptResourcePath, sslContext);
+
+ if (result.getExitCode() != 0) {
+
+ if (result.getExitCode() == 3 || result.getExitCode() == 1) {
+ throw new ExecutionException(
+ String.format(
+ EXECUTE_SCRIPT_ERROR_TEMPLATE,
+ scriptResourcePath,
+ result.getExitCode(),
+ result.getStderr(),
+ result.getStdout()),
+ new Throwable());
+ }
+
+ throw new IllegalStateException(
+ String.format(
+ EXECUTE_SCRIPT_ERROR_TEMPLATE,
+ scriptResourcePath,
+ result.getExitCode(),
+ result.getStderr(),
+ result.getStdout()));
+ }
+
+ return yaml.load(result.getStdout());
+ }
+
+ public Container.ExecResult executeCommand(String command, SslContext sslContext)
+ throws IOException, InterruptedException {
+ if (!container.isRunning()) {
+ throw new IllegalStateException("Cannot execute commands in stopped container");
+ }
+
+ command = command.replace("\"", "\\\"");
+ command = command.replace("\'", "\\\'");
+
+ String bashCommand;
+ if (sslContext == null) { // No SSL
+ bashCommand =
+ String.format(
+ COMMAND_TEMPLATE,
+ container.getHost(),
+ container.getInternalPort(),
+ container.getUsername(),
+ container.getPassword(),
+ command);
+ } else if (sslContext.getKeyFile() != null && sslContext.getCertFile() != null) { // mTLS
+ bashCommand =
+ String.format(
+ MTLS_COMMAND_TEMPLATE,
+ container.getHost(),
+ container.getInternalPort(),
+ sslContext.getKeyFile(),
+ sslContext.getCertFile(),
+ container.getUsername(),
+ container.getPassword(),
+ command);
+ } else { // SSL
+ bashCommand =
+ String.format(
+ SSL_COMMAND_TEMPLATE,
+ container.getHost(),
+ container.getInternalPort(),
+ container.getUsername(),
+ container.getPassword(),
+ command);
+ }
+
+ return container.execInContainer("sh", "-c", bashCommand);
+ }
+
+ public T executeCommandDecoded(String command, SslContext sslContext)
+ throws IOException, InterruptedException {
+ Container.ExecResult result = executeCommand(command, sslContext);
+
+ if (result.getExitCode() != 0) {
+ throw new IllegalStateException(
+ String.format(
+ EXECUTE_COMMAND_ERROR_TEMPLATE,
+ command,
+ result.getExitCode(),
+ result.getStderr(),
+ result.getStdout()));
+ }
+
+ return yaml.load(result.getStdout());
+ }
+}
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/TarantoolContainerOperations.java b/testcontainers/src/main/java/org/testcontainers/containers/TarantoolContainerOperations.java
new file mode 100644
index 0000000..80132e5
--- /dev/null
+++ b/testcontainers/src/main/java/org/testcontainers/containers/TarantoolContainerOperations.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY
+ * All Rights Reserved.
+ */
+
+package org.testcontainers.containers;
+
+/** Represents operations available on a Tarantool Container */
+public interface TarantoolContainerOperations> extends Container {
+ /**
+ * Get the Tarantool server exposed port for connecting the client to
+ *
+ * @return a port
+ */
+ int getPort();
+
+ /**
+ * Get the Tarantool user name for connecting the client with
+ *
+ * @return a user name
+ */
+ String getUsername();
+
+ /**
+ * Get the Tarantool user password for connecting the client with
+ *
+ * @return a user password
+ */
+ String getPassword();
+
+ /**
+ * Get the app scripts directory
+ *
+ * @return the app directory
+ */
+ String getDirectoryBinding();
+
+ /**
+ * Get the app scripts directory in the container
+ *
+ * @return the app scripts directory
+ */
+ String getInstanceDir();
+
+ /**
+ * Get the Tarantool server internal port for client connections
+ *
+ * @return a port
+ */
+ int getInternalPort();
+
+ /**
+ * Execute a local script in the Tarantool instance. The path must be classpath-relative.
+ * `dofile()` function is executed internally, so possible exceptions will be caught as the client
+ * exceptions.
+ *
+ * @param scriptResourcePath the classpath resource path to a script
+ * @return script execution result
+ * @throws Exception if failed to connect to the instance or execution fails
+ */
+ Container.ExecResult executeScript(String scriptResourcePath) throws Exception;
+
+ /**
+ * Execute a local script in the Tarantool instance. The path must be classpath-relative.
+ * `dofile()` function is executed internally, so possible exceptions will be caught as the client
+ * exceptions.
+ *
+ * @param the result of script
+ * @param scriptResourcePath the classpath resource path to a script
+ * @return script execution result in {@link Container.ExecResult}
+ * @throws Exception if failed to connect to the instance or execution fails
+ */
+ V executeScriptDecoded(String scriptResourcePath) throws Exception;
+
+ /**
+ * Execute a command in the Tarantool instance. Example of a command: `return 1 + 2, 'foo'`
+ *
+ * @param command a valid Lua command or a sequence of Lua commands
+ * @return command execution result
+ * @throws Exception if failed to connect to the instance or execution fails
+ */
+ Container.ExecResult executeCommand(String command) throws Exception;
+
+ /**
+ * Execute a command in the Tarantool instance. Example of a command: `return 1 + 2, 'foo'`
+ *
+ * @param the result of script
+ * @param command a valid Lua command or a sequence of Lua commands
+ * @return command execution result in {@link Container.ExecResult}
+ * @throws Exception if failed to connect to the instance or execution fails
+ */
+ V executeCommandDecoded(String command) throws Exception;
+}
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/VshardClusterContainer.java b/testcontainers/src/main/java/org/testcontainers/containers/VshardClusterContainer.java
index a33b459..315bbfd 100644
--- a/testcontainers/src/main/java/org/testcontainers/containers/VshardClusterContainer.java
+++ b/testcontainers/src/main/java/org/testcontainers/containers/VshardClusterContainer.java
@@ -15,10 +15,11 @@
import java.util.concurrent.ExecutionException;
import java.util.function.Supplier;
-import static org.testcontainers.containers.PathUtils.normalizePath;
+import static org.testcontainers.containers.utils.PathUtils.normalizePath;
import com.github.dockerjava.api.command.InspectContainerResponse;
import lombok.Getter;
import org.apache.commons.lang3.ArrayUtils;
+import org.testcontainers.containers.utils.SslContext;
import org.testcontainers.images.builder.ImageFromDockerfile;
/**
@@ -236,6 +237,10 @@ public int getInternalPort() {
return routerPort;
}
+ public Integer getMappedPort(int containerPort) {
+ return super.getMappedPort(containerPort);
+ }
+
public String getAPIHost() {
return routerHost;
}
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/exceptions/CartridgeTopologyException.java b/testcontainers/src/main/java/org/testcontainers/containers/exceptions/CartridgeTopologyException.java
new file mode 100644
index 0000000..80fa72c
--- /dev/null
+++ b/testcontainers/src/main/java/org/testcontainers/containers/exceptions/CartridgeTopologyException.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY
+ * All Rights Reserved.
+ */
+
+package org.testcontainers.containers.exceptions;
+
+public class CartridgeTopologyException extends RuntimeException {
+ static final String errorMsg = "Failed to change the app topology";
+
+ public CartridgeTopologyException(String message) {
+ super(message);
+ }
+
+ public CartridgeTopologyException(Throwable cause) {
+ super(errorMsg, cause);
+ }
+}
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/tarantool/Tarantool2Container.java b/testcontainers/src/main/java/org/testcontainers/containers/tarantool/Tarantool2Container.java
index a3dbb0c..db08c42 100644
--- a/testcontainers/src/main/java/org/testcontainers/containers/tarantool/Tarantool2Container.java
+++ b/testcontainers/src/main/java/org/testcontainers/containers/tarantool/Tarantool2Container.java
@@ -14,24 +14,13 @@
import com.github.dockerjava.api.command.InspectContainerResponse;
import org.testcontainers.containers.BindMode;
+import org.testcontainers.containers.Container;
import org.testcontainers.containers.ContainerLaunchException;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.SelinuxContext;
import org.testcontainers.containers.utils.Utils;
import org.testcontainers.utility.DockerImageName;
-/**
- * Testcontainers for Tarantool version 2.11.x.
- *
- * @implNote The implementation assumes that you will not configure the following parameters in the
- * init script (they adjusted automatically):
- *
- * - {@code listen}
- *
- {@code memtx_dir}
- *
- {@code wal_dir}
- *
- {@code vinyl_dir}
- *
- */
public class Tarantool2Container extends GenericContainer
implements TarantoolContainer {
@@ -45,11 +34,15 @@ public class Tarantool2Container extends GenericContainer
private boolean configured;
+ private final TarantoolContainerLuaExecutor luaExecutor;
+
private Tarantool2Container(DockerImageName dockerImageName, String initScript, String node) {
super(dockerImageName);
this.node = node;
this.initScript = initScript;
this.mountPath = Utils.createTempDirectory(this.node);
+ this.luaExecutor =
+ new TarantoolContainerLuaExecutor(this, TarantoolContainer.DEFAULT_TARANTOOL_PORT);
}
@Override
@@ -150,6 +143,31 @@ public InetSocketAddress internalAddress() {
return new InetSocketAddress(this.node, TarantoolContainer.DEFAULT_TARANTOOL_PORT);
}
+ public String getExecResult(String command) throws Exception {
+ return this.luaExecutor.getExecResult(command);
+ }
+
+ public Container.ExecResult executeCommand(String command)
+ throws IOException, InterruptedException {
+ return luaExecutor.executeCommand(command);
+ }
+
+ public Container.ExecResult executeCommand(
+ String command, org.testcontainers.containers.utils.SslContext sslContext)
+ throws IOException, InterruptedException {
+ return luaExecutor.executeCommand(command, sslContext);
+ }
+
+ public T executeCommandDecoded(String command) throws IOException, InterruptedException {
+ return luaExecutor.executeCommandDecoded(command);
+ }
+
+ public T executeCommandDecoded(
+ String command, org.testcontainers.containers.utils.SslContext sslContext)
+ throws IOException, InterruptedException {
+ return luaExecutor.executeCommandDecoded(command, sslContext);
+ }
+
public static Builder builder(DockerImageName image, Path initScriptPath) {
try {
final String rawScript =
@@ -194,7 +212,7 @@ private static void validateName(String node) {
return;
}
- if (node.strip().isEmpty()) {
+ if (node.isBlank()) {
throw new ContainerLaunchException("instance name can't be blank");
}
}
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/tarantool/Tarantool3Container.java b/testcontainers/src/main/java/org/testcontainers/containers/tarantool/Tarantool3Container.java
index b57e0ef..19dae2e 100644
--- a/testcontainers/src/main/java/org/testcontainers/containers/tarantool/Tarantool3Container.java
+++ b/testcontainers/src/main/java/org/testcontainers/containers/tarantool/Tarantool3Container.java
@@ -5,6 +5,7 @@
package org.testcontainers.containers.tarantool;
+import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -22,6 +23,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.BindMode;
+import org.testcontainers.containers.Container;
import org.testcontainers.containers.ContainerLaunchException;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.SelinuxContext;
@@ -56,6 +58,8 @@ public class Tarantool3Container extends GenericContainer
private boolean configured;
+ private final TarantoolContainerLuaExecutor luaExecutor;
+
public Tarantool3Container(DockerImageName dockerImageName, String node) {
super(dockerImageName);
this.instanceUuid = UUID.randomUUID();
@@ -66,6 +70,8 @@ public Tarantool3Container(DockerImageName dockerImageName, String node) {
this.lock = new ReentrantLock();
this.isClosed = new AtomicBoolean();
this.etcdAddresses = new ArrayList<>(1);
+ this.luaExecutor =
+ new TarantoolContainerLuaExecutor(this, TarantoolContainer.DEFAULT_TARANTOOL_PORT);
}
public Tarantool3Container withEtcdAddresses(HttpHost... etcdAddresses) {
@@ -88,6 +94,10 @@ public Tarantool3Container withEtcdPrefix(String etcdPrefix) {
}
}
+ public String getExecResult(String command) throws Exception {
+ return this.luaExecutor.getExecResult(command);
+ }
+
@Override
public Tarantool3Container withConfigPath(Path configPath) {
try {
@@ -204,15 +214,18 @@ protected void configure() {
withEnv("TT_MEMTX_DIR", DATA_DIR_STR);
if (this.etcdAddresses.isEmpty()) {
- LOGGER.warn(
- "Tarantool will use the configuration from the local file system because no etcd"
- + " cluster addresses were passed");
- withEnv(
- "TT_CONFIG",
- TarantoolContainer.DEFAULT_DATA_DIR
- .resolve(this.configPath.getFileName())
- .toAbsolutePath()
- .toString());
+ if (this.configPath != null && Files.isRegularFile(this.configPath)) {
+ LOGGER.info(
+ "Tarantool will use the configuration from the local file: {}", this.configPath);
+ withEnv(
+ "TT_CONFIG",
+ TarantoolContainer.DEFAULT_DATA_DIR
+ .resolve(this.configPath.getFileName())
+ .toAbsolutePath()
+ .toString());
+ } else {
+ LOGGER.warn("No config file provided; Tarantool 3 will use default configuration");
+ }
} else {
LOGGER.warn(
"Tarantool will use the configuration from the etcd cluster. Endpoints : {}",
@@ -261,6 +274,27 @@ public void stop() {
}
}
+ public Container.ExecResult executeCommand(String command)
+ throws IOException, InterruptedException {
+ return luaExecutor.executeCommand(command);
+ }
+
+ public Container.ExecResult executeCommand(
+ String command, org.testcontainers.containers.utils.SslContext sslContext)
+ throws IOException, InterruptedException {
+ return luaExecutor.executeCommand(command, sslContext);
+ }
+
+ public T executeCommandDecoded(String command) throws IOException, InterruptedException {
+ return luaExecutor.executeCommandDecoded(command);
+ }
+
+ public T executeCommandDecoded(
+ String command, org.testcontainers.containers.utils.SslContext sslContext)
+ throws IOException, InterruptedException {
+ return luaExecutor.executeCommandDecoded(command, sslContext);
+ }
+
private static String joinEtcdAddresses(List etcdAddresses) {
if (etcdAddresses == null || etcdAddresses.isEmpty()) {
return null;
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/tarantool/TarantoolContainerImpl.java b/testcontainers/src/main/java/org/testcontainers/containers/tarantool/TarantoolContainerImpl.java
new file mode 100644
index 0000000..022c9f2
--- /dev/null
+++ b/testcontainers/src/main/java/org/testcontainers/containers/tarantool/TarantoolContainerImpl.java
@@ -0,0 +1,362 @@
+/*
+ * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY
+ * All Rights Reserved.
+ */
+
+package org.testcontainers.containers.tarantool;
+
+import java.net.URL;
+import java.nio.file.Paths;
+import java.util.concurrent.Future;
+
+import static org.testcontainers.containers.utils.PathUtils.normalizePath;
+import com.github.dockerjava.api.command.InspectContainerResponse;
+import org.testcontainers.containers.BindMode;
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.TarantoolContainerClientHelper;
+import org.testcontainers.containers.TarantoolContainerOperations;
+import org.testcontainers.containers.utils.SslContext;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+/**
+ * Sets up a Tarantool instance and provides API for configuring it.
+ *
+ * @author Alexey Kuzin
+ * @author Ivan Dneprov
+ */
+public class TarantoolContainerImpl extends GenericContainer
+ implements TarantoolContainerOperations {
+
+ public static final String DEFAULT_IMAGE = "tarantool/tarantool";
+ public static final String DEFAULT_TAG = "2.11.8-ubuntu20.04";
+ public static final String DEFAULT_BASE_IMAGE =
+ String.format("%s:%s", DEFAULT_IMAGE, DEFAULT_TAG);
+
+ private static final String DEFAULT_HOST = "localhost";
+ private static final int DEFAULT_PORT = 3301;
+ private static final String API_USER = "api_user";
+ private static final String API_PASSWORD = "secret";
+ private static final Integer MEMTX_MEMORY = 128 * 1024 * 1024; // 128 Mb in bytes
+ private static final String SCRIPT_RESOURCE_DIRECTORY = "";
+ private static final String SCRIPT_FILENAME = "server.lua";
+ private static final String INSTANCE_DIR = "/app";
+
+ private String username = API_USER;
+ private String password = API_PASSWORD;
+ private String host = DEFAULT_HOST;
+ private Integer port = DEFAULT_PORT;
+ private Integer memtxMemory = MEMTX_MEMORY;
+ private String directoryResourcePath = SCRIPT_RESOURCE_DIRECTORY;
+ private String scriptFileName = SCRIPT_FILENAME;
+ private String instanceDir = INSTANCE_DIR;
+ private boolean useFixedPorts = false;
+ private SslContext sslContext;
+
+ private final TarantoolContainerClientHelper clientHelper;
+
+ public TarantoolContainerImpl() {
+ this(DEFAULT_BASE_IMAGE);
+ setImageNameFromEnv();
+ }
+
+ public TarantoolContainerImpl(String dockerImageName) {
+ super(dockerImageName);
+ clientHelper = new TarantoolContainerClientHelper(this);
+ }
+
+ public TarantoolContainerImpl(Future image) {
+ super(image);
+ clientHelper = new TarantoolContainerClientHelper(this);
+ }
+
+ /**
+ * Use fixed ports binding. Defaults to false.
+ *
+ * @param useFixedPorts fixed ports for tarantool
+ * @return HTTP API port
+ */
+ public TarantoolContainerImpl withUseFixedPorts(boolean useFixedPorts) {
+ this.useFixedPorts = useFixedPorts;
+ return this;
+ }
+
+ /**
+ * Specify the host for connecting to Tarantool with.
+ *
+ * @param host valid IP address or hostname
+ * @return this container instance
+ */
+ public TarantoolContainerImpl withHost(String host) {
+ checkNotRunning();
+ this.host = host;
+ return this;
+ }
+
+ /**
+ * Specify the port for connecting to Tarantool with.
+ *
+ * @param port valid port number
+ * @return this container instance
+ */
+ public TarantoolContainerImpl withPort(int port) {
+ checkNotRunning();
+ this.port = port;
+ return this;
+ }
+
+ public TarantoolContainerImpl withFixedExposedPort(int hostPort, int containerPort) {
+ super.addFixedExposedPort(hostPort, containerPort);
+ return this;
+ }
+
+ public TarantoolContainerImpl withExposedPort(Integer port) {
+ super.addExposedPort(port);
+ return this;
+ }
+
+ @Override
+ public String getHost() {
+ return host;
+ }
+
+ @Override
+ public int getPort() {
+ return getMappedPort(port);
+ }
+
+ @Override
+ public String getUsername() {
+ return username;
+ }
+
+ @Override
+ public String getPassword() {
+ return password;
+ }
+
+ /**
+ * Specify the username for connecting to Tarantool with. Warning! This user must be created on
+ * Tarantool instance startup, e.g. specified in the startup script.
+ *
+ * @param username the client user name
+ * @return this container instance
+ */
+ public TarantoolContainerImpl withUsername(String username) {
+ checkNotRunning();
+ this.username = username;
+ return this;
+ }
+
+ /**
+ * Specify the password for the specified user for connecting to Tarantool with. Warning! This
+ * user must be created on Tarantool instance startup, e.g. specified in the startup script,
+ * together with setting the password.
+ *
+ * @param password the client user password
+ * @return this container instance
+ */
+ public TarantoolContainerImpl withPassword(String password) {
+ checkNotRunning();
+ this.password = password;
+ return this;
+ }
+
+ /**
+ * Specify SSL as connection transport and path to key and cert files inside your container for
+ * mTLS connection. Warning! SSL must be set as the default transport in your Tarantool cluster.
+ * Supported only in Tarantool Enterprise.
+ *
+ * @param sslContext {@link SslContext} instance
+ * @return this container instance
+ */
+ public TarantoolContainerImpl withSslContext(SslContext sslContext) {
+ checkNotRunning();
+ this.sslContext = sslContext;
+ return this;
+ }
+
+ /**
+ * Change the memtx_memory setting on the Tarantool instance
+ *
+ * @param memtxMemory new memtx_memory value, must be greater than 0
+ * @return this container instance
+ */
+ public TarantoolContainerImpl withMemtxMemory(Integer memtxMemory) {
+ if (memtxMemory <= 0) {
+ throw new RuntimeException(
+ String.format("The specified memtx_memory value must be >= 0, but was %d", memtxMemory));
+ }
+ this.memtxMemory = memtxMemory;
+ if (isRunning()) {
+ try {
+ executeCommand(String.format("box.cfg{memtx_memory=%d}", memtxMemory));
+ } catch (Exception e) {
+ logger().error(String.format("Failed to set memtx_memory to %d", memtxMemory), e);
+ throw new RuntimeException(e);
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Specify a directory in the classpath resource which will be mounted to the container.
+ *
+ * @param directoryResourcePath classpath resource directory full path
+ * @return this container instance
+ */
+ public TarantoolContainerImpl withDirectoryBinding(String directoryResourcePath) {
+ checkNotRunning();
+ this.directoryResourcePath = normalizePath(directoryResourcePath);
+ return this;
+ }
+
+ @Override
+ public String getDirectoryBinding() {
+ return directoryResourcePath;
+ }
+
+ /**
+ * Specify the directory inside container that the resource directory will be mounted to. The
+ * default value is "/app".
+ *
+ * @param instanceDir valid directory path
+ * @return this container instance
+ */
+ public TarantoolContainerImpl withInstanceDir(String instanceDir) {
+ checkNotRunning();
+ this.instanceDir = instanceDir;
+ return this;
+ }
+
+ @Override
+ public String getInstanceDir() {
+ return instanceDir;
+ }
+
+ @Override
+ public int getInternalPort() {
+ return port;
+ }
+
+ /**
+ * Specify the server init script file name
+ *
+ * @param scriptFileName script file path, relative to the mounted resource directory
+ * @return this container instance
+ * @see #withDirectoryBinding(String)
+ */
+ public TarantoolContainerImpl withScriptFileName(String scriptFileName) {
+ checkNotRunning();
+ this.scriptFileName = scriptFileName;
+ return this;
+ }
+
+ /**
+ * Get the server init script file name
+ *
+ * @return file name
+ */
+ public String getScriptFileName() {
+ return scriptFileName;
+ }
+
+ /** Checks if already running and if so raises an exception to prevent too-late setters. */
+ protected void checkNotRunning() {
+ if (isRunning()) {
+ throw new IllegalStateException(
+ "This option can be changed only before the container is running");
+ }
+ }
+
+ private void checkServerScriptExists() {
+ String serverScriptPath = Paths.get(getDirectoryBinding(), getScriptFileName()).toString();
+ URL resource = getClass().getClassLoader().getResource(serverScriptPath);
+ if (resource == null) {
+ throw new RuntimeException(
+ String.format("Server configuration script %s is not found", serverScriptPath));
+ }
+ }
+
+ @Override
+ protected void configure() {
+ checkServerScriptExists();
+
+ URL sourceDirectory = getClass().getClassLoader().getResource(getDirectoryBinding());
+ if (sourceDirectory == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "No resource path found for the specified resource %s", getDirectoryBinding()));
+ }
+ String sourceDirectoryPath = normalizePath(sourceDirectory.getPath());
+
+ // disable bind if directory is empty
+ if (!sourceDirectoryPath.isEmpty()) {
+ withFileSystemBind(sourceDirectoryPath, getInstanceDir(), BindMode.READ_WRITE);
+ }
+
+ if (useFixedPorts) {
+ addFixedExposedPort(port, port);
+ } else {
+ addExposedPorts(port);
+ }
+
+ withCommand("tarantool", normalizePath(Paths.get(getInstanceDir(), getScriptFileName())));
+
+ waitingFor(Wait.forLogMessage(".*entering the event loop.*", 1));
+ }
+
+ @Override
+ protected void containerIsStarting(InspectContainerResponse containerInfo) {
+ logger().info("Tarantool server is starting");
+ }
+
+ @Override
+ protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) {
+ super.containerIsStarted(containerInfo, reused);
+
+ withMemtxMemory(memtxMemory);
+
+ logger().info("Tarantool server is listening at {}:{}", getHost(), getPort());
+ }
+
+ @Override
+ protected void containerIsStopping(InspectContainerResponse containerInfo) {
+ super.containerIsStopping(containerInfo);
+ logger().info("Tarantool server is stopping");
+ }
+
+ @Override
+ public Container.ExecResult executeScript(String scriptResourcePath) throws Exception {
+ return clientHelper.executeScript(scriptResourcePath, this.sslContext);
+ }
+
+ @Override
+ public T executeScriptDecoded(String scriptResourcePath) throws Exception {
+ return clientHelper.executeScriptDecoded(scriptResourcePath, this.sslContext);
+ }
+
+ @Override
+ public Container.ExecResult executeCommand(String command) throws Exception {
+ return clientHelper.executeCommand(command, this.sslContext);
+ }
+
+ @Override
+ public T executeCommandDecoded(String command) throws Exception {
+ return clientHelper.executeCommandDecoded(command, this.sslContext);
+ }
+
+ private void setImageNameFromEnv() {
+ String version = System.getenv("TARANTOOL_VERSION");
+ if (version != null && !version.trim().isEmpty()) {
+ String registry = System.getenv("TARANTOOL_REGISTRY");
+ String image =
+ registry == null || registry.isEmpty()
+ ? DEFAULT_IMAGE
+ : (registry.endsWith("/")
+ ? registry + DEFAULT_IMAGE
+ : registry + "/" + DEFAULT_IMAGE);
+ setDockerImageName(String.format("%s:%s", image, version));
+ }
+ }
+}
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/tarantool/TarantoolContainerLuaExecutor.java b/testcontainers/src/main/java/org/testcontainers/containers/tarantool/TarantoolContainerLuaExecutor.java
new file mode 100644
index 0000000..ca672af
--- /dev/null
+++ b/testcontainers/src/main/java/org/testcontainers/containers/tarantool/TarantoolContainerLuaExecutor.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY
+ * All Rights Reserved.
+ */
+
+package org.testcontainers.containers.tarantool;
+
+import java.io.IOException;
+
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.utils.SslContext;
+import org.yaml.snakeyaml.Yaml;
+
+public final class TarantoolContainerLuaExecutor {
+
+ private static final String EXECUTE_COMMAND_ERROR_TEMPLATE =
+ "Executed command \"%s\" with exit code %d, stderr: \"%s\", stdout: \"%s\"";
+ private static final String MTLS_COMMAND_TEMPLATE =
+ "echo \" print(require('yaml').encode( {require('net.box').connect( {"
+ + " uri='%s:%d', params = { transport='ssl', ssl_key_file = '%s', ssl_cert_file = '%s'"
+ + " }}, { user = '%s', password = '%s' } ):eval('%s')}) "
+ + " ); os.exit(); \" > container-tmp.lua && tarantool container-tmp.lua";
+ private static final String SSL_COMMAND_TEMPLATE =
+ "echo \" "
+ + " print(require('yaml').encode( "
+ + " {require('net.box').connect( "
+ + " { uri='%s:%d', params = { transport='ssl' }}, "
+ + " { user = '%s', password = '%s' } "
+ + " ):eval('%s')}) "
+ + " ); "
+ + " os.exit(); "
+ + "\" > container-tmp.lua &&"
+ + " tarantool container-tmp.lua";
+ private static final String COMMAND_TEMPLATE =
+ "echo \" "
+ + " print(require('yaml').encode( "
+ + " {require('net.box').connect( "
+ + " '%s:%d', "
+ + " { user = '%s', password = '%s' } "
+ + " ):eval('%s')}) "
+ + " ); "
+ + " os.exit(); "
+ + "\" > container-tmp.lua &&"
+ + " tarantool container-tmp.lua";
+
+ private static final String ENV_USERNAME_CMD =
+ "echo ${TARANTOOL_USER_NAME:-${TT_CLI_USERNAME:-guest}}";
+ private static final String ENV_PASSWORD_CMD =
+ "echo ${TARANTOOL_USER_PASSWORD:-${TT_CLI_PASSWORD:-}}";
+
+ private static final Yaml YAML = new Yaml();
+
+ private final Container> container;
+ private final int port;
+
+ public TarantoolContainerLuaExecutor(Container> container, int port) {
+ this.container = container;
+ this.port = port;
+ }
+
+ public String getExecResult(String command) throws Exception {
+ Container.ExecResult result = this.container.execInContainer(command);
+ if (result.getExitCode() != 0) {
+ throw new RuntimeException("Cannot execute script: " + command);
+ }
+ return result.getStdout().trim().replace("\n", "").replace("...", "").replace("--", "").trim();
+ }
+
+ public Container.ExecResult executeCommand(String command)
+ throws IOException, InterruptedException {
+ return executeCommand(command, null);
+ }
+
+ public Container.ExecResult executeCommand(String command, SslContext sslContext)
+ throws IOException, InterruptedException {
+ if (!container.isRunning()) {
+ throw new IllegalStateException("Cannot execute commands in stopped container");
+ }
+
+ command = command.replace("\"", "\\\"");
+ command = command.replace("\'", "\\\'");
+
+ String username = getUsernameFromEnv();
+ String password = getPasswordFromEnv();
+ String host = "localhost";
+
+ String bashCommand;
+ if (sslContext == null) {
+ bashCommand = String.format(COMMAND_TEMPLATE, host, port, username, password, command);
+ } else if (sslContext.getKeyFile() != null && sslContext.getCertFile() != null) {
+ bashCommand =
+ String.format(
+ MTLS_COMMAND_TEMPLATE,
+ host,
+ port,
+ sslContext.getKeyFile(),
+ sslContext.getCertFile(),
+ username,
+ password,
+ command);
+ } else {
+ bashCommand = String.format(SSL_COMMAND_TEMPLATE, host, port, username, password, command);
+ }
+
+ return container.execInContainer("sh", "-c", bashCommand);
+ }
+
+ public T executeCommandDecoded(String command) throws IOException, InterruptedException {
+ return executeCommandDecoded(command, null);
+ }
+
+ public T executeCommandDecoded(String command, SslContext sslContext)
+ throws IOException, InterruptedException {
+ Container.ExecResult result = executeCommand(command, sslContext);
+
+ if (result.getExitCode() != 0) {
+ throw new IllegalStateException(
+ String.format(
+ EXECUTE_COMMAND_ERROR_TEMPLATE,
+ command,
+ result.getExitCode(),
+ result.getStderr(),
+ result.getStdout()));
+ }
+
+ return YAML.load(result.getStdout());
+ }
+
+ private String getUsernameFromEnv() throws IOException, InterruptedException {
+ Container.ExecResult result = container.execInContainer("sh", "-c", ENV_USERNAME_CMD);
+ if (result.getExitCode() != 0) {
+ return "guest";
+ }
+ String username = result.getStdout().trim();
+ return username.isEmpty() ? "guest" : username;
+ }
+
+ private String getPasswordFromEnv() throws IOException, InterruptedException {
+ Container.ExecResult result = container.execInContainer("sh", "-c", ENV_PASSWORD_CMD);
+ if (result.getExitCode() != 0) {
+ return "";
+ }
+ return result.getStdout().trim();
+ }
+}
diff --git a/testcontainers/src/main/java/org/testcontainers/containers/utils/CartridgeConfigParser.java b/testcontainers/src/main/java/org/testcontainers/containers/utils/CartridgeConfigParser.java
new file mode 100644
index 0000000..dc301a1
--- /dev/null
+++ b/testcontainers/src/main/java/org/testcontainers/containers/utils/CartridgeConfigParser.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY
+ * All Rights Reserved.
+ */
+
+package org.testcontainers.containers.utils;
+
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+import org.yaml.snakeyaml.Yaml;
+
+public class CartridgeConfigParser {
+
+ private final AtomicReference