Current File : //opt/alt/python37/lib/python3.7/site-packages/clwpos/object_cache/redis_utils.py
# -*- coding: utf-8 -*-

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

from __future__ import absolute_import

import os
import re
import subprocess
from functools import lru_cache
from pathlib import Path
from typing import List
from pkg_resources import parse_version

from secureio import write_file_via_tempfile

from clcommon.cpapi import getCPName, CPANEL_NAME, PLESK_NAME

from clwpos.constants import (
    RedisRequiredConstants,
    EA_PHP_PREFIX,
    PLESK_PHP_PREFIX,
    CAGEFSCTL
)
from clwpos.data_collector_utils import get_cached_php_installed_versions
from clwpos.logsetup import setup_logging

from clwpos.utils import (
    daemon_communicate,
    PHP,
    run_in_cagefs_if_needed,
    create_pid_file,
    acquire_lock
)

_logger = setup_logging(__name__)

BASE_CPANEL_EA_PHP_DIR = '/opt/cpanel'
BASE_PLESK_PHP_DIR = '/opt/plesk/php'


def configurator():
    """Instantiate appropriate configurator"""
    panel = getCPName()
    if panel == CPANEL_NAME:
        return EaPhpRedisConfigurator()
    elif panel == PLESK_NAME:
        return PleskPhpRedisConfigurator()

    raise Exception("No PHP Redis configurator currently found")


class RedisConfigurator:

    def configure(self):
        with acquire_lock(os.path.join('/var/run', self.PHP_PREFIX),
                          attempts=1):
            self.configure_redis_extension()

    def configure_redis_extension(self):
        """
        Sets up redis if needed:
         - installing package
         - enables in .ini file
        """
        need_cagefs_update = False
        wait_child_process = bool(os.environ.get('CL_WPOS_WAIT_CHILD_PROCESS'))
        php_versions_redis_data = {
            php: _redis_extension_info(PHP(php)) for php in
            self.get_supported_php()
        }
        php_versions_to_enable_redis = [
            php for php, redis_data in php_versions_redis_data.items()
            if
            not redis_data.get('is_present') or not redis_data.get('is_loaded')
        ]
        if not php_versions_to_enable_redis:
            return

        with create_pid_file(self.PHP_PREFIX):
            for php in php_versions_to_enable_redis:
                redis_data = php_versions_redis_data.get(php)
                if not redis_data.get('is_present'):
                    redis_package = self.redis_package(php)
                    result = subprocess.run(
                        ['yum', '-y', 'install', *self._additional_repos,
                         redis_package],
                        capture_output=True,
                        text=True)
                    if result.returncode != 0 and 'Nothing to do' not in result.stdout:
                        _logger.error(
                            'Failed to install package %s, due to reason: %s',
                            redis_package,
                            f'{result.stdout}\n{result.stderr}')
                        continue
                    self.enable_redis_extension(php)
                    need_cagefs_update = True
                elif not redis_data.get('is_loaded'):
                    self.enable_redis_extension(php)
                    need_cagefs_update = True

            if need_cagefs_update and wait_child_process and os.path.isfile(
                    CAGEFSCTL):
                try:
                    subprocess.run([CAGEFSCTL, '--check-cagefs-initialized'],
                                   stdout=subprocess.DEVNULL,
                                   stderr=subprocess.DEVNULL,
                                   check=True)
                except subprocess.CalledProcessError:
                    _logger.info(
                        'CageFS in unintialized, skipping force-update')
                else:
                    subprocess.run(
                        [CAGEFSCTL, '--wait-lock', '--force-update'],
                        stdout=subprocess.DEVNULL,
                        stderr=subprocess.DEVNULL)

    def enable_redis_extension(self, php_version):
        """
        Enables (if needed) redis extension in .ini config
        """
        path = self.redis_ini(php_version)
        keyword = 'redis.so'
        if not os.path.exists(path):
            _logger.error(
                'Redis extension config: %s is not found, ensure corresponding rpm package installed: %s',
                str(path), self.redis_package(php_version))
            return
        with open(path) as f:
            extension_data = f.readlines()

        uncommented_pattern = re.compile(fr'^\s*extension\s*=\s*{keyword}')
        commented_pattern = re.compile(fr'^\s*;\s*extension\s*=\s*{keyword}')
        enabled_line = f'extension = {keyword}\n'
        was_enabled = False
        lines = []

        for line in extension_data:
            if uncommented_pattern.match(line):
                return
            if not was_enabled and commented_pattern.match(line):
                lines.append(enabled_line)
                was_enabled = True
            else:
                lines.append(line)
        if not was_enabled:
            lines.append(enabled_line)
        write_file_via_tempfile(''.join(lines), path, 0o644)

    @property
    def _additional_repos(self):
        return tuple()

    @property
    def PHP_PREFIX(self):
        raise NotImplementedError

    def get_supported_php(self):
        """"""
        raise NotImplementedError

    def redis_package(self, php):
        raise NotImplementedError

    def redis_ini(self, php_version):
        raise NotImplementedError


