feat(database): add managed backup and offline restore (#6359)

This commit is contained in:
InfinityPacer
2026-08-19 17:34:32 +08:00
committed by GitHub
parent f4a879b3de
commit f700db5ac8
27 changed files with 1576 additions and 80 deletions
+43 -7
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6029,
"edge_sha256": "a312bbaffcb68adc5ead36130d0e1e20056cab73273727a55182f2bfed5efd34",
"edge_count": 6059,
"edge_sha256": "85ab4dd2f01f48bae417f0401f272763f15ba7256b58ca0ab5a8256f4946c2f9",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2115,6 +2115,7 @@
"app.api.endpoints.system -> app.runtime.localization",
"app.api.endpoints.system -> app.runtime.log",
"app.api.endpoints.system -> app.runtime.progress",
"app.api.endpoints.system -> app.runtime.scheduling",
"app.api.endpoints.system -> app.runtime.state",
"app.api.endpoints.system -> app.schemas",
"app.api.endpoints.system -> app.schemas.common",
@@ -2302,11 +2303,20 @@
"app.application.audio -> app.runtime.log",
"app.application.audio -> app.schemas",
"app.application.audio -> app.schemas.types",
"app.application.backup -> app.adapters",
"app.application.backup -> app.adapters.system",
"app.application.backup -> app.adapters.system.backup",
"app.application.backup -> app.adapters.system.backup.files",
"app.application.backup -> app.runtime",
"app.application.backup -> app.runtime.log",
"app.application.chain.context -> app.application",
"app.application.chain.context -> app.application.chain",
"app.application.chain.context -> app.application.chain.data",
"app.application.dashboard -> app.schemas",
"app.application.dashboard -> app.schemas.dashboard",
"app.application.database -> app.application",
"app.application.database -> app.application.backup",
"app.application.database -> app.application.maintenance",
"app.application.directory -> app.adapters",
"app.application.directory -> app.adapters.system",
"app.application.directory -> app.adapters.system.host",
@@ -3214,11 +3224,15 @@
"app.chain.workflow -> app.schemas.types",
"app.chain.workflow -> app.schemas.workflow",
"app.chain.workflow -> app.workflow",
"app.cli -> app.application",
"app.cli -> app.application.backup",
"app.cli -> app.doctor",
"app.cli -> app.doctor.formatters",
"app.cli -> app.runtime",
"app.cli -> app.runtime.config",
"app.cli -> app.runtime.state",
"app.cli -> app.startup",
"app.cli -> app.startup.database",
"app.command -> app.application",
"app.command -> app.application.messaging",
"app.command -> app.application.messaging.message",
@@ -5395,8 +5409,8 @@
"app.scheduler -> app.agent",
"app.scheduler -> app.agent.runtime_loader",
"app.scheduler -> app.application",
"app.scheduler -> app.application.database",
"app.scheduler -> app.application.image",
"app.scheduler -> app.application.maintenance",
"app.scheduler -> app.application.messaging",
"app.scheduler -> app.application.messaging.message",
"app.scheduler -> app.application.scheduling",
@@ -5554,6 +5568,9 @@
"app.sdk.cache -> app.runtime.cache",
"app.sdk.config -> app.runtime",
"app.sdk.config -> app.runtime.config",
"app.sdk.database -> app.application",
"app.sdk.database -> app.application.backup",
"app.sdk.database -> app.application.database",
"app.sdk.events -> app.runtime",
"app.sdk.events -> app.runtime.events",
"app.sdk.logging -> app.runtime",
@@ -5667,6 +5684,21 @@
"app.startup.command_initializer -> app.application",
"app.startup.command_initializer -> app.application.commands",
"app.startup.command_initializer -> app.command",
"app.startup.database -> app.adapters",
"app.startup.database -> app.adapters.system",
"app.startup.database -> app.adapters.system.backup",
"app.startup.database -> app.adapters.system.backup.database",
"app.startup.database -> app.application",
"app.startup.database -> app.application.backup",
"app.startup.database -> app.application.database",
"app.startup.database -> app.application.maintenance",
"app.startup.database -> app.db",
"app.startup.database -> app.db.engine",
"app.startup.database -> app.db.health",
"app.startup.database -> app.db.maintenance",
"app.startup.database -> app.db.session",
"app.startup.database -> app.runtime",
"app.startup.database -> app.runtime.config",
"app.startup.database_initializer -> app.db",
"app.startup.database_initializer -> app.db.engine",
"app.startup.database_initializer -> app.db.models",
@@ -5744,7 +5776,6 @@
"app.startup.modules_initializer -> app.application.database",
"app.startup.modules_initializer -> app.application.history",
"app.startup.modules_initializer -> app.application.image",
"app.startup.modules_initializer -> app.application.maintenance",
"app.startup.modules_initializer -> app.application.messaging",
"app.startup.modules_initializer -> app.application.messaging.chat",
"app.startup.modules_initializer -> app.application.messaging.message",
@@ -5777,8 +5808,6 @@
"app.startup.modules_initializer -> app.chain.workflow",
"app.startup.modules_initializer -> app.command",
"app.startup.modules_initializer -> app.db",
"app.startup.modules_initializer -> app.db.health",
"app.startup.modules_initializer -> app.db.maintenance",
"app.startup.modules_initializer -> app.db.oper",
"app.startup.modules_initializer -> app.db.oper.agentchat",
"app.startup.modules_initializer -> app.db.oper.agenttask",
@@ -5818,6 +5847,7 @@
"app.startup.modules_initializer -> app.schemas.types",
"app.startup.modules_initializer -> app.startup",
"app.startup.modules_initializer -> app.startup.agent_initializer",
"app.startup.modules_initializer -> app.startup.database",
"app.startup.modules_initializer -> app.startup.managed_resources_initializer",
"app.startup.monitor_initializer -> app.monitor",
"app.startup.plugins_initializer -> app.adapters",
@@ -6046,7 +6076,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 746,
"module_count": 752,
"modules": [
"app",
"app.adapters",
@@ -6069,6 +6099,9 @@
"app.adapters.network.http",
"app.adapters.network.ip",
"app.adapters.system",
"app.adapters.system.backup",
"app.adapters.system.backup.database",
"app.adapters.system.backup.files",
"app.adapters.system.display",
"app.adapters.system.display.resource",
"app.adapters.system.fsproxy",
@@ -6275,6 +6308,7 @@
"app.application.agent",
"app.application.agentdata",
"app.application.audio",
"app.application.backup",
"app.application.chain",
"app.application.chain.context",
"app.application.chain.data",
@@ -6748,6 +6782,7 @@
"app.sdk.browser",
"app.sdk.cache",
"app.sdk.config",
"app.sdk.database",
"app.sdk.events",
"app.sdk.logging",
"app.sdk.media",
@@ -6761,6 +6796,7 @@
"app.startup.agent_initializer",
"app.startup.cache_initializer",
"app.startup.command_initializer",
"app.startup.database",
"app.startup.database_initializer",
"app.startup.domain_initializer",
"app.startup.lifecycle",
+33 -6
View File
@@ -1210,19 +1210,19 @@
},
{
"caller": "app.api.endpoints.system",
"line": 816
"line": 859
},
{
"caller": "app.api.endpoints.system",
"line": 930
"line": 973
},
{
"caller": "app.api.endpoints.system",
"line": 984
"line": 1027
},
{
"caller": "app.api.endpoints.system",
"line": 998
"line": 1041
},
{
"caller": "app.chain._messaging",
@@ -1652,7 +1652,7 @@
"consumers": [
{
"caller": "app.scheduler",
"line": 1116
"line": 1150
}
],
"producers": []
@@ -1813,7 +1813,7 @@
},
{
"caller": "app.scheduler",
"line": 736
"line": 770
}
]
},
@@ -3729,6 +3729,33 @@
"target": "app.runtime.config.settings"
}
],
"app.sdk.database": [
{
"kind": "import",
"name": "BackupArtifact",
"target": "app.application.backup.BackupArtifact"
},
{
"kind": "import",
"name": "BackupVerification",
"target": "app.application.backup.BackupVerification"
},
{
"kind": "FunctionDef",
"name": "create_backup",
"target": ""
},
{
"kind": "FunctionDef",
"name": "list_backups",
"target": ""
},
{
"kind": "FunctionDef",
"name": "verify_backup",
"target": ""
}
],
"app.sdk.events": [
{
"kind": "import",
+9 -5
View File
@@ -2,7 +2,7 @@ import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@@ -16,9 +16,9 @@ from app.runtime.config import settings
from app.scheduler import SchedulerChain
from app.application.maintenance import (
DataCleanupService,
configure_cleanup_service_factory,
read_cleanup_policy,
)
from app.application.database import DatabaseGovernance, configure_database_governance
from app.db.maintenance import DatabaseCleanupRepository
@@ -51,13 +51,17 @@ class DataCleanupChainTest(unittest.TestCase):
return patch.multiple(settings, **defaults)
def _configure_cleanup_service(self):
"""把当前测试数据库注入清理应用服务,替代旧的 SessionFactory 打桩"""
configure_cleanup_service_factory(
lambda: DataCleanupService(
"""把当前测试数据库注入统一数据库治理门面"""
configure_database_governance(
DatabaseGovernance(
health=MagicMock(),
backup=MagicMock(),
cleanup=DataCleanupService(
repository=DatabaseCleanupRepository(
session_factory=self.SessionFactory,
),
policy_reader=read_cleanup_policy,
),
)
)
+4 -4
View File
@@ -117,15 +117,15 @@ def test_cleanup_service_finishes_other_tables_before_raising_partial_failure()
def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
"""旧 SchedulerChain 入口应原样转发参数和返回值。"""
service = MagicMock()
service.execute.return_value = {"enabled": True}
governance = MagicMock()
governance.cleanup.return_value = {"enabled": True}
progress = MagicMock()
with patch("app.scheduler.build_cleanup_service", return_value=service):
with patch("app.scheduler.get_database_governance", return_value=governance):
result = SchedulerChain().cleanup(batch_size=7, progress_callback=progress)
assert result == {"enabled": True}
service.execute.assert_called_once_with(
governance.cleanup.assert_called_once_with(
batch_size=7,
progress_callback=progress,
)
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import sqlite3
import subprocess
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from sqlalchemy import create_engine
from sqlalchemy.engine import make_url
from app.adapters.system.backup.database import (
PostgreSQLBackupBackend,
SQLiteBackupBackend,
)
def test_sqlite_backup_includes_committed_wal_data(tmp_path: Path) -> None:
source = tmp_path / "user.db"
with sqlite3.connect(source) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")
connection.execute("INSERT INTO entries VALUES ('from-wal')")
engine = create_engine(f"sqlite:///{source}")
backend = SQLiteBackupBackend(engine)
artifact = tmp_path / "backup.db"
backend.create(artifact)
assert backend.verify(artifact).valid is True
with sqlite3.connect(artifact) as connection:
assert connection.execute("SELECT value FROM entries").fetchone() == ("from-wal",)
engine.dispose()
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"
for path, value in ((source, "old"), (backup, "restored")):
with sqlite3.connect(path) as connection:
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")
connection.execute("INSERT INTO entries VALUES (?)", (value,))
source.with_name("user.db-wal").write_bytes(b"old wal")
source.with_name("user.db-shm").write_bytes(b"old shm")
backend = SQLiteBackupBackend(create_engine(f"sqlite:///{source}"))
backend.restore(backup)
with sqlite3.connect(source) as connection:
assert connection.execute("SELECT value FROM entries").fetchone() == ("restored",)
assert not source.with_name("user.db-wal").exists()
assert not source.with_name("user.db-shm").exists()
class _Runner:
def __init__(self) -> None:
self.calls: list[tuple[list[str], dict]] = []
def __call__(self, command, **kwargs):
command = list(command)
self.calls.append((command, kwargs))
if "--file" in command:
Path(command[command.index("--file") + 1]).write_bytes(b"PGDMP")
stdout = "; archive listing" if command[:2] == ["pg_restore", "--list"] else ""
return subprocess.CompletedProcess(command, 0, stdout, "")
def _postgres_backend(runner: _Runner) -> PostgreSQLBackupBackend:
engine = SimpleNamespace(
url=make_url(
"postgresql://moviepilot:secret@database.internal:5432/moviepilot"
"?sslmode=require"
),
dispose=Mock(),
)
return PostgreSQLBackupBackend(
engine,
runner=runner,
tool_resolver=lambda executable: executable,
)
def test_postgresql_backup_keeps_password_out_of_command_and_file(tmp_path: Path) -> None:
runner = _Runner()
backend = _postgres_backend(runner)
artifact = tmp_path / "backup.dump"
backend.create(artifact)
command, kwargs = runner.calls[0]
assert "--format=custom" in command
assert "secret" not in " ".join(command)
assert kwargs["env"]["PGPASSWORD"] == "secret"
assert kwargs["env"]["PGSSLMODE"] == "require"
assert artifact.read_bytes() == b"PGDMP"
def test_postgresql_verify_and_restore_use_pg_restore(tmp_path: Path) -> None:
runner = _Runner()
backend = _postgres_backend(runner)
artifact = tmp_path / "backup.dump"
artifact.write_bytes(b"PGDMP")
assert backend.verify(artifact).valid is True
backend.restore(artifact)
verify_command, verify_kwargs = runner.calls[0]
restore_command, restore_kwargs = runner.calls[1]
assert verify_command == ["pg_restore", "--list", str(artifact)]
assert "PGPASSWORD" not in verify_kwargs["env"]
assert "--single-transaction" in restore_command
assert "--clean" in restore_command
assert restore_kwargs["env"]["PGPASSWORD"] == "secret"
def test_postgresql_source_install_reports_missing_native_client() -> None:
runner = _Runner()
engine = SimpleNamespace(
url=make_url("postgresql://moviepilot:secret@database/moviepilot"),
dispose=Mock(),
)
backend = PostgreSQLBackupBackend(
engine,
runner=runner,
tool_resolver=lambda _executable: None,
)
try:
backend.create(Path("unused.dump"))
except RuntimeError as error:
assert "安装与服务端同主版本或更高的 PostgreSQL client" in str(error)
else:
raise AssertionError("缺少 pg_dump 时未给出安装提示")
+77
View File
@@ -0,0 +1,77 @@
from datetime import datetime
from pathlib import Path
from unittest.mock import Mock
import pytest
from click.testing import CliRunner
from app import cli as cli_module
from app.application.backup import BackupArtifact, BackupVerification
from app.cli import cli
from app.sdk import database as database_sdk
NAME = "sqlite_20260819_030000.db"
def _artifact(tmp_path: Path) -> BackupArtifact:
path = tmp_path / NAME
path.write_bytes(b"snapshot")
return BackupArtifact(NAME, "sqlite", datetime(2026, 8, 19, 3, 0), path, 8)
def test_cli_backup_list_and_verify_use_local_governance(tmp_path: Path, monkeypatch) -> None:
governance = Mock()
governance.create_backup.return_value = _artifact(tmp_path)
governance.list_backups.return_value = (governance.create_backup.return_value,)
governance.verify_backup.return_value = BackupVerification(True, "integrity_check")
monkeypatch.setattr(cli_module, "build_database_governance", lambda: governance)
backup = CliRunner().invoke(cli, ["database", "backup"])
listed = CliRunner().invoke(cli, ["database", "list"])
verified = CliRunner().invoke(cli, ["database", "verify", NAME])
assert backup.exit_code == 0, backup.output
assert "name\tdb_type\tcreated_at\tsize\tpath" in backup.output
assert NAME in backup.output
assert listed.exit_code == 0 and NAME in listed.output
assert verified.exit_code == 0 and "校验通过" in verified.output
def test_cli_restore_requires_explicit_offline_confirmation(tmp_path: Path, monkeypatch) -> None:
governance = Mock()
governance.restore_backup.return_value = _artifact(tmp_path)
monkeypatch.setattr(cli_module, "build_database_governance", lambda: governance)
rejected = CliRunner().invoke(cli, ["database", "restore", NAME])
restored = CliRunner().invoke(cli, ["database", "restore", NAME, "--confirm"])
assert rejected.exit_code == 1
governance.restore_backup.assert_called_once_with(NAME)
assert restored.exit_code == 0 and "还原完成" in restored.output
def test_sdk_exposes_backup_without_restore_or_policy_controls(tmp_path: Path, monkeypatch) -> None:
governance = Mock()
artifact = _artifact(tmp_path)
verification = BackupVerification(True, "integrity_check")
governance.create_backup.return_value = artifact
governance.list_backups.return_value = (artifact,)
governance.verify_backup.return_value = verification
monkeypatch.setattr(database_sdk, "_get_database_governance", lambda: governance)
assert database_sdk.create_backup() == artifact
assert database_sdk.list_backups() == (artifact,)
assert database_sdk.verify_backup(NAME) == verification
assert not hasattr(database_sdk, "get_database_governance")
assert not hasattr(database_sdk, "restore_backup")
assert not hasattr(database_sdk, "delete_backup")
def test_sdk_rejects_invalid_name_at_host_boundary(monkeypatch) -> None:
governance = Mock()
governance.verify_backup.side_effect = ValueError("数据库备份文件名无效")
monkeypatch.setattr(database_sdk, "_get_database_governance", lambda: governance)
with pytest.raises(ValueError, match="文件名"):
database_sdk.verify_backup("../../user.db")
+50
View File
@@ -0,0 +1,50 @@
"""数据库备份所需 Docker 运行时工具合同。"""
import re
from pathlib import Path
def test_runtime_image_installs_postgresql_18_client_from_pgdg() -> None:
"""Bookworm 镜像必须从签名的 PGDG 源安装固定主版本客户端。"""
dockerfile = (
Path(__file__).resolve().parents[1] / "docker" / "Dockerfile"
).read_text(encoding="utf-8")
assert re.search(
r"^FROM python:[^\s]+-slim-bookworm AS base$", dockerfile, re.MULTILINE
)
assert "https://www.postgresql.org/media/keys/ACCC4CF8.asc" in dockerfile
for curl_option in (
"--connect-timeout 10",
"--max-time 30",
"--retry 3",
"--retry-all-errors",
"--retry-max-time 90",
):
assert curl_option in dockerfile
keyring = "/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc"
assert f"chmod 0644 {keyring}" in dockerfile
assert f"signed-by={keyring}" in dockerfile
assert "https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" in dockerfile
assert re.search(r"^\s+postgresql-client-18 \\$", dockerfile, re.MULTILINE)
assert not re.search(r"^\s+postgresql-client \\$", dockerfile, re.MULTILINE)
def test_runtime_image_keeps_pgdg_setup_architecture_neutral_and_cleans_apt_cache(
) -> None:
"""PGDG 原生架构解析需同时适用于 amd64/arm64,且不得遗留 APT 索引。"""
dockerfile = (
Path(__file__).resolve().parents[1] / "docker" / "Dockerfile"
).read_text(encoding="utf-8")
pgdg_sources = [
line for line in dockerfile.splitlines() if "apt.postgresql.org/pub/repos/apt" in line
]
assert len(pgdg_sources) == 1
pgdg_source = pgdg_sources[0]
assert "arch=" not in pgdg_source
assert "/var/lib/apt/lists/*" in dockerfile
assert dockerfile.index("postgresql-client-18") < dockerfile.index(
"/var/lib/apt/lists/*"
)
+40
View File
@@ -0,0 +1,40 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from app.application.database import get_database_governance
from app.startup import database as startup_database
def test_builder_uses_cached_engine_as_database_fact_source(monkeypatch) -> None:
engine = SimpleNamespace(dialect=SimpleNamespace(name="sqlite"))
backend = Mock(db_type="sqlite", suffix=".db")
sqlite_backend = Mock(return_value=backend)
monkeypatch.setattr(startup_database, "get_engine", lambda: engine)
monkeypatch.setattr(startup_database, "SQLiteBackupBackend", sqlite_backend)
monkeypatch.setattr(startup_database.settings, "DB_TYPE", "postgresql")
startup_database.build_database_governance()
sqlite_backend.assert_called_once_with(engine)
def test_backup_policy_reads_current_path_and_retention(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(startup_database.settings, "DB_BACKUP_PATH", str(tmp_path))
monkeypatch.setattr(startup_database.settings, "DB_BACKUP_RETENTION_DAYS", 7)
monkeypatch.setattr(startup_database.settings, "DB_BACKUP_MAX_COUNT", 5)
policy = startup_database.read_backup_policy()
assert policy.root == tmp_path
assert policy.retention_days == 7
assert policy.max_count == 5
def test_configure_registers_one_database_governance(monkeypatch) -> None:
governance = Mock()
monkeypatch.setattr(startup_database, "build_database_governance", lambda: governance)
startup_database.configure_database()
assert get_database_governance() is governance
+93
View File
@@ -0,0 +1,93 @@
"""数据库备份与宿主调度器的接入合同。"""
import ast
from pathlib import Path
from unittest.mock import Mock
from app import scheduler as scheduler_module
from app.scheduler import Scheduler
class _SchedulerStub:
def __init__(self) -> None:
self.jobs = {}
def add_job(self, func, *, trigger, id, **kwargs) -> None:
self.jobs[id] = {"func": func, "trigger": trigger, **kwargs}
def _scheduler() -> Scheduler:
scheduler = object.__new__(Scheduler)
scheduler._scheduler = _SchedulerStub()
scheduler._jobs = {}
return scheduler
def test_database_backup_schedule_only_watches_job_shape() -> None:
assert Scheduler.CONFIG_WATCH.intersection({
"DB_BACKUP_ENABLE",
"DB_BACKUP_CRON",
"DB_BACKUP_PATH",
"DB_BACKUP_RETENTION_DAYS",
"DB_BACKUP_MAX_COUNT",
}) == {"DB_BACKUP_ENABLE", "DB_BACKUP_CRON"}
def test_disabled_database_backup_does_not_register_job(monkeypatch) -> None:
scheduler = _scheduler()
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_ENABLE", False)
scheduler._register_database_backup_job()
assert scheduler._scheduler.jobs == {}
def test_enabled_database_backup_without_cron_does_not_register_job(monkeypatch) -> None:
"""总开关开启但未配置周期时,不启用定时备份。"""
scheduler = _scheduler()
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_ENABLE", True)
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_CRON", "")
scheduler._register_database_backup_job()
assert scheduler._scheduler.jobs == {}
def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -> None:
scheduler = _scheduler()
trigger = object()
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_ENABLE", True)
monkeypatch.setattr(scheduler_module.settings, "DB_BACKUP_CRON", "0 3 * * *")
monkeypatch.setattr(scheduler_module.TimerUtils, "build_schedule_trigger", Mock(return_value=trigger))
scheduler._register_database_backup_job()
scheduler._register_database_backup_job()
assert list(scheduler._scheduler.jobs) == ["database_backup"]
assert scheduler._scheduler.jobs["database_backup"]["replace_existing"] is True
def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> None:
governance = Mock()
monkeypatch.setattr(scheduler_module, "get_database_governance", lambda: governance)
result = Scheduler.database_backup()
assert result is governance.create_backup.return_value
governance.create_backup.assert_called_once_with()
def test_scheduler_database_dependencies_are_explicit_module_imports() -> None:
tree = ast.parse(
(Path(__file__).parents[1] / "app" / "scheduler.py").read_text(encoding="utf-8")
)
function_imports = [
node
for function in ast.walk(tree)
if isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef))
for node in ast.walk(function)
if isinstance(node, (ast.Import, ast.ImportFrom))
and getattr(node, "module", "")
and str(getattr(node, "module", "")).startswith("app.application.database")
]
assert function_imports == []
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import stat
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import pytest
from app.application.backup import BackupPolicy, DatabaseBackupService
@dataclass(frozen=True, slots=True)
class _Check:
valid: bool
method: str = "test-check"
detail: str | None = None
class _Backend:
db_type = "sqlite"
suffix = ".db"
def __init__(self, *, valid: bool = True) -> None:
self.valid = valid
self.restored: Path | None = None
def create(self, destination: Path) -> None:
destination.write_bytes(b"database snapshot")
def verify(self, artifact: Path) -> _Check:
return _Check(self.valid and artifact.read_bytes() == b"database snapshot")
def restore(self, artifact: Path) -> None:
self.restored = artifact
def _service(
root: Path,
*,
backend: _Backend | None = None,
now: datetime | None = None,
retention_days: int = 0,
max_count: int = 0,
) -> DatabaseBackupService:
return DatabaseBackupService(
backend=backend or _Backend(),
policy_reader=lambda: BackupPolicy(root, retention_days, max_count),
clock=lambda: now or datetime(2026, 8, 19, 13, 45, 26),
)
def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None:
artifact = _service(tmp_path).create()
assert artifact.name == "sqlite_20260819_134526.db"
assert artifact.path.read_bytes() == b"database snapshot"
assert stat.S_IMODE(tmp_path.stat().st_mode) == 0o700
assert stat.S_IMODE(artifact.path.stat().st_mode) == 0o600
assert not list(tmp_path.glob("*.partial"))
def test_failed_verification_does_not_publish_artifact(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="数据库备份校验失败"):
_service(tmp_path, backend=_Backend(valid=False)).create()
assert list(tmp_path.iterdir()) == []
def test_same_second_backups_receive_short_sequence_suffix(tmp_path: Path) -> None:
service = _service(tmp_path)
first = service.create()
second = service.create()
assert first.name == "sqlite_20260819_134526.db"
assert second.name == "sqlite_20260819_134526_1.db"
def test_retention_applies_after_new_artifact_is_available(tmp_path: Path) -> None:
old = _service(tmp_path, now=datetime(2026, 8, 1, 3, 0, 0)).create()
current = _service(
tmp_path,
now=datetime(2026, 8, 19, 3, 0, 0),
retention_days=7,
max_count=1,
).create()
assert current.path.exists()
assert not old.path.exists()
def test_list_ignores_unmanaged_files_and_rejects_paths(tmp_path: Path) -> None:
artifact = _service(tmp_path).create()
(tmp_path / "notes.txt").write_text("ignore", encoding="utf-8")
assert [item.name for item in _service(tmp_path).list()] == [artifact.name]
with pytest.raises(ValueError, match="文件名"):
_service(tmp_path).verify("../user.db")
def test_restore_requires_matching_database_type(tmp_path: Path) -> None:
backend = _Backend()
service = _service(tmp_path, backend=backend)
artifact = service.create()
restored = service.restore(artifact.name)
assert restored.name == artifact.name
assert backend.restored == artifact.path
postgres = tmp_path / "postgresql_20260819_134526.dump"
postgres.write_bytes(b"database snapshot")
with pytest.raises(ValueError, match="当前数据库类型"):
service.restore(postgres.name)
@@ -0,0 +1,89 @@
"""系统数据库备份策略配置测试。"""
import asyncio
from unittest.mock import patch
import pytest
from app.api.endpoints import system as system_endpoint
@pytest.mark.parametrize(
("env", "message"),
[
({"DB_BACKUP_CRON": "0 3 * *"}, "数据库备份周期格式不正确"),
({"DB_BACKUP_PATH": 123}, "数据库备份目录必须是路径字符串"),
({"DB_BACKUP_RETENTION_DAYS": -1}, "数据库备份过期天数"),
({"DB_BACKUP_RETENTION_DAYS": 1.5}, "数据库备份过期天数"),
({"DB_BACKUP_MAX_COUNT": True}, "数据库备份最大保留份数"),
({"DB_BACKUP_MAX_COUNT": "many"}, "数据库备份最大保留份数"),
],
)
def test_database_backup_policy_rejects_invalid_values(
env: dict,
message: str,
) -> None:
"""无效策略必须在批量配置写入前被拒绝。"""
env["DB_BACKUP_ENABLE"] = True
assert message in str(system_endpoint._validate_database_backup_config(env))
@pytest.mark.parametrize(
"env",
[
{"DB_BACKUP_CRON": ""},
{"DB_BACKUP_CRON": "0 3 * * *"},
{"DB_BACKUP_PATH": None},
{"DB_BACKUP_PATH": ""},
{"DB_BACKUP_PATH": "database_backup"},
{"DB_BACKUP_RETENTION_DAYS": 0},
{"DB_BACKUP_MAX_COUNT": "0"},
],
)
def test_database_backup_policy_accepts_supported_boundaries(env: dict) -> None:
"""空目录使用默认路径,两个保留值的零均表示不限制。"""
env["DB_BACKUP_ENABLE"] = True
assert system_endpoint._validate_database_backup_config(env) is None
@pytest.mark.parametrize("disabled", [False, "false", "0", "off"])
def test_database_backup_policy_rejects_invalid_hidden_values_when_disabled(disabled) -> None:
"""关闭总开关只暂停调度,不能把无效策略写入持久配置。"""
error = system_endpoint._validate_database_backup_config({
"DB_BACKUP_ENABLE": disabled,
"DB_BACKUP_CRON": "invalid",
"DB_BACKUP_PATH": 123,
"DB_BACKUP_RETENTION_DAYS": -1,
"DB_BACKUP_MAX_COUNT": 1.5,
})
assert error is not None
def test_set_env_rejects_invalid_database_backup_policy_without_partial_write() -> None:
"""备份策略校验失败时不得调用 Settings 的批量写入。"""
env = {
"DB_BACKUP_ENABLE": True,
"DB_BACKUP_CRON": "invalid",
"DB_BACKUP_RETENTION_DAYS": 30,
"DB_BACKUP_MAX_COUNT": 30,
}
with patch.object(
system_endpoint,
"_validate_llm_server_tool_config",
return_value=None,
), patch.object(type(system_endpoint.settings), "update_settings") as update_settings:
response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object()))
assert response.success is False
assert "数据库备份周期格式不正确" in response.message
update_settings.assert_not_called()
def test_database_backup_default_path_tracks_config_directory(tmp_path, monkeypatch) -> None:
"""未显式配置目录时应跟随当前配置根,而不是写死 Docker 路径。"""
monkeypatch.setattr(system_endpoint.settings, "CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(system_endpoint.settings, "DB_BACKUP_PATH", None)
assert system_endpoint.settings.DATABASE_BACKUP_PATH == tmp_path / "database_backup"