Your IP : 216.73.216.215


Current Path : /proc/3/root/opt/dedrads/
Upload File :
Current File : //proc/3/root/opt/dedrads/mysql_selector.py

#!/usr/bin/env python3


import sys, os, re, json, shlex, subprocess, datetime, time, socket, glob, shutil
import logging
from shutil import which

TARGETS = {
    "mariadb1011": ("mariadb", "10.11"),
    "mariadb106": ("mariadb", "10.6"),
    "mariadb114": ("mariadb", "11.4"),
    "mysql80": ("mysql", "8.0"),
    "mysql84": ("mysql", "8.4"),
}

CPANEL_CONFIG = "/var/cpanel/cpanel.config"
DATADIR = "/var/lib/mysql"
BACKUP_ROOT = "/root"
ROOT_MYCNF = "/root/.my.cnf"
ETC_MYCNF = "/etc/my.cnf"
LOG_FILE_DEFAULT = None  # initialized after ts() is defined
DRY_RUN = False
FORCE_DELETE = False
MAX_ALLOWED_PACKET = "512M"
ORIGINAL_MAX_ALLOWED_PACKET = None
_RPM_CACHE = None
_DPKG_CACHE = None
_OS_INFO = None

MYSQL_TO_MARIADB_COLLATION_MAP = {
    "utf8mb4_0900_ai_ci": "utf8mb4_unicode_ci",
    "utf8mb4_0900_as_ci": "utf8mb4_unicode_ci",
    "utf8mb4_0900_as_cs": "utf8mb4_bin",
    "utf8mb4_0900_bin": "utf8mb4_bin",
}

PROTECTED_DEB_PACKAGE_PREFIXES = ("cpanel-", "ea-")
PROTECTED_RPM_PACKAGE_PREFIXES = ("cpanel-", "ea-")
MYSQL_DEB_PACKAGE_REGEX = (
    r"^(mysql-server|mysql-client|mysql-common|mysql-community-server|"
    r"mysql-community-client|mysql-community-client-core|"
    r"mysql-community-server-core|mysql-apt-config)(?:$|-)"
)
MARIADB_DEB_PACKAGE_REGEX = (
    r"^(mariadb-server|mariadb-client|mariadb-common|mariadb-backup|"
    r"galera-[0-9]+|socat)(?:$|-)"
)
MYSQL_RPM_PACKAGE_REGEX = (
    r"^(mysql|mysql-community|mysql[0-9]|mysql[0-9]+-community)(?:$|-)"
)
MARIADB_RPM_PACKAGE_REGEX = (
    r"^(mariadb|MariaDB|galera|socat|boost-.*mariadb)(?:$|-)"
)


def show_help(prog):
    """Display detailed usage guidance."""
    help_text = f"""mysql_selector.py - Install or switch MySQL/MariaDB on cPanel (AlmaLinux 8+ / Ubuntu 24+)

Usage:
  {prog} [--dry-run] [--force] [--log-file=/path/to/log] <target>

Targets (key -> engine/version):
  mariadb1011 -> MariaDB 10.11 (LTS)
  mariadb106  -> MariaDB 10.6
  mariadb114  -> MariaDB 11.4
  mysql80     -> MySQL 8.0
  mysql84     -> MySQL 8.4

What this script does:
  - Verifies the host is AlmaLinux 8+ or Ubuntu 24+.
  - Backs up all non-system databases to {BACKUP_ROOT}/mysql-backup-<timestamp>.
  - Stops existing MySQL/MariaDB services and removes conflicting packages.
  - Sets the requested version in cPanel config, then triggers WHM's installer.
  - Rewrites MySQL 8-only utf8mb4_0900 collations during MySQL-to-MariaDB restores.
  - Falls back to manual installs for supported RPM versions if WHM install fails.
  - Ensures /root/.my.cnf credentials work and restores databases + grants.
  - Writes a log to {LOG_FILE_DEFAULT or "<default>"} (or a custom --log-file).

Expectations and prerequisites:
  - Run as root on a cPanel server (whmapi1 required for the primary path).
  - /root/.my.cnf must contain working root credentials; backups are not deleted.
  - Sufficient free space (>5% headroom) is required on {BACKUP_ROOT} for backups.

Options:
  --dry-run          Skip destructive commands (package removals, globs) and prompt before deletion.
  --force            Proceed with deletions/removals without prompting (overrides prompts).
  --log-file=PATH    Write log output to PATH instead of the default in /root.
  -h, --help         Show this help.

Exit codes:
  0 on success, non-zero on error.

Examples:
  {prog} mariadb114
  {prog} mysql84
"""
    log(help_text)


def ts():
    return datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S")


LOG_FILE_DEFAULT = f"/root/mysql_selector-{ts()}.log"
LOGGER = logging.getLogger("mysql_selector")


def init_logging(log_file=None):
    """Initialize logging to both stderr and a file."""
    global LOG_FILE_DEFAULT
    target = log_file or LOG_FILE_DEFAULT
    handlers = [logging.StreamHandler()]
    try:
        handlers.append(logging.FileHandler(target))
        LOG_FILE_DEFAULT = target
    except Exception as e:
        handlers[0].setLevel(logging.INFO)
        logging.basicConfig(
            level=logging.INFO,
            format="%(message)s",
            handlers=handlers,
            force=True,
        )
        LOGGER.warning("[!] Unable to open log file %s: %s", target, e)
        return
    logging.basicConfig(
        level=logging.INFO,
        format="%(message)s",
        handlers=handlers,
        force=True,
    )


def log(message="", *args):
    """Log script output through the configured logger."""
    LOGGER.info(message, *args)


def get_os_info():
    """Read /etc/os-release once and return normalized OS metadata."""
    global _OS_INFO
    if _OS_INFO is not None:
        return _OS_INFO
    try:
        info = {}
        with open("/etc/os-release") as f:
            for line in f:
                if "=" in line:
                    k, v = line.strip().split("=", 1)
                    info[k] = v.strip('"')
    except Exception:
        info = {}
    name = info.get("NAME", "")
    os_id = info.get("ID", "").lower()
    version = info.get("VERSION_ID", "")
    try:
        major = int(version.split(".")[0])
    except (ValueError, IndexError):
        major = 0
    _OS_INFO = {
        "name": name,
        "id": os_id,
        "version": version,
        "major": major,
    }
    return _OS_INFO


def is_rpm_os():
    return get_os_info()["id"] in ("almalinux", "rocky", "centos", "rhel")


def is_deb_os():
    return get_os_info()["id"] in ("ubuntu", "debian")


def check_os_support():
    """Ensure we are running on a supported cPanel OS family."""
    os_info = get_os_info()
    os_id = os_info["id"]
    version = os_info["version"]
    major = os_info["major"]

    if os_id == "almalinux":
        if major < 8:
            log(
                "[!] AlmaLinux version {} detected. Version 8 or newer required.".format(
                    version or "<unknown>"
                )
            )
            sys.exit(1)
        return

    if os_id == "ubuntu":
        if major < 24:
            log(
                "[!] Ubuntu version {} detected. Version 24.04 or newer required.".format(
                    version or "<unknown>"
                )
            )
            sys.exit(1)
        return

    log(
        "[!] This script supports only AlmaLinux 8+ or Ubuntu 24+ cPanel systems."
    )
    sys.exit(1)

def run(cmd, check=True, destructive=False):
    if DRY_RUN and destructive:
        log(f"[dry-run] Skipping command: {cmd}")
        return 0
    if isinstance(cmd, str):
        cmd = shlex.split(cmd)
    log("+ " + shlex.join(cmd))
    p = subprocess.run(cmd)
    if check and p.returncode != 0:
        sys.exit(p.returncode)
    return p.returncode


def out(cmd):
    try:
        if isinstance(cmd, str):
            cmd = shlex.split(cmd)
        return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode(
            "utf-8", "replace"
        )
    except subprocess.CalledProcessError as e:
        return (e.output or b"").decode("utf-8", "replace")
    except FileNotFoundError:
        return ""


def is_mysql_client_noise(line):
    """Return True for client compatibility warnings mixed into command output."""
    line = line.strip()
    return (
        "Deprecated program name" in line
        and "use '/usr/bin/mariadb' instead" in line
    )


def run_background(cmd):
    """Run a command in the background, suppressing stdout/stderr, and return the Popen object."""
    if isinstance(cmd, str):
        cmd = shlex.split(cmd)
    if DRY_RUN:
        log(f"[dry-run] Skipping background command: {cmd}")
        return None
    log("+ " + shlex.join(cmd) + " (background)")
    try:
        return subprocess.Popen(
            cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
        )
    except Exception as e:
        log(f"[!] Failed to start background command {cmd}: {e}")
        return None


