mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
"""插件安装事务的持久化端口与可逆状态记录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
from app.application.database import AsyncDatabaseExecutor
|
||||
from app.application.plugin.identity import PluginIdentity
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PluginInstallationPhase(StrEnum):
|
||||
"""安装事务在持久化协调器中的两个数据库阶段。"""
|
||||
|
||||
PREPARED = "prepared"
|
||||
COMMITTED = "committed"
|
||||
|
||||
|
||||
class PluginInstallationConflictError(RuntimeError):
|
||||
"""事务不存在、阶段竞争或实际状态发生漂移。"""
|
||||
|
||||
|
||||
class PluginInstallationRecordError(ValueError):
|
||||
"""事务记录不符合可持久化和恢复合同。"""
|
||||
|
||||
|
||||
_TRANSACTION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
INSTALLATION_JOURNAL_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def _validate_revision(value: int | None, *, field_name: str) -> None:
|
||||
"""校验身份 CAS revision;``None`` 表示对应身份行不存在。"""
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise PluginInstallationRecordError(
|
||||
f"{field_name} 必须是大于等于 1 的整数或 null"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallationRecord:
|
||||
"""可跨进程恢复的插件安装事务状态。"""
|
||||
|
||||
transaction_id: str
|
||||
plugin_id: str
|
||||
phase: PluginInstallationPhase
|
||||
membership_before: bool
|
||||
membership_target: bool | None
|
||||
identity_before_revision: int | None
|
||||
identity_target_revision: int | None
|
||||
package_existed: bool
|
||||
persistent_backup_existed: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
schema_version: int = INSTALLATION_JOURNAL_SCHEMA_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""拒绝不能作为 CAS 或崩溃恢复依据的事务记录。"""
|
||||
if not _TRANSACTION_ID_PATTERN.fullmatch(self.transaction_id):
|
||||
raise PluginInstallationRecordError("transaction_id 格式不合法")
|
||||
if not self.plugin_id or self.plugin_id != self.plugin_id.strip():
|
||||
raise PluginInstallationRecordError("plugin_id 不能为空或带首尾空格")
|
||||
if not isinstance(self.phase, PluginInstallationPhase):
|
||||
try:
|
||||
object.__setattr__(self, "phase", PluginInstallationPhase(self.phase))
|
||||
except ValueError as error:
|
||||
raise PluginInstallationRecordError("未知安装事务 phase") from error
|
||||
|
||||
if not isinstance(self.membership_before, bool):
|
||||
raise PluginInstallationRecordError("membership_before 必须是布尔值")
|
||||
if not isinstance(self.package_existed, bool):
|
||||
raise PluginInstallationRecordError("package_existed 必须是布尔值")
|
||||
if not isinstance(self.persistent_backup_existed, bool):
|
||||
raise PluginInstallationRecordError(
|
||||
"persistent_backup_existed 必须是布尔值"
|
||||
)
|
||||
if self.membership_target is not None and not isinstance(
|
||||
self.membership_target,
|
||||
bool,
|
||||
):
|
||||
raise PluginInstallationRecordError("membership_target 必须是布尔值或 null")
|
||||
if (
|
||||
self.phase is PluginInstallationPhase.COMMITTED
|
||||
and self.membership_target is None
|
||||
):
|
||||
raise PluginInstallationRecordError(
|
||||
"COMMITTED 事务必须包含 membership target"
|
||||
)
|
||||
|
||||
_validate_revision(
|
||||
self.identity_before_revision,
|
||||
field_name="identity_before_revision",
|
||||
)
|
||||
_validate_revision(
|
||||
self.identity_target_revision,
|
||||
field_name="identity_target_revision",
|
||||
)
|
||||
if self.created_at.tzinfo is None or self.updated_at.tzinfo is None:
|
||||
raise PluginInstallationRecordError("事务时间必须包含时区")
|
||||
if self.updated_at < self.created_at:
|
||||
raise PluginInstallationRecordError("事务更新时间不能早于创建时间")
|
||||
if (
|
||||
isinstance(self.schema_version, bool)
|
||||
or not isinstance(self.schema_version, int)
|
||||
or self.schema_version != INSTALLATION_JOURNAL_SCHEMA_VERSION
|
||||
):
|
||||
raise PluginInstallationRecordError(
|
||||
f"不支持的插件安装事务快照版本: {self.schema_version}"
|
||||
)
|
||||
|
||||
|
||||
class PluginInstallationStore(Protocol):
|
||||
"""安装 Gateway 使用的同步持久化端口。"""
|
||||
|
||||
def create(self, record: PluginInstallationRecord) -> PluginInstallationRecord:
|
||||
"""创建一条 PREPARED 安装事务。"""
|
||||
|
||||
def get(self, transaction_id: str) -> PluginInstallationRecord | None:
|
||||
"""按事务 ID 读取记录。"""
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str | None = None,
|
||||
) -> list[PluginInstallationRecord]:
|
||||
"""列出事务记录。"""
|
||||
|
||||
def set_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
membership_target: bool,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在 PREPARED 阶段登记目标 membership 和身份 revision。"""
|
||||
|
||||
def commit_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在同一同步事务中完成身份、membership 和 COMMITTED phase。"""
|
||||
|
||||
def delete(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase CAS 删除已处理的事务记录。"""
|
||||
|
||||
|
||||
class PluginIdentityPersistence(Protocol):
|
||||
"""插件来源身份读取与存量迁移使用的同步窄端口。"""
|
||||
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""仅供存量迁移首次创建或按 revision 更新身份。"""
|
||||
|
||||
def bind_online(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""把存量未绑定身份按 revision 绑定到在线来源。"""
|
||||
|
||||
|
||||
class PluginPersistenceService:
|
||||
"""通过有界数据库 worker 暴露插件专用异步持久化能力。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
executor: AsyncDatabaseExecutor,
|
||||
identities: PluginIdentityPersistence,
|
||||
installations: PluginInstallationStore,
|
||||
) -> None:
|
||||
"""保存身份、安装事务和唯一同步数据库执行边界。"""
|
||||
self.__executor = executor
|
||||
self.__identities = identities
|
||||
self.__installations = installations
|
||||
|
||||
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""在数据库 worker 中读取插件来源身份。"""
|
||||
return await self.__executor.run(partial(self.__identities.get, plugin_id))
|
||||
|
||||
async def migrate_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""在数据库 worker 中提交存量身份迁移。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__identities.compare_and_set,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
async def bind_online_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在数据库 worker 中绑定存量身份的可信在线来源。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__identities.bind_online,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
async def create_installation(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
) -> PluginInstallationRecord:
|
||||
"""创建 PREPARED journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(self.__installations.create, record)
|
||||
)
|
||||
|
||||
async def list_installations(self) -> list[PluginInstallationRecord]:
|
||||
"""列出全部待恢复或待清理的安装 journal。"""
|
||||
return await self.__executor.run(self.__installations.list)
|
||||
|
||||
async def get_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
) -> PluginInstallationRecord | None:
|
||||
"""读取一次提交结果确认所需的安装 journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(self.__installations.get, transaction_id)
|
||||
)
|
||||
|
||||
async def set_installation_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
membership_target: bool,
|
||||
identity_target: PluginIdentity | None,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在 PREPARED journal 中登记最终数据库目标。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.set_target,
|
||||
transaction_id,
|
||||
membership_target=membership_target,
|
||||
identity_target=identity_target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
)
|
||||
|
||||
async def commit_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
identity_target: PluginIdentity | None,
|
||||
) -> PluginInstallationRecord:
|
||||
"""原子提交 membership、身份和 COMMITTED phase。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.commit_target,
|
||||
transaction_id,
|
||||
identity_target=identity_target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase 删除已恢复或已收尾的 journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.delete,
|
||||
transaction_id,
|
||||
expected_phase=expected_phase,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_PLUGIN_PERSISTENCE: list[PluginPersistenceService] = []
|
||||
|
||||
|
||||
def configure_plugin_persistence(service: PluginPersistenceService) -> None:
|
||||
"""由启动组合根登记当前 lifespan 的插件持久化服务。"""
|
||||
_PLUGIN_PERSISTENCE.clear()
|
||||
_PLUGIN_PERSISTENCE.append(service)
|
||||
|
||||
|
||||
def get_plugin_persistence() -> PluginPersistenceService:
|
||||
"""返回当前插件持久化服务,未装配时拒绝数据库操作。"""
|
||||
if not _PLUGIN_PERSISTENCE:
|
||||
raise RuntimeError("插件持久化服务尚未完成初始化")
|
||||
return _PLUGIN_PERSISTENCE[0]
|
||||
|
||||
|
||||
def reset_plugin_persistence() -> None:
|
||||
"""清除当前 lifespan 的插件持久化服务。"""
|
||||
_PLUGIN_PERSISTENCE.clear()
|
||||
Reference in New Issue
Block a user