#!/opt/cloudlinux/venv/bin/python3 -bb # 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 import argparse import logging import os import sys from cllimits.cagefs_lib import CageFs from clwpos.constants import ALT_PHP_PREFIX, CLWPOS_VAR_DIR from clwpos.feature_suites import is_module_visible_for_user from clwpos.logsetup import setup_logging from clwpos.object_cache.redis_utils import filter_php_versions_with_not_loaded_redis from clwpos.optimization_features import OBJECT_CACHE_FEATURE from clwpos.php.alt_php import ( create_generic_php, disable_selector_extensions, get_alt_php_versions, update_selector_built_in_extensions, ) from clwpos.php.base import PHP from clwpos.utils import ( PhpIniConfig, acquire_lock, create_pid_file, ) logger = setup_logging( caller_name='enable_redis_for_alt_php', file_level=logging.DEBUG, logfile_path='/var/log/clwpos/enable_redis_for_alt_php.log', ) awp_ini = 'acceleratewp.ini' # ALTPHP-2352 default-extension layout scanned in standalone environments STANDALONE_INI_DIR = PhpIniConfig.STANDALONE_INI_DIR # PHP Selector extension sources: cagefs copies every active line of # php.d.all/.ini into the users' alt_php.ini SELECTOR_INI_DIR = 'etc/php.d.all' # marker for those clients, who do not want us modifying global ini DO_NOT_MODIFY_PHP_GLOBAL_MARKER = f'{CLWPOS_VAR_DIR}/admin/do_not_modify_global_php.flag' NOT_VISIBLE_MESSAGE = 'Run only if object caching is allowed for at least one user' def remove_custom_ini(version: str | None, all_ini: bool | None): if not os.path.exists(DO_NOT_MODIFY_PHP_GLOBAL_MARKER): logger.debug('Nothing to do') return supported_version = supported_alt_php(version) for php_version in supported_version: php_ini_config = PhpIniConfig(php_version, custom_logger=logger) php_ini_config.remove_custom_ini(f'etc/php.d/{awp_ini}', all_ini) def update_user_ini(php_version: PHP): php_ini_config = PhpIniConfig(php_version, custom_logger=logger) modules_to_sync = php_ini_config.get_ini_content(f'etc/php.d/{awp_ini}') php_ini_config.update_user_ini('acceleratewp.ini', modules_to_sync) def enable_redis_extension(php_version: PHP): """ Enable 'redis' extension for specified alt-php version by adding 'extension=redis.so' into php.ini file. Also enable 'json' and 'igbinary' extensions if exist which are reqired by 'redis' extension. At the same time: - disable extension in 'php.d.all/.ini' to deny user to enable extension via PHP Selector. - disable extensions in 'php.d/default.ini' to eliminate duplicate loading of extensions. - disable extensions in 'php.d.std/*.ini' for the same reason (see disable_standalone_duplicates). - disable extensions declared as dependencies in other 'php.d.all/*.ini' (see disable_selector_dependency_duplicates). """ php_ini_config = PhpIniConfig(php_version, custom_logger=logger) redis_required_modules = php_ini_config.get_required_modules('etc/php.d.all/redis.ini') if not redis_required_modules: logger.debug('Redis extension is not present in the system, skip') return modules = ['json'] + redis_required_modules modules.sort(key=lambda x: x.endswith('redis')) module_status_map = {} for module in modules: if module == 'redis': module_status_map['redis'] = php_ini_config.disable_modules( 'etc/php.d.all/redis.ini', redis_required_modules ) else: module_status_map[module] = php_ini_config.disable_modules(f'etc/php.d.all/{module}.ini', [module]) logger.debug(f'Final current modules status: {module_status_map}') existing_modules = [module for module, exists in module_status_map.items() if exists] logger.debug(f'Existing extensions: {existing_modules}') php_ini_config.disable_modules('etc/php.d/default.ini', existing_modules) # CLOS-6959: php.d.std/ (ALTPHP-2352) is one more place where the same # extensions may be declared; php.ini stays the single active declaration php_ini_config.disable_modules_in_dir(STANDALONE_INI_DIR, existing_modules) non_loaded_modules = [module for module in existing_modules if not php_version.is_extension_loaded(module)] if not os.path.exists(DO_NOT_MODIFY_PHP_GLOBAL_MARKER): php_ini_config.enable_modules('etc/php.ini', non_loaded_modules) else: logger.debug( 'Marker exists: %s, adding those modules to custom awp ini: %s', DO_NOT_MODIFY_PHP_GLOBAL_MARKER, str(non_loaded_modules), ) php_ini_config.create_custom_ini(f'etc/php.d/{awp_ini}', non_loaded_modules) disable_selector_dependency_duplicates(php_version) disable_selector_extensions(non_loaded_modules, php_version.version, logger) def _get_managed_modules(php_version: PHP, php_ini_config: PhpIniConfig) -> list[str]: """ Return the extensions AccelerateWP moves into php.ini for the version: redis with its dependencies, plus json where it is a shared object. Empty when redis is not installed for the version. """ redis_required_modules = php_ini_config.get_required_modules('etc/php.d.all/redis.ini') if not redis_required_modules: return [] managed_modules = list(redis_required_modules) # json is a shared object only on older alt-php (php.d.all/json.ini present); # there enable_redis_extension() moves it into php.ini as well if os.path.exists(os.path.join(php_version.dir, 'etc/php.d.all/json.ini')): managed_modules.append('json') return managed_modules def disable_standalone_duplicates(php_version: PHP, owned_only: bool = False) -> bool: """ Keep php.ini the single active declaration of the extensions AccelerateWP manages (igbinary, msgpack, redis and json where it is a shared object). :param owned_only: touch only the modules AccelerateWP already declares actively in php.ini (or php.d/acceleratewp.ini on servers with the DO_NOT_MODIFY_PHP_GLOBAL_MARKER). Used when object cache is not visible for any user: the declarations we wrote earlier stay in php.ini, so a later alt-php pecl update would otherwise leave the 'already loaded' warning behind for good - but we must not take over declarations on a server where AccelerateWP never configured this version (redis may legitimately be loaded from php.d.std/ alone there). Since ALTPHP-2352 the alt-php pecl packages ship /opt/alt/phpXY/etc/php.d.std/-.ini with an active 'extension=' line. The interpreter scans php.d.std/ (in addition to link/conf) when the /opt/alt/php-standalone marker exists and the process is not under PHP Selector (link/conf -> /etc/cl.php.d/...). AccelerateWP enables the extensions in php.ini because that is the only location read in every environment, including PHP Selector users inside CageFS where php.d.std/ is never scanned - so the php.ini lines must stay. The php.d.std/ copies are redundant there and make PHP print 'Module "" is already loaded in Unknown on line 0' on every startup (CLOS-6959). Comment them out the same way we already do for php.d.all/.ini and php.d/default.ini. This has to run on every invocation (yum/dnf hook, daily cron, package posttrans) and not only on the first configuration of a PHP version: the duplicates arrive with an alt-php package update long after redis was configured. It also has to run BEFORE the 'is redis loaded' check, otherwise a freshly installed alt-php on a standalone server looks configured to root (redis loaded via php.d.std/) while PHP Selector users never get the extension. :return: True when at least one php.d.std/ file was changed """ php_ini_config = PhpIniConfig(php_version, custom_logger=logger) managed_modules = _get_managed_modules(php_version, php_ini_config) if not managed_modules: # redis is not installed for this version, nothing is managed by us return False if owned_only: owned_modules = php_ini_config.get_enabled_modules('etc/php.ini') | php_ini_config.get_enabled_modules( f'etc/php.d/{awp_ini}' ) managed_modules = [module for module in managed_modules if module in owned_modules] if not managed_modules: logger.debug('%s: no extension declared by AccelerateWP, nothing to reconcile', php_version.identifier) return False disabled = php_ini_config.disable_modules_in_dir(STANDALONE_INI_DIR, managed_modules) if disabled: logger.info( '%s: disabled duplicate extension declarations in %s: %s', php_version.identifier, STANDALONE_INI_DIR, disabled, ) return bool(disabled) def disable_selector_dependency_duplicates(php_version: PHP) -> bool: """ Comment out the dependency lines for the extensions AccelerateWP loads from php.ini in every php.d.all/*.ini (CLOS-7527). Other pecl packages declare our extensions as their dependency, e.g. alt-phpXY-pecl-memcached ships php.d.all/memcached.ini with 'extension=igbinary.so' before 'extension=memcached.so'. cagefs builds a PHP Selector user's alt_php.ini from the active lines of php.d.all/.ini and skips a line only when it is already written, so with igbinary.ini disabled by enable_redis_extension() the igbinary line of memcached.ini lands in alt_php.ini. igbinary is then loaded from both php.ini and alt_php.ini and PHP prints 'Module "igbinary" is already loaded' on every startup. The same applies to json on alt-php 7.2-7.4, where it is a shared object loaded from php.ini and declared as a dependency in solr.ini, mongodb.ini, apm.ini and phalcon*.ini. Only modules with an active line in php.ini are touched: php.ini is read for every PHP Selector user and loads before the scan directory, so memcached still finds igbinary. php.d/acceleratewp.ini (DO_NOT_MODIFY_PHP_GLOBAL_MARKER) is not counted, it reaches a user only after update_user_ini() and a user without it would lose igbinary and fail to load memcached. Servers where AccelerateWP never configured the version keep php.d.all as shipped. Runs on every invocation (like disable_standalone_duplicates) because a pecl package may be installed or updated after igbinary moved to php.ini. The caller must rebuild the users' alt_php.ini when files were changed. :return: True when at least one php.d.all/ file was changed """ php_ini_config = PhpIniConfig(php_version, custom_logger=logger) loaded_by_php_ini = php_ini_config.get_enabled_modules('etc/php.ini') modules = [module for module in _get_managed_modules(php_version, php_ini_config) if module in loaded_by_php_ini] if not modules: return False disabled = php_ini_config.disable_modules_in_dir(SELECTOR_INI_DIR, modules) if disabled: logger.info( '%s: disabled duplicate extension declarations in %s: %s', php_version.identifier, SELECTOR_INI_DIR, disabled, ) return bool(disabled) def rebuild_users_alt_php_ini(cagefs: CageFs, no_rebuild: bool): """ Rebuild users' alt_php.ini if all condition are satisfied: - script called with no flag --no-rebuild - cagefs package is installed """ if not no_rebuild and cagefs.is_cagefs_present(): cagefs.rebuild_alt_php_ini() update_selector_built_in_extensions(logger) def rebuild_after_selector_reconcile(cagefs: CageFs, no_rebuild: bool): """ Deliver php.d.all/ changes of disable_selector_dependency_duplicates() to PHP Selector users when main() has nothing else to do. A failed rebuild is logged and does not abort the yum hook / cron run. """ try: rebuild_users_alt_php_ini(cagefs, no_rebuild) except Exception: logger.exception("Failed to rebuild users' alt_php.ini after %s changes", SELECTOR_INI_DIR) def supported_alt_php(version: str | None): alt_php_versions = [create_generic_php(f'{ALT_PHP_PREFIX}{version}')] if version else get_alt_php_versions() supported_alt_php_versions = [ version for version in alt_php_versions if OBJECT_CACHE_FEATURE.is_php_supported(version) ] return supported_alt_php_versions def get_php_versions_to_enable_redis(version: str | None) -> list[PHP]: """ Return list of php version supported by AccelerateWP and on which redis is presented but not enabled. """ supported_alt_php_versions = supported_alt_php(version) return filter_php_versions_with_not_loaded_redis(supported_alt_php_versions) def parse_args() -> argparse.Namespace: """ Parse arguments for enable_redis_for_alt_php.py utility. """ parser = argparse.ArgumentParser( prog='enable_redis_for_alt_php', description='Utility for enabling redis extension for alt-php', ) parser.add_argument( '--version', type=int, help='Enable redis extension for specific alt-php version', ) parser.add_argument( '--no-rebuild', action='store_true', help='Run without rebuilding users\' alt_php.ini files', ) parser.add_argument('--remove-custom-ini', action='store_true', help='Run to remove custom acceleratewp.ini') parser.add_argument('--all', action='store_true', help='Run to remove ALL custom acceleratewp.ini') parser.add_argument('-v', '--verbose', action='store_true') parser.add_argument('-f', '--force', action='store_true') opts, _ = parser.parse_known_args() if opts.verbose: logging.basicConfig(level=logging.DEBUG) return opts def main(): """ Enable redis extension for alt-php versions supported by AccelerateWP. """ opts = parse_args() # with object cache visible for at least one user (or --force) the utility # may enable redis and take over its declarations for every supported version object_cache_visible = opts.force or is_module_visible_for_user(OBJECT_CACHE_FEATURE) if opts.remove_custom_ini: if not object_cache_visible: print(NOT_VISIBLE_MESSAGE) sys.exit(0) remove_custom_ini(opts.version, opts.all) sys.exit(0) cagefs = CageFs() with acquire_lock(os.path.join('/var/run', ALT_PHP_PREFIX), attempts=None): # CLOS-6959: reconcile php.d.std/ duplicates for every supported version # on every run, before deciding whether redis still has to be enabled. # This also runs when object cache is not visible for anybody anymore: # the php.ini lines written earlier stay, and an alt-php pecl update # would otherwise leave the 'already loaded' warning behind for good. # In that state only the modules we already load are touched. # CLOS-7527: the same for dependency lines in php.d.all/, which reach # PHP Selector users only after their alt_php.ini is rebuilt. selector_ini_changed = False for php_version in supported_alt_php(opts.version): try: disable_standalone_duplicates(php_version, owned_only=not object_cache_visible) except Exception: logger.exception('Failed to reconcile %s duplicates for %s', STANDALONE_INI_DIR, php_version.identifier) try: if disable_selector_dependency_duplicates(php_version): selector_ini_changed = True except Exception: logger.exception('Failed to reconcile %s duplicates for %s', SELECTOR_INI_DIR, php_version.identifier) if not object_cache_visible: if selector_ini_changed: rebuild_after_selector_reconcile(cagefs, opts.no_rebuild) print(NOT_VISIBLE_MESSAGE) sys.exit(0) php_versions_to_enable_redis = get_php_versions_to_enable_redis(opts.version) # we don't continue if there are no alt-php versions (supported by WPOS) # with redis presented but not loaded if not php_versions_to_enable_redis: if selector_ini_changed: rebuild_after_selector_reconcile(cagefs, opts.no_rebuild) logger.info('All php versions have redis extension installed and active') sys.exit(0) with create_pid_file(ALT_PHP_PREFIX): for php_version in php_versions_to_enable_redis: enable_redis_extension(php_version) # iter all supported, to sync users ini in case we already enabled in global .ini if os.path.exists(DO_NOT_MODIFY_PHP_GLOBAL_MARKER): for php_version in supported_alt_php(None): update_user_ini(php_version) # we don't continue if there are no alt-php versions (supported by WPOS) # with redis presented but not loaded if not php_versions_to_enable_redis: sys.exit(0) rebuild_users_alt_php_ini(cagefs, opts.no_rebuild) if __name__ == '__main__': main()