%PDF- %PDF-
Direktori : /opt/cloudlinux/venv/lib64/python3.11/site-packages/clwpos/ |
Current File : //opt/cloudlinux/venv/lib64/python3.11/site-packages/clwpos/wp_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 json import os import pwd import re import subprocess from dataclasses import dataclass from typing import Optional, Union, List from functools import lru_cache from typing_extensions import TypedDict from clwpos import gettext as _ from clwpos.cl_wpos_exceptions import WposError, WpCliCommandError from clwpos.constants import RedisRequiredConstants, WP_CLI, WP_CLI_SKIP_PLUGINS_ENV from clwpos.data_collector_utils import php_info from clwpos.logsetup import ( setup_logging, ADMIN_LOGFILE_PATH, USER_LOGFILE_PATH ) from clwpos.php.base import PHP from clwpos.scoped_cache import cached_in_scope from clwpos.user.cache import wp_config_cache from clwpos.user.website_check import RollbackException, WebsiteCheckError from clwpos.user.website_check.errors import ( WebsiteCheckBadHttpCode, CDNActivationFailed, JSCssCheckBadHttpCode ) from clwpos.utils import ( WposUser, is_run_under_user, wp_cli_compatibility_check, run_in_cagefs_if_needed, user_name, litespeed_is_running ) _logger = setup_logging(__name__) @dataclass class WordpressError: message: str context: dict code: Optional[int] = None def get_php_version(abs_wp_path: str) -> PHP: """ Return php_version that will be used for calling wp-cli commands. If 'CLWPOS_USE_SAVED_PHP_VERSION' envar is defined, try to get this version from a previously saved file. """ use_saved_php_version = bool(os.environ.get("CLWPOS_USE_SAVED_PHP_VERSION")) if use_saved_php_version: php_version = _get_saved_php_version(abs_wp_path) or _get_php_version(abs_wp_path) else: php_version = _get_php_version(abs_wp_path) return php_version @lru_cache(maxsize=None) def _get_php_version(abs_wp_path: str) -> PHP: """Return PHP version.""" result = php_info() items = [] for item in result: if abs_wp_path.startswith(item["documentroot"]): items.append((item["documentroot"], item["version"])) items.sort(reverse=True) return items[0][1] def _get_saved_php_version(abs_wp_path: str) -> Optional[PHP]: """ Get domain's php version from a previously saved file. """ if not is_run_under_user(): raise WposError('Internal Error. Contact CloudLinux support') php_file_id = os.environ.get("CLWPOS_PHP_FILE_ID") php_info_file = WposUser(user_name()).php_info.format(file_id=php_file_id) if not os.path.exists(php_info_file): return None try: with open(php_info_file) as f: _php_info = json.load(f) except (OSError, json.decoder.JSONDecodeError) as e: _logger.exception("Error during reading of \".php_info\" file: %s", e) return None php_versions = [] for vhost_info in _php_info: if abs_wp_path.startswith(vhost_info["documentroot"]): php_versions.append((vhost_info["documentroot"], vhost_info["version"])) if not php_versions: return None return PHP(**sorted( php_versions, key=lambda item: item[1]['identifier'], reverse=True )[0][1]) def wordpress(path: str, command: str, subcommand: str, *args, env=None) -> Union[str, WordpressError]: """ Helper to execute wp commands, for example wp --path=<path> plugin install redis-cache wp --path=<path> plugin activate redis-cache wp --path=<path> redis enable wp --path=<path> plugin deactivate redis-cache wp --path=<path> plugin uninstall redis-cache @return: stderr if error was happened. """ php_version = get_php_version(path) php_bin_path = str(php_version.bin) if not os.path.exists(php_bin_path): _logger.exception("Error during wp-cli command execution \"%s\": " "invalid path to binary file \"%s\"", command, php_bin_path) return WordpressError( message=_("Error during resolving path to php binary file:\n" "got non-existent path \"%(path)s\"."), context={"path": php_bin_path} ) # [attention] compatibility check may raise WpCliUnsupportedException exception wp_cli_compatibility_check(php_version) command_part = [command, subcommand, *args] full_command = [ WP_CLI, php_bin_path, path, *command_part ] environment = env or {} try: output = run_in_cagefs_if_needed(full_command, check=True, env=environment) except subprocess.CalledProcessError as error: post_check_flag = 'Post-check failed' in error.stderr command = ' '.join(full_command) _logger.exception("Error during command execution: \n%s\n" "stdout=%s\n" "stderr=%s", command, error.stdout, error.stderr) logger_path = ADMIN_LOGFILE_PATH if not os.getuid() else USER_LOGFILE_PATH.format( homedir=pwd.getpwuid(os.getuid()).pw_dir) if post_check_flag: _process_post_check(error, logger_path) else: message = _("Unexpected error happened during command execution: '%(command)s'.\n" "Event is logged to file: '%(logger_path)s' with stdout and stderr recorded.") context = { "command": command, "logger_path": logger_path, "error_desc": error.stderr } return WordpressError( message=message, context=context, code=error.returncode ) return output.stdout def _process_post_check( result: subprocess.CalledProcessError, log_path: str): """ In some rare cases plugin installation (CDN) may return post-check errors which we should handle properly. """ # case 1: plugin did not replace static links on website # with cdn urls for some reason cnd_not_active_msg = "CDN url not found on page" if cnd_not_active_msg in result.stderr: raise RollbackException(CDNActivationFailed(log_path)) # case 2: website returned error code during check # URL ' . $this->url . ' returned unexpected response status (' . $this->url_code . ‘)’ url_match = 'URL ' unexpected_error_match = 'returned unexpected response status' if url_match in result.stderr and unexpected_error_match in result.stderr: # "URL https://.../ ", " status (300)" url_part, code_part = result.stderr.split(unexpected_error_match) url = url_part.split()[1] code = code_part.strip("( )’'") raise RollbackException( WebsiteCheckBadHttpCode(url, code) ) # case 3: website itself works fine, but plugin was not able to reach static files # CDN url returned unexpected response status (' . $this->static_code . ’) unexpected_error_static_content = 'CDN url returned unexpected response status' if unexpected_error_static_content in result.stderr: # "URL https://.../ ", " status (300)" code_part = result.stderr.split('(')[1] code = code_part.strip("( )’'") raise RollbackException( JSCssCheckBadHttpCode(code) ) # case 4: unknown error message = _("WordPress plugin failed to activate correctly. " "Changes were reverted and caching module is now disabled.\n" "Error reported from plugin: \n" "%(error_desc)s.\n\n" "Event is logged to file: '%(logger_path)s' with stdout and stderr recorded.") context = { "command": result.cmd, "logger_path": log_path, "error_desc": result.stdout + result.stderr } raise RollbackException(WebsiteCheckError( header='Post check failed', description=message, context=context, fix_tip='', )) def is_multisite(path: str) -> bool: marker = 'cl_multisite_detected' command = 'if ( is_multisite() ) { echo "%s"; } else { echo "not_multisite"; }' % marker with wp_config_cache('is_multisite', path=path) as record: if record.data: return marker in record.data result = wordpress(path, 'eval', command, env={WP_CLI_SKIP_PLUGINS_ENV: '1'}) if isinstance(result, WordpressError): raise WposError(message=result.message, context=result.context) record.data = result return marker in result def wp_get_constant(wp_path: str, constant: str, raise_exception=False) -> Optional[str]: """ Get: - defined constant value - None in case of error - empty string if no such constant found """ command = "if (defined('%(const)s')) { echo %(const)s; }" % {'const': constant} with wp_config_cache('const.' + constant, path=wp_path) as record: if record.data: return record.data result = wordpress(wp_path, 'eval', command, env={WP_CLI_SKIP_PLUGINS_ENV: '1'}) if isinstance(result, WordpressError): if raise_exception: raise WpCliCommandError(message=result.message, context=result.context) _logger.error('Error during get WP constant: %s', result) return None record.data = result return result def diagnose_redis_connection_constants(docroot: str, wordpress_path: str): """ Check required constants for redis connection establishment """ redis_schema = wp_get_constant(os.path.join(docroot, wordpress_path), RedisRequiredConstants.WP_REDIS_SCHEME.name, raise_exception=True) if not redis_schema and redis_schema != RedisRequiredConstants.WP_REDIS_SCHEME.val: raise WposError('WordPress constant "%(constant)s" is not defined or defined with wrong value %(value)s', context={'constant': RedisRequiredConstants.WP_REDIS_SCHEME.name, 'value': redis_schema}) socket = wp_get_constant(os.path.join(docroot, wordpress_path), RedisRequiredConstants.WP_REDIS_PATH.name, raise_exception=True) if not socket: raise WposError('WordPress constant "%(constant)s" is not defined', context={'constant': RedisRequiredConstants.WP_REDIS_PATH.name}) if not os.path.exists(socket): raise WposError('Redis socket %(socket)s does not exist in the system', context={'socket': socket}) def obtain_wp_cli_env(abs_wp_path: str): """ Returns needed envars for wp-cli """ env = None if os.path.exists(os.path.join(abs_wp_path, '.htaccess')): server_software = 'LiteSpeed' if litespeed_is_running() else 'Apache' env = {'SERVER_SOFTWARE': server_software} return env class PluginInfo(TypedDict): # e.g. "clsop", name: str # e.g. "active", status: str # e.g. "none", update: str # e.g. "3.12.6.1-1-2" version: str @cached_in_scope def _cached_plugin_list(abs_wp_path: str, wp_cli_env=None) -> List[PluginInfo]: if wp_cli_env: wp_cli_env = dict(wp_cli_env) wp_cli_env.update({WP_CLI_SKIP_PLUGINS_ENV: '1'}) else: wp_cli_env = {WP_CLI_SKIP_PLUGINS_ENV: '1'} result = wordpress(abs_wp_path, "plugin", "list", "--json", env=wp_cli_env) # TODO: raise exceptions instead of silent errors if isinstance(result, WordpressError): return [] # Try to find json, when output contains php errors def _maybe_json(output): _result = None pattern = r'(\[{.*?}\])' matches = re.search(pattern, output) if matches: try: json_string = matches.group(1) _result = json.loads(json_string) except Exception: pass return _result try: plugin_list_raw = json.loads(result) except (ValueError, TypeError, json.JSONDecodeError) as e: maybe_json_result = _maybe_json(result) if maybe_json_result is not None: plugin_list_raw = maybe_json_result else: raise WposError( message=_( 'Malformed plugins information received from wp-cli. ' 'Raw response is %(response)s.'), context={'response': result}, details=str(e) ) # https://cl.sentry.cloudlinux.com/organizations/cloudlinux_os/issues/163201/events/f59273162433432e86f2d83bdb4848d7/?project=22 if not isinstance(plugin_list_raw, list): raise WposError( message=_( 'Malformed plugin list received from wp-cli. ' 'Raw response is %(response)s.'), context={'response': result} ) for plugin_info in plugin_list_raw: if not isinstance(plugin_info, dict): raise WposError( message=_( 'Malformed plugin information received from wp-cli. ' 'Raw response is %(response)s.'), context={'response': result} ) return plugin_list_raw def plugin_list(abs_wp_path: str, wp_cli_env=None) -> List[PluginInfo]: return _cached_plugin_list( os.path.normpath(abs_wp_path), tuple(wp_cli_env.items()) if wp_cli_env else None ) def list_active_plugins(abs_wp_path: str, wp_cli_env=None): return [ item for item in plugin_list(abs_wp_path, wp_cli_env) if item['status'] == 'active' ] def get_plugin_data(wordpress_abs_path, plugin_name) -> List[PluginInfo]: plugins_data = plugin_list(abs_wp_path=wordpress_abs_path) if isinstance(plugins_data, WordpressError): raise WposError(message=plugins_data.message, context=plugins_data.context) return [item for item in plugins_data if item['name'] == plugin_name] def is_plugin_activated(abs_wp_path: str, plugin_name: str, wp_cli_env=None) -> bool: return any(item['name'] == plugin_name and item['status'] == 'active' for item in plugin_list(abs_wp_path, wp_cli_env)) def is_plugin_installed(abs_wp_path: str, plugin_name: str, wp_cli_env=None) -> bool: return any(item['name'] == plugin_name for item in plugin_list(abs_wp_path, wp_cli_env)) def update_constant(abs_wp_path: str, constant_name: str, constant_value=None) -> Optional[WordpressError]: if constant_value is None: # Remove constant result = wordpress(abs_wp_path, "config", "has", constant_name, "--type=constant", env={WP_CLI_SKIP_PLUGINS_ENV: '1'}) # Skip, because not zero return code means that constant doesn't exist if isinstance(result, WordpressError): result = None else: result = wordpress(abs_wp_path, "config", "delete", constant_name, "--type=constant", "--quiet", env={WP_CLI_SKIP_PLUGINS_ENV: '1'}) else: # Update constant result = wordpress(abs_wp_path, "config", "set", constant_name, constant_value, "--raw", "--type=constant", '--anchor=// End of CloudLinux generated section', "--placement=before", "--quiet", env={WP_CLI_SKIP_PLUGINS_ENV: '1'}) return result def update_redis_disable_banners_constant(abs_wp_path: str, constant_value=None) -> Optional[WordpressError]: return update_constant(abs_wp_path, RedisRequiredConstants.WP_REDIS_DISABLE_BANNERS.name, constant_value)