Initial YakPanel commit
This commit is contained in:
411
YakPanel-server/backend/app/services/database_service.py
Normal file
411
YakPanel-server/backend/app/services/database_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user