Mini Shell
# 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/LICENCE.TXT
#
# Redis manipulation library for reloader script
# pylint: disable=no-absolute-import
import pwd
import os
import re
import traceback
import subprocess
from logging import Logger
from typing import List, Optional
from secureio import write_file_via_tempfile
from clcommon.clpwd import drop_user_privileges
from clcommon.cpapi import userdomains
from clcommon.const import Feature
from clcommon.cpapi import is_panel_feature_supported
from clcommon.utils import demote
from clwpos import gettext as _
from clwpos.cl_wpos_exceptions import WposError
from clwpos.daemon_redis_lib import _get_redis_pid_from_pid_file_with_wait, kill_process_by_pid
from clwpos.utils import USER_WPOS_DIR, is_run_under_user
from clwpos.user.config import UserConfig, ConfigError
from clwpos.optimization_features import OBJECT_CACHE_FEATURE
from clwpos.feature_suites import get_allowed_modules
from clwpos.constants import REDIS_SERVER_BIN_FILE
from clwpos.logsetup import NullLogger
from clcommon.cpapi.cpapiexceptions import NoPackage
_USER_REDIS_CONF_PATTERN = """# !!! WARNING !!! AUTO-GENERATED FILE, PLEASE DO NOT MODIFY IT
maxclients {maxclients}
databases 16
maxmemory-policy allkeys-lru
appendonly no
appendfsync always
loglevel warning
logfile {logfile}
# Disable TCP ports using
port 0
unixsocket {socket_path}
unixsocketperm 600
dir {clwpos_dir}
maxmemory {maxmemory}
save ""
pidfile {pidfile}
"""
logger = NullLogger()
def _read_maxclients_from_config(redis_config_path: str) -> str:
"""
Read and return maxclients value from the Redis config file
if config file exists maxclients is integer value.
Return default value otherwise.
:param redis_config_path: path to Redis configuration file
:return: maxclients value from config or default
"""
if os.path.exists(redis_config_path):
with open(redis_config_path) as redis_config:
config = redis_config.read()
value = re.search(r'maxclients (\d+)$', config, flags=re.MULTILINE)
if value is not None:
return value.group(1)
return '16'
def _write_redis_config_for_user(username: str, user_homedir: str, clwpos_dir: str, redis_socket_path: str,
redis_config_path: str, maxmemory: str, pidfile_path: str):
"""
Writes redis config for user (call under drop_privileges)
:param username: User name for write config
:param user_homedir: User's homedir
:param redis_socket_path: Full path to user's redis socket
:param redis_config_path: Full path to user's redis config file
:param maxmemory: maxmemory value to write to redis config
:param pidfile_path: Redis pid file path
:return: None
"""
maxclients = _read_maxclients_from_config(redis_config_path)
redis_config_content = _USER_REDIS_CONF_PATTERN.format(
maxclients=maxclients,
logfile=os.path.join(os.path.dirname(redis_config_path), 'redis.log'),
socket_path=redis_socket_path,
clwpos_dir=clwpos_dir, maxmemory=maxmemory,
pidfile=pidfile_path
)
try:
clwpos_dir = os.path.join(user_homedir, USER_WPOS_DIR)
try:
os.makedirs(clwpos_dir, exist_ok=True)
except (OSError, IOError,) as system_error:
raise WposError(message=_("Can't create directory %(directory)s. "),
details=_("The operating system reported error: %(system_error)s"),
context={"directory": clwpos_dir, 'system_error': system_error})
# Write redis config
write_file_via_tempfile(redis_config_content, redis_config_path, 0o600)
except (OSError, IOError) as system_error:
raise WposError(message=_("Error happened while writing redis config for user '%(user)s' "),
details=_("The operating system reported error: %(system_error)s"),
context={"user": username, "system_error": system_error})
def _get_valid_docroots_from_config(logger: Logger, username: str, user_homedir: str,
docroot_list_to_validate: List[str]) -> List[str]:
"""
Validates docroots from list
:param logger: Logger to log errors
:param username: User name
:param user_homedir: User's homedir
:param docroot_list_to_validate: Docroot list to validate
:return: List of valid docroots
"""
if not docroot_list_to_validate:
# Nothing to validate
return []
try:
# Get active docroots for user from panel
userdomains_data_list = userdomains(username)
except (OSError, IOError, IndexError, NoPackage) as e:
# Skip all cpapi.userdomains errors
logger.warning("Can't get user list from panel: %s", str(e))
return []
# Add some filename to all docroots to avoid any problems with / at the end of paths
docroots_from_panel = []
for _, document_root in userdomains_data_list:
docroots_from_panel.append(os.path.normpath(document_root))
# Validate docroots from config
valid_docroot_list: List[str] = []
for _dr in docroot_list_to_validate:
tempname = os.path.normpath(os.path.join(user_homedir, _dr))
if tempname in docroots_from_panel:
valid_docroot_list.append(_dr)
return valid_docroot_list
def _is_redis_plugin_enabled(logger: Logger, username: str, uid: int, user_homedir: str,
user_config_dict: dict) -> bool:
"""
Get redis status according to user's and admin's configs
:param logger: Logger to log errors
:param username: User name
:parem uid: User uid
:param user_homedir: User's homedir
:param user_config_dict: User's config
:return: True - module enabled, False - disabled
"""
# TODO: Refactor this function in LU-2613
# # Admin's config example:
# allowed_modules_list example: ['object_cache']
allowed_modules_list = get_allowed_modules(uid)
if OBJECT_CACHE_FEATURE not in allowed_modules_list:
return False
# WPOS enabled by admin, check user's config
# User's config example:
# {
# "docroots": {
# "public_html": {"": ["object_cache"], "1": []},
# "public_html/gggh.com": {"wp1": ["object_cache"], "wp2": []}
# },
# "maxmemory": "512mb"
# }
target_docroots = user_config_dict.get('docroots', {})
docroots_to_validate: list = list(target_docroots.keys())
valid_wp_docroots = _get_valid_docroots_from_config(logger, username, user_homedir, docroots_to_validate)
for wp_docroot in valid_wp_docroots:
wp_data = target_docroots.get(wp_docroot, {})
for module_list in wp_data.values(): # By WP paths
if isinstance(module_list, list) and OBJECT_CACHE_FEATURE in module_list:
# Redis enabled
return True
return False
def __start_redis(is_cagefs_missing: bool, redis_config_path: str, username, _logger) -> None:
"""
Run subprocess with redis
"""
user_pwd = pwd.getpwnam(username)
if is_cagefs_missing:
command = [REDIS_SERVER_BIN_FILE, redis_config_path]
else:
# CL Shared
# cagefs_enter.proxied brought by lve-wrappers which is required by lvemanager/lve-utils
command = ['/bin/cagefs_enter.proxied', REDIS_SERVER_BIN_FILE, redis_config_path]
_logger.info('[Start redis] Executing command=%s', str(command))
stdout, stderr = b'', b''
process = subprocess.Popen(
command,
preexec_fn=demote(user_pwd.pw_uid, user_pwd.pw_gid),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# to get errors if forking failed
try:
stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
pass
_logger.info('Captured stdout=%s, stderr=%s', str(stdout.decode()), str(stderr.decode()))
pidfile_path = os.path.join(user_pwd.pw_dir, USER_WPOS_DIR, 'redis.pid')
redis_pid = _get_redis_pid_from_pid_file_with_wait(pidfile_path)
_logger.info('[Start redis] Redis pid=%s', str(redis_pid))
if redis_pid is None:
raise OSError('Redis instance was not started for user')
return None
def _start_redis_server_for_user(_logger: Logger, username: str, maxmemory: str) -> dict:
"""
Start redis server for supplied user (calls under user)
:param _logger: Daemon's logger
:param username: Username to setup redis
:param maxmemory: maxmemory value to write to redis config
:return: dict
If redis was started for user - {"result": "success"}
else - redis was not started - {"result": "error", "context": "...."}
"""
user_pwd = pwd.getpwnam(username)
# /home/username/.clwpos/
_logger.info('Starting redis for username=%s', username)
wpos_dir = os.path.join(user_pwd.pw_dir, USER_WPOS_DIR)
redis_socket_path = os.path.join(wpos_dir, 'redis.sock')
redis_config_path = os.path.join(wpos_dir, 'redis.conf')
pidfile_path = os.path.join(wpos_dir, 'redis.pid')
_write_redis_config_for_user(username, user_pwd.pw_dir, wpos_dir, redis_socket_path, redis_config_path,
maxmemory, pidfile_path)
_logger.info('Redis config for user=%s is saved with parameters:'
'socket_path=%s, '
'config_path=%s, '
'pidfile_path=%s',
username,
redis_socket_path,
redis_socket_path,
pidfile_path)
try:
is_cagefs_available = is_panel_feature_supported(Feature.CAGEFS)
__start_redis(not is_cagefs_available, redis_config_path, username, _logger)
return {"result": "success", "redis_enabled": True}
except (OSError, IOError) as e:
str_exc = traceback.format_exc()
_logger.warning("Error while starting redis server for user %s. Error is: %s", username, str_exc)
return {"result": _("Error while starting redis server: %(error)s"),
"context": {"error": str(e)}}
def _check_redis_process_and_kill(old_redis_pid: int) -> bool:
"""
Check existing process by PID and kill it:
- If old_redis_pid == -1, nothing will kill, return False
- else check process existance.
if exi
:param old_redis_pid: PID to check and kill
:return: True - Can't kill process not owned by current user, this is error, continue work not allowed
False - Process absent or was killed succesfully, continue work allowed
"""
if old_redis_pid == -1:
# Process absent or was killed succesfully, continue work allowed
return False
if not is_run_under_user():
raise WposError("Internal error! Trying to kill process with root privileges")
try:
os.kill(old_redis_pid, 0)
except PermissionError:
# process not owned by current user -- error, exit
return True
except ProcessLookupError:
# No such process - no error
pass
kill_process_by_pid(logger, old_redis_pid)
# Process absent or was killed succesfully, continue work allowed
return False
def reload_redis_for_user(username: str, old_redis_pid: int, _logger: Optional[Logger] = None,
is_log_debug_level: bool = False, force_reload: str = 'no') -> dict:
"""
Reloads redis for supplied user without logging and killing old redis process
Calls only from redis_reloader.py script
:param username: Username to setup redis
:param old_redis_pid: Redis PID to kill, -1 - no PID to kill
:param _logger: Daemon's logger
:param is_log_debug_level: True - log messages/ False - no
:param force_reload: reload redis w/o conditions
:return: dict
If redis was started for user - {"result": "success"}
else - redis was not started - {"result": "error", "context": "...."}
"""
if _logger is None:
_logger = logger
_logger.info('[Reload redis for user]: Request to reload redis for '
'username=%s, '
'old redis pid=%s,'
'force_reload',
username,
str(old_redis_pid),
str(force_reload))
drop_user_privileges(username, effective_or_real=False, set_env=False)
if _check_redis_process_and_kill(old_redis_pid):
return {"result": _("Can't kill old redis process for user '%(user)s'. PID is %(pid)s"),
"context": {"user": username, "pid": old_redis_pid}}
try:
user_config_class = UserConfig(username)
user_config = user_config_class.read_config()
except ConfigError as e:
# User's config read error
if is_log_debug_level:
_logger.warning("Can't reload redis for user %s. User's config read error: %s", username, str(e))
if not force_reload:
return {"result": _("Can't reload redis for user '%(user)s'. User's config read error"),
"context": {"user": username}}
else:
_logger.info('Cannot read user config, but force_reload was passed ---> unconditional reload')
user_config = {}
pw_user = pwd.getpwnam(username)
is_redis_enable = _is_redis_plugin_enabled(_logger, username,
pw_user.pw_uid, pw_user.pw_dir, user_config)
if is_redis_enable or force_reload == 'yes':
_logger.info('Starting redis for username=%s', username)
# Start redis
return _start_redis_server_for_user(_logger, username,
user_config.get('max_cache_memory',
UserConfig.DEFAULT_MAX_CACHE_MEMORY))
# Redis is disabled by user's config
return {"result": "success", "redis_enabled": False}