def installed_family():
    pkgs = "\n".join(installed_packages())
    if re.search(r"\bmysql-community-server\b", pkgs, re.I) or re.search(
        r"\bmysql-server\b", pkgs, re.I
    ):
        return "mysql"
    if re.search(r"\b(mariadb-server|MariaDB-server)\b", pkgs, re.I):
        return "mariadb"
    return "none"


def ping_mysql():
    mysqladmin = which("mysqladmin")
    if not mysqladmin:
        return False
    rc = run([mysqladmin, "ping"], check=False)
    return rc == 0


def ensure_dir(d):
    if not os.path.isdir(d):
        os.makedirs(d, exist_ok=True)


def remove_matches(patterns, force=None):
    """Remove files/dirs matching glob patterns without invoking a shell."""
    force = FORCE_DELETE if force is None else force
    matches = []
    for pat in patterns:
        matches.extend(glob.glob(pat))
    matches = sorted(set(matches))
    if not matches:
        return
    log("[*] Paths matched for removal:")
    for path in matches:
        log(f"    {path}")
    if DRY_RUN:
        log("[dry-run] Skipping deletion of matched paths.")
        return
    if not force:
        log("[?] Remove these paths? [y/N]: ")
        ans = input().strip().lower()
        if ans not in ("y", "yes"):
            log("[!] Skipping removal by user request.")
            return
    for path in matches:
        try:
            if os.path.isdir(path) and not os.path.islink(path):
                shutil.rmtree(path, ignore_errors=True)
            else:
                os.remove(path)
        except FileNotFoundError:
            continue
        except Exception as e:
            log(f"[!] Unable to remove {path}: {e}")


def rpm_packages_matching(regex, refresh=False):
    """Return rpm -qa entries matching regex (case-insensitive)."""
    global _RPM_CACHE
    if refresh or _RPM_CACHE is None:
        _RPM_CACHE = out(["rpm", "-qa"]).splitlines()
    pattern = re.compile(regex, re.I)
    return [p for p in _RPM_CACHE if pattern.search(p)]


def invalidate_rpm_cache():
    """Clear cached rpm -qa results after package changes."""
    global _RPM_CACHE
    _RPM_CACHE = None


def dpkg_packages_matching(regex, refresh=False):
    """Return installed dpkg package names matching regex (case-insensitive)."""
    global _DPKG_CACHE
    if refresh or _DPKG_CACHE is None:
        packages = []
        raw = out(
            [
                "dpkg-query",
                "-W",
                "-f=${binary:Package}\t${db:Status-Abbrev}\n",
            ]
        )
        for line in raw.splitlines():
            parts = line.split()
            if len(parts) >= 2 and parts[-1].startswith("ii"):
                packages.append(parts[0].split(":")[0])
        _DPKG_CACHE = sorted(set(packages))
    pattern = re.compile(regex, re.I)
    return [p for p in _DPKG_CACHE if pattern.search(p)]


def invalidate_dpkg_cache():
    """Clear cached dpkg package list after package changes."""
    global _DPKG_CACHE
    _DPKG_CACHE = None


def installed_packages(refresh=False):
    if is_deb_os():
        return dpkg_packages_matching(r".*", refresh=refresh)
    return rpm_packages_matching(r".*", refresh=refresh)


def packages_matching(regex, refresh=False):
    if is_deb_os():
        return dpkg_packages_matching(regex, refresh=refresh)
    return rpm_packages_matching(regex, refresh=refresh)


def is_protected_deb_package(package):
    return package.startswith(PROTECTED_DEB_PACKAGE_PREFIXES)


def is_protected_rpm_package(package):
    return package.startswith(PROTECTED_RPM_PACKAGE_PREFIXES)


def filter_protected_deb_packages(packages):
    protected = [pkg for pkg in packages if is_protected_deb_package(pkg)]
    if protected:
        log(
            "[!] Refusing to remove protected cPanel/EA packages: {}".format(
                " ".join(protected)
            )
        )
    return [pkg for pkg in packages if pkg not in protected]


def filter_protected_rpm_packages(packages):
    protected = [pkg for pkg in packages if is_protected_rpm_package(pkg)]
    if protected:
        log(
            "[!] Refusing to remove protected cPanel/EA packages: {}".format(
                " ".join(protected)
            )
        )
    return [pkg for pkg in packages if pkg not in protected]


def apt_planned_removals(args):
    raw = out(["apt-get", "-s"] + args)
    removals = []
    for line in raw.splitlines():
        match = re.match(r"^(?:Remv|Purg)\s+(\S+)", line)
        if match:
            removals.append(match.group(1).split(":")[0])
    return sorted(set(removals))


def apt_plan_has_protected_removals(args):
    removals = apt_planned_removals(args)
    protected = [pkg for pkg in removals if is_protected_deb_package(pkg)]
    if protected:
        log(
            "[!] Refusing apt operation because it would remove protected "
            "packages: {}".format(" ".join(protected))
        )
        return True
    return False


def dnf_planned_removals(args):
    raw = out(["dnf", "--assumeno"] + args)
    removals = []
    in_removal_section = False
    for line in raw.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith(
            ("Installing", "Upgrading", "Downgrading", "Reinstalling")
        ):
            in_removal_section = False
            continue
        if stripped.startswith("Removing"):
            in_removal_section = True
            continue
        if stripped.startswith("Transaction Summary"):
            in_removal_section = False
            continue
        if not in_removal_section:
            continue
        if (
            stripped.startswith("=")
            or stripped.startswith("-")
            or stripped.startswith("Package ")
            or stripped.startswith("Arch ")
        ):
            continue
        package = stripped.split()[0]
        if package:
            removals.append(package)
    return sorted(set(removals))


def dnf_plan_has_protected_removals(args):
    removals = dnf_planned_removals(args)
    protected = [pkg for pkg in removals if is_protected_rpm_package(pkg)]
    if protected:
        log(
            "[!] Refusing dnf operation because it would remove protected "
            "packages: {}".format(" ".join(protected))
        )
        return True
    return False


def invalidate_package_cache():
    invalidate_rpm_cache()
    invalidate_dpkg_cache()


def rpm_force_remove(packages):
    packages = filter_protected_rpm_packages(packages)
    if not packages:
        return
    run(
        ["rpm", "-e", "--nodeps"] + packages,
        check=False,
        destructive=True,
    )
    invalidate_package_cache()


def package_remove(packages):
    if not packages:
        return
    if is_deb_os():
        packages = filter_protected_deb_packages(packages)
        if not packages:
            return
        apt_args = ["-y", "purge"] + packages
        if apt_plan_has_protected_removals(apt_args):
            return
        run(
            ["apt-get"] + apt_args,
            check=False,
            destructive=True,
        )
    else:
        packages = filter_protected_rpm_packages(packages)
        if not packages:
            return
        dnf_args = ["remove"] + packages
        if dnf_plan_has_protected_removals(dnf_args):
            return
        run(["dnf", "-y"] + dnf_args, check=False, destructive=True)
    invalidate_package_cache()


def package_autoremove():
    if is_deb_os():
        apt_args = ["-y", "autoremove", "--purge"]
        if apt_plan_has_protected_removals(apt_args):
            return
        run(
            ["apt-get"] + apt_args,
            check=False,
            destructive=True,
        )
    else:
        dnf_args = ["autoremove"]
        if dnf_plan_has_protected_removals(dnf_args):
            return
        run(["dnf", "-y"] + dnf_args, check=False, destructive=True)
    invalidate_package_cache()


def package_clean():
    if is_deb_os():
        run(["apt-get", "clean"], check=False)
    else:
        run(["dnf", "clean", "all"], check=False)


def estimate_datadir_size():
    """Return total size of DATADIR in bytes using du -sb."""
    try:
        output = subprocess.check_output(
            ["du", "-sb", DATADIR], stderr=subprocess.STDOUT
        ).decode("utf-8", "replace")
        size_str = output.strip().split()[0]
        return int(size_str)
    except subprocess.CalledProcessError as e:
        log(
            f"[!] Unable to measure {DATADIR} size: {e.output.decode('utf-8', 'replace')}"
        )
    except Exception as e:
        log(f"[!] Unable to measure {DATADIR} size: {e}")
    return None


def has_sufficient_space_for_backup():
    """Ensure creating a backup won't exceed 95% capacity on the backup filesystem."""
    try:
        stat = os.statvfs(BACKUP_ROOT)
    except Exception as e:
        log(
            f"[!] Could not determine filesystem stats for {BACKUP_ROOT}: {e}"
        )
        return False
    total = stat.f_blocks * stat.f_frsize
    avail = stat.f_bavail * stat.f_frsize
    datadir_size = estimate_datadir_size()
    if datadir_size is None:
        log("[!] Unable to determine MySQL data size; refusing to continue.")
        return False
    min_free = total * 0.05  # require 5% free space remaining
    projected_free = avail - datadir_size
    log(
        f"[i] Backup filesystem total: {total / (1024**3):.2f} GB, free: {avail / (1024**3):.2f} GB, datadir size: {datadir_size / (1024**3):.2f} GB"
    )
    if projected_free < min_free:
        log(
            f"[!] Not enough free space: projected free {projected_free / (1024**3):.2f} GB would drop below 5% of capacity."
        )
        return False
    return True


