diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index c31dd05e048..f78595fff9e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -68,6 +68,7 @@ body: - ToxiProxy - Trino - Typesense + - Valkey - Vault - Weaviate - YugabyteDB diff --git a/.github/ISSUE_TEMPLATE/enhancement.yaml b/.github/ISSUE_TEMPLATE/enhancement.yaml index 9b9a06ecf6a..b63978775af 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.yaml +++ b/.github/ISSUE_TEMPLATE/enhancement.yaml @@ -68,6 +68,7 @@ body: - ToxiProxy - Trino - Typesense + - Valkey - Vault - Weaviate - YugabyteDB diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml index b655b4ac505..4a26337e90a 100644 --- a/.github/ISSUE_TEMPLATE/feature.yaml +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -68,6 +68,7 @@ body: - ToxiProxy - Trino - Typesense + - Valkey - Vault - Weaviate - YugabyteDB diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 72a6d9110b6..7e84bc07e6e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -373,6 +373,11 @@ updates: schedule: interval: "monthly" open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/valkey" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 - package-ecosystem: "gradle" directory: "/modules/vault" schedule: diff --git a/.github/labeler.yml b/.github/labeler.yml index f4649bd7f99..02efaf75f14 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -248,6 +248,10 @@ - changed-files: - any-glob-to-any-file: - modules/typesense/**/* +"modules/valkey": + - changed-files: + - any-glob-to-any-file: + - modules/valkey/**/* "modules/vault": - changed-files: - any-glob-to-any-file: diff --git a/docs/modules/valkey.md b/docs/modules/valkey.md new file mode 100644 index 00000000000..fd318cbcf3a --- /dev/null +++ b/docs/modules/valkey.md @@ -0,0 +1,34 @@ +# Valkey + +!!! note This module is INCUBATING. +While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. +See our [contributing guidelines](../contributing.md#incubating-modules) for more information on our incubating modules policy. + +Testcontainers module for [Valkey](https://hub.docker.com/r/valkey/valkey) + +## Valkey's usage examples + +You can start a Valkey container instance from any Java application by using: + + +[Default Valkey container](../../modules/valkey/src/test/java/org/testcontainers/valkey/ValkeyContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" +```groovy +testImplementation "org.testcontainers:valkey:{{latest_version}}" +``` + +=== "Maven" +```xml + +org.testcontainers +valkey +{{latest_version}} +test + +``` diff --git a/mkdocs.yml b/mkdocs.yml index 3e39a67f959..8dedfec2ede 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -108,6 +108,7 @@ nav: - modules/solr.md - modules/toxiproxy.md - modules/typesense.md + - modules/valkey.md - modules/vault.md - modules/weaviate.md - modules/webdriver_containers.md diff --git a/modules/pinecone/build.gradle b/modules/pinecone/build.gradle index 3ad5b97d98f..ad46d3ce9d9 100644 --- a/modules/pinecone/build.gradle +++ b/modules/pinecone/build.gradle @@ -1,4 +1,4 @@ -description = "Testcontainers :: ActiveMQ" +description = "Testcontainers :: Pinecone" dependencies { api project(':testcontainers') diff --git a/modules/valkey/build.gradle b/modules/valkey/build.gradle new file mode 100644 index 00000000000..497b76a4c3f --- /dev/null +++ b/modules/valkey/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: Valkey" + +dependencies { + api project(':testcontainers') + + testImplementation("io.valkey:valkey-java:5.5.0") +} diff --git a/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyContainer.java b/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyContainer.java new file mode 100644 index 00000000000..e2ed4136e34 --- /dev/null +++ b/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyContainer.java @@ -0,0 +1,250 @@ +package org.testcontainers.valkey; + +import com.google.common.base.Preconditions; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Testcontainers implementation for Valkey. + *

+ * Supported image: {@code valkey} + *

+ * Exposed ports: + *

+ */ +public class ValkeyContainer extends GenericContainer { + + @AllArgsConstructor + @Getter + private static class SnapshottingSettings { + + int seconds; + + int changedKeys; + } + + private static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("valkey/valkey:8.1"); + + private static final String DEFAULT_CONFIG_FILE = "/usr/local/valkey.conf"; + + private static final int CONTAINER_PORT = 6379; + + private String username; + + private String password; + + private String persistenceVolume; + + private String initialImportScriptFile; + + private String configFile; + + private ValkeyLogLevel logLevel; + + private SnapshottingSettings snapshottingSettings; + + public ValkeyContainer() { + this(DEFAULT_IMAGE); + } + + public ValkeyContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ValkeyContainer(DockerImageName dockerImageName) { + super(dockerImageName); + withExposedPorts(CONTAINER_PORT); + withStartupTimeout(Duration.ofMinutes(2)); + waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1)); + } + + public ValkeyContainer withUsername(String username) { + this.username = username; + return this; + } + + public ValkeyContainer withPassword(String password) { + this.password = password; + return this; + } + + /** + * Sets a host path to be mounted as a volume for Valkey persistence. The path must exist on the + * host system. Valkey will store its data in this directory. + */ + public ValkeyContainer withPersistenceVolume(String persistenceVolume) { + this.persistenceVolume = persistenceVolume; + return this; + } + + /** + * Sets an initial import script file to be executed via the Valkey CLI after startup. + *

+ * Example line of an import script file: SET key1 "value1" + */ + public ValkeyContainer withInitialData(String initialImportScriptFile) { + this.initialImportScriptFile = initialImportScriptFile; + return this; + } + + /** + * Sets the log level for the valkey server process. + */ + public ValkeyContainer withLogLevel(ValkeyLogLevel logLevel) { + this.logLevel = logLevel; + return this; + } + + /** + * Sets the snapshotting configuration for the valkey server process. You can configure Valkey + * to have it save the dataset every N seconds if there are at least M changes in the dataset. + * This method allows Valkey to benefit from copy-on-write semantics. + * + * @see + */ + public ValkeyContainer withSnapshotting(int seconds, int changedKeys) { + Preconditions.checkArgument(seconds > 0, "seconds must be greater than 0"); + Preconditions.checkArgument(changedKeys > 0, "changedKeys must be non-negative"); + + this.snapshottingSettings = new SnapshottingSettings(seconds, changedKeys); + return this; + } + + /** + * Sets the config file to be used for the Valkey container. + */ + public ValkeyContainer withConfigFile(String configFile) { + this.configFile = configFile; + + return this; + } + + @Override + public void start() { + List command = new ArrayList<>(); + command.add("valkey-server"); + + if (StringUtils.isNotEmpty(configFile)) { + withCopyToContainer(MountableFile.forHostPath(configFile), DEFAULT_CONFIG_FILE); + command.add(DEFAULT_CONFIG_FILE); + } + + if (StringUtils.isNotEmpty(password)) { + command.add("--requirepass"); + command.add(password); + + if (StringUtils.isNotEmpty(username)) { + command.add("--user " + username + " on >" + password + " ~* +@all"); + } + } + + if (StringUtils.isNotEmpty(persistenceVolume)) { + command.addAll(Arrays.asList("--appendonly", "yes")); + withFileSystemBind(persistenceVolume, "/data"); + } + + if (snapshottingSettings != null) { + command.addAll( + Arrays.asList("--save", + snapshottingSettings.getSeconds() + " " + snapshottingSettings.getChangedKeys()) + ); + } + + if (logLevel != null) { + command.addAll(Arrays.asList("--loglevel", logLevel.getLevel())); + } + + if (StringUtils.isNotEmpty(initialImportScriptFile)) { + withCopyToContainer(MountableFile.forHostPath(initialImportScriptFile), + "/tmp/import.valkey"); + withCopyToContainer(MountableFile.forClasspathResource("import.sh"), "/tmp/import.sh"); + } + + withCommand(command.toArray(new String[0])); + + super.start(); + + evaluateImportScript(); + } + + public int getPort() { + return getMappedPort(CONTAINER_PORT); + } + + /** + * Executes a command in the Valkey CLI inside the container. + */ + public String executeCli(String cmd, String... flags) { + List args = new ArrayList<>(); + args.add("redis-cli"); + + if (StringUtils.isNotEmpty(password)) { + args.addAll( + StringUtils.isNotEmpty(username) + ? Arrays.asList("--user", username, "--pass", password) + : Arrays.asList("--pass", password) + ); + } + + args.add(cmd); + args.addAll(Arrays.asList(flags)); + + try { + ExecResult result = execInContainer(args.toArray(new String[0])); + if (result.getExitCode() != 0) { + throw new RuntimeException(result.getStdout() + result.getStderr()); + } + + return result.getStdout(); + } catch (Exception e) { + throw new RuntimeException("failed to execute CLI command", e); + } + } + + public String createConnectionUrl() { + String userInfo = null; + if (StringUtils.isNotEmpty(username) && StringUtils.isNotEmpty(password)) { + userInfo = username + ":" + password; + } else if (StringUtils.isNotEmpty(password)) { + userInfo = ":" + password; + } + + try { + URI uri = new URI("redis", userInfo, getHost(), getPort(), null, null, null); + return uri.toString(); + } catch (URISyntaxException e) { + throw new RuntimeException("Failed to build Redis URI", e); + } + } + + private void evaluateImportScript() { + if (StringUtils.isEmpty(initialImportScriptFile)) { + return; + } + + try { + ExecResult result = execInContainer("/bin/sh", "/tmp/import.sh", + password != null ? password : ""); + + if (result.getExitCode() != 0 || result.getStdout().contains("ERR")) { + throw new RuntimeException("Could not import initial data: " + result.getStdout()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyLogLevel.java b/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyLogLevel.java new file mode 100644 index 00000000000..b24884d66c4 --- /dev/null +++ b/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyLogLevel.java @@ -0,0 +1,18 @@ +package org.testcontainers.valkey; + +public enum ValkeyLogLevel { + DEBUG("debug"), + VERBOSE("verbose"), + NOTICE("notice"), + WARNING("warning"); + + private final String level; + + ValkeyLogLevel(String level) { + this.level = level; + } + + public String getLevel() { + return level; + } +} diff --git a/modules/valkey/src/main/resources/import.sh b/modules/valkey/src/main/resources/import.sh new file mode 100644 index 00000000000..bfc76a22606 --- /dev/null +++ b/modules/valkey/src/main/resources/import.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +valkey-cli $([[ -n "$1" ]] && echo "-a $1") < "/tmp/import.valkey" +echo "Imported" diff --git a/modules/valkey/src/test/java/org/testcontainers/valkey/ValkeyContainerTest.java b/modules/valkey/src/test/java/org/testcontainers/valkey/ValkeyContainerTest.java new file mode 100644 index 00000000000..2a93c677e84 --- /dev/null +++ b/modules/valkey/src/test/java/org/testcontainers/valkey/ValkeyContainerTest.java @@ -0,0 +1,144 @@ +package org.testcontainers.valkey; + +import io.valkey.Jedis; +import io.valkey.JedisPool; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ValkeyContainerTest { + + @TempDir + Path tempDir; + + @Test + void shouldWriteAndReadEntry() { + try ( + ValkeyContainer valkeyContainer = new ValkeyContainer() + .withLogLevel(ValkeyLogLevel.DEBUG) + .withSnapshotting(3, 1) + ) { + valkeyContainer.start(); + try (JedisPool jedisPool = new JedisPool(valkeyContainer.createConnectionUrl()); + Jedis jedis = jedisPool.getResource()) { + jedis.set("key", "value"); + assertThat(jedis.get("key")).isEqualTo("value"); + } + } + } + + @Test + void shouldConfigureServiceWithAuthentication() { + try ( + ValkeyContainer valkeyContainer = new ValkeyContainer().withUsername("testuser") + .withPassword("testpass") + ) { + valkeyContainer.start(); + String url = valkeyContainer.createConnectionUrl(); + assertThat(url).contains("testuser:testpass"); + + try (JedisPool jedisPool = new JedisPool(url); + Jedis jedis = jedisPool.getResource()) { + jedis.set("k1", "v2"); + assertThat(jedis.get("k1")).isEqualTo("v2"); + } + } + } + + + @Test + void shouldPersistData() { + Path dataDir = tempDir.resolve("valkey-data"); + dataDir.toFile().mkdirs(); + + try ( + ValkeyContainer valkeyContainer = new ValkeyContainer() + .withPersistenceVolume(dataDir.toString()) + .withSnapshotting(1, 1) + ) { + valkeyContainer.start(); + + String containerConnectionUrl = valkeyContainer.createConnectionUrl(); + try (JedisPool jedisPool = new JedisPool(containerConnectionUrl); + Jedis jedis = jedisPool.getResource()) { + jedis.set("persistKey", "persistValue"); + } + + valkeyContainer.stop(); + try (ValkeyContainer restarted = new ValkeyContainer().withPersistenceVolume( + dataDir.toString())) { + restarted.start(); + String connectionUrl = restarted.createConnectionUrl(); + + try (JedisPool restartedPool = new JedisPool(connectionUrl); + Jedis jedis = restartedPool.getResource()) { + assertThat(jedis.get("persistKey")).isEqualTo("persistValue"); + } + } + } + } + + @Test + void shouldInitializeDatabaseWithPayload() throws Exception { + Path importFile = Paths.get(getClass().getResource("/initData.valkey").toURI()); + + try (ValkeyContainer valkeyContainer = new ValkeyContainer().withInitialData( + importFile.toString())) { + valkeyContainer.start(); + String connectionUrl = valkeyContainer.createConnectionUrl(); + + try (JedisPool jedisPool = new JedisPool( + connectionUrl); Jedis jedis = jedisPool.getResource()) { + assertThat(jedis.get("key1")).isEqualTo("value1"); + assertThat(jedis.get("key2")).isEqualTo("value2"); + } + } + } + + @Test + void shouldExecuteContainerCmdAndReturnResult() { + try (ValkeyContainer valkeyContainer = new ValkeyContainer()) { + valkeyContainer.start(); + + String queryResult = valkeyContainer.executeCli("info", "clients"); + + assertThat(queryResult).contains("connected_clients:1"); + } + } + + @Test + void shouldMountValkeyConfigToContainer() throws Exception { + Path configFile = Paths.get(getClass().getResource("/valkey.conf").toURI()); + + try (ValkeyContainer valkeyContainer = new ValkeyContainer().withConfigFile( + configFile.toString())) { + valkeyContainer.start(); + + String connectionUrl = valkeyContainer.createConnectionUrl(); + try (JedisPool jedisPool = new JedisPool(connectionUrl); + Jedis jedis = jedisPool.getResource()) { + String maxMemory = jedis.configGet("maxmemory").get("maxmemory"); + + assertThat(maxMemory).isEqualTo("2097152"); + } + } + } + + @Test + void shouldValidateSnapshottingConfiguration() { + try (ValkeyContainer container = new ValkeyContainer()) { + assertThatThrownBy(() -> container.withSnapshotting(0, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("seconds must be greater than 0"); + + assertThatThrownBy(() -> container.withSnapshotting(10, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("changedKeys must be non-negative"); + } + } +} diff --git a/modules/valkey/src/test/resources/initData.valkey b/modules/valkey/src/test/resources/initData.valkey new file mode 100644 index 00000000000..e2c4c2c8e7b --- /dev/null +++ b/modules/valkey/src/test/resources/initData.valkey @@ -0,0 +1,2 @@ +SET key1 "value1" +SET key2 "value2" diff --git a/modules/valkey/src/test/resources/valkey.conf b/modules/valkey/src/test/resources/valkey.conf new file mode 100644 index 00000000000..b58609fc4b3 --- /dev/null +++ b/modules/valkey/src/test/resources/valkey.conf @@ -0,0 +1 @@ +maxmemory 2mb