sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1
- P0: 实例访问控制全线接入权限组 (829测试) - P1: permissions字段激活 + 操作级权限检查 (843测试) - 前端: 删除数据权限页面, 权限组合并为唯一入口 - 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析 - 新增: MongoDB管理页面 + Dashboard增强 - 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
1 parent
6fcd9465cf
commit
4c9427e546
105 files changed
+23680
-471
No files matched your search
@@ -0,0 +1,351 @@
|
||||
"""数据库备份执行器 — 使用 mysqldump/pg_dump 执行真实备份。
|
||||
|
||||
支持:
|
||||
- MySQL: mysqldump + gzip 压缩
|
||||
- PostgreSQL: pg_dump custom format
|
||||
|
||||
备份文件存储在 /app/backups 目录。
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from app.models.instance import Instance
|
||||
from app.models.backup import Backup
|
||||
from app.core.crypto import decrypt_password
|
||||
|
||||
|
||||
# 备份存储目录
|
||||
BACKUP_DIR = os.environ.get("BACKUP_DIR", "/app/backups")
|
||||
|
||||
|
||||
class BackupExecutor:
|
||||
"""数据库备份执行器。"""
|
||||
|
||||
async def execute(self, backup: Backup, instance: Instance) -> Tuple[str, int]:
|
||||
"""执行备份,返回 (文件路径, 文件大小字节)。
|
||||
|
||||
Raises:
|
||||
RuntimeError: 备份工具未安装或备份失败
|
||||
"""
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
password = decrypt_password(instance.password)
|
||||
|
||||
if instance.db_type == "mysql":
|
||||
filepath = await self._backup_mysql(
|
||||
instance, backup.database, password, timestamp
|
||||
)
|
||||
elif instance.db_type in ("postgres", "postgresql"):
|
||||
filepath = await self._backup_postgres(
|
||||
instance, backup.database, password, timestamp
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"不支持的备份类型: {instance.db_type}")
|
||||
|
||||
size_bytes = os.path.getsize(filepath) if os.path.exists(filepath) else 0
|
||||
return filepath, size_bytes
|
||||
|
||||
async def _backup_mysql(self, instance: Instance, database: str,
|
||||
password: str, timestamp: str) -> str:
|
||||
"""使用 mysqldump 备份 MySQL 数据库。
|
||||
|
||||
输出通过 gzip 压缩存储。
|
||||
"""
|
||||
filename = f"{database}_mysql_{timestamp}.sql.gz"
|
||||
filepath = os.path.join(BACKUP_DIR, filename)
|
||||
|
||||
# 检查 mysqldump 是否可用
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"mysqldump", "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("mysqldump 未安装")
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
"mysqldump 未安装,请安装 MySQL 客户端工具:\\n"
|
||||
" apt-get install mysql-client 或\\n"
|
||||
" yum install mysql"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"mysqldump",
|
||||
"-h", instance.host,
|
||||
"-P", str(instance.port),
|
||||
"-u", instance.username,
|
||||
f"-p{password}",
|
||||
"--single-transaction",
|
||||
"--routines",
|
||||
"--triggers",
|
||||
"--events",
|
||||
"--hex-blob",
|
||||
"--skip-lock-tables",
|
||||
database,
|
||||
]
|
||||
|
||||
# 创建 mysqldump 子进程,stdout 通过 gzip 压缩
|
||||
dump_proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# 创建 gzip 子进程
|
||||
gzip_proc = await asyncio.create_subprocess_exec(
|
||||
"gzip", "-c",
|
||||
stdin=dump_proc.stdout,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# 关闭 dump_proc 的 stdout 在本进程的引用
|
||||
if dump_proc.stdout:
|
||||
dump_proc.stdout.close()
|
||||
|
||||
# 读取 gzip 输出写入文件
|
||||
try:
|
||||
with open(filepath, "wb") as f:
|
||||
while True:
|
||||
chunk = await gzip_proc.stdout.read(65536) if gzip_proc.stdout else b""
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
|
||||
# 等待进程完成
|
||||
dump_stderr = await dump_proc.stderr.read() if dump_proc.stderr else b""
|
||||
gzip_stderr = await gzip_proc.stderr.read() if gzip_proc.stderr else b""
|
||||
|
||||
await dump_proc.wait()
|
||||
await gzip_proc.wait()
|
||||
|
||||
if dump_proc.returncode != 0:
|
||||
error_msg = dump_stderr.decode("utf-8", errors="ignore")[-500:]
|
||||
raise RuntimeError(f"mysqldump 执行失败: {error_msg}")
|
||||
|
||||
if gzip_proc.returncode != 0:
|
||||
error_msg = gzip_stderr.decode("utf-8", errors="ignore")
|
||||
raise RuntimeError(f"gzip 压缩失败: {error_msg}")
|
||||
|
||||
except Exception:
|
||||
# 清理失败的文件
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
raise
|
||||
|
||||
return filepath
|
||||
|
||||
async def _backup_postgres(self, instance: Instance, database: str,
|
||||
password: str, timestamp: str) -> str:
|
||||
"""使用 pg_dump 备份 PostgreSQL 数据库。
|
||||
|
||||
使用 custom format(已内置压缩)。
|
||||
"""
|
||||
filename = f"{database}_postgres_{timestamp}.dump"
|
||||
filepath = os.path.join(BACKUP_DIR, filename)
|
||||
|
||||
# 检查 pg_dump 是否可用
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"pg_dump", "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("pg_dump 未安装")
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
"pg_dump 未安装,请安装 PostgreSQL 客户端工具:\\n"
|
||||
" apt-get install postgresql-client 或\\n"
|
||||
" yum install postgresql"
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = password
|
||||
|
||||
cmd = [
|
||||
"pg_dump",
|
||||
"-h", instance.host,
|
||||
"-p", str(instance.port),
|
||||
"-U", instance.username,
|
||||
"-d", database,
|
||||
"-F", "c", # custom format (compressed)
|
||||
"-Z", "5", # compression level 5
|
||||
"--no-owner", # don't add ownership commands
|
||||
"--no-privileges", # don't add privilege commands
|
||||
"-f", filepath,
|
||||
]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode("utf-8", errors="ignore")[-500:]
|
||||
# 清理失败的文件
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
raise RuntimeError(f"pg_dump 执行失败: {error_msg}")
|
||||
|
||||
return filepath
|
||||
|
||||
async def restore_mysql(self, instance: Instance, database: str,
|
||||
filepath: str) -> str:
|
||||
"""从备份恢复 MySQL 数据库。
|
||||
|
||||
Args:
|
||||
instance: 目标实例
|
||||
database: 目标数据库
|
||||
filepath: 备份文件路径(.sql.gz)
|
||||
"""
|
||||
password = decrypt_password(instance.password)
|
||||
|
||||
if filepath.endswith(".gz"):
|
||||
# gunzip 解压后通过 mysql 导入
|
||||
gunzip_proc = await asyncio.create_subprocess_exec(
|
||||
"gunzip", "-c", filepath,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
mysql_cmd = [
|
||||
"mysql",
|
||||
"-h", instance.host,
|
||||
"-P", str(instance.port),
|
||||
"-u", instance.username,
|
||||
f"-p{password}",
|
||||
database,
|
||||
]
|
||||
|
||||
mysql_proc = await asyncio.create_subprocess_exec(
|
||||
*mysql_cmd,
|
||||
stdin=gunzip_proc.stdout,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
if gunzip_proc.stdout:
|
||||
gunzip_proc.stdout.close()
|
||||
|
||||
await mysql_proc.wait()
|
||||
await gunzip_proc.wait()
|
||||
|
||||
if mysql_proc.returncode != 0:
|
||||
raise RuntimeError("MySQL 恢复失败")
|
||||
else:
|
||||
# 直接导入 .sql 文件
|
||||
with open(filepath, "rb") as f:
|
||||
mysql_cmd = [
|
||||
"mysql",
|
||||
"-h", instance.host,
|
||||
"-P", str(instance.port),
|
||||
"-u", instance.username,
|
||||
f"-p{password}",
|
||||
database,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*mysql_cmd,
|
||||
stdin=f,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"MySQL 恢复失败: {stderr.decode()[-500:]}")
|
||||
|
||||
return f"数据库 {database} 恢复成功"
|
||||
|
||||
async def restore_postgres(self, instance: Instance, database: str,
|
||||
filepath: str) -> str:
|
||||
"""从备份恢复 PostgreSQL 数据库。
|
||||
|
||||
Args:
|
||||
instance: 目标实例
|
||||
database: 目标数据库
|
||||
filepath: 备份文件路径(.dump)
|
||||
"""
|
||||
password = decrypt_password(instance.password)
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = password
|
||||
|
||||
cmd = [
|
||||
"pg_restore",
|
||||
"-h", instance.host,
|
||||
"-p", str(instance.port),
|
||||
"-U", instance.username,
|
||||
"-d", database,
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"-c", # clean (drop) database objects before recreating
|
||||
filepath,
|
||||
]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
|
||||
_, stderr = await proc.communicate()
|
||||
|
||||
# pg_restore 即使有一些警告也可能成功
|
||||
if proc.returncode not in (0, 1): # 1 = warnings
|
||||
error_msg = stderr.decode("utf-8", errors="ignore")[-500:]
|
||||
raise RuntimeError(f"pg_restore 失败: {error_msg}")
|
||||
|
||||
return f"数据库 {database} 恢复成功"
|
||||
|
||||
@staticmethod
|
||||
def validate_backup_file(filepath: str) -> bool:
|
||||
"""验证备份文件是否有效。
|
||||
|
||||
- MySQL: 检查 gzip 文件完整性
|
||||
- PostgreSQL: 检查 custom format 文件
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
return False
|
||||
|
||||
if filepath.endswith(".gz"):
|
||||
# 使用 gzip -t 测试
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gzip", "-t", filepath],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
# 简单检查文件大小和魔数
|
||||
with open(filepath, "rb") as f:
|
||||
magic = f.read(2)
|
||||
return magic == b"\\x1f\\x8b" # gzip magic number
|
||||
|
||||
elif filepath.endswith(".dump"):
|
||||
# PostgreSQL custom format: 检查文件大小和魔数
|
||||
if os.path.getsize(filepath) < 100:
|
||||
return False
|
||||
with open(filepath, "rb") as f:
|
||||
header = f.read(5)
|
||||
# PostgreSQL custom format starts with "PGDMP"
|
||||
return header == b"PGDMP"
|
||||
|
||||
return os.path.getsize(filepath) > 0
|
||||
|
||||
@staticmethod
|
||||
def get_backup_size(filepath: str) -> int:
|
||||
"""获取备份文件大小(字节)。"""
|
||||
if os.path.exists(filepath):
|
||||
return os.path.getsize(filepath)
|
||||
return 0
|
||||
Reference in new issue
Block a user