def backup_all(rootdir):
    """Dump all non-system databases and copy cPanel metadata if present.
    Returns (backup_directory, status) where status in {"ok","empty","error"}.
    """
    ensure_dir(rootdir)
    bdir = os.path.join(rootdir, "mysql-backup-" + ts())
    os.makedirs(bdir, exist_ok=True)
    log(f"[*] Backing up to: {bdir}")
    status = "ok"

    if not ping_mysql():
        log("[!] MySQL server is not running, cannot perform backup!")
        return bdir, "error"

    if not which("mysql") or not which("mysqldump"):
        log(
            "[!] mysql or mysqldump command not found, skipping database backup"
        )
        return bdir, "error"

    user_dbs = []
    try:
        # Get list of all databases
        log("[*] Getting list of databases...")
        dbs_cmd = [which("mysql"), "-NBe", "SHOW DATABASES"]
        dbs = []
        for line in out(dbs_cmd).splitlines():
            if is_mysql_client_noise(line):
                log(f"[i] Ignoring MySQL client warning: {line.strip()}")
                continue
            dbs.append(line)

        if not dbs:
            log("[*] No databases found to back up")
        else:
            # Filter out system databases
            system_dbs = {
                'mysql',
                'information_schema',
                'performance_schema',
                'sys',
            }
            user_dbs = [
                db.strip()
                for db in dbs
                if db.strip() and db.strip() not in system_dbs
            ]

        if not user_dbs:
            log("[*] No user databases found to back up")
            return bdir, "empty"
        else:
            log(
                "[*] Backing up user databases: {}".format(", ".join(user_dbs))
            )

            # Backup each database separately for better reliability
            for db in user_dbs:
                try:
                    backup_file = os.path.join(bdir, f"{db}.sql")
                    log(f"[*] Backing up database: {db}")

                    # Build the mysqldump command
                    dump_cmd = [
                        which("mysqldump"),
                        "--defaults-file=/root/.my.cnf",
                        "--single-transaction",
                        "--quick",
                        "--routines",
                        "--events",
                        "--triggers",
                        "--hex-blob",
                        db,
                    ]

                    # Execute the command and write output to file
                    with open(backup_file, 'w') as f:
                        subprocess.run(dump_cmd, stdout=f, check=True)

                    log(f"[+] Successfully backed up {db} to {backup_file}")

                except subprocess.CalledProcessError as e:
                    log(f"[!] Error backing up database {db}: {e}")
                    status = "error"
                    if os.path.exists(backup_file):
                        os.remove(backup_file)
                except Exception as e:
                    log(
                        f"[!] Unexpected error backing up database {db}: {str(e)}"
                    )
                    status = "error"
                    if os.path.exists(backup_file):
                        os.remove(backup_file)

    except Exception as e:
        log(f"[!] Error during backup process: {str(e)}")
        status = "error"

    # Backup cPanel databases configuration
    if os.path.isdir("/var/cpanel/databases"):
        run(
            [
                "/usr/bin/rsync",
                "-aH",
                "/var/cpanel/databases/",
                os.path.join(bdir, "cpanel_databases"),
            ],
            check=False,
        )

    return bdir, status


def mariadb_compatible_collation(collation):
    """Map MySQL-only utf8mb4 collations to MariaDB-compatible collations."""
    lowered = collation.lower()
    if lowered in MYSQL_TO_MARIADB_COLLATION_MAP:
        return MYSQL_TO_MARIADB_COLLATION_MAP[lowered]
    if lowered.startswith("utf8mb4_0900_"):
        if lowered.endswith("_bin") or lowered.endswith("_cs"):
            return "utf8mb4_bin"
        return "utf8mb4_unicode_ci"
    return collation


def normalize_dump_collations_for_mariadb(sql_path):
    """Create a MariaDB-compatible copy of a MySQL dump if collation rewrites are needed."""
    pattern = re.compile(r"\butf8mb4_0900_[A-Za-z0-9_]+", re.I)
    tmp_path = sql_path + ".mariadb-collation-fix.tmp"
    replacements = {}
    try:
        with open(sql_path, "r", encoding="utf-8", errors="replace") as src:
            if not any(pattern.search(line) for line in src):
                return sql_path

        with open(sql_path, "r", encoding="utf-8", errors="replace") as src, open(
            tmp_path, "w", encoding="utf-8"
        ) as dst:
            for line in src:
                def repl(match):
                    original = match.group(0)
                    replacement = mariadb_compatible_collation(original)
                    if replacement != original:
                        replacements[(original, replacement)] = (
                            replacements.get((original, replacement), 0) + 1
                        )
                    return replacement

                dst.write(pattern.sub(repl, line))
    except Exception as e:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except Exception:
            pass
        log(f"[!] Unable to normalize collations in {sql_path}: {e}")
        return sql_path

    log("[*] Rewrote MySQL-only collations for MariaDB import:")
    for (original, replacement), count in sorted(replacements.items()):
        log(f"    {original} -> {replacement} ({count})")
    return tmp_path


def restore_all_databases(backup_dir, fix_mysql_to_mariadb_collations=False):
    """Restore all SQL dumps found in backup_dir (user DBs only). Returns True on success."""
    if not backup_dir or not os.path.isdir(backup_dir):
        log("[!] Backup directory missing or invalid, skipping restore")
        return False
    set_server_max_allowed_packet(MAX_ALLOWED_PACKET)
    if not wait_for_mysql_ready(timeout=600, interval=5, require_auth=True):
        log(
            "[!] MySQL/MariaDB service is not ready; cannot restore databases"
        )
        return False
    mysql_bin = which("mysql")
    if not mysql_bin:
        log("[!] mysql client not found, skipping restore")
        return False
    sql_files = sorted(
        [f for f in os.listdir(backup_dir) if f.endswith(".sql")]
    )
    if not sql_files:
        log(f"[*] No SQL dumps found to restore in {backup_dir}")
        return True
    restore_ok = True
    for fname in sql_files:
        db = os.path.splitext(fname)[0]
        if db in ("mysql", "sys"):
            continue
        fpath = os.path.join(backup_dir, fname)
        log(f"[*] Restoring database: {db} from {fpath}")
        import_path = fpath
        temp_import_path = None
        try:
            if fix_mysql_to_mariadb_collations:
                import_path = normalize_dump_collations_for_mariadb(fpath)
                if import_path != fpath:
                    temp_import_path = import_path
            # Ensure the database exists before import
            create_stmt = f"CREATE DATABASE IF NOT EXISTS `{db}` DEFAULT CHARACTER SET utf8mb4"
            if fix_mysql_to_mariadb_collations:
                create_stmt += " COLLATE utf8mb4_unicode_ci"
            run(
                [
                    mysql_bin,
                    "--defaults-file=/root/.my.cnf",
                    f"--max_allowed_packet={MAX_ALLOWED_PACKET}",
                    "-e",
                    create_stmt,
                ],
                check=False,
            )
            with open(import_path, encoding="utf-8", errors="replace") as f:
                subprocess.run(
                    [
                        mysql_bin,
                        "--defaults-file=/root/.my.cnf",
                        f"--max_allowed_packet={MAX_ALLOWED_PACKET}",
                        db,
                    ],
                    stdin=f,
                    check=True,
                )
            log(f"[+] Restored {db}")
        except subprocess.CalledProcessError as e:
            log(f"[!] Failed to restore {db}: {e}")
            restore_ok = False
        finally:
            if temp_import_path:
                try:
                    os.remove(temp_import_path)
                except Exception as e:
                    log(
                        f"[!] Unable to remove temporary import file {temp_import_path}: {e}"
                    )
    # Restore cPanel database metadata if present
    cp_src = os.path.join(backup_dir, "cpanel_databases")
    if os.path.isdir(cp_src):
        run(
            ["/usr/bin/rsync", "-aH", cp_src + "/", "/var/cpanel/databases/"],
            check=False,
        )
        log("[*] Restored /var/cpanel/databases metadata")
    return restore_ok


def stop_db():
    run(["systemctl", "stop", "mysqld"], check=False)
    run(["systemctl", "stop", "mysql"], check=False)
    run(["systemctl", "stop", "mariadb"], check=False)


def park_datadir():
    if os.path.isdir(DATADIR) and not os.path.islink(DATADIR):
        parked = DATADIR + ".off-" + ts()
        log(f"[*] Parking datadir: {DATADIR} -> {parked}")
        run(["mv", DATADIR, parked], check=True, destructive=True)


