/
bin
/
Upload File
HOME
#!/usr/bin/env python # -*- mode:python; coding:utf-8; -*- # author: Eugene Zamriy <ezamriy@cloudlinux.com>, # Sergey Fokin <sfokin@cloudlinux.com> # editor: Eduard Chiganov <echiganov@cloudlinux.com> # created: 29.07.2015 15:46 # edited: Eduard Chiganov 05.2025 16:20 # description: Selects correct alt-php MySQL binding according to the system # configuration. import getopt import glob import logging import os import platform import re import subprocess import sys import traceback from decimal import Decimal try: import rpm except: class rpm: RPMMIRE_REGEX = None class pattern: def __init__(self, packages): self.packages = packages def pattern(self, field, flag, pattern): regexp = re.compile(pattern) self.packages = list(filter(regexp.match, self.packages)) def __getitem__(self, item): return self.packages[item] class TransactionSet: @staticmethod def dbMatch(): return rpm.pattern(os.popen('rpm -qa').readlines()) VER_PATTERNS = {"18.1": "5.6", "18.1.0": "5.6", "18.0": "5.5", "18.0.0": "5.5", "18": "5.5", "16": "5.1", "15": "5.0", "20.1": "5.7", "20.2": "5.7", "20.3": "5.7", "21.0": "8.0", "21.2": "8.0", "24.0.5": "8.4"} def is_debian(): """ Check if we running on Debian/Ubuntu @rtype : bool @return True or False """ if os.path.exists("/etc/redhat-release"): return False return True def is_plesk(): """ Check is it environment with installed plesk panel @rtype : bool @return True or False """ if not os.path.exists("/usr/sbin/plesk"): return False return True def has_cagefs(): """ Check if we're in environment with enabled cagefs @rtype : bool @return True or False """ if not os.path.exists("/usr/sbin/cagefsctl"): return False with open(os.devnull, 'wb') as devnull: result = subprocess.call( ["/usr/sbin/cagefsctl", "--cagefs-status"], stdout=devnull, stderr=devnull ) return result == 0 def is_bare_plesk(): """ Check is it environment with installed plesk panel on clean ELS system without cagefs @rtype : bool @return True or False """ return is_plesk() and not has_cagefs() def configure_logging(verbose): """ Logging configuration function. @type verbose: bool @param verbose: Enable additional debug output if True, display only errors otherwise. """ if verbose: level = logging.DEBUG else: level = logging.ERROR handler = logging.StreamHandler() handler.setLevel(level) log_format = "%(levelname)-8s: %(message)s" formatter = logging.Formatter(log_format, "%H:%M:%S %d.%m.%y") handler.setFormatter(formatter) logger = logging.getLogger() logger.addHandler(handler) logger.setLevel(level) return logger def symlink_abs_path(path): """ Recursively resolves symlink. @type path: str @param path: Symlink path. @rtype: str @return: Resolved symlink absolute path. """ processed_symlinks = set() if not isinstance(path, str): return None while os.path.islink(path): if path in processed_symlinks: return None path = os.path.join(os.path.dirname(path), os.readlink(path)) processed_symlinks.add(path) return os.path.abspath(path) def create_symlink_to_mysqli_ini(source_dir, target_dir, int_ver=None): """ Makes php.d/mysqli.ini current for one alt-php version. php<82: a symlink at the php.d.all copy, as it has always been. php82+: a real file, because the two copies must differ (see int_ver). @type source_dir: str @param source_dir: php.d.all - holds the authoritative mysqli.ini body. @type target_dir: str @param target_dir: php.d - where the active copy is created or updated. @type int_ver: str @param int_ver: alt-php version (e.g. "82"). On php82+ the two copies of mysqli.ini deliberately differ (ALTEAPLN-53): php.d.all declares extension=mysqlnd.so for the PHP Selector, php.d must not because MYSQLND_INI_NAME already loads it there. A symlink cannot express that, so on php82+ this writes the php.d body instead of linking - see the branch below. Unset only by callers that cannot have a version in hand, which then get the historical symlink behaviour. @rtype: bool @return: True if php.d/mysqli.ini is correct afterwards, False on error or when skipped on bare Plesk. """ source_path = os.path.join(source_dir, 'mysqli.ini') target_path = os.path.join(target_dir, 'mysqli.ini') try: ver_num = int(int_ver) except (TypeError, ValueError): ver_num = 0 if is_bare_plesk(): return False if ver_num >= 82: # ALTEAPLN-53: php.d must hold the alias body WITHOUT the # extension=mysqlnd.so that php.d.all carries for the Selector - # MYSQLND_INI_NAME owns mysqlnd here, and a second declaration makes PHP # warn 'Module "mysqlnd" is already loaded'. A symlink cannot express # that, so write the file. # # Rewrite rather than just skip: this is reached from the bare # (non---driver) reconfigure that alt-php-config's %triggerin runs for # every installed version whenever an alt-phpXY-mysqlnd package changes. # An older alt-php-config running against php.d.all bodies from # mysql-meta 3-28 leaves a symlink here, and only that version's own # mysql-meta posttrans would otherwise repair it - so a rollback of # alt-php-config alone would silently reinstate the duplicate warning. # Deriving the body from php.d.all keeps this driver-agnostic. try: with open(source_path) as fh: body = fh.read() except (IOError, OSError) as e: logging.error(u"cannot read %s: %s" % (source_path, e)) return False active = "".join(l + "\n" for l in body.splitlines() if l.strip() != "extension=mysqlnd.so") try: if os.path.islink(target_path) or os.path.exists(target_path): with open(target_path) as fh: # ALTPHP-2624: lines yielded to default.ini still count # as correct; yield_to_default_ini() keeps them current if not os.path.islink(target_path) and \ unyield_body(fh.read()) == active: return True # already correct, leave mtime alone os.remove(target_path) with open(target_path, "w") as fh: fh.write(active) except (IOError, OSError) as e: logging.error(u"cannot write %s: %s" % (target_path, e)) return False logging.info(u"php%s: %s written (php.d copy, mysqlnd owned by %s)" % (int_ver, target_path, MYSQLND_INI_NAME)) return True else: try: # Remove the target file/symlink if it exists (mimic --force) if os.path.exists(target_path) and not os.path.islink(target_path): os.remove(target_path) if source_path == symlink_abs_path(target_path): logging.debug(u"%s is already configured to %s" % (target_path, source_path)) return True # Create the symlink os.symlink(source_path, target_path) logging.info(u"Symlink created or updated: %s -> %s" % (target_path, source_path)) return True except Exception as e: logging.error(u"Error creating or updating symlink: %s" % str(e)) return False def find_interpreter_versions(interpreter="php"): """ Returns list of installed alt-php versions and their base directories. @rtype: list @return: List of version (e.g. 44, 55) and base directory tuples. """ int_versions = [] if interpreter == "ea-php": base_path_regex = "/opt/cpanel/ea-php[0-9][0-9]/root/" else: base_path_regex = "/opt/alt/%s[0-9][0-9]" % interpreter for int_dir in glob.glob(base_path_regex): int_versions.append((int_dir[-2:], int_dir)) int_versions.sort() return int_versions def find_mysql_executable(mysql="mysql"): """ Detects MySQL binary full path. @type mysql: str @param mysql: MySQL binary name (default is "mysql"). @rtype: str or None @return: MySQL binary full path or None if nothing is found. """ for path in os.environ["PATH"].split(os.pathsep): mysql_path = os.path.join(path, mysql) if os.path.exists(mysql_path) and os.access(mysql_path, os.X_OK): if os.path.islink(mysql_path): return os.readlink(mysql_path) else: return mysql_path def is_percona(major, minor): """ Check if Percona server is installed @type major: str @param major: major version of sql server @type minor: str @param minor: minor version of sql server @rtype: bool @return: True or False """ if is_debian(): if not os.system("dpkg -l | grep -i percona-server"): return True else: ts = rpm.TransactionSet() mi = ts.dbMatch() pattern = "Percona-Server-shared-{0}{1}|cl-Percona{0}{1}-shared".format( major, minor) mi.pattern('name', rpm.RPMMIRE_REGEX, pattern) for _ in mi: mysql_type = "percona" return True return False def parse_mysql_version(version): """ Extracts MySQL engine type and version from the version string (mysql -V output). @type version: str @param version: MySQL version string (mysql -V output). @rtype: tuple @return: MySQL engine type (e.g. mariadb, mysql) and version (e.g. 5.6, 10.0) tuple. """ ver_rslt = "" if re.search(r"mysql", version, re.IGNORECASE): ver_rslt = re.search(r"mysql\s+Ver\s+.*?Distrib\s+((\d+)\.(\d+)\S*?),?\s+for", version) if not ver_rslt: ver_rslt = re.search(r"mysql\s+Ver\s+((\d+)\.(\d+)\S*)", version) else: ver_rslt = re.search(r"mariadb\s+from\s+((\d+)\.(\d+)\S*?),?\s+", version) if not ver_rslt: ver_rslt = re.search(r"mariadb\s+Ver\s+.*?Distrib\s+((\d+)\.(\d+)\S*?),?" r"\s+for", version) if not ver_rslt: return None, None full_ver, major, minor = ver_rslt.groups() mysql_type = "mysql" mysql_ver = "%s.%s" % (major, minor) if re.search(r"mariadb", full_ver, re.IGNORECASE): mysql_type = "mariadb" # NOTE: there are no way to detect Percona by "mysql -V" output, so we # are looking for Percona-Server-shared* or cl-Percona*-shared package installed if is_percona(major, minor): mysql_type = "percona" return mysql_type, mysql_ver def get_mysql_version(mysql_path): """ Returns MySQL engine type and version of specified MySQL executable. @type mysql_path: str @param mysql_path: MySQL executable path. @rtype: tuple @return: MySQL engine type (mariadb or mysql) and version (e.g. 5.6, 10.0) tuple. """ proc = subprocess.Popen([mysql_path, "-V"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) out, _ = proc.communicate() if proc.returncode != 0: raise Exception(u"cannot execute \"%s -V\": %s" % (mysql_path, out)) ver_string = out.strip() logging.debug(u"SQL version string is '%s'" % ver_string) return parse_mysql_version(ver_string) def detect_so_version(so_path): """ Parameters ---------- so_path : str or unicode Absolute path to .so library Returns ------- tuple Tuple of MySQL type name and MySQL version """ mysql_ver = None for ver_pattern in VER_PATTERNS: if re.search(re.escape(".so.%s" % ver_pattern), so_path): mysql_ver = VER_PATTERNS[ver_pattern] if is_debian(): mysql_ver = mysql_ver.replace(".", "") # in some Percona builds .so was renamed to libperconaserverclient.so if "libperconaserverclient.so" in so_path: return "percona", mysql_ver # search for markers (mariadb/percona) in .so strings proc = subprocess.Popen(["strings", so_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) out, _ = proc.communicate() if proc.returncode != 0: raise Exception(u"cannot execute \"strings %s\": %s" % (so_path, out)) mysql_type = "mysql" for line in out.split("\n"): if re.search(r"percona", line, re.IGNORECASE): return "percona", mysql_ver maria_version = re.search(r"^(1[0-1]\.[0-9]+)\.[0-9]*(-MariaDB)?$", line, re.IGNORECASE) if maria_version is not None and len(maria_version.groups()) != 0: return "mariadb", maria_version.group(1) if re.search(r"5\.5.*?-MariaDB", line, re.IGNORECASE): return "mariadb", "5.5" if re.search(r"mariadb", line, re.IGNORECASE): mysql_type = "mariadb" return mysql_type, mysql_ver def detect_lib_dir(): """ Returns ------- str lib if running on 32-bit system, lib64 otherwise """ if is_debian(): # ALTPHP-2242: the multiarch triplet is arch-specific — hardcoding # x86_64-linux-gnu breaks ARM64 (needs aarch64-linux-gnu). Derive it from # the running machine so the module path is correct on amd64 and arm64. return "lib/%s-linux-gnu" % os.uname()[4] if platform.architecture()[0] == "64bit": return "lib64" else: return "lib" def get_int_files_root_path(int_name, int_ver): """ Parameters ---------- int_name : str or unicode Interpreter name (php, python) int_ver : str or unicode Interpreter version (44, 70, 27, etc.) Returns ------- str Absolute path to interpreter root """ if int_name == "php": return "/opt/alt/php%s" % int_ver elif int_name == "ea-php": return "/opt/cpanel/ea-php%s/root/" % int_ver elif int_name == "python": return "/opt/alt/python%s" % int_ver else: raise NotImplementedError("Unknown interpreter") def get_dst_so_path(int_name, int_ver, so_name): """ Parameters ---------- int_name : str or unicode Interpreter name (php, python) int_ver : str or unicode Interpreter version (44, 70, 27, etc.) so_name : str or unicode MySQL shared library name Returns ------- str Absolute path to MySQL binding destination point """ lib_dir = detect_lib_dir() int_path = get_int_files_root_path(int_name, int_ver) int_dot_ver = "%s.%s" % (int_ver[0], int_ver[-1]) if int_name in ["php", "ea-php"]: if re.match(r".*_ts.so", so_name): return os.path.join(int_path, "usr", lib_dir, "php-zts/modules", re.sub(r'_ts\.so', '.so', so_name)) else: return os.path.join(int_path, "usr", lib_dir, "php/modules", so_name) elif int_name == "python": if os.path.exists("/opt/alt/python{0}/bin/python{1}".format(int_ver, int_ver[0])): proc = subprocess.Popen("/opt/alt/python{0}/bin/python{1} -c \"from distutils.sysconfig import get_python_lib; print(get_python_lib(True))\"".format(int_ver, int_ver[0]), shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) out, _ = proc.communicate() return os.path.join(out.strip(),so_name) else: raise NotImplementedError("Unknown interpreter") def get_mysql_pkg_name(int_name, int_ver, mysql_type, mysql_ver, zts=False): """ Parameters ---------- int_name : str or unicode Interpreter name (php, python) int_ver : str or unicode Interpreter version (44, 27, 71, etc.) mysql_type : str or unicode Mysql base type (mysql, mariadb, percona) mysql_ver : str or unicode Mysql version (5.5, 10, 10.1) Returns ------- """ if int_name == "php": if not zts: return "alt-php%s-%s%s" % (int_ver, mysql_type, mysql_ver) else: return "alt-php%s-%s%s-zts" % (int_ver, mysql_type, mysql_ver) elif int_name == "ea-php": return "%s%s-php-%s%s" % (int_name, int_ver, mysql_type, mysql_ver) elif int_name == "python": return "alt-python%s-MySQL-%s%s" % (int_ver, mysql_type, mysql_ver) else: raise NotImplementedError("Unknown interpreter") def get_so_list(int_name): """ Parameters ---------- int_name : str Interpreter name (e.g. php, python, etc.) Returns ------- list Binding .so names reconfigure manages for the interpreter. """ if int_name == "ea-php": return ["mysql.so", "mysqli.so", "pdo_mysql.so"] elif int_name == "php": # ALTPHP-2242: client-lib bindings stay available for every version; # the active driver (native nd_* vs binding) is chosen via --driver. # reconfigure keeps the binding .so symlinks fresh regardless of mode. if is_bare_plesk(): return ["mysql.so", "mysqli.so", "pdo_mysql.so"] return ["mysql.so", "mysqli.so", "pdo_mysql.so", "mysql_ts.so", "mysqli_ts.so", "pdo_mysql_ts.so"] elif int_name == "python": return ["_mysql.so"] else: raise NotImplementedError("Unknown interpreter") def match_so_to_mysql(): mysql = find_mysql_executable() # If we have no MySQL, then nothing should be done if not mysql: return possible_versions = list(VER_PATTERNS.values()) possible_versions += ["10", "10.0", "10.1", "10.2", "10.3", "10.4", "10.5", "10.6", "10.11", "11.4","11.04", "11.8"] mysql_type, mysql_ver = get_mysql_version(mysql) if is_debian(): mysql_ver = mysql_ver.replace(".", "") if mysql_type not in ["mysql", "mariadb", "percona"] or \ mysql_ver not in possible_versions: return if mysql_ver == "5.0": search_pattern = re.compile(r"(\S*libmysqlclient\.so\.15\S*)") elif mysql_ver == "5.1": search_pattern = re.compile(r"(\S*libmysqlclient\.so\.16\S*)") elif mysql_ver in ("5.5", "10", "10.0", "10.1"): search_pattern = re.compile(r"(\S*libmysqlclient\.so\.18\.0\S*)") elif mysql_ver == "5.6": search_pattern = re.compile(r"(\S*libmysqlclient\.so\.18\.1\S*)") elif mysql_ver == "5.7": search_pattern = re.compile(r"(\S*libmysqlclient\.so\.20\S*)") elif mysql_ver == "8.0": search_pattern = re.compile(r"(\S*libmysqlclient\.so\.21\S*)") elif mysql_ver == "8.4": search_pattern = re.compile(r"(\S*libmysqlclient\.so\.24\S*)") elif mysql_ver in ("10.2", "10.3", "10.4", "10.5", "10.6", "10.11", "11.04", "11.4", "11.8"): search_pattern = re.compile(r"(\S*libmariadb\.so\.3\S*)") else: raise Exception(u"Cannot match MySQL library to any version") if mysql_type == "percona": search_path = ["/usr/%s" % detect_lib_dir()] else: search_path = ["/usr/local/mysql/lib/", # Added path for Direect Admin "/usr/%s/" % detect_lib_dir(), "/usr/%s/mysql" % detect_lib_dir(), "/usr/%s/mariadb" % detect_lib_dir()] files = [] for libs_path in search_path: if os.path.exists(libs_path): for file in os.listdir(libs_path): files.append(os.path.join(libs_path, file)) for one_file in files: if search_pattern.match(one_file): return (search_pattern.match(one_file).string, mysql_type, mysql_ver) def get_mysql_so_files(): proc = subprocess.Popen(["/sbin/ldconfig", "-p"], stdout=subprocess.PIPE, universal_newlines=True) out, _ = proc.communicate() if proc.returncode != 0: raise Exception(u"cannot execute \"ldconfig -p\": %s" % out) so_re = re.compile(r"^.*?=>\s*(\S*?(libmysqlclient|" r"libmariadb|" r"libperconaserverclient)\.so\S*)") forced_so_file = match_so_to_mysql() if forced_so_file: so_files = [forced_so_file] else: so_files = [] for line in out.split("\n"): re_rslt = so_re.search(line) if not re_rslt: continue so_path = symlink_abs_path(re_rslt.group(1)) if not so_path or not os.path.exists(so_path): continue mysql_type, mysql_ver = detect_so_version(so_path) so_rec = (so_path, mysql_type, mysql_ver) if so_rec not in so_files: so_files.append(so_rec) return so_files def reconfigure_mysql(int_ver, mysql_type, mysql_ver, force=False, int_name="php"): """ Parameters ---------- int_ver : str or unicode Interpreter version (44, 70, 27, etc.) mysql_type : str or unicode MySQL type (mysql, mariadb, percona) mysql_ver : str or unicode MySQL version (5.5, 10.1, etc.) force : bool Force symlink reconfiguration if True, do nothing otherwise int_name : str or unicode Optional, defines interpreter name (php, python). Default is php Returns ------- bool True if reconfiguration was successful, False otherwise """ int_dir = get_int_files_root_path(int_name, int_ver) if is_bare_plesk(): so_list = get_so_list(int_name) for so_name in so_list: src_so = get_dst_so_path(int_name, int_ver, "nd_%s" % so_name) dst_so = get_dst_so_path(int_name, int_ver, so_name) if os.path.exists(src_so): try: if os.path.exists(dst_so): os.remove(dst_so) os.symlink(src_so, dst_so) except Exception as e: logging.error(u"Error creating symlink: %s" % str(e)) return True if mysql_type == "mariadb": if mysql_ver in ("10", "10.0"): mysql_ver = "10" elif mysql_ver.startswith("10."): mysql_ver = mysql_ver.replace(".", "") elif mysql_ver.startswith("11."): # ALTPHP-2242: MariaDB 11.x binding dir/package names are INCONSISTENT # upstream and no single transform works: 11.4 -> mariadb1104 # (zero-padded minor) but 11.8 -> mariadb118 (unpadded). Both ship on # CL9/CL10. Use an explicit map; fall back to the dot-stripped form for # any future 11.x not listed here. mariadb_11x_map = {"11.4": "1104", "11.8": "118"} mysql_ver = mariadb_11x_map.get(mysql_ver, mysql_ver.replace(".", "")) elif mysql_ver == "5.5": # NOTE: there are no special bindings for MariaDB 5.5 in Cloud Linux # so we are using the MySQL one mysql_type = "mysql" so_list = get_so_list(int_name) for so_name in so_list: src_so = os.path.join(int_dir, "etc", "%s%s" % (mysql_type, mysql_ver), so_name) if not os.path.exists(src_so): if (so_name in ("mysqli.so", "pdo_mysql.so") and int_ver == "44") \ or (so_name == "mysql.so" and int_ver.startswith("7")) \ or (so_name == "mysql.so" and int_ver.startswith("8")) \ or (re.match(r".*_ts.so", so_name) and int_ver != 72): # NOTE: there are no mysql.so for alt-php7X and mysqli.so / # pdo_mysql.so for alt-php44 continue # TODO: maybe find an appropriate replacement for missing # .so in other alt-php-(mysql|mariadb|percona) packages? mysql_pkg_name = get_mysql_pkg_name(int_name, int_ver, mysql_type, mysql_ver, bool(re.match(r".*_ts.so", so_name))) logging.debug(u"%s is not found. Please install " u"%s package" % (so_name, mysql_pkg_name)) return False dst_so = get_dst_so_path(int_name, int_ver, so_name) dst_so_real = symlink_abs_path(dst_so) if src_so == dst_so_real: logging.debug(u"%s is already updated" % dst_so) continue else: force = True if not isinstance(dst_so, str): return False if os.access(dst_so, os.R_OK): # seems alt-php is already configured - don't touch without force # argument if not force: logging.debug(u"current %s configuration is ok (%s)" % (dst_so, dst_so_real)) continue os.remove(dst_so) os.symlink(src_so, dst_so) logging.info(u"%s was reconfigured to %s" % (dst_so, src_so)) else: # seems current alt-php configuration is broken, reconfigure it try: os.remove(dst_so) except: pass os.symlink(src_so, dst_so) logging.info(u"%s was configured to %s" % (dst_so, src_so)) continue return True def check_alt_path_exists(int_path, int_name, int_ver): """ Parameters ---------- int_path : str or unicode Interpreter directory on the disk (/opt/alt/php51, etc.) int_name : str or unicode Interpreter name (php, python) int_ver : str or unicode Interpreter version (44, 70, 27, etc.) Returns ------- bool True if interpreter path exists, False otherwise """ if not os.path.isdir(int_path): sys.stderr.write("unknown {0} version {1}".format(int_name, int_ver)) return False return True # ALTPHP-2242: .ini bodies for switching the active mysql driver. "native" # loads the mysqlnd-based nd_* extensions (no client lib); "binding" loads the # client-lib .so managed by the reconfigure symlinks. # # ALTEAPLN-53: there are two audiences for these files and they need different # bodies, so we write both instead of symlinking php.d at the php.d.all copy. # # php.d.all - read ONLY by the PHP Selector / CageFS per-user generators # (cagefslib.get_dependencies, clselect.clextselect and # cpanel/lib/extensions.process_dependencies). They build each # user's alt_php.ini from php.d.all alone and derive the load # order purely from the `extension=<dep>.so` lines in these # bodies. A dependency that is not declared here does not exist # as far as they are concerned - php.d is never read, so # MYSQLND_INI_NAME below is invisible to all three. All three # also look a dependency up as php.d.all/<so basename>.ini, so # the owner file has to be named exactly mysqlnd.ini (it is, # from alt-phpXY-mysqlnd) - a 00- prefixed name would never be # found. Hence: php82+ bodies MUST declare extension=mysqlnd.so. # # php.d - what the native/root PHP actually scans, alphabetically. # MYSQLND_INI_NAME owns mysqlnd there, so an alias repeating it # makes PHP print 'Module "mysqlnd" is already loaded in Unknown # on line 0' on every invocation (ALTEAPLN-12). Hence: no alias # body written to php.d may declare it. # # ALTEAPLN-12 collapsed the two by dropping mysqlnd from both bodies. That # silenced the warning but deleted the only mysqli/pdo_mysql -> mysqlnd edge # the Selector could see, so every account whose module list carries the alias # names got nd_mysqli.so loaded before mysqlnd.so and mysqli died with # 'undefined symbol: mysqlnd_global_stats' (ALTPHP-2598, ALTEAPLN-53). # # Keep the php.d.all bodies byte-identical to the ones alt-phpXY-mysql-meta # ships: they are config(noreplace) there, and any drift makes every upgrade # leave an .rpmnew behind. # Bodies written to php.d.all (the Selector/CageFS dependency graph). DRIVER_INI = { "native": { "mysqli.ini": "; Enable mysqli native driver extension\n" "extension=mysqlnd.so\nextension=nd_mysqli.so\n", "pdo_mysql.ini": "; Enable pdo_mysql native driver extension\n" "extension=pdo.so\nextension=mysqlnd.so\n" "extension=nd_pdo_mysql.so\n", }, "binding": { # php82+ only - see BINDING_MYSQLI_INI_PRE82 for php<82. From php82 the # binding mysqli.so is mysqlnd-based too, so the edge is real in this # mode as well. "mysqli.ini": "; Enable mysqli extension module\n" "extension=mysqlnd.so\nextension=mysqli.so\n", "pdo_mysql.ini": "; Enable pdo_mysql extension module\n" "extension=pdo.so\nextension=pdo_mysql.so\n", }, } # Bodies written to php.d (what native/root PHP scans). Identical to the # php.d.all ones minus the mysqlnd declaration, which MYSQLND_INI_NAME owns. ACTIVE_INI = { "native": { "mysqli.ini": "; Enable mysqli native driver extension\n" "extension=nd_mysqli.so\n", "pdo_mysql.ini": "; Enable pdo_mysql native driver extension\n" "extension=pdo.so\nextension=nd_pdo_mysql.so\n", }, "binding": { "mysqli.ini": "; Enable mysqli extension module\n" "extension=mysqli.so\n", "pdo_mysql.ini": "; Enable pdo_mysql extension module\n" "extension=pdo.so\nextension=pdo_mysql.so\n", }, } # php<82 ships a plain client-lib mysqli.so that must not load mysqlnd at all, # in either directory. Nothing else differs by version: pdo_mysql binding never # declared mysqlnd, and native only exists on php82+ (see NATIVE_SO). BINDING_MYSQLI_INI_PRE82 = ("; Enable mysqli extension module\n" "extension=mysqli.so\n") # ALTEAPLN-12: sole owner of extension=mysqlnd.so for the native php.d scan. # The 00- prefix makes php.d read it before every nd_* consumer, and it lives # directly in php.d (like default.ini from alt-phpXY-common) rather than in # php.d.all, so it is not offered as a selectable extension. Only php82+ has # the nd_* modules. It is deliberately NOT the Selector's mysqlnd owner - see # the note above; php.d.all/mysqlnd.ini from alt-phpXY-mysqlnd is. MYSQLND_INI_NAME = "00-mysqlnd.ini" MYSQLND_EXT_LINE = "extension=mysqlnd.so" MYSQLND_INI_BODY = ("; mysqlnd must load before nd_mysqli/nd_pdo_mysql " "(php.d is scanned alphabetically)\n" + MYSQLND_EXT_LINE + "\n") # native .so that must exist for a given ini before we may switch it to native NATIVE_SO = {"mysqli.ini": "nd_mysqli.so", "pdo_mysql.ini": "nd_pdo_mysql.so"} # ALTPHP-2598: the php.d.all bodies alt-phpXY-mysqlnd owns, and the dependency # each one must declare ahead of its own module. # # Nothing rewrites these on upgrade. They are config(noreplace) and their # packaged bodies have not changed in years, which parks rpm permanently in its # "old payload == new payload" case: a local edit is kept for good and no # .rpmnew is ever written. A body stripped down to its own extension= line # therefore deletes the edge from the Selector/CageFS graph for good - that is # how ZD 292358 lost mysqli across a 9-server fleet, on versions the packaging # regression never touched. The alias files above are rewritten unconditionally # by set_driver_ini(); these are not, so normalize them here. CHAIN_INI_DEPS = { "nd_mysqli.ini": ("nd_mysqli.so", ("mysqlnd.so",)), "nd_pdo_mysql.ini": ("nd_pdo_mysql.so", ("pdo.so", "mysqlnd.so")), } def normalize_chain_ini(int_name, int_ver): """ ALTPHP-2598: put back dependency declarations missing from the php.d.all bodies alt-phpXY-mysqlnd owns. The PHP Selector and the CageFS per-user generator derive the entire load order from the extension= lines in these bodies, so a dependency that is not declared here does not exist as far as they are concerned: they emit nd_mysqli.so ahead of mysqlnd.so and PHP fails the extension with 'undefined symbol: mysqlnd_global_stats'. Deliberately conservative - this runs unattended from every alt-phpXY-mysql-meta posttrans: - only an existing file is repaired. An absent one means the owning package is not installed, and writing it would declare a module that is not there, trading one startup error for another. - a dependency whose .so is missing is skipped, for the same reason. - a body that does not load its own module is left alone; it has been disabled on purpose and is not ours to second-guess. - the declaration is inserted ahead of the module's own line and the rest of the body is preserved, so local additions survive. Returns True if any file was rewritten. """ if int_name != "php": return False root = get_int_files_root_path(int_name, int_ver) ini_all = os.path.join(root, "etc", "php.d.all") modules_dir = os.path.join(root, "usr", detect_lib_dir(), "php", "modules") changed = False for ini_name in sorted(CHAIN_INI_DEPS): own_so, deps = CHAIN_INI_DEPS[ini_name] path = os.path.join(ini_all, ini_name) if not os.path.isfile(path): continue try: with open(path) as fh: lines = fh.read().splitlines() except (IOError, OSError) as e: logging.error(u"cannot read %s: %s" % (path, e)) continue # Exact line membership, never a substring test - the substring # shortcut is precisely what broke lvemanager's resolver in # ALTEAPLN-53. own_line = "extension=%s" % own_so if own_line not in lines: continue own_at = lines.index(own_line) head, tail = lines[:own_at], lines[own_at:] # A declaration sitting after the module's own line is as useless as a # missing one, so treat both the same way and re-seat it. missing = [d for d in deps if "extension=%s" % d not in head and os.path.exists(os.path.join(modules_dir, d))] if not missing: continue stale = ["extension=%s" % d for d in missing] lines = head + stale + [l for l in tail if l not in stale] try: with open(path, "w") as fh: fh.write("\n".join(lines) + "\n") except (IOError, OSError) as e: logging.error(u"cannot write %s: %s" % (path, e)) continue changed = True logging.info(u"php%s: %s restored %s" % (int_ver, ini_name, ", ".join(missing))) return changed # Duplicate declarations between php.d/default.ini and the php.d files this # tool writes. # # default.ini is the one php.d file admins edit by hand: alt-phpXY used to ship # it with the whole extension list commented out, and "uncomment the line" is # how the native/root PHP got its extensions. Since ALTPHP-2242 this tool also # declares the mysql chain in php.d (00-mysqlnd.ini, mysqli.ini, # pdo_mysql.ini), so every server where an admin had uncommented one of those # modules loads it twice and PHP warns 'Module "X" is already loaded' on every # invocation (ZD 294842). # # default.ini belongs to the admin and is NEVER written, not even to comment a # line out - that is a promise made to customers. It is only read. The php.d # files this tool owns yield instead: a module default.ini already loads is # commented out in them, with a marker, and put back as soon as default.ini # stops loading it. # # Order: PHP dlopen()s extensions in scan order and nd_mysqli/nd_pdo_mysql need # mysqlnd's data symbols at load time. default.ini is read after 00-mysqlnd.ini # but before mysqli.ini and pdo_mysql.ini, so dropping a later file's copy never # moves a module later; dropping 00-mysqlnd.ini's copy does, and is only done # when no mysqlnd consumer is read in between. DEFAULT_INI_NAME = "default.ini" # the php.d files set_driver_ini() / create_symlink_to_mysqli_ini() write MANAGED_PHP_D_INI = (MYSQLND_INI_NAME, "mysqli.ini", "pdo_mysql.ini") YIELD_MARK = " ; alt-php-mysql-reconfigure: loaded by php.d/" + DEFAULT_INI_NAME _EXT_LINE_RE = re.compile(r'^\s*extension\s*=\s*"?([^";\s]+)"?\s*(;.*)?$') def ext_module(line): """Module name an active extension= line loads, or None.""" m = _EXT_LINE_RE.match(line) if not m: return None name = os.path.basename(m.group(1)) if name.endswith(".so"): name = name[:-3] return name def module_requires(module, dep): """True if `module` needs `dep` loaded before it. Only mysqlnd matters: nd_mysqli/nd_pdo_mysql resolve its data symbols at dlopen() time. Erring towards True only ever keeps a declaration.""" if dep == "mysqlnd": return (module in ("mysqli", "nd_mysqli", "pdo_mysql", "nd_pdo_mysql", "mysql_xdevapi") or module.startswith("mysqlnd_")) return False def _read_lines(path): with open(path) as fh: return fh.read().splitlines() def yielded_line(line): """The original extension= line behind a line this tool commented out because default.ini loads it, or None for any other line.""" s = line.rstrip() if s.startswith(";") and s.endswith(YIELD_MARK): return s[1:-len(YIELD_MARK)] return None def unyield_body(body): """`body` with every yielded line put back - the canonical body.""" out = [] for line in body.splitlines(): orig = yielded_line(line) out.append(line if orig is None else orig) return "".join(l + "\n" for l in out) def _php_d_ini_names(ini_d): return sorted(n for n in os.listdir(ini_d) if n.endswith(".ini")) def _file_modules(path): try: return [m for m in (ext_module(l) for l in _read_lines(path)) if m] except (IOError, OSError): return [] def yield_to_default_ini(int_name, int_ver): """ Keep the php.d files this tool writes from loading a module that php.d/default.ini already loads. default.ini is only read, never written. Deliberately conservative - this runs unattended from scriptlets: - lines are commented out with a marker, never deleted, and a marked line is restored once default.ini no longer loads that module, so the admin stays in control from default.ini alone. - 00-mysqlnd.ini keeps mysqlnd when a mysqlnd consumer is read between it and default.ini's own declaration (the duplicate is then logged). - php<82 php.d files that are symlinks into php.d.all (the Selector's graph) are left alone; on php82+ such a symlink is replaced by a file, never written through. Returns True if any file was rewritten. """ if int_name != "php": return False root = get_int_files_root_path(int_name, int_ver) ini_d = os.path.join(root, "etc", "php.d") if not os.path.isdir(ini_d): return False try: ver_num = int(int_ver) except (TypeError, ValueError): ver_num = 0 default_path = os.path.join(ini_d, DEFAULT_INI_NAME) default_mods = [] if os.path.isfile(default_path): default_mods = _file_modules(default_path) names = _php_d_ini_names(ini_d) changed = False for name in MANAGED_PHP_D_INI: path = os.path.join(ini_d, name) if not os.path.isfile(path): continue if os.path.islink(path) and ver_num < 82: continue try: lines = _read_lines(path) except (IOError, OSError) as e: logging.error(u"cannot read %s: %s" % (path, e)) continue new = [] for line in lines: orig = yielded_line(line) base = line if orig is None else orig mod = ext_module(base) drop = False if mod and mod in default_mods: drop = True if name < DEFAULT_INI_NAME: # moving this module later: everything read before # default.ini's own declaration must manage without it before = [m for f in names if name < f < DEFAULT_INI_NAME for m in _file_modules(os.path.join(ini_d, f))] before += default_mods[:default_mods.index(mod)] if any(module_requires(m, mod) for m in before): drop = False logging.warning( u"php%s: %s and %s both load %s; kept in %s, a " u"consumer is read before %s's copy" % (int_ver, name, DEFAULT_INI_NAME, mod, name, DEFAULT_INI_NAME)) new.append(";" + base.strip() + YIELD_MARK if drop else base) if new == lines: continue try: if os.path.islink(path): os.remove(path) with open(path, "w") as fh: fh.write("\n".join(new) + "\n") changed = True logging.info(u"php%s: %s now defers to %s for: %s" % ( int_ver, name, DEFAULT_INI_NAME, ", ".join(ext_module(yielded_line(l)) for l in new if yielded_line(l)) or "nothing")) except (IOError, OSError) as e: logging.error(u"cannot write %s: %s" % (path, e)) return changed def yield_all_to_default_ini(int_name, int_versions): """yield_to_default_ini() for every version; never fails the caller.""" if int_name != "php": return for int_ver, int_dir in int_versions: try: yield_to_default_ini(int_name, int_ver) except Exception as e: logging.error(u"php%s: default.ini dedupe failed: %s" % (int_ver, e)) def ini_bodies(driver, ini_name, ver_num): """ ALTEAPLN-53: return (php_d_all_body, php_d_body) for one alias file. They differ on php82+: the php.d.all copy declares extension=mysqlnd.so so the Selector can see the dependency, the php.d copy does not so the native scan does not load mysqlnd twice. """ if driver == "binding" and ini_name == "mysqli.ini" and ver_num < 82: return BINDING_MYSQLI_INI_PRE82, BINDING_MYSQLI_INI_PRE82 return DRIVER_INI[driver][ini_name], ACTIVE_INI[driver][ini_name] def set_driver_ini(int_name, int_ver, driver): """ ALTPHP-2242: switch the active mysql driver for one alt-php version by rewriting mysqli.ini / pdo_mysql.ini in php.d.all and activating them in php.d. For "native", only extensions whose nd_*.so is present are switched; the rest are left as binding. Does not touch the binding .so symlinks. ALTEAPLN-12: on php82+ also (re)writes php.d/00-mysqlnd.ini, the single owner of extension=mysqlnd.so, which must be scanned before any nd_* consumer. Returns True if at least one .ini was written. """ if int_name != "php": logging.error(u"--driver is only supported for alt-php") return False if driver not in DRIVER_INI: logging.error(u"unknown driver '%s' (use native|binding)" % driver) return False root = get_int_files_root_path(int_name, int_ver) ini_all = os.path.join(root, "etc", "php.d.all") ini_d = os.path.join(root, "etc", "php.d") modules_dir = os.path.join(root, "usr", detect_lib_dir(), "php", "modules") try: ver_num = int(int_ver) except (TypeError, ValueError): ver_num = 0 changed = False failed = False # ALTEAPLN-12: make sure the single mysqlnd owner exists before anything that # needs it. # # ALTEAPLN-55: decide that from the bodies about to be written, not from the # version. php.d gets each alias body minus its extension=mysqlnd.so line # (that is the whole point of the php.d/php.d.all split), so the owner is # needed exactly when the php.d.all body for the selected driver declares # mysqlnd - which is every native selection, because nd_mysqli/nd_pdo_mysql # are mysqlnd-based and ship from php53, and every php82+ binding selection, # because ALTPHP-1626 made the binding mysqli.so mysqlnd-based too. Only # php<82 in binding mode has a plain client-lib mysqli.so that must not load # mysqlnd at all. # # Gating the write on `ver_num >= 82` instead left `--driver native` on # php53-81 with extension=nd_mysqli.so in php.d and nothing loading mysqlnd # ahead of it, so the native/root PHP failed with 'undefined symbol: # mysqlnd_global_stats' and 'Cannot load module "pdo_mysql" because required # module "mysqlnd" is not loaded' - the ALTEAPLN-53 symptom, on the versions # ALTEAPLN-53 did not cover. Reachable through a bare # `alt-php-mysql-reconfigure.py --driver native`, which iterates every # installed version. mysqlnd_path = os.path.join(ini_d, MYSQLND_INI_NAME) # Exact line membership, not a substring test - the substring shortcut is # precisely what broke lvemanager's resolver in ALTEAPLN-53. needs_mysqlnd_owner = any( MYSQLND_EXT_LINE in ini_bodies(driver, ini_name, ver_num)[0].splitlines() for ini_name in DRIVER_INI[driver]) # Never declare a module that is not installed: that would trade one # startup error for another. if not os.path.exists(os.path.join(modules_dir, "mysqlnd.so")): needs_mysqlnd_owner = False if needs_mysqlnd_owner: try: if not os.path.isdir(ini_d): os.makedirs(ini_d) write_it = True if os.path.exists(mysqlnd_path): with open(mysqlnd_path) as fh: write_it = unyield_body(fh.read()) != MYSQLND_INI_BODY if write_it: with open(mysqlnd_path, "w") as fh: fh.write(MYSQLND_INI_BODY) changed = True except (IOError, OSError) as e: logging.error(u"cannot write %s: %s" % (mysqlnd_path, e)) failed = True elif ver_num < 82 and os.path.exists(mysqlnd_path): # Switching a php<82 back to the binding driver: nothing in php.d loads # mysqlnd any more, so leave php.d matching the selected driver exactly. # Only safe below php82, where alt-phpXY-mysql-meta does not package # this file - on php82+ it is %config(noreplace) and must be left alone. try: os.remove(mysqlnd_path) changed = True except OSError as e: logging.error(u"cannot remove %s: %s" % (mysqlnd_path, e)) failed = True for ini_name in sorted(DRIVER_INI[driver]): if driver == "native" and not os.path.exists( os.path.join(modules_dir, NATIVE_SO[ini_name])): logging.error(u"php%s: %s absent, cannot switch %s to native" % (int_ver, NATIVE_SO[ini_name], ini_name)) failed = True continue all_content, active_content = ini_bodies(driver, ini_name, ver_num) all_path = os.path.join(ini_all, ini_name) try: with open(all_path, "w") as fh: fh.write(all_content) except (IOError, OSError) as e: logging.error(u"cannot write %s: %s" % (all_path, e)) failed = True continue # activate in php.d if not os.path.isdir(ini_d): logging.error(u"cannot activate %s: %s is missing" % (ini_name, ini_d)) failed = True continue dst = os.path.join(ini_d, ini_name) try: if os.path.islink(dst) or os.path.exists(dst): os.remove(dst) if active_content == all_content: # bodies agree (php<82): keep the historical symlink, so # nothing that expects php.d to point into php.d.all changes. os.symlink(all_path, dst) else: # ALTEAPLN-53: php82+ needs two different bodies - the php.d.all # one carries extension=mysqlnd.so for the Selector, the php.d # one must not repeat what MYSQLND_INI_NAME already loads. A # symlink cannot express that, so write a real file. with open(dst, "w") as fh: fh.write(active_content) except (IOError, OSError) as e: logging.error(u"cannot activate %s: %s" % (dst, e)) failed = True continue changed = True logging.info(u"php%s: %s -> %s driver" % (int_ver, ini_name, driver)) # fully applied only if something changed and nothing failed return changed and not failed def main(sys_args): try: opts, args = getopt.getopt(sys_args, "p:P:e:d:v", ["php=", "python=", "ea-php=", "driver=", "verbose"]) except getopt.GetoptError as e: sys.stderr.write("cannot parse command line arguments: {0}".format(e)) return 1 verbose = False driver = None int_versions = [] int_name = "php" for opt, arg in opts: if opt in ("-d", "--driver"): driver = arg if opt in ("-p", "--php"): int_name = "php" int_path = "/opt/alt/php%s" % arg if check_alt_path_exists(int_path, int_name, arg): int_versions.append((arg, int_path)) else: return 1 elif opt in ("-e", "--ea-php"): int_name = "ea-php" int_path = "/opt/cpanel/ea-php%s/root/" % arg if check_alt_path_exists(int_path, int_name, arg): int_versions.append((arg, int_path)) else: return 1 elif opt == "--python": int_name = "python" int_path = "/opt/alt/python%s" % arg if check_alt_path_exists(int_path, int_name, arg): int_versions.append((arg, int_path)) else: return 1 if opt in ("-v", "--verbose"): verbose = True log = configure_logging(verbose) if int_name == "ea-php": int_group = int_name else: int_group = "alt-%s" % int_name if not int_versions: int_versions = find_interpreter_versions() log.info(u"installed %s versions are\n%s" % (int_group, "\n".join(["\t %s: %s" % (int_group, i) for i in int_versions]))) # ALTPHP-2242: validate the --driver option first, so a bad command (e.g. a # --driver typo, or --driver with a non-php interpreter) is still reported # even on a frozen system instead of silently exiting 0. if driver: if int_name != "php": log.error(u"--driver is only supported for alt-php, not %s" % int_name) return 1 if driver not in ("native", "binding"): log.error(u"unknown driver '%s' (use native|binding)" % driver) return 1 # ALTPHP-2242: honour the reconfiguration freeze (for --driver too) — after # option validation, before any ini/symlink rewrite below. if os.path.exists("/opt/alt/alt-php-config/disable"): log.info(u"skip reconfiguration, because '/opt/alt/alt-php-config/disable' exists") return 0 # ALTPHP-2598: repair stripped php.d.all bodies before anything reads them. # Runs for every installed version and independently of --driver: the # php<82 alt-phpXY-mysql-meta posttrans calls this tool without one, so a # driver-gated repair would never reach those versions. if int_name == "php": for int_ver, int_dir in int_versions: normalize_chain_ini(int_name, int_ver) # ALTPHP-2242: explicit driver switch (native nd_* <-> client-lib binding). driver_ok = True if driver: for int_ver, int_dir in int_versions: if not set_driver_ini(int_name, int_ver, driver): driver_ok = False if driver == "native": # native extensions load directly, no binding .so symlinks needed yield_all_to_default_ini(int_name, int_versions) return 0 if driver_ok else 1 # driver == "binding": fall through to refresh the binding .so symlinks mysql_so_files = get_mysql_so_files() refresh_ok = True log.info(u"available SQL so files are\n%s" % "\n".join(["\t%s (%s-%s)" % i for i in mysql_so_files])) try: mysql_path = find_mysql_executable() if not mysql_path: log.info(u"cannot find system SQL binary") for int_ver, int_dir in int_versions: status = False for so_name, so_type, so_ver in mysql_so_files: if reconfigure_mysql(int_ver, so_type, so_ver, force=False, int_name=int_name): status = True break if not status: refresh_ok = False log.debug(u"alt-%s%s reconfiguration is failed" % (int_name, int_ver)) else: log.debug(u"system SQL binary path is %s" % mysql_path) mysql_type, mysql_ver = get_mysql_version(mysql_path) log.debug(u"system SQL is %s-%s" % (mysql_type, mysql_ver)) # check if we have .so for the system SQL version mysql_so_exists = False for so_name, so_type, so_ver in mysql_so_files: if so_type == mysql_type and so_ver == mysql_ver: mysql_so_exists = True break # reconfigure alt-php symlinks for int_ver, int_dir in int_versions: # system SQL was correctly detected and we found .so for it - # reconfigure alt-php to use it instead of previous # configuration if (mysql_so_exists or is_bare_plesk()) and \ reconfigure_mysql(int_ver, mysql_type, mysql_ver, force=True, int_name=int_name): ini_src_path = "%s/etc/php.d.all" % get_int_files_root_path(int_name, int_ver) ini_dst_path = "%s/etc/php.d" % get_int_files_root_path(int_name, int_ver) if int_name == "php": create_symlink_to_mysqli_ini(ini_src_path, ini_dst_path, int_ver) continue # we are unable to detect system SQL or it's .so is missing - # reconfigure alt-php to use .so that we have available, but # only if current configuration is broken status = False for so_name, so_type, so_ver in mysql_so_files: if reconfigure_mysql(int_ver, so_type, so_ver, force=False, int_name=int_name): status = True ini_src_path = "%s/etc/php.d.all" % get_int_files_root_path(int_name, int_ver) ini_dst_path = "%s/etc/php.d" % get_int_files_root_path(int_name, int_ver) if int_name == "php": create_symlink_to_mysqli_ini(ini_src_path, ini_dst_path, int_ver) break if not status: refresh_ok = False log.debug(u"alt-%s%s reconfiguration is failed" % (int_name, int_ver)) except Exception as e: log.error(u"cannot reconfigure alt-%s SQL bindings: %s. " u"Traceback:\n%s" % (int_name, e, traceback.format_exc())) return 1 # after every php.d write above, so it sees the final alias files yield_all_to_default_ini(int_name, int_versions) # ALTPHP-2242: propagate --driver binding failures (ini write/activation in # set_driver_ini, or the binding .so symlink refresh above). The plain # no-driver run keeps its historical success exit code. if driver == "binding": return 0 if (driver_ok and refresh_ok) else 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))