diff options
Diffstat (limited to 'library/Icingadb/Common/IcingaRedis.php')
-rw-r--r-- | library/Icingadb/Common/IcingaRedis.php | 323 |
1 files changed, 323 insertions, 0 deletions
diff --git a/library/Icingadb/Common/IcingaRedis.php b/library/Icingadb/Common/IcingaRedis.php new file mode 100644 index 0000000..a22a0f0 --- /dev/null +++ b/library/Icingadb/Common/IcingaRedis.php @@ -0,0 +1,323 @@ +<?php + +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +namespace Icinga\Module\Icingadb\Common; + +use Exception; +use Generator; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Predis\Client as Redis; + +class IcingaRedis +{ + /** @var static The singleton */ + protected static $instance; + + /** @var Redis Connection to the Icinga Redis */ + private $redis; + + /** @var bool true if no connection attempt was successful */ + private $redisUnavailable = false; + + /** + * Get the singleton + * + * @return static + */ + public static function instance(): self + { + if (self::$instance === null) { + self::$instance = new static(); + } + + return self::$instance; + } + + /** + * Get whether Redis is unavailable + * + * @return bool + */ + public static function isUnavailable(): bool + { + $self = self::instance(); + + if (! $self->redisUnavailable && $self->redis === null) { + try { + $self->getConnection(); + } catch (Exception $_) { + // getConnection already logs the error + } + } + + return $self->redisUnavailable; + } + + /** + * Get the connection to the Icinga Redis + * + * @return Redis + * + * @throws Exception + */ + public function getConnection(): Redis + { + if ($this->redisUnavailable) { + throw new Exception('Redis is still not available'); + } elseif ($this->redis === null) { + try { + $primaryRedis = $this->getPrimaryRedis(); + } catch (Exception $e) { + try { + $secondaryRedis = $this->getSecondaryRedis(); + } catch (Exception $ee) { + $this->redisUnavailable = true; + Logger::error($ee); + + throw $e; + } + + if ($secondaryRedis === null) { + $this->redisUnavailable = true; + + throw $e; + } + + $this->redis = $secondaryRedis; + + return $this->redis; + } + + $primaryTimestamp = $this->getLastIcingaHeartbeat($primaryRedis); + + if ($primaryTimestamp <= time() - 60) { + $secondaryRedis = $this->getSecondaryRedis(); + + if ($secondaryRedis === null) { + $this->redis = $primaryRedis; + + return $this->redis; + } + + $secondaryTimestamp = $this->getLastIcingaHeartbeat($secondaryRedis); + + if ($secondaryTimestamp > $primaryTimestamp) { + $this->redis = $secondaryRedis; + } else { + $this->redis = $primaryRedis; + } + } else { + $this->redis = $primaryRedis; + } + } + + return $this->redis; + } + + /** + * Fetch host states + * + * @param array $ids The host ids to fetch results for + * @param array $columns The columns to include in the results + * + * @return Generator + */ + public static function fetchHostState(array $ids, array $columns): Generator + { + return self::fetchState('icinga:host:state', $ids, $columns); + } + + /** + * Fetch service states + * + * @param array $ids The service ids to fetch results for + * @param array $columns The columns to include in the results + * + * @return Generator + */ + public static function fetchServiceState(array $ids, array $columns): Generator + { + return self::fetchState('icinga:service:state', $ids, $columns); + } + + /** + * Fetch object states + * + * @param string $key The object key to access + * @param array $ids The object ids to fetch results for + * @param array $columns The columns to include in the results + * + * @return Generator + */ + protected static function fetchState(string $key, array $ids, array $columns): Generator + { + try { + $results = self::instance()->getConnection()->hmget($key, $ids); + } catch (Exception $_) { + // The error has already been logged elsewhere + return; + } + + foreach ($results as $i => $json) { + if ($json !== null) { + $data = json_decode($json, true); + $keyMap = array_fill_keys($columns, null); + unset($keyMap['is_overdue']); // Is calculated by Icinga DB, not Icinga 2, hence it's never in redis + + // TODO: Remove once https://github.com/Icinga/icinga2/issues/9427 is fixed + $data['state_type'] = $data['state_type'] === 0 ? 'soft' : 'hard'; + + if (isset($data['in_downtime']) && is_bool($data['in_downtime'])) { + $data['in_downtime'] = $data['in_downtime'] ? 'y' : 'n'; + } + + if (isset($data['is_acknowledged']) && is_int($data['is_acknowledged'])) { + $data['is_acknowledged'] = $data['is_acknowledged'] ? 'y' : 'n'; + } + + yield $ids[$i] => array_intersect_key(array_merge($keyMap, $data), $keyMap); + } + } + } + + /** + * Get the last icinga heartbeat from redis + * + * @param Redis|null $redis + * + * @return float|int|null + */ + public static function getLastIcingaHeartbeat(Redis $redis = null) + { + if ($redis === null) { + $redis = self::instance()->getConnection(); + } + + // Predis doesn't support streams (yet). + // https://github.com/predis/predis/issues/607#event-3640855190 + $rs = $redis->executeRaw(['XREAD', 'COUNT', '1', 'STREAMS', 'icinga:stats', '0']); + + if (! is_array($rs)) { + return null; + } + + $key = null; + + foreach ($rs[0][1][0][1] as $kv) { + if ($key === null) { + $key = $kv; + } else { + if ($key === 'timestamp') { + return $kv / 1000; + } + + $key = null; + } + } + + return null; + } + + /** + * Get the primary redis instance + * + * @param Config|null $moduleConfig + * @param Config|null $redisConfig + * + * @return Redis + */ + public static function getPrimaryRedis(Config $moduleConfig = null, Config $redisConfig = null): Redis + { + if ($moduleConfig === null) { + $moduleConfig = Config::module('icingadb'); + } + + if ($redisConfig === null) { + $redisConfig = Config::module('icingadb', 'redis'); + } + + $section = $redisConfig->getSection('redis1'); + + $redis = new Redis([ + 'host' => $section->get('host', 'localhost'), + 'port' => $section->get('port', 6380), + 'password' => $section->get('password', ''), + 'timeout' => 0.5 + ] + self::getTlsParams($moduleConfig)); + + $redis->ping(); + + return $redis; + } + + /** + * Get the secondary redis instance if exists + * + * @param Config|null $moduleConfig + * @param Config|null $redisConfig + * + * @return ?Redis + */ + public static function getSecondaryRedis(Config $moduleConfig = null, Config $redisConfig = null) + { + if ($moduleConfig === null) { + $moduleConfig = Config::module('icingadb'); + } + + if ($redisConfig === null) { + $redisConfig = Config::module('icingadb', 'redis'); + } + + $section = $redisConfig->getSection('redis2'); + $host = $section->host; + + if (empty($host)) { + return null; + } + + $redis = new Redis([ + 'host' => $host, + 'port' => $section->get('port', 6380), + 'password' => $section->get('password', ''), + 'timeout' => 0.5 + ] + self::getTlsParams($moduleConfig)); + + $redis->ping(); + + return $redis; + } + + private static function getTlsParams(Config $config): array + { + $config = $config->getSection('redis'); + + if (! $config->get('tls', false)) { + return []; + } + + $ssl = []; + + if ($config->get('insecure')) { + $ssl['verify_peer'] = false; + $ssl['verify_peer_name'] = false; + } else { + $ca = $config->get('ca'); + + if ($ca !== null) { + $ssl['cafile'] = $ca; + } + } + + $cert = $config->get('cert'); + $key = $config->get('key'); + + if ($cert !== null && $key !== null) { + $ssl['local_cert'] = $cert; + $ssl['local_pk'] = $key; + } + + return ['scheme' => 'tls', 'ssl' => $ssl]; + } +} |