def get_current_mysql_version():
    try:
        output = out(["mysql", "--version"])
        if not output:
            return None
        # Extract version number (e.g., 'mysql  Ver 8.0.33 for Linux on x86_64' -> '8.0')
        match = re.search(r'(?:Ver )?(\d+\.\d+)', output)
        if match:
            return match.group(1)
    except Exception as e:
        log(f"[!] Could not determine current MySQL version: {e}")
    return None


def remove_mysql_family():
    log("[*] Stopping MySQL service and parking datadir...")
    stop_db()
    park_datadir()

    log("[*] Removing MySQL package manager configuration...")
    if is_deb_os():
        remove_matches(
            [
                "/etc/apt/sources.list.d/mysql*.list",
                "/etc/apt/sources.list.d/mysql*.sources",
                "/etc/apt/trusted.gpg.d/mysql*.gpg",
            ]
        )
    else:
        run(["dnf", "-y", "module", "reset", "mysql"], check=False)
        run(["dnf", "-y", "module", "disable", "mysql"], check=False)
        remove_matches(
            [
                "/etc/yum.repos.d/mysql*.repo",
                "/etc/yum.repos.d/mysql*-community*.repo",
                "/etc/yum/repos.d/mysql*.repo",
            ]
        )

    log("[*] Removing MySQL packages...")
    # Find all MySQL packages
    mysql_regex = (
        MYSQL_DEB_PACKAGE_REGEX
        if is_deb_os()
        else MYSQL_RPM_PACKAGE_REGEX
    )
    mysql_pkgs = packages_matching(mysql_regex)
    if mysql_pkgs:
        log(f"[*] Found MySQL packages to remove: {' '.join(mysql_pkgs)}")
        package_remove(mysql_pkgs)
        # Then force remove any remaining RPM packages
        remaining = packages_matching(mysql_regex, refresh=True)
        if remaining and is_rpm_os():
            log(
                f"[*] Force removing remaining MySQL packages: {' '.join(remaining)}"
            )
            rpm_force_remove(remaining)

    # Additional cleanup for common MySQL-related packages
    if is_rpm_os():
        extra_pkgs = packages_matching(
            r"^(mysql-community|mysql80-community|mysql84-community|numactl-libs)",
            refresh=True,
        )
        if extra_pkgs:
            package_remove(extra_pkgs)

    # Clean up any remaining MySQL files and directories
    log("[*] Cleaning up MySQL files and directories...")
    remove_matches(
        [
            "/var/lib/mysql*",
            "/var/run/mysql*",
            "/etc/my.cnf*",
            "/etc/mysql*",
            "/usr/lib64/mysql*",
            "/usr/lib/mysql*",
            "/usr/share/mysql*",
        ]
    )

    # Clean up any remaining dependencies
    package_autoremove()
    package_clean()

    log("[*] MySQL removal completed")


def remove_mariadb_family():
    log("[*] Stopping MariaDB service and parking datadir...")
    stop_db()
    park_datadir()

    log("[*] Removing MariaDB package manager configuration...")
    if is_deb_os():
        remove_matches(
            [
                "/etc/apt/sources.list.d/mariadb*.list",
                "/etc/apt/sources.list.d/mariadb*.sources",
                "/etc/apt/trusted.gpg.d/mariadb*.gpg",
                "/etc/apt/sources.list.d/mysql*.list",
                "/etc/apt/sources.list.d/mysql*.sources",
            ]
        )
    else:
        run(["dnf", "-y", "module", "reset", "mariadb"], check=False)
        run(["dnf", "-y", "module", "disable", "mariadb"], check=False)
        remove_matches(
            [
                "/etc/yum.repos.d/MariaDB*.repo",
                "/etc/yum/repos.d/mariadb*.repo",
                "/etc/yum.repos.d/mysql*.repo",
            ]
        )

    log("[*] Removing all MySQL and MariaDB packages...")
    # First, try to remove everything using the OS package manager
    db_regex = (
        f"(?:{MYSQL_DEB_PACKAGE_REGEX}|{MARIADB_DEB_PACKAGE_REGEX})"
        if is_deb_os()
        else f"(?:{MYSQL_RPM_PACKAGE_REGEX}|{MARIADB_RPM_PACKAGE_REGEX})"
    )
    pkg_list = packages_matching(db_regex, refresh=True)
    if pkg_list:
        package_remove(pkg_list)

    # Force remove any remaining RPM packages
    remaining_pkgs = packages_matching(db_regex, refresh=True)
    if remaining_pkgs and is_rpm_os():
        log(
            f"[*] Found remaining packages to remove: {' '.join(remaining_pkgs)}"
        )
        rpm_force_remove(remaining_pkgs)

    log("[*] Cleaning up configuration files and directories...")
    # Clean up all MySQL/MariaDB related files and directories
    remove_matches(
        [
            "/var/lib/mysql*",
            "/var/run/mariadb*",
            "/var/run/mysqld*",
            "/etc/my.cnf*",
            "/etc/mysql*",
            "/etc/mariadb*",
            "/usr/lib64/mysql*",
            "/usr/lib/mysql*",
            "/usr/share/mysql*",
            "/usr/bin/mysql*",
            "/usr/sbin/mysql*",
        ]
    )

    # Clean up any remaining dependencies and clear cache
    package_autoremove()
    package_clean()

    log("[*] MariaDB/MySQL removal completed")


def ensure_perl_mysql_modules():
    """Install required Perl modules used by cPanel DB tooling."""
    # Use cPanel tooling to satisfy bundled Perl modules.
    check_pkgs = "/usr/local/cpanel/scripts/check_cpanel_pkgs"
    if os.path.exists(check_pkgs):
        log("[*] Ensuring cPanel Perl modules via check_cpanel_pkgs --fix")
        run([check_pkgs, "--fix"], check=False)
    else:
        log(
            "[!] /usr/local/cpanel/scripts/check_cpanel_pkgs not found; skipping Perl module fix"
        )


def set_cpanel_mysql_version(ver):
    log(
        "[*] Setting mysql-version={} in {} (Python write)".format(
            ver, CPANEL_CONFIG
        )
    )
    try:
        run(
            ["/bin/cp", "-a", CPANEL_CONFIG, CPANEL_CONFIG + ".bak-" + ts()],
            check=False,
        )
    except Exception:
        pass
    try:
        with open(CPANEL_CONFIG) as f:
            lines = f.read().splitlines(True)
    except Exception:
        lines = []

    found = False
    new_lines = []
    for line in lines:
        if line.strip().startswith("mysql-version="):
            new_lines.append(f"mysql-version={ver}\n")
            found = True
        else:
            new_lines.append(line)

    if not found:
        if new_lines and not new_lines[-1].endswith("\n"):
            new_lines[-1] = new_lines[-1] + "\n"
        new_lines.append(f"mysql-version={ver}\n")

    tmp = CPANEL_CONFIG + ".tmp"
    with open(tmp, "w") as f:
        f.writelines(new_lines)
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, CPANEL_CONFIG)
    log(f"[+] mysql-version set to {ver}")
    # verify
    cur = ""
    try:
        with open(CPANEL_CONFIG) as f:
            for l in f:
                if l.strip().startswith("mysql-version="):
                    cur = l.strip()
                    break
    except Exception:
        cur = "<unreadable>"
    log("[=] [{}] -> {}".format(CPANEL_CONFIG, cur or "<missing>"))


def whm_allowed():
    raw = out(
        [
            "/usr/sbin/whmapi1",
            "start_background_mysql_upgrade",
            "version=__probe__",
            "--output=json",
        ]
    )
    allowed = []
    try:
        j = json.loads(raw)
        reason = (j.get("metadata") or {}).get("reason", "")
    except Exception:
        reason = raw
    vers = re.findall(r"[“\"]([0-9]+\.[0-9]+)[”\"]", reason)
    if vers:
        seen = set()
        allowed = [v for v in vers if not (v in seen or seen.add(v))]
    return allowed


def whm_start_upgrade(ver):
    log("[*] Triggering WHM MySQL/MariaDB install/upgrade …")
    # Remove --allow-downgrade as it's not a valid option
    rc = run(
        [
            "/usr/sbin/whmapi1",
            "start_background_mysql_upgrade",
            f"version={ver}",
        ],
        check=False,
    )
    if rc != 0:
        log("[!] WHM did not accept the request.")
        return False
    log("=== TRIGGERED ===")
    log("Track with: whmapi1 get_mysql_upgrade --output=json|yaml")
    log(
        "If datadir was parked, restore **after** engine is up, then run: /usr/local/cpanel/bin/restoregrants --force"
    )
    return True


