
­­­­­­­­­­­­­­­­­­
<!DOCTYPE html>
<html>
#!/usr/libexec/platform-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:]))
