#!/usr/bin/env python # -*- mode:python; coding:utf-8; -*- # author: Eugene Zamriy # created: 29.07.2015 15:46 # 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 = 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"} 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() 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 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): return mysql_path 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 = re.search("mysql\s+Ver\s+(.*?Distrib\s+)?((\d+)\.(\d+)\S*?)(,)?" "\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("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 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" break 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) 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] # 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) 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("percona", line, re.IGNORECASE): return "percona", mysql_ver maria_version = re.search("^(10\.[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("5\.5.*?-MariaDB", line, re.IGNORECASE): return "mariadb", "5.5" if re.search("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 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('_ts\.so', '.so', so_name)) else: return os.path.join(int_path, "usr", lib_dir, "php/modules", so_name) elif int_name == "python": return os.path.join(int_path, lib_dir, "python%s" % int_dot_ver, "site-packages", 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 ------- """ if int_name == "ea-php": return ["mysql.so", "mysqli.so", "pdo_mysql.so"] elif int_name == "php": 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 = VER_PATTERNS.values() possible_versions.extend(["10", "10.0", "10.1", "10.2", "10.3", "10.4"]) mysql_type, mysql_ver = get_mysql_version(mysql) 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(ur"(\S*libmysqlclient\.so\.15\.\S*)") elif mysql_ver == "5.1": search_pattern = re.compile(ur"(\S*libmysqlclient\.so\.16\.\S*)") elif mysql_ver in ("5.5", "10", "10.0", "10.1"): search_pattern = re.compile(ur"(\S*libmysqlclient\.so\.18\.0\.\S*)") elif mysql_ver == "5.6": search_pattern = re.compile(ur"(\S*libmysqlclient\.so\.18\.1\.\S*)") elif mysql_ver == "5.7": search_pattern = re.compile(ur"(\S*libmysqlclient\.so\.20\.\S*)") elif mysql_ver == "8.0": search_pattern = re.compile(ur"(\S*libmysqlclient\.so\.21\.\S*)") elif mysql_ver in ("10.2", "10.3", "10.4"): search_pattern = re.compile(ur"(\S*libmariadb\.so\.3\.\S*)") else: raise Exception(u"Cannot match MySQL library to any version") if mysql_type == "percona": search_path = "/%s" % detect_lib_dir() else: search_path = "/usr/%s/" % detect_lib_dir() if mysql_type == "mariadb" or (mysql_type == "mysql" and Decimal(mysql_ver) >= 8.0): if os.path.exists(search_path + "mysql"): search_path += "mysql" elif os.path.exists(search_path + "mariadb"): search_path += "mariadb" else: raise Exception("Detected %s but path for libraries is not found" % mysql_type) files = os.listdir(search_path) for one_file in files: if search_pattern.match(one_file): return (os.path.join(search_path, search_pattern.match(one_file).string), mysql_type, mysql_ver) def get_mysql_so_files(): proc = subprocess.Popen(["/sbin/ldconfig", "-p"], stdout=subprocess.PIPE) out, _ = proc.communicate() if proc.returncode != 0: raise Exception(u"cannot execute \"ldconfig -p\": %s" % out) so_re = re.compile("^.*?=>\s*(\S*?(libmysqlclient|" "libmariadb|" "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 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 == "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 (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.error(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 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): print >> sys.stderr, u"unknown %s version %s" % (int_name, int_ver) return False return True def main(sys_args): try: opts, args = getopt.getopt(sys_args, "p:P:e:v", ["php=", "python=", "ea-php=", "verbose"]) except getopt.GetoptError, e: print >> sys.stderr, \ u"cannot parse command line arguments: %s" % unicode(e) return 1 verbose = False int_versions = [] int_name = "php" for opt, arg in opts: 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]))) mysql_so_files = get_mysql_so_files() 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: log.error(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 and \ reconfigure_mysql(int_ver, mysql_type, mysql_ver, force=True, int_name=int_name): 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 break if not status: log.error(u"alt-%s%s reconfiguration is failed" % (int_name, int_ver)) except Exception, e: log.error(u"cannot reconfigure alt-%s SQL bindings: %s. " u"Traceback:\n%s" % (int_name, unicode(e), traceback.format_exc())) return 1 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))