mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
feat(doctor): add database backup recovery guidance (#6397)
This commit is contained in:
+5
-2
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6379,
|
||||
"edge_sha256": "1c619d72157004590838a85c497330b8ca462d3bc1eb490fd5e7bac97e6190b8",
|
||||
"edge_count": 6382,
|
||||
"edge_sha256": "d94f2e60efcc1697fe8dfa0ad23cbc99c38fd4873caf889ae1c44769c69fdcc3",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -3659,6 +3659,9 @@
|
||||
"app.db.session -> app.runtime.observability",
|
||||
"app.doctor.checks -> app.adapters",
|
||||
"app.doctor.checks -> app.adapters.system",
|
||||
"app.doctor.checks -> app.adapters.system.backup",
|
||||
"app.doctor.checks -> app.adapters.system.backup.database",
|
||||
"app.doctor.checks -> app.adapters.system.backup.files",
|
||||
"app.doctor.checks -> app.adapters.system.host",
|
||||
"app.doctor.checks -> app.doctor",
|
||||
"app.doctor.checks -> app.doctor.models",
|
||||
|
||||
@@ -6,12 +6,14 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from app.adapters.system.backup.database import (
|
||||
PostgreSQLBackupBackend,
|
||||
SQLiteBackupBackend,
|
||||
verify_database_backup,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,6 +36,15 @@ def test_sqlite_backup_includes_committed_wal_data(tmp_path: Path) -> None:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_sqlite_backup_can_be_verified_without_active_engine(tmp_path: Path) -> None:
|
||||
"""Doctor 等离线入口不应为校验备份而构造活动数据库引擎。"""
|
||||
artifact = tmp_path / "backup.db"
|
||||
with sqlite3.connect(artifact) as connection:
|
||||
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")
|
||||
|
||||
assert verify_database_backup(artifact, db_type="sqlite").valid is True
|
||||
|
||||
|
||||
def test_sqlite_restore_replaces_database_and_removes_old_wal_files(tmp_path: Path) -> None:
|
||||
source = tmp_path / "user.db"
|
||||
backup = tmp_path / "backup.db"
|
||||
@@ -66,6 +77,11 @@ class _Runner:
|
||||
return subprocess.CompletedProcess(command, 0, stdout, "")
|
||||
|
||||
|
||||
class _FailedRunner:
|
||||
def __call__(self, command, **_kwargs):
|
||||
return subprocess.CompletedProcess(command, 1, "", "invalid archive")
|
||||
|
||||
|
||||
def _postgres_backend(runner: _Runner) -> PostgreSQLBackupBackend:
|
||||
engine = SimpleNamespace(
|
||||
url=make_url(
|
||||
@@ -114,6 +130,55 @@ def test_postgresql_verify_and_restore_use_pg_restore(tmp_path: Path) -> None:
|
||||
assert restore_kwargs["env"]["PGPASSWORD"] == "secret"
|
||||
|
||||
|
||||
def test_postgresql_backup_can_be_verified_without_active_engine(tmp_path: Path) -> None:
|
||||
"""PostgreSQL 归档校验只依赖 pg_restore,不连接活动数据库。"""
|
||||
runner = _Runner()
|
||||
artifact = tmp_path / "backup.dump"
|
||||
artifact.write_bytes(b"PGDMP")
|
||||
|
||||
result = verify_database_backup(
|
||||
artifact,
|
||||
db_type="postgresql",
|
||||
runner=runner,
|
||||
tool_resolver=lambda executable: executable,
|
||||
)
|
||||
|
||||
assert result.valid is True
|
||||
command, kwargs = runner.calls[0]
|
||||
assert command == ["pg_restore", "--list", str(artifact)]
|
||||
assert "PGPASSWORD" not in kwargs["env"]
|
||||
assert "PGSSLMODE" not in kwargs["env"]
|
||||
|
||||
|
||||
def test_postgresql_offline_verify_rejects_invalid_archive(tmp_path: Path) -> None:
|
||||
"""pg_restore 无法读取归档目录时备份必须判定为无效。"""
|
||||
artifact = tmp_path / "backup.dump"
|
||||
artifact.write_bytes(b"invalid")
|
||||
|
||||
result = verify_database_backup(
|
||||
artifact,
|
||||
db_type="postgresql",
|
||||
runner=_FailedRunner(),
|
||||
tool_resolver=lambda executable: executable,
|
||||
)
|
||||
|
||||
assert result.valid is False
|
||||
assert result.detail == "pg_restore 退出码 1"
|
||||
|
||||
|
||||
def test_postgresql_offline_verify_reports_missing_client(tmp_path: Path) -> None:
|
||||
"""缺少 pg_restore 时离线校验应给出可执行的安装提示。"""
|
||||
artifact = tmp_path / "backup.dump"
|
||||
artifact.write_bytes(b"PGDMP")
|
||||
|
||||
with pytest.raises(RuntimeError, match="PostgreSQL client"):
|
||||
verify_database_backup(
|
||||
artifact,
|
||||
db_type="postgresql",
|
||||
tool_resolver=lambda _executable: None,
|
||||
)
|
||||
|
||||
|
||||
def test_postgresql_source_install_reports_missing_native_client() -> None:
|
||||
runner = _Runner()
|
||||
engine = SimpleNamespace(
|
||||
|
||||
+95
-2
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import sqlite3
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.doctor import checks, run_doctor
|
||||
from app.doctor import checks
|
||||
from app.doctor.formatters import format_json_report, format_text_report
|
||||
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
|
||||
from app.doctor.runner import DoctorRunner
|
||||
from app.doctor.runner import DoctorRunner, run_doctor
|
||||
|
||||
|
||||
def _current_log_timestamp() -> str:
|
||||
@@ -33,6 +34,98 @@ def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
|
||||
assert any(item["id"] == "runtime.paths" for item in payload["findings"])
|
||||
|
||||
|
||||
def test_doctor_reports_valid_backup_when_sqlite_database_is_corrupt(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""主数据库损坏时 Doctor 仍应离线校验备份并给出还原命令。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
(tmp_path / "user.db").write_bytes(b"not a sqlite database")
|
||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||
backup_dir.mkdir(parents=True)
|
||||
backup = backup_dir / "sqlite_20260822_030000.db"
|
||||
with sqlite3.connect(backup) as connection:
|
||||
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")
|
||||
(backup_dir / "sqlite_20260822_040000.db").write_bytes(b"invalid newer backup")
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_database(runner)
|
||||
|
||||
assert runner.report.find("database.sqlite_open_failed") is not None
|
||||
finding = runner.report.find("database.backup_recovery")
|
||||
assert finding is not None
|
||||
assert finding.status == DoctorFindingStatus.Ok
|
||||
assert finding.context["backups"][0]["valid"] is False
|
||||
assert finding.context["backups"][1]["valid"] is True
|
||||
assert finding.context["restore_command"] == (
|
||||
"moviepilot database restore sqlite_20260822_030000.db --confirm"
|
||||
)
|
||||
assert finding.context["restore_command"] in finding.recommendation
|
||||
|
||||
|
||||
def test_doctor_distinguishes_missing_and_mismatched_backups(tmp_path, monkeypatch):
|
||||
"""无备份与仅存在其他数据库类型备份应生成不同诊断结论。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
runner = DoctorRunner()
|
||||
checks._check_database_backups(runner)
|
||||
missing = runner.report.find("database.backup_recovery")
|
||||
assert missing is not None
|
||||
assert missing.status == DoctorFindingStatus.Skipped
|
||||
|
||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||
backup_dir.mkdir(parents=True)
|
||||
(backup_dir / "postgresql_20260822_030000.dump").write_bytes(b"PGDMP")
|
||||
runner = DoctorRunner()
|
||||
checks._check_database_backups(runner)
|
||||
mismatched = runner.report.find("database.backup_recovery")
|
||||
assert mismatched is not None
|
||||
assert mismatched.status == DoctorFindingStatus.Degraded
|
||||
assert mismatched.context["mismatched"] == ["postgresql_20260822_030000.dump"]
|
||||
|
||||
|
||||
def test_doctor_reports_invalid_backup_without_modifying_it(tmp_path, monkeypatch):
|
||||
"""Doctor --fix 也只校验备份,不覆盖或删除无效文件。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||
backup_dir.mkdir(parents=True)
|
||||
backup = backup_dir / "sqlite_20260822_030000.db"
|
||||
original = b"invalid sqlite backup"
|
||||
backup.write_bytes(original)
|
||||
|
||||
runner = DoctorRunner(fix=True)
|
||||
checks._check_database_backups(runner)
|
||||
|
||||
finding = runner.report.find("database.backup_recovery")
|
||||
assert finding is not None
|
||||
assert finding.status == DoctorFindingStatus.Failed
|
||||
assert finding.affects_report_status is False
|
||||
assert finding.context["backups"][0]["valid"] is False
|
||||
assert backup.read_bytes() == original
|
||||
assert runner.report.status.value == "healthy"
|
||||
|
||||
|
||||
def test_doctor_exposes_missing_pg_restore_in_text_finding(tmp_path, monkeypatch):
|
||||
"""PostgreSQL 离线校验工具缺失时应直接告诉用户如何补齐。"""
|
||||
def missing_pg_restore(*_args, **_kwargs):
|
||||
raise RuntimeError("未找到 pg_restore,请安装 PostgreSQL client 并加入 PATH")
|
||||
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "DB_TYPE", "postgresql")
|
||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||
backup_dir.mkdir(parents=True)
|
||||
(backup_dir / "postgresql_20260822_030000.dump").write_bytes(b"PGDMP")
|
||||
monkeypatch.setattr(checks, "verify_database_backup", missing_pg_restore)
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_database_backups(runner)
|
||||
|
||||
finding = runner.report.find("database.backup_recovery")
|
||||
assert finding is not None
|
||||
assert finding.status == DoctorFindingStatus.Failed
|
||||
assert "未找到 pg_restore" in finding.detail
|
||||
assert "PostgreSQL client" in format_text_report(runner.report)
|
||||
|
||||
|
||||
def test_doctor_formatters_include_status_and_finding(tmp_path, monkeypatch):
|
||||
"""doctor 文本和 JSON 格式化应展示状态与诊断项。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
|
||||
Reference in New Issue
Block a user