class EaPhpRedisConfigurator(RedisConfigurator):
    """
    Install and configure redis extensions for cPanel ea-php
    """

    @property
    def PHP_PREFIX(self):
        return EA_PHP_PREFIX

    def get_supported_php(self):
        """
        Looks through /opt/cpanel and gets installed phps
        """
        base_dir = Path(BASE_CPANEL_EA_PHP_DIR)
        minimal_supported = parse_version('ea-php74')
        supported = []
        for item in os.listdir(base_dir):
            if item.startswith('ea-php') and PHP(
                    item).bin().exists() and parse_version(
                item) >= minimal_supported:
                supported.append(item)
        return supported

    def redis_package(self, php):
        return f'{php}-php-redis'

    def redis_ini(self, php_version):
        return Path(PHP(php_version).dir()).joinpath('root/etc/php.d/50-redis.ini')


class PleskPhpRedisConfigurator(RedisConfigurator):
    """
    Install and configure redis extensions for Plesk php
    """

    @property
    def _additional_repos(self):
        return '--enablerepo', 'PLESK*'

    @property
    def PHP_PREFIX(self):
        return PLESK_PHP_PREFIX

    def get_supported_php(self):
        """
        Looks through /opt/plesk/php and gets installed phps.
        /opt/plesk/php contains plain version directories, e.g. 7.4; 8.0; 8.1
        """
        base_dir = Path(BASE_PLESK_PHP_DIR)
        minimal_supported = parse_version('plesk-php74')
        supported = []
        for item in os.listdir(base_dir):
            _php = f"plesk-php{item.replace('.', '')}"
            if PHP(_php).bin().exists() and parse_version(
                    _php) >= minimal_supported:
                supported.append(_php)
        return supported

    def redis_package(self, php):
        return f'{php}-redis'

    def redis_ini(self, php_version):
        return Path(PHP(php_version).dir()).joinpath(f'etc/php.d/redis.ini')


@lru_cache()
def _redis_extension_info(version: PHP) -> dict:
    is_present = bool(list(version.modules_dir().glob("**/redis.so")))
    php_bin_path = version.bin()
    if os.geteuid() == 0:
        exec_func = subprocess.run
    else:
        exec_func = run_in_cagefs_if_needed

    is_loaded = exec_func(
        f'{php_bin_path} -m | /bin/grep redis', shell=True, executable='/bin/bash', env={}
    ).returncode == 0 if is_present else False

    return {
        "is_present": is_present,
        "is_loaded": is_loaded
    }


def filter_php_versions_with_not_loaded_redis(php_versions: List[PHP]) -> List[PHP]:
    """
    Filter list of given php versions to find out
    for which redis extension is presented but not loaded.
    """
    php_versions_with_not_loaded_redis = []
    for version in php_versions:
        php_redis_info = _redis_extension_info(version)
        if not php_redis_info['is_loaded'] and php_redis_info['is_present']:
            php_versions_with_not_loaded_redis.append(version)
    return php_versions_with_not_loaded_redis


@lru_cache(maxsize=None)
def get_cached_php_versions_with_redis_loaded() -> set:
    """
    List all installed php version on the system which has redis-extension enabled
    :return: installed php versions which has redis-extension
    """
    versions = get_cached_php_installed_versions()
    return {version for version in versions if _redis_extension_info(version)["is_loaded"]}


@lru_cache(maxsize=None)
def get_cached_php_versions_with_redis_present() -> set:
    """
    List all installed php version on the system which has redis-extension installed
    :return: installed php versions which has redis-extension installed
    """
    versions = get_cached_php_installed_versions()
    return {version for version in versions if _redis_extension_info(version)["is_present"]}


def reload_redis(uid: int = None, force: str = 'no'):
    """
    Make redis reload via CLWPOS daemon
    :param uid: User uid (optional)
    :param force: force reload w/o config check
    """
    cmd_dict = {"command": "reload", 'force_reload': force}
    if uid:
        cmd_dict['uid'] = uid
    daemon_communicate(cmd_dict)