Files
go/maintnotifications/e2e/config_parser_test.go
T
b53f2b0c9d feat(sch): MaintNotifications for ClusterClient (#3643)
* [maintnotif] Cluster specific handlers (#3613)

* maint notification handlers for cluster messages

* unrelax all conns

* trigger ci on feature branches

* feat(maintnotif): lazy cluster topology reload (#3614)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <[email protected]>

* Update osscluster.go

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>

* feat(e2e): mock maintnotif e2e tests (#3639)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <[email protected]>

* Update osscluster.go

Co-authored-by: Copilot <[email protected]>

* wip fault with mock proxy

* make lint happy

* fix linter issues

* faster tests with mocks

* linter once again

* add complex node test

* add ci e2e

* use correct redis container

* e2e fix

---------

Co-authored-by: Copilot <[email protected]>

* fix(retry): Add retry mechanism for NOREPLICAS error (#3647)

* fix(queuedNewConn): protect against nil context (#3649)

* fix(maintnotif): fix smigrated parser and add cluster state reload interval option (#3663)

* fix SMIGRATED parsing

* fix smigrated parser

* add ClusterStateReloadInterval to ClusterOptions

* fix tests

* set default cluster reload interval to 10s

* chore(lint): format

* feat(smigrated): new format & remember original host:port (#3697)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <[email protected]>

* Update osscluster.go

Co-authored-by: Copilot <[email protected]>

* wip fault with mock proxy

* make lint happy

* fix linter issues

* faster tests with mocks

* linter once again

* add complex node test

* add ci e2e

* use correct redis container

* e2e fix

* additional e2e tests

* fix data race

* fix random shard picker

* fix e2e tests

* fix for empty endpoint

* fix case when semaphore is full, but still need to check idle

* scenario tests

* create database from config

* wip

* feat(client): store original addrs for later use

* fix(notif): change smigrated notification

* fix(lint): fix linter

* fix(smigrated): use array

* fix(e2e): wip

* Update options.go

Co-authored-by: Copilot <[email protected]>

* Update redis.go

Co-authored-by: Copilot <[email protected]>

* fix(notif): if the conn has no original addr, trigger reload with first target

* chore(e2e): wip cluster e2e

* chore(e2e): fix nil pointer from e2e tests

* chore(e2e): fix tests and reports

* chore(e2e): proper logging in e2e

* chore(e2e): add pubsub in the tests as well

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes v3

* stop background routines

* fix(e2e): tests

---------

Co-authored-by: Copilot <[email protected]>

* chore(docs): add example app (#3651)

* chore(lint): fmt

* chore(docs): improve docs

* chore(docs): update features.md

* chore(github): remove example

* chore(maintnotif): rename option and address pr comments

* fix(e2e): command runner should use client timeout

* chore(e2e): refactor tests

* fix(e2e): set default timeout to 90m

* fix(e2e): skip tests if proxy cannot be started

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: ofekshenawa <[email protected]>
Co-authored-by: Elena Kolevska <[email protected]>
2026-02-13 13:25:07 +02:00

1634 lines
53 KiB
Go

package e2e
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
)
// debugE2E returns true if E2E_DEBUG environment variable is set to "true"
// Use this to control verbose debug logging in e2e tests
func debugE2E() bool {
return os.Getenv("E2E_DEBUG") == "true"
}
// isPortUnavailableErrorString checks if the error string indicates a port unavailable error
func isPortUnavailableErrorString(errStr string) bool {
return strings.Contains(errStr, "port_unavailable") || strings.Contains(errStr, "Unavailable or invalid port")
}
// DatabaseEndpoint represents a single database endpoint configuration
type DatabaseEndpoint struct {
Addr []string `json:"addr"`
AddrType string `json:"addr_type"`
DNSName string `json:"dns_name"`
OSSClusterAPIPreferredEndpointType string `json:"oss_cluster_api_preferred_endpoint_type"`
OSSClusterAPIPreferredIPType string `json:"oss_cluster_api_preferred_ip_type"`
Port int `json:"port"`
ProxyPolicy string `json:"proxy_policy"`
UID string `json:"uid"`
}
// EnvDatabaseConfig represents the configuration for a single database
type EnvDatabaseConfig struct {
BdbID interface{} `json:"bdb_id,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
TLS bool `json:"tls"`
CertificatesLocation string `json:"certificatesLocation,omitempty"`
RawEndpoints []DatabaseEndpoint `json:"raw_endpoints,omitempty"`
Endpoints []string `json:"endpoints"`
OSSCluster bool `json:"oss_cluster,omitempty"`
}
// EnvDatabasesConfig represents the complete configuration file structure
type EnvDatabasesConfig map[string]EnvDatabaseConfig
// EnvConfig represents environment configuration for test scenarios
type EnvConfig struct {
RedisEndpointsConfigPath string
FaultInjectorURL string
}
// RedisConnectionConfig represents Redis connection parameters
type RedisConnectionConfig struct {
Host string
Port int
Username string
Password string
TLS bool
BdbID int
CertificatesLocation string
Endpoints []string
IsClusterMode bool // Force cluster mode even with single endpoint
}
// GetEnvConfig reads environment variables required for the test scenario
func GetEnvConfig() (*EnvConfig, error) {
redisConfigPath := os.Getenv("REDIS_ENDPOINTS_CONFIG_PATH")
if redisConfigPath == "" {
return nil, fmt.Errorf("REDIS_ENDPOINTS_CONFIG_PATH environment variable must be set")
}
faultInjectorURL := os.Getenv("FAULT_INJECTION_API_URL")
if faultInjectorURL == "" {
// Default to localhost if not set
faultInjectorURL = "http://localhost:8080"
}
return &EnvConfig{
RedisEndpointsConfigPath: redisConfigPath,
FaultInjectorURL: faultInjectorURL,
}, nil
}
// GetDatabaseConfigFromEnv reads database configuration from a file
func GetDatabaseConfigFromEnv(filePath string) (EnvDatabasesConfig, error) {
fileContent, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read database config from %s: %w", filePath, err)
}
var config EnvDatabasesConfig
if err := json.Unmarshal(fileContent, &config); err != nil {
return nil, fmt.Errorf("failed to parse database config from %s: %w", filePath, err)
}
return config, nil
}
// GetDatabaseConfig gets Redis connection parameters for a specific database
func GetDatabaseConfig(databasesConfig EnvDatabasesConfig, databaseName string) (*RedisConnectionConfig, error) {
var dbConfig EnvDatabaseConfig
var exists bool
if databaseName == "" {
// Get the first database if no name is provided
for _, config := range databasesConfig {
dbConfig = config
exists = true
break
}
} else {
dbConfig, exists = databasesConfig[databaseName]
}
if !exists {
return nil, fmt.Errorf("database %s not found in configuration", databaseName)
}
// Parse connection details from endpoints or raw_endpoints
var host string
var port int
if len(dbConfig.RawEndpoints) > 0 {
// Use raw_endpoints if available (for more complex configurations)
endpoint := dbConfig.RawEndpoints[0] // Use the first endpoint
host = endpoint.DNSName
port = endpoint.Port
} else if len(dbConfig.Endpoints) > 0 {
// Parse from endpoints URLs
endpointURL, err := url.Parse(dbConfig.Endpoints[0])
if err != nil {
return nil, fmt.Errorf("failed to parse endpoint URL %s: %w", dbConfig.Endpoints[0], err)
}
host = endpointURL.Hostname()
portStr := endpointURL.Port()
if portStr == "" {
// Default ports based on scheme
switch endpointURL.Scheme {
case "redis":
port = 6379
case "rediss":
port = 6380
default:
port = 6379
}
} else {
port, err = strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid port in endpoint URL %s: %w", dbConfig.Endpoints[0], err)
}
}
// Override TLS setting based on scheme if not explicitly set
if endpointURL.Scheme == "rediss" {
dbConfig.TLS = true
}
} else {
return nil, fmt.Errorf("no endpoints found in database configuration")
}
var bdbId int
switch (dbConfig.BdbID).(type) {
case int:
bdbId = dbConfig.BdbID.(int)
case float64:
bdbId = int(dbConfig.BdbID.(float64))
case string:
bdbId, _ = strconv.Atoi(dbConfig.BdbID.(string))
}
return &RedisConnectionConfig{
Host: host,
Port: port,
Username: dbConfig.Username,
Password: dbConfig.Password,
TLS: dbConfig.TLS,
BdbID: bdbId,
CertificatesLocation: dbConfig.CertificatesLocation,
Endpoints: dbConfig.Endpoints,
}, nil
}
// ConvertEnvDatabaseConfigToRedisConnectionConfig converts EnvDatabaseConfig to RedisConnectionConfig
func ConvertEnvDatabaseConfigToRedisConnectionConfig(dbConfig EnvDatabaseConfig) (*RedisConnectionConfig, error) {
// Parse connection details from endpoints or raw_endpoints
var host string
var port int
if len(dbConfig.RawEndpoints) > 0 {
// Use raw_endpoints if available (for more complex configurations)
endpoint := dbConfig.RawEndpoints[0] // Use the first endpoint
host = endpoint.DNSName
port = endpoint.Port
} else if len(dbConfig.Endpoints) > 0 {
// Parse from endpoints URLs
endpointURL, err := url.Parse(dbConfig.Endpoints[0])
if err != nil {
return nil, fmt.Errorf("failed to parse endpoint URL %s: %w", dbConfig.Endpoints[0], err)
}
host = endpointURL.Hostname()
portStr := endpointURL.Port()
if portStr == "" {
// Default ports based on scheme
switch endpointURL.Scheme {
case "redis":
port = 6379
case "rediss":
port = 6380
default:
port = 6379
}
} else {
port, err = strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid port in endpoint URL %s: %w", dbConfig.Endpoints[0], err)
}
}
// Override TLS setting based on scheme if not explicitly set
if endpointURL.Scheme == "rediss" {
dbConfig.TLS = true
}
} else {
return nil, fmt.Errorf("no endpoints found in database configuration")
}
var bdbId int
switch dbConfig.BdbID.(type) {
case int:
bdbId = dbConfig.BdbID.(int)
case float64:
bdbId = int(dbConfig.BdbID.(float64))
case string:
bdbId, _ = strconv.Atoi(dbConfig.BdbID.(string))
}
return &RedisConnectionConfig{
Host: host,
Port: port,
Username: dbConfig.Username,
Password: dbConfig.Password,
TLS: dbConfig.TLS,
BdbID: bdbId,
CertificatesLocation: dbConfig.CertificatesLocation,
Endpoints: dbConfig.Endpoints,
IsClusterMode: dbConfig.OSSCluster,
}, nil
}
// ClientFactory manages Redis client creation and lifecycle
type ClientFactory struct {
config *RedisConnectionConfig
clients map[string]redis.UniversalClient
mutex sync.RWMutex
}
// NewClientFactory creates a new client factory with the specified configuration
func NewClientFactory(config *RedisConnectionConfig) *ClientFactory {
return &ClientFactory{
config: config,
clients: make(map[string]redis.UniversalClient),
}
}
// CreateClientOptions represents options for creating Redis clients
type CreateClientOptions struct {
Protocol int
MaintNotificationsConfig *maintnotifications.Config
MaxRetries int
PoolSize int
MinIdleConns int
MaxActiveConns int
ClientName string
DB int
ReadTimeout time.Duration
WriteTimeout time.Duration
ClusterStateReloadInterval time.Duration // For cluster clients, interval for automatic state reloads
}
// DefaultCreateClientOptions returns default options for creating Redis clients
func DefaultCreateClientOptions() *CreateClientOptions {
return &CreateClientOptions{
Protocol: 3, // RESP3 by default for push notifications
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeEnabled,
HandoffTimeout: 30 * time.Second,
RelaxedTimeout: 10 * time.Second,
MaxWorkers: 20,
},
MaxRetries: 3,
PoolSize: 10,
MinIdleConns: 10,
MaxActiveConns: 10,
}
}
func (cf *ClientFactory) PrintPoolStats(t *testing.T) {
cf.mutex.RLock()
defer cf.mutex.RUnlock()
for key, client := range cf.clients {
stats := client.PoolStats()
t.Logf("Pool stats for client %s: %+v", key, stats)
}
}
// Create creates a new Redis client with the specified options and connects it
func (cf *ClientFactory) Create(key string, options *CreateClientOptions) (redis.UniversalClient, error) {
if options == nil {
options = DefaultCreateClientOptions()
}
cf.mutex.Lock()
defer cf.mutex.Unlock()
// Check if client already exists
if client, exists := cf.clients[key]; exists {
return client, nil
}
var client redis.UniversalClient
var opts interface{}
// Determine if this is a cluster configuration
if len(cf.config.Endpoints) > 1 || cf.isClusterEndpoint() {
// Create cluster client
clusterOptions := &redis.ClusterOptions{
Addrs: cf.getAddresses(),
Username: cf.config.Username,
Password: cf.config.Password,
Protocol: options.Protocol,
MaintNotificationsConfig: options.MaintNotificationsConfig,
MaxRetries: options.MaxRetries,
PoolSize: options.PoolSize,
MinIdleConns: options.MinIdleConns,
MaxActiveConns: options.MaxActiveConns,
ClientName: options.ClientName,
ClusterStateReloadInterval: 10 * time.Minute,
}
if options.ReadTimeout > 0 {
clusterOptions.ReadTimeout = options.ReadTimeout
}
if options.WriteTimeout > 0 {
clusterOptions.WriteTimeout = options.WriteTimeout
}
if options.ClusterStateReloadInterval > 0 {
clusterOptions.ClusterStateReloadInterval = options.ClusterStateReloadInterval
}
if cf.config.TLS {
clusterOptions.TLSConfig = &tls.Config{
InsecureSkipVerify: true, // For testing purposes
}
}
opts = clusterOptions
client = redis.NewClusterClient(clusterOptions)
} else {
// Create single client
clientOptions := &redis.Options{
Addr: fmt.Sprintf("%s:%d", cf.config.Host, cf.config.Port),
Username: cf.config.Username,
Password: cf.config.Password,
DB: options.DB,
Protocol: options.Protocol,
MaintNotificationsConfig: options.MaintNotificationsConfig,
MaxRetries: options.MaxRetries,
PoolSize: options.PoolSize,
MinIdleConns: options.MinIdleConns,
MaxActiveConns: options.MaxActiveConns,
ClientName: options.ClientName,
}
if options.ReadTimeout > 0 {
clientOptions.ReadTimeout = options.ReadTimeout
}
if options.WriteTimeout > 0 {
clientOptions.WriteTimeout = options.WriteTimeout
}
if cf.config.TLS {
clientOptions.TLSConfig = &tls.Config{
InsecureSkipVerify: true, // For testing purposes
}
}
opts = clientOptions
client = redis.NewClient(clientOptions)
}
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to Redis: %w\nOptions: %+v", err, opts)
}
// Store the client
cf.clients[key] = client
return client, nil
}
// Get retrieves an existing client by key or the first one if no key is provided
func (cf *ClientFactory) Get(key string) redis.UniversalClient {
cf.mutex.RLock()
defer cf.mutex.RUnlock()
if key != "" {
return cf.clients[key]
}
// Return the first client if no key is provided
for _, client := range cf.clients {
return client
}
return nil
}
// GetAll returns all created clients
func (cf *ClientFactory) GetAll() map[string]redis.UniversalClient {
cf.mutex.RLock()
defer cf.mutex.RUnlock()
result := make(map[string]redis.UniversalClient)
for key, client := range cf.clients {
result[key] = client
}
return result
}
// DestroyAll closes and removes all created clients
func (cf *ClientFactory) DestroyAll() error {
cf.mutex.Lock()
defer cf.mutex.Unlock()
var lastErr error
for key, client := range cf.clients {
if err := client.Close(); err != nil {
lastErr = err
}
delete(cf.clients, key)
}
return lastErr
}
// Destroy closes and removes a specific client
func (cf *ClientFactory) Destroy(key string) error {
cf.mutex.Lock()
defer cf.mutex.Unlock()
client, exists := cf.clients[key]
if !exists {
return fmt.Errorf("client %s not found", key)
}
err := client.Close()
delete(cf.clients, key)
return err
}
// GetConfig returns the connection configuration
func (cf *ClientFactory) GetConfig() *RedisConnectionConfig {
return cf.config
}
// Helper methods
// isClusterEndpoint determines if the configuration represents a cluster
func (cf *ClientFactory) isClusterEndpoint() bool {
// Check if cluster mode is explicitly set
if cf.config.IsClusterMode {
return true
}
// Check if any endpoint contains cluster-related keywords
for _, endpoint := range cf.config.Endpoints {
if strings.Contains(strings.ToLower(endpoint), "cluster") {
return true
}
}
// Check if we have multiple raw endpoints
if len(cf.config.Endpoints) > 1 {
return true
}
return false
}
// getAddresses returns a list of addresses for cluster configuration
func (cf *ClientFactory) getAddresses() []string {
if len(cf.config.Endpoints) > 0 {
addresses := make([]string, 0, len(cf.config.Endpoints))
for _, endpoint := range cf.config.Endpoints {
if parsedURL, err := url.Parse(endpoint); err == nil {
addr := parsedURL.Host
if addr != "" {
addresses = append(addresses, addr)
}
}
}
if len(addresses) > 0 {
return addresses
}
}
// Fallback to single address
return []string{fmt.Sprintf("%s:%d", cf.config.Host, cf.config.Port)}
}
// Utility functions for common test scenarios
// CreateTestClientFactory creates a client factory from environment configuration
func CreateTestClientFactory(databaseName string) (*ClientFactory, error) {
envConfig, err := GetEnvConfig()
if err != nil {
return nil, fmt.Errorf("failed to get environment config: %w", err)
}
databasesConfig, err := GetDatabaseConfigFromEnv(envConfig.RedisEndpointsConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to get database config: %w", err)
}
dbConfig, err := GetDatabaseConfig(databasesConfig, databaseName)
if err != nil {
return nil, fmt.Errorf("failed to get database config for %s: %w", databaseName, err)
}
return NewClientFactory(dbConfig), nil
}
// CreateTestClientFactoryWithBdbID creates a client factory using a specific bdb_id
// This is useful when you've created a fresh database and want to connect to it
func CreateTestClientFactoryWithBdbID(databaseName string, bdbID int) (*ClientFactory, error) {
envConfig, err := GetEnvConfig()
if err != nil {
return nil, fmt.Errorf("failed to get environment config: %w", err)
}
databasesConfig, err := GetDatabaseConfigFromEnv(envConfig.RedisEndpointsConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to get database config: %w", err)
}
dbConfig, err := GetDatabaseConfig(databasesConfig, databaseName)
if err != nil {
return nil, fmt.Errorf("failed to get database config for %s: %w", databaseName, err)
}
// Override the bdb_id with the newly created database ID
dbConfig.BdbID = bdbID
return NewClientFactory(dbConfig), nil
}
// CreateTestFaultInjector creates a fault injector client from environment configuration
// If REDIS_ENDPOINTS_CONFIG_PATH is not set, it automatically starts a proxy fault injector server
//
// Deprecated: Use CreateTestFaultInjectorWithCleanup instead for proper cleanup
func CreateTestFaultInjector() (*FaultInjectorClient, error) {
client, _, err := CreateTestFaultInjectorWithCleanup()
return client, err
}
// CreateTestFaultInjectorWithCleanup creates a fault injector client and returns a cleanup function
//
// Decision logic based on environment:
// 1. If REDIS_ENDPOINTS_CONFIG_PATH is set -> use real fault injector from FAULT_INJECTION_API_URL
// 2. If REDIS_ENDPOINTS_CONFIG_PATH is NOT set -> use Docker fault injector at http://localhost:15000
//
// Both the Docker proxy and fault injector should already be running (started via Docker Compose)
// This function does NOT start any services - it only connects to existing ones
//
// Usage:
//
// client, cleanup, err := CreateTestFaultInjectorWithCleanup()
// if err != nil { ... }
// defer cleanup()
func CreateTestFaultInjectorWithCleanup() (*FaultInjectorClient, func(), error) {
// Try to get environment config
envConfig, err := GetEnvConfig()
// If environment config fails, use Docker fault injector
// Note: GetEnvConfig() only fails if REDIS_ENDPOINTS_CONFIG_PATH is not set
if err != nil {
// Use Docker fault injector at http://localhost:15000 (updated to avoid macOS Control Center conflict)
// The fault injector should already be running via docker-compose
faultInjectorURL := "http://localhost:15000"
// Check if fault injector is accessible
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(faultInjectorURL + "/actions")
if err != nil {
return nil, nil, fmt.Errorf("Docker fault injector not accessible at %s (did you run 'make docker.e2e.start'?): %w", faultInjectorURL, err)
}
resp.Body.Close()
fmt.Printf("✓ Using Docker fault injector at %s\n", faultInjectorURL)
// Return client with no-op cleanup (Docker manages the fault injector lifecycle)
noopCleanup := func() {}
return NewFaultInjectorClient(faultInjectorURL), noopCleanup, nil
}
// Using real fault injector - no cleanup needed
fmt.Printf("✓ Using real fault injector at %s\n", envConfig.FaultInjectorURL)
noopCleanup := func() {}
return NewFaultInjectorClient(envConfig.FaultInjectorURL), noopCleanup, nil
}
// GetAvailableDatabases returns a list of available database names from the configuration
func GetAvailableDatabases(configPath string) ([]string, error) {
databasesConfig, err := GetDatabaseConfigFromEnv(configPath)
if err != nil {
return nil, err
}
databases := make([]string, 0, len(databasesConfig))
for name := range databasesConfig {
databases = append(databases, name)
}
return databases, nil
}
// ConvertEnvDatabaseConfigToFaultInjectorConfig converts EnvDatabaseConfig to fault injector DatabaseConfig
// This creates an OSS Cluster database by default for backward compatibility.
// Use ConvertEnvDatabaseConfigToFaultInjectorConfigWithMode for explicit control over cluster mode.
func ConvertEnvDatabaseConfigToFaultInjectorConfig(envConfig EnvDatabaseConfig, name string) (DatabaseConfig, error) {
return ConvertEnvDatabaseConfigToFaultInjectorConfigWithMode(envConfig, name, true)
}
// ConvertEnvDatabaseConfigToFaultInjectorConfigWithMode converts EnvDatabaseConfig to fault injector DatabaseConfig
// with explicit control over whether to create a cluster or standalone database.
// Parameters:
// - envConfig: The environment database configuration
// - name: The name for the new database
// - clusterMode: If true, creates an OSS Cluster database (ClusterClient). If false, creates a standalone database (Client).
func ConvertEnvDatabaseConfigToFaultInjectorConfigWithMode(envConfig EnvDatabaseConfig, name string, clusterMode bool) (DatabaseConfig, error) {
var port int
// Extract port and DNS name from raw_endpoints or endpoints
if len(envConfig.RawEndpoints) > 0 {
endpoint := envConfig.RawEndpoints[0]
port = endpoint.Port
} else if len(envConfig.Endpoints) > 0 {
endpointURL, err := url.Parse(envConfig.Endpoints[0])
if err != nil {
return DatabaseConfig{}, fmt.Errorf("failed to parse endpoint URL: %w", err)
}
portStr := endpointURL.Port()
if portStr != "" {
port, err = strconv.Atoi(portStr)
if err != nil {
return DatabaseConfig{}, fmt.Errorf("invalid port: %w", err)
}
} else {
port = 6379 * 2 // default*2
}
} else {
return DatabaseConfig{}, fmt.Errorf("no endpoints found in configuration")
}
randomPortOffset := 1 + rand.Intn(5) + rand.Intn(10) + rand.Intn(10) + rand.Intn(10) // Random port offset to avoid conflicts
var dbConfig DatabaseConfig
if clusterMode {
// Build OSS Cluster database config for ClusterClient support
// Supports cluster-specific features like SMIGRATING notifications
dbConfig = DatabaseConfig{
Name: name,
Port: port + randomPortOffset,
MemorySize: 268435456, // 256MB default
Replication: true,
EvictionPolicy: "noeviction",
ProxyPolicy: "all-master-shards", // OSS Cluster API requires all-master-shards
AutoUpgrade: true,
Sharding: true,
ShardsCount: 2,
ShardKeyRegex: []ShardKeyRegexPattern{
{Regex: ".*\\{(?<tag>.*)\\}.*"},
{Regex: "(?<tag>.*)"},
},
ShardsPlacement: "sparse", // Use sparse placement to distribute shards across nodes (required for slot-shuffle)
ModuleList: []DatabaseModule{
{ModuleArgs: "", ModuleName: "ReJSON"},
{ModuleArgs: "", ModuleName: "search"},
{ModuleArgs: "", ModuleName: "timeseries"},
{ModuleArgs: "", ModuleName: "bf"},
},
OSSCluster: true, // Enable OSS Cluster API for ClusterClient support
}
// If we have raw_endpoints with cluster info, configure for cluster
if len(envConfig.RawEndpoints) > 0 {
endpoint := envConfig.RawEndpoints[0]
// Check if this is a cluster configuration
if endpoint.ProxyPolicy != "" && endpoint.ProxyPolicy != "single" {
dbConfig.OSSCluster = true
dbConfig.Sharding = true
dbConfig.ShardsCount = 3 // default for cluster
dbConfig.ProxyPolicy = endpoint.ProxyPolicy
dbConfig.Replication = true
}
// Note: We ignore endpoint.OSSClusterAPIPreferredIPType here because
// e2e tests run from outside the cluster network and need external IPs
}
// For OSS Cluster databases, always use external IPs since e2e tests
// run from outside the cluster network and cannot reach internal IPs
dbConfig.OSSClusterAPIPreferredIPType = "external"
} else {
// Build standalone database config for regular Client support
// Supports MOVING, MIGRATING, MIGRATED, FAILING_OVER, FAILED_OVER notifications
dbConfig = DatabaseConfig{
Name: name,
Port: port + randomPortOffset,
MemorySize: 268435456, // 256MB default
Replication: true, // Enable replication for failover support
EvictionPolicy: "noeviction",
ProxyPolicy: "single", // Single proxy for standalone database
AutoUpgrade: true,
Sharding: false, // No sharding for standalone
ShardsCount: 1, // Single shard
ModuleList: []DatabaseModule{
{ModuleArgs: "", ModuleName: "ReJSON"},
{ModuleArgs: "", ModuleName: "search"},
{ModuleArgs: "", ModuleName: "timeseries"},
{ModuleArgs: "", ModuleName: "bf"},
},
OSSCluster: false, // Disable OSS Cluster API for standalone Client support
}
}
return dbConfig, nil
}
// TestDatabaseManager manages database lifecycle for tests
type TestDatabaseManager struct {
faultInjector *FaultInjectorClient
clusterIndex int
createdBdbID int
dbConfig DatabaseConfig
t *testing.T
}
// NewTestDatabaseManager creates a new test database manager
func NewTestDatabaseManager(t *testing.T, faultInjector *FaultInjectorClient, clusterIndex int) *TestDatabaseManager {
return &TestDatabaseManager{
faultInjector: faultInjector,
clusterIndex: clusterIndex,
t: t,
}
}
// CreateDatabaseFromEnvConfig creates a database using EnvDatabaseConfig
func (m *TestDatabaseManager) CreateDatabaseFromEnvConfig(ctx context.Context, envConfig EnvDatabaseConfig, name string) (int, error) {
// Convert EnvDatabaseConfig to DatabaseConfig
dbConfig, err := ConvertEnvDatabaseConfigToFaultInjectorConfig(envConfig, name)
if err != nil {
return 0, fmt.Errorf("failed to convert config: %w", err)
}
m.dbConfig = dbConfig
return m.CreateDatabase(ctx, dbConfig)
}
// CreateDatabase creates a database and waits for it to be ready
// Returns the bdb_id of the created database
// If the port is unavailable, it will retry with incremented port numbers
func (m *TestDatabaseManager) CreateDatabase(ctx context.Context, dbConfig DatabaseConfig) (int, error) {
resp, finalPort, err := m.faultInjector.CreateDatabaseConfigWithPortRetry(ctx, m.clusterIndex, dbConfig, 100)
if err != nil {
return 0, fmt.Errorf("failed to trigger database creation: %w", err)
}
if finalPort != dbConfig.Port && debugE2E() {
fmt.Printf("[TestDatabaseManager] Database created on port %d (originally requested %d)\n", finalPort, dbConfig.Port)
}
// Wait for creation to complete
status, err := m.faultInjector.WaitForAction(ctx, resp.ActionID,
WithMaxWaitTime(5*time.Minute),
WithPollInterval(5*time.Second))
if err != nil {
return 0, fmt.Errorf("failed to wait for database creation: %w", err)
}
if status.Status != StatusSuccess {
return 0, fmt.Errorf("database creation failed: %v", status.Error)
}
// Extract bdb_id from output
var bdbID int
if status.Output != nil {
if id, ok := status.Output["bdb_id"].(float64); ok {
bdbID = int(id)
} else if resultMap, ok := status.Output["result"].(map[string]interface{}); ok {
if id, ok := resultMap["bdb_id"].(float64); ok {
bdbID = int(id)
}
}
}
if bdbID == 0 {
return 0, fmt.Errorf("failed to extract bdb_id from creation output")
}
m.createdBdbID = bdbID
return bdbID, nil
}
// CreateDatabaseAndGetConfig creates a database and returns both the bdb_id and the full connection config from the fault injector response
// This includes endpoints, username, password, TLS settings, and raw_endpoints
// If the port is unavailable, it will retry with incremented port numbers
func (m *TestDatabaseManager) CreateDatabaseAndGetConfig(ctx context.Context, dbConfig DatabaseConfig) (int, EnvDatabaseConfig, error) {
const maxPortRetries = 100
currentPort := dbConfig.Port
var status *ActionStatusResponse
// Retry loop for port unavailable errors
for attempt := 0; attempt < maxPortRetries; attempt++ {
// Update the port in the config
dbConfig.Port = currentPort
resp, err := m.faultInjector.CreateDatabase(ctx, m.clusterIndex, dbConfig)
if err != nil {
// Check if it's a port unavailable error at trigger time
if isPortUnavailableErrorString(err.Error()) {
if debugE2E() {
fmt.Printf("[TestDatabaseManager] Port %d unavailable at trigger, trying port %d\n", currentPort, currentPort+1)
}
currentPort++
continue
}
return 0, EnvDatabaseConfig{}, fmt.Errorf("failed to trigger database creation: %w", err)
}
// Wait for creation to complete
status, err = m.faultInjector.WaitForAction(ctx, resp.ActionID,
WithMaxWaitTime(5*time.Minute),
WithPollInterval(5*time.Second))
if err != nil {
return 0, EnvDatabaseConfig{}, fmt.Errorf("failed to wait for database creation: %w", err)
}
if status.Status == StatusSuccess {
// Success! Break out of retry loop
if currentPort != dbConfig.Port && debugE2E() {
fmt.Printf("[TestDatabaseManager] Database created on port %d (originally requested %d)\n", currentPort, dbConfig.Port)
}
break
}
// Check if the error is port unavailable
errorStr := ""
if status.Error != nil {
errorStr = fmt.Sprintf("%v", status.Error)
}
if isPortUnavailableErrorString(errorStr) {
if debugE2E() {
fmt.Printf("[TestDatabaseManager] Port %d unavailable after action, trying port %d\n", currentPort, currentPort+1)
}
currentPort++
continue
}
// Different error, don't retry
return 0, EnvDatabaseConfig{}, fmt.Errorf("database creation failed: %v", status.Error)
}
// Check if we exhausted retries
if status == nil || status.Status != StatusSuccess {
return 0, EnvDatabaseConfig{}, fmt.Errorf("failed to create database after %d port retries (last port tried: %d)", maxPortRetries, currentPort)
}
// Extract database configuration from output
var envConfig EnvDatabaseConfig
if status.Output == nil {
return 0, EnvDatabaseConfig{}, fmt.Errorf("no output in creation response")
}
// Extract bdb_id
var bdbID int
if id, ok := status.Output["bdb_id"].(float64); ok {
bdbID = int(id)
envConfig.BdbID = bdbID
} else {
return 0, EnvDatabaseConfig{}, fmt.Errorf("failed to extract bdb_id from creation output")
}
// Extract username
if username, ok := status.Output["username"].(string); ok {
envConfig.Username = username
}
// Extract password
if password, ok := status.Output["password"].(string); ok {
envConfig.Password = password
}
// Extract TLS setting
if tls, ok := status.Output["tls"].(bool); ok {
envConfig.TLS = tls
}
// Extract endpoints
if endpoints, ok := status.Output["endpoints"].([]interface{}); ok {
envConfig.Endpoints = make([]string, 0, len(endpoints))
for _, ep := range endpoints {
if epStr, ok := ep.(string); ok {
envConfig.Endpoints = append(envConfig.Endpoints, epStr)
}
}
}
// Extract raw_endpoints
if rawEndpoints, ok := status.Output["raw_endpoints"].([]interface{}); ok {
envConfig.RawEndpoints = make([]DatabaseEndpoint, 0, len(rawEndpoints))
for _, rawEp := range rawEndpoints {
if rawEpMap, ok := rawEp.(map[string]interface{}); ok {
var dbEndpoint DatabaseEndpoint
// Extract addr
if addr, ok := rawEpMap["addr"].([]interface{}); ok {
dbEndpoint.Addr = make([]string, 0, len(addr))
for _, a := range addr {
if aStr, ok := a.(string); ok {
dbEndpoint.Addr = append(dbEndpoint.Addr, aStr)
}
}
}
// Extract other fields
if addrType, ok := rawEpMap["addr_type"].(string); ok {
dbEndpoint.AddrType = addrType
}
if dnsName, ok := rawEpMap["dns_name"].(string); ok {
dbEndpoint.DNSName = dnsName
}
if preferredEndpointType, ok := rawEpMap["oss_cluster_api_preferred_endpoint_type"].(string); ok {
dbEndpoint.OSSClusterAPIPreferredEndpointType = preferredEndpointType
}
if preferredIPType, ok := rawEpMap["oss_cluster_api_preferred_ip_type"].(string); ok {
dbEndpoint.OSSClusterAPIPreferredIPType = preferredIPType
}
if port, ok := rawEpMap["port"].(float64); ok {
dbEndpoint.Port = int(port)
}
if proxyPolicy, ok := rawEpMap["proxy_policy"].(string); ok {
dbEndpoint.ProxyPolicy = proxyPolicy
}
if uid, ok := rawEpMap["uid"].(string); ok {
dbEndpoint.UID = uid
}
envConfig.RawEndpoints = append(envConfig.RawEndpoints, dbEndpoint)
}
}
}
m.createdBdbID = bdbID
return bdbID, envConfig, nil
}
// DeleteDatabase deletes the created database
func (m *TestDatabaseManager) DeleteDatabase(ctx context.Context) error {
if m.createdBdbID == 0 {
return fmt.Errorf("no database to delete (bdb_id is 0)")
}
resp, err := m.faultInjector.DeleteDatabase(ctx, m.clusterIndex, m.createdBdbID)
if err != nil {
return fmt.Errorf("failed to trigger database deletion: %w", err)
}
// Wait for deletion to complete
status, err := m.faultInjector.WaitForAction(ctx, resp.ActionID,
WithMaxWaitTime(2*time.Minute),
WithPollInterval(3*time.Second))
if err != nil {
return fmt.Errorf("failed to wait for database deletion: %w", err)
}
if status.Status != StatusSuccess {
return fmt.Errorf("database deletion failed: %v", status.Error)
}
m.createdBdbID = 0
return nil
}
// GetBdbID returns the created database ID
func (m *TestDatabaseManager) GetBdbID() int {
return m.createdBdbID
}
// Cleanup ensures the database is deleted (safe to call multiple times)
func (m *TestDatabaseManager) Cleanup(ctx context.Context) {
if m.createdBdbID != 0 {
if err := m.DeleteDatabase(ctx); err != nil {
m.t.Logf("Warning: Failed to cleanup database: %v", err)
}
}
}
// SetupTestDatabaseFromEnv creates a database from environment config and returns a cleanup function
// Usage:
//
// cleanup := SetupTestDatabaseFromEnv(t, ctx, "my-test-db")
// defer cleanup()
func SetupTestDatabaseFromEnv(t *testing.T, ctx context.Context, databaseName string) (bdbID int, cleanup func()) {
// Get environment config
envConfig, err := GetEnvConfig()
if err != nil {
t.Fatalf("Failed to get environment config: %v", err)
}
// Get database config from environment
databasesConfig, err := GetDatabaseConfigFromEnv(envConfig.RedisEndpointsConfigPath)
if err != nil {
t.Fatalf("Failed to get database config: %v", err)
}
// Get the specific database config
var envDbConfig EnvDatabaseConfig
var exists bool
if databaseName == "" {
// Get first database if no name provided
for _, config := range databasesConfig {
envDbConfig = config
exists = true
break
}
} else {
envDbConfig, exists = databasesConfig[databaseName]
}
if !exists {
t.Fatalf("Database %s not found in configuration", databaseName)
}
// Create fault injector with cleanup
faultInjector, fiCleanup, err := CreateTestFaultInjectorWithCleanup()
if err != nil {
t.Fatalf("Failed to create fault injector: %v", err)
}
// Create database manager
dbManager := NewTestDatabaseManager(t, faultInjector, 0)
// Create the database
testDBName := fmt.Sprintf("e2e-test-%s-%d", databaseName, time.Now().Unix())
bdbID, err = dbManager.CreateDatabaseFromEnvConfig(ctx, envDbConfig, testDBName)
if err != nil {
fiCleanup()
t.Fatalf("[ERROR] Failed to create test database: %v", err)
}
// Return combined cleanup function
cleanup = func() {
dbManager.Cleanup(ctx)
fiCleanup()
}
return bdbID, cleanup
}
// SetupTestDatabaseWithConfig creates a database with custom config and returns a cleanup function
// Usage:
//
// bdbID, cleanup := SetupTestDatabaseWithConfig(t, ctx, dbConfig)
// defer cleanup()
func SetupTestDatabaseWithConfig(t *testing.T, ctx context.Context, dbConfig DatabaseConfig) (bdbID int, cleanup func()) {
// Create fault injector with cleanup
faultInjector, fiCleanup, err := CreateTestFaultInjectorWithCleanup()
if err != nil {
t.Fatalf("Failed to create fault injector: %v", err)
}
// Create database manager
dbManager := NewTestDatabaseManager(t, faultInjector, 0)
// Create the database
bdbID, err = dbManager.CreateDatabase(ctx, dbConfig)
if err != nil {
fiCleanup()
t.Fatalf("[ERROR] Failed to create test database: %v", err)
}
// Return combined cleanup function
cleanup = func() {
dbManager.Cleanup(ctx)
fiCleanup()
}
return bdbID, cleanup
}
// SetupTestDatabaseAndFactory creates a database from environment config and returns both bdbID, factory, test mode config, and cleanup function
// This is the recommended way to setup tests as it ensures the client factory connects to the newly created database
//
// If REDIS_ENDPOINTS_CONFIG_PATH is not set, it will use the Docker proxy setup (127.0.0.1:17000) instead of creating a new database.
// This allows tests to work with either the real fault injector OR the Docker proxy setup.
//
// Usage:
//
// bdbID, factory, testMode, cleanup := SetupTestDatabaseAndFactory(t, ctx, "standalone")
// defer cleanup()
func SetupTestDatabaseAndFactory(t *testing.T, ctx context.Context, databaseName string) (bdbID int, factory *ClientFactory, testMode *TestModeConfig, cleanup func()) {
// Get environment config
envConfig, err := GetEnvConfig()
if err != nil {
// No environment config - use Docker proxy setup
t.Logf("No environment config found, using Docker proxy setup at 127.0.0.1:17000")
// Determine cluster mode based on database name
// "standalone" creates a non-cluster client for testing FAILING_OVER/FAILED_OVER notifications
isClusterMode := databaseName != "standalone"
// Create a simple Redis connection config for Docker proxy
redisConfig := &RedisConnectionConfig{
Host: "127.0.0.1", // Use 127.0.0.1 to force IPv4
Port: 17000,
Username: "",
Password: "",
TLS: false,
BdbID: 0,
IsClusterMode: isClusterMode,
}
factory = NewClientFactory(redisConfig)
// Get proxy mock test mode config
testMode = GetTestModeConfig()
// No-op cleanup since we're not creating a database
cleanup = func() {
factory.DestroyAll()
}
return 0, factory, testMode, cleanup
}
// Get database config from environment
databasesConfig, err := GetDatabaseConfigFromEnv(envConfig.RedisEndpointsConfigPath)
if err != nil {
t.Fatalf("Failed to get database config: %v", err)
}
// Get the specific database config
var envDbConfig EnvDatabaseConfig
var exists bool
if databaseName == "" {
// Get first database if no name provided
for _, config := range databasesConfig {
envDbConfig = config
exists = true
break
}
} else {
envDbConfig, exists = databasesConfig[databaseName]
}
if !exists {
t.Fatalf("Database %s not found in configuration", databaseName)
}
// Determine cluster mode based on database name
// "standalone" creates a non-OSS-Cluster database for testing FAILING_OVER/FAILED_OVER notifications
// Other names create OSS Cluster databases for testing SMIGRATING/SMIGRATED notifications
clusterMode := databaseName != "standalone"
// Convert to DatabaseConfig with appropriate cluster mode
dbConfig, err := ConvertEnvDatabaseConfigToFaultInjectorConfigWithMode(envDbConfig, fmt.Sprintf("e2e-test-%s-%d", databaseName, time.Now().Unix()), clusterMode)
if err != nil {
t.Fatalf("Failed to convert config: %v", err)
}
// Create fault injector with cleanup
faultInjector, fiCleanup, err := CreateTestFaultInjectorWithCleanup()
if err != nil {
t.Fatalf("Failed to create fault injector: %v", err)
}
// Create database manager
dbManager := NewTestDatabaseManager(t, faultInjector, 0)
// Create the database and get the actual connection config from fault injector
bdbID, newEnvConfig, err := dbManager.CreateDatabaseAndGetConfig(ctx, dbConfig)
if err != nil {
fiCleanup()
t.Fatalf("[ERROR] Failed to create test database: %v", err)
}
// Use certificate location from original config if not provided by fault injector
if newEnvConfig.CertificatesLocation == "" && envDbConfig.CertificatesLocation != "" {
newEnvConfig.CertificatesLocation = envDbConfig.CertificatesLocation
}
// Propagate OSSCluster flag since fault injector response may not include it
newEnvConfig.OSSCluster = dbConfig.OSSCluster
// Convert EnvDatabaseConfig to RedisConnectionConfig
redisConfig, err := ConvertEnvDatabaseConfigToRedisConnectionConfig(newEnvConfig)
if err != nil {
dbManager.Cleanup(ctx)
fiCleanup()
t.Fatalf("Failed to convert database config: %v", err)
}
// Create client factory with the actual config from fault injector
factory = NewClientFactory(redisConfig)
// Get real fault injector test mode config
testMode = GetTestModeConfig()
// Combined cleanup function
cleanup = func() {
factory.DestroyAll()
dbManager.Cleanup(ctx)
fiCleanup()
}
return bdbID, factory, testMode, cleanup
}
// SetupTestDatabaseAndFactoryWithConfig creates a database with custom config and returns both bdbID, factory, test mode config, and cleanup function
//
// If REDIS_ENDPOINTS_CONFIG_PATH is not set, it will use the Docker proxy setup (127.0.0.1:17000) instead of creating a new database.
// This allows tests to work with either the real fault injector OR the Docker proxy setup.
//
// Usage:
//
// bdbID, factory, testMode, cleanup := SetupTestDatabaseAndFactoryWithConfig(t, ctx, "standalone", dbConfig)
// defer cleanup()
func SetupTestDatabaseAndFactoryWithConfig(t *testing.T, ctx context.Context, databaseName string, dbConfig DatabaseConfig) (bdbID int, factory *ClientFactory, testMode *TestModeConfig, cleanup func()) {
// Get environment config to use as template for connection details
envConfig, err := GetEnvConfig()
if err != nil {
// No environment config - use Docker proxy setup
t.Logf("No environment config found, using Docker proxy setup at 127.0.0.1:17000")
// Create a simple Redis connection config for Docker proxy
// The proxy simulates a cluster, so we set IsClusterMode to true
redisConfig := &RedisConnectionConfig{
Host: "127.0.0.1", // Use 127.0.0.1 to force IPv4
Port: 17000,
Username: "",
Password: "",
TLS: false,
BdbID: 0,
IsClusterMode: true, // Docker proxy simulates a cluster
}
factory = NewClientFactory(redisConfig)
// Get proxy mock test mode config
testMode = GetTestModeConfig()
// No-op cleanup since we're not creating a database
cleanup = func() {
factory.DestroyAll()
}
return 0, factory, testMode, cleanup
}
// Get database config from environment
databasesConfig, err := GetDatabaseConfigFromEnv(envConfig.RedisEndpointsConfigPath)
if err != nil {
t.Fatalf("Failed to get database config: %v", err)
}
// Get the specific database config as template
var envDbConfig EnvDatabaseConfig
var exists bool
if databaseName == "" {
// Get first database if no name provided
for _, config := range databasesConfig {
envDbConfig = config
exists = true
break
}
} else {
envDbConfig, exists = databasesConfig[databaseName]
}
if !exists {
t.Fatalf("Database %s not found in configuration", databaseName)
}
// Create fault injector with cleanup
faultInjector, fiCleanup, err := CreateTestFaultInjectorWithCleanup()
if err != nil {
t.Fatalf("Failed to create fault injector: %v", err)
}
// Create database manager
dbManager := NewTestDatabaseManager(t, faultInjector, 0)
// Create the database and get the actual connection config from fault injector
bdbID, newEnvConfig, err := dbManager.CreateDatabaseAndGetConfig(ctx, dbConfig)
if err != nil {
fiCleanup()
t.Fatalf("[ERROR] Failed to create test database: %v", err)
}
// Use certificate location from original config if not provided by fault injector
if newEnvConfig.CertificatesLocation == "" && envDbConfig.CertificatesLocation != "" {
newEnvConfig.CertificatesLocation = envDbConfig.CertificatesLocation
}
// Convert EnvDatabaseConfig to RedisConnectionConfig
redisConfig, err := ConvertEnvDatabaseConfigToRedisConnectionConfig(newEnvConfig)
if err != nil {
dbManager.Cleanup(ctx)
fiCleanup()
t.Fatalf("Failed to convert database config: %v", err)
}
// Create client factory with the actual config from fault injector
factory = NewClientFactory(redisConfig)
// Get real fault injector test mode config
testMode = GetTestModeConfig()
// Combined cleanup function
cleanup = func() {
factory.DestroyAll()
dbManager.Cleanup(ctx)
fiCleanup()
}
return bdbID, factory, testMode, cleanup
}
// GetSlotMigrateRequirementsCount returns the number of database requirements for a given effect/variant.
// This is useful for iterating over all required databases when running tests.
// Returns 0 if the effect/variant combination is not found or if in proxy mode (which always returns 1).
func GetSlotMigrateRequirementsCount(t *testing.T, ctx context.Context, effect SlotMigrateEffect, variant SlotMigrateVariant) int {
// Get environment config
envConfig, err := GetEnvConfig()
if err != nil {
// No environment config - use Docker proxy setup (always 1 requirement)
return 1
}
// Create fault injector client using the URL from environment config
fiClient := NewFaultInjectorClient(envConfig.FaultInjectorURL)
// Query the fault injector for the required database configuration
triggersResp, err := fiClient.GetSlotMigrateTriggers(ctx, effect, 0)
if err != nil {
t.Logf("Warning: Failed to get slot-migrate triggers: %v", err)
return 1
}
// Find the trigger that matches our variant
for _, trigger := range triggersResp.Triggers {
if trigger.Name == string(variant) || (variant == SlotMigrateVariantDefault && trigger.Name == "migrate") {
return len(trigger.Requirements)
}
}
return 1
}
// SetupTestDatabaseForSlotMigrate creates a database configured for a specific slot-migrate effect
// It queries the fault injector's GET /slot-migrate endpoint to get the required database configuration
// and creates the database with those settings.
//
// Usage:
//
// bdbID, factory, testMode, fiClient, cleanup := SetupTestDatabaseForSlotMigrate(t, ctx, SlotMigrateEffectSlotShuffle, SlotMigrateVariantMigrate)
// defer cleanup()
func SetupTestDatabaseForSlotMigrate(t *testing.T, ctx context.Context, effect SlotMigrateEffect, variant SlotMigrateVariant) (bdbID int, factory *ClientFactory, testMode *TestModeConfig, fiClient *FaultInjectorClient, cleanup func()) {
return SetupTestDatabaseForSlotMigrateWithRequirementIndex(t, ctx, effect, variant, 0)
}
// SetupTestDatabaseForSlotMigrateWithRequirementIndex creates a database configured for a specific slot-migrate effect
// using the requirement at the specified index. This allows testing against all required database configurations.
//
// Usage:
//
// reqCount := GetSlotMigrateRequirementsCount(t, ctx, effect, variant)
// for i := 0; i < reqCount; i++ {
// bdbID, factory, testMode, fiClient, cleanup := SetupTestDatabaseForSlotMigrateWithRequirementIndex(t, ctx, effect, variant, i)
// defer cleanup()
// // run test...
// }
func SetupTestDatabaseForSlotMigrateWithRequirementIndex(t *testing.T, ctx context.Context, effect SlotMigrateEffect, variant SlotMigrateVariant, requirementIndex int) (bdbID int, factory *ClientFactory, testMode *TestModeConfig, fiClient *FaultInjectorClient, cleanup func()) {
// Get environment config
envConfig, err := GetEnvConfig()
if err != nil {
// No environment config - use Docker proxy setup
t.Logf("No environment config found, using Docker proxy setup at 127.0.0.1:17000")
redisConfig := &RedisConnectionConfig{
Host: "127.0.0.1",
Port: 17000,
Username: "",
Password: "",
TLS: false,
BdbID: 0,
IsClusterMode: true,
}
factory = NewClientFactory(redisConfig)
testMode = GetTestModeConfig()
// Create fault injector client for proxy mode using Docker fault injector URL
fiClient = NewFaultInjectorClient("http://localhost:15000")
// Call CreateDatabase to initialize the proxy fault injector with 2 nodes
// This is required for slot-shuffle, remove, and remove-add effects which need >= 2 nodes
resp, err := fiClient.CreateDatabase(ctx, 0, DatabaseConfig{})
if err != nil {
t.Fatalf("Failed to trigger CreateDatabase on proxy fault injector: %v", err)
}
// Wait for the action to complete
status, err := fiClient.WaitForAction(ctx, resp.ActionID,
WithMaxWaitTime(10*time.Second),
WithPollInterval(100*time.Millisecond))
if err != nil {
t.Fatalf("Failed to wait for CreateDatabase action: %v", err)
}
if status.Status != StatusSuccess {
t.Fatalf("CreateDatabase action failed: %v", status.Error)
}
cleanup = func() {
factory.DestroyAll()
}
return 0, factory, testMode, fiClient, cleanup
}
// Create fault injector client using the URL from environment config
fiClient = NewFaultInjectorClient(envConfig.FaultInjectorURL)
// Query the fault injector for the required database configuration
t.Logf("Querying fault injector for %s effect requirements...", effect)
if debugE2E() {
slotMigrateURL := fmt.Sprintf("%s/slot-migrate?effect=%s&cluster_index=0", envConfig.FaultInjectorURL, effect)
t.Logf(" URL: %s", slotMigrateURL)
// Get raw response for debugging
rawResp, err := fiClient.GetSlotMigrateTriggersRaw(ctx, effect, 0)
if err != nil {
t.Logf("Warning: Failed to get raw slot-migrate triggers: %v", err)
} else {
t.Logf(" Raw response: %s", string(rawResp))
}
}
triggersResp, err := fiClient.GetSlotMigrateTriggers(ctx, effect, 0)
if err != nil {
t.Fatalf("Failed to get slot-migrate triggers: %v", err)
}
// Log the full response (only in debug mode)
if debugE2E() {
t.Logf(" Response: Effect=%s, Triggers=%d", triggersResp.Effect, len(triggersResp.Triggers))
for i, trigger := range triggersResp.Triggers {
t.Logf(" Trigger[%d]: Name=%s, Description=%s, Requirements=%d", i, trigger.Name, trigger.Description, len(trigger.Requirements))
for j, req := range trigger.Requirements {
t.Logf(" Requirement[%d]: DBConfig=%v, Cluster=%v, Description=%s", j, req.DBConfig, req.Cluster, req.Description)
}
}
}
// Find the trigger that matches our variant
var requiredDBConfig map[string]interface{}
for _, trigger := range triggersResp.Triggers {
if trigger.Name == string(variant) || (variant == SlotMigrateVariantDefault && trigger.Name == "migrate") {
if requirementIndex < len(trigger.Requirements) {
requiredDBConfig = trigger.Requirements[requirementIndex].DBConfig
if debugE2E() {
t.Logf("Found required DB config for %s/%s (requirement %d/%d): %v",
effect, variant, requirementIndex+1, len(trigger.Requirements), requiredDBConfig)
}
} else if len(trigger.Requirements) > 0 {
// Fall back to first requirement if index is out of bounds
requiredDBConfig = trigger.Requirements[0].DBConfig
t.Logf("Warning: requirement index %d out of bounds (max %d), using first requirement",
requirementIndex, len(trigger.Requirements)-1)
}
break
}
}
// Get database config from environment
databasesConfig, err := GetDatabaseConfigFromEnv(envConfig.RedisEndpointsConfigPath)
if err != nil {
t.Fatalf("Failed to get database config: %v", err)
}
// Get the first database config
var envDbConfig EnvDatabaseConfig
for _, config := range databasesConfig {
envDbConfig = config
break
}
// Convert to DatabaseConfig
dbConfig, err := ConvertEnvDatabaseConfigToFaultInjectorConfig(envDbConfig, fmt.Sprintf("e2e-slotmigrate-%d", time.Now().Unix()))
if err != nil {
t.Fatalf("Failed to convert config: %v", err)
}
// Apply the required configuration from fault injector
if requiredDBConfig != nil {
if shardsCount, ok := requiredDBConfig["shards_count"]; ok {
if sc, ok := shardsCount.(float64); ok {
dbConfig.ShardsCount = int(sc)
if debugE2E() {
t.Logf("Setting ShardsCount to %d (from fault injector)", dbConfig.ShardsCount)
}
}
}
if shardsPlacement, ok := requiredDBConfig["shards_placement"]; ok {
if sp, ok := shardsPlacement.(string); ok {
dbConfig.ShardsPlacement = sp
if debugE2E() {
t.Logf("Setting ShardsPlacement to %s (from fault injector)", dbConfig.ShardsPlacement)
}
}
}
if replication, ok := requiredDBConfig["replication"]; ok {
if r, ok := replication.(bool); ok {
dbConfig.Replication = r
if debugE2E() {
t.Logf("Setting Replication to %v (from fault injector)", dbConfig.Replication)
}
}
}
if ossCluster, ok := requiredDBConfig["oss_cluster"]; ok {
if oc, ok := ossCluster.(bool); ok {
dbConfig.OSSCluster = oc
if debugE2E() {
t.Logf("Setting OSSCluster to %v (from fault injector)", dbConfig.OSSCluster)
}
}
}
if sharding, ok := requiredDBConfig["sharding"]; ok {
if s, ok := sharding.(bool); ok {
dbConfig.Sharding = s
if debugE2E() {
t.Logf("Setting Sharding to %v (from fault injector)", dbConfig.Sharding)
}
}
}
}
if debugE2E() {
t.Logf("Creating database with config: ShardsCount=%d, ShardsPlacement=%s, Replication=%v, OSSCluster=%v",
dbConfig.ShardsCount, dbConfig.ShardsPlacement, dbConfig.Replication, dbConfig.OSSCluster)
}
// Create fault injector with cleanup
faultInjector, fiCleanup, err := CreateTestFaultInjectorWithCleanup()
if err != nil {
t.Fatalf("Failed to create fault injector: %v", err)
}
// Create database manager
dbManager := NewTestDatabaseManager(t, faultInjector, 0)
// Create the database and get the actual connection config from fault injector
bdbID, newEnvConfig, err := dbManager.CreateDatabaseAndGetConfig(ctx, dbConfig)
if err != nil {
fiCleanup()
t.Fatalf("[ERROR] Failed to create test database: %v", err)
}
// Use certificate location from original config if not provided by fault injector
if newEnvConfig.CertificatesLocation == "" && envDbConfig.CertificatesLocation != "" {
newEnvConfig.CertificatesLocation = envDbConfig.CertificatesLocation
}
// Propagate OSSCluster flag since fault injector response may not include it
newEnvConfig.OSSCluster = dbConfig.OSSCluster
// Convert EnvDatabaseConfig to RedisConnectionConfig
redisConfig, err := ConvertEnvDatabaseConfigToRedisConnectionConfig(newEnvConfig)
if err != nil {
dbManager.Cleanup(ctx)
fiCleanup()
t.Fatalf("Failed to convert database config: %v", err)
}
// Create client factory with the actual config from fault injector
factory = NewClientFactory(redisConfig)
// Get real fault injector test mode config
testMode = GetTestModeConfig()
// Combined cleanup function
cleanup = func() {
factory.DestroyAll()
dbManager.Cleanup(ctx)
fiCleanup()
}
return bdbID, factory, testMode, fiClient, cleanup
}