| Current Path : /proc/self/root/opt/dedrads/ |
| Current File : //proc/self/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()