def wait_for_mysql_ready(timeout=1800, interval=10, require_auth=False):
    """Wait until MySQL responds to mysqladmin ping.
    If require_auth=False, treat 'Access denied' as service-ready."""
    start = time.time()
    while time.time() - start < timeout:
        mysqladmin = which("mysqladmin")
        if not mysqladmin:
            time.sleep(interval)
            continue
        proc = subprocess.run(
            [mysqladmin, "ping"],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
        )
        if proc.returncode == 0:
            return True
        output = proc.stdout.decode("utf-8", "replace")
        if not require_auth and "Access denied" in output:
            log(
                "[i] MySQL service responding but root authentication failed; continuing."
            )
            return True
        time.sleep(interval)
    return False


def install_with_cpanel_api(target_ver):
    log(f"[*] Requesting MySQL/MariaDB {target_ver} via cPanel API...")
    if not which("whmapi1"):
        log("[!] whmapi1 not found; cannot use cPanel API installer")
        return False
    if not whm_start_upgrade(target_ver):
        return False
    log("[*] Waiting for database service to come online...")
    if wait_for_mysql_ready():
        log("[+] Database service is online after API request")
        return True
    log(
        "[!] Database service did not come online in expected time; check whmapi1 get_mysql_upgrade"
    )
    return False


def mysql_size_to_bytes(value):
    """Convert MySQL client size suffixes to bytes for SET GLOBAL statements."""
    match = re.match(r"^\s*(\d+)\s*([KkMmGg])?\s*$", str(value))
    if not match:
        return value
    size = int(match.group(1))
    suffix = (match.group(2) or "").upper()
    multiplier = {
        "": 1,
        "K": 1024,
        "M": 1024**2,
        "G": 1024**3,
    }[suffix]
    return str(size * multiplier)


def set_server_max_allowed_packet(packet_size):
    """Raise server/client max_allowed_packet before imports."""
    global ORIGINAL_MAX_ALLOWED_PACKET

    def _current_packet():
        mysql_bin_inner = which("mysql")
        if not mysql_bin_inner or not os.path.exists(ROOT_MYCNF):
            return None
        try:
            raw = out(
                [
                    mysql_bin_inner,
                    "--defaults-file=" + ROOT_MYCNF,
                    "-NBe",
                    "SHOW VARIABLES LIKE 'max_allowed_packet'",
                ]
            )
            parts = raw.strip().split()
            if parts:
                return parts[-1]
        except Exception:
            return None
        return None

    mysql_bin = which("mysql")
    if not mysql_bin or not os.path.exists(ROOT_MYCNF):
        return
    if ORIGINAL_MAX_ALLOWED_PACKET is None:
        ORIGINAL_MAX_ALLOWED_PACKET = _current_packet()
    try:
        run(
            [
                mysql_bin,
                "--defaults-file=" + ROOT_MYCNF,
                "-e",
                f"SET GLOBAL max_allowed_packet={mysql_size_to_bytes(packet_size)};",
            ],
            check=False,
        )
    except Exception as e:
        log(f"[!] Unable to set max_allowed_packet to {packet_size}: {e}")


def restore_max_allowed_packet():
    """Revert max_allowed_packet to original value if it was changed."""
    global ORIGINAL_MAX_ALLOWED_PACKET
    if not ORIGINAL_MAX_ALLOWED_PACKET:
        return
    mysql_bin = which("mysql")
    if not mysql_bin or not os.path.exists(ROOT_MYCNF):
        return
    try:
        run(
            [
                mysql_bin,
                "--defaults-file=" + ROOT_MYCNF,
                "-e",
                f"SET GLOBAL max_allowed_packet={ORIGINAL_MAX_ALLOWED_PACKET};",
            ],
            check=False,
        )
        log(
            f"[+] max_allowed_packet reverted to {ORIGINAL_MAX_ALLOWED_PACKET}"
        )
    except Exception as e:
        log(
            f"[!] Unable to restore max_allowed_packet to {ORIGINAL_MAX_ALLOWED_PACKET}: {e}"
        )


def run_upcp_force():
    if os.path.exists("/usr/local/cpanel/scripts/upcp"):
        log("[*] Running /usr/local/cpanel/scripts/upcp --force ...")
        run(["/usr/local/cpanel/scripts/upcp", "--force"], check=False)


def ensure_cpanel_readable_mysql_config():
    """Ensure cPanel UI code can read the global MySQL config."""
    if not os.path.exists(ETC_MYCNF):
        log(f"[!] {ETC_MYCNF} does not exist; skipping cPanel readability fix.")
        return False
    try:
        os.chmod(ETC_MYCNF, 0o644)
        log(f"[+] Set {ETC_MYCNF} permissions to 0644 for cPanel readability.")
        return True
    except Exception as e:
        log(f"[!] Unable to set {ETC_MYCNF} permissions: {e}")
        return False


def read_my_cnf_credentials():
    creds = {}
    if not os.path.exists(ROOT_MYCNF):
        return creds
    try:
        with open(ROOT_MYCNF) as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or line.startswith("["):
                    continue
                if "=" in line:
                    k, v = line.split("=", 1)
                    k = k.strip().lower()
                    v = v.strip()
                    if k in ("user", "username"):
                        creds["user"] = v
                    elif k in ("password", "pass"):
                        creds["password"] = v
    except Exception as e:
        log(f"[!] Unable to read {ROOT_MYCNF}: {e}")
    return creds


def write_root_mycnf(user, password):
    """Write /root/.my.cnf with provided credentials."""
    try:
        with open(ROOT_MYCNF, "w") as f:
            f.write("[client]\n")
            f.write(f"user={user}\n")
            f.write(f"password={password}\n")
        os.chmod(ROOT_MYCNF, 0o600)
        log(f"[+] Wrote {ROOT_MYCNF} with provided credentials.")
        return True
    except Exception as e:
        log(f"[!] Failed to write {ROOT_MYCNF}: {e}")
        return False


