"""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"