Files
yakpanel-core/YakPanel-server/backend/app/services/database_service.py
2026-04-07 02:04:22 +05:30

412 lines
16 KiB
Python

"""YakPanel - Database service (MySQL creation via CLI; PostgreSQL/MongoDB/Redis panel records only)"""
import os
import shutil
import subprocess
from datetime import datetime
from app.core.config import get_runtime_config
def get_mysql_root() -> str | None:
"""Get MySQL root password from config (key: mysql_root)"""
cfg = get_runtime_config()
return cfg.get("mysql_root") or None
def _run_mysql(sql: str, root_pw: str) -> tuple[str, str]:
"""Run mysql with SQL. Uses MYSQL_PWD to avoid password in argv."""
env = os.environ.copy()
env["MYSQL_PWD"] = root_pw
try:
r = subprocess.run(
["mysql", "-u", "root", "-e", sql],
capture_output=True,
text=True,
timeout=10,
env=env,
)
return r.stdout or "", r.stderr or ""
except FileNotFoundError:
return "", "mysql command not found"
except subprocess.TimeoutExpired:
return "", "Timed out"
def create_mysql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
"""Create MySQL database and user via mysql CLI. Returns (success, message)."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured. Set it in Settings."
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters in name: {x}"
pw_esc = password.replace("\\", "\\\\").replace("'", "''")
steps = [
(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;", "create database"),
(f"CREATE USER IF NOT EXISTS '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';", "create user"),
(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{username}'@'localhost';", "grant"),
("FLUSH PRIVILEGES;", "flush"),
]
for sql, step in steps:
out, err = _run_mysql(sql, root_pw)
if err and "error" in err.lower() and "already exists" not in err.lower():
return False, f"{step}: {err.strip() or out.strip()}"
return True, "Database created"
def drop_mysql_database(db_name: str, username: str) -> tuple[bool, str]:
"""Drop MySQL database and user."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured"
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters: {x}"
steps = [
(f"DROP DATABASE IF EXISTS `{db_name}`;", "drop database"),
(f"DROP USER IF EXISTS '{username}'@'localhost';", "drop user"),
("FLUSH PRIVILEGES;", "flush"),
]
for sql, step in steps:
out, err = _run_mysql(sql, root_pw)
if err and "error" in err.lower():
return False, f"{step}: {err.strip() or out.strip()}"
return True, "Database dropped"
def backup_mysql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
"""Create mysqldump backup. Returns (success, message, filename)."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured", None
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name", None
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{db_name}_{ts}.sql.gz"
dest = os.path.join(backup_dir, filename)
env = os.environ.copy()
env["MYSQL_PWD"] = root_pw
try:
r = subprocess.run(
f'mysqldump -u root {db_name} | gzip > "{dest}"',
shell=True,
env=env,
timeout=300,
capture_output=True,
text=True,
)
if r.returncode != 0 or not os.path.isfile(dest):
return False, r.stderr or r.stdout or "Backup failed", None
return True, "Backup created", filename
except subprocess.TimeoutExpired:
return False, "Timed out", None
except Exception as e:
return False, str(e), None
def restore_mysql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
"""Restore MySQL database from .sql.gz backup."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured"
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name"
if not os.path.isfile(backup_path):
return False, "Backup file not found"
env = os.environ.copy()
env["MYSQL_PWD"] = root_pw
try:
r = subprocess.run(
f'gunzip -c "{backup_path}" | mysql -u root {db_name}',
shell=True,
env=env,
timeout=300,
capture_output=True,
text=True,
)
if r.returncode != 0:
return False, r.stderr or r.stdout or "Restore failed"
return True, "Restored"
except subprocess.TimeoutExpired:
return False, "Timed out"
except Exception as e:
return False, str(e)
def change_mysql_password(username: str, new_password: str) -> tuple[bool, str]:
"""Change MySQL user password."""
root_pw = get_mysql_root()
if not root_pw:
return False, "MySQL root password not configured"
if not all(c.isalnum() or c == "_" for c in username):
return False, "Invalid username"
pw_esc = new_password.replace("\\", "\\\\").replace("'", "''")
sql = f"ALTER USER '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';"
out, err = _run_mysql(sql, root_pw)
if err and "error" in err.lower():
return False, err.strip() or out.strip()
return True, "Password updated"
def create_postgresql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
"""Create PostgreSQL database and user via psql (runs as postgres). Returns (success, message)."""
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters in name: {x}"
pw_esc = password.replace("'", "''")
cmds = [
f"CREATE DATABASE {db_name};",
f"CREATE USER {username} WITH PASSWORD '{pw_esc}';",
f"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {username};",
f"GRANT ALL ON SCHEMA public TO {username};",
]
for sql in cmds:
try:
r = subprocess.run(
["sudo", "-u", "postgres", "psql", "-tAc", sql],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
err = (r.stderr or r.stdout or "Failed").strip()[:200]
if "already exists" in err.lower():
continue
return False, err
except FileNotFoundError:
return False, "PostgreSQL (psql) not found"
return True, "Database created"
def drop_postgresql_database(db_name: str, username: str) -> tuple[bool, str]:
"""Drop PostgreSQL database and user."""
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters: {x}"
try:
r = subprocess.run(
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP DATABASE IF EXISTS {db_name};"],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
r = subprocess.run(
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP USER IF EXISTS {username};"],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
return True, "Database dropped"
except FileNotFoundError:
return False, "PostgreSQL (psql) not found"
def change_postgresql_password(username: str, new_password: str) -> tuple[bool, str]:
"""Change PostgreSQL user password."""
if not all(c.isalnum() or c == "_" for c in username):
return False, "Invalid username"
pw_esc = new_password.replace("'", "''")
try:
r = subprocess.run(
["sudo", "-u", "postgres", "psql", "-tAc", f"ALTER USER {username} WITH PASSWORD '{pw_esc}';"],
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
return True, "Password updated"
except FileNotFoundError:
return False, "PostgreSQL (psql) not found"
def create_mongodb_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
"""Create MongoDB database and user via mongosh."""
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters in name: {x}"
pw_esc = password.replace("\\", "\\\\").replace("'", "\\'")
js = (
f"db = db.getSiblingDB('{db_name}'); "
f"db.createUser({{user: '{username}', pwd: '{pw_esc}', roles: [{{role: 'readWrite', db: '{db_name}'}}]}});"
)
try:
r = subprocess.run(
["mongosh", "--quiet", "--eval", js],
capture_output=True,
text=True,
timeout=15,
)
if r.returncode != 0:
err = (r.stderr or r.stdout or "Failed").strip()[:200]
if "already exists" in err.lower():
return True, "Database created"
return False, err
return True, "Database created"
except FileNotFoundError:
return False, "MongoDB (mongosh) not found"
def drop_mongodb_database(db_name: str, username: str) -> tuple[bool, str]:
"""Drop MongoDB database and user."""
for x in (db_name, username):
if not all(c.isalnum() or c == "_" for c in x):
return False, f"Invalid characters: {x}"
try:
js = f"db = db.getSiblingDB('{db_name}'); db.dropUser('{username}'); db.dropDatabase();"
r = subprocess.run(
["mongosh", "--quiet", "--eval", js],
capture_output=True,
text=True,
timeout=15,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
return True, "Database dropped"
except FileNotFoundError:
return False, "MongoDB (mongosh) not found"
def backup_postgresql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
"""Create PostgreSQL backup via pg_dump. Returns (success, message, filename)."""
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name", None
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{db_name}_{ts}.sql.gz"
dest = os.path.join(backup_dir, filename)
try:
r = subprocess.run(
f'sudo -u postgres pg_dump {db_name} | gzip > "{dest}"',
shell=True,
timeout=300,
capture_output=True,
text=True,
)
if r.returncode != 0 or not os.path.isfile(dest):
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
return True, "Backup created", filename
except subprocess.TimeoutExpired:
return False, "Timed out", None
except Exception as e:
return False, str(e), None
def restore_postgresql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
"""Restore PostgreSQL database from .sql.gz backup."""
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name"
if not os.path.isfile(backup_path):
return False, "Backup file not found"
try:
r = subprocess.run(
f'gunzip -c "{backup_path}" | sudo -u postgres psql -d {db_name}',
shell=True,
timeout=300,
capture_output=True,
text=True,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Restore failed").strip()[:200]
return True, "Restored"
except subprocess.TimeoutExpired:
return False, "Timed out"
except Exception as e:
return False, str(e)
def backup_mongodb_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
"""Create MongoDB backup via mongodump. Returns (success, message, filename)."""
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name", None
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = os.path.join(backup_dir, f"{db_name}_{ts}")
try:
r = subprocess.run(
["mongodump", "--db", db_name, "--out", out_dir, "--gzip"],
capture_output=True,
text=True,
timeout=300,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
# Create tarball of the dump
archive = out_dir + ".tar.gz"
r2 = subprocess.run(
f'tar -czf "{archive}" -C "{backup_dir}" "{os.path.basename(out_dir)}"',
shell=True,
capture_output=True,
text=True,
timeout=120,
)
if os.path.isdir(out_dir):
shutil.rmtree(out_dir, ignore_errors=True)
if r2.returncode != 0 or not os.path.isfile(archive):
return False, "Archive failed", None
return True, "Backup created", os.path.basename(archive)
except subprocess.TimeoutExpired:
return False, "Timed out", None
except Exception as e:
return False, str(e), None
def restore_mongodb_database(db_name: str, backup_path: str) -> tuple[bool, str]:
"""Restore MongoDB database from .tar.gz mongodump backup."""
if not all(c.isalnum() or c == "_" for c in db_name):
return False, "Invalid database name"
if not os.path.isfile(backup_path):
return False, "Backup file not found"
import tempfile
tmp = tempfile.mkdtemp()
try:
r = subprocess.run(
["tar", "-xzf", backup_path, "-C", tmp],
capture_output=True,
text=True,
timeout=60,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Extract failed").strip()[:200]
# Find the extracted dir (mongodump creates db_name/ subdir)
extracted = os.path.join(tmp, os.listdir(tmp)[0]) if os.listdir(tmp) else None
if not extracted or not os.path.isdir(extracted):
return False, "Invalid backup format"
r2 = subprocess.run(
["mongorestore", "--db", db_name, "--gzip", "--drop", extracted],
capture_output=True,
text=True,
timeout=300,
)
if r2.returncode != 0:
return False, (r2.stderr or r2.stdout or "Restore failed").strip()[:200]
return True, "Restored"
except Exception as e:
return False, str(e)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def change_mongodb_password(username: str, db_name: str, new_password: str) -> tuple[bool, str]:
"""Change MongoDB user password."""
if not all(c.isalnum() or c == "_" for c in username):
return False, "Invalid username"
pw_esc = new_password.replace("\\", "\\\\").replace("'", "\\'")
js = f"db = db.getSiblingDB('{db_name}'); db.changeUserPassword('{username}', '{pw_esc}');"
try:
r = subprocess.run(
["mongosh", "--quiet", "--eval", js],
capture_output=True,
text=True,
timeout=15,
)
if r.returncode != 0:
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
return True, "Password updated"
except FileNotFoundError:
return False, "MongoDB (mongosh) not found"