def mysql_login_works():
    mysql_bin = which("mysql")
    if not mysql_bin or not os.path.exists(ROOT_MYCNF):
        return False
    proc = subprocess.run(
        [mysql_bin, "--defaults-file=" + ROOT_MYCNF, "-e", "SELECT 1;"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    return proc.returncode == 0


def fetch_temp_root_password():
    log_path = os.path.join(DATADIR, f"{socket.gethostname()}.err")
    if not os.path.exists(log_path):
        return None
    temp_pw = None
    try:
        with open(log_path) as f:
            for line in f:
                if "temporary password" in line.lower():
                    temp_pw = line.strip().split()[-1]
    except Exception:
        return None
    return temp_pw


def ensure_root_password_matches_mycnf(max_wait=300, interval=5):
    """Ensure mysql root password matches credentials stored in /root/.my.cnf."""
    # Wait for service to be reachable before auth attempts
    log(
        f"[*] Waiting up to {max_wait}s for database service to accept connections before root auth sync..."
    )
    if not wait_for_mysql_ready(
        timeout=max_wait, interval=interval, require_auth=False
    ):
        log("[!] Database service did not become reachable in time.")
        return False

    # Try a few times to handle slow starts
    for attempt in range(1, 6):
        if mysql_login_works():
            return True

        creds = read_my_cnf_credentials()
        desired_user = creds.get("user", "root")
        desired_pass = creds.get("password")

        # If .my.cnf is missing or incomplete, attempt passwordless root login and write .my.cnf accordingly.
        if desired_pass is None:
            log(
                f"[i] Attempt {attempt}: {ROOT_MYCNF} missing or lacks password; probing for passwordless root access..."
            )
            proc = subprocess.run(
                ["mysql", "-uroot", "-e", "SELECT 1;"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            if proc.returncode == 0:
                log(
                    "[+] Passwordless root login succeeded; writing /root/.my.cnf with empty password."
                )
                if write_root_mycnf("root", ""):
                    return True
            else:
                log(f"[!] Attempt {attempt}: passwordless root login failed.")
        else:
            temp_password = fetch_temp_root_password()
            if temp_password:
                log(
                    f"[*] Attempt {attempt}: synchronizing MySQL root password using temporary password."
                )
                env = os.environ.copy()
                env["MYSQL_PWD"] = temp_password
                sql = "ALTER USER 'root'@'localhost' IDENTIFIED BY '{}'".format(
                    desired_pass.replace("'", "\\'")
                )
                subprocess.run(
                    [
                        "mysql",
                        "-uroot",
                        "--connect-expired-password",
                        "-e",
                        sql,
                    ],
                    env=env,
                    check=False,
                )
                if mysql_login_works():
                    log(
                        "[+] Root password synchronized with {}".format(
                            ROOT_MYCNF
                        )
                    )
                    return True

            setpass = "/usr/local/cpanel/scripts/setmysqlrootpass"
            if os.path.exists(setpass):
                log(
                    f"[*] Attempt {attempt}: setting MySQL root password via setmysqlrootpass."
                )
                run([setpass, desired_pass], check=False)
                if mysql_login_works():
                    log(
                        "[+] Root password synchronized with {}".format(
                            ROOT_MYCNF
                        )
                    )
                    return True

        time.sleep(interval)

    log(
        "[!] Unable to synchronize MySQL root password automatically. Please ensure {} credentials are valid.".format(
            ROOT_MYCNF
        )
    )
    return False


def enable_mysql_module_80():
    run(["dnf", "-y", "module", "reset", "mysql"], check=False)
    run(["dnf", "-y", "module", "enable", "mysql:8.0"], check=False)


def install_mysql80():
    # First, make sure we have a clean system
    log("[*] Ensuring clean system state for MySQL 8.0 installation...")

    # Stop any running database services
    stop_db()

    # Clean up any remaining packages and files
    log("[*] Removing existing MySQL/MariaDB packages...")

    # Remove all MySQL and MariaDB packages
    existing_pkgs = rpm_packages_matching(
        f"(?:{MYSQL_RPM_PACKAGE_REGEX}|{MARIADB_RPM_PACKAGE_REGEX})"
    )
    if existing_pkgs:
        rpm_force_remove(existing_pkgs)

    # Clean up any remaining files
    log("[*] Cleaning up remaining database files...")
    remove_matches(
        [
            "/var/lib/mysql*",
            "/etc/my.cnf*",
            "/etc/mysql*",
        ]
    )

    # Remove any remaining packages that might conflict
    conflicting = rpm_packages_matching(
        f"(?:{MYSQL_RPM_PACKAGE_REGEX}|{MARIADB_RPM_PACKAGE_REGEX})",
        refresh=True,
    )
    if conflicting:
        package_remove(conflicting)

    # Clean all dnf cache and metadata
    log("[*] Cleaning package manager cache...")
    run(["dnf", "clean", "all"], check=False)
    remove_matches(["/var/cache/dnf"])

    # Reset and enable MySQL 8.0 module
    log("[*] Configuring MySQL 8.0 repository...")
    run(["dnf", "-y", "module", "reset", "mysql"], check=False)
    run(["dnf", "-y", "module", "disable", "mysql"], check=False)

    # Remove any existing MySQL repositories to avoid conflicts
    remove_matches(
        [
            "/etc/yum.repos.d/mariadb*.repo",
            "/etc/yum.repos.d/mysql*.repo",
        ]
    )

    # Add MySQL 8.0 community repository
    mysql_repo = """[mysql80-community]
name=MySQL 8.0 Community Server
baseurl=http://repo.mysql.com/yum/mysql-8.0-community/el/8/$basearch/
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-mysql
"""
    with open("/etc/yum.repos.d/mysql-community.repo", "w") as f:
        f.write(mysql_repo)

    # Import MySQL GPG key
    run(
        ["rpm", "--import", "https://repo.mysql.com/RPM-GPG-KEY-mysql"],
        check=False,
    )

    # Clean and rebuild the dnf cache
    run(["dnf", "clean", "all"], check=False)
    run(["dnf", "makecache"], check=False)

    # Install MySQL 8.0 server with --allowerasing to handle any conflicts
    log("[*] Installing MySQL 8.0 server...")
    try:
        # Install MySQL server and client
        install_args = [
            "install",
            "mysql-community-server",
            "--allowerasing",
        ]
        if dnf_plan_has_protected_removals(install_args):
            raise RuntimeError(
                "dnf install would remove protected cPanel/EA packages"
            )
        run(
            ["dnf", "-y"] + install_args,
            check=True,
        )

        # For cPanel systems, create necessary symlinks
        if os.path.exists("/usr/local/cpanel"):
            log("[*] Configuring for cPanel compatibility...")
            os.makedirs("/usr/local/mysql/bin", exist_ok=True)
            for cmd in ["mysql", "mysqladmin", "mysqldump"]:
                if os.path.exists(f"/usr/bin/{cmd}") and not os.path.exists(
                    f"/usr/local/mysql/bin/{cmd}"
                ):
                    os.symlink(f"/usr/bin/{cmd}", f"/usr/local/mysql/bin/{cmd}")

            # Update cPanel MySQL version
            with open("/var/cpanel/mysql/version", "w") as f:
                f.write("8.0")

    except Exception as e:
        log(f"[!] Failed to install MySQL 8.0: {str(e)}")
        log("[*] Attempting alternative installation method...")
        # Try with module if direct installation fails
        run(["dnf", "-y", "module", "reset", "mysql"], check=False)
        run(["dnf", "-y", "module", "enable", "mysql:8.0"], check=False)
        install_args = ["install", "@mysql:8.0", "--allowerasing"]
        if dnf_plan_has_protected_removals(install_args):
            log("[!] Refusing alternative MySQL install due to protected removals.")
            sys.exit(1)
        run(["dnf", "-y"] + install_args, check=True)

    # Enable and start the service
    log("[*] Starting MySQL 8.0 service...")
    run(["systemctl", "daemon-reload"], check=True)
    run(["systemctl", "enable", "--now", "mysqld"], check=True)

    # Wait a moment for the service to start
    time.sleep(5)

    # Verify the service is running
    if not ping_mysql():
        log("[!] MySQL service failed to start. Checking status...")
        run(["systemctl", "status", "mysqld"], check=False)
        run(["journalctl", "-xe", "-u", "mysqld"], check=False)

        # Try to get the error log
        hostname_log = os.path.join(DATADIR, f"{socket.gethostname()}.err")
        if os.path.exists(hostname_log):
            log(f"\n[!] Error Log ({hostname_log} - last 20 lines):")
            run(["tail", "-n", "20", hostname_log], check=False)
        else:
            log(f"[!] Expected error log {hostname_log} not found.")

        log("\n[!] Trying to start MySQL manually...")
        run(["mysqld", "--initialize-insecure", "--user=mysql"], check=False)
        run(["systemctl", "start", "mysqld"], check=False)

        if not ping_mysql():
            raise Exception(
                "Failed to start MySQL service after multiple attempts"
            )

    # Get temporary root password if this is a fresh install
    temp_password = None
    hostname_log = os.path.join(DATADIR, f"{socket.gethostname()}.err")
    if os.path.exists(hostname_log):
        with open(hostname_log) as f:
            for line in f:
                if "temporary password" in line.lower():
                    temp_password = line.strip().split()[-1]
                    break

    # Secure the installation
    log("[*] Securing MySQL installation...")
    try:
        if temp_password:
            log(f"\n[!] IMPORTANT: Temporary root password: {temp_password}")
            # Change root password and secure installation
            cmds = [
                "ALTER USER 'root'@'localhost' IDENTIFIED BY '';",
                "DELETE FROM mysql.user WHERE User='';",
                "DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');",
                "DROP DATABASE IF EXISTS test;",
                "DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';",
                "FLUSH PRIVILEGES;",
            ]

            # Execute security commands
            for cmd in cmds:
                run(
                    [
                        "mysql",
                        "--user=root",
                        f"--password={temp_password}",
                        "--connect-expired-password",
                        "-e",
                        cmd,
                    ],
                    check=False,
                )
        else:
            log(
                "[*] No temporary password found, assuming existing installation"
            )

    except Exception as e:
        log(f"[!] Warning: Could not secure installation: {str(e)}")

    # For cPanel systems, update configuration
    if os.path.exists("/usr/local/cpanel"):
        log("[*] Updating cPanel configuration...")
        cpanel_scripts = [
            "/usr/local/cpanel/bin/update_local_rpm_versions",
            "/usr/local/cpanel/bin/check-db-stable-version",
            "/usr/local/cpanel/scripts/check_cpanel_mysql_routines",
        ]

        for script in cpanel_scripts:
            if os.path.exists(script):
                try:
                    run([script], check=False)
                except Exception as e:
                    log(f"[!] Warning: Failed to run {script}: {str(e)}")

        # Run upcp to restore cPanel users and databases
        log(
            "[*] Running cPanel update (upcp) to restore users and databases..."
        )
        log("    This may take several minutes to complete...")
        run(["/usr/local/cpanel/scripts/upcp", "--force"], check=False)

    log("\n[+] MySQL 8.0 installation completed successfully!")
    log("\n[!] IMPORTANT NEXT STEPS:")
    if temp_password:
        log(
            "    1. Set a root password using: mysqladmin -u root -p password 'new-password'"
        )
    else:
        log(
            "    1. If you need to reset the root password, use: mysql_secure_installation"
        )
    log("    2. Verify all databases and users are accessible")
    log("    3. Check application compatibility with MySQL 8.0")
    log("\n[!] If you encounter any issues, check these log files:")
    log("    - /var/lib/mysql/*.*.err")
    log("    - journalctl -xe | grep -i mysql")


def install_mariadb106():
    log("[*] Installing MariaDB 10.6...")

    # First, ensure we have a clean system
    log("[*] Ensuring clean system state for MariaDB 10.6 installation...")
    remove_mysql_family()  # Make sure MySQL is completely removed

    # Add MariaDB 10.6 repository
    log("[*] Setting up MariaDB 10.6 repository...")
    maria_repo = """[mariadb]
name = MariaDB
baseurl = http://yum.mariadb.org/10.6/rhel8-amd64
module_hotfixes=1
gpgkey=https://yum.mariadb.org/RPM-GPG-KEY-MariaDB
gpgcheck=1
"""
    with open("/etc/yum.repos.d/mariadb.repo", "w") as f:
        f.write(maria_repo)

    # Import GPG key
    run(
        ["rpm", "--import", "https://yum.mariadb.org/RPM-GPG-KEY-MariaDB"],
        check=False,
    )

    # Install required dependencies first
    log("[*] Installing required dependencies...")
    run(
        [
            "dnf",
            "-y",
            "install",
            "socat",
            "libnsl",
            "perl-DBD-MySQL",
            "policycoreutils-python-utils",
        ],
        check=True,
    )

    # Clean DNF cache
    run(["dnf", "clean", "all"], check=False)
    run(["dnf", "makecache"], check=False)

    # Install MariaDB 10.6 server
    log("[*] Installing MariaDB 10.6 server...")
    try:
        # Install base MariaDB packages
        run(
            [
                "dnf",
                "-y",
                "install",
                "MariaDB-server",
                "MariaDB-client",
                "MariaDB-shared",
                "MariaDB-common",
                "MariaDB-compat",
            ],
            check=True,
        )

        # Install cPanel specific packages if cPanel is detected
        if os.path.exists("/usr/local/cpanel"):
            log("[*] Configuring for cPanel compatibility...")
            # Create necessary symlinks for cPanel
            for cmd in ["mysql", "mysqladmin", "mysqldump"]:
                if os.path.exists(f"/usr/bin/{cmd}") and not os.path.exists(
                    f"/usr/local/mysql/bin/{cmd}"
                ):
                    os.makedirs("/usr/local/mysql/bin", exist_ok=True)
                    os.symlink(f"/usr/bin/{cmd}", f"/usr/local/mysql/bin/{cmd}")

            # Update cPanel MySQL version
            with open("/var/cpanel/mysql/version", "w") as f:
                f.write("10.6")

    except Exception as e:
        log(f"[!] Warning during MariaDB installation: {str(e)}")

    # Configure SELinux if it's enabled
    if os.path.exists("/usr/sbin/sestatus"):
        log("[*] Configuring SELinux for MariaDB...")
        try:
            # Set proper SELinux contexts
            run(
                [
                    "semanage",
                    "port",
                    "-a",
                    "-t",
                    "mysqld_port_t",
                    "-p",
                    "tcp",
                    "3306",
                ],
                check=False,
            )
            run(
                [
                    "semanage",
                    "port",
                    "-m",
                    "-t",
                    "mysqld_port_t",
                    "-p",
                    "tcp",
                    "3306",
                ],
                check=False,
            )

            # Set file contexts for MariaDB
            run(
                [
                    "semanage",
                    "fcontext",
                    "-a",
                    "-t",
                    "mysqld_db_t",
                    "/var/lib/mysql(/.*)?",
                ],
                check=False,
            )
            run(["restorecon", "-R", "/var/lib/mysql"], check=False)

        except Exception as e:
            log(f"[!] Warning: Could not configure SELinux: {str(e)}")

    # Enable and start the service
    log("[*] Starting MariaDB service...")
    run(["systemctl", "daemon-reload"], check=True)
    run(["systemctl", "enable", "--now", "mariadb"], check=True)

    # Verify the service is running
    if not ping_mysql():
        log("[!] MariaDB service failed to start. Checking status...")
        run(["systemctl", "status", "mariadb"], check=False)
        run(["journalctl", "-xe", "-u", "mariadb"], check=False)

        # Try to get the error log
        error_logs = [
            "/var/lig/mysql/*.*.err",
            f"/var/lib/mysql/{os.uname().nodename}.err",
            "/var/lib/mysql/*.*.err",
        ]

        for log in error_logs:
            if os.path.exists(log):
                log(f"\n[!] Error Log ({log} - last 20 lines):")
                run(["tail", "-n", "20", log], check=False)

        log("\n[!] Trying to start MariaDB with systemd...")
        run(["systemctl", "start", "mariadb"], check=False)

        if not ping_mysql():
            log("\n[!] Attempting to start MariaDB directly...")
            run_background(
                [
                    "/usr/libexec/mysqld",
                    "--skip-grant-tables",
                    "--skip-networking",
                ]
            )

    # Final verification
    if not ping_mysql():
        log(
            "\n[!] ERROR: Could not start MariaDB. Please check the error logs above."
        )
        log(
            "    You may need to manually configure MariaDB or check for port conflicts."
        )
        log("    Try running: journalctl -xe | grep -i mysql")
        return False

    # Secure the installation
    log("[*] Securing MariaDB installation...")
    try:
        # Set root password and secure installation
        cmds = [
            "SET PASSWORD FOR 'root'@'localhost' = PASSWORD('');",
            "DELETE FROM mysql.user WHERE User='';",
            "DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');",
            "DROP DATABASE IF EXISTS test;",
            "DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';",
            "FLUSH PRIVILEGES;",
        ]

        # Execute security commands
        for cmd in cmds:
            try:
                run(["mysql", "-e", cmd], check=False)
            except Exception as e:
                log(f"[!] Warning: Failed to execute: {cmd}")
                log(f"     Error: {str(e)}")

        # For cPanel systems, update the configuration
        if os.path.exists("/usr/local/cpanel"):
            log("[*] Updating cPanel MySQL configuration...")
            cpanel_scripts = [
                "/usr/local/cpanel/bin/update_local_rpm_versions",
                "/usr/local/cpanel/bin/check-db-stable-version",
                "/usr/local/cpanel/scripts/check_cpanel_mysql_routines",
            ]

            for script in cpanel_scripts:
                if os.path.exists(script):
                    try:
                        run([script], check=False)
                    except Exception as e:
                        log(f"[!] Warning: Failed to run {script}: {str(e)}")

        # Restart cPanel services and run upcp if cPanel is installed
        if os.path.exists("/usr/local/cpanel"):
            log("[*] Restarting cPanel services...")
            run(["/usr/local/cpanel/scripts/restartsrv_cpsrvd"], check=False)

            # Run upcp to restore cPanel users and databases
            log(
                "[*] Running cPanel update (upcp) to restore users and databases..."
            )
            log("    This may take several minutes to complete...")
            run(["/usr/local/cpanel/scripts/upcp", "--force"], check=False)

    except Exception as e:
        log(f"[!] Warning: Could not complete all security steps: {str(e)}")

    log("\n[+] MariaDB 10.6 installation completed successfully!")
    log("\n[!] IMPORTANT NEXT STEPS:")
    log(
        "    1. Set a root password using: mysqladmin -u root password 'your-new-password'"
    )
    log(
        "    2. If using cPanel, run: /usr/local/cpanel/bin/update_local_rpm_versions"
    )
    log("    3. Check MySQL users and permissions")
    log("\n[!] If you encounter any issues, check these log files:")
    log("    - /var/lib/mysql/*.*.err")
    log("    - journalctl -xe | grep -i mysql")


def discover_cpusers():
    """Return a sorted list of cPanel users inferred from DB metadata and user files."""
    users = set()
    meta_dir = "/var/cpanel/databases"
    users_dir = "/var/cpanel/users"
    if os.path.isdir(users_dir):
        for entry in os.listdir(users_dir):
            if re.match(r"^[A-Za-z0-9_]+$", entry):
                users.add(entry)
    if users:
        return sorted(users)
    if os.path.isdir(meta_dir):
        for entry in os.listdir(meta_dir):
            if entry.endswith(".yaml") and not entry.startswith("grants_"):
                users.add(entry[:-5])
    return sorted(users)


def restoregrants_all_users():
    """Restore grants for each cPanel user, aligning with cPanel guidance."""
    script = "/usr/local/cpanel/bin/restoregrants"
    if not os.path.exists(script):
        log("[!] restoregrants script not found; skipping grant restoration.")
        return False
    cpusers = discover_cpusers()
    if not cpusers:
        log("[*] No cPanel users detected for grant restoration.")
        return True
    ok = True
    for user in cpusers:
        rc = run([script, "--db=mysql", f"--cpuser={user}"], check=False)
        if rc != 0:
            log(f"[!] restoregrants failed for cpuser {user}")
            ok = False
    return ok


def restart_mysql_service():
    """Restart MySQL/MariaDB service to pick up restored settings."""
    log("[*] Restarting database service...")
    # Try common service names; ignore failures to avoid aborting flow.
    run(["systemctl", "restart", "mysqld"], check=False)
    run(["systemctl", "restart", "mysql"], check=False)
    run(["systemctl", "restart", "mariadb"], check=False)


def post_restore_health_check():
    """Basic service + data sanity check after restore."""
    if DRY_RUN:
        log("[dry-run] Skipping post-restore health check.")
        return True
    mysql_bin = which("mysql")
    if not mysql_bin:
        log("[!] mysql client not found; skipping post-restore health check.")
        return False
    if not wait_for_mysql_ready(timeout=180, interval=5, require_auth=True):
        log("[!] MySQL/MariaDB did not become ready for health check.")
        return False

    dbs_raw = out(
        [mysql_bin, "--defaults-file=" + ROOT_MYCNF, "-NBe", "SHOW DATABASES"]
    )
    dbs = [d.strip() for d in dbs_raw.splitlines() if d.strip()]
    system_dbs = {"mysql", "information_schema", "performance_schema", "sys"}
    user_dbs = [d for d in dbs if d not in system_dbs]

    log(f"[*] Health check: found {len(user_dbs)} user databases.")
    if not user_dbs:
        return True

    sample_db = user_dbs[0]
    tables_raw = out(
        [
            mysql_bin,
            "--defaults-file=" + ROOT_MYCNF,
            "-NBe",
            f"SHOW TABLES IN `{sample_db}`",
        ]
    )
    tables = [t.strip() for t in tables_raw.splitlines() if t.strip()]
    if not tables:
        log(
            f"[i] Health check: sample DB {sample_db} has no tables to check."
        )
        return True

    sample_table = tables[0]
    rc = run(
        [
            mysql_bin,
            "--defaults-file=" + ROOT_MYCNF,
            "-e",
            f"CHECK TABLE `{sample_db}`.`{sample_table}`",
        ],
        check=False,
    )
    if rc != 0:
        log(f"[!] Health check failed on {sample_db}.{sample_table}")
        return False
    log(f"[+] Health check passed on {sample_db}.{sample_table}")
    return True


def get_current_mariadb_version():
    try:
        matches = packages_matching(r"^mariadb-server")
        if not matches:
            return None
        if is_deb_os():
            raw = out(["dpkg-query", "-W", "-f=${Version}", matches[0]])
            match = re.search(r"(\d+\.\d+)", raw)
        else:
            # Extract version number from package name (e.g., 'mariadb-server-10.11.5-1.el9_2.x86_64' -> '10.11')
            match = re.search(r'mariadb[^\d]*(\d+\.\d+)', matches[0])
        if match:
            return match.group(1)
    except Exception as e:
        log(f"[!] Could not determine current MariaDB version: {e}")
    return None


def main():
    global DRY_RUN, FORCE_DELETE
    args = sys.argv[1:]
    positional = []
    log_path = None
    for arg in args:
        if arg in ("-h", "--help"):
            init_logging(log_path)
            show_help(sys.argv[0])
            sys.exit(0)
        elif arg == "--dry-run":
            DRY_RUN = True
        elif arg == "--force":
            FORCE_DELETE = True
        elif arg.startswith("--log-file="):
            log_path = arg.split("=", 1)[1]
        else:
            positional.append(arg)

    init_logging(log_path)
    log(f"[*] Logging to: {LOG_FILE_DEFAULT}")

    if not positional:
        show_help(sys.argv[0])
        sys.exit(1)

    check_os_support()

    target_key = positional[0]
    if target_key not in TARGETS:
        log(
            "[!] Invalid target: {}\n    Allowed: {}".format(
                target_key, ", ".join(TARGETS.keys())
            )
        )
        sys.exit(1)

    tfamily, tver = TARGETS[target_key]
    current_family = installed_family()
    current_ver = None
    if current_family == "mariadb":
        current_ver = get_current_mariadb_version()
    elif current_family == "mysql":
        current_ver = get_current_mysql_version()

    if current_family == tfamily and current_ver == tver:
        log(
            f"[*] {tfamily.title()} {current_ver} is already installed. No changes needed."
        )
        sys.exit(0)

    log(f"Target: {target_key} ({tver})")
    log(f"When:   {ts()}")

    # 1. First backup all databases while the server is still running
    log("\n[1/5] Backing up all user databases...")
    if not has_sufficient_space_for_backup():
        log("[!] Aborting due to insufficient space for backups.")
        sys.exit(1)
    backup_dir, backup_status = backup_all(BACKUP_ROOT)
    if backup_status == "error":
        log(
            "[!] Backup failed; aborting to prevent data loss. Backup directory: {}".format(
                backup_dir
            )
        )
        sys.exit(1)
    if backup_status == "empty":
        log("[?] No user databases were found to back up. Continue anyway? [y/N]: ")
        ans = (
            input()
            .strip()
            .lower()
        )
        if ans not in ("y", "yes"):
            log("[!] Aborting at user request due to missing backups.")
            sys.exit(1)

    # 2. Stop database services
    log("\n[2/5] Stopping database services...")
    stop_db()

    # 3. Remove existing installation
    log("\n[3/5] Removing existing installation (if any)...")
    removed_existing = False
    if current_family == 'mariadb':
        log("[*] Removing existing MariaDB installation...")
        remove_mariadb_family()
        removed_existing = True
    elif current_family == 'mysql':
        log("[*] Removing existing MySQL installation...")
        remove_mysql_family()
        removed_existing = True
    else:
        log("[*] No existing MySQL/MariaDB packages detected.")

    # 4. Clean up any remaining packages
    log("\n[4/5] Cleaning up...")
    if removed_existing:
        log("[*] Package cleanup already completed during database removal.")
    else:
        package_autoremove()

    # Ensure Perl modules after cleanup, before installing new DB version
    log("[*] Ensuring Perl MySQL modules are present...")
    ensure_perl_mysql_modules()

    # Set the desired version in cPanel config
    set_cpanel_mysql_version(tver)

    # Check what WHM allows (informational only)
    allowed = whm_allowed()
    if allowed:
        log("[i] WHM (probe) allows: {}".format(", ".join(allowed)))

    # Use cPanel API to install requested version
    log("\n[5/5] Installing requested engine via cPanel API...")
    install_ok = install_with_cpanel_api(tver)

    # Fallbacks if API is not available or fails
    if not install_ok:
        if is_deb_os():
            log(
                "[!] Manual package fallback is not implemented for Ubuntu; cPanel API installation is required."
            )
        elif tfamily == "mariadb" and tver == "10.6":
            log(
                "\n[!] API install failed; falling back to manual MariaDB {} install...".format(
                    tver
                )
            )
            install_mariadb106()
            install_ok = True
        elif tfamily == "mysql" and tver == "8.0":
            log(
                "\n[!] API install failed; falling back to manual MySQL {} install...".format(
                    tver
                )
            )
            install_mysql80()
            install_ok = True
        else:
            log(f"[!] No manual fallback implemented for {tfamily} {tver}.")

    if not install_ok:
        log(
            "\n[!] Installation did not complete successfully for {}.".format(
                tver
            )
        )
        log(f"    - Backup was saved to: {backup_dir}")
        log(
            "    - Track WHM status with: whmapi1 get_mysql_upgrade --output=json|yaml"
        )
        sys.exit(1)

    ensure_cpanel_readable_mysql_config()

    # Ensure /root/.my.cnf credentials work with the new service
    if not ensure_root_password_matches_mycnf():
        log(
            "[!] Unable to authenticate with MySQL using /root/.my.cnf credentials. Aborting to prevent data issues."
        )
        sys.exit(1)

    # Restore data and finish up
    fix_collations = current_family == "mysql" and tfamily == "mariadb"
    if fix_collations:
        log("[*] MySQL-to-MariaDB migration detected; enabling collation compatibility fixes.")
    if not restore_all_databases(
        backup_dir, fix_mysql_to_mariadb_collations=fix_collations
    ):
        log(
            "[!] One or more database restores failed; check logs before proceeding."
        )
        sys.exit(1)
    restore_max_allowed_packet()
    restart_mysql_service()
    if not restoregrants_all_users():
        log(
            "[!] One or more grant restoration steps failed. Review output above."
        )
    run_upcp_force()
    ensure_cpanel_readable_mysql_config()
    health_ok = post_restore_health_check()
    if not health_ok:
        log(
            "[!] Post-restore health check reported issues. Review logs and database state."
        )
    else:
        log("[+] Post-restore health check passed.")
    log("[+] Requested database engine installed and data restore complete.")
    log(f"    - Backup directory retained at: {backup_dir}")


if __name__ == "__main__":
    main()