mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(database): add managed backup and offline restore (#6359)
This commit is contained in:
+43
-7
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 时未给出安装提示")
|
||||
@@ -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")
|
||||
@@ -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/*"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user