From f58c8135ec3126f137f1ec70cf5d110a969144f6 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 25 May 2026 12:22:18 +0200 Subject: [PATCH 1/2] fix(bin): correct autoloader resolution in horde-redis-tester Use $_composer_autoload_path when available (set by Composer's bin proxy). Fall back to git-checkout path first, then production path (vendor/horde/hashtable/bin/../../../autoload.php). --- bin/horde-redis-tester | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/bin/horde-redis-tester b/bin/horde-redis-tester index 746f927..2c81155 100755 --- a/bin/horde-redis-tester +++ b/bin/horde-redis-tester @@ -16,15 +16,25 @@ declare(strict_types=1); * did not receive this file, see http://www.horde.org/licenses/lgpl21. */ -foreach ([__DIR__ . '/../vendor/autoload.php', __DIR__ . '/../../../vendor/autoload.php'] as $autoloader) { - if (file_exists($autoloader)) { - require_once $autoloader; - break; +if (isset($GLOBALS['_composer_autoload_path'])) { + require_once $GLOBALS['_composer_autoload_path']; +} else { + $found = false; + foreach ([__DIR__ . '/../vendor/autoload.php', __DIR__ . '/../../../autoload.php'] as $autoloader) { + if (file_exists($autoloader)) { + require_once $autoloader; + $found = true; + break; + } + } + if (!$found) { + fwrite(STDERR, "Error: Cannot find autoloader. Run 'composer install' first.\n"); + exit(1); } } if (!class_exists(\Horde\HashTable\Redis\Diagnostic\RedisTester::class)) { - fwrite(STDERR, "Error: Cannot find autoloader. Run 'composer install' first.\n"); + fwrite(STDERR, "Error: RedisTester class not found. Ensure horde/hashtable is installed correctly.\n"); exit(1); } From 287ad25afecace92719f9a8ae3a5dbcf11886d68 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 25 May 2026 15:57:24 +0200 Subject: [PATCH 2/2] feat(redis): add typed config objects and sentinel support Introduce RedisConfig interface, SingleNodeConfig, SentinelConfig, RedisNode value object, RedisConfigMapper (legacy array bridge), and RedisClientFactory. Supports both ext-redis and Predis for single-node and sentinel topologies. Update diagnostic tester to accept RedisConfig with sentinel-aware testing (probes sentinels, resolves master, then tests data commands). Refs https://github.com/horde/HashTable/issues/3 --- .gitignore | 6 + .horde.yml | 1 + bin/horde-redis-tester | 105 ++++--- lib/Horde/HashTable/Base.php | 2 +- lib/Horde/HashTable/Exception.php | 2 +- lib/Horde/HashTable/Lock.php | 2 +- lib/Horde/HashTable/Memcache.php | 2 +- lib/Horde/HashTable/Memory.php | 2 +- lib/Horde/HashTable/Null.php | 2 +- lib/Horde/HashTable/Predis.php | 2 +- lib/Horde/HashTable/Vfs.php | 2 +- src/ConnectionException.php | 4 +- src/Driver/NullDriver.php | 8 +- src/LockTimeoutException.php | 4 +- src/Redis/Config/RedisClientFactory.php | 259 ++++++++++++++++++ src/Redis/Config/RedisConfig.php | 32 +++ src/Redis/Config/RedisConfigMapper.php | 117 ++++++++ src/Redis/Config/RedisNode.php | 26 ++ src/Redis/Config/SentinelConfig.php | 75 +++++ src/Redis/Config/SingleNodeConfig.php | 60 ++++ src/Redis/Diagnostic/RedisTester.php | 200 +++++++++++++- test/src/Integration/MemcacheTest.php | 3 +- test/src/Integration/RedisPredisTest.php | 3 +- .../Redis/Config/RedisConfigMapperTest.php | 174 ++++++++++++ test/src/Unit/Redis/Config/RedisNodeTest.php | 39 +++ .../Unit/Redis/Config/SentinelConfigTest.php | 70 +++++ .../Redis/Config/SingleNodeConfigTest.php | 54 ++++ 27 files changed, 1195 insertions(+), 61 deletions(-) create mode 100644 src/Redis/Config/RedisClientFactory.php create mode 100644 src/Redis/Config/RedisConfig.php create mode 100644 src/Redis/Config/RedisConfigMapper.php create mode 100644 src/Redis/Config/RedisNode.php create mode 100644 src/Redis/Config/SentinelConfig.php create mode 100644 src/Redis/Config/SingleNodeConfig.php create mode 100644 test/src/Unit/Redis/Config/RedisConfigMapperTest.php create mode 100644 test/src/Unit/Redis/Config/RedisNodeTest.php create mode 100644 test/src/Unit/Redis/Config/SentinelConfigTest.php create mode 100644 test/src/Unit/Redis/Config/SingleNodeConfigTest.php diff --git a/.gitignore b/.gitignore index 3046e7c..78af8e5 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,9 @@ composer.lock /phpstan.neon # PHPStan cache directory /.phpstan.cache/ + +# Added by horde-components QC --fix-qc-issues +# Horde installer plugin runtime data +/var/ +# Horde installer plugin web-accessible directory +/web/ diff --git a/.horde.yml b/.horde.yml index 091cd51..e4105b7 100644 --- a/.horde.yml +++ b/.horde.yml @@ -45,3 +45,4 @@ autoload-dev: Horde\HashTable\Test\Src\Unit\: test/src/Unit Horde\HashTable\Test\Src\Integration\: test/src/Integration vendor: horde +keywords: [] diff --git a/bin/horde-redis-tester b/bin/horde-redis-tester index 2c81155..5d01f6e 100755 --- a/bin/horde-redis-tester +++ b/bin/horde-redis-tester @@ -56,6 +56,10 @@ if (!extension_loaded('redis') && !class_exists(\Predis\Client::class)) { use Horde\Argv\Option; use Horde\Argv\Parser; use Horde\Cli\Cli; +use Horde\HashTable\Redis\Config\RedisConfigMapper; +use Horde\HashTable\Redis\Config\RedisNode; +use Horde\HashTable\Redis\Config\SentinelConfig; +use Horde\HashTable\Redis\Config\SingleNodeConfig; use Horde\HashTable\Redis\Diagnostic\DiagnosticResult; use Horde\HashTable\Redis\Diagnostic\RedisDriver; use Horde\HashTable\Redis\Diagnostic\RedisTester; @@ -133,39 +137,61 @@ $parser->addOption(new Option('--driver', [ [$options, $args] = $parser->parseArgs(); -$configDefaults = loadConfigFile($options->configFile); - -$tls = (bool) $options->tls; -$hostname = $options->hostname ?? $configDefaults['hostname'] ?? '127.0.0.1'; -$port = $options->port ?? $configDefaults['port'] ?? ($tls ? 6380 : 6379); -$username = $options->username ?? $configDefaults['username'] ?? null; -$password = $options->password ?? $configDefaults['password'] ?? null; -$prefix = $options->prefix ?? $configDefaults['prefix'] ?? 'hht_'; -$database = $options->database ?? $configDefaults['database'] ?? 0; $driver = RedisDriver::from($options->driver); - $cli = new Cli(['pager' => false]); +$configParams = loadConfigFileParams($options->configFile); + +if ($configParams !== [] && $options->hostname === null) { + $prefix = $options->prefix ?? $configParams['prefix'] ?? 'hht_'; + if (isset($options->database)) { + $configParams['database'] = $options->database; + } + if ($options->username !== null) { + $configParams['username'] = $options->username; + } + if ($options->password !== null) { + $configParams['password'] = $options->password; + } + $config = RedisConfigMapper::fromArray($configParams, $prefix); +} else { + $configDefaults = extractLegacyDefaults($configParams); + $tls = (bool) $options->tls; + $hostname = $options->hostname ?? $configDefaults['hostname'] ?? '127.0.0.1'; + $port = $options->port ?? $configDefaults['port'] ?? ($tls ? 6380 : 6379); + $username = $options->username ?? $configDefaults['username'] ?? null; + $password = $options->password ?? $configDefaults['password'] ?? null; + $prefix = $options->prefix ?? $configDefaults['prefix'] ?? 'hht_'; + $database = $options->database ?? $configDefaults['database'] ?? 0; + + $config = new SingleNodeConfig( + node: new RedisNode(host: $hostname, port: $port, tls: $tls), + password: ($password !== null && $password !== '') ? $password : null, + username: ($username !== null && $username !== '') ? $username : null, + prefix: $prefix, + database: (int) $database, + ); +} + $cli->writeln(''); $cli->header('Horde Redis Diagnostic'); -$cli->writeln('Host: ' . $hostname . ':' . $port . ' (TLS: ' . ($tls ? 'yes' : 'no') . ')'); -$cli->writeln('Prefix: ' . $prefix); -$cli->writeln('Database: ' . $database); -if ($username) { - $cli->writeln('Username: ' . $username); + +if ($config instanceof SentinelConfig) { + $hosts = array_map(fn($n) => $n->host . ':' . $n->port, $config->sentinels); + $cli->writeln('Mode: Sentinel (service: ' . $config->service . ')'); + $cli->writeln('Sentinels: ' . implode(', ', $hosts)); +} elseif ($config instanceof SingleNodeConfig) { + $tlsLabel = $config->node->tls ? 'yes' : 'no'; + $cli->writeln('Host: ' . $config->node->host . ':' . $config->node->port . ' (TLS: ' . $tlsLabel . ')'); +} +$cli->writeln('Prefix: ' . $config->prefix()); +$cli->writeln('Database: ' . $config->database()); +if ($config->username()) { + $cli->writeln('Username: ' . $config->username()); } $cli->writeln(''); -$tester = new RedisTester( - hostname: $hostname, - port: $port, - tls: $tls, - username: $username, - password: $password, - prefix: $prefix, - database: $database, - driver: $driver, -); +$tester = RedisTester::fromConfig($config, $driver); $result = $tester->run(); printResults($cli, $result); @@ -173,7 +199,7 @@ exit($result->hasErrors() ? 2 : ($result->hasWarnings() ? 1 : 0)); // --- Helper functions --- -function loadConfigFile(?string $path): array +function loadConfigFileParams(?string $path): array { if ($path === null) { return []; @@ -195,6 +221,23 @@ function loadConfigFile(?string $path): array $ht = $conf['hashtable']; $params = $ht['params'] ?? []; + if (isset($ht['prefix']) && $ht['prefix'] !== '') { + $params['prefix'] = $ht['prefix']; + } + + if (isset($ht['driver'])) { + $driver = strtolower($ht['driver']); + if (!in_array($driver, ['predis', 'redis'], true)) { + fwrite(STDERR, "Warning: hashtable.driver is '{$ht['driver']}' (not predis/redis). " + . "This tool tests Redis only.\n"); + } + } + + return $params; +} + +function extractLegacyDefaults(array $params): array +{ $defaults = []; $defaults['hostname'] = $params['hostspec'][0] @@ -215,15 +258,7 @@ function loadConfigFile(?string $path): array $defaults['database'] = (int) $db; } - $defaults['prefix'] = $ht['prefix'] ?? null; - - if (isset($ht['driver'])) { - $driver = strtolower($ht['driver']); - if (!in_array($driver, ['predis', 'redis'], true)) { - fwrite(STDERR, "Warning: hashtable.driver is '{$ht['driver']}' (not predis/redis). " - . "This tool tests Redis only.\n"); - } - } + $defaults['prefix'] = $params['prefix'] ?? null; return array_filter($defaults, fn($v) => $v !== null && $v !== ''); } diff --git a/lib/Horde/HashTable/Base.php b/lib/Horde/HashTable/Base.php index 308f007..2864cbd 100644 --- a/lib/Horde/HashTable/Base.php +++ b/lib/Horde/HashTable/Base.php @@ -1,7 +1,7 @@ createPhpRedis($config); + } + + if (class_exists(PredisClient::class)) { + return $this->createPredis($config); + } + + if (extension_loaded('redis')) { + return $this->createPhpRedis($config); + } + + throw new ConnectionException( + 'No Redis client available. Install ext-redis or predis/predis.' + ); + } + + private function createPredis(RedisConfig $config): PredisClient + { + if ($config instanceof SentinelConfig) { + return $this->createPredisSentinel($config); + } + + if ($config instanceof SingleNodeConfig) { + return $this->createPredisSingle($config); + } + + throw new ConnectionException('Unsupported RedisConfig type: ' . $config::class); + } + + private function createPredisSingle(SingleNodeConfig $config): PredisClient + { + if ($config->isUnixSocket()) { + $connectionParams = [ + 'scheme' => 'unix', + 'path' => $config->socket, + ]; + } else { + $connectionParams = [ + 'scheme' => $config->node->tls ? 'tls' : 'tcp', + 'host' => $config->node->host, + 'port' => $config->node->port, + ]; + } + + if ($config->password() !== null) { + $connectionParams['password'] = $config->password(); + } + + if ($config->username() !== null) { + $connectionParams['username'] = $config->username(); + } + + if ($config->database() !== 0) { + $connectionParams['database'] = $config->database(); + } + + if ($config->persistent()) { + $connectionParams['persistent'] = true; + } + + return new PredisClient($connectionParams); + } + + private function createPredisSentinel(SentinelConfig $config): PredisClient + { + $parameters = []; + foreach ($config->sentinels as $node) { + $entry = [ + 'scheme' => $node->tls ? 'tls' : 'tcp', + 'host' => $node->host, + 'port' => $node->port, + ]; + if ($config->sentinelPassword !== null) { + $entry['password'] = $config->sentinelPassword; + } + $parameters[] = $entry; + } + + $options = [ + 'replication' => 'sentinel', + 'service' => $config->service, + ]; + + $dataParams = []; + if ($config->password() !== null) { + $dataParams['password'] = $config->password(); + } + if ($config->username() !== null) { + $dataParams['username'] = $config->username(); + } + if ($config->database() !== 0) { + $dataParams['database'] = $config->database(); + } + if ($config->persistent()) { + $dataParams['persistent'] = true; + } + if ($dataParams !== []) { + $options['parameters'] = $dataParams; + } + + return new PredisClient($parameters, $options); + } + + private function createPhpRedis(RedisConfig $config): PhpRedis + { + if ($config instanceof SentinelConfig) { + return $this->createPhpRedisSentinel($config); + } + + if ($config instanceof SingleNodeConfig) { + return $this->createPhpRedisSingle($config); + } + + throw new ConnectionException('Unsupported RedisConfig type: ' . $config::class); + } + + private function createPhpRedisSingle(SingleNodeConfig $config): PhpRedis + { + $redis = new PhpRedis(); + + if ($config->isUnixSocket()) { + $connected = $config->persistent() + ? $redis->pconnect($config->socket) + : $redis->connect($config->socket); + } else { + $host = $config->node->tls ? 'tls://' . $config->node->host : $config->node->host; + $connected = $config->persistent() + ? $redis->pconnect($host, $config->node->port) + : $redis->connect($host, $config->node->port); + } + + if (!$connected) { + throw new ConnectionException( + 'Failed to connect to Redis at ' . $config->node->host . ':' . $config->node->port + ); + } + + $this->phpRedisAuth($redis, $config); + $this->phpRedisSelect($redis, $config); + + return $redis; + } + + private function createPhpRedisSentinel(SentinelConfig $config): PhpRedis + { + if (!class_exists(RedisSentinel::class)) { + throw new ConnectionException( + 'ext-redis RedisSentinel class not available. ' + . 'Upgrade to phpredis 5.x+ or use Predis for sentinel support.' + ); + } + + foreach ($config->sentinels as $node) { + $opts = ['host' => $node->host, 'port' => $node->port]; + if ($config->sentinelPassword !== null) { + $opts['auth'] = $config->sentinelPassword; + } + + try { + $sentinel = new RedisSentinel($opts); + $master = $sentinel->getMasterAddrByName($config->service); + } catch (RedisException $e) { + continue; + } + + if ($master === false || !is_array($master)) { + continue; + } + + $redis = new PhpRedis(); + $masterHost = (string) $master[0]; + $masterPort = (int) $master[1]; + + $connected = $config->persistent() + ? $redis->pconnect($masterHost, $masterPort) + : $redis->connect($masterHost, $masterPort); + + if (!$connected) { + continue; + } + + $this->phpRedisAuth($redis, $config); + $this->phpRedisSelect($redis, $config); + + return $redis; + } + + throw new ConnectionException( + 'No sentinel responded with master address for service: ' . $config->service + ); + } + + private function phpRedisAuth(PhpRedis $redis, RedisConfig $config): void + { + if ($config->password() === null) { + return; + } + + if ($config->username() !== null) { + $result = $redis->auth([$config->username(), $config->password()]); + } else { + $result = $redis->auth($config->password()); + } + + if (!$result) { + throw new ConnectionException('Redis authentication failed.'); + } + } + + private function phpRedisSelect(PhpRedis $redis, RedisConfig $config): void + { + if ($config->database() !== 0) { + $redis->select($config->database()); + } + } +} diff --git a/src/Redis/Config/RedisConfig.php b/src/Redis/Config/RedisConfig.php new file mode 100644 index 0000000..1b8dc64 --- /dev/null +++ b/src/Redis/Config/RedisConfig.php @@ -0,0 +1,32 @@ + ['host1', 'host2'], + * 'port' => ['6379', '6379'], + * 'protocol' => 'tcp', + * 'replication' => 'sentinel', // or 'none' + * 'service' => 'mymaster', + * 'password' => 'secret', + * 'username' => 'horde', + * 'sentinelPassword' => 'sentinel_secret', + * 'database' => 0, + * 'persistent' => true, + * 'socket' => '/var/run/redis.sock', + * ]; + */ +final class RedisConfigMapper +{ + /** + * @param array $params The hashtable.params array from conf.php. + * @param string $prefix Key prefix (from hashtable.prefix config). + */ + public static function fromArray(array $params, string $prefix = 'hht_'): RedisConfig + { + $protocol = (string) ($params['protocol'] ?? 'tcp'); + $replication = (string) ($params['replication'] ?? 'none'); + $password = self::nullIfEmpty($params['password'] ?? null); + $username = self::nullIfEmpty($params['username'] ?? null); + $database = (int) ($params['database'] ?? 0); + $persistent = !empty($params['persistent']); + + if ($replication === 'sentinel') { + return new SentinelConfig( + sentinels: self::buildNodeList($params), + service: (string) ($params['service'] ?? 'mymaster'), + password: $password, + username: $username, + sentinelPassword: self::nullIfEmpty($params['sentinelPassword'] ?? null), + prefix: $prefix, + database: $database, + persistent: $persistent, + ); + } + + if ($protocol === 'unix') { + return new SingleNodeConfig( + node: new RedisNode('127.0.0.1'), + password: $password, + username: $username, + prefix: $prefix, + database: $database, + persistent: $persistent, + socket: $params['socket'] ?? null, + ); + } + + $hostspec = $params['hostspec'] ?? ['127.0.0.1']; + $host = is_array($hostspec) ? (string) ($hostspec[0] ?? '127.0.0.1') : (string) $hostspec; + $port = $params['port'] ?? [6379]; + $portInt = is_array($port) ? (int) ($port[0] ?? 6379) : (int) $port; + + return new SingleNodeConfig( + node: new RedisNode(host: trim($host), port: $portInt), + password: $password, + username: $username, + prefix: $prefix, + database: $database, + persistent: $persistent, + ); + } + + /** + * Builds a list of RedisNode from parallel hostspec/port arrays. + * + * @return list + */ + private static function buildNodeList(array $params): array + { + $hosts = (array) ($params['hostspec'] ?? []); + $ports = (array) ($params['port'] ?? []); + $nodes = []; + + foreach ($hosts as $i => $host) { + $port = (int) trim((string) ($ports[$i] ?? '26379')); + $nodes[] = new RedisNode(host: trim((string) $host), port: $port); + } + + return $nodes; + } + + private static function nullIfEmpty(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return (string) $value; + } +} diff --git a/src/Redis/Config/RedisNode.php b/src/Redis/Config/RedisNode.php new file mode 100644 index 0000000..2cb96e9 --- /dev/null +++ b/src/Redis/Config/RedisNode.php @@ -0,0 +1,26 @@ + */ + public readonly array $sentinels; + + /** + * @param list $sentinels Sentinel endpoints to query. + * @param string $service Sentinel service name (e.g. "mymaster"). + * @param ?string $password Data node AUTH password. + * @param ?string $username Data node ACL username (Redis 6+). + * @param ?string $sentinelPassword Password for sentinel nodes themselves. + * @param string $prefix Key prefix. + * @param int $database Database index for data node. + * @param bool $persistent Use persistent connections. + */ + public function __construct( + array $sentinels, + public readonly string $service, + private readonly ?string $password = null, + private readonly ?string $username = null, + public readonly ?string $sentinelPassword = null, + private readonly string $prefix = 'hht_', + private readonly int $database = 0, + private readonly bool $persistent = false, + ) { + $this->sentinels = array_values($sentinels); + } + + public function prefix(): string + { + return $this->prefix; + } + + public function database(): int + { + return $this->database; + } + + public function persistent(): bool + { + return $this->persistent; + } + + public function password(): ?string + { + return $this->password; + } + + public function username(): ?string + { + return $this->username; + } +} diff --git a/src/Redis/Config/SingleNodeConfig.php b/src/Redis/Config/SingleNodeConfig.php new file mode 100644 index 0000000..1418098 --- /dev/null +++ b/src/Redis/Config/SingleNodeConfig.php @@ -0,0 +1,60 @@ +prefix; + } + + public function database(): int + { + return $this->database; + } + + public function persistent(): bool + { + return $this->persistent; + } + + public function password(): ?string + { + return $this->password; + } + + public function username(): ?string + { + return $this->username; + } + + public function isUnixSocket(): bool + { + return $this->socket !== null; + } +} diff --git a/src/Redis/Diagnostic/RedisTester.php b/src/Redis/Diagnostic/RedisTester.php index caa9c1e..86d9e4f 100644 --- a/src/Redis/Diagnostic/RedisTester.php +++ b/src/Redis/Diagnostic/RedisTester.php @@ -11,9 +11,15 @@ namespace Horde\HashTable\Redis\Diagnostic; +use Horde\HashTable\Redis\Config\RedisConfig; +use Horde\HashTable\Redis\Config\RedisNode; +use Horde\HashTable\Redis\Config\SentinelConfig; +use Horde\HashTable\Redis\Config\SingleNodeConfig; use Predis\Client as PredisClient; use Redis as PhpRedis; +use RedisSentinel; use Throwable; +use RuntimeException; /** * Standalone Redis diagnostic tester. @@ -36,14 +42,68 @@ public function __construct( private readonly string $prefix, private readonly int $database, private readonly RedisDriver $driver = RedisDriver::AUTO, + private readonly ?RedisConfig $config = null, ) {} + /** + * Create a tester from a typed RedisConfig object. + */ + public static function fromConfig(RedisConfig $config, RedisDriver $driver = RedisDriver::AUTO): self + { + if ($config instanceof SentinelConfig) { + $firstNode = $config->sentinels[0] ?? new RedisNode('127.0.0.1', 26379); + return new self( + hostname: $firstNode->host, + port: $firstNode->port, + tls: $firstNode->tls, + username: $config->username(), + password: $config->password(), + prefix: $config->prefix(), + database: $config->database(), + driver: $driver, + config: $config, + ); + } + + if ($config instanceof SingleNodeConfig) { + return new self( + hostname: $config->node->host, + port: $config->node->port, + tls: $config->node->tls, + username: $config->username(), + password: $config->password(), + prefix: $config->prefix(), + database: $config->database(), + driver: $driver, + config: $config, + ); + } + + return new self( + hostname: '127.0.0.1', + port: 6379, + tls: false, + username: $config->username(), + password: $config->password(), + prefix: $config->prefix(), + database: $config->database(), + driver: $driver, + config: $config, + ); + } + public function run(): DiagnosticResult { $result = new DiagnosticResult(); $this->reportDriverAvailability($result); - $this->testConnection($result); + + if ($this->config instanceof SentinelConfig) { + $this->testSentinelTopology($result); + } else { + $this->testConnection($result); + } + if ($result->hasErrors()) { return $result; } @@ -110,14 +170,14 @@ private function resolveDriver(bool $phpredisAvailable, bool $predisAvailable): { if ($this->driver === RedisDriver::PHPREDIS) { if (!$phpredisAvailable) { - throw new \RuntimeException('ext-redis requested but not available'); + throw new RuntimeException('ext-redis requested but not available'); } return RedisDriver::PHPREDIS; } if ($this->driver === RedisDriver::PREDIS) { if (!$predisAvailable) { - throw new \RuntimeException('predis requested but not installed'); + throw new RuntimeException('predis requested but not installed'); } return RedisDriver::PREDIS; } @@ -130,6 +190,138 @@ private function resolveDriver(bool $phpredisAvailable, bool $predisAvailable): return RedisDriver::PREDIS; } + private function testSentinelTopology(DiagnosticResult $result): void + { + assert($this->config instanceof SentinelConfig); + $sentinelConfig = $this->config; + + $reachable = 0; + $masterAddr = null; + + foreach ($sentinelConfig->sentinels as $node) { + try { + if ($this->resolvedDriver === RedisDriver::PREDIS) { + $params = ['scheme' => 'tcp', 'host' => $node->host, 'port' => $node->port]; + if ($sentinelConfig->sentinelPassword !== null) { + $params['password'] = $sentinelConfig->sentinelPassword; + } + $sentinel = new PredisClient($params); + $sentinel->ping(); + $reachable++; + + if ($masterAddr === null) { + $addr = $sentinel->sentinel('get-master-addr-by-name', $sentinelConfig->service); + if (is_array($addr) && count($addr) === 2) { + $masterAddr = [(string) $addr[0], (int) $addr[1]]; + } + } + } else { + $opts = ['host' => $node->host, 'port' => $node->port]; + if ($sentinelConfig->sentinelPassword !== null) { + $opts['auth'] = $sentinelConfig->sentinelPassword; + } + $sentinel = new RedisSentinel($opts); + $sentinel->ping(); + $reachable++; + + if ($masterAddr === null) { + $addr = $sentinel->getMasterAddrByName($sentinelConfig->service); + if (is_array($addr) && count($addr) === 2) { + $masterAddr = [(string) $addr[0], (int) $addr[1]]; + } + } + } + } catch (Throwable $e) { + $result->add(new TestResult( + 'Sentinel', + TestStatus::WARNING, + 'Cannot reach sentinel ' . $node->host . ':' . $node->port, + $e->getMessage(), + )); + } + } + + $total = count($sentinelConfig->sentinels); + if ($reachable === 0) { + $result->add(new TestResult( + 'Sentinel', + TestStatus::ERROR, + 'No sentinels reachable (tried ' . $total . ')', + )); + return; + } + + $result->add(new TestResult( + 'Sentinel', + TestStatus::OK, + $reachable . '/' . $total . ' sentinels reachable', + )); + + if ($masterAddr === null) { + $result->add(new TestResult( + 'Master', + TestStatus::ERROR, + 'No sentinel returned master address for service: ' . $sentinelConfig->service, + )); + return; + } + + $result->add(new TestResult( + 'Master', + TestStatus::OK, + 'Resolved master: ' . $masterAddr[0] . ':' . $masterAddr[1] . ' (service: ' . $sentinelConfig->service . ')', + )); + + $this->connectToResolvedMaster($result, $masterAddr[0], $masterAddr[1]); + } + + private function connectToResolvedMaster(DiagnosticResult $result, string $host, int $port): void + { + try { + if ($this->resolvedDriver === RedisDriver::PHPREDIS) { + $redis = new PhpRedis(); + $connected = $redis->connect($host, $port); + if (!$connected) { + throw new RuntimeException('connect() returned false'); + } + if ($this->username !== null && $this->username !== '') { + $redis->auth([$this->username, $this->password ?? '']); + } elseif ($this->password !== null && $this->password !== '') { + $redis->auth($this->password); + } + $this->client = $redis; + $info = $redis->info('server'); + $version = $info['redis_version'] ?? 'unknown'; + } else { + $connectionParams = ['scheme' => 'tcp', 'host' => $host, 'port' => $port]; + if ($this->password !== null && $this->password !== '') { + $connectionParams['password'] = $this->password; + } + if ($this->username !== null && $this->username !== '') { + $connectionParams['username'] = $this->username; + } + $this->client = new PredisClient($connectionParams); + $info = $this->client->info('server'); + $version = $info['Server']['redis_version'] ?? $info['redis_version'] ?? 'unknown'; + } + + $driverName = $this->resolvedDriver === RedisDriver::PHPREDIS ? 'ext-redis' : 'predis'; + $result->add(new TestResult( + 'Connection', + TestStatus::OK, + 'Connected to master Redis ' . $version . ' via ' . $driverName, + )); + $this->reportAuth($result); + } catch (Throwable $e) { + $result->add(new TestResult( + 'Connection', + TestStatus::ERROR, + 'Failed to connect to resolved master ' . $host . ':' . $port, + $e->getMessage(), + )); + } + } + private function testConnection(DiagnosticResult $result): void { try { @@ -160,7 +352,7 @@ private function connectPhpRedis(DiagnosticResult $result): void $connected = $redis->connect($host, $this->port); if (!$connected) { - throw new \RuntimeException('connect() returned false'); + throw new RuntimeException('connect() returned false'); } if ($this->username !== null && $this->username !== '') { diff --git a/test/src/Integration/MemcacheTest.php b/test/src/Integration/MemcacheTest.php index a5c8f4e..b94c608 100644 --- a/test/src/Integration/MemcacheTest.php +++ b/test/src/Integration/MemcacheTest.php @@ -24,6 +24,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Exception; /** * Integration tests for the Memcache HashTable driver. @@ -53,7 +54,7 @@ public static function setUpBeforeClass(): void 'port' => [11211], 'prefix' => 'hht_integration_test', ]); - } catch (\Exception $e) { + } catch (Exception $e) { self::markTestSkipped('Memcache server not available: ' . $e->getMessage()); } } diff --git a/test/src/Integration/RedisPredisTest.php b/test/src/Integration/RedisPredisTest.php index 053e10d..f250c8a 100644 --- a/test/src/Integration/RedisPredisTest.php +++ b/test/src/Integration/RedisPredisTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Predis\Client as PredisClient; +use Exception; #[CoversClass(Redis::class)] #[Group('integration')] @@ -34,7 +35,7 @@ public static function setUpBeforeClass(): void 'database' => 15, ]); self::$predis->ping(); - } catch (\Exception $e) { + } catch (Exception $e) { self::markTestSkipped('Redis server not available: ' . $e->getMessage()); } } diff --git a/test/src/Unit/Redis/Config/RedisConfigMapperTest.php b/test/src/Unit/Redis/Config/RedisConfigMapperTest.php new file mode 100644 index 0000000..b3cfae4 --- /dev/null +++ b/test/src/Unit/Redis/Config/RedisConfigMapperTest.php @@ -0,0 +1,174 @@ +assertInstanceOf(SingleNodeConfig::class, $config); + $this->assertSame('127.0.0.1', $config->node->host); + $this->assertSame(6379, $config->node->port); + } + + #[Test] + public function singleHostTcp(): void + { + $params = [ + 'hostspec' => ['redis.local'], + 'port' => ['6380'], + 'protocol' => 'tcp', + 'password' => 'secret', + 'username' => 'horde', + 'database' => 3, + 'persistent' => true, + ]; + + $config = RedisConfigMapper::fromArray($params, 'myapp_'); + $this->assertInstanceOf(SingleNodeConfig::class, $config); + $this->assertSame('redis.local', $config->node->host); + $this->assertSame(6380, $config->node->port); + $this->assertSame('secret', $config->password()); + $this->assertSame('horde', $config->username()); + $this->assertSame(3, $config->database()); + $this->assertTrue($config->persistent()); + $this->assertSame('myapp_', $config->prefix()); + } + + #[Test] + public function unixSocket(): void + { + $params = [ + 'protocol' => 'unix', + 'socket' => '/var/run/redis.sock', + 'password' => 'pw', + ]; + + $config = RedisConfigMapper::fromArray($params); + $this->assertInstanceOf(SingleNodeConfig::class, $config); + $this->assertTrue($config->isUnixSocket()); + $this->assertSame('/var/run/redis.sock', $config->socket); + $this->assertSame('pw', $config->password()); + } + + #[Test] + public function sentinelConfig(): void + { + $params = [ + 'hostspec' => ['edm-cmfe01', 'edm-cmfe02', 'edm-webapps01', 'edm-webapps02'], + 'port' => ['26379', '26379', '26379', '26379'], + 'service' => 'mymaster', + 'replication' => 'sentinel', + 'protocol' => 'tcp', + 'persistent' => true, + 'password' => 'data_pass', + 'username' => 'horde_user', + 'sentinelPassword' => 'sentinel_pass', + ]; + + $config = RedisConfigMapper::fromArray($params, 'hht_'); + $this->assertInstanceOf(SentinelConfig::class, $config); + $this->assertSame('mymaster', $config->service); + $this->assertCount(4, $config->sentinels); + $this->assertSame('edm-cmfe01', $config->sentinels[0]->host); + $this->assertSame(26379, $config->sentinels[0]->port); + $this->assertSame('edm-webapps02', $config->sentinels[3]->host); + $this->assertSame('data_pass', $config->password()); + $this->assertSame('horde_user', $config->username()); + $this->assertSame('sentinel_pass', $config->sentinelPassword); + $this->assertTrue($config->persistent()); + } + + #[Test] + public function sentinelDefaultsPort26379WhenPortArrayShorter(): void + { + $params = [ + 'hostspec' => ['s1', 's2', 's3'], + 'port' => ['26379'], + 'replication' => 'sentinel', + 'service' => 'redis', + ]; + + $config = RedisConfigMapper::fromArray($params); + $this->assertInstanceOf(SentinelConfig::class, $config); + $this->assertSame(26379, $config->sentinels[0]->port); + $this->assertSame(26379, $config->sentinels[1]->port); + $this->assertSame(26379, $config->sentinels[2]->port); + } + + #[Test] + public function emptyPasswordTreatedAsNull(): void + { + $params = [ + 'hostspec' => ['localhost'], + 'port' => ['6379'], + 'password' => '', + 'username' => '', + ]; + + $config = RedisConfigMapper::fromArray($params); + $this->assertInstanceOf(SingleNodeConfig::class, $config); + $this->assertNull($config->password()); + $this->assertNull($config->username()); + } + + #[Test] + public function scalarHostspecHandledGracefully(): void + { + $params = [ + 'hostspec' => 'single-host', + 'port' => 6380, + ]; + + $config = RedisConfigMapper::fromArray($params); + $this->assertInstanceOf(SingleNodeConfig::class, $config); + $this->assertSame('single-host', $config->node->host); + $this->assertSame(6380, $config->node->port); + } + + #[Test] + public function replicationNoneProducesSingleNode(): void + { + $params = [ + 'hostspec' => ['myhost'], + 'port' => ['6379'], + 'replication' => 'none', + ]; + + $config = RedisConfigMapper::fromArray($params); + $this->assertInstanceOf(SingleNodeConfig::class, $config); + } + + #[Test] + public function nelsLindquistRealWorldConfig(): void + { + $params = [ + 'hostspec' => ['edm-cmfe01', 'edm-cmfe02', 'edm-webapps01', 'edm-webapps02'], + 'port' => ['26379', '26379', '26379', '26379'], + 'service' => 'mymaster', + 'replication' => 'sentinel', + 'protocol' => 'tcp', + 'persistent' => true, + ]; + + $config = RedisConfigMapper::fromArray($params); + $this->assertInstanceOf(SentinelConfig::class, $config); + $this->assertSame('mymaster', $config->service); + $this->assertCount(4, $config->sentinels); + $this->assertNull($config->password()); + $this->assertNull($config->sentinelPassword); + $this->assertTrue($config->persistent()); + } +} diff --git a/test/src/Unit/Redis/Config/RedisNodeTest.php b/test/src/Unit/Redis/Config/RedisNodeTest.php new file mode 100644 index 0000000..1d09d1e --- /dev/null +++ b/test/src/Unit/Redis/Config/RedisNodeTest.php @@ -0,0 +1,39 @@ +assertSame('redis.local', $node->host); + $this->assertSame(6379, $node->port); + $this->assertFalse($node->tls); + } + + #[Test] + public function customPortAndTls(): void + { + $node = new RedisNode('secure.redis.io', 6380, true); + $this->assertSame('secure.redis.io', $node->host); + $this->assertSame(6380, $node->port); + $this->assertTrue($node->tls); + } + + #[Test] + public function sentinelPort(): void + { + $node = new RedisNode('sentinel1', 26379); + $this->assertSame(26379, $node->port); + } +} diff --git a/test/src/Unit/Redis/Config/SentinelConfigTest.php b/test/src/Unit/Redis/Config/SentinelConfigTest.php new file mode 100644 index 0000000..404b115 --- /dev/null +++ b/test/src/Unit/Redis/Config/SentinelConfigTest.php @@ -0,0 +1,70 @@ +assertCount(2, $config->sentinels); + $this->assertSame('mymaster', $config->service); + $this->assertNull($config->password()); + $this->assertNull($config->username()); + $this->assertNull($config->sentinelPassword); + $this->assertSame('hht_', $config->prefix()); + $this->assertSame(0, $config->database()); + $this->assertFalse($config->persistent()); + } + + #[Test] + public function withAllCredentials(): void + { + $config = new SentinelConfig( + sentinels: [new RedisNode('s1', 26379)], + service: 'prod', + password: 'data_pass', + username: 'horde', + sentinelPassword: 'sentinel_pass', + prefix: 'app_', + database: 2, + persistent: true, + ); + + $this->assertSame('data_pass', $config->password()); + $this->assertSame('horde', $config->username()); + $this->assertSame('sentinel_pass', $config->sentinelPassword); + $this->assertSame('app_', $config->prefix()); + $this->assertSame(2, $config->database()); + $this->assertTrue($config->persistent()); + } + + #[Test] + public function sentinelsAreReindexed(): void + { + $nodes = [ + 2 => new RedisNode('a', 26379), + 5 => new RedisNode('b', 26379), + ]; + $config = new SentinelConfig($nodes, 'svc'); + $this->assertSame(0, array_key_first($config->sentinels)); + $this->assertSame(1, array_key_last($config->sentinels)); + } +} diff --git a/test/src/Unit/Redis/Config/SingleNodeConfigTest.php b/test/src/Unit/Redis/Config/SingleNodeConfigTest.php new file mode 100644 index 0000000..5744502 --- /dev/null +++ b/test/src/Unit/Redis/Config/SingleNodeConfigTest.php @@ -0,0 +1,54 @@ +assertSame('hht_', $config->prefix()); + $this->assertSame(0, $config->database()); + $this->assertFalse($config->persistent()); + $this->assertNull($config->password()); + $this->assertNull($config->username()); + $this->assertFalse($config->isUnixSocket()); + } + + #[Test] + public function withCredentials(): void + { + $config = new SingleNodeConfig( + new RedisNode('redis.local', 6380), + password: 'secret', + username: 'horde', + database: 3, + persistent: true, + ); + $this->assertSame('secret', $config->password()); + $this->assertSame('horde', $config->username()); + $this->assertSame(3, $config->database()); + $this->assertTrue($config->persistent()); + } + + #[Test] + public function unixSocket(): void + { + $config = new SingleNodeConfig( + new RedisNode('127.0.0.1'), + socket: '/var/run/redis.sock', + ); + $this->assertTrue($config->isUnixSocket()); + $this->assertSame('/var/run/redis.sock', $config->socket); + } +}