mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat(doctor): add database backup recovery guidance (#6397)
This commit is contained in:
@@ -46,6 +46,73 @@ class ProcessRunner(Protocol):
|
||||
"""执行命令并返回结果。"""
|
||||
|
||||
|
||||
def verify_database_backup(
|
||||
artifact: Path,
|
||||
*,
|
||||
db_type: str,
|
||||
runner: ProcessRunner = subprocess.run,
|
||||
tool_resolver: Callable[[str], str | None] = shutil.which,
|
||||
pg_restore: str = "pg_restore",
|
||||
) -> DatabaseBackupCheck:
|
||||
"""在不访问活动数据库的前提下校验一个受管备份文件。"""
|
||||
if db_type == "sqlite":
|
||||
method = "PRAGMA integrity_check"
|
||||
try:
|
||||
# 正式备份不会再变化,immutable 可避免只读校验创建 WAL 旁路文件。
|
||||
uri = f"{artifact.resolve().as_uri()}?mode=ro&immutable=1"
|
||||
with closing(sqlite3.connect(uri, uri=True)) as connection:
|
||||
rows = connection.execute("PRAGMA integrity_check").fetchall()
|
||||
except sqlite3.Error as error:
|
||||
return DatabaseBackupCheck(False, method, str(error))
|
||||
valid = bool(rows) and all(row[0] == "ok" for row in rows)
|
||||
detail = None if valid else "; ".join(str(row[0]) for row in rows)
|
||||
return DatabaseBackupCheck(valid, method, detail)
|
||||
|
||||
if db_type == "postgresql":
|
||||
method = "pg_restore --list"
|
||||
executable = _require_tool(pg_restore, tool_resolver)
|
||||
result = runner(
|
||||
[executable, "--list", str(artifact)],
|
||||
env=_postgres_environment(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
valid = result.returncode == 0 and bool(result.stdout.strip())
|
||||
detail = None if valid else f"pg_restore 退出码 {result.returncode}"
|
||||
return DatabaseBackupCheck(valid, method, detail)
|
||||
|
||||
raise ValueError(f"不支持的数据库备份类型:{db_type}")
|
||||
|
||||
|
||||
def _require_tool(
|
||||
executable: str,
|
||||
tool_resolver: Callable[[str], str | None],
|
||||
) -> str:
|
||||
resolved = tool_resolver(executable)
|
||||
if resolved is None:
|
||||
raise RuntimeError(
|
||||
f"未找到 {executable},请安装与服务端同主版本或更高的 "
|
||||
"PostgreSQL client 并加入 PATH"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def _postgres_environment(
|
||||
*,
|
||||
password: str | None = None,
|
||||
sslmode: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
environment = dict(os.environ)
|
||||
environment.pop("PGPASSWORD", None)
|
||||
environment.pop("PGSSLMODE", None)
|
||||
if password:
|
||||
environment["PGPASSWORD"] = password
|
||||
if sslmode:
|
||||
environment["PGSSLMODE"] = sslmode
|
||||
return environment
|
||||
|
||||
|
||||
class SQLiteBackupBackend:
|
||||
"""使用 SQLite 在线备份 API 管理活动文件数据库。"""
|
||||
|
||||
@@ -71,17 +138,7 @@ class SQLiteBackupBackend:
|
||||
|
||||
def verify(self, artifact: Path) -> DatabaseBackupCheck:
|
||||
"""通过 SQLite integrity_check 校验备份内容。"""
|
||||
method = "PRAGMA integrity_check"
|
||||
try:
|
||||
# 已发布前的临时快照不会再变化;immutable 避免 WAL 模式为只读校验创建旁路文件。
|
||||
uri = f"{artifact.resolve().as_uri()}?mode=ro&immutable=1"
|
||||
with closing(sqlite3.connect(uri, uri=True)) as connection:
|
||||
rows = connection.execute("PRAGMA integrity_check").fetchall()
|
||||
except sqlite3.Error as error:
|
||||
return DatabaseBackupCheck(False, method, str(error))
|
||||
valid = bool(rows) and all(row[0] == "ok" for row in rows)
|
||||
detail = None if valid else "; ".join(str(row[0]) for row in rows)
|
||||
return DatabaseBackupCheck(valid, method, detail)
|
||||
return verify_database_backup(artifact, db_type=self.db_type)
|
||||
|
||||
def restore(self, artifact: Path) -> None:
|
||||
"""在 CLI 离线进程中原子替换活动 SQLite 文件。"""
|
||||
@@ -137,14 +194,13 @@ class PostgreSQLBackupBackend:
|
||||
|
||||
def verify(self, artifact: Path) -> DatabaseBackupCheck:
|
||||
"""通过 pg_restore 目录读取校验 custom-format 归档。"""
|
||||
method = "pg_restore --list"
|
||||
result = self._run(
|
||||
[self._require_tool(self._pg_restore), "--list", str(artifact)],
|
||||
include_password=False,
|
||||
return verify_database_backup(
|
||||
artifact,
|
||||
db_type=self.db_type,
|
||||
runner=self._runner,
|
||||
tool_resolver=self._tool_resolver,
|
||||
pg_restore=self._pg_restore,
|
||||
)
|
||||
valid = result.returncode == 0 and bool(result.stdout.strip())
|
||||
detail = None if valid else f"pg_restore 退出码 {result.returncode}"
|
||||
return DatabaseBackupCheck(valid, method, detail)
|
||||
|
||||
def restore(self, artifact: Path) -> None:
|
||||
"""在 CLI 离线进程中覆盖当前 PostgreSQL 数据库内容。"""
|
||||
@@ -189,21 +245,16 @@ class PostgreSQLBackupBackend:
|
||||
)
|
||||
|
||||
def _require_tool(self, executable: str) -> str:
|
||||
resolved = self._tool_resolver(executable)
|
||||
if resolved is None:
|
||||
raise RuntimeError(
|
||||
f"未找到 {executable},请安装与服务端同主版本或更高的 "
|
||||
"PostgreSQL client 并加入 PATH"
|
||||
)
|
||||
return resolved
|
||||
return _require_tool(executable, self._tool_resolver)
|
||||
|
||||
def _environment(self, *, include_password: bool) -> dict[str, str]:
|
||||
environment = dict(os.environ)
|
||||
environment.pop("PGPASSWORD", None)
|
||||
environment.pop("PGSSLMODE", None)
|
||||
if include_password and self._engine.url.password:
|
||||
environment["PGPASSWORD"] = str(self._engine.url.password)
|
||||
password = (
|
||||
str(self._engine.url.password)
|
||||
if include_password and self._engine.url.password
|
||||
else None
|
||||
)
|
||||
sslmode = self._engine.url.query.get("sslmode")
|
||||
if sslmode:
|
||||
environment["PGSSLMODE"] = str(sslmode)
|
||||
return environment
|
||||
return _postgres_environment(
|
||||
password=password,
|
||||
sslmode=str(sslmode) if sslmode else None,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ from urllib.request import Request, urlopen
|
||||
|
||||
import psutil
|
||||
|
||||
from app.adapters.system.backup.database import verify_database_backup
|
||||
from app.adapters.system.backup.files import BackupFiles
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.topology import process_topology_issue
|
||||
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorReport, DoctorSeverity
|
||||
@@ -824,6 +826,116 @@ def _check_database(runner: DoctorRunnerProtocol) -> None:
|
||||
_check_postgresql_database(runner)
|
||||
else:
|
||||
_check_sqlite_database(runner)
|
||||
_check_database_backups(runner)
|
||||
|
||||
|
||||
def _check_database_backups(runner: DoctorRunnerProtocol) -> None:
|
||||
"""列举并离线校验与当前数据库类型匹配的受管备份。"""
|
||||
db_type = "postgresql" if settings.DB_TYPE.lower() == "postgresql" else "sqlite"
|
||||
try:
|
||||
paths = BackupFiles(settings.DATABASE_BACKUP_PATH).list()
|
||||
except OSError as error:
|
||||
runner.add(
|
||||
finding_id="database.backup_recovery",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="数据库备份目录无法读取",
|
||||
detail=str(error),
|
||||
recommendation="检查数据库备份目录是否存在且当前用户具有读取权限。",
|
||||
context={"db_type": db_type},
|
||||
)
|
||||
return
|
||||
|
||||
matching = [path for path in paths if BackupFiles.database_type(path.name) == db_type]
|
||||
mismatched = [path.name for path in paths if path not in matching]
|
||||
if not paths:
|
||||
runner.add(
|
||||
finding_id="database.backup_recovery",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Skipped,
|
||||
title="未找到受管数据库备份",
|
||||
detail=f"当前数据库类型为 {db_type},备份目录中没有正式备份文件。",
|
||||
recommendation="可执行 `moviepilot database backup` 创建一次可校验备份。",
|
||||
affects_report_status=False,
|
||||
context={"db_type": db_type, "backups": []},
|
||||
)
|
||||
return
|
||||
|
||||
if not matching:
|
||||
runner.add(
|
||||
finding_id="database.backup_recovery",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="没有匹配当前数据库类型的备份",
|
||||
detail=f"当前数据库类型为 {db_type},仅找到其他类型备份:{', '.join(mismatched)}。",
|
||||
recommendation="确认数据库类型配置,或执行 `moviepilot database backup` 创建当前类型备份。",
|
||||
affects_report_status=False,
|
||||
context={"db_type": db_type, "backups": [], "mismatched": mismatched},
|
||||
)
|
||||
return
|
||||
|
||||
backups = []
|
||||
for path in matching:
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
verification = verify_database_backup(path, db_type=db_type)
|
||||
valid = verification.valid
|
||||
method = verification.method
|
||||
detail = verification.detail
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
size = None
|
||||
valid = False
|
||||
method = "unavailable"
|
||||
detail = str(error)
|
||||
backups.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"db_type": db_type,
|
||||
"size": size,
|
||||
"valid": valid,
|
||||
"method": method,
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
|
||||
valid_backups = [backup for backup in backups if backup["valid"]]
|
||||
context = {
|
||||
"db_type": db_type,
|
||||
"backups": backups,
|
||||
"mismatched": mismatched,
|
||||
}
|
||||
if valid_backups:
|
||||
newest = valid_backups[0]
|
||||
command = f"moviepilot database restore {newest['name']} --confirm"
|
||||
runner.add(
|
||||
finding_id="database.backup_recovery",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="存在可还原的数据库备份",
|
||||
detail=f"已校验 {len(backups)} 个 {db_type} 备份,其中 {len(valid_backups)} 个可用。",
|
||||
recommendation=f"需要恢复时先停止 MoviePilot,再执行 `{command}`。",
|
||||
context={**context, "restore_command": command},
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
failures = "; ".join(
|
||||
f"{backup['name']}: {backup['detail'] or backup['method']}"
|
||||
for backup in backups
|
||||
)
|
||||
runner.add(
|
||||
finding_id="database.backup_recovery",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="数据库备份均未通过校验",
|
||||
detail=(
|
||||
f"已检查 {len(backups)} 个 {db_type} 备份,没有可直接还原的文件。"
|
||||
f"校验结果:{failures}"
|
||||
),
|
||||
recommendation="根据校验详情处理备份文件或 PostgreSQL client,再重新运行 doctor。",
|
||||
affects_report_status=False,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
def _check_frontend_assets(runner: DoctorRunnerProtocol) -> None:
|
||||
|
||||
Reference in New Issue
Block a user