Skip to content

Commit

Permalink
Introduces test matrix based on Redis versions [8.0-M1, 7.4.1, 7.2.6,…
Browse files Browse the repository at this point in the history
… 6.2.16]

Use docker composer to bring up the test env using `redislabs/client-libs-test` image.

When run against older Redis version some tests are using commands available only in newer Redis server versions. To resolve this we are introducing two new annotations/rules

 - Introduce `SinceRedisVersion` annotation/Rule - for conditionally running tests based on Redis server version contacted
 - Introduce `EnableOnCommad` annotation/Rule -  for conditionally running tests based on command availability on the server

And mark respective tests with the least Redis Version required by the test
 - SinceRedisVersion ("7.4.0") - Mark tests using commands/modifiers introduced with Redis 7.4.0
 - SinceRedisVersion ("7.2.0") - Mark tests using commands/modifiers introduced with Redis 7.2.0
 - SinceRedisVersion ("7.0.0") - Mark tests using commands/modifiers introduced with Redis 7.0.0

 The same approach used to mark CSC tests
 - Disabled client-side caching tests for versions below  7.4

Fix in Jedis Client against Redis server 6.x

 - Fix NPW on CommandInfo - some of the array elements returned are available based from given RedisServer
         aclCategories (as of Redis 6.0) ,
         tips ,  (as of Redis 7.0) subcommands

 - Fix NPE  AccessControlLogEntry when used against Redis 6
   Starting with Redis version 7.2.0: Added entry ID, timestamp created, and timestamp last updated fields.

Fix Test failures against 6.x
 - Fix JedisPooledClientSideCacheTest
 - Fix AccessControlListCommandsTest.aclLogTest:372 » NullPointer
 - Fix AccessControlListCommandsTest.aclLogWithEntryID:473 » NullPointer
 - Fix StreamsCommandsTest
 - Fix StreamsPipelineCommandsTest xadd - Starting with Redis version 7.0.0: Added support for the <ms>-* explicit ID form.

- Test env migrated to use native Redis server TLS instead of  using stunnel

Not all tests were migrated
 - Disable Modules test in containerized test env ModuleTest uses custom test module to test load/unload/sendCommand.
    Requires pre-build test module on the same os like test container to avoid errors

 - Disable UDS tests in containerized test env
    No easy way to make unix sockets work on MAC with docker
  • Loading branch information
ggivo committed Nov 10, 2024
1 parent 9b88636 commit d165787
Show file tree
Hide file tree
Showing 127 changed files with 2,523 additions and 619 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ jobs:
- name: System setup
run: |
sudo apt update
sudo apt install -y stunnel make
make system-setup
sudo apt install -y make
make compile-module
- name: Cache dependencies
uses: actions/cache@v2
with:
Expand Down
128 changes: 128 additions & 0 deletions .github/workflows/test-on-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---

name: Build and Test using containerized environment

on:
push:
paths-ignore:
- 'docs/**'
- '**/*.md'
- '**/*.rst'
branches:
- master
- '[0-9].*'
pull_request:
branches:
- master
- '[0-9].*'
schedule:
- cron: '0 1 * * *' # nightly build
workflow_dispatch:
inputs:
specific_test:
description: 'Run specific test(s) (optional)'
required: false
default: ''
jobs:

build:
name: Build and Test
runs-on: ubuntu-latest
env:
REDIS_ENV_WORK_DIR: ${{ github.workspace }}/redis-env-work
REDIS_ENV_CONF_DIR: ${{ github.workspace }}/src/test/resources/env
CLIENT_LIBS_IMAGE_PREFIX: "redislabs/client-libs-test"
strategy:
fail-fast: false
matrix:
redis_version:
- "8.0-M01"
- "7.4.1"
- "7.2.6"
- "6.2.16"
steps:
- uses: actions/checkout@v2
- name: Set up publishing to maven central
uses: actions/setup-java@v2
with:
java-version: '8'
distribution: 'temurin'
- name: System setup
run: |
sudo apt update
sudo apt install -y make
make compile-module
- name: Cache dependencies
uses: actions/cache@v2
with:
path: |
~/.m2/repository
/var/cache/apt
key: jedis-${{hashFiles('**/pom.xml')}}
# Set up Docker Compose environment
- name: Set up Docker Compose environment
run: |
mkdir -m 777 $REDIS_ENV_WORK_DIR
export CLIENT_LIBS_TEST_IMAGE="${CLIENT_LIBS_IMAGE_PREFIX}:${{ matrix.redis_version }}"
export COMPOSE_ENV_FILES="src/test/resources/env/.env"
if [[ "${{ matrix.redis_version }}" == "6.2.16" ]]; then
COMPOSE_ENV_FILES+=",src/test/resources/env/.env.v${{ matrix.redis_version }}"
fi
docker compose -f src/test/resources/env/docker-compose.yml up -d
- name: Maven offline
run: |
mvn -q dependency:go-offline
- name: Build docs
run: |
mvn javadoc:jar
# Run Tests
- name: Run Maven tests
run: |
export TEST_ENV_PROVIDER=docker
export TEST_WORK_FOLDER=$REDIS_ENV_WORK_DIR
echo $TEST_WORK_FOLDER
if [ -z "$TESTS" ]; then
mvn clean compile test
else
mvn -Dtest=$SPECIFIC_TEST clean compile test
fi
env:
TESTS: ${{ github.event.inputs.specific_test || '' }}
- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: |
target/surefire-reports/**/*.xml
# Collect logs on failure
- name: Collect logs on failure
if: failure() # This runs only if the previous steps failed
run: |
echo "Collecting logs from $WORK_DIR..."
ls -la $REDIS_ENV_WORK_DIR
# Upload logs as artifacts
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v3
with:
name: redis-env-work-logs
path: ${{ env.REDIS_ENV_WORK_DIR }}
# Bring down the Docker Compose test environment
- name: Tear down Docker Compose environment
if: always()
run: |
docker compose $COMPOSE_ENV_FILES -f src/test/resources/env/docker-compose.yml down
continue-on-error: true
# Upload code coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test results to Codecov
if: ${{ github.event_name == 'schedule' || (github.event_name == 'push') || github.event_name == 'workflow_dispatch'}}
uses: codecov/test-results-action@v1
with:
fail_ci_if_error: false
files: ./target/surefire-reports/TEST*
token: ${{ secrets.CODECOV_TOKEN }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ build/
bin/
tags
.idea
.run
*.aof
*.rdb
redis-git
appendonlydir/
.DS_Store
12 changes: 10 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ protected-mode no
port 6379
requirepass foobared
user acljedis on allcommands allkeys >fizzbuzz
user deploy on allcommands allkeys >verify
pidfile /tmp/redis1.pid
logfile /tmp/redis1.log
save ""
Expand Down Expand Up @@ -189,6 +190,7 @@ endef

define REDIS_SENTINEL5
port 26383
tlsport 36383
daemonize yes
protected-mode no
user default off
Expand Down Expand Up @@ -525,8 +527,14 @@ mvn-release:
mvn release:prepare
mvn release:perform -DskipTests

system-setup:
sudo apt install -y gcc g++
install-gcc:
@if [ "$(shell uname)" = "Darwin" ]; then \
brew install gcc; \
else \
sudo apt install -y gcc g++; \
fi

system-setup: install-gcc
[ ! -e redis-git ] && git clone https://github.com/redis/redis.git --branch unstable --single-branch redis-git || true
$(MAKE) -C redis-git clean
$(MAKE) -C redis-git
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public class AccessControlLogEntry implements Serializable {
private final long timestampCreated;
private final long timestampLastUpdated;

/*
* Starting with Redis version 7.2.0: Added entry ID, timestamp created, and timestamp last updated.
* @see https://redis.io/docs/latest/commands/acl-log/
*/
public AccessControlLogEntry(Map<String, Object> map) {
count = (long) map.get(COUNT);
reason = (String) map.get(REASON);
Expand All @@ -47,9 +51,9 @@ public AccessControlLogEntry(Map<String, Object> map) {
ageSeconds = (Double) map.get(AGE_SECONDS);
clientInfo = getMapFromRawClientInfo((String) map.get(CLIENT_INFO));
logEntry = map;
entryId = (long) map.get(ENTRY_ID);
timestampCreated = (long) map.get(TIMESTAMP_CREATED);
timestampLastUpdated = (long) map.get(TIMESTAMP_LAST_UPDATED);
entryId = map.get(ENTRY_ID) == null ? 0L : (long) map.get(ENTRY_ID);
timestampCreated = map.get(TIMESTAMP_CREATED) == null ? 0L : (long) map.get(TIMESTAMP_CREATED);
timestampLastUpdated = map.get(TIMESTAMP_LAST_UPDATED) == null ? 0L : (long) map.get(TIMESTAMP_LAST_UPDATED);
}

public long getCount() {
Expand Down
11 changes: 8 additions & 3 deletions src/main/java/redis/clients/jedis/resps/CommandInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import redis.clients.jedis.Builder;

import java.util.Collections;
import java.util.List;

import static redis.clients.jedis.BuilderFactory.STRING_LIST;
Expand Down Expand Up @@ -103,9 +104,13 @@ public CommandInfo build(Object data) {
long firstKey = LONG.build(commandData.get(3));
long lastKey = LONG.build(commandData.get(4));
long step = LONG.build(commandData.get(5));
List<String> aclCategories = STRING_LIST.build(commandData.get(6));
List<String> tips = STRING_LIST.build(commandData.get(7));
List<String> subcommands = STRING_LIST.build(commandData.get(9));

// (as of Redis 6.0)
List<String> aclCategories = commandData.size()>=6?STRING_LIST.build(commandData.get(6)):Collections.emptyList();

// (as of Redis 7.0)
List<String> tips = commandData.size()>=8?STRING_LIST.build(commandData.get(7)):Collections.emptyList();
List<String> subcommands = commandData.size()>=10?STRING_LIST.build(commandData.get(9)): Collections.emptyList();

return new CommandInfo(arity, flags, firstKey, lastKey, step, aclCategories, tips, subcommands);
}
Expand Down
11 changes: 11 additions & 0 deletions src/test/java/io/redis/test/annotations/EnabledOnCommand.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.redis.test.annotations;

import java.lang.annotation.*;

@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface EnabledOnCommand {
String value();
String subCommand() default "";
}
11 changes: 11 additions & 0 deletions src/test/java/io/redis/test/annotations/SinceRedisVersion.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.redis.test.annotations;

import java.lang.annotation.*;

@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface SinceRedisVersion {
String value();
String message() default "";
}
126 changes: 126 additions & 0 deletions src/test/java/io/redis/test/utils/EnabledOnCommandRule.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package io.redis.test.utils;

import io.redis.test.annotations.EnabledOnCommand;
import org.junit.Assume;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.*;
import redis.clients.jedis.resps.CommandInfo;

import java.lang.reflect.Method;
import java.util.Map;


public class EnabledOnCommandRule implements TestRule {
private static final Logger logger = LoggerFactory.getLogger(EnabledOnCommandRule.class);

private final HostAndPort hostPort;
private final JedisClientConfig config;

public EnabledOnCommandRule(HostAndPort hostPort, JedisClientConfig config) {
this.hostPort = hostPort;
this.config = config;
}

public EnabledOnCommandRule(EndpointConfig endpointConfig) {
this.hostPort = endpointConfig.getHostAndPort();
this.config = endpointConfig.getClientConfigBuilder().build();
}

@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
try (Jedis jedisClient = new Jedis(hostPort, config)) {
String[] command = getCommandFromAnnotations(description);

if (command != null && !isCommandAvailable(jedisClient, command[0],command[1])) {
Assume.assumeTrue("Test requires Redis command '" + command[0] + " " + command[1] + "' to be available, but it was not found.", false);
}

base.evaluate();
}
}

/**
* Retrieves the command from either class-level or method-level annotations.
*
* @param description The test description containing annotations.
* @return The Redis array containing command, subcommand from the annotations, or null if not found.
*/
private String[] getCommandFromAnnotations(Description description) {
// Retrieve class-level and method-level annotations
EnabledOnCommand descriptionCommandAnnotation = description.getAnnotation(EnabledOnCommand.class);
if (descriptionCommandAnnotation != null) {
return new String[] {descriptionCommandAnnotation.value(), descriptionCommandAnnotation.subCommand()};
}

EnabledOnCommand methodCommand = getMethodAnnotation(description);
if (methodCommand != null) {
return new String[] {methodCommand.value(), methodCommand.subCommand()};
}

EnabledOnCommand classCommand = description.getTestClass().getAnnotation(EnabledOnCommand.class);
if (classCommand != null) {
return new String[] {classCommand.value(), classCommand.subCommand()};
}

return null;
}

private EnabledOnCommand getMethodAnnotation(Description description) {
try {
// description.getAnnotation() does not return anootaion when used
// with parametrised tests
String methodName = description.getMethodName();
if (methodName != null) {
Class<?> testClass = description.getTestClass();
if (testClass != null) {
for (Method method : testClass.getDeclaredMethods()) {
if (method.getName().equals(methodName)) {
return method.getAnnotation(EnabledOnCommand.class);
}
}
}
}
} catch (Exception e) {
// Handle any potential exceptions here
throw new RuntimeException("Could not resolve EnabledOnCommand annotation",e);
}
return null;
}

/**
* Checks if the specified Redis command is available.
*/
private boolean isCommandAvailable(Jedis jedisClient, String command, String subCommand) {
try {
Object raw = jedisClient.sendCommand(redis.clients.jedis.Protocol.Command.COMMAND);
Map<String, CommandInfo> commandList = BuilderFactory.COMMAND_INFO_RESPONSE.build(raw);
CommandInfo commandInfo = commandList.get(command.toLowerCase());
if (commandInfo != null) {
// If a subCommand is provided, check for the subcommand under this command
if (subCommand != null && !subCommand.isEmpty()) {
// Check if this command supports the provided subcommand
for (String supportedSubCommand : commandInfo.getSubcommands()) {
if (subCommand.equalsIgnoreCase(supportedSubCommand)) {
return true;
}
}
return false; // Subcommand not found
}
return true; // Command found (no subcommand required)
}
return false; // Command not found
} catch (Exception e) {
logger.error("Error checking command '{}' availability: {}", command, e.getMessage());
return false;
}
}
};
}
}
Loading

0 comments on commit d165787

Please sign in to comment.