diff --git a/README.md b/README.md index 07c9e05..d5679ad 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,16 @@ symfony_health_check: ping_checks: - id: symfony_health_check.status_up_check ``` +To perform redis check you need use provide its dsn in the config: +```yaml +symfony_health_check: + health_checks: + ... + - id: symfony_health_check.redis_check + + redis_dsn: 'redis://localhost:6379' +``` + Change response code: - default response code is 200. - determine your custom response code in case of some check fails (Response code must be a valid HTTP status code) diff --git a/composer.json b/composer.json index bfc7c32..789092a 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,9 @@ "symfony/phpunit-bridge": "^3.4 || ^4.1.12 || ^5.0 || ^6.0 || ^7.0", "phpunit/phpunit": "^8.5 || ^9.0", "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0", - "symfony/browser-kit": "^3.4 || ^4.4 || ^5.0 || ^6.0 || ^7.0" + "symfony/browser-kit": "^3.4 || ^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/cache": "^3.4 || ^4.4 || ^5.0 || ^6.0 || ^7.0", + "predis/predis": "^2.3" }, "autoload": { "psr-4": { diff --git a/src/Adapter/RedisAdapterWrapper.php b/src/Adapter/RedisAdapterWrapper.php new file mode 100644 index 0000000..b04b674 --- /dev/null +++ b/src/Adapter/RedisAdapterWrapper.php @@ -0,0 +1,24 @@ +redisAdapter = $redisAdapter; + $this->redisDsn = $redisDsn; + } + + public function check(): Response + { + try { + $redisConnection = $this->redisAdapter->createConnection($this->redisDsn); + + switch (true) { + case $redisConnection instanceof \Redis: + $result = $this->checkForDefaultRedisClient($redisConnection); + + break; + case $redisConnection instanceof \Predis\ClientInterface: + $result = $this->checkForPredisClient($redisConnection); + + break; + case $redisConnection instanceof \RedisArray: + $result = $this->checkForRedisArrayClient($redisConnection); + + break; + default: + throw new \RuntimeException(sprintf( + 'Unsupported Redis client type: %s', + get_class($redisConnection), + )); + } + + if (!$result) { + return new Response(self::CHECK_RESULT_NAME, false, 'Redis ping failed.'); + } + + return new Response(self::CHECK_RESULT_NAME, true, 'ok'); + } catch (\Throwable $e) { + return new Response(self::CHECK_RESULT_NAME, false, $e->getMessage()); + } + } + + private function checkForDefaultRedisClient(\Redis $client): bool + { + $response = $client->ping(); + + if (is_bool($response)) { + return $response; + } + + return $this->isValidPingResponse($response); + } + + private function checkForPredisClient(\Predis\ClientInterface $client): bool + { + $response = $client->ping(); + + if (is_bool($response)) { + return $response; + } + + return $this->isValidPingResponse($response); + } + + private function checkForRedisArrayClient(\RedisArray $client): bool + { + $response = $client->ping(); + + if (is_bool($response)) { + return $response; + } + + // invalid configuration, RedisClient have different response, than one, provided by RedisArray in fact. + // @phpstan-ignore-next-line + foreach ($response as $pingResult) { + if (is_bool($pingResult)) { + continue; + } + + if (!$this->isValidPingResponse($pingResult)) { + return false; + } + } + + return true; + } + + private function isValidPingResponse(string $response): bool + { + return in_array(strtolower($response), ['pong', '+pong'], true); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 0ed76bd..e97982d 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -40,6 +40,15 @@ public function getConfigTreeBuilder(): TreeBuilder ->thenInvalid('The health_error_response_code must be valid HTTP status code or null.') ->end() ->end() + ->variableNode('redis_dsn') + ->defaultValue(null) + ->validate() + ->ifTrue(function ($value) { + return $value !== null && !is_string($value); + }) + ->thenInvalid('The redis_dsn must be a string or null.') + ->end() + ->end() ->arrayNode('health_checks') ->prototype('array') ->children() diff --git a/src/DependencyInjection/SymfonyHealthCheckExtension.php b/src/DependencyInjection/SymfonyHealthCheckExtension.php index 3d04548..43657af 100644 --- a/src/DependencyInjection/SymfonyHealthCheckExtension.php +++ b/src/DependencyInjection/SymfonyHealthCheckExtension.php @@ -4,6 +4,7 @@ namespace SymfonyHealthCheckBundle\DependencyInjection; +use Composer\InstalledVersions; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -23,6 +24,9 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('controller.xml'); $this->loadHealthChecks($config, $loader, $container); + + $container->getDefinition('symfony_health_check.redis_check') + ->setArgument(1, $config['redis_dsn']); } private function loadHealthChecks( @@ -34,6 +38,17 @@ private function loadHealthChecks( $healthCheckCollection = $container->findDefinition(HealthController::class); + $usedChecks = array_column(array_merge($config['health_checks'], $config['ping_checks']), 'id'); + if (in_array('symfony_health_check.redis_check', $usedChecks)) { + if (!InstalledVersions::isInstalled('symfony/cache')) { + throw new \RuntimeException('To use RedisCheck you need to install symfony/cache package.'); + } + + if (empty($config['redis_dsn'])) { + throw new \RuntimeException('To use RedisCheck you need to configure redis_dsn parameter.'); + } + } + foreach ($config['health_checks'] as $healthCheckConfig) { $healthCheckDefinition = new Reference($healthCheckConfig['id']); $healthCheckCollection->addMethodCall('addHealthCheck', [$healthCheckDefinition]); diff --git a/src/Resources/config/health_checks.xml b/src/Resources/config/health_checks.xml index 20acf51..b950e8a 100644 --- a/src/Resources/config/health_checks.xml +++ b/src/Resources/config/health_checks.xml @@ -5,6 +5,8 @@ http://symfony.com/schema/dic/services/services-1.0.xsd"> + + The "%service_id%" service alias is deprecated, use symfony_health_check.doctrine_orm_check instead @@ -15,6 +17,9 @@ + + + diff --git a/tests/Integration/DependencyInjection/ConfigurationTest.php b/tests/Integration/DependencyInjection/ConfigurationTest.php index 8decdef..54debf1 100644 --- a/tests/Integration/DependencyInjection/ConfigurationTest.php +++ b/tests/Integration/DependencyInjection/ConfigurationTest.php @@ -15,6 +15,7 @@ public function testProcessConfigurationWithDefaultConfiguration(): void $expectedBundleDefaultConfig = [ 'ping_error_response_code' => null, 'health_error_response_code' => null, + 'redis_dsn' => null, 'health_checks' => [], 'ping_checks' => [], ]; @@ -40,6 +41,7 @@ public function testProcessConfigurationHealthChecks(): void 'ping_checks' => [], 'ping_error_response_code' => null, 'health_error_response_code' => null, + 'redis_dsn' => null, ]; $new = ['health_checks' => [ ['id' => 'symfony_health_check.doctrine_check'] @@ -60,6 +62,7 @@ public function testProcessConfigurationPing(): void ], 'ping_error_response_code' => null, 'health_error_response_code' => null, + 'redis_dsn' => null, ]; $new = ['health_checks' => [], 'ping_checks' => [ ['id' => 'symfony_health_check.doctrine_check'] @@ -82,6 +85,7 @@ public function testProcessConfigurationPingAndHealthChecks(): void ], 'ping_error_response_code' => null, 'health_error_response_code' => null, + 'redis_dsn' => null, ]; $new = [ 'health_checks' => [['id' => 'symfony_health_check.doctrine_check']], @@ -105,6 +109,7 @@ public function testProcessConfigurationCustomErrorCode(): void ], 'ping_error_response_code' => 404, 'health_error_response_code' => 500, + 'redis_dsn' => null, ]; $new = [ 'health_checks' => [['id' => 'symfony_health_check.doctrine_check']], @@ -119,6 +124,33 @@ public function testProcessConfigurationCustomErrorCode(): void ); } + public function testItProcessConfigurationWithRedisDsn(): void + { + $expectedConfig = [ + 'health_checks' => [ + ['id' => 'symfony_health_check.doctrine_check'] + ], + 'ping_checks' => [ + ['id' => 'symfony_health_check.doctrine_check'] + ], + 'ping_error_response_code' => 404, + 'health_error_response_code' => 500, + 'redis_dsn' => 'redis://redis', + ]; + $new = [ + 'health_checks' => [['id' => 'symfony_health_check.doctrine_check']], + 'ping_checks' => [['id' => 'symfony_health_check.doctrine_check']], + 'ping_error_response_code' => 404, + 'health_error_response_code' => 500, + 'redis_dsn' => 'redis://redis', + ]; + + self::assertSame( + $expectedConfig, + $this->processConfiguration($new) + ); + } + private function processConfiguration(array $values): array { $processor = new Processor(); diff --git a/tests/Integration/DependencyInjection/Fixtures/error_redis_check_bundle_config.yaml b/tests/Integration/DependencyInjection/Fixtures/error_redis_check_bundle_config.yaml new file mode 100644 index 0000000..75e3949 --- /dev/null +++ b/tests/Integration/DependencyInjection/Fixtures/error_redis_check_bundle_config.yaml @@ -0,0 +1,5 @@ +symfony_health_check: + health_checks: + - id: symfony_health_check.redis_check + ping_checks: + - id: symfony_health_check.doctrine_check diff --git a/tests/Integration/DependencyInjection/SymfonyHealthCheckExtensionTest.php b/tests/Integration/DependencyInjection/SymfonyHealthCheckExtensionTest.php index f6dff8f..03bc139 100644 --- a/tests/Integration/DependencyInjection/SymfonyHealthCheckExtensionTest.php +++ b/tests/Integration/DependencyInjection/SymfonyHealthCheckExtensionTest.php @@ -46,11 +46,19 @@ public function testWithEmptyConfigPing(): void } } + public function testWithEmptyRedisDsnConfig(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('To use RedisCheck you need to configure redis_dsn parameter.'); + + $this->createContainerFromFixture('error_redis_check_bundle_config'); + } + public function testWithFullConfig(): void { $container = $this->createContainerFromFixture('filled_bundle_config'); - self::assertCount(8, $container->getDefinitions()); + self::assertCount(10, $container->getDefinitions()); self::assertArrayHasKey(HealthController::class, $container->getDefinitions()); self::assertArrayHasKey(PingController::class, $container->getDefinitions()); self::assertArrayHasKey('symfony_health_check.doctrine_check', $container->getDefinitions()); #deprecated @@ -58,6 +66,8 @@ public function testWithFullConfig(): void self::assertArrayHasKey('symfony_health_check.doctrine_odm_check', $container->getDefinitions()); self::assertArrayHasKey('symfony_health_check.environment_check', $container->getDefinitions()); self::assertArrayHasKey('symfony_health_check.status_up_check', $container->getDefinitions()); + self::assertArrayHasKey('symfony_health_check.redis_check', $container->getDefinitions()); + self::assertArrayHasKey('symfony_health_check.redis_adapter_wrapper', $container->getDefinitions()); } private function createContainerFromFixture(string $fixtureFile): ContainerBuilder diff --git a/tests/Unit/Check/RedisCheckTest.php b/tests/Unit/Check/RedisCheckTest.php new file mode 100644 index 0000000..f0c6b8c --- /dev/null +++ b/tests/Unit/Check/RedisCheckTest.php @@ -0,0 +1,268 @@ +createMock(\RedisCluster::class); + + $adapter = $this->createMock(RedisAdapterWrapper::class); + $adapter + ->method('createConnection') + ->willReturn($connectionMock); + + $check = new RedisCheck($adapter, 'redis://localhost'); + + $result = $check->check()->toArray(); + + self::assertIsArray($result); + self::assertNotEmpty($result); + + self::assertArrayHasKey('name', $result); + self::assertArrayHasKey('result', $result); + self::assertArrayHasKey('message', $result); + self::assertArrayHasKey('params', $result); + + self::assertSame('redis_check', $result['name']); + self::assertFalse($result['result']); + self::assertSame( + sprintf('Unsupported Redis client type: %s', get_class($connectionMock)), + $result['message'], + ); + self::assertIsArray($result['params']); + } + + public function testItFailsCheckWithExceptionInPing(): void + { + $adapter = $this->createMock(RedisAdapterWrapper::class); + + /** @var \Redis $connectionMock */ + $connectionMock = $this->createMock(\Redis::class); + $connectionMock + ->method('ping') + ->willThrowException(new \Exception('Redis ping failed.')); + + $adapter + ->method('createConnection') + ->willReturn($connectionMock); + + $check = new RedisCheck($adapter, 'redis://localhost'); + + $result = $check->check()->toArray(); + + self::assertIsArray($result); + self::assertNotEmpty($result); + + self::assertArrayHasKey('name', $result); + self::assertArrayHasKey('result', $result); + self::assertArrayHasKey('message', $result); + self::assertArrayHasKey('params', $result); + + self::assertSame('redis_check', $result['name']); + self::assertFalse($result['result']); + self::assertSame('Redis ping failed.', $result['message']); + self::assertIsArray($result['params']); + } + + public function testItFailsCheckWithInvalidStatusInPing(): void + { + $adapter = $this->createMock(RedisAdapterWrapper::class); + + /** @var \Redis $connectionMock */ + $connectionMock = $this->createMock(\Redis::class); + $connectionMock + ->method('ping') + ->willReturn('something went wrong'); + + $adapter + ->method('createConnection') + ->willReturn($connectionMock); + + $check = new RedisCheck($adapter, 'redis://localhost'); + + $result = $check->check()->toArray(); + + self::assertIsArray($result); + self::assertNotEmpty($result); + + self::assertArrayHasKey('name', $result); + self::assertArrayHasKey('result', $result); + self::assertArrayHasKey('message', $result); + self::assertArrayHasKey('params', $result); + + self::assertSame('redis_check', $result['name']); + self::assertFalse($result['result']); + self::assertSame('Redis ping failed.', $result['message']); + self::assertIsArray($result['params']); + } + + /** + * @param string|bool $response + * + * @dataProvider provideAvailablePingResponsesOnDefaultClients + */ + public function testItSuccessCheck($response): void + { + /** @var \Redis $connectionMock */ + $connectionMock = $this->createMock(\Redis::class); + $connectionMock + ->method('ping') + ->willReturn($response); + + $adapter = $this->createMock(RedisAdapterWrapper::class); + $adapter + ->method('createConnection') + ->willReturn($connectionMock); + + $check = new RedisCheck($adapter, 'redis://localhost'); + + $result = $check->check()->toArray(); + + self::assertIsArray($result); + self::assertNotEmpty($result); + + self::assertArrayHasKey('name', $result); + self::assertArrayHasKey('result', $result); + self::assertArrayHasKey('message', $result); + self::assertArrayHasKey('params', $result); + + self::assertSame('redis_check', $result['name']); + self::assertTrue($result['result']); + self::assertSame('ok', $result['message']); + self::assertIsArray($result['params']); + } + + /** + * @param string[]|bool $response + * + * @dataProvider provideAvailablePingResponsesOnRedisArrayClient + */ + public function testItSuccessCheckWithRedisArrayClient($response): void + { + /** @var \RedisArray $connectionMock */ + $connectionMock = $this->createMock(\RedisArray::class); + $connectionMock + ->method('ping') + ->willReturn($response); + + $adapter = $this->createMock(RedisAdapterWrapper::class); + $adapter + ->method('createConnection') + ->willReturn($connectionMock); + + $check = new RedisCheck($adapter, 'redis://localhost'); + + $result = $check->check()->toArray(); + + self::assertIsArray($result); + self::assertNotEmpty($result); + + self::assertArrayHasKey('name', $result); + self::assertArrayHasKey('result', $result); + self::assertArrayHasKey('message', $result); + self::assertArrayHasKey('params', $result); + + self::assertSame('redis_check', $result['name']); + self::assertTrue($result['result']); + self::assertSame('ok', $result['message']); + self::assertIsArray($result['params']); + } + + public function testItFailsCheckWithRedisArrayClient(): void + { + /** @var \RedisArray $connectionMock */ + $connectionMock = $this->createMock(\RedisArray::class); + $connectionMock + ->method('ping') + ->willReturn(['something went wrong']); + + $adapter = $this->createMock(RedisAdapterWrapper::class); + $adapter + ->method('createConnection') + ->willReturn($connectionMock); + + $check = new RedisCheck($adapter, 'redis://localhost'); + + $result = $check->check()->toArray(); + + self::assertIsArray($result); + self::assertNotEmpty($result); + + self::assertArrayHasKey('name', $result); + self::assertArrayHasKey('result', $result); + self::assertArrayHasKey('message', $result); + self::assertArrayHasKey('params', $result); + + self::assertSame('redis_check', $result['name']); + self::assertFalse($result['result']); + self::assertSame('Redis ping failed.', $result['message']); + self::assertIsArray($result['params']); + } + + /** + * @param string|bool $response + * + * @dataProvider provideAvailablePingResponsesOnDefaultClients + */ + public function testItSuccessCheckWithPredisClient($response): void + { + /** @var \Predis\Client $connectionMock */ + $connectionMock = $this->getMockBuilder(\Predis\Client::class) + ->disableOriginalConstructor() + ->getMock(); + + $connectionMock->expects($this->once()) + ->method('__call') + ->with('ping') + ->willReturn($response); + + $adapter = $this->createMock(RedisAdapterWrapper::class); + $adapter + ->method('createConnection') + ->willReturn($connectionMock); + + $check = new RedisCheck($adapter, 'redis://localhost'); + + $result = $check->check()->toArray(); + + self::assertIsArray($result); + self::assertNotEmpty($result); + + self::assertArrayHasKey('name', $result); + self::assertArrayHasKey('result', $result); + self::assertArrayHasKey('message', $result); + self::assertArrayHasKey('params', $result); + + self::assertSame('redis_check', $result['name']); + self::assertTrue($result['result']); + self::assertSame('ok', $result['message']); + self::assertIsArray($result['params']); + } + + public static function provideAvailablePingResponsesOnDefaultClients(): array + { + return [ + ['pong'], + [true], + ]; + } + + public static function provideAvailablePingResponsesOnRedisArrayClient(): array + { + return [ + [['PONG']], + [[true]], + [true], + ]; + } +}